mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 17:24:16 +00:00
more billing/settings/webhook tweaks
This commit is contained in:
@@ -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).
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
@@ -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=<hex>`. 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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<div class="flex-1 min-w-0">
|
||||
|
||||
<!-- STORAGE TAB -->
|
||||
<div id="tab-storage" class="settings-panel space-y-4">
|
||||
<div id="tab-storage" class="settings-panel hidden space-y-4">
|
||||
<!-- Available Plans -->
|
||||
{{ template "subscription_plans" .Subscription }}
|
||||
|
||||
@@ -146,8 +146,8 @@
|
||||
<div id="tab-webhooks" class="settings-panel hidden space-y-6">
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Scan Webhooks</h2>
|
||||
<p class="text-base-content/70 mt-1">Get notified when vulnerability scans complete on any of your images.</p>
|
||||
<h2 class="text-xl font-semibold">Webhooks</h2>
|
||||
<p class="text-base-content/70 mt-1">Get notified when images are pushed or vulnerability scans complete.</p>
|
||||
</div>
|
||||
<div id="webhooks-content">
|
||||
{{ template "webhooks_list" .WebhooksData }}
|
||||
@@ -284,10 +284,8 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Activate initial tab (use requestAnimationFrame to ensure HTMX has initialized)
|
||||
requestAnimationFrame(function() {
|
||||
switchSettingsTab(hash);
|
||||
});
|
||||
// Activate initial tab
|
||||
switchSettingsTab(hash);
|
||||
});
|
||||
|
||||
// Handle browser back/forward
|
||||
|
||||
@@ -33,8 +33,10 @@
|
||||
{{ end }}
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl font-bold">{{ .ViewedUser.Handle }}</h1>
|
||||
{{ if .SupporterBadge }}
|
||||
<span class="badge badge-sm supporter-badge-{{ .SupporterBadge }}">{{ .SupporterBadge }}</span>
|
||||
{{ if or (eq .SupporterBadge "Captain") (eq .SupporterBadge "owner") }}
|
||||
<span class="badge badge-sm supporter-badge-owner">{{ .SupporterBadge }}</span>
|
||||
{{ else if .SupporterBadge }}
|
||||
<span class="badge badge-sm supporter-badge">{{ .SupporterBadge }}</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="px-4 pb-4">
|
||||
<div id="storage-stats-active"
|
||||
hx-get="/api/storage?hold_did={{ .DID | urlquery }}"
|
||||
hx-trigger="tab:storage from:body once"
|
||||
hx-trigger="load, tab:storage from:body once"
|
||||
hx-swap="innerHTML">
|
||||
<p class="flex items-center gap-2 text-sm text-base-content/50">{{ icon "loader-2" "size-4 animate-spin" }} Loading storage...</p>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<td class="text-right">
|
||||
<span id="storage-compact-{{ sanitizeID .DID }}"
|
||||
hx-get="/api/storage?hold_did={{ .DID | urlquery }}&compact=true"
|
||||
hx-trigger="tab:storage from:body once"
|
||||
hx-trigger="load, tab:storage from:body once"
|
||||
hx-swap="innerHTML"
|
||||
class="text-sm font-mono">
|
||||
...
|
||||
|
||||
@@ -29,13 +29,13 @@
|
||||
<div class="space-y-2 mt-1">
|
||||
{{ range .TriggerInfo }}
|
||||
<label class="flex items-start gap-3{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50 cursor-not-allowed{{ else }} cursor-pointer{{ end }}">
|
||||
<input type="checkbox" name="trigger_{{ if eq .Name "scan:first" }}first{{ else if eq .Name "scan:all" }}all{{ else }}changed{{ end }}"
|
||||
<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 }}"
|
||||
class="checkbox checkbox-sm mt-0.5"
|
||||
{{ if .AlwaysAvailable }}checked{{ end }}
|
||||
{{ if .DefaultChecked }}checked{{ end }}
|
||||
{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }}disabled{{ end }}>
|
||||
<span>
|
||||
<span class="text-sm font-medium">{{ .Label }}</span>
|
||||
{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }}<span class="badge badge-xs badge-outline ml-1">Paid</span>{{ end }}
|
||||
{{ 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 }}
|
||||
<br><span class="text-xs text-base-content/60">{{ .Description }}</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -63,6 +63,9 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<code class="text-sm break-all">{{ .URL }}</code>
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
{{ if .HasPush }}
|
||||
<span class="badge badge-sm badge-info">push</span>
|
||||
{{ end }}
|
||||
{{ if .HasFirst }}
|
||||
<span class="badge badge-sm badge-primary">scan:first</span>
|
||||
{{ end }}
|
||||
|
||||
@@ -15,10 +15,11 @@ import (
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/storage"
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// Dispatcher handles webhook delivery for scan notifications.
|
||||
// Dispatcher handles webhook delivery for push and scan notifications.
|
||||
// It reads webhooks from the appview DB and delivers payloads
|
||||
// with Discord/Slack formatting and HMAC signing.
|
||||
type Dispatcher struct {
|
||||
@@ -109,6 +110,66 @@ func (d *Dispatcher) DispatchForScan(ctx context.Context, scan, previousScan *db
|
||||
}
|
||||
}
|
||||
|
||||
// DispatchForPush fires matching webhooks after a manifest is pushed.
|
||||
func (d *Dispatcher) DispatchForPush(ctx context.Context, event storage.PushWebhookEvent) {
|
||||
webhooks, err := db.GetWebhooksForUser(d.db, event.OwnerDID)
|
||||
if err != nil || len(webhooks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch star/pull counts for payload enrichment
|
||||
var starCount, pullCount int
|
||||
stats, err := db.GetRepositoryStats(d.db, event.OwnerDID, event.Repository)
|
||||
if err == nil && stats != nil {
|
||||
starCount = stats.StarCount
|
||||
pullCount = stats.PullCount
|
||||
}
|
||||
|
||||
// Build repo URL using the primary registry domain (pull domain) if available
|
||||
baseURL := d.meta.BaseURL
|
||||
if len(d.meta.RegistryDomains) > 0 {
|
||||
baseURL = "https://" + d.meta.RegistryDomains[0]
|
||||
}
|
||||
repoURL := fmt.Sprintf("%s/%s/%s", baseURL, event.OwnerHandle, event.Repository)
|
||||
|
||||
payload := PushWebhookPayload{
|
||||
Trigger: "push",
|
||||
PushData: PushData{
|
||||
PushedAt: time.Now().Format(time.RFC3339),
|
||||
Pusher: event.PusherHandle,
|
||||
PusherDID: event.PusherDID,
|
||||
Tag: event.Tag,
|
||||
Digest: event.Digest,
|
||||
},
|
||||
Repository: PushRepository{
|
||||
Name: event.Repository,
|
||||
Namespace: event.OwnerHandle,
|
||||
RepoName: event.OwnerHandle + "/" + event.Repository,
|
||||
RepoURL: repoURL,
|
||||
MediaType: event.MediaType,
|
||||
StarCount: starCount,
|
||||
PullCount: pullCount,
|
||||
},
|
||||
Hold: PushHold{
|
||||
DID: event.HoldDID,
|
||||
Endpoint: event.HoldEndpoint,
|
||||
},
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal push webhook payload", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, wh := range webhooks {
|
||||
if wh.Triggers&TriggerPush == 0 {
|
||||
continue
|
||||
}
|
||||
go d.deliverWithRetry(wh.URL, wh.Secret, payloadBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// DeliverTest sends a test payload to a specific webhook (synchronous, single attempt)
|
||||
func (d *Dispatcher) DeliverTest(ctx context.Context, webhookID, userDID, userHandle string) (bool, error) {
|
||||
wh, err := db.GetWebhookByID(d.db, webhookID)
|
||||
@@ -171,18 +232,9 @@ func (d *Dispatcher) attemptDelivery(webhookURL, secret string, payload []byte)
|
||||
// Reformat payload for platform-specific webhook APIs
|
||||
sendPayload := payload
|
||||
if isDiscordWebhook(webhookURL) || isSlackWebhook(webhookURL) {
|
||||
var p WebhookPayload
|
||||
if err := json.Unmarshal(payload, &p); err == nil {
|
||||
var formatted []byte
|
||||
var fmtErr error
|
||||
if isDiscordWebhook(webhookURL) {
|
||||
formatted, fmtErr = formatDiscordPayload(p, d.meta)
|
||||
} else {
|
||||
formatted, fmtErr = formatSlackPayload(p, d.meta)
|
||||
}
|
||||
if fmtErr == nil {
|
||||
sendPayload = formatted
|
||||
}
|
||||
formatted, fmtErr := formatPlatformPayload(payload, webhookURL, d.meta)
|
||||
if fmtErr == nil {
|
||||
sendPayload = formatted
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,118 @@ func formatVulnDescription(v WebhookVulnCounts, digest string) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// formatPlatformPayload detects the payload type and formats for Discord or Slack
|
||||
func formatPlatformPayload(payload []byte, webhookURL string, meta atproto.AppviewMetadata) ([]byte, error) {
|
||||
// Detect push vs scan payload by checking for push_data key
|
||||
var probe struct {
|
||||
PushData json.RawMessage `json:"push_data"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &probe); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if probe.PushData != nil {
|
||||
var p PushWebhookPayload
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isDiscordWebhook(webhookURL) {
|
||||
return formatDiscordPushPayload(p, meta)
|
||||
}
|
||||
return formatSlackPushPayload(p, meta)
|
||||
}
|
||||
|
||||
var p WebhookPayload
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isDiscordWebhook(webhookURL) {
|
||||
return formatDiscordPayload(p, meta)
|
||||
}
|
||||
return formatSlackPayload(p, meta)
|
||||
}
|
||||
|
||||
// formatDiscordPushPayload wraps a push webhook payload in Discord's embed format
|
||||
func formatDiscordPushPayload(p PushWebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
|
||||
appviewURL := meta.BaseURL
|
||||
|
||||
title := p.Repository.Name
|
||||
if p.PushData.Tag != "" {
|
||||
title += ":" + p.PushData.Tag
|
||||
}
|
||||
|
||||
digest := p.PushData.Digest
|
||||
if len(digest) > 19 {
|
||||
digest = digest[:19] + "..."
|
||||
}
|
||||
description := fmt.Sprintf("Pushed by **%s**\nDigest: `%s`", p.PushData.Pusher, digest)
|
||||
|
||||
embed := map[string]any{
|
||||
"title": title,
|
||||
"url": p.Repository.RepoURL,
|
||||
"description": description,
|
||||
"color": 0x5865F2, // blurple
|
||||
"footer": map[string]string{
|
||||
"text": meta.ClientShortName,
|
||||
"icon_url": meta.FaviconURL,
|
||||
},
|
||||
"timestamp": p.PushData.PushedAt,
|
||||
}
|
||||
|
||||
embed["author"] = map[string]string{
|
||||
"name": p.PushData.Pusher,
|
||||
"url": appviewURL + "/u/" + p.PushData.Pusher,
|
||||
}
|
||||
embed["image"] = map[string]string{
|
||||
"url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Repository.Namespace, p.Repository.Name),
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"username": meta.ClientShortName,
|
||||
"avatar_url": meta.FaviconURL,
|
||||
"embeds": []any{embed},
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
// formatSlackPushPayload wraps a push webhook payload in Slack's message format
|
||||
func formatSlackPushPayload(p PushWebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
|
||||
appviewURL := meta.BaseURL
|
||||
|
||||
title := p.Repository.Name
|
||||
if p.PushData.Tag != "" {
|
||||
title += ":" + p.PushData.Tag
|
||||
}
|
||||
|
||||
fallback := fmt.Sprintf("%s pushed %s", p.PushData.Pusher, title)
|
||||
|
||||
digest := p.PushData.Digest
|
||||
if len(digest) > 19 {
|
||||
digest = digest[:19] + "..."
|
||||
}
|
||||
description := fmt.Sprintf("Pushed by *%s*\nDigest: `%s`", p.PushData.Pusher, digest)
|
||||
|
||||
attachment := map[string]any{
|
||||
"fallback": fallback,
|
||||
"color": "#5865F2",
|
||||
"title": title,
|
||||
"title_link": p.Repository.RepoURL,
|
||||
"text": description,
|
||||
"footer": meta.ClientShortName,
|
||||
"footer_icon": meta.FaviconURL,
|
||||
"ts": p.PushData.PushedAt,
|
||||
"author_name": p.PushData.Pusher,
|
||||
"author_link": appviewURL + "/u/" + p.PushData.Pusher,
|
||||
"image_url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Repository.Namespace, p.Repository.Name),
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"text": fallback,
|
||||
"attachments": []any{attachment},
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
// formatDiscordPayload wraps an ATCR webhook payload in Discord's embed format
|
||||
func formatDiscordPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
|
||||
appviewURL := meta.BaseURL
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package webhooks provides webhook dispatch and formatting for scan notifications.
|
||||
// Package webhooks provides webhook dispatch and formatting for push and scan notifications.
|
||||
package webhooks
|
||||
|
||||
// Webhook trigger bitmask constants
|
||||
@@ -6,6 +6,7 @@ const (
|
||||
TriggerFirst = 0x01 // First-time scan (no previous scan record)
|
||||
TriggerAll = 0x02 // Every scan completion
|
||||
TriggerChanged = 0x04 // Vulnerability counts changed from previous
|
||||
TriggerPush = 0x08 // Image push (manifest stored)
|
||||
)
|
||||
|
||||
// WebhookPayload is the JSON body sent to webhook URLs
|
||||
@@ -42,3 +43,37 @@ type WebhookVulnCounts struct {
|
||||
Low int `json:"low"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// PushWebhookPayload is the JSON body sent for push events (Docker Hub-inspired format)
|
||||
type PushWebhookPayload struct {
|
||||
Trigger string `json:"trigger"`
|
||||
PushData PushData `json:"push_data"`
|
||||
Repository PushRepository `json:"repository"`
|
||||
Hold PushHold `json:"hold"`
|
||||
}
|
||||
|
||||
// PushData describes the push event
|
||||
type PushData struct {
|
||||
PushedAt string `json:"pushed_at"`
|
||||
Pusher string `json:"pusher"`
|
||||
PusherDID string `json:"pusher_did"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// PushRepository describes the repository that was pushed to
|
||||
type PushRepository struct {
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
RepoName string `json:"repo_name"`
|
||||
RepoURL string `json:"repo_url"`
|
||||
MediaType string `json:"media_type"`
|
||||
StarCount int `json:"star_count"`
|
||||
PullCount int `json:"pull_count"`
|
||||
}
|
||||
|
||||
// PushHold describes the hold service where blobs are stored
|
||||
type PushHold struct {
|
||||
DID string `json:"did"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
@@ -704,6 +704,20 @@ func formatBytes(b int64) string {
|
||||
return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp])
|
||||
}
|
||||
|
||||
// GetFirstTierWithAllTriggers returns the name of the lowest-rank tier that has
|
||||
// webhook_all_triggers enabled. Returns empty string if none found.
|
||||
func (m *Manager) GetFirstTierWithAllTriggers() string {
|
||||
if !m.Enabled() {
|
||||
return ""
|
||||
}
|
||||
for _, tier := range m.cfg.Tiers {
|
||||
if tier.WebhookAllTriggers {
|
||||
return tier.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fetchPrice returns the unit amount in cents for a Stripe price ID, using a cache.
|
||||
func (m *Manager) fetchPrice(priceID string) (int64, error) {
|
||||
m.priceCacheMu.RLock()
|
||||
|
||||
@@ -65,6 +65,11 @@ func (m *Manager) GetSupporterBadge(userDID string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetFirstTierWithAllTriggers returns empty string when billing is not compiled in.
|
||||
func (m *Manager) GetFirstTierWithAllTriggers() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// RegisterRoutes is a no-op when billing is not compiled in.
|
||||
func (m *Manager) RegisterRoutes(_ chi.Router) {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user