The code and data behind xeiaso.net
0
fork

Configure Feed

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

chore: delete unused github-sponsor-webhook service (#1182)

* chore: delete unused github-sponsor-webhook service

Removes the github-sponsor-webhook service as it is no longer used.

Changes:
- Delete cmd/github-sponsor-webhook/ directory
- Delete manifest/github-sponsor-webhook/ directory
- Delete docker/github-sponsor-webhook.Dockerfile
- Remove github-sponsor-webhook from docker-bake.hcl
- Remove github-sponsor-webhook tags from .github/workflows/earthly.yml
- Remove /github-sponsor-webhook from .gitignore
- Update AGENTS.md to remove github-sponsor-webhook from commands list
- Remove webhook integration section from internal/models/README.md

* chore: delete unused internal/models package

The models package was only used by the deleted github-sponsor-webhook
service and is no longer needed.

Deleted:
- internal/models/account.go
- internal/models/models.go
- internal/models/sponsorship.go
- internal/models/tier.go
- internal/models/webhook_event.go
- internal/models/README.md

* docs: prune references to deleted internal/models package

Update sponsor-panel specification documents to remove references to
the deleted internal/models package.

Changes:
- cmd/sponsor-panel/docs/README.md - update Phase 1 checklist
- cmd/sponsor-panel/docs/SPEC.md - update Phase 1 checklist

authored by

Xe Iaso and committed by
GitHub
504058e4 75d12d99

+4 -1274
-2
.github/workflows/earthly.yml
··· 37 37 source: . 38 38 push: true 39 39 set: | 40 - github-sponsor-webhook.tags=ghcr.io/xe/site/github-sponsor-webhook:latest 41 40 patreon-saasproxy.tags=ghcr.io/xe/site/patreon-saasproxy:latest 42 41 sponsor-panel.tags=ghcr.io/xe/site/sponsor-panel:latest 43 42 xesite.tags=ghcr.io/xe/site/bin:latest ··· 49 48 source: . 50 49 push: false 51 50 set: | 52 - github-sponsor-webhook.tags=ghcr.io/xe/site/github-sponsor-webhook:latest 53 51 patreon-saasproxy.tags=ghcr.io/xe/site/patreon-saasproxy:latest 54 52 sponsor-panel.tags=ghcr.io/xe/site/sponsor-panel:latest 55 53 xesite.tags=ghcr.io/xe/site/bin:latest
-1
.gitignore
··· 11 11 node_modules 12 12 /xesite 13 13 /xesitectl 14 - /github-sponsor-webhook
+1 -1
AGENTS.md
··· 5 5 ## Project Structure 6 6 7 7 ``` 8 - cmd/ # Binaries: fabricate-generation, github-sponsor-webhook, hydrate, no-way-to-prevent-this, patreon-saasproxy, xesite (main), xesitectl 8 + cmd/ # Binaries: fabricate-generation, hydrate, no-way-to-prevent-this, patreon-saasproxy, xesite (main), xesitectl 9 9 internal/ # Private packages 10 10 lume/ # Static site generator configuration and pages 11 11 ```
-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 - }
+1 -1
cmd/sponsor-panel/docs/README.md
··· 192 192 193 193 ### Phase 1: Foundation 194 194 195 - - [ ] Create `internal/models/` with User and LogoSubmission structs 195 + - [ ] Define User and LogoSubmission structs 196 196 - [ ] Set up sqlx with PostgreSQL 197 197 - [ ] Create migration for 2 tables 198 198 - [ ] Implement cookie encryption/decryption
+1 -1
cmd/sponsor-panel/docs/SPEC.md
··· 888 888 889 889 ### Phase 1: Foundation 890 890 891 - - [ ] Create `internal/models/` with User and LogoSubmission structs 891 + - [ ] Define User and LogoSubmission structs 892 892 - [ ] Set up sqlx with PostgreSQL 893 893 - [ ] Create migration for 2 tables 894 894 - [ ] Implement cookie encryption/decryption
+1 -15
docker-bake.hcl
··· 12 12 variable "UBUNTU_VERSION" { default = "24.04" } 13 13 14 14 group "default" { 15 - targets = [ "patreon-saasproxy", "xesite", "github-sponsor-webhook", "sponsor-panel" ] 15 + targets = [ "patreon-saasproxy", "xesite", "sponsor-panel" ] 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" 66 52 ] 67 53 } 68 54
-30
docker/github-sponsor-webhook.Dockerfile
··· 1 - ARG GO_VERSION=1.26 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"
-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 - }
-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