From 7d74e76772488e77219c809371cc7eb993607819 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sat, 28 Feb 2026 14:42:35 -0600 Subject: [PATCH] more billing/settings/webhook tweaks --- config-appview.example.yaml | 8 +- docker-compose.yml | 8 +- docs/WEBHOOKS.md | 227 ++++++++++++++++++ pkg/appview/handlers/settings.go | 9 +- pkg/appview/handlers/storage.go | 18 +- pkg/appview/handlers/webhooks.go | 58 +++-- pkg/appview/middleware/registry.go | 50 ++-- pkg/appview/server.go | 1 + pkg/appview/src/css/main.css | 29 +-- pkg/appview/storage/context.go | 31 ++- pkg/appview/storage/manifest_store.go | 34 +++ pkg/appview/templates/pages/settings.html | 12 +- pkg/appview/templates/pages/user.html | 6 +- pkg/appview/templates/partials/hold_card.html | 2 +- .../templates/partials/other_holds_table.html | 2 +- .../templates/partials/webhooks_list.html | 9 +- pkg/appview/webhooks/dispatch.go | 78 +++++- pkg/appview/webhooks/format.go | 112 +++++++++ pkg/appview/webhooks/types.go | 37 ++- pkg/billing/billing.go | 14 ++ pkg/billing/billing_stub.go | 5 + 21 files changed, 633 insertions(+), 117 deletions(-) create mode 100644 docs/WEBHOOKS.md diff --git a/config-appview.example.yaml b/config-appview.example.yaml index 229fdde..6ab814e 100644 --- a/config-appview.example.yaml +++ b/config-appview.example.yaml @@ -120,7 +120,7 @@ billing: webhook_all_triggers: false supporter_badge: false - # Tier name. Position in list determines rank (0-based). - name: deckhand + name: Supporter # Short description shown on the plan card. description: Get started with basic storage # List of features included in this tier. @@ -128,7 +128,7 @@ billing: # Stripe price ID for monthly billing. Empty = free tier. stripe_price_monthly: "" # Stripe price ID for yearly billing. - stripe_price_yearly: "" + stripe_price_yearly: "price_1SmK1mRROAC4bYmSwhTQ7RY9" # Maximum webhooks for this tier (-1 = unlimited). max_webhooks: 1 # Allow all webhook trigger types (not just first-scan). @@ -141,9 +141,9 @@ billing: # List of features included in this tier. features: [] # Stripe price ID for monthly billing. Empty = free tier. - stripe_price_monthly: "" + stripe_price_monthly: "price_1SmK4QRROAC4bYmSxpr35HUl" # Stripe price ID for yearly billing. - stripe_price_yearly: "" + stripe_price_yearly: "price_1SmJuLRROAC4bYmSUgVCwZWo" # Maximum webhooks for this tier (-1 = unlimited). max_webhooks: 10 # Allow all webhook trigger types (not just first-scan). diff --git a/docker-compose.yml b/docker-compose.yml index 998e086..c6a2c8e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: container_name: atcr-appview ports: - "5000:5000" + env_file: + - ../atcr-secrets.env # Optional: Load from .env.appview file (create from .env.appview.example) # env_file: # - .env.appview @@ -15,15 +17,12 @@ services: environment: # ATCR_SERVER_CLIENT_NAME: "Seamark" # ATCR_SERVER_CLIENT_SHORT_NAME: "Seamark" + ATCR_SERVER_MANAGED_HOLDS: did:web:172.28.0.3%3A8080 ATCR_SERVER_DEFAULT_HOLD_DID: did:web:172.28.0.3%3A8080 ATCR_SERVER_TEST_MODE: true ATCR_LOG_LEVEL: debug LOG_SHIPPER_BACKEND: victoria LOG_SHIPPER_URL: http://172.28.0.10:9428 - # Stripe billing (only used with -tags billing) - STRIPE_SECRET_KEY: sk_test_ - STRIPE_PUBLISHABLE_KEY: pk_test_ - STRIPE_WEBHOOK_SECRET: whsec_ # Limit local Docker logs - real logs go to Victoria Logs # Local logs just for live tailing (docker logs -f) logging: @@ -56,6 +55,7 @@ services: # Base config: config-hold.example.yaml (passed via Air entrypoint) # Env vars below override config file values for local dev environment: + HOLD_SERVER_APPVIEW_DID: did:web:172.28.0.2%3A5000 HOLD_SCANNER_SECRET: dev-secret HOLD_SERVER_PUBLIC_URL: http://172.28.0.3:8080 HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md new file mode 100644 index 0000000..a89f107 --- /dev/null +++ b/docs/WEBHOOKS.md @@ -0,0 +1,227 @@ +# Webhooks + +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. + +## Current Events + +### `push` — Image Push + +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. + +**Bitmask:** `0x08` — Free tier + +```json +{ + "trigger": "push", + "push_data": { + "pushed_at": "2026-02-27T15:30:00Z", + "pusher": "alice.bsky.social", + "pusher_did": "did:plc:abc123", + "tag": "latest", + "digest": "sha256:abc..." + }, + "repository": { + "name": "myapp", + "namespace": "alice.bsky.social", + "repo_name": "alice.bsky.social/myapp", + "repo_url": "https://buoy.cr/alice.bsky.social/myapp", + "media_type": "application/vnd.oci.image.manifest.v1+json", + "star_count": 42, + "pull_count": 1337 + }, + "hold": { + "did": "did:web:hold01.atcr.io", + "endpoint": "https://hold01.atcr.io" + } +} +``` + +`repo_url` uses `registry_domains[0]` (the pull domain) when configured, otherwise falls back to `base_url`. + +### `scan:first` — First Scan + +Fires the first time an image is scanned (no previous scan record exists). + +**Bitmask:** `0x01` — Free tier + +### `scan:all` — Every Scan + +Fires on every scan completion. + +**Bitmask:** `0x02` — Paid tier + +### `scan:changed` — Vulnerability Change + +Fires when vulnerability counts change from the previous scan. Includes a `previous` field with the old counts. + +**Bitmask:** `0x04` — Paid tier + +**Scan payload format** (shared by all scan triggers): + +```json +{ + "trigger": "scan:first", + "holdDid": "did:web:hold01.atcr.io", + "holdEndpoint": "https://hold01.atcr.io", + "manifest": { + "digest": "sha256:abc...", + "repository": "myapp", + "tag": "latest", + "userDid": "did:plc:abc123", + "userHandle": "alice.bsky.social" + }, + "scan": { + "scannedAt": "2026-02-27T16:00:00Z", + "scannerVersion": "atcr-scanner-v1.0.0", + "vulnerabilities": { + "critical": 0, + "high": 2, + "medium": 5, + "low": 12, + "total": 19 + } + }, + "previous": null +} +``` + +For `scan:changed`, the `previous` field contains the previous vulnerability counts. + +## Billing + +| Tier | Max Webhooks | Available Triggers | +|------|-------------|-------------------| +| Free | 1 | `push`, `scan:first` | +| Paid | Per plan | All triggers | +| Captain | Unlimited | All triggers | + +Free users can enable both `push` and `scan:first` on their single webhook. + +## Security + +- **HMAC-SHA256 signing:** If a secret is set, payloads include `X-Webhook-Signature-256: sha256=`. The signature covers the delivered payload (including platform-specific formatting for Discord/Slack). +- **Retry:** 4 attempts with exponential backoff (0s, 30s, 2m, 8m). +- **Test delivery:** The settings UI supports sending a test payload to verify connectivity. + +## Implementation + +- Types: `pkg/appview/webhooks/types.go` +- Dispatch + retry: `pkg/appview/webhooks/dispatch.go` +- Discord/Slack formatting: `pkg/appview/webhooks/format.go` +- UI handlers: `pkg/appview/handlers/webhooks.go` +- Settings page SSR: `pkg/appview/handlers/settings.go` +- Template: `pkg/appview/templates/partials/webhooks_list.html` +- Trigger bitmask stored in `webhooks.triggers` column (integer) + +--- + +## Future Events + +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. + +### `pull` — Image Pull + +**Bitmask:** `0x10` (reserved) + +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. + +**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. + +**Suggested payload:** + +```json +{ + "trigger": "pull", + "pull_data": { + "pulled_at": "2026-02-27T15:30:00Z", + "puller": "bob.bsky.social", + "puller_did": "did:plc:def456", + "tag": "latest", + "digest": "sha256:abc..." + }, + "repository": { + "name": "myapp", + "namespace": "alice.bsky.social", + "repo_name": "alice.bsky.social/myapp", + "repo_url": "https://buoy.cr/alice.bsky.social/myapp", + "star_count": 42, + "pull_count": 1338 + }, + "hold": { + "did": "did:web:hold01.atcr.io", + "endpoint": "https://hold01.atcr.io" + } +} +``` + +Anonymous pulls would have empty `puller` / `puller_did` fields. + +### `delete` — Manifest Delete + +**Bitmask:** `0x20` (reserved) + +Fires when a manifest is deleted from the user's PDS. Lower priority — deletes are uncommon. + +**Suggested payload:** + +```json +{ + "trigger": "delete", + "delete_data": { + "deleted_at": "2026-02-27T15:30:00Z", + "deleted_by": "alice.bsky.social", + "deleted_by_did": "did:plc:abc123", + "tag": "v1.0.0", + "digest": "sha256:abc..." + }, + "repository": { + "name": "myapp", + "namespace": "alice.bsky.social", + "repo_name": "alice.bsky.social/myapp", + "repo_url": "https://buoy.cr/alice.bsky.social/myapp", + "star_count": 42, + "pull_count": 1337 + } +} +``` + +No `hold` field — deletion removes the manifest record from the PDS; blob cleanup is handled separately by GC. + +### `quota:warning` / `quota:exceeded` — Storage Quota + +**Bitmask:** `0x40` (warning), `0x80` (exceeded) — reserved + +Fires when a hold's storage quota reaches a threshold or is exceeded. Open design questions: + +- **Thresholds:** Harbor uses a single warning threshold (85%). Options: fixed 80/90/100%, or configurable per hold. +- **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. +- **Scope:** Per-user quotas (crew member limits) vs per-hold quotas (total storage). Both exist in the quota system. + +**Suggested payload:** + +```json +{ + "trigger": "quota:warning", + "quota_data": { + "timestamp": "2026-02-27T15:30:00Z", + "usage_bytes": 8589934592, + "limit_bytes": 10737418240, + "usage_percent": 80, + "threshold_percent": 80 + }, + "hold": { + "did": "did:web:hold01.atcr.io", + "endpoint": "https://hold01.atcr.io" + }, + "user": { + "did": "did:plc:abc123", + "handle": "alice.bsky.social" + } +} +``` + +### Events explicitly not planned + +- **Scan failed / scan stopped** — Server-side operational issues, not user-actionable. Belongs in ops monitoring (logs, alerting), not user-facing webhooks. +- **Replication** — No replication feature in ATCR. +- **Tag retention** — No retention policies yet. diff --git a/pkg/appview/handlers/settings.go b/pkg/appview/handlers/settings.go index 5e2e4ae..7830400 100644 --- a/pkg/appview/handlers/settings.go +++ b/pkg/appview/handlers/settings.go @@ -175,15 +175,10 @@ type webhooksTemplateData struct { func (h *SettingsHandler) buildWebhooksData(userDID string) webhooksTemplateData { data := webhooksTemplateData{ ContainerID: "webhooks-content", - TriggerInfo: []triggerInfo{ - {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, - {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, - {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, - }, + TriggerInfo: webhookTriggerInfo(), } - maxWebhooks, allTriggers := h.getWebhookLimits(userDID) - data.Limits = webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers} + data.Limits = h.getWebhookLimits(userDID) webhookList, err := db.ListWebhooks(h.ReadOnlyDB, userDID) if err != nil { diff --git a/pkg/appview/handlers/storage.go b/pkg/appview/handlers/storage.go index bc6fef8..5b11d9d 100644 --- a/pkg/appview/handlers/storage.go +++ b/pkg/appview/handlers/storage.go @@ -1,10 +1,12 @@ package handlers import ( + "context" "encoding/json" "fmt" "log/slog" "net/http" + "time" "atcr.io/pkg/appview/middleware" "atcr.io/pkg/appview/storage" @@ -33,12 +35,16 @@ func (h *StorageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // 5-second timeout for the entire operation (DID resolution + quota fetch) + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + // Use hold_did query param if provided (for previewing other holds), // otherwise fall back to the user's saved default hold from their profile. holdDID := r.URL.Query().Get("hold_did") if holdDID == "" { client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) - profile, err := storage.GetProfile(r.Context(), client) + profile, err := storage.GetProfile(ctx, client) if err != nil { slog.Warn("Failed to get profile for storage quota", "did", user.DID, "error", err) h.renderError(w, "Failed to load profile") @@ -52,7 +58,7 @@ func (h *StorageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Resolve hold URL from DID - holdURL, err := atproto.ResolveHoldURL(r.Context(), holdDID) + holdURL, err := atproto.ResolveHoldURL(ctx, holdDID) if err != nil { slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID, "error", err) h.renderError(w, "Failed to resolve hold service") @@ -61,7 +67,13 @@ func (h *StorageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Call the hold's quota endpoint quotaURL := fmt.Sprintf("%s%s?userDid=%s", holdURL, atproto.HoldGetQuota, user.DID) - resp, err := http.Get(quotaURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, quotaURL, nil) + if err != nil { + slog.Warn("Failed to create quota request", "did", user.DID, "error", err) + h.renderError(w, "Failed to connect to hold service") + return + } + resp, err := http.DefaultClient.Do(req) if err != nil { slog.Warn("Failed to fetch quota from hold", "did", user.DID, "holdURL", holdURL, "error", err) h.renderError(w, "Failed to connect to hold service") diff --git a/pkg/appview/handlers/webhooks.go b/pkg/appview/handlers/webhooks.go index afb95c3..eaf379f 100644 --- a/pkg/appview/handlers/webhooks.go +++ b/pkg/appview/handlers/webhooks.go @@ -22,14 +22,16 @@ type webhookEntry struct { CreatedAt string // Computed fields from bitmask + HasPush bool HasFirst bool HasAll bool HasChanged bool } type webhookLimits struct { - Max int - AllTriggers bool + Max int + AllTriggers bool + PaidTierName string // Name of the first tier that enables all triggers } // WebhooksHandler returns the webhooks list partial via HTMX @@ -52,9 +54,9 @@ func (h *WebhooksHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Get tier limits from billing manager - maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) + limits := h.getWebhookLimits(user.DID) - h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers}) + h.renderWebhookList(w, webhookList, limits) } // AddWebhookHandler handles adding a new webhook via form POST @@ -84,6 +86,9 @@ func (h *AddWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Parse trigger checkboxes triggers := 0 + if r.FormValue("trigger_push") == "on" { + triggers |= webhooks.TriggerPush + } if r.FormValue("trigger_first") == "on" { triggers |= webhooks.TriggerFirst } @@ -98,7 +103,7 @@ func (h *AddWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Tier enforcement - maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) + limits := h.getWebhookLimits(user.DID) // Check webhook count limit count, err := db.CountWebhooks(h.ReadOnlyDB, user.DID) @@ -106,13 +111,14 @@ func (h *AddWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.renderWebhookError(w, "Failed to check webhook count") return } - if maxWebhooks >= 0 && count >= maxWebhooks { + if limits.Max >= 0 && count >= limits.Max { h.renderWebhookError(w, "Webhook limit reached") return } - // Trigger bitmask enforcement: free users can only set TriggerFirst - if !allTriggers && triggers & ^webhooks.TriggerFirst != 0 { + // Trigger bitmask enforcement: free users can only set TriggerFirst and TriggerPush + freeMask := webhooks.TriggerFirst | webhooks.TriggerPush + if !limits.AllTriggers && triggers & ^freeMask != 0 { h.renderWebhookError(w, "Additional trigger types require a paid plan") return } @@ -207,11 +213,15 @@ func (h *TestWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // ---- Shared helpers ---- // getWebhookLimits returns the webhook limits for a user based on their billing tier. -func (h *BaseUIHandler) getWebhookLimits(userDID string) (maxWebhooks int, allTriggers bool) { - if h.BillingManager != nil && h.BillingManager.Enabled() { - return h.BillingManager.GetWebhookLimits(userDID) +func (h *BaseUIHandler) getWebhookLimits(userDID string) webhookLimits { + limits := webhookLimits{Max: 1} + if h.BillingManager != nil { + if h.BillingManager.Enabled() { + limits.Max, limits.AllTriggers = h.BillingManager.GetWebhookLimits(userDID) + } + limits.PaidTierName = h.BillingManager.GetFirstTierWithAllTriggers() } - return 1, false + return limits } func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, user *db.User) { @@ -221,8 +231,8 @@ func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, user *db.User) { return } - maxWebhooks, allTriggers := h.getWebhookLimits(user.DID) - h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers}) + limits := h.getWebhookLimits(user.DID) + h.renderWebhookList(w, webhookList, limits) } func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, dbWebhooks []db.Webhook, limits webhookLimits) { @@ -237,6 +247,7 @@ func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, dbWebhooks []db URL: wh.URL, HasSecret: wh.HasSecret, CreatedAt: wh.CreatedAt.Format(time.RFC3339), + HasPush: wh.Triggers&webhooks.TriggerPush != 0, HasFirst: wh.Triggers&webhooks.TriggerFirst != 0, HasAll: wh.Triggers&webhooks.TriggerAll != 0, HasChanged: wh.Triggers&webhooks.TriggerChanged != 0, @@ -252,11 +263,7 @@ func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, dbWebhooks []db Webhooks: entries, Limits: limits, ContainerID: "webhooks-content", - TriggerInfo: []triggerInfo{ - {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, - {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, - {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, - }, + TriggerInfo: webhookTriggerInfo(), } if err := h.Templates.ExecuteTemplate(w, "webhooks_list", templateData); err != nil { @@ -270,7 +277,18 @@ type triggerInfo struct { Bit int Label string Description string - AlwaysAvailable bool + AlwaysAvailable bool // Available to free-tier users + DefaultChecked bool // Checked by default in the form +} + +// webhookTriggerInfo returns the canonical list of webhook trigger types. +func webhookTriggerInfo() []triggerInfo { + return []triggerInfo{ + {Name: "push", Bit: webhooks.TriggerPush, Label: "Image push", Description: "When an image is pushed to your repository", AlwaysAvailable: true}, + {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, + {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, + {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, + } } func (h *BaseUIHandler) renderWebhookError(w http.ResponseWriter, message string) { diff --git a/pkg/appview/middleware/registry.go b/pkg/appview/middleware/registry.go index 115404f..e02c089 100644 --- a/pkg/appview/middleware/registry.go +++ b/pkg/appview/middleware/registry.go @@ -170,9 +170,10 @@ func (vc *validationCache) getOrFetch(ctx context.Context, cacheKey string, fetc // These are set by main.go during startup and copied into NamespaceResolver instances. // After initialization, request handling uses the NamespaceResolver's instance fields. var ( - globalRefresher *oauth.Refresher - globalDatabase storage.HoldDIDLookup - globalAuthorizer auth.HoldAuthorizer + globalRefresher *oauth.Refresher + globalDatabase storage.HoldDIDLookup + globalAuthorizer auth.HoldAuthorizer + globalWebhookDispatcher storage.PushWebhookDispatcher ) // SetGlobalRefresher sets the OAuth refresher instance during initialization @@ -193,6 +194,12 @@ func SetGlobalAuthorizer(authorizer auth.HoldAuthorizer) { globalAuthorizer = authorizer } +// SetGlobalWebhookDispatcher sets the push webhook dispatcher during initialization +// Must be called before the registry starts serving requests +func SetGlobalWebhookDispatcher(dispatcher storage.PushWebhookDispatcher) { + globalWebhookDispatcher = dispatcher +} + // GetGlobalAuthorizer returns the global authorizer instance // Used by components that need to clear denial cache (e.g., EnsureCrewMembership) func GetGlobalAuthorizer() auth.HoldAuthorizer { @@ -209,14 +216,15 @@ func init() { // NamespaceResolver wraps a namespace and resolves names type NamespaceResolver struct { distribution.Namespace - defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io") - baseURL string // Base URL for error messages (e.g., "https://atcr.io") - testMode bool // If true, fallback to default hold when user's hold is unreachable - refresher *oauth.Refresher // OAuth session manager (copied from global on init) - database storage.HoldDIDLookup // Database for hold DID lookups (copied from global on init) - authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init) - validationCache *validationCache // Request-level service token cache - readmeFetcher *readme.Fetcher // README fetcher for repo pages + defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io") + baseURL string // Base URL for error messages (e.g., "https://atcr.io") + testMode bool // If true, fallback to default hold when user's hold is unreachable + refresher *oauth.Refresher // OAuth session manager (copied from global on init) + database storage.HoldDIDLookup // Database for hold DID lookups (copied from global on init) + authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init) + webhookDispatcher storage.PushWebhookDispatcher // Push webhook dispatcher (copied from global on init) + validationCache *validationCache // Request-level service token cache + readmeFetcher *readme.Fetcher // README fetcher for repo pages } // initATProtoResolver initializes the name resolution middleware @@ -243,15 +251,16 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive // Copy shared services from globals into the instance // This avoids accessing globals during request handling return &NamespaceResolver{ - Namespace: ns, - defaultHoldDID: defaultHoldDID, - baseURL: baseURL, - testMode: testMode, - refresher: globalRefresher, - database: globalDatabase, - authorizer: globalAuthorizer, - validationCache: newValidationCache(), - readmeFetcher: readme.NewFetcher(), + Namespace: ns, + defaultHoldDID: defaultHoldDID, + baseURL: baseURL, + testMode: testMode, + refresher: globalRefresher, + database: globalDatabase, + authorizer: globalAuthorizer, + webhookDispatcher: globalWebhookDispatcher, + validationCache: newValidationCache(), + readmeFetcher: readme.NewFetcher(), }, nil } @@ -482,6 +491,7 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name Authorizer: nr.authorizer, Refresher: nr.refresher, ReadmeFetcher: nr.readmeFetcher, + WebhookDispatcher: nr.webhookDispatcher, } return storage.NewRoutingRepository(repo, registryCtx), nil diff --git a/pkg/appview/server.go b/pkg/appview/server.go index 54eafba..a19df95 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -281,6 +281,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, RegistryDomains: cfg.Server.RegistryDomains, } s.WebhookDispatcher = webhooks.NewDispatcher(s.Database, appviewMeta) + middleware.SetGlobalWebhookDispatcher(s.WebhookDispatcher) // Initialize Jetstream workers s.initializeJetstream() diff --git a/pkg/appview/src/css/main.css b/pkg/appview/src/css/main.css index 6a58200..41213d8 100644 --- a/pkg/appview/src/css/main.css +++ b/pkg/appview/src/css/main.css @@ -280,31 +280,8 @@ /* ---------------------------------------- TIER BADGE COLORS ---------------------------------------- */ - .badge-owner { - @apply badge-primary; - } - .badge-deckhand { - @apply badge-ghost; - } - - .badge-bosun { - @apply badge-secondary; - } - - .badge-quartermaster { - @apply badge-accent; - } - - .supporter-badge-deckhand { - @apply badge-ghost; - } - - .supporter-badge-bosun { - @apply badge-secondary; - } - - .supporter-badge-quartermaster { + .supporter-badge { @apply badge-accent; } @@ -435,7 +412,5 @@ Unlayered — wins over DaisyUI's layered .badge base class (utilities layer) ======================================== */ -.supporter-badge-deckhand { color: var(--color-base-content); } -.supporter-badge-bosun { color: var(--color-secondary-content); } -.supporter-badge-quartermaster { color: var(--color-accent-content); } +.supporter-badge { color: var(--color-accent-content); } .supporter-badge-owner { color: var(--color-primary-content); } diff --git a/pkg/appview/storage/context.go b/pkg/appview/storage/context.go index 7df74f8..1c7d9fd 100644 --- a/pkg/appview/storage/context.go +++ b/pkg/appview/storage/context.go @@ -1,12 +1,34 @@ package storage import ( + "context" + "atcr.io/pkg/appview/readme" "atcr.io/pkg/atproto" "atcr.io/pkg/auth" "atcr.io/pkg/auth/oauth" ) +// PushWebhookDispatcher dispatches push event webhooks. +// Defined here (in storage) to avoid import cycles with the webhooks package. +type PushWebhookDispatcher interface { + DispatchForPush(ctx context.Context, event PushWebhookEvent) +} + +// PushWebhookEvent contains the data needed to dispatch a push webhook. +type PushWebhookEvent struct { + OwnerDID string + OwnerHandle string + PusherDID string + PusherHandle string + Repository string + Tag string + Digest string + MediaType string + HoldDID string + HoldEndpoint string +} + // HoldDIDLookup interface for querying and updating hold DIDs in manifests type HoldDIDLookup interface { GetLatestHoldDIDForRepo(did, repository string) (string, error) @@ -32,8 +54,9 @@ type RegistryContext struct { PullerPDSEndpoint string // Puller's PDS endpoint URL // Shared services (same for all requests) - Database HoldDIDLookup // Database for hold DID lookups - Authorizer auth.HoldAuthorizer // Hold access authorization - Refresher *oauth.Refresher // OAuth session manager - ReadmeFetcher *readme.Fetcher // README fetcher for repo pages + Database HoldDIDLookup // Database for hold DID lookups + Authorizer auth.HoldAuthorizer // Hold access authorization + Refresher *oauth.Refresher // OAuth session manager + ReadmeFetcher *readme.Fetcher // README fetcher for repo pages + WebhookDispatcher PushWebhookDispatcher // Push webhook dispatcher (nil if not configured) } diff --git a/pkg/appview/storage/manifest_store.go b/pkg/appview/storage/manifest_store.go index 6400c4a..05394d7 100644 --- a/pkg/appview/storage/manifest_store.go +++ b/pkg/appview/storage/manifest_store.go @@ -241,6 +241,40 @@ func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest, }() } + // Dispatch push webhooks asynchronously + if s.ctx.WebhookDispatcher != nil { + pusherDID := s.ctx.PullerDID + pusherHandle := s.ctx.Handle // Default to owner handle + if pusherDID == "" { + pusherDID = s.ctx.DID + } + if pusherDID != s.ctx.DID { + // Crew push: resolve the pusher's handle + if _, resolvedHandle, _, resolveErr := atproto.ResolveIdentity(ctx, pusherDID); resolveErr == nil { + pusherHandle = resolvedHandle + } + } + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("Panic in push webhook dispatch", "panic", r) + } + }() + s.ctx.WebhookDispatcher.DispatchForPush(context.Background(), PushWebhookEvent{ + OwnerDID: s.ctx.DID, + OwnerHandle: s.ctx.Handle, + PusherDID: pusherDID, + PusherHandle: pusherHandle, + Repository: s.ctx.Repository, + Tag: tag, + Digest: dgst.String(), + MediaType: mediaType, + HoldDID: s.ctx.HoldDID, + HoldEndpoint: s.ctx.HoldURL, + }) + }() + } + // Create or update repo page asynchronously if manifest has relevant annotations // This ensures repository metadata is synced to user's PDS go func() { diff --git a/pkg/appview/templates/pages/settings.html b/pkg/appview/templates/pages/settings.html index 3f6a4da..ec02eb1 100644 --- a/pkg/appview/templates/pages/settings.html +++ b/pkg/appview/templates/pages/settings.html @@ -52,7 +52,7 @@
-
+