more billing/settings/webhook tweaks

This commit is contained in:
Evan Jarrett
2026-02-28 14:42:35 -06:00
parent 0827219716
commit 7d74e76772
21 changed files with 633 additions and 117 deletions
+4 -4
View File
@@ -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
View File
@@ -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
+227
View File
@@ -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.
+2 -7
View File
@@ -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 {
+15 -3
View File
@@ -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")
+38 -20
View File
@@ -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) {
+30 -20
View File
@@ -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
+1
View File
@@ -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()
+2 -27
View File
@@ -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); }
+27 -4
View File
@@ -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)
}
+34
View File
@@ -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() {
+5 -7
View File
@@ -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
+4 -2
View File
@@ -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 }}
+65 -13
View File
@@ -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
}
}
+112
View File
@@ -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
+36 -1
View File
@@ -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"`
}
+14
View File
@@ -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()
+5
View File
@@ -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) {}