···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"
0010 # 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"
018 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:
059 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
000026 # 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
···1package handlers
23import (
04 "encoding/json"
5 "fmt"
6 "log/slog"
7 "net/http"
089 "atcr.io/pkg/appview/middleware"
10 "atcr.io/pkg/appview/storage"
···33 return
34 }
35000036 // 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 }
5354 // 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")
···6162 // 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)
00000065 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")
···1package handlers
23import (
4+ "context"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "net/http"
9+ "time"
1011 "atcr.io/pkg/appview/middleware"
12 "atcr.io/pkg/appview/storage"
···35 return
36 }
3738+ // 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 }
5960 // 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")
···6768 // 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
2324 // Computed fields from bitmask
025 HasFirst bool
26 HasAll bool
27 HasChanged bool
28}
2930type webhookLimits struct {
31- Max int
32- AllTriggers bool
033}
3435// WebhooksHandler returns the webhooks list partial via HTMX
···52 }
5354 // Get tier limits from billing manager
55- maxWebhooks, allTriggers := h.getWebhookLimits(user.DID)
5657- h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers})
58}
5960// AddWebhookHandler handles adding a new webhook via form POST
···8485 // Parse trigger checkboxes
86 triggers := 0
00087 if r.FormValue("trigger_first") == "on" {
88 triggers |= webhooks.TriggerFirst
89 }
···98 }
99100 // Tier enforcement
101- maxWebhooks, allTriggers := h.getWebhookLimits(user.DID)
102103 // 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 }
113114- // Trigger bitmask enforcement: free users can only set TriggerFirst
115- if !allTriggers && triggers & ^webhooks.TriggerFirst != 0 {
0116 h.renderWebhookError(w, "Additional trigger types require a paid plan")
117 return
118 }
···207// ---- Shared helpers ----
208209// 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)
0000213 }
214- return 1, false
215}
216217func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, user *db.User) {
···221 return
222 }
223224- maxWebhooks, allTriggers := h.getWebhookLimits(user.DID)
225- h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers})
226}
227228func (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),
0240 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 }
261262 if err := h.Templates.ExecuteTemplate(w, "webhooks_list", templateData); err != nil {
···270 Bit int
271 Label string
272 Description string
273- AlwaysAvailable bool
00000000000274}
275276func (h *BaseUIHandler) renderWebhookError(w http.ResponseWriter, message string) {
···22 CreatedAt string
2324 // Computed fields from bitmask
25+ HasPush bool
26 HasFirst bool
27 HasAll bool
28 HasChanged bool
29}
3031type webhookLimits struct {
32+ Max int
33+ AllTriggers bool
34+ PaidTierName string // Name of the first tier that enables all triggers
35}
3637// WebhooksHandler returns the webhooks list partial via HTMX
···54 }
5556 // Get tier limits from billing manager
57+ limits := h.getWebhookLimits(user.DID)
5859+ h.renderWebhookList(w, webhookList, limits)
60}
6162// AddWebhookHandler handles adding a new webhook via form POST
···8687 // 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 }
104105 // Tier enforcement
106+ limits := h.getWebhookLimits(user.DID)
107108 // 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 }
118119+ // 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 ----
214215// 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}
226227func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, user *db.User) {
···231 return
232 }
233234+ limits := h.getWebhookLimits(user.DID)
235+ h.renderWebhookList(w, webhookList, limits)
236}
237238func (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(),
0000267 }
268269 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}
293294func (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.
172var (
173- globalRefresher *oauth.Refresher
174- globalDatabase storage.HoldDIDLookup
175- globalAuthorizer auth.HoldAuthorizer
0176)
177178// SetGlobalRefresher sets the OAuth refresher instance during initialization
···191// Must be called before the registry starts serving requests
192func SetGlobalAuthorizer(authorizer auth.HoldAuthorizer) {
193 globalAuthorizer = authorizer
000000194}
195196// GetGlobalAuthorizer returns the global authorizer instance
···209// NamespaceResolver wraps a namespace and resolves names
210type 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
0220}
221222// 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(),
0255 }, nil
256}
257···482 Authorizer: nr.authorizer,
483 Refresher: nr.refresher,
484 ReadmeFetcher: nr.readmeFetcher,
0485 }
486487 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.
172var (
173+ globalRefresher *oauth.Refresher
174+ globalDatabase storage.HoldDIDLookup
175+ globalAuthorizer auth.HoldAuthorizer
176+ globalWebhookDispatcher storage.PushWebhookDispatcher
177)
178179// SetGlobalRefresher sets the OAuth refresher instance during initialization
···192// Must be called before the registry starts serving requests
193func 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}
202203// GetGlobalAuthorizer returns the global authorizer instance
···216// NamespaceResolver wraps a namespace and resolves names
217type 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}
229230// 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 }
496497 return storage.NewRoutingRepository(repo, registryCtx), nil
···1package storage
23import (
004 "atcr.io/pkg/appview/readme"
5 "atcr.io/pkg/atproto"
6 "atcr.io/pkg/auth"
7 "atcr.io/pkg/auth/oauth"
8)
90000000000000000000010// HoldDIDLookup interface for querying and updating hold DIDs in manifests
11type HoldDIDLookup interface {
12 GetLatestHoldDIDForRepo(did, repository string) (string, error)
···32 PullerPDSEndpoint string // Puller's PDS endpoint URL
3334 // 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
039}
···1package storage
23import (
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)
1112+// 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
33type HoldDIDLookup interface {
34 GetLatestHoldDIDForRepo(did, repository string) (string, error)
···54 PullerPDSEndpoint string // Puller's PDS endpoint URL
5556 // 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 }
2430000000000000000000000000000000000244 // 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 }
243244+ // 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() {
···1-// Package webhooks provides webhook dispatch and formatting for scan notifications.
2package webhooks
34// 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
09)
1011// WebhookPayload is the JSON body sent to webhook URLs
···42 Low int `json:"low"`
43 Total int `json:"total"`
44}
0000000000000000000000000000000000
···1+// Package webhooks provides webhook dispatch and formatting for push and scan notifications.
2package webhooks
34// 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)
1112// 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}
70600000000000000707// fetchPrice returns the unit amount in cents for a Stripe price ID, using a cache.
708func (m *Manager) fetchPrice(priceID string) (int64, error) {
709 m.priceCacheMu.RLock()
···704 return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp])
705}
706707+// 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.
722func (m *Manager) fetchPrice(priceID string) (int64, error) {
723 m.priceCacheMu.RLock()
+5
pkg/billing/billing_stub.go
···65 return ""
66}
670000068// RegisterRoutes is a no-op when billing is not compiled in.
69func (m *Manager) RegisterRoutes(_ chi.Router) {}
70
···65 return ""
66}
6768+// 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.
74func (m *Manager) RegisterRoutes(_ chi.Router) {}
75