The code and data behind xeiaso.net
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: github sponsors webhook ingress (#1061)

* feat(github): add github sponsors webhook metadata

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore(pb): format generated code

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore: go fmt ./...

Signed-off-by: Xe Iaso <me@xeiaso.net>

* docs(AGENTS): tell AI agents to format code before committing it

Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(xesite): expose github sponsors webhook

Signed-off-by: Xe Iaso <me@xeiaso.net>

* 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 <me@xeiaso.net>

* feat: add GORM models

Signed-off-by: Xe Iaso <me@xeiaso.net>

* chore(manifest): introduce github sponsor webhook manifests

Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(sponsor-webhook): setup database connection

Signed-off-by: Xe Iaso <me@xeiaso.net>

* feat(github-webhook): kinda terrible automatic writing to the database on ingress

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>

authored by

Xe Iaso and committed by
GitHub
0933293f 3cdac095

+1896 -117
+6
.env.example
··· 1 + # Test environment variables for xesitectl 2 + GITHUB_SPONSORS_SECRET=test-secret-from-env 3 + WEBHOOK_URL=http://localhost:3000/.within/hook/github-sponsors 4 + TEST_SPONSOR=envtestsponsor 5 + TEST_TIER=Env Tier 6 + TEST_PRICE=25
+6 -6
.github/workflows/earthly.yml
··· 2 2 3 3 on: 4 4 push: 5 - branches: [ "main" ] 5 + branches: ["main"] 6 6 # Publish semver tags as releases. 7 - tags: [ 'v*.*.*' ] 7 + tags: ["v*.*.*"] 8 8 pull_request: 9 - branches: [ "main" ] 9 + branches: ["main"] 10 10 11 11 jobs: 12 12 build: ··· 21 21 - name: Set up Docker Buildx 22 22 uses: docker/setup-buildx-action@v3 23 23 24 - - name: Log into registry 24 + - name: Log into registry 25 25 if: github.event_name != 'pull_request' 26 26 uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 27 27 with: ··· 37 37 source: . 38 38 push: true 39 39 set: | 40 + github-sponsor-webhook.tags=ghcr.io/xe/site/github-sponsor-webhook:latest 40 41 patreon-saasproxy.tags=ghcr.io/xe/site/patreon-saasproxy:latest 41 42 xesite.tags=ghcr.io/xe/site/bin:latest 42 43 ··· 47 48 source: . 48 49 push: false 49 50 set: | 51 + github-sponsor-webhook.tags=ghcr.io/xe/site/github-sponsor-webhook:latest 50 52 patreon-saasproxy.tags=ghcr.io/xe/site/patreon-saasproxy:latest 51 53 xesite.tags=ghcr.io/xe/site/bin:latest 52 - 53 -
+3
.gitignore
··· 9 9 .patreon.json 10 10 .direnv 11 11 node_modules 12 + /xesite 13 + /xesitectl 14 + /github-sponsor-webhook
+1
AGENTS.md
··· 18 18 19 19 ## Coding Style & Naming Conventions 20 20 - Go code follows `gofmt`; run `go fmt ./...` before committing. 21 + - Use `goimports` to organize imports: `go get -tool golang.org/x/tools/cmd/goimports@latest` then `find . -name "*.go" -exec goimports -w {} \;`. 21 22 - Use `camelCase` for variables/functions, `PascalCase` for exported types. 22 23 - Indentation: tabs (default `go fmt`). 23 24 - Dhall files use kebab‑case filenames (e.g., `my-config.dhall`).
+91
cmd/github-sponsor-webhook/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "flag" 6 + "log" 7 + "log/slog" 8 + "net" 9 + "net/http" 10 + "os" 11 + 12 + "github.com/donatj/hmacsig" 13 + "github.com/facebookgo/flagenv" 14 + _ "github.com/joho/godotenv/autoload" 15 + "github.com/prometheus/client_golang/prometheus/promhttp" 16 + "gorm.io/driver/postgres" 17 + "gorm.io/gorm" 18 + "xeiaso.net/v4/internal" 19 + "xeiaso.net/v4/internal/models" 20 + ) 21 + 22 + var ( 23 + bind = flag.String("bind", ":4823", "Port to listen on") 24 + databaseURL = flag.String("database-url", "", "Database URL") 25 + githubSponsorsSecret = flag.String("github-sponsors-secret", "", "GitHub Sponsors secret to use for webhooks") 26 + ) 27 + 28 + func main() { 29 + flagenv.Parse() 30 + flag.Parse() 31 + internal.Slog() 32 + 33 + _ = context.Background() 34 + 35 + ln, err := net.Listen("tcp", *bind) 36 + if err != nil { 37 + log.Fatal(err) 38 + } 39 + 40 + if *databaseURL == "" { 41 + slog.Error("database-url is required") 42 + os.Exit(1) 43 + } 44 + 45 + if *githubSponsorsSecret == "" { 46 + slog.Error("github-sponsors-secret is required") 47 + os.Exit(1) 48 + } 49 + 50 + slog.Info("starting GitHub Sponsors webhook service", 51 + "bind", *bind, 52 + ) 53 + 54 + db, err := gorm.Open(postgres.Open(*databaseURL), &gorm.Config{}) 55 + if err != nil { 56 + slog.Error("can't connect to database", "err", err) 57 + os.Exit(1) 58 + } 59 + 60 + if err := db.Exec("SELECT 1 + 1").Error; err != nil { 61 + slog.Error("can't ping database", "err", err) 62 + os.Exit(1) 63 + } 64 + 65 + if err := models.SetupDatabase(db); err != nil { 66 + slog.Error("database setup error", "err", err) 67 + os.Exit(1) 68 + } 69 + 70 + gsh := &GitHubSponsorsWebhook{DB: db} 71 + s := hmacsig.Handler256(gsh, *githubSponsorsSecret) 72 + 73 + mux := http.NewServeMux() 74 + mux.Handle("/.within/hook/github-sponsors", s) 75 + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { 76 + w.Header().Set("Content-Type", "application/json") 77 + w.WriteHeader(http.StatusOK) 78 + w.Write([]byte(`{"status":"ok","service":"github-sponsor-webhook"}`)) 79 + }) 80 + 81 + // Expose Prometheus metrics at /metrics for observability 82 + mux.Handle("/metrics", promhttp.Handler()) 83 + 84 + var h http.Handler = mux 85 + h = internal.CacheHeader(h) 86 + h = internal.AcceptEncodingMiddleware(h) 87 + h = internal.RefererMiddleware(h) 88 + 89 + slog.Info("GitHub Sponsors webhook service ready", "bind", *bind) 90 + log.Fatal(http.Serve(ln, h)) 91 + }
+449
cmd/github-sponsor-webhook/webhook.go
··· 1 + package main 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "time" 9 + 10 + "github.com/prometheus/client_golang/prometheus" 11 + "gorm.io/gorm" 12 + "xeiaso.net/v4/internal/github" 13 + "xeiaso.net/v4/internal/models" 14 + ) 15 + 16 + var ( 17 + sponsorsWebhookCount = prometheus.NewCounterVec( 18 + prometheus.CounterOpts{ 19 + Name: "github_sponsors_webhook_total", 20 + Help: "Total number of GitHub Sponsors webhook events processed, by action", 21 + }, 22 + []string{"action"}, 23 + ) 24 + 25 + sponsorsErrorCount = prometheus.NewCounterVec( 26 + prometheus.CounterOpts{ 27 + Name: "github_sponsors_webhook_errors_total", 28 + Help: "Total number of GitHub Sponsors webhook errors, by error type", 29 + }, 30 + []string{"error_type"}, 31 + ) 32 + ) 33 + 34 + func init() { 35 + prometheus.MustRegister(sponsorsWebhookCount) 36 + prometheus.MustRegister(sponsorsErrorCount) 37 + } 38 + 39 + // GitHubSponsorsWebhook handles GitHub Sponsors webhook events. 40 + type GitHubSponsorsWebhook struct { 41 + DB *gorm.DB // Database connection for persisting webhook data 42 + } 43 + 44 + // ServeHTTP processes incoming GitHub Sponsors webhook events. 45 + func (gsh *GitHubSponsorsWebhook) ServeHTTP(w http.ResponseWriter, r *http.Request) { 46 + // Set CORS headers for web requests 47 + w.Header().Set("Access-Control-Allow-Origin", "*") 48 + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") 49 + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-GitHub-Event, X-Hub-Signature-256") 50 + 51 + if r.Method == "OPTIONS" { 52 + w.WriteHeader(http.StatusOK) 53 + return 54 + } 55 + 56 + if r.Method != "POST" { 57 + slog.Info("method not allowed", "method", r.Method) 58 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 59 + return 60 + } 61 + 62 + // Check for GitHub Sponsors event header 63 + eventType := r.Header.Get("X-GitHub-Event") 64 + if eventType != "sponsorship" { 65 + slog.Info("not a sponsorship event", "event", eventType) 66 + sponsorsErrorCount.WithLabelValues("invalid_event_type").Inc() 67 + http.Error(w, "Invalid event type", http.StatusBadRequest) 68 + return 69 + } 70 + 71 + // Decode the webhook payload 72 + var event github.SponsorsEvent 73 + if err := json.NewDecoder(r.Body).Decode(&event); err != nil { 74 + slog.Error("error decoding GitHub Sponsors event", "error", err) 75 + sponsorsErrorCount.WithLabelValues("decode_error").Inc() 76 + http.Error(w, err.Error(), http.StatusBadRequest) 77 + return 78 + } 79 + 80 + // Create webhook event record for tracking 81 + webhookEvent := &models.WebhookEvent{ 82 + GitHubID: r.Header.Get("X-GitHub-Delivery"), 83 + Action: event.Action, 84 + EventType: eventType, 85 + ProcessedAt: time.Now(), 86 + RemoteAddr: r.RemoteAddr, 87 + UserAgent: r.UserAgent(), 88 + Timestamp: time.Now(), 89 + } 90 + 91 + // Store the raw payload for debugging 92 + eventBytes, _ := json.Marshal(event) 93 + webhookEvent.SetPayload(map[string]interface{}{ 94 + "raw_payload": string(eventBytes), 95 + "action": event.Action, 96 + }) 97 + 98 + // Increment the specific action counter 99 + sponsorsWebhookCount.WithLabelValues(event.Action).Inc() 100 + 101 + // Log the sponsorship event 102 + slog.Info("GitHub Sponsors webhook received", 103 + "action", event.Action, 104 + "sponsor", event.Sponsorship.Sponsor.Login, 105 + "sponsorable", event.Sponsorship.Sponsorable.Login, 106 + "tier", event.Sponsorship.Tier.Name, 107 + "monthly_price", event.Sponsorship.Tier.MonthlyPriceInDollars, 108 + "sender", event.Sender.Login, 109 + ) 110 + 111 + // Handle different sponsorship event types with database operations 112 + var err error 113 + switch event.Action { 114 + case github.SponsorsEventCreated: 115 + err = gsh.handleSponsorshipCreated(event, webhookEvent) 116 + case github.SponsorsEventEdited: 117 + err = gsh.handleSponsorshipEdited(event, webhookEvent) 118 + case github.SponsorsEventCancelled: 119 + err = gsh.handleSponsorshipCancelled(event, webhookEvent) 120 + case github.SponsorsEventPendingTierChange: 121 + err = gsh.handlePendingTierChange(event, webhookEvent) 122 + case github.SponsorsEventPendingCancellation: 123 + err = gsh.handlePendingCancellation(event, webhookEvent) 124 + default: 125 + err = fmt.Errorf("unhandled GitHub Sponsors event type: %s", event.Action) 126 + sponsorsErrorCount.WithLabelValues("unhandled_action").Inc() 127 + } 128 + 129 + // Record webhook event processing result 130 + if err != nil { 131 + slog.Error("error processing webhook", "error", err, "action", event.Action) 132 + webhookEvent.Success = false 133 + webhookEvent.ErrorMessage = err.Error() 134 + sponsorsErrorCount.WithLabelValues("processing_error").Inc() 135 + } else { 136 + webhookEvent.Success = true 137 + } 138 + 139 + // Save webhook event to database 140 + if dbErr := gsh.DB.Create(webhookEvent).Error; dbErr != nil { 141 + slog.Error("failed to save webhook event", "error", dbErr) 142 + // Don't fail the request if we can't save the webhook event 143 + } 144 + 145 + // Respond with success 146 + w.Header().Set("Content-Type", "text/plain") 147 + fmt.Fprintln(w, "OK") 148 + } 149 + 150 + // handleSponsorshipCreated processes new sponsorships 151 + func (gsh *GitHubSponsorsWebhook) handleSponsorshipCreated(event github.SponsorsEvent, webhookEvent *models.WebhookEvent) error { 152 + slog.Info("New sponsorship created", 153 + "sponsor", event.Sponsorship.Sponsor.Login, 154 + "sponsor_id", event.Sponsorship.Sponsor.ID, 155 + "tier", event.Sponsorship.Tier.Name, 156 + "monthly_price", event.Sponsorship.Tier.MonthlyPriceInDollars, 157 + "privacy_level", event.Sponsorship.PrivacyLevel, 158 + ) 159 + 160 + // Create or find the sponsor account 161 + sponsor := models.Account{ 162 + GitHubID: int64(event.Sponsorship.Sponsor.ID), 163 + NodeID: event.Sponsorship.Sponsor.NodeID, 164 + Login: event.Sponsorship.Sponsor.Login, 165 + AvatarURL: event.Sponsorship.Sponsor.AvatarURL, 166 + URL: event.Sponsorship.Sponsor.HTMLURL, 167 + Type: event.Sponsorship.Sponsor.Type, 168 + } 169 + 170 + if err := gsh.DB.Where("github_id = ?", sponsor.GitHubID).FirstOrCreate(&sponsor).Error; err != nil { 171 + return fmt.Errorf("failed to create/find sponsor: %w", err) 172 + } 173 + 174 + // Create or find the sponsoree account 175 + sponsoree := models.Account{ 176 + GitHubID: int64(event.Sponsorship.Sponsorable.ID), 177 + NodeID: event.Sponsorship.Sponsorable.NodeID, 178 + Login: event.Sponsorship.Sponsorable.Login, 179 + AvatarURL: event.Sponsorship.Sponsorable.AvatarURL, 180 + URL: event.Sponsorship.Sponsorable.HTMLURL, 181 + Type: event.Sponsorship.Sponsorable.Type, 182 + } 183 + 184 + if err := gsh.DB.Where("github_id = ?", sponsoree.GitHubID).FirstOrCreate(&sponsoree).Error; err != nil { 185 + return fmt.Errorf("failed to create/find sponsoree: %w", err) 186 + } 187 + 188 + // Create or find the tier 189 + tier := models.Tier{ 190 + GitHubID: int64(event.Sponsorship.Tier.ID), 191 + Name: event.Sponsorship.Tier.Name, 192 + MonthlyPriceInCents: event.Sponsorship.Tier.MonthlyPriceInDollars * 100, // Convert to cents 193 + Description: event.Sponsorship.Tier.Description, 194 + SelectedTier: event.Sponsorship.Tier.SelectedTier, 195 + SponsorshipCount: event.Sponsorship.Tier.SponsorshipCount, 196 + } 197 + 198 + if err := gsh.DB.Where("github_id = ?", tier.GitHubID).FirstOrCreate(&tier).Error; err != nil { 199 + return fmt.Errorf("failed to create/find tier: %w", err) 200 + } 201 + 202 + // Convert GitHub timestamps 203 + var createdAt *time.Time 204 + if event.Sponsorship.CreatedAt.Time.Year() > 1 { 205 + createdAt = &event.Sponsorship.CreatedAt.Time 206 + } 207 + 208 + // Create the sponsorship 209 + sponsorship := models.Sponsorship{ 210 + GitHubID: int64(event.Sponsorship.ID), 211 + PrivacyLevel: event.Sponsorship.PrivacyLevel, 212 + Variant: event.Sponsorship.Variant, 213 + SponsorshipType: event.Sponsorship.SponsorshipType, 214 + TierID: tier.ID, 215 + SponsorID: sponsor.ID, 216 + SponsoreeID: sponsoree.ID, 217 + GitHubCreatedAt: createdAt, 218 + } 219 + 220 + // Set metadata 221 + sponsorship.SetMetadata(map[string]interface{}{ 222 + "github_id": event.Sponsorship.ID, 223 + "node_id": event.Sponsorship.NodeID, 224 + }) 225 + 226 + if err := gsh.DB.Create(&sponsorship).Error; err != nil { 227 + return fmt.Errorf("failed to create sponsorship: %w", err) 228 + } 229 + 230 + // Update webhook event with sponsorship ID 231 + webhookEvent.SponsorshipID = &sponsorship.ID 232 + 233 + slog.Info("Successfully created sponsorship in database", 234 + "sponsorship_id", sponsorship.ID, 235 + "sponsor", sponsor.Login, 236 + "sponsoree", sponsoree.Login, 237 + "tier", tier.Name, 238 + ) 239 + 240 + return nil 241 + } 242 + 243 + // handleSponsorshipEdited processes sponsorship changes 244 + func (gsh *GitHubSponsorsWebhook) handleSponsorshipEdited(event github.SponsorsEvent, webhookEvent *models.WebhookEvent) error { 245 + slog.Info("Sponsorship edited", 246 + "sponsor", event.Sponsorship.Sponsor.Login, 247 + "sponsor_id", event.Sponsorship.Sponsor.ID, 248 + "tier", event.Sponsorship.Tier.Name, 249 + "monthly_price", event.Sponsorship.Tier.MonthlyPriceInDollars, 250 + "privacy_level", event.Sponsorship.PrivacyLevel, 251 + ) 252 + 253 + // Find existing sponsorship 254 + var sponsorship models.Sponsorship 255 + if err := gsh.DB.Where("github_id = ?", event.Sponsorship.ID).First(&sponsorship).Error; err != nil { 256 + if err == gorm.ErrRecordNotFound { 257 + // If sponsorship doesn't exist, treat it as a new sponsorship 258 + return gsh.handleSponsorshipCreated(event, webhookEvent) 259 + } 260 + return fmt.Errorf("failed to find sponsorship: %w", err) 261 + } 262 + 263 + // Update tier if changed 264 + if event.Sponsorship.Tier.ID != 0 { 265 + var tier models.Tier 266 + if err := gsh.DB.Where("github_id = ?", event.Sponsorship.Tier.ID).First(&tier).Error; err != nil { 267 + return fmt.Errorf("failed to find tier: %w", err) 268 + } 269 + sponsorship.TierID = tier.ID 270 + } 271 + 272 + // Update other fields 273 + sponsorship.PrivacyLevel = event.Sponsorship.PrivacyLevel 274 + sponsorship.Variant = event.Sponsorship.Variant 275 + sponsorship.SponsorshipType = event.Sponsorship.SponsorshipType 276 + 277 + // Update GitHub timestamps 278 + if event.Sponsorship.CreatedAt.Time.Year() > 1 { 279 + sponsorship.GitHubCreatedAt = &event.Sponsorship.CreatedAt.Time 280 + } 281 + // Note: UpdatedAt is not available in the GitHub Sponsors webhook payload 282 + now := time.Now() 283 + sponsorship.GitHubUpdatedAt = &now 284 + 285 + // Update metadata 286 + sponsorship.SetMetadata(map[string]interface{}{ 287 + "github_id": event.Sponsorship.ID, 288 + "node_id": event.Sponsorship.NodeID, 289 + "edited_at": time.Now(), 290 + }) 291 + 292 + if err := gsh.DB.Save(&sponsorship).Error; err != nil { 293 + return fmt.Errorf("failed to update sponsorship: %w", err) 294 + } 295 + 296 + // Update webhook event with sponsorship ID 297 + webhookEvent.SponsorshipID = &sponsorship.ID 298 + 299 + slog.Info("Successfully updated sponsorship in database", 300 + "sponsorship_id", sponsorship.ID, 301 + "sponsor", event.Sponsorship.Sponsor.Login, 302 + ) 303 + 304 + return nil 305 + } 306 + 307 + // handleSponsorshipCancelled processes cancelled sponsorships 308 + func (gsh *GitHubSponsorsWebhook) handleSponsorshipCancelled(event github.SponsorsEvent, webhookEvent *models.WebhookEvent) error { 309 + slog.Info("Sponsorship cancelled", 310 + "sponsor", event.Sponsorship.Sponsor.Login, 311 + "sponsor_id", event.Sponsorship.Sponsor.ID, 312 + "tier", event.Sponsorship.Tier.Name, 313 + ) 314 + 315 + // Find existing sponsorship 316 + var sponsorship models.Sponsorship 317 + if err := gsh.DB.Where("github_id = ?", event.Sponsorship.ID).First(&sponsorship).Error; err != nil { 318 + if err == gorm.ErrRecordNotFound { 319 + slog.Info("Sponsorship not found for cancellation, possibly already cancelled", 320 + "github_id", event.Sponsorship.ID, 321 + "sponsor", event.Sponsorship.Sponsor.Login, 322 + ) 323 + return nil // Don't treat as error, just log and continue 324 + } 325 + return fmt.Errorf("failed to find sponsorship: %w", err) 326 + } 327 + 328 + // Mark as cancelled (soft delete) 329 + now := time.Now() 330 + sponsorship.GitHubCancelledAt = &now 331 + 332 + // Update metadata to reflect cancellation 333 + metadata := sponsorship.Metadata() 334 + metadata["cancelled_at"] = now 335 + metadata["cancellation_reason"] = "github_sponsors_webhook" 336 + sponsorship.SetMetadata(metadata) 337 + 338 + if err := gsh.DB.Save(&sponsorship).Error; err != nil { 339 + return fmt.Errorf("failed to cancel sponsorship: %w", err) 340 + } 341 + 342 + // Update webhook event with sponsorship ID 343 + webhookEvent.SponsorshipID = &sponsorship.ID 344 + 345 + slog.Info("Successfully cancelled sponsorship in database", 346 + "sponsorship_id", sponsorship.ID, 347 + "sponsor", event.Sponsorship.Sponsor.Login, 348 + "cancelled_at", now, 349 + ) 350 + 351 + return nil 352 + } 353 + 354 + // handlePendingTierChange processes pending tier changes 355 + func (gsh *GitHubSponsorsWebhook) handlePendingTierChange(event github.SponsorsEvent, webhookEvent *models.WebhookEvent) error { 356 + slog.Info("Pending sponsorship tier change", 357 + "sponsor", event.Sponsorship.Sponsor.Login, 358 + "sponsor_id", event.Sponsorship.Sponsor.ID, 359 + "tier", event.Sponsorship.Tier.Name, 360 + "monthly_price", event.Sponsorship.Tier.MonthlyPriceInDollars, 361 + ) 362 + 363 + // Find existing sponsorship 364 + var sponsorship models.Sponsorship 365 + if err := gsh.DB.Where("github_id = ?", event.Sponsorship.ID).First(&sponsorship).Error; err != nil { 366 + if err == gorm.ErrRecordNotFound { 367 + // If sponsorship doesn't exist, treat it as a new sponsorship 368 + return gsh.handleSponsorshipCreated(event, webhookEvent) 369 + } 370 + return fmt.Errorf("failed to find sponsorship: %w", err) 371 + } 372 + 373 + // Update tier if changed 374 + if event.Sponsorship.Tier.ID != 0 { 375 + var tier models.Tier 376 + if err := gsh.DB.Where("github_id = ?", event.Sponsorship.Tier.ID).First(&tier).Error; err != nil { 377 + return fmt.Errorf("failed to find new tier: %w", err) 378 + } 379 + sponsorship.TierID = tier.ID 380 + } 381 + 382 + // Update metadata to reflect pending tier change 383 + metadata := sponsorship.Metadata() 384 + metadata["pending_tier_change"] = map[string]interface{}{ 385 + "new_tier_id": event.Sponsorship.Tier.ID, 386 + "new_tier_name": event.Sponsorship.Tier.Name, 387 + "notified_at": time.Now(), 388 + } 389 + sponsorship.SetMetadata(metadata) 390 + 391 + if err := gsh.DB.Save(&sponsorship).Error; err != nil { 392 + return fmt.Errorf("failed to update sponsorship for pending tier change: %w", err) 393 + } 394 + 395 + // Update webhook event with sponsorship ID 396 + webhookEvent.SponsorshipID = &sponsorship.ID 397 + 398 + slog.Info("Successfully recorded pending tier change in database", 399 + "sponsorship_id", sponsorship.ID, 400 + "sponsor", event.Sponsorship.Sponsor.Login, 401 + "new_tier", event.Sponsorship.Tier.Name, 402 + ) 403 + 404 + return nil 405 + } 406 + 407 + // handlePendingCancellation processes pending cancellations 408 + func (gsh *GitHubSponsorsWebhook) handlePendingCancellation(event github.SponsorsEvent, webhookEvent *models.WebhookEvent) error { 409 + slog.Info("Pending sponsorship cancellation", 410 + "sponsor", event.Sponsorship.Sponsor.Login, 411 + "sponsor_id", event.Sponsorship.Sponsor.ID, 412 + "tier", event.Sponsorship.Tier.Name, 413 + ) 414 + 415 + // Find existing sponsorship 416 + var sponsorship models.Sponsorship 417 + if err := gsh.DB.Where("github_id = ?", event.Sponsorship.ID).First(&sponsorship).Error; err != nil { 418 + if err == gorm.ErrRecordNotFound { 419 + slog.Info("Sponsorship not found for pending cancellation", 420 + "github_id", event.Sponsorship.ID, 421 + "sponsor", event.Sponsorship.Sponsor.Login, 422 + ) 423 + return nil // Don't treat as error, just log and continue 424 + } 425 + return fmt.Errorf("failed to find sponsorship: %w", err) 426 + } 427 + 428 + // Update metadata to reflect pending cancellation 429 + metadata := sponsorship.Metadata() 430 + metadata["pending_cancellation"] = map[string]interface{}{ 431 + "notified_at": time.Now(), 432 + "tier": event.Sponsorship.Tier.Name, 433 + } 434 + sponsorship.SetMetadata(metadata) 435 + 436 + if err := gsh.DB.Save(&sponsorship).Error; err != nil { 437 + return fmt.Errorf("failed to update sponsorship for pending cancellation: %w", err) 438 + } 439 + 440 + // Update webhook event with sponsorship ID 441 + webhookEvent.SponsorshipID = &sponsorship.ID 442 + 443 + slog.Info("Successfully recorded pending cancellation in database", 444 + "sponsorship_id", sponsorship.ID, 445 + "sponsor", event.Sponsorship.Sponsor.Login, 446 + ) 447 + 448 + return nil 449 + }
-2
cmd/xesite/main.go
··· 83 83 84 84 mux := http.NewServeMux() 85 85 mux.Handle("/", http.FileServerFS(fs)) 86 - //mux.Handle("/", http.FileServer(http.FS(fs))) 87 86 mux.Handle("/api/defs/", http.StripPrefix("/api/defs/", http.FileServer(http.FS(pb.Proto)))) 88 87 89 88 ms := pb.NewMetaServer(&MetaServer{fs}, twirp.WithServerPathPrefix("/api")) ··· 113 112 gh := &GitHubWebhook{fs: fs} 114 113 s := hmacsig.Handler256(gh, *githubSecret) 115 114 mux.Handle("/.within/hook/github", s) 116 - 117 115 mux.Handle("/.within/hook/patreon", &PatreonWebhook{fs: fs}) 118 116 119 117 mux.HandleFunc("/static/talks/irc-why-it-failed.pdf", func(w http.ResponseWriter, r *http.Request) {
+281
cmd/xesitectl/commands/test-webhook.go
··· 1 + package commands 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "crypto/hmac" 7 + "crypto/sha256" 8 + "encoding/hex" 9 + "encoding/json" 10 + "flag" 11 + "fmt" 12 + "log/slog" 13 + "net/http" 14 + "os" 15 + "strconv" 16 + "time" 17 + 18 + "github.com/google/subcommands" 19 + "xeiaso.net/v4/internal/github" 20 + ) 21 + 22 + const ( 23 + defaultWebhookURL = "http://localhost:4823/.within/hook/github-sponsors" 24 + defaultSecret = "test-secret" 25 + ) 26 + 27 + // TestWebhookCmd implements the test webhook command using subcommands 28 + type TestWebhookCmd struct { 29 + action string 30 + sponsor string 31 + sponsorable string 32 + tier string 33 + price int 34 + webhookURL string 35 + secret string 36 + verbose bool 37 + } 38 + 39 + // Name returns the command name 40 + func (*TestWebhookCmd) Name() string { return "test-webhook" } 41 + 42 + // Synopsis returns a short command description 43 + func (*TestWebhookCmd) Synopsis() string { return "Test GitHub Sponsors webhook handler" } 44 + 45 + // Usage returns detailed command usage 46 + func (*TestWebhookCmd) Usage() string { 47 + return `test-webhook [-action] [-sponsor] [-sponsorable] [-tier] [-price] [-url] [-secret] [-verbose] 48 + 49 + Test the GitHub Sponsors webhook handler by sending mock sponsorship events. 50 + 51 + Examples: 52 + xesitectl test-webhook -action created -sponsor testsponsor -tier "Pro Tier" -price 10 53 + xesitectl test-webhook -action cancelled -sponsor oldsponsor -url https://myapp.com/hook 54 + xesitectl test-webhook -action edited -sponsor testsponsor -price 20 -verbose 55 + 56 + Flags: 57 + -action Event action (created, edited, cancelled, pending_tier_change, pending_cancellation) 58 + -sponsor Sponsor login name (default: "testsponsor") 59 + -sponsorable Sponsorable login name (default: "xeiaso") 60 + -tier Tier name (default: "Test Tier") 61 + -price Monthly price in dollars (default: 10) 62 + -url Webhook URL (default: http://localhost:3000/.within/hook/github-sponsors) 63 + -secret Webhook secret for HMAC signature (default: "test-secret") 64 + -verbose Enable verbose logging 65 + ` 66 + } 67 + 68 + // SetFlags defines the command-line flags 69 + func (cmd *TestWebhookCmd) SetFlags(f *flag.FlagSet) { 70 + // Get defaults from environment variables if available 71 + defaultSecret := os.Getenv("GITHUB_SPONSORS_SECRET") 72 + if defaultSecret == "" { 73 + defaultSecret = "test-secret" 74 + } 75 + 76 + defaultURL := os.Getenv("WEBHOOK_URL") 77 + if defaultURL == "" { 78 + defaultURL = defaultWebhookURL 79 + } 80 + 81 + defaultSponsor := os.Getenv("TEST_SPONSOR") 82 + if defaultSponsor == "" { 83 + defaultSponsor = "testsponsor" 84 + } 85 + 86 + defaultTier := os.Getenv("TEST_TIER") 87 + if defaultTier == "" { 88 + defaultTier = "Test Tier" 89 + } 90 + 91 + defaultPrice := 10 92 + if priceStr := os.Getenv("TEST_PRICE"); priceStr != "" { 93 + if price, err := strconv.Atoi(priceStr); err == nil { 94 + defaultPrice = price 95 + } 96 + } 97 + 98 + f.StringVar(&cmd.action, "action", "created", "Sponsorship event action") 99 + f.StringVar(&cmd.sponsor, "sponsor", defaultSponsor, "Sponsor login name") 100 + f.StringVar(&cmd.sponsorable, "sponsorable", "xeiaso", "Sponsorable login name") 101 + f.StringVar(&cmd.tier, "tier", defaultTier, "Tier name") 102 + f.IntVar(&cmd.price, "price", defaultPrice, "Monthly price in dollars") 103 + f.StringVar(&cmd.webhookURL, "url", defaultURL, "Webhook URL") 104 + f.StringVar(&cmd.secret, "secret", defaultSecret, "Webhook secret") 105 + f.BoolVar(&cmd.verbose, "verbose", false, "Enable verbose logging") 106 + } 107 + 108 + // Execute runs the command 109 + func (cmd *TestWebhookCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { 110 + if f.NArg() != 0 { 111 + fmt.Fprintf(os.Stderr, "Unexpected arguments: %v\n", f.Args()) 112 + return subcommands.ExitUsageError 113 + } 114 + 115 + // Configure logging 116 + logLevel := slog.LevelInfo 117 + if cmd.verbose { 118 + logLevel = slog.LevelDebug 119 + } 120 + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ 121 + Level: logLevel, 122 + }))) 123 + 124 + // Validate action 125 + validActions := map[string]bool{ 126 + github.SponsorsEventCreated: true, 127 + github.SponsorsEventEdited: true, 128 + github.SponsorsEventCancelled: true, 129 + github.SponsorsEventPendingTierChange: true, 130 + github.SponsorsEventPendingCancellation: true, 131 + } 132 + 133 + if !validActions[cmd.action] { 134 + fmt.Fprintf(os.Stderr, "Invalid action: %s\nValid actions: created, edited, cancelled, pending_tier_change, pending_cancellation\n", cmd.action) 135 + return subcommands.ExitUsageError 136 + } 137 + 138 + slog.Info("Testing GitHub Sponsors webhook", 139 + "action", cmd.action, 140 + "sponsor", cmd.sponsor, 141 + "sponsorable", cmd.sponsorable, 142 + "tier", cmd.tier, 143 + "price", cmd.price, 144 + "url", cmd.webhookURL, 145 + ) 146 + 147 + // Create test sponsorship event 148 + event := cmd.createTestEvent() 149 + 150 + // Send webhook request 151 + if err := cmd.sendWebhook(ctx, event); err != nil { 152 + fmt.Fprintf(os.Stderr, "Failed to send webhook: %v\n", err) 153 + return subcommands.ExitFailure 154 + } 155 + 156 + fmt.Printf("✅ Successfully sent %s webhook event for sponsor %s\n", cmd.action, cmd.sponsor) 157 + return subcommands.ExitSuccess 158 + } 159 + 160 + // createTestEvent creates a mock GitHub Sponsors event 161 + func (cmd *TestWebhookCmd) createTestEvent() github.SponsorsEvent { 162 + now := time.Now() 163 + 164 + event := github.SponsorsEvent{ 165 + Action: cmd.action, 166 + Sponsorship: github.Sponsorship{ 167 + ID: 12345, 168 + NodeID: "SPONSORSHIP_12345", 169 + CreatedAt: github.Time{Time: now}, 170 + Sponsor: github.Sponsor{ 171 + Login: cmd.sponsor, 172 + ID: 67890, 173 + NodeID: "USER_67890", 174 + AvatarURL: fmt.Sprintf("https://avatars.githubusercontent.com/u/67890?v=4"), 175 + HTMLURL: fmt.Sprintf("https://github.com/%s", cmd.sponsor), 176 + Type: "User", 177 + }, 178 + Sponsorable: github.Sponsor{ 179 + Login: cmd.sponsorable, 180 + ID: 54321, 181 + NodeID: "USER_54321", 182 + AvatarURL: fmt.Sprintf("https://avatars.githubusercontent.com/u/54321?v=4"), 183 + HTMLURL: fmt.Sprintf("https://github.com/%s", cmd.sponsorable), 184 + Type: "User", 185 + }, 186 + Tier: github.Tier{ 187 + ID: 98765, 188 + NodeID: "TIER_98765", 189 + CreatedAt: github.Time{Time: now}, 190 + Description: fmt.Sprintf("Test sponsorship tier for %s", cmd.tier), 191 + MonthlyPriceInDollars: cmd.price, 192 + IsOneTime: false, 193 + IsCustomAmount: false, 194 + Name: cmd.tier, 195 + Published: true, 196 + SelectedTier: true, 197 + SponsorshipCount: 1, 198 + }, 199 + PrivacyLevel: "public", 200 + Variant: "recurring", 201 + SponsorshipType: "user", 202 + }, 203 + Sender: github.User{ 204 + Login: cmd.sponsor, 205 + ID: 67890, 206 + AvatarURL: fmt.Sprintf("https://avatars.githubusercontent.com/u/67890?v=4"), 207 + HTMLURL: fmt.Sprintf("https://github.com/%s", cmd.sponsor), 208 + Type: "User", 209 + }, 210 + } 211 + 212 + return event 213 + } 214 + 215 + // sendWebhook sends the event as an HTTP request to the webhook endpoint 216 + func (cmd *TestWebhookCmd) sendWebhook(ctx context.Context, event github.SponsorsEvent) error { 217 + // Marshal the event to JSON 218 + eventJSON, err := json.Marshal(event) 219 + if err != nil { 220 + return fmt.Errorf("failed to marshal event: %w", err) 221 + } 222 + 223 + if cmd.verbose { 224 + slog.Debug("Webhook payload", "json", string(eventJSON)) 225 + } 226 + 227 + // Create HTTP request with context 228 + req, err := http.NewRequestWithContext(ctx, "POST", cmd.webhookURL, bytes.NewBuffer(eventJSON)) 229 + if err != nil { 230 + return fmt.Errorf("failed to create request: %w", err) 231 + } 232 + 233 + // Set headers 234 + req.Header.Set("Content-Type", "application/json") 235 + req.Header.Set("X-GitHub-Event", "sponsorship") 236 + req.Header.Set("X-GitHub-Delivery", fmt.Sprintf("%d", time.Now().UnixNano())) 237 + req.Header.Set("User-Agent", "Xesitectl-Webhook-Test/1.0") 238 + 239 + // Add HMAC signature 240 + if cmd.secret != "" { 241 + signature := cmd.calculateHMACSignature(eventJSON) 242 + req.Header.Set("X-Hub-Signature-256", signature) 243 + slog.Debug("Added HMAC signature", "signature", signature) 244 + } 245 + 246 + slog.Info("Sending webhook request", 247 + "url", cmd.webhookURL, 248 + "action", event.Action, 249 + "sponsor", event.Sponsorship.Sponsor.Login, 250 + "content_length", len(eventJSON), 251 + ) 252 + 253 + // Send request with timeout 254 + client := &http.Client{Timeout: 10 * time.Second} 255 + resp, err := client.Do(req) 256 + if err != nil { 257 + return fmt.Errorf("failed to send request: %w", err) 258 + } 259 + defer resp.Body.Close() 260 + 261 + // Check response 262 + if resp.StatusCode != http.StatusOK { 263 + return fmt.Errorf("webhook returned status %d %s", resp.StatusCode, resp.Status) 264 + } 265 + 266 + slog.Info("Webhook test completed successfully", 267 + "action", event.Action, 268 + "sponsor", event.Sponsorship.Sponsor.Login, 269 + "status", resp.StatusCode, 270 + ) 271 + 272 + return nil 273 + } 274 + 275 + // calculateHMACSignature calculates the HMAC-SHA256 signature for the webhook payload 276 + func (cmd *TestWebhookCmd) calculateHMACSignature(payload []byte) string { 277 + mac := hmac.New(sha256.New, []byte(cmd.secret)) 278 + mac.Write(payload) 279 + signature := mac.Sum(nil) 280 + return "sha256=" + hex.EncodeToString(signature) 281 + }
+23
cmd/xesitectl/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "flag" 6 + "os" 7 + 8 + _ "github.com/joho/godotenv/autoload" 9 + "github.com/google/subcommands" 10 + "xeiaso.net/v4/cmd/xesitectl/commands" 11 + ) 12 + 13 + func main() { 14 + subcommands.Register(subcommands.HelpCommand(), "") 15 + subcommands.Register(subcommands.FlagsCommand(), "") 16 + subcommands.Register(subcommands.CommandsCommand(), "") 17 + subcommands.Register(&commands.TestWebhookCmd{}, "") 18 + 19 + flag.Parse() 20 + 21 + ctx := context.Background() 22 + os.Exit(int(subcommands.Execute(ctx))) 23 + }
+15 -1
docker-bake.hcl
··· 12 12 variable "UBUNTU_VERSION" { default = "24.04" } 13 13 14 14 group "default" { 15 - targets = [ "patreon-saasproxy", "xesite" ] 15 + targets = [ "patreon-saasproxy", "xesite", "github-sponsor-webhook" ] 16 16 } 17 17 18 18 target "patreon-saasproxy" { ··· 49 49 pull = true 50 50 tags = [ 51 51 "registry.int.xeserv.us/xe/site/bin:main" 52 + ] 53 + } 54 + 55 + target "github-sponsor-webhook" { 56 + args = { 57 + ALPINE_VERSION = null 58 + GO_VERSION = null 59 + } 60 + context = "." 61 + dockerfile = "./docker/github-sponsor-webhook.Dockerfile" 62 + platforms = [ "linux/amd64", "linux/arm64" ] 63 + pull = true 64 + tags = [ 65 + "registry.int.xeserv.us/xe/site/github-sponsor-webhook:main" 52 66 ] 53 67 }
+30
docker/github-sponsor-webhook.Dockerfile
··· 1 + ARG GO_VERSION=1.25 2 + ARG ALPINE_VERSION=edge 3 + 4 + FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION}-alpine AS build 5 + 6 + ARG TARGETOS 7 + ARG TARGETARCH 8 + 9 + WORKDIR /app 10 + 11 + COPY go.mod go.sum ./ 12 + RUN go mod download 13 + 14 + COPY . . 15 + RUN --mount=type=cache,target=/root/.cache GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=0 go build -gcflags "all=-N -l" -o /app/bin/github-sponsor-webhook ./cmd/github-sponsor-webhook 16 + 17 + FROM alpine:${ALPINE_VERSION} AS run 18 + WORKDIR /app 19 + 20 + RUN apk add --no-cache ca-certificates 21 + 22 + COPY --from=build /app/bin/github-sponsor-webhook /app/bin/github-sponsor-webhook 23 + 24 + EXPOSE 8080 25 + 26 + CMD ["/app/bin/github-sponsor-webhook"] 27 + 28 + LABEL org.opencontainers.image.source="https://github.com/Xe/site" 29 + LABEL org.opencontainers.image.title="GitHub Sponsors Webhook Service" 30 + LABEL org.opencontainers.image.description="Standalone webhook service for processing GitHub Sponsors events"
+26 -5
go.mod
··· 43 43 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect 44 44 github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect 45 45 github.com/aws/smithy-go v1.23.1 // indirect 46 + github.com/beorn7/perks v1.0.1 // indirect 47 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 46 48 github.com/cloudflare/circl v1.6.1 // indirect 47 49 github.com/cyphar/filepath-securejoin v0.4.1 // indirect 48 50 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect ··· 60 62 github.com/gogo/protobuf v1.3.2 // indirect 61 63 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 62 64 github.com/google/gnostic-models v0.7.0 // indirect 65 + github.com/google/subcommands v1.2.0 // indirect 63 66 github.com/google/uuid v1.6.0 // indirect 67 + github.com/jackc/pgpassfile v1.0.0 // indirect 68 + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 69 + github.com/jackc/pgx/v5 v5.6.0 // indirect 70 + github.com/jackc/puddle/v2 v2.2.2 // indirect 64 71 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 72 + github.com/jinzhu/inflection v1.0.0 // indirect 73 + github.com/jinzhu/now v1.1.5 // indirect 65 74 github.com/josharian/intern v1.0.0 // indirect 66 75 github.com/json-iterator/go v1.1.12 // indirect 67 76 github.com/kevinburke/ssh_config v1.2.0 // indirect ··· 72 81 github.com/pjbgf/sha1cd v0.3.2 // indirect 73 82 github.com/pkg/errors v0.9.1 // indirect 74 83 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 84 + github.com/prometheus/client_golang v1.23.2 // indirect 85 + github.com/prometheus/client_model v0.6.2 // indirect 86 + github.com/prometheus/common v0.66.1 // indirect 87 + github.com/prometheus/procfs v0.16.1 // indirect 75 88 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 76 89 github.com/skeema/knownhosts v1.3.1 // indirect 77 90 github.com/spf13/pflag v1.0.6 // indirect ··· 80 93 go.yaml.in/yaml/v2 v2.4.2 // indirect 81 94 go.yaml.in/yaml/v3 v3.0.4 // indirect 82 95 go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect 83 - golang.org/x/crypto v0.39.0 // indirect 84 - golang.org/x/net v0.41.0 // indirect 85 - golang.org/x/sys v0.33.0 // indirect 86 - golang.org/x/term v0.32.0 // indirect 87 - golang.org/x/text v0.29.0 // indirect 96 + golang.org/x/crypto v0.43.0 // indirect 97 + golang.org/x/mod v0.29.0 // indirect 98 + golang.org/x/net v0.46.0 // indirect 99 + golang.org/x/sync v0.17.0 // indirect 100 + golang.org/x/sys v0.37.0 // indirect 101 + golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect 102 + golang.org/x/term v0.36.0 // indirect 103 + golang.org/x/text v0.30.0 // indirect 88 104 golang.org/x/time v0.11.0 // indirect 105 + golang.org/x/tools v0.38.0 // indirect 89 106 gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 90 107 gopkg.in/inf.v0 v0.9.1 // indirect 91 108 gopkg.in/warnings.v0 v0.1.2 // indirect 92 109 gopkg.in/yaml.v3 v3.0.1 // indirect 110 + gorm.io/driver/postgres v1.6.0 // indirect 111 + gorm.io/gorm v1.31.0 // indirect 93 112 k8s.io/api v0.34.1 // indirect 94 113 k8s.io/klog/v2 v2.130.1 // indirect 95 114 k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect ··· 99 118 sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 100 119 sigs.k8s.io/yaml v1.6.0 // indirect 101 120 ) 121 + 122 + tool golang.org/x/tools/cmd/goimports
+50
go.sum
··· 45 45 github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= 46 46 github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= 47 47 github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 48 + github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 49 + github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 48 50 github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= 49 51 github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= 52 + github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 53 + github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 50 54 github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 51 55 github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 52 56 github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= ··· 110 114 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 111 115 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 112 116 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 117 + github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= 118 + github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= 113 119 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 114 120 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 121 + github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 122 + github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 123 + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 124 + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 125 + github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= 126 + github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= 127 + github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 128 + github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 115 129 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 116 130 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 131 + github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 132 + github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 133 + github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 134 + github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 117 135 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 118 136 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 119 137 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= ··· 152 170 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 153 171 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 154 172 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 173 + github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 174 + github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 175 + github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 176 + github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 177 + github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= 178 + github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= 179 + github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 180 + github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 155 181 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 156 182 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 157 183 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= ··· 167 193 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 168 194 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 169 195 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 196 + github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 170 197 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 171 198 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 172 199 github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU= ··· 189 216 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 190 217 golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= 191 218 golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 219 + golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= 220 + golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 192 221 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 193 222 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 223 + golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= 224 + golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 194 225 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 195 226 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 196 227 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= ··· 198 229 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 199 230 golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= 200 231 golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= 232 + golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= 233 + golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 201 234 golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= 202 235 golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 203 236 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 204 237 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 205 238 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 239 + golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 240 + golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 206 241 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 207 242 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 208 243 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 214 249 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 215 250 golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 216 251 golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 252 + golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 253 + golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 254 + golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU= 255 + golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= 217 256 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 218 257 golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 219 258 golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 259 + golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= 260 + golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= 220 261 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 221 262 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 222 263 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 223 264 golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 224 265 golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 266 + golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= 267 + golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 225 268 golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 226 269 golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 227 270 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 230 273 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 231 274 golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= 232 275 golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= 276 + golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= 277 + golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 233 278 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 234 279 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 235 280 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= ··· 252 297 gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 253 298 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 254 299 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 300 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 255 301 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 256 302 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 303 + gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= 304 + gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= 305 + gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= 306 + gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 257 307 k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= 258 308 k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= 259 309 k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
+4 -3
internal/adminpb/internal.pb.go
··· 7 7 package adminpb 8 8 9 9 import ( 10 + reflect "reflect" 11 + sync "sync" 12 + unsafe "unsafe" 13 + 10 14 protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 15 protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 16 emptypb "google.golang.org/protobuf/types/known/emptypb" 13 17 timestamppb "google.golang.org/protobuf/types/known/timestamppb" 14 - reflect "reflect" 15 - sync "sync" 16 - unsafe "unsafe" 17 18 pb "xeiaso.net/v4/pb" 18 19 ) 19 20
+31 -17
internal/adminpb/internal.twirp.go
··· 3 3 4 4 package adminpb 5 5 6 - import context "context" 7 - import fmt "fmt" 8 - import http "net/http" 9 - import io "io" 10 - import json "encoding/json" 11 - import strconv "strconv" 12 - import strings "strings" 6 + import ( 7 + context "context" 8 + fmt "fmt" 13 9 14 - import protojson "google.golang.org/protobuf/encoding/protojson" 15 - import proto "google.golang.org/protobuf/proto" 16 - import twirp "github.com/twitchtv/twirp" 17 - import ctxsetters "github.com/twitchtv/twirp/ctxsetters" 10 + http "net/http" 18 11 19 - import google_protobuf "google.golang.org/protobuf/types/known/emptypb" 20 - import xeiaso_net "xeiaso.net/v4/pb" 12 + io "io" 21 13 22 - import bytes "bytes" 23 - import errors "errors" 24 - import path "path" 25 - import url "net/url" 14 + json "encoding/json" 15 + 16 + strconv "strconv" 17 + 18 + strings "strings" 19 + 20 + protojson "google.golang.org/protobuf/encoding/protojson" 21 + 22 + proto "google.golang.org/protobuf/proto" 23 + 24 + twirp "github.com/twitchtv/twirp" 25 + 26 + ctxsetters "github.com/twitchtv/twirp/ctxsetters" 27 + 28 + google_protobuf "google.golang.org/protobuf/types/known/emptypb" 29 + 30 + xeiaso_net "xeiaso.net/v4/pb" 31 + 32 + bytes "bytes" 33 + 34 + errors "errors" 35 + 36 + path "path" 37 + 38 + url "net/url" 39 + ) 26 40 27 41 // Version compatibility assertion. 28 42 // If the constant is not defined in the package, that likely means
+69
internal/github/sponsors.go
··· 1 + package github 2 + 3 + // Sponsor represents a GitHub sponsor. 4 + type Sponsor struct { 5 + Login string `json:"login"` 6 + ID int `json:"id"` 7 + NodeID string `json:"node_id"` 8 + AvatarURL string `json:"avatar_url"` 9 + GravatarID string `json:"gravatar_id"` 10 + URL string `json:"url"` 11 + HTMLURL string `json:"html_url"` 12 + FollowersURL string `json:"followers_url"` 13 + FollowingURL string `json:"following_url"` 14 + GistsURL string `json:"gists_url"` 15 + StarredURL string `json:"starred_url"` 16 + SubscriptionsURL string `json:"subscriptions_url"` 17 + OrganizationsURL string `json:"organizations_url"` 18 + ReposURL string `json:"repos_url"` 19 + EventsURL string `json:"events_url"` 20 + ReceivedEventsURL string `json:"received_events_url"` 21 + Type string `json:"type"` 22 + SiteAdmin bool `json:"site_admin"` 23 + } 24 + 25 + // Sponsorship represents a GitHub sponsorship. 26 + type Sponsorship struct { 27 + ID int `json:"id"` 28 + NodeID string `json:"node_id"` 29 + CreatedAt Time `json:"created_at"` 30 + Sponsor Sponsor `json:"sponsor"` 31 + Sponsorable Sponsor `json:"sponsorable"` 32 + Tier Tier `json:"tier"` 33 + PrivacyLevel string `json:"privacy_level"` 34 + Variant string `json:"variant"` 35 + SponsorshipType string `json:"sponsorship_type"` 36 + } 37 + 38 + // Tier represents the sponsorship tier. 39 + type Tier struct { 40 + ID int `json:"id"` 41 + NodeID string `json:"node_id"` 42 + CreatedAt Time `json:"created_at"` 43 + Description string `json:"description"` 44 + MonthlyPriceInDollars int `json:"monthly_price_in_dollars"` 45 + IsOneTime bool `json:"is_one_time"` 46 + IsCustomAmount bool `json:"is_custom_amount"` 47 + Name string `json:"name"` 48 + Published bool `json:"published"` 49 + SelectedTier bool `json:"selected_tier"` 50 + SponsorshipCount int `json:"sponsorship_count"` 51 + } 52 + 53 + // SponsorsEvent represents a GitHub Sponsors webhook event. 54 + type SponsorsEvent struct { 55 + Action string `json:"action"` 56 + Sponsorship Sponsorship `json:"sponsorship"` 57 + Sender User `json:"sender"` 58 + Repository Repository `json:"repository,omitempty"` 59 + Organization User `json:"organization,omitempty"` 60 + } 61 + 62 + // GithubSponsorsWebhookEventTypes contains the possible GitHub Sponsors webhook event types. 63 + const ( 64 + SponsorsEventCreated = "created" 65 + SponsorsEventEdited = "edited" 66 + SponsorsEventCancelled = "cancelled" 67 + SponsorsEventPendingTierChange = "pending_tier_change" 68 + SponsorsEventPendingCancellation = "pending_cancellation" 69 + )
+184
internal/models/README.md
··· 1 + # Models Package 2 + 3 + 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). 4 + 5 + ## Models 6 + 7 + ### Account 8 + Represents a GitHub account (user or organization) that can be either a sponsor or sponsoree. 9 + 10 + **Fields:** 11 + - `GitHubID` - GitHub's internal ID (unique) 12 + - `NodeID` - GitHub's global node ID 13 + - `Login` - GitHub username 14 + - `AvatarURL` - Profile avatar URL 15 + - `URL` - GitHub profile URL 16 + - `Type` - "User" or "Organization" 17 + - `SiteAdmin` - Whether the account is a site admin 18 + 19 + ### Tier 20 + Represents a sponsorship tier with pricing and benefits. 21 + 22 + **Fields:** 23 + - `GitHubID` - GitHub's internal ID (unique) 24 + - `Name` - Tier name 25 + - `MonthlyPriceInCents` - Price in cents 26 + - `Description` - Tier description 27 + - `Benefits` - JSON array of tier benefits 28 + - `IsOneTime` - Whether this is a one-time payment 29 + - `IsCustomAmount` - Whether this accepts custom amounts 30 + - `Published` - Whether this tier is publicly visible 31 + - `SelectedTier` - Whether this is the selected tier for the sponsor 32 + - `SponsorshipCount` - Number of active sponsorships 33 + 34 + ### Sponsorship 35 + Represents a sponsorship relationship between a sponsor and sponsoree. 36 + 37 + **Fields:** 38 + - `GitHubID` - GitHub's internal ID (unique) 39 + - `PrivacyLevel` - "public" or "private" 40 + - `Variant` - "recurring" or "one_time" 41 + - `SponsorshipType` - "user" or "organization" 42 + - `TierID` - Foreign key to Tier 43 + - `SponsorID` - Foreign key to Account (sponsor) 44 + - `SponsoreeID` - Foreign key to Account (sponsoree) 45 + - `GitHubCreatedAt` - Creation timestamp from GitHub 46 + - `GitHubUpdatedAt` - Last update timestamp from GitHub 47 + - `GitHubCancelledAt` - Cancellation timestamp from GitHub 48 + - `Metadata` - JSON metadata map 49 + 50 + ### WebhookEvent 51 + Tracks incoming GitHub Sponsors webhook events for auditing and debugging. 52 + 53 + **Fields:** 54 + - `GitHubID` - GitHub's delivery ID (unique) 55 + - `Action` - Event action ("created", "edited", "cancelled", etc.) 56 + - `SenderID` - Account that triggered the event 57 + - `SponsorshipID` - Related sponsorship 58 + - `EventType` - Always "sponsorship" for our use case 59 + - `ProcessedAt` - When we processed the event 60 + - `Success` - Whether processing was successful 61 + - `ErrorMessage` - Error message if processing failed 62 + - `Payload` - Raw webhook payload (JSON) 63 + - `RemoteAddr` - Client IP address 64 + - `UserAgent` - Client user agent 65 + 66 + ## Usage 67 + 68 + ### Database Setup 69 + 70 + ```go 71 + import ( 72 + "gorm.io/driver/postgres" 73 + "gorm.io/gorm" 74 + "xeiaso.net/v4/internal/models" 75 + ) 76 + 77 + func main() { 78 + dsn := "host=localhost user=gorm dbname=gorm port=9920 sslmode=disable TimeZone=UTC" 79 + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) 80 + if err != nil { 81 + panic("failed to connect database") 82 + } 83 + 84 + // Auto-migrate all models 85 + err = models.SetupDatabase(db) 86 + if err != nil { 87 + panic("failed to migrate database") 88 + } 89 + } 90 + ``` 91 + 92 + ### Creating Records 93 + 94 + ```go 95 + // Create a sponsor 96 + sponsor := models.Account{ 97 + GitHubID: 12345, 98 + NodeID: "U_kgDOBQ...", 99 + Login: "example_sponsor", 100 + AvatarURL: "https://avatars.githubusercontent.com/u/12345?v=4", 101 + URL: "https://github.com/example_sponsor", 102 + Type: models.AccountTypeUser, 103 + } 104 + 105 + db.Create(&sponsor) 106 + 107 + // Create a tier 108 + tier := models.Tier{ 109 + GitHubID: 67890, 110 + Name: "Gold Sponsor", 111 + MonthlyPriceInCents: 1000, // $10.00 112 + Description: "Gold level sponsorship with benefits", 113 + } 114 + 115 + tier.SetBenefits([]string{ 116 + "Logo on website", 117 + "Discord role", 118 + "Monthly shoutout", 119 + }) 120 + 121 + db.Create(&tier) 122 + 123 + // Create a sponsorship 124 + sponsorship := models.Sponsorship{ 125 + GitHubID: 11111, 126 + PrivacyLevel: models.PrivacyLevelPublic, 127 + Variant: models.VariantRecurring, 128 + SponsorshipType: models.SponsorshipTypeUser, 129 + TierID: tier.ID, 130 + SponsorID: sponsor.ID, 131 + SponsoreeID: sponsoree.ID, 132 + } 133 + 134 + db.Create(&sponsorship) 135 + ``` 136 + 137 + ### Querying Records 138 + 139 + ```go 140 + // Find active sponsorships for a specific sponsor 141 + var sponsorships []models.Sponsorship 142 + db.Where("sponsor_id = ? AND github_cancelled_at IS NULL", sponsorID). 143 + Preload("Tier"). 144 + Preload("Sponsor"). 145 + Preload("Sponsoree"). 146 + Find(&sponsorships) 147 + 148 + // Count webhooks by action type 149 + var eventCounts []struct { 150 + Action string 151 + Count int64 152 + } 153 + 154 + db.Model(&models.WebhookEvent{}). 155 + Select("action, count(*) as count"). 156 + Group("action"). 157 + Scan(&eventCounts) 158 + ``` 159 + 160 + ## Validation 161 + 162 + The package provides validation functions for common fields: 163 + 164 + ```go 165 + models.IsValidSponsorshipAction("created") // true 166 + models.IsValidPrivacyLevel("public") // true 167 + models.IsValidVariant("recurring") // true 168 + models.IsValidSponsorshipType("user") // true 169 + models.IsValidAccountType("Organization") // true 170 + ``` 171 + 172 + ## Constraints 173 + 174 + - `MaxLoginLength`: 255 characters 175 + - `MaxNameLength`: 255 characters 176 + - `MaxNodeIDLength`: 255 characters 177 + - `MaxAvatarURLLength`: 500 characters 178 + - `MaxURLLength`: 500 characters 179 + - `MaxDescriptionLength`: 1000 characters 180 + - `MaxMetadataSize`: 10000 bytes 181 + 182 + ## Webhook Integration 183 + 184 + 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.
+43
internal/models/account.go
··· 1 + package models 2 + 3 + import ( 4 + "time" 5 + 6 + "gorm.io/gorm" 7 + ) 8 + 9 + // Account represents a sponsoring or sponsored account (user or org). 10 + // Based on the Account type from SITE-10. 11 + type Account struct { 12 + ID uint `json:"id" gorm:"primaryKey"` 13 + GitHubID int64 `json:"github_id" gorm:"uniqueIndex;not null"` // GitHub's internal ID 14 + NodeID string `json:"node_id" gorm:"not null"` // GitHub's global node ID 15 + Login string `json:"login" gorm:"not null"` // GitHub username 16 + AvatarURL string `json:"avatar_url"` // Avatar URL 17 + URL string `json:"url"` // GitHub profile URL 18 + Type string `json:"type" gorm:"not null"` // "User" or "Organization" 19 + SiteAdmin bool `json:"site_admin"` // Whether the account is a site admin 20 + 21 + // Timestamps 22 + CreatedAt time.Time `json:"created_at"` 23 + UpdatedAt time.Time `json:"updated_at"` 24 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` 25 + 26 + // Relationships 27 + SponsorshipsAsSponsor []Sponsorship `json:"-" gorm:"foreignKey:SponsorID"` 28 + SponsorshipsAsSponsoree []Sponsorship `json:"-" gorm:"foreignKey:SponsoreeID"` 29 + } 30 + 31 + // TableName specifies the table name for the Account model. 32 + func (Account) TableName() string { 33 + return "accounts" 34 + } 35 + 36 + // BeforeCreate ensures data consistency before creating records. 37 + func (a *Account) BeforeCreate(tx *gorm.DB) error { 38 + // Ensure login is not empty 39 + if a.Login == "" { 40 + return gorm.ErrInvalidField 41 + } 42 + return nil 43 + }
+115
internal/models/models.go
··· 1 + package models 2 + 3 + import ( 4 + "gorm.io/gorm" 5 + ) 6 + 7 + // AllModels returns all models that should be auto-migrated. 8 + func AllModels() []interface{} { 9 + return []interface{}{ 10 + &Account{}, 11 + &Tier{}, 12 + &Sponsorship{}, 13 + &WebhookEvent{}, 14 + } 15 + } 16 + 17 + // AutoMigrate runs database auto-migration for all models. 18 + func AutoMigrate(db *gorm.DB) error { 19 + return db.AutoMigrate(AllModels()...) 20 + } 21 + 22 + // SetupDatabase creates all necessary tables and sets up constraints. 23 + func SetupDatabase(db *gorm.DB) error { 24 + // Run auto-migration 25 + if err := AutoMigrate(db); err != nil { 26 + return err 27 + } 28 + 29 + // Add any additional constraints or indexes here 30 + // For example, we might want to add indexes for common queries 31 + 32 + return nil 33 + } 34 + 35 + // Common validation constraints 36 + const ( 37 + MaxLoginLength = 255 38 + MaxNameLength = 255 39 + MaxNodeIDLength = 255 40 + MaxAvatarURLLength = 500 41 + MaxURLLength = 500 42 + MaxDescriptionLength = 1000 43 + MaxMetadataSize = 10000 // Maximum size for JSON metadata in bytes 44 + ) 45 + 46 + // Sponsorship actions from GitHub Sponsors webhooks 47 + const ( 48 + SponsorshipActionCreated = "created" 49 + SponsorshipActionEdited = "edited" 50 + SponsorshipActionCancelled = "cancelled" 51 + SponsorshipActionPendingTierChange = "pending_tier_change" 52 + SponsorshipActionPendingCancellation = "pending_cancellation" 53 + ) 54 + 55 + // Privacy levels for sponsorships 56 + const ( 57 + PrivacyLevelPublic = "public" 58 + PrivacyLevelPrivate = "private" 59 + ) 60 + 61 + // Sponsorship variants 62 + const ( 63 + VariantRecurring = "recurring" 64 + VariantOneTime = "one_time" 65 + ) 66 + 67 + // Sponsorship types 68 + const ( 69 + SponsorshipTypeUser = "user" 70 + SponsorshipTypeOrganization = "organization" 71 + ) 72 + 73 + // Account types 74 + const ( 75 + AccountTypeUser = "User" 76 + AccountTypeOrganization = "Organization" 77 + ) 78 + 79 + // IsValidSponsorshipAction checks if the action is valid. 80 + func IsValidSponsorshipAction(action string) bool { 81 + validActions := []string{ 82 + SponsorshipActionCreated, 83 + SponsorshipActionEdited, 84 + SponsorshipActionCancelled, 85 + SponsorshipActionPendingTierChange, 86 + SponsorshipActionPendingCancellation, 87 + } 88 + 89 + for _, validAction := range validActions { 90 + if action == validAction { 91 + return true 92 + } 93 + } 94 + return false 95 + } 96 + 97 + // IsValidPrivacyLevel checks if the privacy level is valid. 98 + func IsValidPrivacyLevel(level string) bool { 99 + return level == PrivacyLevelPublic || level == PrivacyLevelPrivate 100 + } 101 + 102 + // IsValidVariant checks if the variant is valid. 103 + func IsValidVariant(variant string) bool { 104 + return variant == VariantRecurring || variant == VariantOneTime 105 + } 106 + 107 + // IsValidSponsorshipType checks if the sponsorship type is valid. 108 + func IsValidSponsorshipType(sponsorshipType string) bool { 109 + return sponsorshipType == SponsorshipTypeUser || sponsorshipType == SponsorshipTypeOrganization 110 + } 111 + 112 + // IsValidAccountType checks if the account type is valid. 113 + func IsValidAccountType(accountType string) bool { 114 + return accountType == AccountTypeUser || accountType == AccountTypeOrganization 115 + }
+104
internal/models/sponsorship.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "time" 6 + 7 + "gorm.io/gorm" 8 + ) 9 + 10 + // Sponsorship represents the sponsorship object. 11 + // Based on the Sponsorship type from SITE-10. 12 + type Sponsorship struct { 13 + ID uint `json:"id" gorm:"primaryKey"` 14 + GitHubID int64 `json:"github_id" gorm:"uniqueIndex;not null"` // GitHub's internal ID 15 + PrivacyLevel string `json:"privacy_level" gorm:"not null"` // "public" or "private" 16 + Variant string `json:"variant" gorm:"not null"` // "recurring" or "one_time" 17 + SponsorshipType string `json:"sponsorship_type" gorm:"not null"` // "user" or "organization" 18 + 19 + // Pricing and tier information 20 + TierID uint `json:"tier_id" gorm:"not null"` // Foreign key to Tier 21 + Tier Tier `json:"tier" gorm:"foreignKey:TierID"` // Relationship to Tier 22 + SponsorID uint `json:"sponsor_id" gorm:"not null"` // Foreign key to Account (sponsor) 23 + Sponsor Account `json:"sponsor" gorm:"foreignKey:SponsorID"` 24 + SponsoreeID uint `json:"sponsoree_id" gorm:"not null"` // Foreign key to Account (sponsoree) 25 + Sponsoree Account `json:"sponsoree" gorm:"foreignKey:SponsoreeID"` 26 + 27 + // Timestamps from GitHub 28 + GitHubCreatedAt *time.Time `json:"github_created_at"` 29 + GitHubUpdatedAt *time.Time `json:"github_updated_at"` 30 + GitHubCancelledAt *time.Time `json:"github_cancelled_at"` 31 + 32 + // Metadata stored as JSON 33 + MetadataJSON string `json:"-" gorm:"type:text"` // JSON encoded metadata 34 + 35 + // Local timestamps 36 + CreatedAt time.Time `json:"created_at"` 37 + UpdatedAt time.Time `json:"updated_at"` 38 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` 39 + 40 + // Relationships for webhook events 41 + WebhookEvents []WebhookEvent `json:"-" gorm:"foreignKey:SponsorshipID"` 42 + } 43 + 44 + // TableName specifies the table name for the Sponsorship model. 45 + func (Sponsorship) TableName() string { 46 + return "sponsorships" 47 + } 48 + 49 + // Metadata returns the metadata as a map. 50 + func (s *Sponsorship) Metadata() map[string]interface{} { 51 + if s.MetadataJSON == "" { 52 + return make(map[string]interface{}) 53 + } 54 + 55 + var metadata map[string]interface{} 56 + if err := json.Unmarshal([]byte(s.MetadataJSON), &metadata); err != nil { 57 + return make(map[string]interface{}) 58 + } 59 + return metadata 60 + } 61 + 62 + // SetMetadata sets the metadata from a map. 63 + func (s *Sponsorship) SetMetadata(metadata map[string]interface{}) error { 64 + if metadata == nil { 65 + s.MetadataJSON = "" 66 + return nil 67 + } 68 + 69 + data, err := json.Marshal(metadata) 70 + if err != nil { 71 + return err 72 + } 73 + s.MetadataJSON = string(data) 74 + return nil 75 + } 76 + 77 + // BeforeSave marshals the metadata before saving. 78 + func (s *Sponsorship) BeforeSave(tx *gorm.DB) error { 79 + return s.SetMetadata(s.Metadata()) 80 + } 81 + 82 + // BeforeCreate ensures data consistency before creating records. 83 + func (s *Sponsorship) BeforeCreate(tx *gorm.DB) error { 84 + if s.SponsorID == 0 || s.SponsoreeID == 0 { 85 + return gorm.ErrInvalidField 86 + } 87 + if s.TierID == 0 { 88 + return gorm.ErrInvalidField 89 + } 90 + return nil 91 + } 92 + 93 + // IsActive returns true if the sponsorship is active (not cancelled). 94 + func (s *Sponsorship) IsActive() bool { 95 + return s.GitHubCancelledAt == nil && s.DeletedAt.Time.IsZero() 96 + } 97 + 98 + // GetMonthlyPriceInDollars returns the monthly price in dollars. 99 + func (s *Sponsorship) GetMonthlyPriceInDollars() float64 { 100 + if s.Tier.MonthlyPriceInCents == 0 { 101 + return 0 102 + } 103 + return float64(s.Tier.MonthlyPriceInCents) / 100.0 104 + }
+83
internal/models/tier.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "time" 6 + 7 + "gorm.io/gorm" 8 + ) 9 + 10 + // Tier represents a sponsorship tier. 11 + // Based on the Tier type from SITE-10. 12 + type Tier struct { 13 + ID uint `json:"id" gorm:"primaryKey"` 14 + GitHubID int64 `json:"github_id" gorm:"uniqueIndex;not null"` // GitHub's internal ID 15 + Name string `json:"name" gorm:"not null"` // Tier name 16 + MonthlyPriceInCents int `json:"monthly_price_in_cents" gorm:"not null"` // Price in cents 17 + Description string `json:"description"` // Tier description 18 + IsOneTime bool `json:"is_one_time" gorm:"default:false"` // Whether this is a one-time payment 19 + IsCustomAmount bool `json:"is_custom_amount" gorm:"default:false"` // Whether this accepts custom amounts 20 + Published bool `json:"published" gorm:"default:false"` // Whether this tier is publicly visible 21 + SelectedTier bool `json:"selected_tier" gorm:"default:false"` // Whether this is the selected tier for the sponsor 22 + SponsorshipCount int `json:"sponsorship_count" gorm:"default:0"` // Number of active sponsorships 23 + 24 + // Benefits stored as JSON array 25 + BenefitsJSON string `json:"-" gorm:"type:text"` // JSON encoded benefits 26 + 27 + // Timestamps 28 + CreatedAt time.Time `json:"created_at"` 29 + UpdatedAt time.Time `json:"updated_at"` 30 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` 31 + 32 + // Relationships 33 + Sponsorships []Sponsorship `json:"-" gorm:"foreignKey:TierID"` 34 + } 35 + 36 + // TableName specifies the table name for the Tier model. 37 + func (Tier) TableName() string { 38 + return "tiers" 39 + } 40 + 41 + // Benefits returns the benefits as a slice of strings. 42 + func (t *Tier) Benefits() []string { 43 + if t.BenefitsJSON == "" { 44 + return []string{} 45 + } 46 + 47 + var benefits []string 48 + if err := json.Unmarshal([]byte(t.BenefitsJSON), &benefits); err != nil { 49 + return []string{} 50 + } 51 + return benefits 52 + } 53 + 54 + // SetBenefits sets the benefits from a slice of strings. 55 + func (t *Tier) SetBenefits(benefits []string) error { 56 + if benefits == nil || len(benefits) == 0 { 57 + t.BenefitsJSON = "" 58 + return nil 59 + } 60 + 61 + data, err := json.Marshal(benefits) 62 + if err != nil { 63 + return err 64 + } 65 + t.BenefitsJSON = string(data) 66 + return nil 67 + } 68 + 69 + // BeforeSave marshals the benefits before saving. 70 + func (t *Tier) BeforeSave(tx *gorm.DB) error { 71 + return t.SetBenefits(t.Benefits()) 72 + } 73 + 74 + // BeforeCreate ensures data consistency before creating records. 75 + func (t *Tier) BeforeCreate(tx *gorm.DB) error { 76 + if t.Name == "" { 77 + return gorm.ErrInvalidField 78 + } 79 + if t.MonthlyPriceInCents < 0 { 80 + return gorm.ErrInvalidField 81 + } 82 + return nil 83 + }
+107
internal/models/webhook_event.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "time" 6 + 7 + "gorm.io/gorm" 8 + ) 9 + 10 + // WebhookEvent represents an incoming GitHub Sponsors webhook event. 11 + // This helps with auditing and debugging webhook processing. 12 + type WebhookEvent struct { 13 + ID uint `json:"id" gorm:"primaryKey"` 14 + GitHubID string `json:"github_id" gorm:"uniqueIndex;not null"` // GitHub's delivery ID 15 + Action string `json:"action" gorm:"not null"` // "created", "edited", "cancelled", etc. 16 + SenderID uint `json:"sender_id"` // The account that triggered the event 17 + Sender Account `json:"sender" gorm:"foreignKey:SenderID"` 18 + SponsorshipID *uint `json:"sponsorship_id"` // The sponsorship this event relates to 19 + Sponsorship *Sponsorship `json:"sponsorship" gorm:"foreignKey:SponsorshipID"` 20 + 21 + // Event details 22 + EventType string `json:"event_type" gorm:"not null"` // Always "sponsorship" for our use case 23 + ProcessedAt time.Time `json:"processed_at"` // When we processed the event 24 + Success bool `json:"success" gorm:"default:true"` // Whether processing was successful 25 + ErrorMessage string `json:"error_message"` // Error message if processing failed 26 + 27 + // Raw webhook payload for debugging/reprocessing 28 + PayloadJSON string `json:"-" gorm:"type:text"` // JSON encoded webhook payload 29 + PayloadSize int `json:"payload_size"` // Size of the payload in bytes 30 + 31 + // Request details 32 + RemoteAddr string `json:"remote_addr"` // Client IP address 33 + UserAgent string `json:"user_agent"` // Client user agent 34 + Timestamp time.Time `json:"timestamp"` // When the webhook was received 35 + 36 + // Local timestamps 37 + CreatedAt time.Time `json:"created_at"` 38 + UpdatedAt time.Time `json:"updated_at"` 39 + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` 40 + } 41 + 42 + // TableName specifies the table name for the WebhookEvent model. 43 + func (WebhookEvent) TableName() string { 44 + return "webhook_events" 45 + } 46 + 47 + // Payload returns the parsed webhook payload. 48 + func (w *WebhookEvent) Payload() map[string]interface{} { 49 + if w.PayloadJSON == "" { 50 + return make(map[string]interface{}) 51 + } 52 + 53 + var payload map[string]interface{} 54 + if err := json.Unmarshal([]byte(w.PayloadJSON), &payload); err != nil { 55 + return make(map[string]interface{}) 56 + } 57 + return payload 58 + } 59 + 60 + // SetPayload sets the webhook payload from a map. 61 + func (w *WebhookEvent) SetPayload(payload map[string]interface{}) error { 62 + if payload == nil { 63 + w.PayloadJSON = "" 64 + w.PayloadSize = 0 65 + return nil 66 + } 67 + 68 + data, err := json.Marshal(payload) 69 + if err != nil { 70 + return err 71 + } 72 + w.PayloadJSON = string(data) 73 + w.PayloadSize = len(data) 74 + return nil 75 + } 76 + 77 + // BeforeSave marshals the payload before saving. 78 + func (w *WebhookEvent) BeforeSave(tx *gorm.DB) error { 79 + return w.SetPayload(w.Payload()) 80 + } 81 + 82 + // BeforeCreate ensures data consistency before creating records. 83 + func (w *WebhookEvent) BeforeCreate(tx *gorm.DB) error { 84 + if w.GitHubID == "" { 85 + return gorm.ErrInvalidField 86 + } 87 + if w.Action == "" { 88 + return gorm.ErrInvalidField 89 + } 90 + if w.Timestamp.IsZero() { 91 + w.Timestamp = time.Now() 92 + } 93 + if w.ProcessedAt.IsZero() { 94 + w.ProcessedAt = time.Now() 95 + } 96 + return nil 97 + } 98 + 99 + // IsRecent returns true if the webhook was received within the last hour. 100 + func (w *WebhookEvent) IsRecent() bool { 101 + return time.Since(w.Timestamp) < time.Hour 102 + } 103 + 104 + // GetProcessingDuration returns how long it took to process the webhook. 105 + func (w *WebhookEvent) GetProcessingDuration() time.Duration { 106 + return w.ProcessedAt.Sub(w.Timestamp) 107 + }
+1 -1
lume/src/static/blog/maybedoer.go
··· 1 1 // Package maybedoer contains a pipeline of actions that might fail. If any action 2 2 // in the chain fails, no further actions take place and the error becomes the pipeline 3 3 // error. 4 - // 4 + // 5 5 // MIT License 6 6 package maybedoer 7 7
+21
manifest/github-sponsor-webhook/app.yaml
··· 1 + apiVersion: x.within.website/v1 2 + kind: App 3 + metadata: 4 + name: spons-webhook 5 + 6 + spec: 7 + image: reg.xeiaso.net/xe/site/github-sponsor-webhook:main 8 + autoUpdate: true 9 + 10 + healthcheck: 11 + enabled: true 12 + path: /health 13 + 14 + ingress: 15 + enabled: true 16 + host: webhook.sponsors.xeiaso.net 17 + 18 + secrets: 19 + - name: env 20 + itemPath: vaults/lc5zo4zjz3if3mkeuhufjmgmui/items/ykvytf776gf5pt53a2u5u4eeie 21 + environment: true
+10
manifest/github-sponsor-webhook/database.yaml
··· 1 + apiVersion: postgresql.cnpg.io/v1 2 + kind: Cluster 3 + metadata: 4 + name: github-sponsor-webhook 5 + spec: 6 + instances: 2 7 + enableSuperuserAccess: true 8 + 9 + storage: 10 + size: 16Gi
+4
manifest/github-sponsor-webhook/kustomization.yaml
··· 1 + resources: 2 + - app.yaml 3 + - database.yaml 4 + - valkey.yaml
+12
manifest/github-sponsor-webhook/valkey.yaml
··· 1 + apiVersion: db.x.within.website/v1 2 + kind: Valkey 3 + metadata: 4 + name: anubis 5 + namespace: gitea 6 + spec: 7 + env: 8 + - name: ALLOW_EMPTY_PASSWORD 9 + value: "true" 10 + storage: 11 + enabled: true 12 + size: 5Gi
+4 -3
pb/external/mi/mi.pb.go
··· 9 9 package mi 10 10 11 11 import ( 12 + reflect "reflect" 13 + sync "sync" 14 + unsafe "unsafe" 15 + 12 16 protoreflect "google.golang.org/protobuf/reflect/protoreflect" 13 17 protoimpl "google.golang.org/protobuf/runtime/protoimpl" 14 18 emptypb "google.golang.org/protobuf/types/known/emptypb" 15 19 timestamppb "google.golang.org/protobuf/types/known/timestamppb" 16 - reflect "reflect" 17 - sync "sync" 18 - unsafe "unsafe" 19 20 ) 20 21 21 22 const (
+29 -16
pb/external/mi/mi.twirp.go
··· 3 3 4 4 package mi 5 5 6 - import context "context" 7 - import fmt "fmt" 8 - import http "net/http" 9 - import io "io" 10 - import json "encoding/json" 11 - import strconv "strconv" 12 - import strings "strings" 6 + import ( 7 + context "context" 8 + fmt "fmt" 9 + 10 + http "net/http" 11 + 12 + io "io" 13 + 14 + json "encoding/json" 15 + 16 + strconv "strconv" 17 + 18 + strings "strings" 19 + 20 + protojson "google.golang.org/protobuf/encoding/protojson" 21 + 22 + proto "google.golang.org/protobuf/proto" 23 + 24 + twirp "github.com/twitchtv/twirp" 25 + 26 + ctxsetters "github.com/twitchtv/twirp/ctxsetters" 27 + 28 + google_protobuf "google.golang.org/protobuf/types/known/emptypb" 13 29 14 - import protojson "google.golang.org/protobuf/encoding/protojson" 15 - import proto "google.golang.org/protobuf/proto" 16 - import twirp "github.com/twitchtv/twirp" 17 - import ctxsetters "github.com/twitchtv/twirp/ctxsetters" 30 + bytes "bytes" 18 31 19 - import google_protobuf "google.golang.org/protobuf/types/known/emptypb" 32 + errors "errors" 20 33 21 - import bytes "bytes" 22 - import errors "errors" 23 - import path "path" 24 - import url "net/url" 34 + path "path" 35 + 36 + url "net/url" 37 + ) 25 38 26 39 // Version compatibility assertion. 27 40 // If the constant is not defined in the package, that likely means
+3 -2
pb/external/mimi/announce/mimi-announce.pb.go
··· 7 7 package announce 8 8 9 9 import ( 10 + reflect "reflect" 11 + unsafe "unsafe" 12 + 10 13 protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 14 protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 15 emptypb "google.golang.org/protobuf/types/known/emptypb" 13 - reflect "reflect" 14 - unsafe "unsafe" 15 16 protofeed "xeiaso.net/v4/pb/external/protofeed" 16 17 ) 17 18
+31 -17
pb/external/mimi/announce/mimi-announce.twirp.go
··· 3 3 4 4 package announce 5 5 6 - import context "context" 7 - import fmt "fmt" 8 - import http "net/http" 9 - import io "io" 10 - import json "encoding/json" 11 - import strconv "strconv" 12 - import strings "strings" 6 + import ( 7 + context "context" 8 + fmt "fmt" 13 9 14 - import protojson "google.golang.org/protobuf/encoding/protojson" 15 - import proto "google.golang.org/protobuf/proto" 16 - import twirp "github.com/twitchtv/twirp" 17 - import ctxsetters "github.com/twitchtv/twirp/ctxsetters" 10 + http "net/http" 18 11 19 - import google_protobuf "google.golang.org/protobuf/types/known/emptypb" 20 - import protofeed "xeiaso.net/v4/pb/external/protofeed" 12 + io "io" 21 13 22 - import bytes "bytes" 23 - import errors "errors" 24 - import path "path" 25 - import url "net/url" 14 + json "encoding/json" 15 + 16 + strconv "strconv" 17 + 18 + strings "strings" 19 + 20 + protojson "google.golang.org/protobuf/encoding/protojson" 21 + 22 + proto "google.golang.org/protobuf/proto" 23 + 24 + twirp "github.com/twitchtv/twirp" 25 + 26 + ctxsetters "github.com/twitchtv/twirp/ctxsetters" 27 + 28 + google_protobuf "google.golang.org/protobuf/types/known/emptypb" 29 + 30 + protofeed "xeiaso.net/v4/pb/external/protofeed" 31 + 32 + bytes "bytes" 33 + 34 + errors "errors" 35 + 36 + path "path" 37 + 38 + url "net/url" 39 + ) 26 40 27 41 // Version compatibility assertion. 28 42 // If the constant is not defined in the package, that likely means
+4 -3
pb/external/protofeed/protofeed.pb.go
··· 7 7 package protofeed 8 8 9 9 import ( 10 + reflect "reflect" 11 + sync "sync" 12 + unsafe "unsafe" 13 + 10 14 protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 15 protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 16 timestamppb "google.golang.org/protobuf/types/known/timestamppb" 13 - reflect "reflect" 14 - sync "sync" 15 - unsafe "unsafe" 16 17 ) 17 18 18 19 const (
+4 -3
pb/xesite.pb.go
··· 7 7 package pb 8 8 9 9 import ( 10 + reflect "reflect" 11 + sync "sync" 12 + unsafe "unsafe" 13 + 10 14 protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 15 protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 16 emptypb "google.golang.org/protobuf/types/known/emptypb" 13 17 timestamppb "google.golang.org/protobuf/types/known/timestamppb" 14 - reflect "reflect" 15 - sync "sync" 16 - unsafe "unsafe" 17 18 _ "xeiaso.net/v4/pb/external/mi" 18 19 protofeed "xeiaso.net/v4/pb/external/protofeed" 19 20 )
+31 -17
pb/xesite.twirp.go
··· 3 3 4 4 package pb 5 5 6 - import context "context" 7 - import fmt "fmt" 8 - import http "net/http" 9 - import io "io" 10 - import json "encoding/json" 11 - import strconv "strconv" 12 - import strings "strings" 6 + import ( 7 + context "context" 8 + fmt "fmt" 13 9 14 - import protojson "google.golang.org/protobuf/encoding/protojson" 15 - import proto "google.golang.org/protobuf/proto" 16 - import twirp "github.com/twitchtv/twirp" 17 - import ctxsetters "github.com/twitchtv/twirp/ctxsetters" 10 + http "net/http" 18 11 19 - import google_protobuf "google.golang.org/protobuf/types/known/emptypb" 20 - import protofeed "xeiaso.net/v4/pb/external/protofeed" 12 + io "io" 21 13 22 - import bytes "bytes" 23 - import errors "errors" 24 - import path "path" 25 - import url "net/url" 14 + json "encoding/json" 15 + 16 + strconv "strconv" 17 + 18 + strings "strings" 19 + 20 + protojson "google.golang.org/protobuf/encoding/protojson" 21 + 22 + proto "google.golang.org/protobuf/proto" 23 + 24 + twirp "github.com/twitchtv/twirp" 25 + 26 + ctxsetters "github.com/twitchtv/twirp/ctxsetters" 27 + 28 + google_protobuf "google.golang.org/protobuf/types/known/emptypb" 29 + 30 + protofeed "xeiaso.net/v4/pb/external/protofeed" 31 + 32 + bytes "bytes" 33 + 34 + errors "errors" 35 + 36 + path "path" 37 + 38 + url "net/url" 39 + ) 26 40 27 41 // Version compatibility assertion. 28 42 // If the constant is not defined in the package, that likely means
+21 -21
scripts/check-mdx-tags.go
··· 10 10 ) 11 11 12 12 type TagInfo struct { 13 - Name string 14 - Line int 13 + Name string 14 + Line int 15 15 IsClosing bool 16 16 } 17 17 ··· 23 23 } 24 24 25 25 path := os.Args[1] 26 - 26 + 27 27 stat, err := os.Stat(path) 28 28 if err != nil { 29 29 fmt.Printf("Error: %v\n", err) ··· 35 35 if err != nil { 36 36 return err 37 37 } 38 - 38 + 39 39 if strings.HasSuffix(path, ".mdx") || strings.HasSuffix(path, ".jsx") || strings.HasSuffix(path, ".tsx") { 40 40 checkFile(path) 41 41 } 42 42 return nil 43 43 }) 44 - 44 + 45 45 if err != nil { 46 46 fmt.Printf("Error walking directory: %v\n", err) 47 47 os.Exit(1) ··· 63 63 // Matches: <TagName>, </TagName>, <TagName/>, <TagName ...>, </TagName ...> 64 64 // More robust pattern to handle all self-closing variations 65 65 tagRegex := regexp.MustCompile(`<(/?)([A-Z][a-zA-Z0-9]*)((?:[^>]*?)?)(/?)>`) 66 - 66 + 67 67 var tagStack []TagInfo 68 68 var hasErrors bool 69 - 69 + 70 70 scanner := bufio.NewScanner(file) 71 71 lineNum := 0 72 - 72 + 73 73 for scanner.Scan() { 74 74 lineNum++ 75 75 line := scanner.Text() 76 - 76 + 77 77 // Find all tags in this line 78 78 matches := tagRegex.FindAllStringSubmatch(line, -1) 79 - 79 + 80 80 for _, match := range matches { 81 - isClosingSlash := match[1] == "/" // </TagName> 81 + isClosingSlash := match[1] == "/" // </TagName> 82 82 tagName := match[2] 83 83 innerContent := strings.TrimSpace(match[3]) 84 84 trailingSlash := match[4] == "/" 85 - 85 + 86 86 // Check if it's self-closing: either ends with /> or has / at the end of content 87 87 isSelfClosing := trailingSlash || strings.HasSuffix(innerContent, "/") 88 - 88 + 89 89 if isSelfClosing { 90 90 // Self-closing tags don't need to be tracked 91 91 continue 92 92 } 93 - 93 + 94 94 if isClosingSlash { 95 95 // This is a closing tag 96 96 if len(tagStack) == 0 { ··· 98 98 hasErrors = true 99 99 continue 100 100 } 101 - 101 + 102 102 // Check if it matches the most recent opening tag 103 103 lastTag := tagStack[len(tagStack)-1] 104 104 if lastTag.Name != tagName { 105 - fmt.Printf("%s:%d: Mismatched closing tag </%s>, expected </%s> (opened at line %d)\n", 105 + fmt.Printf("%s:%d: Mismatched closing tag </%s>, expected </%s> (opened at line %d)\n", 106 106 filename, lineNum, tagName, lastTag.Name, lastTag.Line) 107 107 hasErrors = true 108 108 } else { ··· 112 112 } else { 113 113 // This is an opening tag 114 114 tagStack = append(tagStack, TagInfo{ 115 - Name: tagName, 116 - Line: lineNum, 115 + Name: tagName, 116 + Line: lineNum, 117 117 IsClosing: false, 118 118 }) 119 119 } 120 120 } 121 121 } 122 - 122 + 123 123 if err := scanner.Err(); err != nil { 124 124 fmt.Printf("Error reading %s: %v\n", filename, err) 125 125 return 126 126 } 127 - 127 + 128 128 // Check for unclosed tags 129 129 for _, tag := range tagStack { 130 130 fmt.Printf("%s:%d: Unclosed opening tag <%s>\n", filename, tag.Line, tag.Name) 131 131 hasErrors = true 132 132 } 133 - 133 + 134 134 if !hasErrors { 135 135 fmt.Printf("%s: ✓ All tags properly closed\n", filename) 136 136 }