A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

more billing/settings/webhook tweaks

evan.jarrett.net 7d74e767 08272197

verified
+633 -117
+4 -4
config-appview.example.yaml
··· 120 webhook_all_triggers: false 121 supporter_badge: false 122 - # Tier name. Position in list determines rank (0-based). 123 - name: deckhand 124 # Short description shown on the plan card. 125 description: Get started with basic storage 126 # List of features included in this tier. ··· 128 # Stripe price ID for monthly billing. Empty = free tier. 129 stripe_price_monthly: "" 130 # Stripe price ID for yearly billing. 131 - stripe_price_yearly: "" 132 # Maximum webhooks for this tier (-1 = unlimited). 133 max_webhooks: 1 134 # Allow all webhook trigger types (not just first-scan). ··· 141 # List of features included in this tier. 142 features: [] 143 # Stripe price ID for monthly billing. Empty = free tier. 144 - stripe_price_monthly: "" 145 # Stripe price ID for yearly billing. 146 - stripe_price_yearly: "" 147 # Maximum webhooks for this tier (-1 = unlimited). 148 max_webhooks: 10 149 # Allow all webhook trigger types (not just first-scan).
··· 120 webhook_all_triggers: false 121 supporter_badge: false 122 - # Tier name. Position in list determines rank (0-based). 123 + name: Supporter 124 # Short description shown on the plan card. 125 description: Get started with basic storage 126 # List of features included in this tier. ··· 128 # Stripe price ID for monthly billing. Empty = free tier. 129 stripe_price_monthly: "" 130 # Stripe price ID for yearly billing. 131 + stripe_price_yearly: "price_1SmK1mRROAC4bYmSwhTQ7RY9" 132 # Maximum webhooks for this tier (-1 = unlimited). 133 max_webhooks: 1 134 # Allow all webhook trigger types (not just first-scan). ··· 141 # List of features included in this tier. 142 features: [] 143 # Stripe price ID for monthly billing. Empty = free tier. 144 + stripe_price_monthly: "price_1SmK4QRROAC4bYmSxpr35HUl" 145 # Stripe price ID for yearly billing. 146 + stripe_price_yearly: "price_1SmJuLRROAC4bYmSUgVCwZWo" 147 # Maximum webhooks for this tier (-1 = unlimited). 148 max_webhooks: 10 149 # Allow all webhook trigger types (not just first-scan).
+4 -4
docker-compose.yml
··· 7 container_name: atcr-appview 8 ports: 9 - "5000:5000" 10 # Optional: Load from .env.appview file (create from .env.appview.example) 11 # env_file: 12 # - .env.appview ··· 15 environment: 16 # ATCR_SERVER_CLIENT_NAME: "Seamark" 17 # ATCR_SERVER_CLIENT_SHORT_NAME: "Seamark" 18 ATCR_SERVER_DEFAULT_HOLD_DID: did:web:172.28.0.3%3A8080 19 ATCR_SERVER_TEST_MODE: true 20 ATCR_LOG_LEVEL: debug 21 LOG_SHIPPER_BACKEND: victoria 22 LOG_SHIPPER_URL: http://172.28.0.10:9428 23 - # Stripe billing (only used with -tags billing) 24 - STRIPE_SECRET_KEY: sk_test_ 25 - STRIPE_PUBLISHABLE_KEY: pk_test_ 26 - STRIPE_WEBHOOK_SECRET: whsec_ 27 # Limit local Docker logs - real logs go to Victoria Logs 28 # Local logs just for live tailing (docker logs -f) 29 logging: ··· 56 # Base config: config-hold.example.yaml (passed via Air entrypoint) 57 # Env vars below override config file values for local dev 58 environment: 59 HOLD_SCANNER_SECRET: dev-secret 60 HOLD_SERVER_PUBLIC_URL: http://172.28.0.3:8080 61 HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg
··· 7 container_name: atcr-appview 8 ports: 9 - "5000:5000" 10 + env_file: 11 + - ../atcr-secrets.env 12 # Optional: Load from .env.appview file (create from .env.appview.example) 13 # env_file: 14 # - .env.appview ··· 17 environment: 18 # ATCR_SERVER_CLIENT_NAME: "Seamark" 19 # ATCR_SERVER_CLIENT_SHORT_NAME: "Seamark" 20 + ATCR_SERVER_MANAGED_HOLDS: did:web:172.28.0.3%3A8080 21 ATCR_SERVER_DEFAULT_HOLD_DID: did:web:172.28.0.3%3A8080 22 ATCR_SERVER_TEST_MODE: true 23 ATCR_LOG_LEVEL: debug 24 LOG_SHIPPER_BACKEND: victoria 25 LOG_SHIPPER_URL: http://172.28.0.10:9428 26 # Limit local Docker logs - real logs go to Victoria Logs 27 # Local logs just for live tailing (docker logs -f) 28 logging: ··· 55 # Base config: config-hold.example.yaml (passed via Air entrypoint) 56 # Env vars below override config file values for local dev 57 environment: 58 + HOLD_SERVER_APPVIEW_DID: did:web:172.28.0.2%3A5000 59 HOLD_SCANNER_SECRET: dev-secret 60 HOLD_SERVER_PUBLIC_URL: http://172.28.0.3:8080 61 HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg
+227
docs/WEBHOOKS.md
···
··· 1 + # Webhooks 2 + 3 + Webhooks notify external services when events occur in the registry. Payloads are JSON, signed with HMAC-SHA256 (optional), and delivered with retry (exponential backoff: 0s, 30s, 2m, 8m). Discord and Slack URLs are auto-detected and receive platform-native formatting. 4 + 5 + ## Current Events 6 + 7 + ### `push` — Image Push 8 + 9 + Fires when a manifest is stored (the "logical push complete" moment). Tagless pushes (e.g., buildx platform manifests) also fire with an empty `tag` field. 10 + 11 + **Bitmask:** `0x08` — Free tier 12 + 13 + ```json 14 + { 15 + "trigger": "push", 16 + "push_data": { 17 + "pushed_at": "2026-02-27T15:30:00Z", 18 + "pusher": "alice.bsky.social", 19 + "pusher_did": "did:plc:abc123", 20 + "tag": "latest", 21 + "digest": "sha256:abc..." 22 + }, 23 + "repository": { 24 + "name": "myapp", 25 + "namespace": "alice.bsky.social", 26 + "repo_name": "alice.bsky.social/myapp", 27 + "repo_url": "https://buoy.cr/alice.bsky.social/myapp", 28 + "media_type": "application/vnd.oci.image.manifest.v1+json", 29 + "star_count": 42, 30 + "pull_count": 1337 31 + }, 32 + "hold": { 33 + "did": "did:web:hold01.atcr.io", 34 + "endpoint": "https://hold01.atcr.io" 35 + } 36 + } 37 + ``` 38 + 39 + `repo_url` uses `registry_domains[0]` (the pull domain) when configured, otherwise falls back to `base_url`. 40 + 41 + ### `scan:first` — First Scan 42 + 43 + Fires the first time an image is scanned (no previous scan record exists). 44 + 45 + **Bitmask:** `0x01` — Free tier 46 + 47 + ### `scan:all` — Every Scan 48 + 49 + Fires on every scan completion. 50 + 51 + **Bitmask:** `0x02` — Paid tier 52 + 53 + ### `scan:changed` — Vulnerability Change 54 + 55 + Fires when vulnerability counts change from the previous scan. Includes a `previous` field with the old counts. 56 + 57 + **Bitmask:** `0x04` — Paid tier 58 + 59 + **Scan payload format** (shared by all scan triggers): 60 + 61 + ```json 62 + { 63 + "trigger": "scan:first", 64 + "holdDid": "did:web:hold01.atcr.io", 65 + "holdEndpoint": "https://hold01.atcr.io", 66 + "manifest": { 67 + "digest": "sha256:abc...", 68 + "repository": "myapp", 69 + "tag": "latest", 70 + "userDid": "did:plc:abc123", 71 + "userHandle": "alice.bsky.social" 72 + }, 73 + "scan": { 74 + "scannedAt": "2026-02-27T16:00:00Z", 75 + "scannerVersion": "atcr-scanner-v1.0.0", 76 + "vulnerabilities": { 77 + "critical": 0, 78 + "high": 2, 79 + "medium": 5, 80 + "low": 12, 81 + "total": 19 82 + } 83 + }, 84 + "previous": null 85 + } 86 + ``` 87 + 88 + For `scan:changed`, the `previous` field contains the previous vulnerability counts. 89 + 90 + ## Billing 91 + 92 + | Tier | Max Webhooks | Available Triggers | 93 + |------|-------------|-------------------| 94 + | Free | 1 | `push`, `scan:first` | 95 + | Paid | Per plan | All triggers | 96 + | Captain | Unlimited | All triggers | 97 + 98 + Free users can enable both `push` and `scan:first` on their single webhook. 99 + 100 + ## Security 101 + 102 + - **HMAC-SHA256 signing:** If a secret is set, payloads include `X-Webhook-Signature-256: sha256=<hex>`. The signature covers the delivered payload (including platform-specific formatting for Discord/Slack). 103 + - **Retry:** 4 attempts with exponential backoff (0s, 30s, 2m, 8m). 104 + - **Test delivery:** The settings UI supports sending a test payload to verify connectivity. 105 + 106 + ## Implementation 107 + 108 + - Types: `pkg/appview/webhooks/types.go` 109 + - Dispatch + retry: `pkg/appview/webhooks/dispatch.go` 110 + - Discord/Slack formatting: `pkg/appview/webhooks/format.go` 111 + - UI handlers: `pkg/appview/handlers/webhooks.go` 112 + - Settings page SSR: `pkg/appview/handlers/settings.go` 113 + - Template: `pkg/appview/templates/partials/webhooks_list.html` 114 + - Trigger bitmask stored in `webhooks.triggers` column (integer) 115 + 116 + --- 117 + 118 + ## Future Events 119 + 120 + Inspired by [Harbor's webhook model](https://goharbor.io/docs/working-with-projects/project-configuration/configure-webhooks/). These are not yet implemented but document the intended direction. 121 + 122 + ### `pull` — Image Pull 123 + 124 + **Bitmask:** `0x10` (reserved) 125 + 126 + Fires when a manifest is pulled. This is tricky because pulls go through presigned S3 URLs — the appview issues a redirect and never sees the actual blob download. Manifest fetches *are* visible to the appview, so a pull event would fire on manifest GET, not blob download. 127 + 128 + **Scalability concern:** Public repos with high pull volume would generate excessive webhook traffic. Would need rate limiting or batching (e.g., "5 pulls in the last minute" digest). Not suitable for free tier without throttling. 129 + 130 + **Suggested payload:** 131 + 132 + ```json 133 + { 134 + "trigger": "pull", 135 + "pull_data": { 136 + "pulled_at": "2026-02-27T15:30:00Z", 137 + "puller": "bob.bsky.social", 138 + "puller_did": "did:plc:def456", 139 + "tag": "latest", 140 + "digest": "sha256:abc..." 141 + }, 142 + "repository": { 143 + "name": "myapp", 144 + "namespace": "alice.bsky.social", 145 + "repo_name": "alice.bsky.social/myapp", 146 + "repo_url": "https://buoy.cr/alice.bsky.social/myapp", 147 + "star_count": 42, 148 + "pull_count": 1338 149 + }, 150 + "hold": { 151 + "did": "did:web:hold01.atcr.io", 152 + "endpoint": "https://hold01.atcr.io" 153 + } 154 + } 155 + ``` 156 + 157 + Anonymous pulls would have empty `puller` / `puller_did` fields. 158 + 159 + ### `delete` — Manifest Delete 160 + 161 + **Bitmask:** `0x20` (reserved) 162 + 163 + Fires when a manifest is deleted from the user's PDS. Lower priority — deletes are uncommon. 164 + 165 + **Suggested payload:** 166 + 167 + ```json 168 + { 169 + "trigger": "delete", 170 + "delete_data": { 171 + "deleted_at": "2026-02-27T15:30:00Z", 172 + "deleted_by": "alice.bsky.social", 173 + "deleted_by_did": "did:plc:abc123", 174 + "tag": "v1.0.0", 175 + "digest": "sha256:abc..." 176 + }, 177 + "repository": { 178 + "name": "myapp", 179 + "namespace": "alice.bsky.social", 180 + "repo_name": "alice.bsky.social/myapp", 181 + "repo_url": "https://buoy.cr/alice.bsky.social/myapp", 182 + "star_count": 42, 183 + "pull_count": 1337 184 + } 185 + } 186 + ``` 187 + 188 + No `hold` field — deletion removes the manifest record from the PDS; blob cleanup is handled separately by GC. 189 + 190 + ### `quota:warning` / `quota:exceeded` — Storage Quota 191 + 192 + **Bitmask:** `0x40` (warning), `0x80` (exceeded) — reserved 193 + 194 + Fires when a hold's storage quota reaches a threshold or is exceeded. Open design questions: 195 + 196 + - **Thresholds:** Harbor uses a single warning threshold (85%). Options: fixed 80/90/100%, or configurable per hold. 197 + - **Recipient:** Who gets the webhook — the user who pushed (triggering the quota check), the hold captain, or both? Likely the captain, since they own the storage. 198 + - **Scope:** Per-user quotas (crew member limits) vs per-hold quotas (total storage). Both exist in the quota system. 199 + 200 + **Suggested payload:** 201 + 202 + ```json 203 + { 204 + "trigger": "quota:warning", 205 + "quota_data": { 206 + "timestamp": "2026-02-27T15:30:00Z", 207 + "usage_bytes": 8589934592, 208 + "limit_bytes": 10737418240, 209 + "usage_percent": 80, 210 + "threshold_percent": 80 211 + }, 212 + "hold": { 213 + "did": "did:web:hold01.atcr.io", 214 + "endpoint": "https://hold01.atcr.io" 215 + }, 216 + "user": { 217 + "did": "did:plc:abc123", 218 + "handle": "alice.bsky.social" 219 + } 220 + } 221 + ``` 222 + 223 + ### Events explicitly not planned 224 + 225 + - **Scan failed / scan stopped** — Server-side operational issues, not user-actionable. Belongs in ops monitoring (logs, alerting), not user-facing webhooks. 226 + - **Replication** — No replication feature in ATCR. 227 + - **Tag retention** — No retention policies yet.
+2 -7
pkg/appview/handlers/settings.go
··· 175 func (h *SettingsHandler) buildWebhooksData(userDID string) webhooksTemplateData { 176 data := webhooksTemplateData{ 177 ContainerID: "webhooks-content", 178 - TriggerInfo: []triggerInfo{ 179 - {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, 180 - {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, 181 - {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, 182 - }, 183 } 184 185 - maxWebhooks, allTriggers := h.getWebhookLimits(userDID) 186 - data.Limits = webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers} 187 188 webhookList, err := db.ListWebhooks(h.ReadOnlyDB, userDID) 189 if err != nil {
··· 175 func (h *SettingsHandler) buildWebhooksData(userDID string) webhooksTemplateData { 176 data := webhooksTemplateData{ 177 ContainerID: "webhooks-content", 178 + TriggerInfo: webhookTriggerInfo(), 179 } 180 181 + data.Limits = h.getWebhookLimits(userDID) 182 183 webhookList, err := db.ListWebhooks(h.ReadOnlyDB, userDID) 184 if err != nil {
+15 -3
pkg/appview/handlers/storage.go
··· 1 package handlers 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "log/slog" 7 "net/http" 8 9 "atcr.io/pkg/appview/middleware" 10 "atcr.io/pkg/appview/storage" ··· 33 return 34 } 35 36 // Use hold_did query param if provided (for previewing other holds), 37 // otherwise fall back to the user's saved default hold from their profile. 38 holdDID := r.URL.Query().Get("hold_did") 39 if holdDID == "" { 40 client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 41 - profile, err := storage.GetProfile(r.Context(), client) 42 if err != nil { 43 slog.Warn("Failed to get profile for storage quota", "did", user.DID, "error", err) 44 h.renderError(w, "Failed to load profile") ··· 52 } 53 54 // Resolve hold URL from DID 55 - holdURL, err := atproto.ResolveHoldURL(r.Context(), holdDID) 56 if err != nil { 57 slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID, "error", err) 58 h.renderError(w, "Failed to resolve hold service") ··· 61 62 // Call the hold's quota endpoint 63 quotaURL := fmt.Sprintf("%s%s?userDid=%s", holdURL, atproto.HoldGetQuota, user.DID) 64 - resp, err := http.Get(quotaURL) 65 if err != nil { 66 slog.Warn("Failed to fetch quota from hold", "did", user.DID, "holdURL", holdURL, "error", err) 67 h.renderError(w, "Failed to connect to hold service")
··· 1 package handlers 2 3 import ( 4 + "context" 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 "net/http" 9 + "time" 10 11 "atcr.io/pkg/appview/middleware" 12 "atcr.io/pkg/appview/storage" ··· 35 return 36 } 37 38 + // 5-second timeout for the entire operation (DID resolution + quota fetch) 39 + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) 40 + defer cancel() 41 + 42 // Use hold_did query param if provided (for previewing other holds), 43 // otherwise fall back to the user's saved default hold from their profile. 44 holdDID := r.URL.Query().Get("hold_did") 45 if holdDID == "" { 46 client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 47 + profile, err := storage.GetProfile(ctx, client) 48 if err != nil { 49 slog.Warn("Failed to get profile for storage quota", "did", user.DID, "error", err) 50 h.renderError(w, "Failed to load profile") ··· 58 } 59 60 // Resolve hold URL from DID 61 + holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) 62 if err != nil { 63 slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID, "error", err) 64 h.renderError(w, "Failed to resolve hold service") ··· 67 68 // Call the hold's quota endpoint 69 quotaURL := fmt.Sprintf("%s%s?userDid=%s", holdURL, atproto.HoldGetQuota, user.DID) 70 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, quotaURL, nil) 71 + if err != nil { 72 + slog.Warn("Failed to create quota request", "did", user.DID, "error", err) 73 + h.renderError(w, "Failed to connect to hold service") 74 + return 75 + } 76 + resp, err := http.DefaultClient.Do(req) 77 if err != nil { 78 slog.Warn("Failed to fetch quota from hold", "did", user.DID, "holdURL", holdURL, "error", err) 79 h.renderError(w, "Failed to connect to hold service")
+38 -20
pkg/appview/handlers/webhooks.go
··· 22 CreatedAt string 23 24 // Computed fields from bitmask 25 HasFirst bool 26 HasAll bool 27 HasChanged bool 28 } 29 30 type webhookLimits struct { 31 - Max int 32 - AllTriggers bool 33 } 34 35 // WebhooksHandler returns the webhooks list partial via HTMX ··· 52 } 53 54 // Get tier limits from billing manager 55 - maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) 56 57 - h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers}) 58 } 59 60 // AddWebhookHandler handles adding a new webhook via form POST ··· 84 85 // Parse trigger checkboxes 86 triggers := 0 87 if r.FormValue("trigger_first") == "on" { 88 triggers |= webhooks.TriggerFirst 89 } ··· 98 } 99 100 // Tier enforcement 101 - maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) 102 103 // Check webhook count limit 104 count, err := db.CountWebhooks(h.ReadOnlyDB, user.DID) ··· 106 h.renderWebhookError(w, "Failed to check webhook count") 107 return 108 } 109 - if maxWebhooks >= 0 && count >= maxWebhooks { 110 h.renderWebhookError(w, "Webhook limit reached") 111 return 112 } 113 114 - // Trigger bitmask enforcement: free users can only set TriggerFirst 115 - if !allTriggers && triggers & ^webhooks.TriggerFirst != 0 { 116 h.renderWebhookError(w, "Additional trigger types require a paid plan") 117 return 118 } ··· 207 // ---- Shared helpers ---- 208 209 // getWebhookLimits returns the webhook limits for a user based on their billing tier. 210 - func (h *BaseUIHandler) getWebhookLimits(userDID string) (maxWebhooks int, allTriggers bool) { 211 - if h.BillingManager != nil && h.BillingManager.Enabled() { 212 - return h.BillingManager.GetWebhookLimits(userDID) 213 } 214 - return 1, false 215 } 216 217 func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, user *db.User) { ··· 221 return 222 } 223 224 - maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) 225 - h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers}) 226 } 227 228 func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, dbWebhooks []db.Webhook, limits webhookLimits) { ··· 237 URL: wh.URL, 238 HasSecret: wh.HasSecret, 239 CreatedAt: wh.CreatedAt.Format(time.RFC3339), 240 HasFirst: wh.Triggers&webhooks.TriggerFirst != 0, 241 HasAll: wh.Triggers&webhooks.TriggerAll != 0, 242 HasChanged: wh.Triggers&webhooks.TriggerChanged != 0, ··· 252 Webhooks: entries, 253 Limits: limits, 254 ContainerID: "webhooks-content", 255 - TriggerInfo: []triggerInfo{ 256 - {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, 257 - {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, 258 - {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, 259 - }, 260 } 261 262 if err := h.Templates.ExecuteTemplate(w, "webhooks_list", templateData); err != nil { ··· 270 Bit int 271 Label string 272 Description string 273 - AlwaysAvailable bool 274 } 275 276 func (h *BaseUIHandler) renderWebhookError(w http.ResponseWriter, message string) {
··· 22 CreatedAt string 23 24 // Computed fields from bitmask 25 + HasPush bool 26 HasFirst bool 27 HasAll bool 28 HasChanged bool 29 } 30 31 type webhookLimits struct { 32 + Max int 33 + AllTriggers bool 34 + PaidTierName string // Name of the first tier that enables all triggers 35 } 36 37 // WebhooksHandler returns the webhooks list partial via HTMX ··· 54 } 55 56 // Get tier limits from billing manager 57 + limits := h.getWebhookLimits(user.DID) 58 59 + h.renderWebhookList(w, webhookList, limits) 60 } 61 62 // AddWebhookHandler handles adding a new webhook via form POST ··· 86 87 // Parse trigger checkboxes 88 triggers := 0 89 + if r.FormValue("trigger_push") == "on" { 90 + triggers |= webhooks.TriggerPush 91 + } 92 if r.FormValue("trigger_first") == "on" { 93 triggers |= webhooks.TriggerFirst 94 } ··· 103 } 104 105 // Tier enforcement 106 + limits := h.getWebhookLimits(user.DID) 107 108 // Check webhook count limit 109 count, err := db.CountWebhooks(h.ReadOnlyDB, user.DID) ··· 111 h.renderWebhookError(w, "Failed to check webhook count") 112 return 113 } 114 + if limits.Max >= 0 && count >= limits.Max { 115 h.renderWebhookError(w, "Webhook limit reached") 116 return 117 } 118 119 + // Trigger bitmask enforcement: free users can only set TriggerFirst and TriggerPush 120 + freeMask := webhooks.TriggerFirst | webhooks.TriggerPush 121 + if !limits.AllTriggers && triggers & ^freeMask != 0 { 122 h.renderWebhookError(w, "Additional trigger types require a paid plan") 123 return 124 } ··· 213 // ---- Shared helpers ---- 214 215 // getWebhookLimits returns the webhook limits for a user based on their billing tier. 216 + func (h *BaseUIHandler) getWebhookLimits(userDID string) webhookLimits { 217 + limits := webhookLimits{Max: 1} 218 + if h.BillingManager != nil { 219 + if h.BillingManager.Enabled() { 220 + limits.Max, limits.AllTriggers = h.BillingManager.GetWebhookLimits(userDID) 221 + } 222 + limits.PaidTierName = h.BillingManager.GetFirstTierWithAllTriggers() 223 } 224 + return limits 225 } 226 227 func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, user *db.User) { ··· 231 return 232 } 233 234 + limits := h.getWebhookLimits(user.DID) 235 + h.renderWebhookList(w, webhookList, limits) 236 } 237 238 func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, dbWebhooks []db.Webhook, limits webhookLimits) { ··· 247 URL: wh.URL, 248 HasSecret: wh.HasSecret, 249 CreatedAt: wh.CreatedAt.Format(time.RFC3339), 250 + HasPush: wh.Triggers&webhooks.TriggerPush != 0, 251 HasFirst: wh.Triggers&webhooks.TriggerFirst != 0, 252 HasAll: wh.Triggers&webhooks.TriggerAll != 0, 253 HasChanged: wh.Triggers&webhooks.TriggerChanged != 0, ··· 263 Webhooks: entries, 264 Limits: limits, 265 ContainerID: "webhooks-content", 266 + TriggerInfo: webhookTriggerInfo(), 267 } 268 269 if err := h.Templates.ExecuteTemplate(w, "webhooks_list", templateData); err != nil { ··· 277 Bit int 278 Label string 279 Description string 280 + AlwaysAvailable bool // Available to free-tier users 281 + DefaultChecked bool // Checked by default in the form 282 + } 283 + 284 + // webhookTriggerInfo returns the canonical list of webhook trigger types. 285 + func webhookTriggerInfo() []triggerInfo { 286 + return []triggerInfo{ 287 + {Name: "push", Bit: webhooks.TriggerPush, Label: "Image push", Description: "When an image is pushed to your repository", AlwaysAvailable: true}, 288 + {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, 289 + {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, 290 + {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, 291 + } 292 } 293 294 func (h *BaseUIHandler) renderWebhookError(w http.ResponseWriter, message string) {
+30 -20
pkg/appview/middleware/registry.go
··· 170 // These are set by main.go during startup and copied into NamespaceResolver instances. 171 // After initialization, request handling uses the NamespaceResolver's instance fields. 172 var ( 173 - globalRefresher *oauth.Refresher 174 - globalDatabase storage.HoldDIDLookup 175 - globalAuthorizer auth.HoldAuthorizer 176 ) 177 178 // SetGlobalRefresher sets the OAuth refresher instance during initialization ··· 191 // Must be called before the registry starts serving requests 192 func SetGlobalAuthorizer(authorizer auth.HoldAuthorizer) { 193 globalAuthorizer = authorizer 194 } 195 196 // GetGlobalAuthorizer returns the global authorizer instance ··· 209 // NamespaceResolver wraps a namespace and resolves names 210 type NamespaceResolver struct { 211 distribution.Namespace 212 - defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io") 213 - baseURL string // Base URL for error messages (e.g., "https://atcr.io") 214 - testMode bool // If true, fallback to default hold when user's hold is unreachable 215 - refresher *oauth.Refresher // OAuth session manager (copied from global on init) 216 - database storage.HoldDIDLookup // Database for hold DID lookups (copied from global on init) 217 - authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init) 218 - validationCache *validationCache // Request-level service token cache 219 - readmeFetcher *readme.Fetcher // README fetcher for repo pages 220 } 221 222 // initATProtoResolver initializes the name resolution middleware ··· 243 // Copy shared services from globals into the instance 244 // This avoids accessing globals during request handling 245 return &NamespaceResolver{ 246 - Namespace: ns, 247 - defaultHoldDID: defaultHoldDID, 248 - baseURL: baseURL, 249 - testMode: testMode, 250 - refresher: globalRefresher, 251 - database: globalDatabase, 252 - authorizer: globalAuthorizer, 253 - validationCache: newValidationCache(), 254 - readmeFetcher: readme.NewFetcher(), 255 }, nil 256 } 257 ··· 482 Authorizer: nr.authorizer, 483 Refresher: nr.refresher, 484 ReadmeFetcher: nr.readmeFetcher, 485 } 486 487 return storage.NewRoutingRepository(repo, registryCtx), nil
··· 170 // These are set by main.go during startup and copied into NamespaceResolver instances. 171 // After initialization, request handling uses the NamespaceResolver's instance fields. 172 var ( 173 + globalRefresher *oauth.Refresher 174 + globalDatabase storage.HoldDIDLookup 175 + globalAuthorizer auth.HoldAuthorizer 176 + globalWebhookDispatcher storage.PushWebhookDispatcher 177 ) 178 179 // SetGlobalRefresher sets the OAuth refresher instance during initialization ··· 192 // Must be called before the registry starts serving requests 193 func SetGlobalAuthorizer(authorizer auth.HoldAuthorizer) { 194 globalAuthorizer = authorizer 195 + } 196 + 197 + // SetGlobalWebhookDispatcher sets the push webhook dispatcher during initialization 198 + // Must be called before the registry starts serving requests 199 + func SetGlobalWebhookDispatcher(dispatcher storage.PushWebhookDispatcher) { 200 + globalWebhookDispatcher = dispatcher 201 } 202 203 // GetGlobalAuthorizer returns the global authorizer instance ··· 216 // NamespaceResolver wraps a namespace and resolves names 217 type NamespaceResolver struct { 218 distribution.Namespace 219 + defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io") 220 + baseURL string // Base URL for error messages (e.g., "https://atcr.io") 221 + testMode bool // If true, fallback to default hold when user's hold is unreachable 222 + refresher *oauth.Refresher // OAuth session manager (copied from global on init) 223 + database storage.HoldDIDLookup // Database for hold DID lookups (copied from global on init) 224 + authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init) 225 + webhookDispatcher storage.PushWebhookDispatcher // Push webhook dispatcher (copied from global on init) 226 + validationCache *validationCache // Request-level service token cache 227 + readmeFetcher *readme.Fetcher // README fetcher for repo pages 228 } 229 230 // initATProtoResolver initializes the name resolution middleware ··· 251 // Copy shared services from globals into the instance 252 // This avoids accessing globals during request handling 253 return &NamespaceResolver{ 254 + Namespace: ns, 255 + defaultHoldDID: defaultHoldDID, 256 + baseURL: baseURL, 257 + testMode: testMode, 258 + refresher: globalRefresher, 259 + database: globalDatabase, 260 + authorizer: globalAuthorizer, 261 + webhookDispatcher: globalWebhookDispatcher, 262 + validationCache: newValidationCache(), 263 + readmeFetcher: readme.NewFetcher(), 264 }, nil 265 } 266 ··· 491 Authorizer: nr.authorizer, 492 Refresher: nr.refresher, 493 ReadmeFetcher: nr.readmeFetcher, 494 + WebhookDispatcher: nr.webhookDispatcher, 495 } 496 497 return storage.NewRoutingRepository(repo, registryCtx), nil
+1
pkg/appview/server.go
··· 281 RegistryDomains: cfg.Server.RegistryDomains, 282 } 283 s.WebhookDispatcher = webhooks.NewDispatcher(s.Database, appviewMeta) 284 285 // Initialize Jetstream workers 286 s.initializeJetstream()
··· 281 RegistryDomains: cfg.Server.RegistryDomains, 282 } 283 s.WebhookDispatcher = webhooks.NewDispatcher(s.Database, appviewMeta) 284 + middleware.SetGlobalWebhookDispatcher(s.WebhookDispatcher) 285 286 // Initialize Jetstream workers 287 s.initializeJetstream()
+2 -27
pkg/appview/src/css/main.css
··· 280 /* ---------------------------------------- 281 TIER BADGE COLORS 282 ---------------------------------------- */ 283 - .badge-owner { 284 - @apply badge-primary; 285 - } 286 287 - .badge-deckhand { 288 - @apply badge-ghost; 289 - } 290 - 291 - .badge-bosun { 292 - @apply badge-secondary; 293 - } 294 - 295 - .badge-quartermaster { 296 - @apply badge-accent; 297 - } 298 - 299 - .supporter-badge-deckhand { 300 - @apply badge-ghost; 301 - } 302 - 303 - .supporter-badge-bosun { 304 - @apply badge-secondary; 305 - } 306 - 307 - .supporter-badge-quartermaster { 308 @apply badge-accent; 309 } 310 ··· 435 Unlayered — wins over DaisyUI's layered 436 .badge base class (utilities layer) 437 ======================================== */ 438 - .supporter-badge-deckhand { color: var(--color-base-content); } 439 - .supporter-badge-bosun { color: var(--color-secondary-content); } 440 - .supporter-badge-quartermaster { color: var(--color-accent-content); } 441 .supporter-badge-owner { color: var(--color-primary-content); }
··· 280 /* ---------------------------------------- 281 TIER BADGE COLORS 282 ---------------------------------------- */ 283 284 + .supporter-badge { 285 @apply badge-accent; 286 } 287 ··· 412 Unlayered — wins over DaisyUI's layered 413 .badge base class (utilities layer) 414 ======================================== */ 415 + .supporter-badge { color: var(--color-accent-content); } 416 .supporter-badge-owner { color: var(--color-primary-content); }
+27 -4
pkg/appview/storage/context.go
··· 1 package storage 2 3 import ( 4 "atcr.io/pkg/appview/readme" 5 "atcr.io/pkg/atproto" 6 "atcr.io/pkg/auth" 7 "atcr.io/pkg/auth/oauth" 8 ) 9 10 // HoldDIDLookup interface for querying and updating hold DIDs in manifests 11 type HoldDIDLookup interface { 12 GetLatestHoldDIDForRepo(did, repository string) (string, error) ··· 32 PullerPDSEndpoint string // Puller's PDS endpoint URL 33 34 // Shared services (same for all requests) 35 - Database HoldDIDLookup // Database for hold DID lookups 36 - Authorizer auth.HoldAuthorizer // Hold access authorization 37 - Refresher *oauth.Refresher // OAuth session manager 38 - ReadmeFetcher *readme.Fetcher // README fetcher for repo pages 39 }
··· 1 package storage 2 3 import ( 4 + "context" 5 + 6 "atcr.io/pkg/appview/readme" 7 "atcr.io/pkg/atproto" 8 "atcr.io/pkg/auth" 9 "atcr.io/pkg/auth/oauth" 10 ) 11 12 + // PushWebhookDispatcher dispatches push event webhooks. 13 + // Defined here (in storage) to avoid import cycles with the webhooks package. 14 + type PushWebhookDispatcher interface { 15 + DispatchForPush(ctx context.Context, event PushWebhookEvent) 16 + } 17 + 18 + // PushWebhookEvent contains the data needed to dispatch a push webhook. 19 + type PushWebhookEvent struct { 20 + OwnerDID string 21 + OwnerHandle string 22 + PusherDID string 23 + PusherHandle string 24 + Repository string 25 + Tag string 26 + Digest string 27 + MediaType string 28 + HoldDID string 29 + HoldEndpoint string 30 + } 31 + 32 // HoldDIDLookup interface for querying and updating hold DIDs in manifests 33 type HoldDIDLookup interface { 34 GetLatestHoldDIDForRepo(did, repository string) (string, error) ··· 54 PullerPDSEndpoint string // Puller's PDS endpoint URL 55 56 // Shared services (same for all requests) 57 + Database HoldDIDLookup // Database for hold DID lookups 58 + Authorizer auth.HoldAuthorizer // Hold access authorization 59 + Refresher *oauth.Refresher // OAuth session manager 60 + ReadmeFetcher *readme.Fetcher // README fetcher for repo pages 61 + WebhookDispatcher PushWebhookDispatcher // Push webhook dispatcher (nil if not configured) 62 }
+34
pkg/appview/storage/manifest_store.go
··· 241 }() 242 } 243 244 // Create or update repo page asynchronously if manifest has relevant annotations 245 // This ensures repository metadata is synced to user's PDS 246 go func() {
··· 241 }() 242 } 243 244 + // Dispatch push webhooks asynchronously 245 + if s.ctx.WebhookDispatcher != nil { 246 + pusherDID := s.ctx.PullerDID 247 + pusherHandle := s.ctx.Handle // Default to owner handle 248 + if pusherDID == "" { 249 + pusherDID = s.ctx.DID 250 + } 251 + if pusherDID != s.ctx.DID { 252 + // Crew push: resolve the pusher's handle 253 + if _, resolvedHandle, _, resolveErr := atproto.ResolveIdentity(ctx, pusherDID); resolveErr == nil { 254 + pusherHandle = resolvedHandle 255 + } 256 + } 257 + go func() { 258 + defer func() { 259 + if r := recover(); r != nil { 260 + slog.Error("Panic in push webhook dispatch", "panic", r) 261 + } 262 + }() 263 + s.ctx.WebhookDispatcher.DispatchForPush(context.Background(), PushWebhookEvent{ 264 + OwnerDID: s.ctx.DID, 265 + OwnerHandle: s.ctx.Handle, 266 + PusherDID: pusherDID, 267 + PusherHandle: pusherHandle, 268 + Repository: s.ctx.Repository, 269 + Tag: tag, 270 + Digest: dgst.String(), 271 + MediaType: mediaType, 272 + HoldDID: s.ctx.HoldDID, 273 + HoldEndpoint: s.ctx.HoldURL, 274 + }) 275 + }() 276 + } 277 + 278 // Create or update repo page asynchronously if manifest has relevant annotations 279 // This ensures repository metadata is synced to user's PDS 280 go func() {
+5 -7
pkg/appview/templates/pages/settings.html
··· 52 <div class="flex-1 min-w-0"> 53 54 <!-- STORAGE TAB --> 55 - <div id="tab-storage" class="settings-panel space-y-4"> 56 <!-- Available Plans --> 57 {{ template "subscription_plans" .Subscription }} 58 ··· 146 <div id="tab-webhooks" class="settings-panel hidden space-y-6"> 147 <section class="card bg-base-100 shadow-sm p-6 space-y-4"> 148 <div> 149 - <h2 class="text-xl font-semibold">Scan Webhooks</h2> 150 - <p class="text-base-content/70 mt-1">Get notified when vulnerability scans complete on any of your images.</p> 151 </div> 152 <div id="webhooks-content"> 153 {{ template "webhooks_list" .WebhooksData }} ··· 284 }); 285 }); 286 287 - // Activate initial tab (use requestAnimationFrame to ensure HTMX has initialized) 288 - requestAnimationFrame(function() { 289 - switchSettingsTab(hash); 290 - }); 291 }); 292 293 // Handle browser back/forward
··· 52 <div class="flex-1 min-w-0"> 53 54 <!-- STORAGE TAB --> 55 + <div id="tab-storage" class="settings-panel hidden space-y-4"> 56 <!-- Available Plans --> 57 {{ template "subscription_plans" .Subscription }} 58 ··· 146 <div id="tab-webhooks" class="settings-panel hidden space-y-6"> 147 <section class="card bg-base-100 shadow-sm p-6 space-y-4"> 148 <div> 149 + <h2 class="text-xl font-semibold">Webhooks</h2> 150 + <p class="text-base-content/70 mt-1">Get notified when images are pushed or vulnerability scans complete.</p> 151 </div> 152 <div id="webhooks-content"> 153 {{ template "webhooks_list" .WebhooksData }} ··· 284 }); 285 }); 286 287 + // Activate initial tab 288 + switchSettingsTab(hash); 289 }); 290 291 // Handle browser back/forward
+4 -2
pkg/appview/templates/pages/user.html
··· 33 {{ end }} 34 <div class="flex items-center gap-2"> 35 <h1 class="text-2xl font-bold">{{ .ViewedUser.Handle }}</h1> 36 - {{ if .SupporterBadge }} 37 - <span class="badge badge-sm supporter-badge-{{ .SupporterBadge }}">{{ .SupporterBadge }}</span> 38 {{ end }} 39 </div> 40 </div>
··· 33 {{ end }} 34 <div class="flex items-center gap-2"> 35 <h1 class="text-2xl font-bold">{{ .ViewedUser.Handle }}</h1> 36 + {{ if or (eq .SupporterBadge "Captain") (eq .SupporterBadge "owner") }} 37 + <span class="badge badge-sm supporter-badge-owner">{{ .SupporterBadge }}</span> 38 + {{ else if .SupporterBadge }} 39 + <span class="badge badge-sm supporter-badge">{{ .SupporterBadge }}</span> 40 {{ end }} 41 </div> 42 </div>
+1 -1
pkg/appview/templates/partials/hold_card.html
··· 23 <div class="px-4 pb-4"> 24 <div id="storage-stats-active" 25 hx-get="/api/storage?hold_did={{ .DID | urlquery }}" 26 - hx-trigger="tab:storage from:body once" 27 hx-swap="innerHTML"> 28 <p class="flex items-center gap-2 text-sm text-base-content/50">{{ icon "loader-2" "size-4 animate-spin" }} Loading storage...</p> 29 </div>
··· 23 <div class="px-4 pb-4"> 24 <div id="storage-stats-active" 25 hx-get="/api/storage?hold_did={{ .DID | urlquery }}" 26 + hx-trigger="load, tab:storage from:body once" 27 hx-swap="innerHTML"> 28 <p class="flex items-center gap-2 text-sm text-base-content/50">{{ icon "loader-2" "size-4 animate-spin" }} Loading storage...</p> 29 </div>
+1 -1
pkg/appview/templates/partials/other_holds_table.html
··· 32 <td class="text-right"> 33 <span id="storage-compact-{{ sanitizeID .DID }}" 34 hx-get="/api/storage?hold_did={{ .DID | urlquery }}&compact=true" 35 - hx-trigger="tab:storage from:body once" 36 hx-swap="innerHTML" 37 class="text-sm font-mono"> 38 ...
··· 32 <td class="text-right"> 33 <span id="storage-compact-{{ sanitizeID .DID }}" 34 hx-get="/api/storage?hold_did={{ .DID | urlquery }}&compact=true" 35 + hx-trigger="load, tab:storage from:body once" 36 hx-swap="innerHTML" 37 class="text-sm font-mono"> 38 ...
+6 -3
pkg/appview/templates/partials/webhooks_list.html
··· 29 <div class="space-y-2 mt-1"> 30 {{ range .TriggerInfo }} 31 <label class="flex items-start gap-3{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50 cursor-not-allowed{{ else }} cursor-pointer{{ end }}"> 32 - <input type="checkbox" name="trigger_{{ if eq .Name "scan:first" }}first{{ else if eq .Name "scan:all" }}all{{ else }}changed{{ end }}" 33 class="checkbox checkbox-sm mt-0.5" 34 - {{ if .AlwaysAvailable }}checked{{ end }} 35 {{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }}disabled{{ end }}> 36 <span> 37 <span class="text-sm font-medium">{{ .Label }}</span> 38 - {{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }}<span class="badge badge-xs badge-outline ml-1">Paid</span>{{ end }} 39 <br><span class="text-xs text-base-content/60">{{ .Description }}</span> 40 </span> 41 </label> ··· 63 <div class="min-w-0 flex-1"> 64 <code class="text-sm break-all">{{ .URL }}</code> 65 <div class="flex flex-wrap gap-1 mt-2"> 66 {{ if .HasFirst }} 67 <span class="badge badge-sm badge-primary">scan:first</span> 68 {{ end }}
··· 29 <div class="space-y-2 mt-1"> 30 {{ range .TriggerInfo }} 31 <label class="flex items-start gap-3{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50 cursor-not-allowed{{ else }} cursor-pointer{{ end }}"> 32 + <input type="checkbox" name="trigger_{{ if eq .Name "push" }}push{{ else if eq .Name "scan:first" }}first{{ else if eq .Name "scan:all" }}all{{ else }}changed{{ end }}" 33 class="checkbox checkbox-sm mt-0.5" 34 + {{ if .DefaultChecked }}checked{{ end }} 35 {{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }}disabled{{ end }}> 36 <span> 37 <span class="text-sm font-medium">{{ .Label }}</span> 38 + {{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }}<span class="badge badge-xs badge-outline ml-1">{{ if $.Limits.PaidTierName }}{{ $.Limits.PaidTierName }}{{ else }}Paid{{ end }}</span>{{ end }} 39 <br><span class="text-xs text-base-content/60">{{ .Description }}</span> 40 </span> 41 </label> ··· 63 <div class="min-w-0 flex-1"> 64 <code class="text-sm break-all">{{ .URL }}</code> 65 <div class="flex flex-wrap gap-1 mt-2"> 66 + {{ if .HasPush }} 67 + <span class="badge badge-sm badge-info">push</span> 68 + {{ end }} 69 {{ if .HasFirst }} 70 <span class="badge badge-sm badge-primary">scan:first</span> 71 {{ end }}
+65 -13
pkg/appview/webhooks/dispatch.go
··· 15 "time" 16 17 "atcr.io/pkg/appview/db" 18 "atcr.io/pkg/atproto" 19 ) 20 21 - // Dispatcher handles webhook delivery for scan notifications. 22 // It reads webhooks from the appview DB and delivers payloads 23 // with Discord/Slack formatting and HMAC signing. 24 type Dispatcher struct { ··· 109 } 110 } 111 112 // DeliverTest sends a test payload to a specific webhook (synchronous, single attempt) 113 func (d *Dispatcher) DeliverTest(ctx context.Context, webhookID, userDID, userHandle string) (bool, error) { 114 wh, err := db.GetWebhookByID(d.db, webhookID) ··· 171 // Reformat payload for platform-specific webhook APIs 172 sendPayload := payload 173 if isDiscordWebhook(webhookURL) || isSlackWebhook(webhookURL) { 174 - var p WebhookPayload 175 - if err := json.Unmarshal(payload, &p); err == nil { 176 - var formatted []byte 177 - var fmtErr error 178 - if isDiscordWebhook(webhookURL) { 179 - formatted, fmtErr = formatDiscordPayload(p, d.meta) 180 - } else { 181 - formatted, fmtErr = formatSlackPayload(p, d.meta) 182 - } 183 - if fmtErr == nil { 184 - sendPayload = formatted 185 - } 186 } 187 } 188
··· 15 "time" 16 17 "atcr.io/pkg/appview/db" 18 + "atcr.io/pkg/appview/storage" 19 "atcr.io/pkg/atproto" 20 ) 21 22 + // Dispatcher handles webhook delivery for push and scan notifications. 23 // It reads webhooks from the appview DB and delivers payloads 24 // with Discord/Slack formatting and HMAC signing. 25 type Dispatcher struct { ··· 110 } 111 } 112 113 + // DispatchForPush fires matching webhooks after a manifest is pushed. 114 + func (d *Dispatcher) DispatchForPush(ctx context.Context, event storage.PushWebhookEvent) { 115 + webhooks, err := db.GetWebhooksForUser(d.db, event.OwnerDID) 116 + if err != nil || len(webhooks) == 0 { 117 + return 118 + } 119 + 120 + // Fetch star/pull counts for payload enrichment 121 + var starCount, pullCount int 122 + stats, err := db.GetRepositoryStats(d.db, event.OwnerDID, event.Repository) 123 + if err == nil && stats != nil { 124 + starCount = stats.StarCount 125 + pullCount = stats.PullCount 126 + } 127 + 128 + // Build repo URL using the primary registry domain (pull domain) if available 129 + baseURL := d.meta.BaseURL 130 + if len(d.meta.RegistryDomains) > 0 { 131 + baseURL = "https://" + d.meta.RegistryDomains[0] 132 + } 133 + repoURL := fmt.Sprintf("%s/%s/%s", baseURL, event.OwnerHandle, event.Repository) 134 + 135 + payload := PushWebhookPayload{ 136 + Trigger: "push", 137 + PushData: PushData{ 138 + PushedAt: time.Now().Format(time.RFC3339), 139 + Pusher: event.PusherHandle, 140 + PusherDID: event.PusherDID, 141 + Tag: event.Tag, 142 + Digest: event.Digest, 143 + }, 144 + Repository: PushRepository{ 145 + Name: event.Repository, 146 + Namespace: event.OwnerHandle, 147 + RepoName: event.OwnerHandle + "/" + event.Repository, 148 + RepoURL: repoURL, 149 + MediaType: event.MediaType, 150 + StarCount: starCount, 151 + PullCount: pullCount, 152 + }, 153 + Hold: PushHold{ 154 + DID: event.HoldDID, 155 + Endpoint: event.HoldEndpoint, 156 + }, 157 + } 158 + 159 + payloadBytes, err := json.Marshal(payload) 160 + if err != nil { 161 + slog.Error("Failed to marshal push webhook payload", "error", err) 162 + return 163 + } 164 + 165 + for _, wh := range webhooks { 166 + if wh.Triggers&TriggerPush == 0 { 167 + continue 168 + } 169 + go d.deliverWithRetry(wh.URL, wh.Secret, payloadBytes) 170 + } 171 + } 172 + 173 // DeliverTest sends a test payload to a specific webhook (synchronous, single attempt) 174 func (d *Dispatcher) DeliverTest(ctx context.Context, webhookID, userDID, userHandle string) (bool, error) { 175 wh, err := db.GetWebhookByID(d.db, webhookID) ··· 232 // Reformat payload for platform-specific webhook APIs 233 sendPayload := payload 234 if isDiscordWebhook(webhookURL) || isSlackWebhook(webhookURL) { 235 + formatted, fmtErr := formatPlatformPayload(payload, webhookURL, d.meta) 236 + if fmtErr == nil { 237 + sendPayload = formatted 238 } 239 } 240
+112
pkg/appview/webhooks/format.go
··· 92 return strings.Join(lines, "\n") 93 } 94 95 // formatDiscordPayload wraps an ATCR webhook payload in Discord's embed format 96 func formatDiscordPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 97 appviewURL := meta.BaseURL
··· 92 return strings.Join(lines, "\n") 93 } 94 95 + // formatPlatformPayload detects the payload type and formats for Discord or Slack 96 + func formatPlatformPayload(payload []byte, webhookURL string, meta atproto.AppviewMetadata) ([]byte, error) { 97 + // Detect push vs scan payload by checking for push_data key 98 + var probe struct { 99 + PushData json.RawMessage `json:"push_data"` 100 + } 101 + if err := json.Unmarshal(payload, &probe); err != nil { 102 + return nil, err 103 + } 104 + 105 + if probe.PushData != nil { 106 + var p PushWebhookPayload 107 + if err := json.Unmarshal(payload, &p); err != nil { 108 + return nil, err 109 + } 110 + if isDiscordWebhook(webhookURL) { 111 + return formatDiscordPushPayload(p, meta) 112 + } 113 + return formatSlackPushPayload(p, meta) 114 + } 115 + 116 + var p WebhookPayload 117 + if err := json.Unmarshal(payload, &p); err != nil { 118 + return nil, err 119 + } 120 + if isDiscordWebhook(webhookURL) { 121 + return formatDiscordPayload(p, meta) 122 + } 123 + return formatSlackPayload(p, meta) 124 + } 125 + 126 + // formatDiscordPushPayload wraps a push webhook payload in Discord's embed format 127 + func formatDiscordPushPayload(p PushWebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 128 + appviewURL := meta.BaseURL 129 + 130 + title := p.Repository.Name 131 + if p.PushData.Tag != "" { 132 + title += ":" + p.PushData.Tag 133 + } 134 + 135 + digest := p.PushData.Digest 136 + if len(digest) > 19 { 137 + digest = digest[:19] + "..." 138 + } 139 + description := fmt.Sprintf("Pushed by **%s**\nDigest: `%s`", p.PushData.Pusher, digest) 140 + 141 + embed := map[string]any{ 142 + "title": title, 143 + "url": p.Repository.RepoURL, 144 + "description": description, 145 + "color": 0x5865F2, // blurple 146 + "footer": map[string]string{ 147 + "text": meta.ClientShortName, 148 + "icon_url": meta.FaviconURL, 149 + }, 150 + "timestamp": p.PushData.PushedAt, 151 + } 152 + 153 + embed["author"] = map[string]string{ 154 + "name": p.PushData.Pusher, 155 + "url": appviewURL + "/u/" + p.PushData.Pusher, 156 + } 157 + embed["image"] = map[string]string{ 158 + "url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Repository.Namespace, p.Repository.Name), 159 + } 160 + 161 + payload := map[string]any{ 162 + "username": meta.ClientShortName, 163 + "avatar_url": meta.FaviconURL, 164 + "embeds": []any{embed}, 165 + } 166 + return json.Marshal(payload) 167 + } 168 + 169 + // formatSlackPushPayload wraps a push webhook payload in Slack's message format 170 + func formatSlackPushPayload(p PushWebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 171 + appviewURL := meta.BaseURL 172 + 173 + title := p.Repository.Name 174 + if p.PushData.Tag != "" { 175 + title += ":" + p.PushData.Tag 176 + } 177 + 178 + fallback := fmt.Sprintf("%s pushed %s", p.PushData.Pusher, title) 179 + 180 + digest := p.PushData.Digest 181 + if len(digest) > 19 { 182 + digest = digest[:19] + "..." 183 + } 184 + description := fmt.Sprintf("Pushed by *%s*\nDigest: `%s`", p.PushData.Pusher, digest) 185 + 186 + attachment := map[string]any{ 187 + "fallback": fallback, 188 + "color": "#5865F2", 189 + "title": title, 190 + "title_link": p.Repository.RepoURL, 191 + "text": description, 192 + "footer": meta.ClientShortName, 193 + "footer_icon": meta.FaviconURL, 194 + "ts": p.PushData.PushedAt, 195 + "author_name": p.PushData.Pusher, 196 + "author_link": appviewURL + "/u/" + p.PushData.Pusher, 197 + "image_url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Repository.Namespace, p.Repository.Name), 198 + } 199 + 200 + payload := map[string]any{ 201 + "text": fallback, 202 + "attachments": []any{attachment}, 203 + } 204 + return json.Marshal(payload) 205 + } 206 + 207 // formatDiscordPayload wraps an ATCR webhook payload in Discord's embed format 208 func formatDiscordPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) { 209 appviewURL := meta.BaseURL
+36 -1
pkg/appview/webhooks/types.go
··· 1 - // Package webhooks provides webhook dispatch and formatting for scan notifications. 2 package webhooks 3 4 // Webhook trigger bitmask constants ··· 6 TriggerFirst = 0x01 // First-time scan (no previous scan record) 7 TriggerAll = 0x02 // Every scan completion 8 TriggerChanged = 0x04 // Vulnerability counts changed from previous 9 ) 10 11 // WebhookPayload is the JSON body sent to webhook URLs ··· 42 Low int `json:"low"` 43 Total int `json:"total"` 44 }
··· 1 + // Package webhooks provides webhook dispatch and formatting for push and scan notifications. 2 package webhooks 3 4 // Webhook trigger bitmask constants ··· 6 TriggerFirst = 0x01 // First-time scan (no previous scan record) 7 TriggerAll = 0x02 // Every scan completion 8 TriggerChanged = 0x04 // Vulnerability counts changed from previous 9 + TriggerPush = 0x08 // Image push (manifest stored) 10 ) 11 12 // WebhookPayload is the JSON body sent to webhook URLs ··· 43 Low int `json:"low"` 44 Total int `json:"total"` 45 } 46 + 47 + // PushWebhookPayload is the JSON body sent for push events (Docker Hub-inspired format) 48 + type PushWebhookPayload struct { 49 + Trigger string `json:"trigger"` 50 + PushData PushData `json:"push_data"` 51 + Repository PushRepository `json:"repository"` 52 + Hold PushHold `json:"hold"` 53 + } 54 + 55 + // PushData describes the push event 56 + type PushData struct { 57 + PushedAt string `json:"pushed_at"` 58 + Pusher string `json:"pusher"` 59 + PusherDID string `json:"pusher_did"` 60 + Tag string `json:"tag,omitempty"` 61 + Digest string `json:"digest"` 62 + } 63 + 64 + // PushRepository describes the repository that was pushed to 65 + type PushRepository struct { 66 + Name string `json:"name"` 67 + Namespace string `json:"namespace"` 68 + RepoName string `json:"repo_name"` 69 + RepoURL string `json:"repo_url"` 70 + MediaType string `json:"media_type"` 71 + StarCount int `json:"star_count"` 72 + PullCount int `json:"pull_count"` 73 + } 74 + 75 + // PushHold describes the hold service where blobs are stored 76 + type PushHold struct { 77 + DID string `json:"did"` 78 + Endpoint string `json:"endpoint"` 79 + }
+14
pkg/billing/billing.go
··· 704 return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp]) 705 } 706 707 // fetchPrice returns the unit amount in cents for a Stripe price ID, using a cache. 708 func (m *Manager) fetchPrice(priceID string) (int64, error) { 709 m.priceCacheMu.RLock()
··· 704 return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp]) 705 } 706 707 + // GetFirstTierWithAllTriggers returns the name of the lowest-rank tier that has 708 + // webhook_all_triggers enabled. Returns empty string if none found. 709 + func (m *Manager) GetFirstTierWithAllTriggers() string { 710 + if !m.Enabled() { 711 + return "" 712 + } 713 + for _, tier := range m.cfg.Tiers { 714 + if tier.WebhookAllTriggers { 715 + return tier.Name 716 + } 717 + } 718 + return "" 719 + } 720 + 721 // fetchPrice returns the unit amount in cents for a Stripe price ID, using a cache. 722 func (m *Manager) fetchPrice(priceID string) (int64, error) { 723 m.priceCacheMu.RLock()
+5
pkg/billing/billing_stub.go
··· 65 return "" 66 } 67 68 // RegisterRoutes is a no-op when billing is not compiled in. 69 func (m *Manager) RegisterRoutes(_ chi.Router) {} 70
··· 65 return "" 66 } 67 68 + // GetFirstTierWithAllTriggers returns empty string when billing is not compiled in. 69 + func (m *Manager) GetFirstTierWithAllTriggers() string { 70 + return "" 71 + } 72 + 73 // RegisterRoutes is a no-op when billing is not compiled in. 74 func (m *Manager) RegisterRoutes(_ chi.Router) {} 75