mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-18 16:24:16 +00:00
billing refactor, move billing to appview, move webhooks to appview
This commit is contained in:
@@ -7,7 +7,7 @@ poll = true
|
||||
poll_interval = 500
|
||||
# Pre-build: generate assets if missing (each string is a shell command)
|
||||
pre_cmd = ["go generate ./pkg/appview/..."]
|
||||
cmd = "go build -buildvcs=false -o ./tmp/atcr-appview ./cmd/appview"
|
||||
cmd = "go build -tags billing -buildvcs=false -o ./tmp/atcr-appview ./cmd/appview"
|
||||
entrypoint = ["./tmp/atcr-appview", "serve", "--config", "config-appview.example.yaml"]
|
||||
include_ext = ["go", "html", "css", "js"]
|
||||
exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "node_modules", "pkg/hold"]
|
||||
|
||||
+1
-1
@@ -131,7 +131,7 @@ func openHoldPDS(ctx context.Context, cfg *hold.Config) (*pds.HoldPDS, func(), e
|
||||
return nil, nil, fmt.Errorf("failed to open hold database: %w", err)
|
||||
}
|
||||
|
||||
holdPDS, err := pds.NewHoldPDSWithDB(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL, cfg.Database.Path, cfg.Database.KeyPath, false, holdDB.DB)
|
||||
holdPDS, err := pds.NewHoldPDSWithDB(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL(), cfg.Database.Path, cfg.Database.KeyPath, false, holdDB.DB)
|
||||
if err != nil {
|
||||
holdDB.Close()
|
||||
return nil, nil, fmt.Errorf("failed to initialize PDS: %w", err)
|
||||
|
||||
+88
-25
@@ -41,29 +41,29 @@ var allCollections = []string{
|
||||
atproto.TagCollection, // io.atcr.tag
|
||||
atproto.SailorProfileCollection, // io.atcr.sailor.profile
|
||||
atproto.StarCollection, // io.atcr.sailor.star
|
||||
atproto.SailorWebhookCollection, // io.atcr.sailor.webhook
|
||||
atproto.RepoPageCollection, // io.atcr.repo.page
|
||||
atproto.CaptainCollection, // io.atcr.hold.captain
|
||||
atproto.CrewCollection, // io.atcr.hold.crew
|
||||
atproto.LayerCollection, // io.atcr.hold.layer
|
||||
atproto.StatsCollection, // io.atcr.hold.stats
|
||||
atproto.ScanCollection, // io.atcr.hold.scan
|
||||
atproto.WebhookCollection, // io.atcr.hold.webhook
|
||||
}
|
||||
|
||||
type summaryRow struct {
|
||||
collection string
|
||||
counts []int
|
||||
status string // "sync", "diff", "error"
|
||||
diffCount int
|
||||
realGaps int // verified: record exists on PDS but relay is missing it
|
||||
ghosts int // verified: record doesn't exist on PDS, relay has stale entry
|
||||
collection string
|
||||
counts []int
|
||||
status string // "sync", "diff", "error"
|
||||
diffCount int
|
||||
realGaps int // verified: record exists on PDS but relay is missing it
|
||||
ghosts int // verified: record doesn't exist on PDS, relay has stale entry
|
||||
deactivated int // verified: account deactivated/deleted on PDS
|
||||
}
|
||||
|
||||
// verifyResult holds the PDS verification result for a (DID, collection) pair.
|
||||
type verifyResult struct {
|
||||
exists bool
|
||||
err error
|
||||
exists bool
|
||||
deactivated bool // account deactivated/deleted on PDS
|
||||
err error
|
||||
}
|
||||
|
||||
// key identifies a (collection, relay-or-DID) pair for result lookups.
|
||||
@@ -211,6 +211,7 @@ func main() {
|
||||
totalMissing := 0
|
||||
totalRealGaps := 0
|
||||
totalGhosts := 0
|
||||
totalDeactivated := 0
|
||||
|
||||
for _, col := range cols {
|
||||
fmt.Printf("\n%s%s━━━ %s ━━━%s\n", cBold, cCyan, col, cReset)
|
||||
@@ -258,6 +259,10 @@ func main() {
|
||||
suffix = fmt.Sprintf(" %s(verify: unknown)%s", cDim, cReset)
|
||||
} else if vr.err != nil {
|
||||
suffix = fmt.Sprintf(" %s(verify: %s)%s", cDim, vr.err, cReset)
|
||||
} else if vr.deactivated {
|
||||
suffix = fmt.Sprintf(" %s← deactivated%s", cDim, cReset)
|
||||
row.deactivated++
|
||||
totalDeactivated++
|
||||
} else if vr.exists {
|
||||
suffix = fmt.Sprintf(" %s← real gap%s", cRed, cReset)
|
||||
row.realGaps++
|
||||
@@ -272,8 +277,18 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// When verifying, ghost/deactivated-only diffs are considered in sync
|
||||
if !inSync && *verify && row.realGaps == 0 {
|
||||
inSync = true
|
||||
}
|
||||
|
||||
if inSync {
|
||||
fmt.Printf(" %s✓ in sync%s\n", cGreen, cReset)
|
||||
notes := formatSyncNotes(row.ghosts, row.deactivated)
|
||||
if notes != "" {
|
||||
fmt.Printf(" %s✓ in sync%s %s(%s)%s\n", cGreen, cReset, cDim, notes, cReset)
|
||||
} else {
|
||||
fmt.Printf(" %s✓ in sync%s\n", cGreen, cReset)
|
||||
}
|
||||
row.status = "sync"
|
||||
} else {
|
||||
row.status = "diff"
|
||||
@@ -282,10 +297,10 @@ func main() {
|
||||
}
|
||||
|
||||
// Summary table
|
||||
printSummary(summary, names, maxNameLen, totalMissing, *verify, totalRealGaps, totalGhosts)
|
||||
printSummary(summary, names, maxNameLen, totalMissing, *verify, totalRealGaps, totalGhosts, totalDeactivated)
|
||||
}
|
||||
|
||||
func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing int, showVerify bool, totalRealGaps, totalGhosts int) {
|
||||
func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing int, showVerify bool, totalRealGaps, totalGhosts, totalDeactivated int) {
|
||||
fmt.Printf("\n%s%s━━━ Summary ━━━%s\n\n", cBold, cCyan, cReset)
|
||||
|
||||
colW := 28
|
||||
@@ -321,11 +336,20 @@ func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing in
|
||||
}
|
||||
switch row.status {
|
||||
case "sync":
|
||||
fmt.Printf(" %s✓ in sync%s", cGreen, cReset)
|
||||
notes := formatSyncNotes(row.ghosts, row.deactivated)
|
||||
if notes != "" {
|
||||
fmt.Printf(" %s✓ in sync%s %s(%s)%s", cGreen, cReset, cDim, notes, cReset)
|
||||
} else {
|
||||
fmt.Printf(" %s✓ in sync%s", cGreen, cReset)
|
||||
}
|
||||
case "diff":
|
||||
if showVerify {
|
||||
fmt.Printf(" %s≠ %d missing%s %s(%d real, %d ghost)%s",
|
||||
cYellow, row.diffCount, cReset, cDim, row.realGaps, row.ghosts, cReset)
|
||||
notes := formatSyncNotes(row.ghosts, row.deactivated)
|
||||
if notes != "" {
|
||||
notes = ", " + notes
|
||||
}
|
||||
fmt.Printf(" %s≠ %d missing%s %s(%s)%s",
|
||||
cYellow, row.realGaps, cReset, cDim, fmt.Sprintf("%d real%s", row.realGaps, notes), cReset)
|
||||
} else {
|
||||
fmt.Printf(" %s≠ %d missing%s", cYellow, row.diffCount, cReset)
|
||||
}
|
||||
@@ -338,16 +362,39 @@ func printSummary(rows []summaryRow, names []string, maxNameLen, totalMissing in
|
||||
// Footer
|
||||
fmt.Println()
|
||||
if totalMissing > 0 {
|
||||
fmt.Printf("%s%d total missing DID-collection pairs across relays%s\n", cYellow, totalMissing, cReset)
|
||||
if showVerify {
|
||||
fmt.Printf(" %s%d real gaps%s (record exists on PDS), %s%d ghosts%s (record deleted from PDS)\n",
|
||||
cRed, totalRealGaps, cReset, cDim, totalGhosts, cReset)
|
||||
if showVerify && totalRealGaps == 0 {
|
||||
notes := formatSyncNotes(totalGhosts, totalDeactivated)
|
||||
fmt.Printf("%s✓ All relays in sync%s %s(%s)%s\n", cGreen, cReset, cDim, notes, cReset)
|
||||
} else {
|
||||
if showVerify {
|
||||
fmt.Printf("%s%d real gaps across relays%s", cYellow, totalRealGaps, cReset)
|
||||
notes := formatSyncNotes(totalGhosts, totalDeactivated)
|
||||
if notes != "" {
|
||||
fmt.Printf(" %s(%s)%s", cDim, notes, cReset)
|
||||
}
|
||||
fmt.Println()
|
||||
} else {
|
||||
fmt.Printf("%s%d total missing DID-collection pairs across relays%s\n", cYellow, totalMissing, cReset)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("%s✓ All relays fully in sync%s\n", cGreen, cReset)
|
||||
}
|
||||
}
|
||||
|
||||
// formatSyncNotes builds a parenthetical like "2 ghost, 1 deactivated" for sync status.
|
||||
// Returns empty string if both counts are zero.
|
||||
func formatSyncNotes(ghosts, deactivated int) string {
|
||||
var parts []string
|
||||
if ghosts > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d ghost", ghosts))
|
||||
}
|
||||
if deactivated > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d deactivated", deactivated))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// verifyDiffs resolves each diff DID to its PDS and checks if records actually exist.
|
||||
func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult {
|
||||
// Collect unique (DID, collection) pairs to verify
|
||||
@@ -402,11 +449,19 @@ func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult {
|
||||
|
||||
k := key{dc.col, dc.did}
|
||||
|
||||
// Check if DID resolution failed
|
||||
// Check if DID resolution failed — could mean account is deactivated/tombstoned
|
||||
if err, ok := pdsErrors[dc.did]; ok {
|
||||
mu.Lock()
|
||||
results[k] = verifyResult{err: fmt.Errorf("DID resolution failed: %w", err)}
|
||||
mu.Unlock()
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "no PDS endpoint") ||
|
||||
strings.Contains(errStr, "not found") {
|
||||
mu.Lock()
|
||||
results[k] = verifyResult{deactivated: true}
|
||||
mu.Unlock()
|
||||
} else {
|
||||
mu.Lock()
|
||||
results[k] = verifyResult{err: fmt.Errorf("DID resolution failed: %w", err)}
|
||||
mu.Unlock()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -415,7 +470,15 @@ func verifyDiffs(ctx context.Context, diffs []diffEntry) map[key]verifyResult {
|
||||
records, _, err := client.ListRecordsForRepo(ctx, dc.did, dc.col, 1, "")
|
||||
mu.Lock()
|
||||
if err != nil {
|
||||
results[k] = verifyResult{err: err}
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "Could not find repo") ||
|
||||
strings.Contains(errStr, "RepoDeactivated") ||
|
||||
strings.Contains(errStr, "RepoTakendown") ||
|
||||
strings.Contains(errStr, "RepoSuspended") {
|
||||
results[k] = verifyResult{deactivated: true}
|
||||
} else {
|
||||
results[k] = verifyResult{err: err}
|
||||
}
|
||||
} else {
|
||||
results[k] = verifyResult{exists: len(records) > 0}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ server:
|
||||
client_short_name: ATCR
|
||||
# Separate domains for OCI registry API (e.g. ["buoy.cr"]). First is primary. Browser visits redirect to BaseURL.
|
||||
registry_domains: []
|
||||
# DIDs of holds this appview manages billing for. Tier updates are pushed to these holds.
|
||||
managed_holds:
|
||||
- did:web:172.28.0.3%3A8080
|
||||
# Web UI settings.
|
||||
ui:
|
||||
# SQLite/libSQL database for OAuth sessions, stars, pull counts, and device approvals.
|
||||
@@ -65,6 +68,8 @@ jetstream:
|
||||
- wss://jetstream1.us-east.bsky.network/subscribe
|
||||
# Sync existing records from PDS on startup.
|
||||
backfill_enabled: true
|
||||
# How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup.
|
||||
backfill_interval: 24h0m0s
|
||||
# Relay endpoints for backfill, tried in order on failure.
|
||||
relay_endpoints:
|
||||
- https://relay1.us-east.bsky.network
|
||||
@@ -85,3 +90,76 @@ legal:
|
||||
company_name: ""
|
||||
# Governing law jurisdiction for legal terms.
|
||||
jurisdiction: ""
|
||||
# Stripe billing integration (requires -tags billing build).
|
||||
billing:
|
||||
# Stripe secret key. Can also be set via STRIPE_SECRET_KEY env var (takes precedence). Billing is enabled automatically when set.
|
||||
stripe_secret_key: ""
|
||||
# Stripe webhook signing secret. Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence).
|
||||
webhook_secret: ""
|
||||
# ISO 4217 currency code (e.g. "usd").
|
||||
currency: usd
|
||||
# Redirect URL after successful checkout. Use {base_url} placeholder.
|
||||
success_url: '{base_url}/settings#storage'
|
||||
# Redirect URL after cancelled checkout. Use {base_url} placeholder.
|
||||
cancel_url: '{base_url}/settings#storage'
|
||||
# Subscription tiers ordered by rank (lowest to highest).
|
||||
tiers:
|
||||
- # Tier name. Position in list determines rank (0-based).
|
||||
name: free
|
||||
# Short description shown on the plan card.
|
||||
description: Get started with basic storage
|
||||
# List of features included in this tier.
|
||||
features: []
|
||||
# Stripe price ID for monthly billing. Empty = free tier.
|
||||
stripe_price_monthly: ""
|
||||
# Stripe price ID for yearly billing.
|
||||
stripe_price_yearly: ""
|
||||
# Maximum webhooks for this tier (-1 = unlimited).
|
||||
max_webhooks: 1
|
||||
# Allow all webhook trigger types (not just first-scan).
|
||||
webhook_all_triggers: false
|
||||
supporter_badge: false
|
||||
- # Tier name. Position in list determines rank (0-based).
|
||||
name: deckhand
|
||||
# Short description shown on the plan card.
|
||||
description: Get started with basic storage
|
||||
# List of features included in this tier.
|
||||
features: []
|
||||
# Stripe price ID for monthly billing. Empty = free tier.
|
||||
stripe_price_monthly: ""
|
||||
# Stripe price ID for yearly billing.
|
||||
stripe_price_yearly: ""
|
||||
# Maximum webhooks for this tier (-1 = unlimited).
|
||||
max_webhooks: 1
|
||||
# Allow all webhook trigger types (not just first-scan).
|
||||
webhook_all_triggers: false
|
||||
supporter_badge: true
|
||||
- # Tier name. Position in list determines rank (0-based).
|
||||
name: bosun
|
||||
# Short description shown on the plan card.
|
||||
description: More storage with scan-on-push
|
||||
# List of features included in this tier.
|
||||
features: []
|
||||
# Stripe price ID for monthly billing. Empty = free tier.
|
||||
stripe_price_monthly: ""
|
||||
# Stripe price ID for yearly billing.
|
||||
stripe_price_yearly: ""
|
||||
# Maximum webhooks for this tier (-1 = unlimited).
|
||||
max_webhooks: 10
|
||||
# Allow all webhook trigger types (not just first-scan).
|
||||
webhook_all_triggers: true
|
||||
supporter_badge: true
|
||||
# - # Tier name. Position in list determines rank (0-based).
|
||||
# name: quartermaster
|
||||
# # Short description shown on the plan card.
|
||||
# description: Maximum storage for power users
|
||||
# # List of features included in this tier.
|
||||
# features: []
|
||||
# # Stripe price ID for monthly billing. Empty = free tier.
|
||||
# stripe_price_monthly: price_xxx
|
||||
# # Stripe price ID for yearly billing.
|
||||
# stripe_price_yearly: price_yyy
|
||||
# # Maximum webhooks for this tier (-1 = unlimited).
|
||||
# max_webhooks: -1
|
||||
# # Allow all webhook trigger types (not just first-scan).
|
||||
# webhook_all_triggers: true
|
||||
|
||||
@@ -47,8 +47,8 @@ server:
|
||||
test_mode: false
|
||||
# Request crawl from this relay on startup to make the embedded PDS discoverable.
|
||||
relay_endpoint: ""
|
||||
# Preferred appview URL for links in webhooks and Bluesky posts, e.g. "https://seamark.dev".
|
||||
appview_url: https://atcr.io
|
||||
# DID of the appview this hold is managed by (e.g. did:web:atcr.io). Resolved via did:web for URL and public key.
|
||||
appview_did: did:web:172.28.0.2%3A5000
|
||||
# Read timeout for HTTP requests.
|
||||
read_timeout: 5m0s
|
||||
# Write timeout for HTTP requests.
|
||||
@@ -101,48 +101,34 @@ gc:
|
||||
quota:
|
||||
# Quota tiers ordered by rank (lowest to highest). Position determines rank.
|
||||
tiers:
|
||||
- # Tier name used as the key for crew assignments.
|
||||
name: free
|
||||
# Storage quota limit (e.g. "5GB", "50GB", "1TB").
|
||||
quota: 5GB
|
||||
# Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
|
||||
scan_on_push: false
|
||||
- # Tier name used as the key for crew assignments.
|
||||
name: deckhand
|
||||
# Storage quota limit (e.g. "5GB", "50GB", "1TB").
|
||||
quota: 5GB
|
||||
# Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
|
||||
scan_on_push: false
|
||||
# Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
|
||||
max_webhooks: 1
|
||||
# Allow all webhook trigger types. Free tiers only get scan:first.
|
||||
webhook_all_triggers: false
|
||||
# Show supporter badge on user profiles for members at this tier.
|
||||
supporter_badge: false
|
||||
- # Tier name used as the key for crew assignments.
|
||||
name: bosun
|
||||
# Storage quota limit (e.g. "5GB", "50GB", "1TB").
|
||||
quota: 50GB
|
||||
# Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
|
||||
scan_on_push: true
|
||||
# Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
|
||||
max_webhooks: 5
|
||||
# Allow all webhook trigger types. Free tiers only get scan:first.
|
||||
webhook_all_triggers: true
|
||||
# Show supporter badge on user profiles for members at this tier.
|
||||
supporter_badge: true
|
||||
- # Tier name used as the key for crew assignments.
|
||||
name: quartermaster
|
||||
# Storage quota limit (e.g. "5GB", "50GB", "1TB").
|
||||
quota: 100GB
|
||||
# Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling.
|
||||
scan_on_push: true
|
||||
# Maximum webhook URLs (0=none, -1=unlimited). Default: 1.
|
||||
max_webhooks: -1
|
||||
# Allow all webhook trigger types. Free tiers only get scan:first.
|
||||
webhook_all_triggers: true
|
||||
# Show supporter badge on user profiles for members at this tier.
|
||||
supporter_badge: true
|
||||
# Default tier assignment for new crew members.
|
||||
defaults:
|
||||
# Tier assigned to new crew members who don't have an explicit tier.
|
||||
new_crew_tier: deckhand
|
||||
# Show supporter badge on the hold owner's profile.
|
||||
owner_badge: true
|
||||
# Vulnerability scanner settings. Empty disables scanning.
|
||||
scanner:
|
||||
# Shared secret for scanner WebSocket auth. Empty disables scanning.
|
||||
|
||||
@@ -34,6 +34,7 @@ jetstream:
|
||||
- wss://jetstream2.us-east.bsky.network/subscribe
|
||||
- wss://jetstream1.us-east.bsky.network/subscribe
|
||||
backfill_enabled: true
|
||||
backfill_interval: 24h
|
||||
relay_endpoints:
|
||||
- https://relay1.us-east.bsky.network
|
||||
- https://relay1.us-west.bsky.network
|
||||
|
||||
@@ -21,7 +21,7 @@ server:
|
||||
successor: ""
|
||||
test_mode: false
|
||||
relay_endpoint: ""
|
||||
appview_url: https://seamark.dev
|
||||
appview_did: did:web:seamark.dev
|
||||
read_timeout: 5m0s
|
||||
write_timeout: 5m0s
|
||||
registration:
|
||||
@@ -50,22 +50,14 @@ quota:
|
||||
tiers:
|
||||
- name: deckhand
|
||||
quota: 5GB
|
||||
max_webhooks: 1
|
||||
- name: bosun
|
||||
quota: 50GB
|
||||
scan_on_push: true
|
||||
max_webhooks: 5
|
||||
webhook_all_triggers: true
|
||||
supporter_badge: true
|
||||
- name: quartermaster
|
||||
quota: 100GB
|
||||
scan_on_push: true
|
||||
max_webhooks: -1
|
||||
webhook_all_triggers: true
|
||||
supporter_badge: true
|
||||
defaults:
|
||||
new_crew_tier: deckhand
|
||||
owner_badge: true
|
||||
scanner:
|
||||
secret: "{{.ScannerSecret}}"
|
||||
rescan_interval: 168h0m0s
|
||||
|
||||
+4
-4
@@ -20,6 +20,10 @@ services:
|
||||
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:
|
||||
@@ -57,10 +61,6 @@ services:
|
||||
HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg
|
||||
HOLD_REGISTRATION_ALLOW_ALL_CREW: true
|
||||
HOLD_SERVER_TEST_MODE: true
|
||||
# Stripe billing (only used with -tags billing)
|
||||
STRIPE_SECRET_KEY: sk_test_
|
||||
STRIPE_PUBLISHABLE_KEY: pk_test_
|
||||
STRIPE_WEBHOOK_SECRET: whsec_
|
||||
HOLD_LOG_LEVEL: debug
|
||||
LOG_SHIPPER_BACKEND: victoria
|
||||
LOG_SHIPPER_URL: http://172.28.0.10:9428
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
# Billing & Webhooks Refactor: Move to AppView
|
||||
|
||||
## Motivation
|
||||
|
||||
The current billing model is **per-hold**: each hold operator runs their own Stripe integration, manages their own tiers, and users pay each hold separately. This creates problems:
|
||||
|
||||
1. **Multi-hold confusion**: A user on 3 holds could have 3 separate Stripe subscriptions with no unified view
|
||||
2. **Orphaned subscriptions**: Users can end up paying for holds they no longer use after switching their active hold
|
||||
3. **Complex UI**: The settings page needs to surface billing per-hold, with separate "Manage Billing" links for each
|
||||
4. **Captain-only billing**: Only hold captains can set up Stripe. Self-hosted hold operators who want to charge users would need their own Stripe account per hold
|
||||
|
||||
The proposed model is **per-appview**: a single Stripe integration on the appview, one subscription per user, covering all holds that appview manages.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
```
|
||||
User ──Settings UI──→ AppView ──XRPC──→ Hold ──Stripe API──→ Stripe
|
||||
↑
|
||||
Stripe Webhooks
|
||||
```
|
||||
|
||||
### What lives where today
|
||||
|
||||
| Component | Location | Notes |
|
||||
|-----------|----------|-------|
|
||||
| Stripe customer management | Hold (`pkg/hold/billing/`) | Build tag: `-tags billing` |
|
||||
| Stripe checkout/portal | Hold XRPC endpoints | Authenticated via service token |
|
||||
| Stripe webhook receiver | Hold (`stripeWebhook` endpoint) | Updates crew tier on subscription change |
|
||||
| Tier definitions + pricing | Hold config (`quotas.yaml`, `billing` section) | Captain configures |
|
||||
| Quota enforcement | Hold (`pkg/hold/quota/`) | Checks tier limit on push |
|
||||
| Storage quota calculation | Hold PDS layer records | Deduped per-user |
|
||||
| Subscription UI | AppView handlers | Proxies all calls to hold |
|
||||
| Webhook management (scan) | Hold PDS + SQLite | URL/secret in SQLite, metadata in PDS record |
|
||||
| Webhook dispatch | Hold (`scan_broadcaster.go`) | Sends on scan completion |
|
||||
| Sailor webhook record | User's PDS | Links to hold's private webhook record |
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
```
|
||||
User ──Settings UI──→ AppView ──Stripe API──→ Stripe
|
||||
│ ↑
|
||||
│ Stripe Webhooks
|
||||
│
|
||||
├──XRPC──→ Hold A (quota enforcement, scan results)
|
||||
├──XRPC──→ Hold B
|
||||
└──XRPC──→ Hold C
|
||||
|
||||
AppView signs attestation
|
||||
│
|
||||
└──→ Hold stores in PDS (trust anchor)
|
||||
```
|
||||
|
||||
### What moves to AppView
|
||||
|
||||
| Component | From | To | Notes |
|
||||
|-----------|------|----|-------|
|
||||
| Stripe customer management | Hold | AppView | One customer per user, not per hold |
|
||||
| Stripe checkout/portal | Hold | AppView | Single subscription covers all holds |
|
||||
| Stripe webhook receiver | Hold | AppView | AppView updates tier across all holds |
|
||||
| Tier definitions + pricing | Hold config | AppView config | AppView defines billing tiers |
|
||||
| Scan webhooks (storage + dispatch) | Hold | AppView | AppView has user context, scan data comes via Jetstream/XRPC |
|
||||
|
||||
### What stays on the hold
|
||||
|
||||
| Component | Notes |
|
||||
|-----------|-------|
|
||||
| Quota enforcement | Hold still checks tier limit on push |
|
||||
| Storage quota calculation | Layer records stay in hold PDS |
|
||||
| Tier definitions (quota only) | Hold defines storage limits per tier, no pricing |
|
||||
| Scan execution + results | Scanner still talks to hold, results stored in hold PDS |
|
||||
| Crew tier field | Source of truth for enforcement, updated by appview |
|
||||
|
||||
## Billing Model
|
||||
|
||||
### One subscription, all holds
|
||||
|
||||
A user pays the appview once. Their subscription tier applies across every hold the appview manages.
|
||||
|
||||
```
|
||||
AppView billing tiers: [Free] [Tier 1] [Tier 2]
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Hold A tiers (3GB/10GB/50GB): deckhand bosun quartermaster
|
||||
Hold B tiers (5GB/20GB/∞): deckhand bosun quartermaster
|
||||
```
|
||||
|
||||
### Tier pairing
|
||||
|
||||
The appview defines N billing slots. Each hold defines its own tier list with storage quotas. The appview maps its billing slots to each hold's lowest N tiers by rank order.
|
||||
|
||||
- AppView doesn't need to know tier names — just "slot 1, slot 2, slot 3"
|
||||
- Each hold independently decides what storage limit each tier gets
|
||||
- The settings UI shows the range: "5-10 GB depending on region" or "minimum 5 GB"
|
||||
|
||||
### Hold captains who want to charge
|
||||
|
||||
If a hold captain wants to charge their own users (not through the shared appview), they spin up their own appview instance with their own Stripe account. The billing code stays the same — it just runs on their appview instead of the shared one.
|
||||
|
||||
## AppView-Hold Trust Model
|
||||
|
||||
### Problem
|
||||
|
||||
The appview needs to tell holds "user X is tier Y." The hold needs to trust that instruction. If domains change, the hold needs to verify the appview's identity.
|
||||
|
||||
### Attestation handshake
|
||||
|
||||
1. **Hold config** already has `server.appview_url` (preferred appview)
|
||||
2. **AppView config** gains a `managed_holds` list (DIDs of holds it manages)
|
||||
3. On first connection, the appview signs an attestation with its private key:
|
||||
```json
|
||||
{
|
||||
"$type": "io.atcr.appview.attestation",
|
||||
"appviewDid": "did:web:atcr.io",
|
||||
"holdDid": "did:web:hold01.atcr.io",
|
||||
"issuedAt": "2026-02-23T...",
|
||||
"signature": "<signed with appview's P-256 key>"
|
||||
}
|
||||
```
|
||||
4. The hold stores this attestation in its embedded PDS
|
||||
5. On subsequent requests, the hold can challenge the appview: present the attestation, appview proves it holds the matching private key
|
||||
6. If the appview's domain changes, the attestation (tied to DID, not URL) remains valid
|
||||
|
||||
### Trust verification flow
|
||||
|
||||
```
|
||||
AppView boots → checks managed_holds list
|
||||
→ for each hold:
|
||||
→ calls hold's describeServer endpoint to verify DID
|
||||
→ signs attestation { appviewDid, holdDid, issuedAt }
|
||||
→ sends to hold via XRPC
|
||||
→ hold stores in PDS as io.atcr.hold.appview record
|
||||
|
||||
Hold receives tier update from appview:
|
||||
→ checks: does this request come from my preferred appview?
|
||||
→ verifies: signature on stored attestation matches appview's current key
|
||||
→ if valid: updates crew tier
|
||||
→ if invalid: rejects, logs warning
|
||||
```
|
||||
|
||||
### Key material
|
||||
|
||||
- **AppView**: P-256 key (already exists at `/var/lib/atcr/oauth/client.key`, used for OAuth)
|
||||
- **Hold**: K-256 key (PDS signing key)
|
||||
- Attestation is signed by appview's P-256 key, verifiable by anyone with the appview's public key (available via DID document)
|
||||
|
||||
## Webhooks: Move to AppView
|
||||
|
||||
### Why move
|
||||
|
||||
Scan webhooks currently live on the hold, but:
|
||||
- The webhook payload needs user handles, repository names, tags — all resolved by the appview
|
||||
- The hold only has DIDs and digests
|
||||
- The appview already processes scan records via Jetstream (backfill + live)
|
||||
- Webhook secrets shouldn't need to live on every hold the user pushes to
|
||||
|
||||
### New flow
|
||||
|
||||
```
|
||||
Scanner completes scan
|
||||
→ Hold stores scan record in PDS
|
||||
→ Jetstream delivers scan record to AppView
|
||||
→ AppView resolves user handle, repo name, tags
|
||||
→ AppView dispatches webhooks with full context
|
||||
```
|
||||
|
||||
### What changes
|
||||
|
||||
| Aspect | Current (hold) | Proposed (appview) |
|
||||
|--------|---------------|-------------------|
|
||||
| Webhook storage | Hold SQLite + PDS record | AppView DB + user's PDS record |
|
||||
| Webhook secrets | Hold SQLite (`webhook_secrets` table) | AppView DB |
|
||||
| Dispatch trigger | `scan_broadcaster.go` on scan completion | Jetstream processor on `io.atcr.hold.scan` record |
|
||||
| Payload enrichment | Hold fetches handle from appview metadata | AppView has full context natively |
|
||||
| Discord/Slack formatting | Hold (`webhooks.go`) | AppView (same code, moved) |
|
||||
| Tier-based limits | Hold quota manager | AppView billing tier |
|
||||
| XRPC endpoints | Hold (`listWebhooks`, `addWebhook`, etc.) | AppView API endpoints (already exist as proxies) |
|
||||
|
||||
### Webhook record changes
|
||||
|
||||
The `io.atcr.sailor.webhook` record in the user's PDS stays. It already stores `holdDid` and `triggers`. The `privateCid` field (linking to hold's internal record) becomes unnecessary since appview owns the full webhook now.
|
||||
|
||||
The `io.atcr.hold.webhook` record in the hold's PDS is no longer needed. Webhooks are appview-scoped, not hold-scoped.
|
||||
|
||||
### Migration path
|
||||
|
||||
1. AppView gains webhook storage in its own DB (new table)
|
||||
2. AppView gains webhook dispatch in its Jetstream processor
|
||||
3. Hold's webhook endpoints deprecated (return 410 Gone after transition period)
|
||||
4. Existing hold webhook records migrated via one-time script reading from hold XRPC + user PDS
|
||||
|
||||
## Config Changes
|
||||
|
||||
### AppView config additions
|
||||
|
||||
```yaml
|
||||
server:
|
||||
# Existing
|
||||
default_hold_did: "did:web:hold01.atcr.io"
|
||||
|
||||
# New
|
||||
managed_holds:
|
||||
- "did:web:hold01.atcr.io"
|
||||
- "did:plc:abc123..."
|
||||
|
||||
# New section
|
||||
billing:
|
||||
enabled: true
|
||||
currency: usd
|
||||
success_url: "{base_url}/settings#storage"
|
||||
cancel_url: "{base_url}/settings#storage"
|
||||
tiers:
|
||||
- name: "Free"
|
||||
# No stripe_price = free tier
|
||||
- name: "Standard"
|
||||
stripe_price_monthly: price_xxx
|
||||
stripe_price_yearly: price_yyy
|
||||
- name: "Pro"
|
||||
stripe_price_monthly: price_xxx
|
||||
stripe_price_yearly: price_yyy
|
||||
```
|
||||
|
||||
### AppView environment additions
|
||||
|
||||
```bash
|
||||
STRIPE_SECRET_KEY=sk_live_xxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
```
|
||||
|
||||
### Hold config changes
|
||||
|
||||
```yaml
|
||||
# Removed
|
||||
billing:
|
||||
# entire section removed from hold config
|
||||
|
||||
# Stays (quota enforcement only)
|
||||
quota:
|
||||
tiers:
|
||||
- name: deckhand
|
||||
quota: 5GB
|
||||
- name: bosun
|
||||
quota: 50GB
|
||||
- name: quartermaster
|
||||
quota: 100GB
|
||||
defaults:
|
||||
new_crew_tier: deckhand
|
||||
```
|
||||
|
||||
The hold no longer has Stripe config. It just defines storage limits per tier and enforces them.
|
||||
|
||||
## AppView DB Schema Additions
|
||||
|
||||
```sql
|
||||
-- Webhook configurations (moved from hold SQLite)
|
||||
CREATE TABLE webhooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_did TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
secret_hash TEXT, -- bcrypt hash of HMAC secret
|
||||
triggers INTEGER NOT NULL DEFAULT 1, -- bitmask: first=1, all=2, changed=4
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_did, url)
|
||||
);
|
||||
|
||||
-- Billing: track which holds have been attested
|
||||
CREATE TABLE hold_attestations (
|
||||
hold_did TEXT PRIMARY KEY,
|
||||
attestation_cid TEXT NOT NULL, -- CID of attestation record in hold's PDS
|
||||
issued_at DATETIME NOT NULL,
|
||||
verified_at DATETIME
|
||||
);
|
||||
```
|
||||
|
||||
Stripe customer/subscription data continues to live in Stripe (queried via API, cached in memory). No local subscription table needed — same pattern as current hold billing, just on appview.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Trust foundation
|
||||
- Add `managed_holds` to appview config
|
||||
- Implement attestation signing (appview) and storage (hold)
|
||||
- Add attestation verification to hold's tier-update endpoint
|
||||
- New XRPC endpoint on hold: `io.atcr.hold.updateCrewTier` (appview-authenticated)
|
||||
|
||||
### Phase 2: Billing migration
|
||||
- Move Stripe integration from hold to appview (reuse `pkg/hold/billing/` code)
|
||||
- AppView billing uses `-tags billing` build tag (same pattern)
|
||||
- Implement tier pairing: appview billing slots mapped to hold tier lists
|
||||
- New appview endpoints: checkout, portal, stripe webhook receiver
|
||||
- Settings UI: single subscription section (not per-hold)
|
||||
|
||||
### Phase 3: Webhook migration ✅
|
||||
- Add webhook + scans tables to appview DB
|
||||
- Implement webhook dispatch in appview's Jetstream processor
|
||||
- Move Discord/Slack formatting code to `pkg/appview/webhooks/`
|
||||
- Deprecate hold webhook XRPC endpoints (X-Deprecated header)
|
||||
- Webhooks now user-scoped (global across all holds) in appview DB
|
||||
- Scan records cached from Jetstream for change detection
|
||||
|
||||
### Phase 4: Cleanup ✅
|
||||
- Removed hold webhook XRPC endpoints, dispatch code, and `webhooks.go`
|
||||
- Removed `io.atcr.hold.webhook` and `io.atcr.sailor.webhook` record types + lexicons
|
||||
- Removed `webhook_secrets` SQLite schema from scan_broadcaster
|
||||
- Removed `MaxWebhooks`/`WebhookAllTriggers` from hold quota config
|
||||
- Removed sailor webhook from OAuth scopes
|
||||
|
||||
## Settings UI Impact
|
||||
|
||||
The storage tab simplifies significantly:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Active Hold: [▼ hold01.atcr.io (Crew) ] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Subscription: Standard ($5/mo) [Manage Billing] │
|
||||
│ Storage: 3-5 GB depending on region │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ ★ hold01.atcr.io [Active] [Crew] [Online] │
|
||||
│ Tier: bosun · 281.5 MB / 5.0 GB (5%) │
|
||||
│ ▸ Webhooks (2 configured) │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Other Holds Role Status Storage │
|
||||
│ hold02.atcr.io Crew ● 230 MB / 3 GB │
|
||||
│ hold03.atcr.io Owner ● No data │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- **One subscription section** at the top (not per-hold)
|
||||
- **Webhooks section** under active hold card (managed by appview now)
|
||||
- **No "Paid" badge per hold** — subscription is global
|
||||
- **Storage range** shown on subscription card ("3-5 GB depending on region")
|
||||
- **Per-hold quota** still shown (each hold enforces its own limit for the user's tier)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Tier list endpoint**: Holds need a new XRPC endpoint that returns their tier list with quotas (without pricing). The appview calls this to build the "3-5 GB depending on region" display. Something like `io.atcr.hold.listTiers`.
|
||||
|
||||
2. **Existing Stripe customers**: Holds with existing Stripe subscriptions need a migration plan. Options: honor existing subscriptions until they expire, or bulk-migrate customers to appview's Stripe account.
|
||||
|
||||
3. **Webhook delivery guarantees**: Moving dispatch to appview adds latency (scan record → Jetstream → appview → webhook). For time-sensitive notifications, consider the hold sending a lightweight "scan completed" signal directly to appview via XRPC rather than waiting for Jetstream propagation.
|
||||
|
||||
4. **Self-hosted appviews**: The attestation model assumes one appview per set of holds. If multiple appviews try to manage the same hold, the hold should only trust the most recent attestation (or maintain a list).
|
||||
@@ -21,6 +21,7 @@ This document lists all XRPC endpoints implemented in the Hold service (`pkg/hol
|
||||
| `/xrpc/com.atproto.identity.resolveHandle` | GET | Resolve handle to DID |
|
||||
| `/xrpc/app.bsky.actor.getProfile` | GET | Get actor profile |
|
||||
| `/xrpc/app.bsky.actor.getProfiles` | GET | Get multiple profiles |
|
||||
| `/xrpc/io.atcr.hold.listTiers` | GET | List hold's available tiers with quotas and features |
|
||||
| `/.well-known/did.json` | GET | DID document |
|
||||
| `/.well-known/atproto-did` | GET | DID for handle resolution |
|
||||
|
||||
@@ -43,10 +44,11 @@ This document lists all XRPC endpoints implemented in the Hold service (`pkg/hol
|
||||
|----------|--------|-------------|
|
||||
| `/xrpc/io.atcr.hold.requestCrew` | POST | Request crew membership |
|
||||
| `/xrpc/io.atcr.hold.exportUserData` | GET | GDPR data export (returns user's records) |
|
||||
| `/xrpc/io.atcr.hold.listWebhooks` | GET | List user's webhook configurations |
|
||||
| `/xrpc/io.atcr.hold.addWebhook` | POST | Add a webhook (tier-gated) |
|
||||
| `/xrpc/io.atcr.hold.deleteWebhook` | POST | Delete a webhook |
|
||||
| `/xrpc/io.atcr.hold.testWebhook` | POST | Send test payload to a webhook |
|
||||
### Appview Token Required
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/xrpc/io.atcr.hold.updateCrewTier` | POST | Update a crew member's tier (appview-only) |
|
||||
|
||||
---
|
||||
|
||||
@@ -78,10 +80,8 @@ All require `blob:write` permission via service token:
|
||||
| `/xrpc/io.atcr.hold.requestCrew` | POST | auth | Request crew membership |
|
||||
| `/xrpc/io.atcr.hold.exportUserData` | GET | auth | GDPR data export |
|
||||
| `/xrpc/io.atcr.hold.getQuota` | GET | none | Get user quota info |
|
||||
| `/xrpc/io.atcr.hold.listWebhooks` | GET | auth | List user's webhook configs |
|
||||
| `/xrpc/io.atcr.hold.addWebhook` | POST | auth | Add webhook (tier-gated) |
|
||||
| `/xrpc/io.atcr.hold.deleteWebhook` | POST | auth | Delete a webhook |
|
||||
| `/xrpc/io.atcr.hold.testWebhook` | POST | auth | Send test payload to webhook |
|
||||
| `/xrpc/io.atcr.hold.listTiers` | GET | none | List hold's available tiers with quotas and features (scanOnPush) |
|
||||
| `/xrpc/io.atcr.hold.updateCrewTier` | POST | appview token | Update crew member's tier |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "io.atcr.hold.addWebhook",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "procedure",
|
||||
"description": "Add a new webhook configuration. Stores URL and optional HMAC secret in hold SQLite, creates an io.atcr.hold.webhook record in the embedded PDS. Enforces tier-based limits on webhook count and trigger types. Requires service token authentication.",
|
||||
"input": {
|
||||
"encoding": "application/json",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["url", "triggers"],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"maxLength": 2048,
|
||||
"description": "HTTPS URL to receive webhook payloads"
|
||||
},
|
||||
"secret": {
|
||||
"type": "string",
|
||||
"description": "Optional HMAC-SHA256 signing secret. When set, payloads include an X-Webhook-Signature-256 header.",
|
||||
"maxLength": 256
|
||||
},
|
||||
"triggers": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"encoding": "application/json",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["rkey", "cid"],
|
||||
"properties": {
|
||||
"rkey": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"description": "Record key of the created io.atcr.hold.webhook record"
|
||||
},
|
||||
"cid": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"description": "CID of the created record (used as privateCid in the sailor webhook record)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": [
|
||||
{ "name": "InvalidUrl", "description": "URL is not a valid HTTPS endpoint" },
|
||||
{ "name": "WebhookLimitReached", "description": "User has reached the maximum number of webhooks for their tier" },
|
||||
{ "name": "TriggerNotAllowed", "description": "Trigger types beyond scan:first require a paid tier" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,14 +41,6 @@
|
||||
"type": "string",
|
||||
"format": "did",
|
||||
"description": "DID of successor hold for migration redirect"
|
||||
},
|
||||
"supporterBadgeTiers": {
|
||||
"type": "array",
|
||||
"description": "Tier names that earn a supporter badge on user profiles",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 64
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "io.atcr.hold.deleteWebhook",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "procedure",
|
||||
"description": "Delete a webhook configuration. Removes URL and secret from hold SQLite and deletes the io.atcr.hold.webhook record from the embedded PDS. Only the webhook owner can delete their own webhooks. Requires service token authentication.",
|
||||
"input": {
|
||||
"encoding": "application/json",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["rkey"],
|
||||
"properties": {
|
||||
"rkey": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"description": "Record key of the io.atcr.hold.webhook record to delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"encoding": "application/json",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["success"],
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the webhook was successfully deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": [
|
||||
{ "name": "WebhookNotFound", "description": "No webhook found with the given rkey" },
|
||||
{ "name": "Unauthorized", "description": "Webhook belongs to a different user" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "io.atcr.hold.listTiers",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "query",
|
||||
"description": "List the hold's available tiers with storage quotas (no pricing info).",
|
||||
"output": {
|
||||
"encoding": "application/json",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["tiers"],
|
||||
"properties": {
|
||||
"tiers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "ref",
|
||||
"ref": "#defs/tierInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tierInfo": {
|
||||
"type": "object",
|
||||
"required": ["name", "quotaBytes", "quotaFormatted", "scanOnPush"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"description": "Tier name."
|
||||
},
|
||||
"quotaBytes": {
|
||||
"type": "integer",
|
||||
"description": "Storage quota in bytes."
|
||||
},
|
||||
"quotaFormatted": {
|
||||
"type": "string",
|
||||
"maxLength": 32,
|
||||
"description": "Human-readable quota (e.g. '5.0 GB')."
|
||||
},
|
||||
"scanOnPush": {
|
||||
"type": "boolean",
|
||||
"description": "Whether pushing triggers an immediate vulnerability scan."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "io.atcr.hold.listWebhooks",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "query",
|
||||
"description": "List webhook configurations for a user. Returns masked URLs (never full URLs), trigger settings, and tier-based limits. Requires service token authentication.",
|
||||
"parameters": {
|
||||
"type": "params",
|
||||
"required": ["userDid"],
|
||||
"properties": {
|
||||
"userDid": {
|
||||
"type": "string",
|
||||
"format": "did",
|
||||
"description": "DID of the user to list webhooks for"
|
||||
}
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"encoding": "application/json",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["webhooks", "limits"],
|
||||
"properties": {
|
||||
"webhooks": {
|
||||
"type": "array",
|
||||
"description": "List of configured webhooks",
|
||||
"items": {
|
||||
"type": "ref",
|
||||
"ref": "#webhookEntry"
|
||||
}
|
||||
},
|
||||
"limits": {
|
||||
"type": "ref",
|
||||
"ref": "#webhookLimits"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"webhookEntry": {
|
||||
"type": "object",
|
||||
"required": ["rkey", "triggers", "url", "hasSecret", "createdAt"],
|
||||
"properties": {
|
||||
"rkey": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"description": "Record key of the io.atcr.hold.webhook record"
|
||||
},
|
||||
"triggers": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Bitmask of trigger events"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"maxLength": 2048,
|
||||
"description": "Masked webhook URL (e.g., https://exam***le.com/web***)"
|
||||
},
|
||||
"hasSecret": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the webhook has an HMAC signing secret configured"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "datetime",
|
||||
"description": "RFC3339 timestamp of when the webhook was created"
|
||||
}
|
||||
}
|
||||
},
|
||||
"webhookLimits": {
|
||||
"type": "object",
|
||||
"required": ["max", "allTriggers"],
|
||||
"properties": {
|
||||
"max": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of webhooks allowed for this user's tier (-1 for unlimited)"
|
||||
},
|
||||
"allTriggers": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the user's tier allows all trigger types (scan:all, scan:changed). Free tiers only get scan:first."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "io.atcr.hold.testWebhook",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "procedure",
|
||||
"description": "Send a test payload to a webhook URL. Delivers a synthetic scan result synchronously and reports whether delivery succeeded (2xx response). Only the webhook owner can test their own webhooks. Requires service token authentication.",
|
||||
"input": {
|
||||
"encoding": "application/json",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["rkey"],
|
||||
"properties": {
|
||||
"rkey": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"description": "Record key of the io.atcr.hold.webhook to test"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"encoding": "application/json",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["success"],
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the test delivery received a 2xx response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": [
|
||||
{ "name": "WebhookNotFound", "description": "No webhook found with the given rkey" },
|
||||
{ "name": "Unauthorized", "description": "Webhook belongs to a different user" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "io.atcr.hold.updateCrewTier",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "procedure",
|
||||
"description": "Update a crew member's tier. Only accepts requests from the trusted appview.",
|
||||
"input": {
|
||||
"encoding": "application/json",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["userDid", "tierRank"],
|
||||
"properties": {
|
||||
"userDid": {
|
||||
"type": "string",
|
||||
"format": "did",
|
||||
"description": "DID of the crew member whose tier is being updated."
|
||||
},
|
||||
"tierRank": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Tier rank index (0-based, maps to hold tier list by position)."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"encoding": "application/json",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["tierName"],
|
||||
"properties": {
|
||||
"tierName": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"description": "Resolved tier name on this hold."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": [
|
||||
{
|
||||
"name": "AuthRequired",
|
||||
"description": "Valid appview token required."
|
||||
},
|
||||
{
|
||||
"name": "UserNotFound",
|
||||
"description": "User is not a crew member on this hold."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "io.atcr.hold.webhook",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"key": "any",
|
||||
"description": "Webhook configuration stored in the hold's embedded PDS. The public portion of a two-record split: URL and HMAC secret are stored only in the hold's SQLite database (never in ATProto records). Record key is deterministic from user DID + sequence number.",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["userDid", "triggers", "createdAt"],
|
||||
"properties": {
|
||||
"userDid": {
|
||||
"type": "string",
|
||||
"format": "did",
|
||||
"description": "DID of the webhook owner"
|
||||
},
|
||||
"triggers": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "datetime",
|
||||
"description": "RFC3339 timestamp of when the webhook was created"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"lexicon": 1,
|
||||
"id": "io.atcr.sailor.webhook",
|
||||
"defs": {
|
||||
"main": {
|
||||
"type": "record",
|
||||
"key": "tid",
|
||||
"description": "Public webhook metadata stored in the user's PDS. Links to a private io.atcr.hold.webhook record on the hold where URL and secret are stored. Part of a two-record split: this record is visible via ATProto (Jetstream), the hold record is not.",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["holdDid", "triggers", "privateCid", "createdAt"],
|
||||
"properties": {
|
||||
"holdDid": {
|
||||
"type": "string",
|
||||
"format": "did",
|
||||
"description": "DID of the hold where the webhook is configured"
|
||||
},
|
||||
"triggers": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Bitmask of trigger events: 0x01=scan:first, 0x02=scan:all, 0x04=scan:changed"
|
||||
},
|
||||
"privateCid": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"description": "CID of the corresponding io.atcr.hold.webhook record on the hold"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "datetime",
|
||||
"description": "RFC3339 timestamp of when the webhook was created"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
"format": "datetime",
|
||||
"description": "RFC3339 timestamp of when the webhook was last updated"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"key": "any",
|
||||
"record": {
|
||||
"type": "object",
|
||||
"required": ["repository", "tag", "createdAt"],
|
||||
"required": ["repository", "tag"],
|
||||
"properties": {
|
||||
"repository": {
|
||||
"type": "string",
|
||||
@@ -30,10 +30,10 @@
|
||||
"description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead.",
|
||||
"maxLength": 128
|
||||
},
|
||||
"createdAt": {
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
"format": "datetime",
|
||||
"description": "Tag creation timestamp"
|
||||
"description": "Timestamp of last tag update"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-1
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/distribution/distribution/v3/configuration"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"atcr.io/pkg/billing"
|
||||
"atcr.io/pkg/config"
|
||||
)
|
||||
|
||||
@@ -31,6 +32,7 @@ type Config struct {
|
||||
Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."`
|
||||
CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper download settings."`
|
||||
Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."`
|
||||
Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."`
|
||||
Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility
|
||||
}
|
||||
|
||||
@@ -59,6 +61,9 @@ type ServerConfig struct {
|
||||
|
||||
// Separate domains for OCI registry API. First entry is the primary (used for JWT service name and UI display).
|
||||
RegistryDomains []string `yaml:"registry_domains" comment:"Separate domains for OCI registry API (e.g. [\"buoy.cr\"]). First is primary. Browser visits redirect to BaseURL."`
|
||||
|
||||
// DIDs of holds this appview manages billing for.
|
||||
ManagedHolds []string `yaml:"managed_holds" comment:"DIDs of holds this appview manages billing for. Tier updates are pushed to these holds."`
|
||||
}
|
||||
|
||||
// UIConfig defines web UI settings
|
||||
@@ -97,6 +102,9 @@ type JetstreamConfig struct {
|
||||
// Sync existing records from PDS on startup.
|
||||
BackfillEnabled bool `yaml:"backfill_enabled" comment:"Sync existing records from PDS on startup."`
|
||||
|
||||
// How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup.
|
||||
BackfillInterval time.Duration `yaml:"backfill_interval" comment:"How often to re-run backfill to catch missed events. Set to 0 to only backfill on startup."`
|
||||
|
||||
// Relay endpoints for backfill, tried in order on failure.
|
||||
RelayEndpoints []string `yaml:"relay_endpoints" comment:"Relay endpoints for backfill, tried in order on failure."`
|
||||
}
|
||||
@@ -146,6 +154,7 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("server.client_short_name", "ATCR")
|
||||
v.SetDefault("server.oauth_key_path", "/var/lib/atcr/oauth/client.key")
|
||||
v.SetDefault("server.registry_domains", []string{})
|
||||
v.SetDefault("server.managed_holds", []string{})
|
||||
|
||||
// UI defaults
|
||||
v.SetDefault("ui.database_path", "/var/lib/atcr/ui.db")
|
||||
@@ -166,6 +175,7 @@ func setDefaults(v *viper.Viper) {
|
||||
"wss://jetstream1.us-east.bsky.network/subscribe",
|
||||
})
|
||||
v.SetDefault("jetstream.backfill_enabled", true)
|
||||
v.SetDefault("jetstream.backfill_interval", "24h")
|
||||
v.SetDefault("jetstream.relay_endpoints", []string{
|
||||
"https://relay1.us-east.bsky.network",
|
||||
"https://relay1.us-west.bsky.network",
|
||||
@@ -199,7 +209,20 @@ func DefaultConfig() *Config {
|
||||
|
||||
// ExampleYAML returns a fully-commented YAML configuration with default values.
|
||||
func ExampleYAML() ([]byte, error) {
|
||||
return config.MarshalCommentedYAML("ATCR AppView Configuration", DefaultConfig())
|
||||
cfg := DefaultConfig()
|
||||
|
||||
// Populate example billing tiers so operators see the structure
|
||||
cfg.Billing.Currency = "usd"
|
||||
cfg.Billing.SuccessURL = "{base_url}/settings#storage"
|
||||
cfg.Billing.CancelURL = "{base_url}/settings#storage"
|
||||
cfg.Billing.OwnerBadge = true
|
||||
cfg.Billing.Tiers = []billing.BillingTierConfig{
|
||||
{Name: "deckhand", Description: "Get started with basic storage", MaxWebhooks: 1},
|
||||
{Name: "bosun", Description: "More storage with scan-on-push", StripePriceMonthly: "price_xxx", StripePriceYearly: "price_yyy", MaxWebhooks: 5, WebhookAllTriggers: true, SupporterBadge: true},
|
||||
{Name: "quartermaster", Description: "Maximum storage for power users", StripePriceMonthly: "price_xxx", StripePriceYearly: "price_yyy", MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true},
|
||||
}
|
||||
|
||||
return config.MarshalCommentedYAML("ATCR AppView Configuration", cfg)
|
||||
}
|
||||
|
||||
// LoadConfig builds a complete configuration using Viper layered loading:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetRepositoryAnnotations retrieves all annotations for a repository
|
||||
func GetRepositoryAnnotations(db DBTX, did, repository string) (map[string]string, error) {
|
||||
@@ -26,23 +29,44 @@ func GetRepositoryAnnotations(db DBTX, did, repository string) (map[string]strin
|
||||
return annotations, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertRepositoryAnnotations replaces all annotations for a repository
|
||||
// UpsertRepositoryAnnotations upserts annotations for a repository.
|
||||
// Stale keys not present in the new map are deleted.
|
||||
// Unchanged values are skipped to avoid unnecessary writes.
|
||||
// Only called when manifest has at least one non-empty annotation.
|
||||
// Atomicity is provided by the caller's transaction when used during backfill.
|
||||
func UpsertRepositoryAnnotations(db DBTX, did, repository string, annotations map[string]string) error {
|
||||
// Delete existing annotations
|
||||
// Delete keys that are no longer in the annotation set
|
||||
if len(annotations) == 0 {
|
||||
_, err := db.Exec(`
|
||||
DELETE FROM repository_annotations
|
||||
WHERE did = ? AND repository = ?
|
||||
`, did, repository)
|
||||
return err
|
||||
}
|
||||
|
||||
// Build placeholders for the NOT IN clause
|
||||
placeholders := make([]string, 0, len(annotations))
|
||||
args := []any{did, repository}
|
||||
for key := range annotations {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, key)
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
DELETE FROM repository_annotations
|
||||
WHERE did = ? AND repository = ?
|
||||
`, did, repository)
|
||||
WHERE did = ? AND repository = ? AND key NOT IN (`+strings.Join(placeholders, ",")+`)
|
||||
`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert new annotations
|
||||
// Upsert each annotation, only writing when value changed
|
||||
stmt, err := db.Prepare(`
|
||||
INSERT INTO repository_annotations (did, repository, key, value, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(did, repository, key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at
|
||||
WHERE excluded.value != repository_annotations.value
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,7 +2,6 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,15 +19,14 @@ func normalizeDateString(s string) string {
|
||||
|
||||
// HoldCaptainRecord represents a cached captain record from a hold's PDS
|
||||
type HoldCaptainRecord struct {
|
||||
HoldDID string `json:"-"` // Set manually, not from JSON
|
||||
OwnerDID string `json:"owner"`
|
||||
Public bool `json:"public"`
|
||||
AllowAllCrew bool `json:"allowAllCrew"`
|
||||
DeployedAt string `json:"deployedAt"`
|
||||
Region string `json:"region"`
|
||||
Successor string `json:"successor"` // DID of successor hold (migration redirect)
|
||||
SupporterBadgeTiers string `json:"-"` // JSON array of tier names, e.g. '["bosun","quartermaster"]'
|
||||
UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
|
||||
HoldDID string `json:"-"` // Set manually, not from JSON
|
||||
OwnerDID string `json:"owner"`
|
||||
Public bool `json:"public"`
|
||||
AllowAllCrew bool `json:"allowAllCrew"`
|
||||
DeployedAt string `json:"deployedAt"`
|
||||
Region string `json:"region"`
|
||||
Successor string `json:"successor"` // DID of successor hold (migration redirect)
|
||||
UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
|
||||
}
|
||||
|
||||
// GetCaptainRecord retrieves a captain record from the cache
|
||||
@@ -36,13 +34,13 @@ type HoldCaptainRecord struct {
|
||||
func GetCaptainRecord(db DBTX, holdDID string) (*HoldCaptainRecord, error) {
|
||||
query := `
|
||||
SELECT hold_did, owner_did, public, allow_all_crew,
|
||||
deployed_at, region, successor, supporter_badge_tiers, updated_at
|
||||
deployed_at, region, successor, updated_at
|
||||
FROM hold_captain_records
|
||||
WHERE hold_did = ?
|
||||
`
|
||||
|
||||
var record HoldCaptainRecord
|
||||
var deployedAt, region, successor, supporterBadgeTiers sql.NullString
|
||||
var deployedAt, region, successor sql.NullString
|
||||
|
||||
err := db.QueryRow(query, holdDID).Scan(
|
||||
&record.HoldDID,
|
||||
@@ -52,7 +50,6 @@ func GetCaptainRecord(db DBTX, holdDID string) (*HoldCaptainRecord, error) {
|
||||
&deployedAt,
|
||||
®ion,
|
||||
&successor,
|
||||
&supporterBadgeTiers,
|
||||
&record.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -74,9 +71,6 @@ func GetCaptainRecord(db DBTX, holdDID string) (*HoldCaptainRecord, error) {
|
||||
if successor.Valid {
|
||||
record.Successor = successor.String
|
||||
}
|
||||
if supporterBadgeTiers.Valid {
|
||||
record.SupporterBadgeTiers = supporterBadgeTiers.String
|
||||
}
|
||||
|
||||
return &record, nil
|
||||
}
|
||||
@@ -86,8 +80,8 @@ func UpsertCaptainRecord(db DBTX, record *HoldCaptainRecord) error {
|
||||
query := `
|
||||
INSERT INTO hold_captain_records (
|
||||
hold_did, owner_did, public, allow_all_crew,
|
||||
deployed_at, region, successor, supporter_badge_tiers, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
deployed_at, region, successor, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(hold_did) DO UPDATE SET
|
||||
owner_did = excluded.owner_did,
|
||||
public = excluded.public,
|
||||
@@ -95,8 +89,13 @@ func UpsertCaptainRecord(db DBTX, record *HoldCaptainRecord) error {
|
||||
deployed_at = excluded.deployed_at,
|
||||
region = excluded.region,
|
||||
successor = excluded.successor,
|
||||
supporter_badge_tiers = excluded.supporter_badge_tiers,
|
||||
updated_at = excluded.updated_at
|
||||
WHERE excluded.owner_did != hold_captain_records.owner_did
|
||||
OR excluded.public != hold_captain_records.public
|
||||
OR excluded.allow_all_crew != hold_captain_records.allow_all_crew
|
||||
OR excluded.deployed_at IS NOT hold_captain_records.deployed_at
|
||||
OR excluded.region IS NOT hold_captain_records.region
|
||||
OR excluded.successor IS NOT hold_captain_records.successor
|
||||
`
|
||||
|
||||
_, err := db.Exec(query,
|
||||
@@ -107,7 +106,6 @@ func UpsertCaptainRecord(db DBTX, record *HoldCaptainRecord) error {
|
||||
nullString(record.DeployedAt),
|
||||
nullString(record.Region),
|
||||
nullString(record.Successor),
|
||||
nullString(record.SupporterBadgeTiers),
|
||||
record.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -118,75 +116,6 @@ func UpsertCaptainRecord(db DBTX, record *HoldCaptainRecord) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasSupporterBadge checks if a given tier is in the hold's supporter badge tiers list.
|
||||
func (r *HoldCaptainRecord) HasSupporterBadge(tier string) bool {
|
||||
if r.SupporterBadgeTiers == "" || tier == "" {
|
||||
return false
|
||||
}
|
||||
var tiers []string
|
||||
if err := json.Unmarshal([]byte(r.SupporterBadgeTiers), &tiers); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, t := range tiers {
|
||||
if t == tier {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeDidWeb ensures did:web DIDs use %3A encoding for port separators.
|
||||
// This is a local copy to avoid importing atproto (prevents circular dependencies).
|
||||
func normalizeDidWeb(did string) string {
|
||||
if !strings.HasPrefix(did, "did:web:") {
|
||||
return did
|
||||
}
|
||||
host := strings.TrimPrefix(did, "did:web:")
|
||||
if !strings.Contains(host, "%3A") && strings.Contains(host, ":") {
|
||||
host = strings.Replace(host, ":", "%3A", 1)
|
||||
}
|
||||
return "did:web:" + host
|
||||
}
|
||||
|
||||
// GetSupporterBadge returns the supporter badge tier name for a user on a specific hold.
|
||||
// Returns empty string if the hold doesn't have badges, the user's tier isn't badge-eligible,
|
||||
// or the user isn't a member of the hold.
|
||||
func GetSupporterBadge(dbConn DBTX, userDID, holdDID string) string {
|
||||
if holdDID == "" || userDID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Normalize did:web encoding for consistent comparison
|
||||
holdDID = normalizeDidWeb(holdDID)
|
||||
|
||||
captain, err := GetCaptainRecord(dbConn, holdDID)
|
||||
if err != nil || captain == nil || captain.SupporterBadgeTiers == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If user is the owner and "owner" badge is enabled, show it
|
||||
if captain.OwnerDID == userDID && captain.HasSupporterBadge("owner") {
|
||||
return "owner"
|
||||
}
|
||||
|
||||
// Look up crew membership for this user on this hold
|
||||
memberships, err := GetCrewMemberships(dbConn, userDID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, m := range memberships {
|
||||
if normalizeDidWeb(m.HoldDID) == holdDID && m.Tier != "" {
|
||||
if captain.HasSupporterBadge(m.Tier) {
|
||||
return m.Tier
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetCrewHoldDID returns the hold DID from the user's most recent crew membership.
|
||||
// Used as a fallback when the user's DefaultHoldDID is not cached.
|
||||
func GetCrewHoldDID(db DBTX, memberDID string) string {
|
||||
@@ -342,6 +271,11 @@ func UpsertCrewMember(db DBTX, member *CrewMember) error {
|
||||
tier = excluded.tier,
|
||||
added_at = excluded.added_at,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE excluded.rkey != hold_crew_members.rkey
|
||||
OR excluded.role IS NOT hold_crew_members.role
|
||||
OR excluded.permissions IS NOT hold_crew_members.permissions
|
||||
OR excluded.tier IS NOT hold_crew_members.tier
|
||||
OR excluded.added_at IS NOT hold_crew_members.added_at
|
||||
`
|
||||
|
||||
_, err := db.Exec(query,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
description: Add webhooks and scans tables for appview-side webhook management and scan caching
|
||||
query: |
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_did TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT,
|
||||
triggers INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_did) REFERENCES users(did) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_user ON webhooks(user_did);
|
||||
CREATE TABLE IF NOT EXISTS scans (
|
||||
hold_did TEXT NOT NULL,
|
||||
manifest_digest TEXT NOT NULL,
|
||||
user_did TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
critical INTEGER NOT NULL DEFAULT 0,
|
||||
high INTEGER NOT NULL DEFAULT 0,
|
||||
medium INTEGER NOT NULL DEFAULT 0,
|
||||
low INTEGER NOT NULL DEFAULT 0,
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
scanner_version TEXT,
|
||||
scanned_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY(hold_did, manifest_digest)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_did);
|
||||
@@ -0,0 +1,3 @@
|
||||
description: Drop supporter_badge_tiers column (badges now determined by appview billing config)
|
||||
query: |
|
||||
ALTER TABLE hold_captain_records DROP COLUMN supporter_badge_tiers;
|
||||
+263
-10
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -369,6 +370,17 @@ func GetUserByHandle(db DBTX, handle string) (*User, error) {
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// InsertUserIfNotExists inserts a user record only if it doesn't already exist.
|
||||
// Used by non-profile collections to avoid unnecessary writes during backfill.
|
||||
func InsertUserIfNotExists(db DBTX, user *User) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(did) DO NOTHING
|
||||
`, user.DID, user.Handle, user.PDSEndpoint, user.Avatar, user.LastSeen)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertUser inserts or updates a user record
|
||||
func UpsertUser(db DBTX, user *User) error {
|
||||
_, err := db.Exec(`
|
||||
@@ -592,6 +604,12 @@ func InsertManifest(db DBTX, manifest *Manifest) (int64, error) {
|
||||
config_digest = excluded.config_digest,
|
||||
config_size = excluded.config_size,
|
||||
artifact_type = excluded.artifact_type
|
||||
WHERE excluded.hold_endpoint != manifests.hold_endpoint
|
||||
OR excluded.schema_version != manifests.schema_version
|
||||
OR excluded.media_type != manifests.media_type
|
||||
OR excluded.config_digest IS NOT manifests.config_digest
|
||||
OR excluded.config_size IS NOT manifests.config_size
|
||||
OR excluded.artifact_type != manifests.artifact_type
|
||||
`, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint,
|
||||
manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest,
|
||||
manifest.ConfigSize, manifest.ArtifactType, manifest.CreatedAt)
|
||||
@@ -614,8 +632,8 @@ func InsertManifest(db DBTX, manifest *Manifest) (int64, error) {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// InsertLayer inserts or updates a layer record.
|
||||
// Uses upsert so backfill re-processing populates new columns (e.g. annotations).
|
||||
// InsertLayer inserts a layer record, skipping if it already exists.
|
||||
// Layers are immutable — once created, their digest/size/media_type never change.
|
||||
func InsertLayer(db DBTX, layer *Layer) error {
|
||||
var annotationsJSON *string
|
||||
if len(layer.Annotations) > 0 {
|
||||
@@ -629,11 +647,7 @@ func InsertLayer(db DBTX, layer *Layer) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO layers (manifest_id, digest, size, media_type, layer_index, annotations)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(manifest_id, layer_index) DO UPDATE SET
|
||||
digest = excluded.digest,
|
||||
size = excluded.size,
|
||||
media_type = excluded.media_type,
|
||||
annotations = excluded.annotations
|
||||
ON CONFLICT(manifest_id, layer_index) DO NOTHING
|
||||
`, layer.ManifestID, layer.Digest, layer.Size, layer.MediaType, layer.LayerIndex, annotationsJSON)
|
||||
return err
|
||||
}
|
||||
@@ -646,6 +660,8 @@ func UpsertTag(db DBTX, tag *Tag) error {
|
||||
ON CONFLICT(did, repository, tag) DO UPDATE SET
|
||||
digest = excluded.digest,
|
||||
created_at = excluded.created_at
|
||||
WHERE excluded.digest != tags.digest
|
||||
OR excluded.created_at != tags.created_at
|
||||
`, tag.DID, tag.Repository, tag.Tag, tag.Digest, tag.CreatedAt)
|
||||
return err
|
||||
}
|
||||
@@ -1607,17 +1623,21 @@ func UpsertRepositoryStats(db DBTX, stats *RepositoryStats) error {
|
||||
last_pull = excluded.last_pull,
|
||||
push_count = excluded.push_count,
|
||||
last_push = excluded.last_push
|
||||
WHERE excluded.pull_count != repository_stats.pull_count
|
||||
OR excluded.last_pull IS NOT repository_stats.last_pull
|
||||
OR excluded.push_count != repository_stats.push_count
|
||||
OR excluded.last_push IS NOT repository_stats.last_push
|
||||
`, stats.DID, stats.Repository, stats.PullCount, stats.LastPull, stats.PushCount, stats.LastPush)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertStar inserts or updates a star record (idempotent)
|
||||
// UpsertStar inserts a star record, skipping if it already exists.
|
||||
// Stars are immutable — once created, they don't change.
|
||||
func UpsertStar(db DBTX, starrerDID, ownerDID, repository string, createdAt time.Time) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO stars (starrer_did, owner_did, repository, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(starrer_did, owner_did, repository) DO UPDATE SET
|
||||
created_at = excluded.created_at
|
||||
ON CONFLICT(starrer_did, owner_did, repository) DO NOTHING
|
||||
`, starrerDID, ownerDID, repository, createdAt)
|
||||
return err
|
||||
}
|
||||
@@ -1957,6 +1977,8 @@ func UpsertRepoPage(db DBTX, did, repository, description, avatarCID string, cre
|
||||
description = excluded.description,
|
||||
avatar_cid = excluded.avatar_cid,
|
||||
updated_at = excluded.updated_at
|
||||
WHERE excluded.description IS NOT repo_pages.description
|
||||
OR excluded.avatar_cid IS NOT repo_pages.avatar_cid
|
||||
`, did, repository, description, avatarCID, createdAt, updatedAt)
|
||||
return err
|
||||
}
|
||||
@@ -2005,3 +2027,234 @@ func GetRepoPagesByDID(db DBTX, did string) ([]RepoPage, error) {
|
||||
}
|
||||
return pages, rows.Err()
|
||||
}
|
||||
|
||||
// --- Webhook types and queries ---
|
||||
|
||||
// Webhook represents a webhook configuration stored in the appview DB
|
||||
type Webhook struct {
|
||||
ID string `json:"id"`
|
||||
UserDID string `json:"userDid"`
|
||||
URL string `json:"url"`
|
||||
Secret string `json:"-"`
|
||||
Triggers int `json:"triggers"`
|
||||
HasSecret bool `json:"hasSecret"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// CountWebhooks returns the number of webhooks configured for a user
|
||||
func CountWebhooks(db DBTX, userDID string) (int, error) {
|
||||
var count int
|
||||
err := db.QueryRow(`SELECT COUNT(*) FROM webhooks WHERE user_did = ?`, userDID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ListWebhooks returns webhook configurations for display (masked URLs, no secrets)
|
||||
func ListWebhooks(db DBTX, userDID string) ([]Webhook, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, user_did, url, secret, triggers, created_at
|
||||
FROM webhooks WHERE user_did = ? ORDER BY created_at ASC
|
||||
`, userDID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var webhooks []Webhook
|
||||
for rows.Next() {
|
||||
var w Webhook
|
||||
var secret string
|
||||
if err := rows.Scan(&w.ID, &w.UserDID, &w.URL, &secret, &w.Triggers, &w.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
w.HasSecret = secret != ""
|
||||
w.URL = maskWebhookURL(w.URL)
|
||||
webhooks = append(webhooks, w)
|
||||
}
|
||||
if webhooks == nil {
|
||||
webhooks = []Webhook{}
|
||||
}
|
||||
return webhooks, rows.Err()
|
||||
}
|
||||
|
||||
// GetWebhookByID returns a single webhook with full URL and secret (for dispatch/test)
|
||||
func GetWebhookByID(db DBTX, id string) (*Webhook, error) {
|
||||
var w Webhook
|
||||
err := db.QueryRow(`
|
||||
SELECT id, user_did, url, secret, triggers, created_at
|
||||
FROM webhooks WHERE id = ?
|
||||
`, id).Scan(&w.ID, &w.UserDID, &w.URL, &w.Secret, &w.Triggers, &w.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w.HasSecret = w.Secret != ""
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
// InsertWebhook creates a new webhook record
|
||||
func InsertWebhook(db DBTX, w *Webhook) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO webhooks (id, user_did, url, secret, triggers, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, w.ID, w.UserDID, w.URL, w.Secret, w.Triggers, w.CreatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteWebhook deletes a webhook by ID, validating ownership
|
||||
func DeleteWebhook(db DBTX, id, userDID string) error {
|
||||
result, err := db.Exec(`DELETE FROM webhooks WHERE id = ? AND user_did = ?`, id, userDID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("webhook not found or not owned by user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWebhooksForUser returns all webhooks with full URL+secret for dispatch
|
||||
func GetWebhooksForUser(db DBTX, userDID string) ([]Webhook, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, user_did, url, secret, triggers, created_at
|
||||
FROM webhooks WHERE user_did = ?
|
||||
`, userDID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var webhooks []Webhook
|
||||
for rows.Next() {
|
||||
var w Webhook
|
||||
if err := rows.Scan(&w.ID, &w.UserDID, &w.URL, &w.Secret, &w.Triggers, &w.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
w.HasSecret = w.Secret != ""
|
||||
webhooks = append(webhooks, w)
|
||||
}
|
||||
return webhooks, rows.Err()
|
||||
}
|
||||
|
||||
// maskWebhookURL masks a URL for display (shows scheme + host, hides path/query)
|
||||
func maskWebhookURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
if len(rawURL) > 30 {
|
||||
return rawURL[:30] + "***"
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
masked := u.Scheme + "://" + u.Host
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
masked += "/***"
|
||||
}
|
||||
return masked
|
||||
}
|
||||
|
||||
// --- Scan types and queries ---
|
||||
|
||||
// Scan represents a cached scan record from Jetstream
|
||||
type Scan struct {
|
||||
HoldDID string
|
||||
ManifestDigest string
|
||||
UserDID string
|
||||
Repository string
|
||||
Critical int
|
||||
High int
|
||||
Medium int
|
||||
Low int
|
||||
Total int
|
||||
ScannerVersion string
|
||||
ScannedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertScan inserts or updates a scan record, returning the previous scan for change detection
|
||||
func UpsertScan(db DBTX, scan *Scan) (*Scan, error) {
|
||||
// Fetch previous scan (if any) before upserting
|
||||
var prev *Scan
|
||||
var p Scan
|
||||
err := db.QueryRow(`
|
||||
SELECT hold_did, manifest_digest, user_did, repository, critical, high, medium, low, total, scanner_version, scanned_at
|
||||
FROM scans WHERE hold_did = ? AND manifest_digest = ?
|
||||
`, scan.HoldDID, scan.ManifestDigest).Scan(
|
||||
&p.HoldDID, &p.ManifestDigest, &p.UserDID, &p.Repository,
|
||||
&p.Critical, &p.High, &p.Medium, &p.Low, &p.Total,
|
||||
&p.ScannerVersion, &p.ScannedAt,
|
||||
)
|
||||
if err == nil {
|
||||
prev = &p
|
||||
}
|
||||
|
||||
// Upsert the new scan
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO scans (hold_did, manifest_digest, user_did, repository, critical, high, medium, low, total, scanner_version, scanned_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(hold_did, manifest_digest) DO UPDATE SET
|
||||
user_did = excluded.user_did,
|
||||
repository = excluded.repository,
|
||||
critical = excluded.critical,
|
||||
high = excluded.high,
|
||||
medium = excluded.medium,
|
||||
low = excluded.low,
|
||||
total = excluded.total,
|
||||
scanner_version = excluded.scanner_version,
|
||||
scanned_at = excluded.scanned_at
|
||||
WHERE excluded.critical != scans.critical
|
||||
OR excluded.high != scans.high
|
||||
OR excluded.medium != scans.medium
|
||||
OR excluded.low != scans.low
|
||||
OR excluded.total != scans.total
|
||||
OR excluded.scanner_version IS NOT scans.scanner_version
|
||||
OR excluded.scanned_at != scans.scanned_at
|
||||
`, scan.HoldDID, scan.ManifestDigest, scan.UserDID, scan.Repository,
|
||||
scan.Critical, scan.High, scan.Medium, scan.Low, scan.Total,
|
||||
scan.ScannerVersion, scan.ScannedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upsert scan: %w", err)
|
||||
}
|
||||
|
||||
return prev, nil
|
||||
}
|
||||
|
||||
// GetTagByDigest returns the most recent tag for a manifest digest in a user's repository
|
||||
func GetTagByDigest(db DBTX, userDID, repository, digest string) (string, error) {
|
||||
var tag string
|
||||
err := db.QueryRow(`
|
||||
SELECT tag FROM tags
|
||||
WHERE did = ? AND repository = ? AND digest = ?
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`, userDID, repository, digest).Scan(&tag)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// IsHoldCaptain returns true if userDID is the owner of any hold in the managedHolds list.
|
||||
func IsHoldCaptain(db DBTX, userDID string, managedHolds []string) (bool, error) {
|
||||
if userDID == "" || len(managedHolds) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(managedHolds))
|
||||
args := make([]any, 0, len(managedHolds)+1)
|
||||
args = append(args, userDID)
|
||||
for i, did := range managedHolds {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, did)
|
||||
}
|
||||
|
||||
var exists int
|
||||
err := db.QueryRow(
|
||||
`SELECT 1 FROM hold_captain_records WHERE owner_did = ? AND hold_did IN (`+strings.Join(placeholders, ",")+`) LIMIT 1`,
|
||||
args...,
|
||||
).Scan(&exists)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -186,7 +186,6 @@ CREATE TABLE IF NOT EXISTS hold_captain_records (
|
||||
deployed_at TEXT,
|
||||
region TEXT,
|
||||
successor TEXT,
|
||||
supporter_badge_tiers TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
|
||||
@@ -245,3 +244,30 @@ CREATE TABLE IF NOT EXISTS crypto_keys (
|
||||
key_data BLOB NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_did TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT,
|
||||
triggers INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_did) REFERENCES users(did) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_user ON webhooks(user_did);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scans (
|
||||
hold_did TEXT NOT NULL,
|
||||
manifest_digest TEXT NOT NULL,
|
||||
user_did TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
critical INTEGER NOT NULL DEFAULT 0,
|
||||
high INTEGER NOT NULL DEFAULT 0,
|
||||
medium INTEGER NOT NULL DEFAULT 0,
|
||||
low INTEGER NOT NULL DEFAULT 0,
|
||||
total INTEGER NOT NULL DEFAULT 0,
|
||||
scanner_version TEXT,
|
||||
scanned_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY(hold_did, manifest_digest)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_did);
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/holdhealth"
|
||||
"atcr.io/pkg/appview/readme"
|
||||
"atcr.io/pkg/appview/webhooks"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"atcr.io/pkg/billing"
|
||||
"github.com/bluesky-social/indigo/atproto/identity"
|
||||
)
|
||||
|
||||
@@ -25,10 +27,12 @@ type BaseUIHandler struct {
|
||||
ReadOnlyDB *sql.DB // Read-only access
|
||||
|
||||
// Services
|
||||
Refresher *oauth.Refresher
|
||||
HealthChecker *holdhealth.Checker
|
||||
ReadmeFetcher *readme.Fetcher
|
||||
Directory identity.Directory
|
||||
Refresher *oauth.Refresher
|
||||
HealthChecker *holdhealth.Checker
|
||||
ReadmeFetcher *readme.Fetcher
|
||||
Directory identity.Directory
|
||||
BillingManager *billing.Manager
|
||||
WebhookDispatcher *webhooks.Dispatcher
|
||||
|
||||
// Stores
|
||||
SessionStore *db.SessionStore
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"atcr.io/pkg/appview/storage"
|
||||
"atcr.io/pkg/appview/webhooks"
|
||||
"atcr.io/pkg/atproto"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
@@ -26,6 +28,7 @@ type HoldDisplay struct {
|
||||
Membership string `json:"membership"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
Status string `json:"status"` // "" = unknown, "online", "offline"
|
||||
IsActive bool `json:"isActive"`
|
||||
}
|
||||
|
||||
// SettingsHandler handles the settings page
|
||||
@@ -61,22 +64,22 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
slog.Debug("Fetched profile", "component", "settings", "did", user.DID, "default_hold", profile.DefaultHold)
|
||||
|
||||
// Get available holds for dropdown
|
||||
var ownedHolds, crewHolds, eligibleHolds []HoldDisplay
|
||||
holdDataMap := make(map[string]HoldDisplay)
|
||||
// Get available holds
|
||||
var activeHold *HoldDisplay
|
||||
var otherHolds, allHolds []HoldDisplay
|
||||
|
||||
if h.DB != nil {
|
||||
availableHolds, err := db.GetAvailableHolds(h.DB, user.DID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get available holds", "component", "settings", "did", user.DID, "error", err)
|
||||
} else {
|
||||
// Group holds by membership type
|
||||
for _, hold := range availableHolds {
|
||||
display := HoldDisplay{
|
||||
DID: hold.HoldDID,
|
||||
DisplayName: resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, hold.HoldDID),
|
||||
Region: hold.Region,
|
||||
Membership: hold.Membership,
|
||||
IsActive: hold.HoldDID == profile.DefaultHold,
|
||||
}
|
||||
|
||||
// Parse permissions JSON if present
|
||||
@@ -86,9 +89,9 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check cached health status (non-blocking, nil = no data yet)
|
||||
// Check health status (uses cache if available, otherwise pings on-demand)
|
||||
if h.HealthChecker != nil {
|
||||
if status := h.HealthChecker.GetCachedStatus(hold.HoldDID); status != nil {
|
||||
if status := h.HealthChecker.GetStatus(r.Context(), hold.HoldDID); status != nil {
|
||||
if status.Reachable {
|
||||
display.Status = "online"
|
||||
} else {
|
||||
@@ -97,37 +100,27 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add to data map for JavaScript
|
||||
holdDataMap[hold.HoldDID] = display
|
||||
// All holds go in dropdown list
|
||||
allHolds = append(allHolds, display)
|
||||
|
||||
// Group by membership type
|
||||
switch hold.Membership {
|
||||
case "owner":
|
||||
ownedHolds = append(ownedHolds, display)
|
||||
case "crew":
|
||||
crewHolds = append(crewHolds, display)
|
||||
case "eligible":
|
||||
eligibleHolds = append(eligibleHolds, display)
|
||||
// Separate active from other member holds (skip eligible)
|
||||
if hold.Membership != "eligible" {
|
||||
if display.IsActive {
|
||||
holdCopy := display
|
||||
activeHold = &holdCopy
|
||||
} else {
|
||||
otherHolds = append(otherHolds, display)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize hold data for JavaScript
|
||||
holdDataJSON, _ := json.Marshal(holdDataMap)
|
||||
// Fetch webhooks (local DB read)
|
||||
webhooksData := h.buildWebhooksData(user.DID)
|
||||
|
||||
// Check if current hold needs to be shown separately (not in discovered holds)
|
||||
_, currentHoldDiscovered := holdDataMap[profile.DefaultHold]
|
||||
showCurrentHold := profile.DefaultHold != "" && !currentHoldDiscovered
|
||||
|
||||
// Look up AppView default hold details from database
|
||||
appViewDefaultDisplay := resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, h.DefaultHoldDID)
|
||||
var appViewDefaultRegion string
|
||||
if h.DefaultHoldDID != "" && h.DB != nil {
|
||||
if captain, err := db.GetCaptainRecord(h.DB, h.DefaultHoldDID); err == nil && captain != nil {
|
||||
appViewDefaultRegion = captain.Region
|
||||
}
|
||||
}
|
||||
// Fetch subscription info (Stripe with in-memory cache)
|
||||
subscriptionData := h.buildSubscriptionDisplay(user.DID)
|
||||
|
||||
meta := NewPageMeta(
|
||||
"Settings - "+h.ClientShortName,
|
||||
@@ -144,29 +137,19 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
PDSEndpoint string
|
||||
DefaultHold string
|
||||
}
|
||||
CurrentHoldDID string
|
||||
CurrentHoldDisplay string
|
||||
ShowCurrentHold bool
|
||||
AppViewDefaultHoldDID string
|
||||
AppViewDefaultHoldDisplay string
|
||||
AppViewDefaultRegion string
|
||||
OwnedHolds []HoldDisplay
|
||||
CrewHolds []HoldDisplay
|
||||
EligibleHolds []HoldDisplay
|
||||
HoldDataJSON template.JS
|
||||
ActiveHold *HoldDisplay
|
||||
OtherHolds []HoldDisplay
|
||||
AllHolds []HoldDisplay
|
||||
WebhooksData webhooksTemplateData
|
||||
Subscription SubscriptionDisplay
|
||||
}{
|
||||
PageData: NewPageData(r, &h.BaseUIHandler),
|
||||
Meta: meta,
|
||||
CurrentHoldDID: profile.DefaultHold,
|
||||
CurrentHoldDisplay: resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, profile.DefaultHold),
|
||||
ShowCurrentHold: showCurrentHold,
|
||||
AppViewDefaultHoldDID: h.DefaultHoldDID,
|
||||
AppViewDefaultHoldDisplay: appViewDefaultDisplay,
|
||||
AppViewDefaultRegion: appViewDefaultRegion,
|
||||
OwnedHolds: ownedHolds,
|
||||
CrewHolds: crewHolds,
|
||||
EligibleHolds: eligibleHolds,
|
||||
HoldDataJSON: template.JS(holdDataJSON),
|
||||
PageData: NewPageData(r, &h.BaseUIHandler),
|
||||
Meta: meta,
|
||||
ActiveHold: activeHold,
|
||||
OtherHolds: otherHolds,
|
||||
AllHolds: allHolds,
|
||||
WebhooksData: webhooksData,
|
||||
Subscription: subscriptionData,
|
||||
}
|
||||
|
||||
data.Profile.Handle = user.Handle
|
||||
@@ -180,6 +163,97 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// webhooksTemplateData is the data passed to the webhooks_list template.
|
||||
type webhooksTemplateData struct {
|
||||
Webhooks []webhookEntry
|
||||
Limits webhookLimits
|
||||
ContainerID string
|
||||
TriggerInfo []triggerInfo
|
||||
}
|
||||
|
||||
// buildWebhooksData fetches webhook data for SSR in the settings page.
|
||||
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"},
|
||||
},
|
||||
}
|
||||
|
||||
maxWebhooks, allTriggers := h.getWebhookLimits(userDID)
|
||||
data.Limits = webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers}
|
||||
|
||||
webhookList, err := db.ListWebhooks(h.ReadOnlyDB, userDID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to list webhooks for settings SSR", "error", err)
|
||||
return data
|
||||
}
|
||||
|
||||
data.Webhooks = make([]webhookEntry, len(webhookList))
|
||||
for i, wh := range webhookList {
|
||||
data.Webhooks[i] = webhookEntry{
|
||||
ID: wh.ID,
|
||||
Triggers: wh.Triggers,
|
||||
URL: wh.URL,
|
||||
HasSecret: wh.HasSecret,
|
||||
CreatedAt: wh.CreatedAt.Format(time.RFC3339),
|
||||
HasFirst: wh.Triggers&webhooks.TriggerFirst != 0,
|
||||
HasAll: wh.Triggers&webhooks.TriggerAll != 0,
|
||||
HasChanged: wh.Triggers&webhooks.TriggerChanged != 0,
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// buildSubscriptionDisplay fetches subscription info for SSR in the settings page.
|
||||
func (h *SettingsHandler) buildSubscriptionDisplay(userDID string) SubscriptionDisplay {
|
||||
if h.BillingManager == nil || !h.BillingManager.Enabled() {
|
||||
return SubscriptionDisplay{HideBilling: true}
|
||||
}
|
||||
|
||||
info, err := h.BillingManager.GetSubscriptionInfo(userDID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get subscription info for settings SSR", "did", userDID, "error", err)
|
||||
return SubscriptionDisplay{HideBilling: true}
|
||||
}
|
||||
|
||||
if !info.PaymentsEnabled {
|
||||
return SubscriptionDisplay{HideBilling: true}
|
||||
}
|
||||
|
||||
display := SubscriptionDisplay{
|
||||
UserDID: info.UserDID,
|
||||
CurrentTier: info.CurrentTier,
|
||||
PaymentsEnabled: info.PaymentsEnabled,
|
||||
SubscriptionID: info.SubscriptionID,
|
||||
BillingInterval: info.BillingInterval,
|
||||
}
|
||||
|
||||
for _, tier := range info.Tiers {
|
||||
td := TierDisplay{
|
||||
ID: tier.ID,
|
||||
Name: tier.Name,
|
||||
Description: tier.Description,
|
||||
Features: tier.Features,
|
||||
PriceCentsMonthly: tier.PriceCentsMonthly,
|
||||
PriceCentsYearly: tier.PriceCentsYearly,
|
||||
IsCurrent: tier.IsCurrent,
|
||||
}
|
||||
if tier.PriceCentsMonthly > 0 {
|
||||
td.PriceMonthly = fmt.Sprintf("$%d/mo", tier.PriceCentsMonthly/100)
|
||||
}
|
||||
if tier.PriceCentsYearly > 0 {
|
||||
td.PriceYearly = fmt.Sprintf("$%d/yr", tier.PriceCentsYearly/100)
|
||||
}
|
||||
display.Tiers = append(display.Tiers, td)
|
||||
}
|
||||
|
||||
return display
|
||||
}
|
||||
|
||||
// resolveHoldDisplayName resolves a hold DID to a human-readable handle via the
|
||||
// identity directory. Falls back to domain extraction (did:web) or truncation (did:plc).
|
||||
func resolveHoldDisplayName(ctx context.Context, h *BaseUIHandler, did string) string {
|
||||
@@ -302,6 +376,7 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("HX-Refresh", "true")
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{
|
||||
"Type": "success",
|
||||
@@ -340,22 +415,12 @@ func refreshCaptainRecord(holdDID string, dbConn *sql.DB) {
|
||||
captainRecord.HoldDID = holdDID
|
||||
captainRecord.UpdatedAt = time.Now()
|
||||
|
||||
// Extract supporterBadgeTiers from raw JSON (db struct uses json:"-")
|
||||
var raw struct {
|
||||
SupporterBadgeTiers []string `json:"supporterBadgeTiers"`
|
||||
}
|
||||
if err := json.Unmarshal(record.Value, &raw); err == nil && len(raw.SupporterBadgeTiers) > 0 {
|
||||
if jsonBytes, err := json.Marshal(raw.SupporterBadgeTiers); err == nil {
|
||||
captainRecord.SupporterBadgeTiers = string(jsonBytes)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.UpsertCaptainRecord(dbConn, &captainRecord); err != nil {
|
||||
slog.Debug("Failed to cache captain record on refresh", "hold_did", holdDID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Refreshed captain record for hold", "hold_did", holdDID, "badge_tiers", captainRecord.SupporterBadgeTiers)
|
||||
slog.Info("Refreshed captain record for hold", "hold_did", holdDID)
|
||||
}
|
||||
|
||||
// refreshCrewMembership fetches a user's crew record from a hold and caches it locally.
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"atcr.io/pkg/appview/storage"
|
||||
"atcr.io/pkg/atproto"
|
||||
@@ -83,10 +82,23 @@ func (h *StorageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Render the stats partial
|
||||
// Render compact or full stats
|
||||
if r.URL.Query().Get("compact") == "true" {
|
||||
h.renderCompact(w, stats)
|
||||
return
|
||||
}
|
||||
h.renderStats(w, stats, holdDID)
|
||||
}
|
||||
|
||||
func (h *StorageHandler) renderCompact(w http.ResponseWriter, stats QuotaStats) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if stats.TotalSize == 0 && stats.UniqueBlobs == 0 {
|
||||
fmt.Fprint(w, `<span class="text-base-content/40">No data</span>`)
|
||||
} else {
|
||||
fmt.Fprint(w, humanizeBytes(stats.TotalSize))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StorageHandler) renderStats(w http.ResponseWriter, stats QuotaStats, holdDID string) {
|
||||
// Calculate usage percentage if limit exists
|
||||
var usagePercent int
|
||||
@@ -102,31 +114,22 @@ func (h *StorageHandler) renderStats(w http.ResponseWriter, stats QuotaStats, ho
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user's tier earns a supporter badge on this hold
|
||||
var hasSupporterBadge bool
|
||||
if stats.Tier != "" && h.ReadOnlyDB != nil && holdDID != "" {
|
||||
badge := db.GetSupporterBadge(h.ReadOnlyDB, stats.UserDID, holdDID)
|
||||
hasSupporterBadge = badge != ""
|
||||
}
|
||||
|
||||
data := struct {
|
||||
UniqueBlobs int
|
||||
TotalSize int64
|
||||
HumanSize string
|
||||
HasLimit bool
|
||||
HumanLimit string
|
||||
UsagePercent int
|
||||
Tier string
|
||||
HasSupporterBadge bool
|
||||
UniqueBlobs int
|
||||
TotalSize int64
|
||||
HumanSize string
|
||||
HasLimit bool
|
||||
HumanLimit string
|
||||
UsagePercent int
|
||||
Tier string
|
||||
}{
|
||||
UniqueBlobs: stats.UniqueBlobs,
|
||||
TotalSize: stats.TotalSize,
|
||||
HumanSize: humanizeBytes(stats.TotalSize),
|
||||
HasLimit: hasLimit,
|
||||
HumanLimit: humanLimit,
|
||||
UsagePercent: usagePercent,
|
||||
Tier: stats.Tier,
|
||||
HasSupporterBadge: hasSupporterBadge,
|
||||
UniqueBlobs: stats.UniqueBlobs,
|
||||
TotalSize: stats.TotalSize,
|
||||
HumanSize: humanizeBytes(stats.TotalSize),
|
||||
HasLimit: hasLimit,
|
||||
HumanLimit: humanLimit,
|
||||
UsagePercent: usagePercent,
|
||||
Tier: stats.Tier,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
||||
@@ -1,166 +1,38 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"atcr.io/pkg/appview/storage"
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/billing"
|
||||
)
|
||||
|
||||
// SubscriptionInfo mirrors the hold's billing.SubscriptionInfo for JSON decoding.
|
||||
type SubscriptionInfo struct {
|
||||
UserDID string `json:"userDid"`
|
||||
CurrentTier string `json:"currentTier"`
|
||||
CrewTier string `json:"crewTier,omitempty"`
|
||||
CurrentUsage int64 `json:"currentUsage"`
|
||||
CurrentLimit *int64 `json:"currentLimit,omitempty"`
|
||||
PaymentsEnabled bool `json:"paymentsEnabled"`
|
||||
Tiers []TierInfo `json:"tiers"`
|
||||
SubscriptionID string `json:"subscriptionId,omitempty"`
|
||||
BillingInterval string `json:"billingInterval,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
HideBilling bool `json:"-"` // hide entire section (no billing support)
|
||||
HoldDisplayName string `json:"-"` // human-readable hold name for display
|
||||
// SubscriptionDisplay is the template-friendly subscription data.
|
||||
type SubscriptionDisplay struct {
|
||||
UserDID string
|
||||
CurrentTier string
|
||||
PaymentsEnabled bool
|
||||
Tiers []TierDisplay
|
||||
SubscriptionID string
|
||||
BillingInterval string
|
||||
HideBilling bool
|
||||
}
|
||||
|
||||
// TierInfo mirrors the hold's billing.TierInfo.
|
||||
type TierInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
QuotaBytes int64 `json:"quotaBytes"`
|
||||
QuotaFormatted string `json:"quotaFormatted"`
|
||||
PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"`
|
||||
PriceCentsYearly int `json:"priceCentsYearly,omitempty"`
|
||||
PriceFormatted string `json:"-"` // computed in handler, e.g., "$5/month"
|
||||
IsCurrent bool `json:"isCurrent,omitempty"`
|
||||
// TierDisplay is a template-friendly tier.
|
||||
type TierDisplay struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Features []string
|
||||
PriceCentsMonthly int
|
||||
PriceCentsYearly int
|
||||
PriceMonthly string // e.g. "$5/mo"
|
||||
PriceYearly string // e.g. "$50/yr"
|
||||
IsCurrent bool
|
||||
}
|
||||
|
||||
// SubscriptionHandler returns subscription info as HTML for HTMX.
|
||||
type SubscriptionHandler struct {
|
||||
BaseUIHandler
|
||||
}
|
||||
|
||||
func (h *SubscriptionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get profile for subscription", "did", user.DID, "error", err)
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
holdDID = h.DefaultHoldDID
|
||||
if profile != nil && profile.DefaultHold != "" {
|
||||
holdDID = profile.DefaultHold
|
||||
}
|
||||
}
|
||||
|
||||
if holdDID == "" {
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve hold DID to endpoint
|
||||
holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err)
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch subscription info from hold (public endpoint, no auth needed)
|
||||
subURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getSubscriptionInfo?userDid=%s", holdEndpoint, user.DID)
|
||||
resp, err := http.Get(subURL)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch subscription info", "url", subURL, "error", err)
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
slog.Warn("Hold returned error for subscription", "status", resp.StatusCode)
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
|
||||
var info SubscriptionInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
slog.Warn("Failed to decode subscription info", "error", err)
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
|
||||
if !info.PaymentsEnabled {
|
||||
h.renderHidden(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Set hold display name so users know which hold the subscription applies to
|
||||
info.HoldDisplayName = resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, holdDID)
|
||||
|
||||
// Format prices for display
|
||||
// Note: -1 means "has price, fetch from Stripe" (placeholder from hold)
|
||||
for i := range info.Tiers {
|
||||
tier := &info.Tiers[i]
|
||||
hasMonthly := tier.PriceCentsMonthly != 0
|
||||
hasYearly := tier.PriceCentsYearly != 0
|
||||
|
||||
switch {
|
||||
case hasMonthly && tier.PriceCentsMonthly > 0:
|
||||
tier.PriceFormatted = fmt.Sprintf("$%d/month", tier.PriceCentsMonthly/100)
|
||||
case hasYearly && tier.PriceCentsYearly > 0:
|
||||
tier.PriceFormatted = fmt.Sprintf("$%d/year", tier.PriceCentsYearly/100)
|
||||
case hasMonthly || hasYearly:
|
||||
// Has price but we don't know the amount (-1 sentinel)
|
||||
tier.PriceFormatted = "Paid"
|
||||
default:
|
||||
tier.PriceFormatted = "Free"
|
||||
}
|
||||
}
|
||||
|
||||
// Render the subscription info
|
||||
h.renderInfo(w, info)
|
||||
}
|
||||
|
||||
func (h *SubscriptionHandler) renderInfo(w http.ResponseWriter, info SubscriptionInfo) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := h.Templates.ExecuteTemplate(w, "subscription_info", info); err != nil {
|
||||
slog.Error("Failed to render subscription template", "error", err)
|
||||
h.renderError(w, "Failed to render template")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SubscriptionHandler) renderHidden(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
info := SubscriptionInfo{HideBilling: true}
|
||||
if err := h.Templates.ExecuteTemplate(w, "subscription_info", info); err != nil {
|
||||
slog.Error("Failed to render hidden subscription template", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SubscriptionHandler) renderError(w http.ResponseWriter, message string) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<div class="alert alert-error"><svg class="icon size-5" aria-hidden="true"><use href="/icons.svg#alert-circle"></use></svg> %s</div>`, message)
|
||||
}
|
||||
|
||||
// SubscriptionCheckoutHandler redirects to hold's Stripe checkout.
|
||||
// SubscriptionCheckoutHandler redirects to Stripe checkout.
|
||||
type SubscriptionCheckoutHandler struct {
|
||||
BaseUIHandler
|
||||
}
|
||||
@@ -172,90 +44,36 @@ func (h *SubscriptionCheckoutHandler) ServeHTTP(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
if h.BillingManager == nil || !h.BillingManager.Enabled() {
|
||||
http.Error(w, "Billing not available", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
tier := r.URL.Query().Get("tier")
|
||||
if tier == "" {
|
||||
http.Error(w, "tier parameter required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user's default hold
|
||||
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
||||
profile, err := storage.GetProfile(r.Context(), client)
|
||||
interval := r.URL.Query().Get("interval")
|
||||
if interval == "" {
|
||||
interval = "monthly"
|
||||
}
|
||||
|
||||
resp, err := h.BillingManager.CreateCheckoutSession(r, user.DID, user.Handle, &billing.CheckoutSessionRequest{
|
||||
Tier: tier,
|
||||
Interval: interval,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to load profile", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
holdDID := h.DefaultHoldDID
|
||||
if profile != nil && profile.DefaultHold != "" {
|
||||
holdDID = profile.DefaultHold
|
||||
}
|
||||
|
||||
if holdDID == "" {
|
||||
http.Error(w, "No default hold configured", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve hold endpoint
|
||||
holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err)
|
||||
http.Error(w, "Failed to resolve hold", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get service token for the hold
|
||||
serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get service token for checkout", "did", user.DID, "holdDid", holdDID, "error", err)
|
||||
http.Error(w, "Failed to authenticate with hold", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Call hold's checkout endpoint
|
||||
checkoutURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.createCheckoutSession", holdEndpoint)
|
||||
reqBody := map[string]string{
|
||||
"tier": tier,
|
||||
"returnUrl": h.SiteURL + "/settings#storage",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), "POST", checkoutURL, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-User-DID", user.DID)
|
||||
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to call checkout endpoint", "error", err)
|
||||
slog.Warn("Failed to create checkout session", "did", user.DID, "tier", tier, "error", err)
|
||||
http.Error(w, "Failed to create checkout session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
http.Error(w, "Hold returned error", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var checkoutResp struct {
|
||||
CheckoutURL string `json:"checkoutUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&checkoutResp); err != nil {
|
||||
http.Error(w, "Invalid response from hold", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to Stripe checkout
|
||||
http.Redirect(w, r, checkoutResp.CheckoutURL, http.StatusFound)
|
||||
http.Redirect(w, r, resp.CheckoutURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// SubscriptionPortalHandler redirects to hold's Stripe billing portal.
|
||||
// SubscriptionPortalHandler redirects to Stripe billing portal.
|
||||
type SubscriptionPortalHandler struct {
|
||||
BaseUIHandler
|
||||
}
|
||||
@@ -267,73 +85,23 @@ func (h *SubscriptionPortalHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
// Get user's default hold
|
||||
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
||||
profile, err := storage.GetProfile(r.Context(), client)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to load profile", http.StatusInternalServerError)
|
||||
if h.BillingManager == nil || !h.BillingManager.Enabled() {
|
||||
http.Error(w, "Billing not available", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
holdDID := h.DefaultHoldDID
|
||||
if profile != nil && profile.DefaultHold != "" {
|
||||
holdDID = profile.DefaultHold
|
||||
scheme := "https"
|
||||
if r.TLS == nil {
|
||||
scheme = "http"
|
||||
}
|
||||
returnURL := scheme + "://" + h.SiteURL + "/settings#storage"
|
||||
|
||||
if holdDID == "" {
|
||||
http.Error(w, "No default hold configured", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve hold endpoint
|
||||
holdEndpoint, err := atproto.ResolveHoldURL(r.Context(), holdDID)
|
||||
resp, err := h.BillingManager.GetBillingPortalURL(user.DID, returnURL)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID, "error", err)
|
||||
http.Error(w, "Failed to resolve hold", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get service token
|
||||
serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get service token for portal", "did", user.DID, "holdDid", holdDID, "error", err)
|
||||
http.Error(w, "Failed to authenticate with hold", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Call hold's portal endpoint
|
||||
portalURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=%s/settings%%23storage", holdEndpoint, h.SiteURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(r.Context(), "GET", portalURL, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
req.Header.Set("X-User-DID", user.DID)
|
||||
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to call portal endpoint", "error", err)
|
||||
slog.Warn("Failed to get billing portal URL", "did", user.DID, "error", err)
|
||||
http.Error(w, "Failed to get billing portal", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
http.Error(w, "Hold returned error", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var portalResp struct {
|
||||
PortalURL string `json:"portalUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&portalResp); err != nil {
|
||||
http.Error(w, "Invalid response from hold", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to Stripe portal
|
||||
http.Redirect(w, r, portalResp.PortalURL, http.StatusFound)
|
||||
http.Redirect(w, r, resp.PortalURL, http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -62,18 +62,8 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
db.SetRegistryURL(cards, h.RegistryURL)
|
||||
|
||||
// Check for supporter badge on user's default hold
|
||||
var supporterBadge string
|
||||
if h.ReadOnlyDB != nil {
|
||||
holdDID := viewedUser.DefaultHoldDID
|
||||
if holdDID == "" {
|
||||
// Fallback: check if user has any crew membership
|
||||
holdDID = db.GetCrewHoldDID(h.ReadOnlyDB, viewedUser.DID)
|
||||
}
|
||||
if holdDID != "" {
|
||||
supporterBadge = db.GetSupporterBadge(h.ReadOnlyDB, viewedUser.DID, holdDID)
|
||||
}
|
||||
}
|
||||
// Check for supporter badge based on billing subscription
|
||||
supporterBadge := h.BillingManager.GetSupporterBadge(viewedUser.DID)
|
||||
|
||||
// Build page meta
|
||||
meta := NewPageMeta(
|
||||
|
||||
+101
-215
@@ -1,42 +1,35 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"atcr.io/pkg/appview/storage"
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/appview/webhooks"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// webhookListResponse mirrors the hold's listWebhooks response
|
||||
type webhookListResponse struct {
|
||||
Webhooks []webhookEntry `json:"webhooks"`
|
||||
Limits webhookLimits `json:"limits"`
|
||||
}
|
||||
|
||||
// webhookEntry is the template data for displaying a webhook
|
||||
type webhookEntry struct {
|
||||
Rkey string `json:"rkey"`
|
||||
Triggers int `json:"triggers"`
|
||||
URL string `json:"url"`
|
||||
HasSecret bool `json:"hasSecret"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string
|
||||
Triggers int
|
||||
URL string
|
||||
HasSecret bool
|
||||
CreatedAt string
|
||||
|
||||
// Computed fields (not from JSON)
|
||||
// Computed fields from bitmask
|
||||
HasFirst bool
|
||||
HasAll bool
|
||||
HasChanged bool
|
||||
}
|
||||
|
||||
type webhookLimits struct {
|
||||
Max int `json:"max"`
|
||||
AllTriggers bool `json:"allTriggers"`
|
||||
Max int
|
||||
AllTriggers bool
|
||||
}
|
||||
|
||||
// WebhooksHandler returns the webhooks list partial via HTMX
|
||||
@@ -51,43 +44,17 @@ func (h *WebhooksHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
holdDID, holdEndpoint, err := h.resolveUserHold(r, user)
|
||||
webhookList, err := db.ListWebhooks(h.ReadOnlyDB, user.DID)
|
||||
if err != nil {
|
||||
h.renderWebhookError(w, "Could not resolve hold: "+err.Error())
|
||||
slog.Warn("Failed to list webhooks", "error", err)
|
||||
h.renderWebhookError(w, "Failed to load webhooks")
|
||||
return
|
||||
}
|
||||
|
||||
serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint)
|
||||
if err != nil {
|
||||
h.renderWebhookError(w, "Failed to authenticate with hold")
|
||||
return
|
||||
}
|
||||
// Get tier limits from billing manager
|
||||
maxWebhooks, allTriggers := h.getWebhookLimits(user.DID)
|
||||
|
||||
// Fetch webhooks from hold
|
||||
listURL := fmt.Sprintf("%s%s?userDid=%s", holdEndpoint, atproto.HoldListWebhooks, user.DID)
|
||||
req, _ := http.NewRequestWithContext(r.Context(), "GET", listURL, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch webhooks from hold", "error", err)
|
||||
h.renderWebhookError(w, "Hold unreachable")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
h.renderWebhookError(w, fmt.Sprintf("Hold returned status %d", resp.StatusCode))
|
||||
return
|
||||
}
|
||||
|
||||
var listResp webhookListResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
|
||||
h.renderWebhookError(w, "Invalid response from hold")
|
||||
return
|
||||
}
|
||||
|
||||
h.renderWebhookList(w, listResp, holdDID)
|
||||
h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers})
|
||||
}
|
||||
|
||||
// AddWebhookHandler handles adding a new webhook via form POST
|
||||
@@ -109,84 +76,65 @@ func (h *AddWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate URL scheme
|
||||
if !strings.HasPrefix(webhookURL, "https://") && !strings.HasPrefix(webhookURL, "http://") {
|
||||
h.renderWebhookError(w, "Invalid webhook URL: must be https")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse trigger checkboxes
|
||||
triggers := 0
|
||||
if r.FormValue("trigger_first") == "on" {
|
||||
triggers |= atproto.TriggerFirst
|
||||
triggers |= webhooks.TriggerFirst
|
||||
}
|
||||
if r.FormValue("trigger_all") == "on" {
|
||||
triggers |= atproto.TriggerAll
|
||||
triggers |= webhooks.TriggerAll
|
||||
}
|
||||
if r.FormValue("trigger_changed") == "on" {
|
||||
triggers |= atproto.TriggerChanged
|
||||
triggers |= webhooks.TriggerChanged
|
||||
}
|
||||
if triggers == 0 {
|
||||
triggers = atproto.TriggerFirst // default
|
||||
triggers = webhooks.TriggerFirst // default
|
||||
}
|
||||
|
||||
holdDID, holdEndpoint, err := h.resolveUserHold(r, user)
|
||||
// Tier enforcement
|
||||
maxWebhooks, allTriggers := h.getWebhookLimits(user.DID)
|
||||
|
||||
// Check webhook count limit
|
||||
count, err := db.CountWebhooks(h.ReadOnlyDB, user.DID)
|
||||
if err != nil {
|
||||
h.renderWebhookError(w, "Could not resolve hold")
|
||||
h.renderWebhookError(w, "Failed to check webhook count")
|
||||
return
|
||||
}
|
||||
if maxWebhooks >= 0 && count >= maxWebhooks {
|
||||
h.renderWebhookError(w, "Webhook limit reached")
|
||||
return
|
||||
}
|
||||
|
||||
serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint)
|
||||
if err != nil {
|
||||
h.renderWebhookError(w, "Failed to authenticate with hold")
|
||||
// Trigger bitmask enforcement: free users can only set TriggerFirst
|
||||
if !allTriggers && triggers & ^webhooks.TriggerFirst != 0 {
|
||||
h.renderWebhookError(w, "Additional trigger types require a paid plan")
|
||||
return
|
||||
}
|
||||
|
||||
// Call hold addWebhook
|
||||
addBody, _ := json.Marshal(map[string]any{
|
||||
"url": webhookURL,
|
||||
"secret": secret,
|
||||
"triggers": triggers,
|
||||
})
|
||||
// Create webhook
|
||||
webhook := &db.Webhook{
|
||||
ID: uuid.New().String(),
|
||||
UserDID: user.DID,
|
||||
URL: webhookURL,
|
||||
Secret: secret,
|
||||
Triggers: triggers,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
addURL := holdEndpoint + atproto.HoldAddWebhook
|
||||
req, _ := http.NewRequestWithContext(r.Context(), "POST", addURL, bytes.NewReader(addBody))
|
||||
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
h.renderWebhookError(w, "Hold unreachable")
|
||||
if err := db.InsertWebhook(h.DB, webhook); err != nil {
|
||||
slog.Warn("Failed to insert webhook", "error", err)
|
||||
h.renderWebhookError(w, "Failed to add webhook")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
var errBody struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
body := make([]byte, 512)
|
||||
n, _ := resp.Body.Read(body)
|
||||
_ = json.Unmarshal(body[:n], &errBody)
|
||||
msg := string(body[:n])
|
||||
if errBody.Message != "" {
|
||||
msg = errBody.Message
|
||||
}
|
||||
h.renderWebhookError(w, "Failed to add webhook: "+msg)
|
||||
return
|
||||
}
|
||||
|
||||
var addResp struct {
|
||||
Rkey string `json:"rkey"`
|
||||
CID string `json:"cid"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&addResp)
|
||||
|
||||
// Write sailor webhook record to user's PDS
|
||||
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
||||
sailorRecord := atproto.NewSailorWebhookRecord(holdDID, triggers, addResp.CID)
|
||||
if _, err := client.PutRecord(r.Context(), atproto.SailorWebhookCollection, addResp.Rkey, sailorRecord); err != nil {
|
||||
slog.Warn("Failed to write sailor webhook record to PDS (hold record exists)",
|
||||
"did", user.DID, "rkey", addResp.Rkey, "error", err)
|
||||
// Not fatal — hold has the record, PDS write is best-effort
|
||||
}
|
||||
|
||||
// Re-render the full list
|
||||
h.refetchAndRender(w, r, user, holdDID, holdEndpoint, serviceToken)
|
||||
h.refetchAndRender(w, user)
|
||||
}
|
||||
|
||||
// DeleteWebhookHandler handles deleting a webhook
|
||||
@@ -201,52 +149,23 @@ func (h *DeleteWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
rkey := chi.URLParam(r, "id")
|
||||
if rkey == "" {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
h.renderWebhookError(w, "Missing webhook ID")
|
||||
return
|
||||
}
|
||||
|
||||
holdDID, holdEndpoint, err := h.resolveUserHold(r, user)
|
||||
if err != nil {
|
||||
h.renderWebhookError(w, "Could not resolve hold")
|
||||
if err := db.DeleteWebhook(h.DB, id, user.DID); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "not owned") {
|
||||
h.renderWebhookError(w, "Webhook not found")
|
||||
} else {
|
||||
h.renderWebhookError(w, "Failed to delete webhook")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint)
|
||||
if err != nil {
|
||||
h.renderWebhookError(w, "Failed to authenticate with hold")
|
||||
return
|
||||
}
|
||||
|
||||
// Call hold deleteWebhook
|
||||
delBody, _ := json.Marshal(map[string]string{"rkey": rkey})
|
||||
delURL := holdEndpoint + atproto.HoldDeleteWebhook
|
||||
req, _ := http.NewRequestWithContext(r.Context(), "POST", delURL, bytes.NewReader(delBody))
|
||||
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
h.renderWebhookError(w, "Hold unreachable")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
h.renderWebhookError(w, "Failed to delete webhook")
|
||||
return
|
||||
}
|
||||
|
||||
// Delete sailor webhook record from PDS (best-effort)
|
||||
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
||||
if err := client.DeleteRecord(r.Context(), atproto.SailorWebhookCollection, rkey); err != nil {
|
||||
slog.Warn("Failed to delete sailor webhook record from PDS",
|
||||
"did", user.DID, "rkey", rkey, "error", err)
|
||||
}
|
||||
|
||||
// Re-render the full list
|
||||
h.refetchAndRender(w, r, user, holdDID, holdEndpoint, serviceToken)
|
||||
h.refetchAndRender(w, user)
|
||||
}
|
||||
|
||||
// TestWebhookHandler sends a test payload
|
||||
@@ -261,43 +180,24 @@ func (h *TestWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
rkey := chi.URLParam(r, "id")
|
||||
if rkey == "" {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
h.renderWebhookError(w, "Missing webhook ID")
|
||||
return
|
||||
}
|
||||
|
||||
holdDID, holdEndpoint, err := h.resolveUserHold(r, user)
|
||||
if err != nil {
|
||||
h.renderWebhookError(w, "Could not resolve hold")
|
||||
if h.WebhookDispatcher == nil {
|
||||
h.renderAlert(w, "error", "Webhooks not configured")
|
||||
return
|
||||
}
|
||||
|
||||
serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint)
|
||||
success, err := h.WebhookDispatcher.DeliverTest(r.Context(), id, user.DID, user.Handle)
|
||||
if err != nil {
|
||||
h.renderWebhookError(w, "Failed to authenticate with hold")
|
||||
h.renderAlert(w, "error", "Webhook not found or unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
testBody, _ := json.Marshal(map[string]string{"rkey": rkey})
|
||||
testURL := holdEndpoint + atproto.HoldTestWebhook
|
||||
req, _ := http.NewRequestWithContext(r.Context(), "POST", testURL, bytes.NewReader(testBody))
|
||||
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
h.renderAlert(w, "error", "Hold unreachable")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var testResp struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&testResp)
|
||||
|
||||
if testResp.Success {
|
||||
if success {
|
||||
h.renderAlert(w, "success", "Test webhook delivered successfully!")
|
||||
} else {
|
||||
h.renderAlert(w, "error", "Test delivery failed - check the webhook URL")
|
||||
@@ -306,70 +206,56 @@ func (h *TestWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ---- Shared helpers ----
|
||||
|
||||
func (h *BaseUIHandler) resolveUserHold(r *http.Request, user *db.User) (holdDID, holdEndpoint string, err error) {
|
||||
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
||||
profile, profileErr := storage.GetProfile(r.Context(), client)
|
||||
|
||||
holdDID = h.DefaultHoldDID
|
||||
if profileErr == nil && profile != nil && profile.DefaultHold != "" {
|
||||
holdDID = profile.DefaultHold
|
||||
// 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)
|
||||
}
|
||||
|
||||
if holdDID == "" {
|
||||
return "", "", fmt.Errorf("no hold configured")
|
||||
}
|
||||
|
||||
holdEndpoint, err = atproto.ResolveHoldURL(r.Context(), holdDID)
|
||||
if err != nil {
|
||||
return holdDID, "", fmt.Errorf("failed to resolve hold: %w", err)
|
||||
}
|
||||
return holdDID, holdEndpoint, nil
|
||||
return 1, false
|
||||
}
|
||||
|
||||
func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, r *http.Request, user *db.User, holdDID, holdEndpoint, serviceToken string) {
|
||||
listURL := fmt.Sprintf("%s%s?userDid=%s", holdEndpoint, atproto.HoldListWebhooks, user.DID)
|
||||
req, _ := http.NewRequestWithContext(r.Context(), "GET", listURL, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
func (h *BaseUIHandler) refetchAndRender(w http.ResponseWriter, user *db.User) {
|
||||
webhookList, err := db.ListWebhooks(h.ReadOnlyDB, user.DID)
|
||||
if err != nil {
|
||||
h.renderWebhookError(w, "Failed to refresh webhook list")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var listResp webhookListResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
|
||||
h.renderWebhookError(w, "Invalid response from hold")
|
||||
return
|
||||
}
|
||||
|
||||
h.renderWebhookList(w, listResp, holdDID)
|
||||
maxWebhooks, allTriggers := h.getWebhookLimits(user.DID)
|
||||
h.renderWebhookList(w, webhookList, webhookLimits{Max: maxWebhooks, AllTriggers: allTriggers})
|
||||
}
|
||||
|
||||
func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, data webhookListResponse, holdDID string) {
|
||||
func (h *BaseUIHandler) renderWebhookList(w http.ResponseWriter, dbWebhooks []db.Webhook, limits webhookLimits) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
||||
// Populate computed trigger fields from bitmask
|
||||
for i := range data.Webhooks {
|
||||
data.Webhooks[i].HasFirst = data.Webhooks[i].Triggers&atproto.TriggerFirst != 0
|
||||
data.Webhooks[i].HasAll = data.Webhooks[i].Triggers&atproto.TriggerAll != 0
|
||||
data.Webhooks[i].HasChanged = data.Webhooks[i].Triggers&atproto.TriggerChanged != 0
|
||||
// Convert DB webhooks to template entries with computed trigger fields
|
||||
entries := make([]webhookEntry, len(dbWebhooks))
|
||||
for i, wh := range dbWebhooks {
|
||||
entries[i] = webhookEntry{
|
||||
ID: wh.ID,
|
||||
Triggers: wh.Triggers,
|
||||
URL: wh.URL,
|
||||
HasSecret: wh.HasSecret,
|
||||
CreatedAt: wh.CreatedAt.Format(time.RFC3339),
|
||||
HasFirst: wh.Triggers&webhooks.TriggerFirst != 0,
|
||||
HasAll: wh.Triggers&webhooks.TriggerAll != 0,
|
||||
HasChanged: wh.Triggers&webhooks.TriggerChanged != 0,
|
||||
}
|
||||
}
|
||||
|
||||
templateData := struct {
|
||||
Webhooks []webhookEntry
|
||||
Limits webhookLimits
|
||||
HoldDID string
|
||||
ContainerID string
|
||||
TriggerInfo []triggerInfo
|
||||
}{
|
||||
Webhooks: data.Webhooks,
|
||||
Limits: data.Limits,
|
||||
HoldDID: holdDID,
|
||||
Webhooks: entries,
|
||||
Limits: limits,
|
||||
ContainerID: "webhooks-content",
|
||||
TriggerInfo: []triggerInfo{
|
||||
{Name: "scan:first", Bit: atproto.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true},
|
||||
{Name: "scan:all", Bit: atproto.TriggerAll, Label: "Every scan", Description: "On every scan completion"},
|
||||
{Name: "scan:changed", Bit: atproto.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"},
|
||||
{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"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package holdclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// HoldTierInfo describes a single tier from a hold's listTiers response.
|
||||
type HoldTierInfo struct {
|
||||
Name string `json:"name"`
|
||||
QuotaBytes int64 `json:"quotaBytes"`
|
||||
QuotaFormatted string `json:"quotaFormatted"`
|
||||
ScanOnPush bool `json:"scanOnPush"`
|
||||
}
|
||||
|
||||
// HoldTiersResponse is the response from a hold's io.atcr.hold.listTiers endpoint.
|
||||
type HoldTiersResponse struct {
|
||||
Tiers []HoldTierInfo `json:"tiers"`
|
||||
}
|
||||
|
||||
// ListTiers queries a hold's public listTiers endpoint to get tier definitions.
|
||||
// No authentication is required.
|
||||
func ListTiers(ctx context.Context, holdDID string) (*HoldTiersResponse, error) {
|
||||
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve hold DID to URL: %w", err)
|
||||
}
|
||||
|
||||
url := strings.TrimSuffix(holdURL, "/") + atproto.HoldListTiers
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query listTiers on %s: %w", holdDID, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("listTiers on %s returned %d: %s", holdDID, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result HoldTiersResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode listTiers response from %s: %w", holdDID, err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package holdclient provides client functions for the appview to call hold XRPC endpoints.
|
||||
package holdclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
)
|
||||
|
||||
// UpdateCrewTierOnHold calls io.atcr.hold.updateCrewTier on a specific hold.
|
||||
// It signs a short-lived JWT with the appview's P-256 key and sends the tier update request.
|
||||
func UpdateCrewTierOnHold(ctx context.Context, holdDID, holdURL, userDID string, tierRank int, privateKey *atcrypto.PrivateKeyP256, appviewDID string) error {
|
||||
// Sign appview service token
|
||||
token, err := auth.CreateAppviewServiceToken(privateKey, appviewDID, holdDID, userDID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create appview token: %w", err)
|
||||
}
|
||||
|
||||
// Build request body
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"userDid": userDID,
|
||||
"tierRank": tierRank,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Build URL
|
||||
url := strings.TrimSuffix(holdURL, "/") + atproto.HoldUpdateCrewTier
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to call updateCrewTier on %s: %w", holdDID, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("updateCrewTier on %s returned %d: %s", holdDID, resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// urlFromDIDWeb converts a did:web to its HTTPS URL.
|
||||
func urlFromDIDWeb(did string) string {
|
||||
if !strings.HasPrefix(did, "did:web:") {
|
||||
return ""
|
||||
}
|
||||
host := strings.TrimPrefix(did, "did:web:")
|
||||
host = strings.ReplaceAll(host, "%3A", ":")
|
||||
return "https://" + host
|
||||
}
|
||||
|
||||
// UpdateCrewTierOnAllHolds pushes a tier update to all managed holds.
|
||||
// It resolves each hold DID to a URL and calls updateCrewTier.
|
||||
// Errors are logged but do not cause the function to fail — best effort.
|
||||
func UpdateCrewTierOnAllHolds(ctx context.Context, managedHolds []string, userDID string, tierRank int, privateKey *atcrypto.PrivateKeyP256, appviewDID string) {
|
||||
for _, holdDID := range managedHolds {
|
||||
holdURL := urlFromDIDWeb(holdDID)
|
||||
if holdURL == "" {
|
||||
slog.Warn("Could not resolve hold DID to URL, skipping", "holdDID", holdDID)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := UpdateCrewTierOnHold(ctx, holdDID, holdURL, userDID, tierRank, privateKey, appviewDID); err != nil {
|
||||
slog.Error("Failed to update crew tier on hold",
|
||||
"holdDID", holdDID,
|
||||
"userDID", userDID,
|
||||
"tierRank", tierRank,
|
||||
"error", err,
|
||||
)
|
||||
} else {
|
||||
slog.Info("Updated crew tier on hold",
|
||||
"holdDID", holdDID,
|
||||
"userDID", userDID,
|
||||
"tierRank", tierRank,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ func (b *BackfillWorker) Start(ctx context.Context) error {
|
||||
atproto.StatsCollection, // io.atcr.hold.stats (from holds)
|
||||
atproto.CaptainCollection, // io.atcr.hold.captain (from holds)
|
||||
atproto.CrewCollection, // io.atcr.hold.crew (from holds)
|
||||
atproto.ScanCollection, // io.atcr.hold.scan (from holds)
|
||||
}
|
||||
|
||||
for _, collection := range collections {
|
||||
@@ -433,16 +434,6 @@ func (b *BackfillWorker) queryCaptainRecord(ctx context.Context, holdDID string)
|
||||
captainRecord.HoldDID = holdDID
|
||||
captainRecord.UpdatedAt = time.Now()
|
||||
|
||||
// Extract supporterBadgeTiers from raw JSON (db struct uses json:"-" so unmarshal skips it)
|
||||
var raw struct {
|
||||
SupporterBadgeTiers []string `json:"supporterBadgeTiers"`
|
||||
}
|
||||
if err := json.Unmarshal(record.Value, &raw); err == nil && len(raw.SupporterBadgeTiers) > 0 {
|
||||
if jsonBytes, err := json.Marshal(raw.SupporterBadgeTiers); err == nil {
|
||||
captainRecord.SupporterBadgeTiers = string(jsonBytes)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.UpsertCaptainRecord(b.db, &captainRecord); err != nil {
|
||||
return fmt.Errorf("failed to cache captain record: %w", err)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/atproto"
|
||||
atpdata "github.com/bluesky-social/indigo/atproto/atdata"
|
||||
"github.com/bluesky-social/indigo/atproto/identity"
|
||||
"github.com/bluesky-social/indigo/atproto/lexicon"
|
||||
)
|
||||
@@ -17,11 +18,24 @@ import (
|
||||
// Processor handles shared database operations for both Worker (live) and Backfill (sync)
|
||||
// This eliminates code duplication between the two data ingestion paths
|
||||
type Processor struct {
|
||||
db db.DBTX
|
||||
userCache *UserCache // Optional - enabled for Worker, disabled for Backfill
|
||||
statsCache *StatsCache // In-memory cache for per-hold stats aggregation
|
||||
useCache bool
|
||||
catalog *lexicon.ResolvingCatalog // For debug logging of validation failures
|
||||
db db.DBTX
|
||||
userCache *UserCache // Optional - enabled for Worker, disabled for Backfill
|
||||
statsCache *StatsCache // In-memory cache for per-hold stats aggregation
|
||||
useCache bool
|
||||
catalog *lexicon.ResolvingCatalog // For debug logging of validation failures
|
||||
webhookDispatcher WebhookDispatcher // Optional - only for live Worker (nil for Backfill)
|
||||
}
|
||||
|
||||
// WebhookDispatcher is an interface for dispatching webhooks on scan completion.
|
||||
// Only the live Worker sets this; backfill does NOT (avoids spamming old scan results).
|
||||
type WebhookDispatcher interface {
|
||||
DispatchForScan(ctx context.Context, scan, previousScan *db.Scan, userHandle, tag, holdEndpoint string)
|
||||
}
|
||||
|
||||
// SetWebhookDispatcher sets the webhook dispatcher for scan processing.
|
||||
// Only the live Worker should set this — backfill skips webhook dispatch.
|
||||
func (p *Processor) SetWebhookDispatcher(d WebhookDispatcher) {
|
||||
p.webhookDispatcher = d
|
||||
}
|
||||
|
||||
// NewProcessor creates a new shared processor
|
||||
@@ -107,12 +121,60 @@ func (p *Processor) EnsureUser(ctx context.Context, did string) error {
|
||||
return db.UpsertUserIgnoreAvatar(p.db, user)
|
||||
}
|
||||
|
||||
// EnsureUserExists ensures a user row exists in the database without updating it.
|
||||
// Used by non-profile collections to avoid unnecessary writes during backfill.
|
||||
// If the user doesn't exist, resolves identity and inserts with ON CONFLICT DO NOTHING.
|
||||
func (p *Processor) EnsureUserExists(ctx context.Context, did string) error {
|
||||
// Check cache first (if enabled)
|
||||
if p.useCache && p.userCache != nil {
|
||||
if _, ok := p.userCache.cache[did]; ok {
|
||||
return nil // User in cache, nothing to do
|
||||
}
|
||||
} else if !p.useCache {
|
||||
// No cache - check if user already exists in DB
|
||||
existingUser, err := db.GetUserByDID(p.db, did)
|
||||
if err == nil && existingUser != nil {
|
||||
return nil // User exists, nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
// User doesn't exist yet — resolve and insert
|
||||
resolvedDID, handle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, did)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
avatarURL := ""
|
||||
client := atproto.NewClient(pdsEndpoint, "", "")
|
||||
profileRecord, err := client.GetProfileRecord(ctx, resolvedDID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch profile record", "component", "processor", "did", resolvedDID, "error", err)
|
||||
} else if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" {
|
||||
avatarURL = atproto.BlobCDNURL(resolvedDID, profileRecord.Avatar.Ref.Link)
|
||||
}
|
||||
|
||||
user := &db.User{
|
||||
DID: resolvedDID,
|
||||
Handle: handle,
|
||||
PDSEndpoint: pdsEndpoint,
|
||||
Avatar: avatarURL,
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
|
||||
// Cache if enabled
|
||||
if p.useCache {
|
||||
p.userCache.cache[did] = user
|
||||
}
|
||||
|
||||
return db.InsertUserIfNotExists(p.db, user)
|
||||
}
|
||||
|
||||
// ValidateRecord performs validation on records.
|
||||
// - Full lexicon validation is logged for debugging but does NOT block ingestion
|
||||
// - Targeted validation (captain/crew DID checks) DOES block bogus records
|
||||
func (p *Processor) ValidateRecord(ctx context.Context, collection string, data []byte) error {
|
||||
var recordData map[string]any
|
||||
if err := json.Unmarshal(data, &recordData); err != nil {
|
||||
recordData, err := atpdata.UnmarshalJSON(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
|
||||
@@ -171,13 +233,18 @@ func (p *Processor) ProcessRecord(ctx context.Context, did, collection, rkey str
|
||||
// Skip for deletes - user should already exist, and we don't need to resolve identity
|
||||
if !isDelete {
|
||||
switch collection {
|
||||
case atproto.SailorProfileCollection:
|
||||
// Sailor profile is the authoritative source for user data — full upsert
|
||||
if err := p.EnsureUser(ctx, did); err != nil {
|
||||
return fmt.Errorf("failed to ensure user: %w", err)
|
||||
}
|
||||
case atproto.ManifestCollection,
|
||||
atproto.TagCollection,
|
||||
atproto.StarCollection,
|
||||
atproto.RepoPageCollection,
|
||||
atproto.SailorProfileCollection:
|
||||
if err := p.EnsureUser(ctx, did); err != nil {
|
||||
return fmt.Errorf("failed to ensure user: %w", err)
|
||||
atproto.RepoPageCollection:
|
||||
// Other user collections just need the row to exist — no update if unchanged
|
||||
if err := p.EnsureUserExists(ctx, did); err != nil {
|
||||
return fmt.Errorf("failed to ensure user exists: %w", err)
|
||||
}
|
||||
// Hold collections (captain, crew, stats) - don't create user entries
|
||||
// These are records FROM holds, not user activity
|
||||
@@ -216,6 +283,9 @@ func (p *Processor) ProcessRecord(ctx context.Context, did, collection, rkey str
|
||||
case atproto.SailorProfileCollection:
|
||||
return p.ProcessSailorProfile(ctx, did, data, queryCaptainFn)
|
||||
|
||||
case atproto.ScanCollection:
|
||||
return p.ProcessScan(ctx, did, data, isDelete)
|
||||
|
||||
case atproto.StatsCollection:
|
||||
return p.ProcessStats(ctx, did, data, isDelete)
|
||||
|
||||
@@ -424,7 +494,7 @@ func (p *Processor) ProcessStar(ctx context.Context, did string, recordData []by
|
||||
// Ensure the starred repository's owner exists in the users table
|
||||
// (the starrer is already ensured by ProcessRecord, but the owner
|
||||
// may not have been processed yet during backfill or live events)
|
||||
if err := p.EnsureUser(ctx, ownerDID); err != nil {
|
||||
if err := p.EnsureUserExists(ctx, ownerDID); err != nil {
|
||||
return fmt.Errorf("failed to ensure star subject user: %w", err)
|
||||
}
|
||||
|
||||
@@ -615,6 +685,78 @@ func (p *Processor) RefreshUserAvatar(ctx context.Context, did, pdsEndpoint stri
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessScan handles scan record events from hold PDSes.
|
||||
// Caches scan results in the appview DB and dispatches webhooks (if dispatcher is set).
|
||||
func (p *Processor) ProcessScan(ctx context.Context, holdDID string, recordData []byte, isDelete bool) error {
|
||||
if isDelete {
|
||||
return nil // Scan deletes are not processed (scans are immutable)
|
||||
}
|
||||
|
||||
// Unmarshal scan record
|
||||
var scanRecord atproto.ScanRecord
|
||||
if err := json.Unmarshal(recordData, &scanRecord); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal scan record: %w", err)
|
||||
}
|
||||
|
||||
// Extract manifest digest from the scan record's manifest AT-URI
|
||||
manifestDigest := ""
|
||||
if parts := strings.Split(scanRecord.Manifest, "/"); len(parts) > 0 {
|
||||
manifestDigest = "sha256:" + parts[len(parts)-1]
|
||||
}
|
||||
|
||||
// Parse scanned_at timestamp
|
||||
scannedAt := time.Now()
|
||||
if t, err := time.Parse(time.RFC3339, scanRecord.ScannedAt); err == nil {
|
||||
scannedAt = t
|
||||
}
|
||||
|
||||
scan := &db.Scan{
|
||||
HoldDID: holdDID,
|
||||
ManifestDigest: manifestDigest,
|
||||
UserDID: scanRecord.UserDID,
|
||||
Repository: scanRecord.Repository,
|
||||
Critical: int(scanRecord.Critical),
|
||||
High: int(scanRecord.High),
|
||||
Medium: int(scanRecord.Medium),
|
||||
Low: int(scanRecord.Low),
|
||||
Total: int(scanRecord.Total),
|
||||
ScannerVersion: scanRecord.ScannerVersion,
|
||||
ScannedAt: scannedAt,
|
||||
}
|
||||
|
||||
// Upsert scan to DB (returns previous scan for change detection)
|
||||
previousScan, err := db.UpsertScan(p.db, scan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upsert scan: %w", err)
|
||||
}
|
||||
|
||||
// Dispatch webhooks if dispatcher is set (live Worker only, not backfill)
|
||||
if p.webhookDispatcher != nil {
|
||||
// Resolve user handle from cache or DB
|
||||
userHandle := ""
|
||||
user, userErr := db.GetUserByDID(p.db, scanRecord.UserDID)
|
||||
if userErr == nil && user != nil {
|
||||
userHandle = user.Handle
|
||||
}
|
||||
|
||||
// Resolve tag for the manifest digest
|
||||
tag := ""
|
||||
if tagVal, tagErr := db.GetTagByDigest(p.db, scanRecord.UserDID, scanRecord.Repository, manifestDigest); tagErr == nil {
|
||||
tag = tagVal
|
||||
}
|
||||
|
||||
// Resolve hold endpoint URL
|
||||
holdEndpoint := ""
|
||||
if holdURL, holdErr := atproto.ResolveHoldURL(ctx, holdDID); holdErr == nil {
|
||||
holdEndpoint = holdURL
|
||||
}
|
||||
|
||||
p.webhookDispatcher.DispatchForScan(ctx, scan, previousScan, userHandle, tag, holdEndpoint)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessStats handles stats record events from hold PDSes
|
||||
// This is called when Jetstream receives a stats create/update/delete event from a hold
|
||||
// The holdDID is the DID of the hold PDS (event.DID), and the record contains ownerDID + repository
|
||||
@@ -679,25 +821,16 @@ func (p *Processor) ProcessCaptain(ctx context.Context, holdDID string, recordDa
|
||||
return fmt.Errorf("failed to unmarshal captain record: %w", err)
|
||||
}
|
||||
|
||||
// Marshal supporter badge tiers to JSON string for storage
|
||||
badgeTiersJSON := ""
|
||||
if len(captainRecord.SupporterBadgeTiers) > 0 {
|
||||
if jsonBytes, err := json.Marshal(captainRecord.SupporterBadgeTiers); err == nil {
|
||||
badgeTiersJSON = string(jsonBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to db struct and upsert
|
||||
record := &db.HoldCaptainRecord{
|
||||
HoldDID: holdDID,
|
||||
OwnerDID: captainRecord.Owner,
|
||||
Public: captainRecord.Public,
|
||||
AllowAllCrew: captainRecord.AllowAllCrew,
|
||||
DeployedAt: captainRecord.DeployedAt,
|
||||
Region: captainRecord.Region,
|
||||
Successor: captainRecord.Successor,
|
||||
SupporterBadgeTiers: badgeTiersJSON,
|
||||
UpdatedAt: time.Now(),
|
||||
HoldDID: holdDID,
|
||||
OwnerDID: captainRecord.Owner,
|
||||
Public: captainRecord.Public,
|
||||
AllowAllCrew: captainRecord.AllowAllCrew,
|
||||
DeployedAt: captainRecord.DeployedAt,
|
||||
Region: captainRecord.Region,
|
||||
Successor: captainRecord.Successor,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := db.UpsertCaptainRecord(p.db, record); err != nil {
|
||||
@@ -747,13 +880,6 @@ func (p *Processor) ProcessCrew(ctx context.Context, holdDID string, rkey string
|
||||
return fmt.Errorf("failed to upsert crew member: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("Processed crew record",
|
||||
"component", "processor",
|
||||
"hold_did", holdDID,
|
||||
"member_did", crewRecord.Member,
|
||||
"role", crewRecord.Role,
|
||||
"permissions", crewRecord.Permissions)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -353,6 +353,11 @@ func (w *Worker) SetEventCallback(cb EventCallback) {
|
||||
w.eventCallback = cb
|
||||
}
|
||||
|
||||
// Processor returns the worker's processor for configuration (e.g., setting webhook dispatcher)
|
||||
func (w *Worker) Processor() *Processor {
|
||||
return w.processor
|
||||
}
|
||||
|
||||
// GetLastCursor returns the last processed cursor (time_us) for reconnects
|
||||
func (w *Worker) GetLastCursor() int64 {
|
||||
w.cursorMutex.RLock()
|
||||
@@ -482,6 +487,7 @@ func isRelevantCollection(collection string) bool {
|
||||
atproto.StatsCollection,
|
||||
atproto.CaptainCollection,
|
||||
atproto.CrewCollection,
|
||||
atproto.ScanCollection,
|
||||
BlueskyProfileCollection: // For avatar sync
|
||||
return true
|
||||
default:
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
<symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol>
|
||||
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
|
||||
<symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
|
||||
<symbol id="badge-check" viewBox="0 0 24 24"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></symbol>
|
||||
<symbol id="box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol>
|
||||
<symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
|
||||
<symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol>
|
||||
@@ -19,6 +18,7 @@
|
||||
<symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
|
||||
<symbol id="database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></symbol>
|
||||
<symbol id="download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol>
|
||||
<symbol id="external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
|
||||
<symbol id="eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="file-plus" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M9 15h6"/><path d="M12 18v-6"/></symbol>
|
||||
<symbol id="file-x" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="m14.5 12.5-5 5"/><path d="m9.5 12.5 5 5"/></symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -11,7 +11,9 @@ import (
|
||||
"atcr.io/pkg/appview/holdhealth"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"atcr.io/pkg/appview/readme"
|
||||
"atcr.io/pkg/appview/webhooks"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"atcr.io/pkg/billing"
|
||||
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -24,22 +26,24 @@ type LegalConfig struct {
|
||||
|
||||
// UIDependencies contains all dependencies needed for UI route registration
|
||||
type UIDependencies struct {
|
||||
Database *sql.DB
|
||||
ReadOnlyDB *sql.DB
|
||||
SessionStore *db.SessionStore
|
||||
OAuthClientApp *indigooauth.ClientApp
|
||||
OAuthStore *db.OAuthStore
|
||||
Refresher *oauth.Refresher
|
||||
BaseURL string
|
||||
RegistryDomain string // Separate OCI registry domain (e.g., "buoy.cr"); empty = same as BaseURL
|
||||
DeviceStore *db.DeviceStore
|
||||
HealthChecker *holdhealth.Checker
|
||||
ReadmeFetcher *readme.Fetcher
|
||||
Templates *template.Template
|
||||
DefaultHoldDID string
|
||||
LegalConfig LegalConfig
|
||||
ClientName string // Full name: "AT Container Registry"
|
||||
ClientShortName string // Short name: "ATCR"
|
||||
Database *sql.DB
|
||||
ReadOnlyDB *sql.DB
|
||||
SessionStore *db.SessionStore
|
||||
OAuthClientApp *indigooauth.ClientApp
|
||||
OAuthStore *db.OAuthStore
|
||||
Refresher *oauth.Refresher
|
||||
BaseURL string
|
||||
RegistryDomain string // Separate OCI registry domain (e.g., "buoy.cr"); empty = same as BaseURL
|
||||
DeviceStore *db.DeviceStore
|
||||
HealthChecker *holdhealth.Checker
|
||||
ReadmeFetcher *readme.Fetcher
|
||||
Templates *template.Template
|
||||
DefaultHoldDID string
|
||||
LegalConfig LegalConfig
|
||||
ClientName string // Full name: "AT Container Registry"
|
||||
ClientShortName string // Short name: "ATCR"
|
||||
BillingManager *billing.Manager // Stripe billing manager (nil if not configured)
|
||||
WebhookDispatcher *webhooks.Dispatcher // Webhook dispatcher (nil if not configured)
|
||||
}
|
||||
|
||||
// RegisterUIRoutes registers all web UI and API routes on the provided router
|
||||
@@ -55,23 +59,25 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
|
||||
// Create base with all dependencies - handlers just embed this
|
||||
base := uihandlers.BaseUIHandler{
|
||||
Templates: deps.Templates,
|
||||
RegistryURL: registryURL,
|
||||
SiteURL: siteURL,
|
||||
DB: deps.Database,
|
||||
ReadOnlyDB: deps.ReadOnlyDB,
|
||||
Refresher: deps.Refresher,
|
||||
HealthChecker: deps.HealthChecker,
|
||||
ReadmeFetcher: deps.ReadmeFetcher,
|
||||
Directory: deps.OAuthClientApp.Dir,
|
||||
SessionStore: deps.SessionStore,
|
||||
DeviceStore: deps.DeviceStore,
|
||||
OAuthStore: deps.OAuthStore,
|
||||
DefaultHoldDID: deps.DefaultHoldDID,
|
||||
CompanyName: deps.LegalConfig.CompanyName,
|
||||
Jurisdiction: deps.LegalConfig.Jurisdiction,
|
||||
ClientName: deps.ClientName,
|
||||
ClientShortName: deps.ClientShortName,
|
||||
Templates: deps.Templates,
|
||||
RegistryURL: registryURL,
|
||||
SiteURL: siteURL,
|
||||
DB: deps.Database,
|
||||
ReadOnlyDB: deps.ReadOnlyDB,
|
||||
Refresher: deps.Refresher,
|
||||
HealthChecker: deps.HealthChecker,
|
||||
ReadmeFetcher: deps.ReadmeFetcher,
|
||||
Directory: deps.OAuthClientApp.Dir,
|
||||
SessionStore: deps.SessionStore,
|
||||
DeviceStore: deps.DeviceStore,
|
||||
OAuthStore: deps.OAuthStore,
|
||||
BillingManager: deps.BillingManager,
|
||||
WebhookDispatcher: deps.WebhookDispatcher,
|
||||
DefaultHoldDID: deps.DefaultHoldDID,
|
||||
CompanyName: deps.LegalConfig.CompanyName,
|
||||
Jurisdiction: deps.LegalConfig.Jurisdiction,
|
||||
ClientName: deps.ClientName,
|
||||
ClientShortName: deps.ClientShortName,
|
||||
}
|
||||
|
||||
// OAuth login routes (public)
|
||||
@@ -154,7 +160,6 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
|
||||
// Subscription management
|
||||
r.Get("/api/subscription", (&uihandlers.SubscriptionHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
r.Get("/settings/subscription/checkout", (&uihandlers.SubscriptionCheckoutHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
r.Get("/settings/subscription/portal", (&uihandlers.SubscriptionPortalHandler{BaseUIHandler: base}).ServeHTTP)
|
||||
|
||||
|
||||
+173
-29
@@ -28,12 +28,15 @@ import (
|
||||
"atcr.io/pkg/appview/readme"
|
||||
"atcr.io/pkg/appview/routes"
|
||||
"atcr.io/pkg/appview/storage"
|
||||
"atcr.io/pkg/appview/webhooks"
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"atcr.io/pkg/auth/token"
|
||||
"atcr.io/pkg/billing"
|
||||
"atcr.io/pkg/logging"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
)
|
||||
|
||||
@@ -95,6 +98,15 @@ type AppViewServer struct {
|
||||
// HoldAuthorizer checks hold access permissions.
|
||||
HoldAuthorizer auth.HoldAuthorizer
|
||||
|
||||
// OAuthKey is the P-256 private key used for OAuth client auth and appview service identity.
|
||||
OAuthKey *atcrypto.PrivateKeyP256
|
||||
|
||||
// BillingManager handles Stripe billing and tier updates (nil if billing disabled).
|
||||
BillingManager *billing.Manager
|
||||
|
||||
// WebhookDispatcher dispatches scan webhooks (stored in appview DB).
|
||||
WebhookDispatcher *webhooks.Dispatcher
|
||||
|
||||
// Private fields for lifecycle management
|
||||
oauthHooks []OAuthPostAuthHook
|
||||
tokenHooks []TokenPostAuthHook
|
||||
@@ -191,6 +203,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load OAuth key: %w", err)
|
||||
}
|
||||
s.OAuthKey = oauthKey
|
||||
|
||||
// Create OAuth client app
|
||||
desiredScopes := oauth.GetDefaultScopes(defaultHoldDID)
|
||||
@@ -236,6 +249,39 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
|
||||
}()
|
||||
}
|
||||
|
||||
// Initialize billing manager
|
||||
appviewDID := DIDFromBaseURL(baseURL)
|
||||
s.BillingManager = billing.New(
|
||||
&cfg.Billing,
|
||||
oauthKey,
|
||||
appviewDID,
|
||||
cfg.Server.ManagedHolds,
|
||||
baseURL,
|
||||
)
|
||||
// Allow hold captains to bypass billing feature gates
|
||||
if len(cfg.Server.ManagedHolds) > 0 {
|
||||
managedHolds := cfg.Server.ManagedHolds
|
||||
roDB := s.ReadOnlyDB
|
||||
s.BillingManager.SetCaptainChecker(func(userDID string) bool {
|
||||
isCaptain, _ := db.IsHoldCaptain(roDB, userDID, managedHolds)
|
||||
return isCaptain
|
||||
})
|
||||
}
|
||||
if s.BillingManager.Enabled() {
|
||||
slog.Info("Billing enabled", "appview_did", appviewDID, "managed_holds", len(cfg.Server.ManagedHolds))
|
||||
go s.BillingManager.RefreshHoldTiers()
|
||||
}
|
||||
|
||||
// Create webhook dispatcher
|
||||
appviewMeta := atproto.AppviewMetadata{
|
||||
ClientName: cfg.Server.ClientName,
|
||||
ClientShortName: cfg.Server.ClientShortName,
|
||||
BaseURL: cfg.Server.BaseURL,
|
||||
FaviconURL: cfg.Server.BaseURL + "/favicon-96x96.png",
|
||||
RegistryDomains: cfg.Server.RegistryDomains,
|
||||
}
|
||||
s.WebhookDispatcher = webhooks.NewDispatcher(s.Database, appviewMeta)
|
||||
|
||||
// Initialize Jetstream workers
|
||||
s.initializeJetstream()
|
||||
|
||||
@@ -264,27 +310,32 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
|
||||
|
||||
// Register UI routes
|
||||
routes.RegisterUIRoutes(mainRouter, routes.UIDependencies{
|
||||
Database: s.Database,
|
||||
ReadOnlyDB: s.ReadOnlyDB,
|
||||
SessionStore: s.SessionStore,
|
||||
OAuthClientApp: s.OAuthClientApp,
|
||||
OAuthStore: s.OAuthStore,
|
||||
Refresher: s.Refresher,
|
||||
BaseURL: baseURL,
|
||||
RegistryDomain: primaryRegistryDomain(cfg.Server.RegistryDomains),
|
||||
DeviceStore: s.DeviceStore,
|
||||
HealthChecker: s.HealthChecker,
|
||||
ReadmeFetcher: s.ReadmeFetcher,
|
||||
Templates: s.Templates,
|
||||
DefaultHoldDID: defaultHoldDID,
|
||||
ClientName: cfg.Server.ClientName,
|
||||
ClientShortName: cfg.Server.ClientShortName,
|
||||
Database: s.Database,
|
||||
ReadOnlyDB: s.ReadOnlyDB,
|
||||
SessionStore: s.SessionStore,
|
||||
OAuthClientApp: s.OAuthClientApp,
|
||||
OAuthStore: s.OAuthStore,
|
||||
Refresher: s.Refresher,
|
||||
BaseURL: baseURL,
|
||||
RegistryDomain: primaryRegistryDomain(cfg.Server.RegistryDomains),
|
||||
DeviceStore: s.DeviceStore,
|
||||
HealthChecker: s.HealthChecker,
|
||||
ReadmeFetcher: s.ReadmeFetcher,
|
||||
Templates: s.Templates,
|
||||
DefaultHoldDID: defaultHoldDID,
|
||||
ClientName: cfg.Server.ClientName,
|
||||
ClientShortName: cfg.Server.ClientShortName,
|
||||
BillingManager: s.BillingManager,
|
||||
WebhookDispatcher: s.WebhookDispatcher,
|
||||
LegalConfig: routes.LegalConfig{
|
||||
CompanyName: cfg.Legal.CompanyName,
|
||||
Jurisdiction: cfg.Legal.Jurisdiction,
|
||||
},
|
||||
})
|
||||
|
||||
// Register Stripe webhook route (if billing enabled)
|
||||
s.BillingManager.RegisterRoutes(mainRouter)
|
||||
|
||||
// Create OAuth server
|
||||
s.OAuthServer = oauth.NewServer(s.OAuthClientApp)
|
||||
s.OAuthServer.SetRefresher(s.Refresher)
|
||||
@@ -550,6 +601,9 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
|
||||
}
|
||||
})
|
||||
|
||||
// Appview DID document endpoint (service identity for key discovery)
|
||||
mainRouter.Get("/.well-known/did.json", s.handleDIDDocument)
|
||||
|
||||
// Register credential helper version API (public endpoint)
|
||||
routes.RegisterCredentialHelperEndpoint(mainRouter, cfg.CredentialHelper.TangledRepo)
|
||||
|
||||
@@ -692,12 +746,99 @@ func primaryRegistryDomain(domains []string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// DID returns the appview's did:web identity derived from its BaseURL.
|
||||
func (s *AppViewServer) DID() string {
|
||||
return DIDFromBaseURL(s.Config.Server.BaseURL)
|
||||
}
|
||||
|
||||
// DIDFromBaseURL derives a did:web identifier from a base URL.
|
||||
// Per the did:web spec, non-standard ports are percent-encoded.
|
||||
// Examples:
|
||||
//
|
||||
// "https://atcr.io" → "did:web:atcr.io"
|
||||
// "http://localhost:5000" → "did:web:localhost%3A5000"
|
||||
func DIDFromBaseURL(baseURL string) string {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "did:web:localhost"
|
||||
}
|
||||
|
||||
hostname := u.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
port := u.Port()
|
||||
isStandardPort := (u.Scheme == "https" && port == "443") ||
|
||||
(u.Scheme == "http" && port == "80") ||
|
||||
port == ""
|
||||
|
||||
if isStandardPort {
|
||||
return "did:web:" + hostname
|
||||
}
|
||||
return fmt.Sprintf("did:web:%s%%3A%s", hostname, port)
|
||||
}
|
||||
|
||||
// handleDIDDocument serves the appview's DID document at /.well-known/did.json.
|
||||
// This is a service identity for key discovery — no PDS, no repo, no firehose.
|
||||
// Holds use this to discover the appview's P-256 public key for JWT verification.
|
||||
func (s *AppViewServer) handleDIDDocument(w http.ResponseWriter, r *http.Request) {
|
||||
did := s.DID()
|
||||
|
||||
pubKey, err := s.OAuthKey.PublicKey()
|
||||
if err != nil {
|
||||
slog.Error("Failed to get public key for DID document", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
doc := map[string]any{
|
||||
"@context": []string{
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
},
|
||||
"id": did,
|
||||
"verificationMethod": []map[string]any{
|
||||
{
|
||||
"id": did + "#appview",
|
||||
"type": "Multikey",
|
||||
"controller": did,
|
||||
"publicKeyMultibase": pubKey.Multibase(),
|
||||
},
|
||||
},
|
||||
"authentication": []string{
|
||||
did + "#appview",
|
||||
},
|
||||
"assertionMethod": []string{
|
||||
did + "#appview",
|
||||
},
|
||||
"service": []map[string]any{
|
||||
{
|
||||
"id": "#atcr_appview",
|
||||
"type": "AtcrAppView",
|
||||
"serviceEndpoint": s.Config.Server.BaseURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/did+ld+json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
if err := json.NewEncoder(w).Encode(doc); err != nil {
|
||||
slog.Error("Failed to encode DID document", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// initializeJetstream initializes the Jetstream workers for real-time events and backfill.
|
||||
func (s *AppViewServer) initializeJetstream() {
|
||||
jetstreamURLs := s.Config.Jetstream.URLs
|
||||
|
||||
go func() {
|
||||
worker := jetstream.NewWorker(s.Database, jetstreamURLs, 0)
|
||||
// Set webhook dispatcher on live worker (backfill skips dispatch)
|
||||
if s.WebhookDispatcher != nil {
|
||||
worker.Processor().SetWebhookDispatcher(s.WebhookDispatcher)
|
||||
}
|
||||
worker.StartWithFailover(context.Background())
|
||||
}()
|
||||
slog.Info("Jetstream real-time worker started", "component", "jetstream", "endpoints", len(jetstreamURLs))
|
||||
@@ -724,22 +865,25 @@ func (s *AppViewServer) initializeJetstream() {
|
||||
}
|
||||
}()
|
||||
|
||||
interval := 1 * time.Hour
|
||||
interval := s.Config.Jetstream.BackfillInterval
|
||||
if interval > 0 {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
slog.Info("Starting periodic backfill", "component", "jetstream/backfill", "interval", interval)
|
||||
if err := backfillWorker.Start(context.Background()); err != nil {
|
||||
slog.Warn("Periodic backfill finished with error", "component", "jetstream/backfill", "error", err)
|
||||
} else {
|
||||
slog.Info("Periodic backfill completed successfully", "component", "jetstream/backfill")
|
||||
for range ticker.C {
|
||||
slog.Info("Starting periodic backfill", "component", "jetstream/backfill", "interval", interval)
|
||||
if err := backfillWorker.Start(context.Background()); err != nil {
|
||||
slog.Warn("Periodic backfill finished with error", "component", "jetstream/backfill", "error", err)
|
||||
} else {
|
||||
slog.Info("Periodic backfill completed successfully", "component", "jetstream/backfill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
slog.Info("Periodic backfill scheduler started", "component", "jetstream/backfill", "interval", interval)
|
||||
}()
|
||||
slog.Info("Periodic backfill scheduler started", "component", "jetstream/backfill", "interval", interval)
|
||||
} else {
|
||||
slog.Info("Periodic backfill disabled (interval=0), only startup backfill will run", "component", "jetstream/backfill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,9 +734,9 @@ function showToast(message, type) {
|
||||
}
|
||||
|
||||
// Test webhook via fetch + toast
|
||||
async function testWebhook(rkey) {
|
||||
async function testWebhook(id) {
|
||||
try {
|
||||
const resp = await fetch(`/api/webhooks/${rkey}/test`, {
|
||||
const resp = await fetch(`/api/webhooks/${id}/test`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
@@ -9,20 +9,22 @@
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Settings</h1>
|
||||
|
||||
<!-- Mobile identity info (below lg) -->
|
||||
<div class="lg:hidden mb-4 space-y-1 text-xs text-base-content/50">
|
||||
<div class="break-all"><code>{{ .Profile.DID }}</code></div>
|
||||
<div><a href="{{ .Profile.PDSEndpoint }}/account" target="_blank" class="link link-primary inline-flex items-center gap-1">{{ .Profile.PDSEndpoint }} {{ icon "external-link" "size-3" }}</a></div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile tab bar (below lg) -->
|
||||
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6">
|
||||
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="identity">
|
||||
{{ icon "fingerprint" "size-4" }} Identity
|
||||
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage">
|
||||
{{ icon "hard-drive" "size-4" }} Storage
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="devices">
|
||||
{{ icon "terminal" "size-4" }} Devices
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage">
|
||||
{{ icon "hard-drive" "size-4" }} Storage
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="webhooks">
|
||||
{{ icon "webhook" "size-4" }} Webhooks
|
||||
</button>
|
||||
@@ -35,36 +37,49 @@
|
||||
<!-- Sidebar (lg and above) -->
|
||||
<aside class="hidden lg:block w-56 shrink-0">
|
||||
<ul class="menu bg-base-200 rounded-box w-full">
|
||||
<li data-tab="identity"><a href="#identity">{{ icon "fingerprint" "size-4" }} Identity</a></li>
|
||||
<li data-tab="devices"><a href="#devices">{{ icon "terminal" "size-4" }} Devices</a></li>
|
||||
<li data-tab="storage"><a href="#storage">{{ icon "hard-drive" "size-4" }} Storage</a></li>
|
||||
<li data-tab="devices"><a href="#devices">{{ icon "terminal" "size-4" }} Devices</a></li>
|
||||
<li data-tab="webhooks"><a href="#webhooks">{{ icon "webhook" "size-4" }} Webhooks</a></li>
|
||||
<li data-tab="advanced"><a href="#advanced">{{ icon "shield-check" "size-4" }} Advanced</a></li>
|
||||
</ul>
|
||||
<div class="mt-4 px-2 space-y-1 text-xs text-base-content/50">
|
||||
<div class="break-all"><code>{{ .Profile.DID }}</code></div>
|
||||
<div><a href="{{ .Profile.PDSEndpoint }}/account" target="_blank" class="link link-primary inline-flex items-center gap-1">{{ .Profile.PDSEndpoint }} {{ icon "external-link" "size-3" }}</a></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Tab content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
|
||||
<!-- IDENTITY TAB -->
|
||||
<div id="tab-identity" class="settings-panel space-y-6">
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Identity</h2>
|
||||
<div class="grid gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-base-content/70">Handle</span>
|
||||
<span>{{ .Profile.Handle }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-base-content/70">DID</span>
|
||||
<code class="cmd">{{ .Profile.DID }}</code>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-base-content/70">PDS</span>
|
||||
<span>{{ .Profile.PDSEndpoint }}</span>
|
||||
<!-- STORAGE TAB -->
|
||||
<div id="tab-storage" class="settings-panel space-y-4">
|
||||
<!-- Available Plans -->
|
||||
{{ template "subscription_plans" .Subscription }}
|
||||
|
||||
<!-- Holds -->
|
||||
{{ if .AllHolds }}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="space-y-4">
|
||||
{{ template "hold_selector" . }}
|
||||
{{ if .ActiveHold }}
|
||||
{{ template "hold_card" .ActiveHold }}
|
||||
{{ else }}
|
||||
<div class="card bg-base-100 shadow-sm p-6 text-center text-base-content/60">
|
||||
No active hold selected. Choose one above.
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</section>
|
||||
<div>
|
||||
{{ if .OtherHolds }}
|
||||
{{ template "other_holds_table" .OtherHolds }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="card bg-base-100 shadow-sm p-6 text-center text-base-content/60">
|
||||
No holds configured. Push an image to get started.
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- DEVICES TAB -->
|
||||
@@ -127,117 +142,15 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- STORAGE TAB -->
|
||||
<div id="tab-storage" class="settings-panel hidden space-y-6">
|
||||
<!-- Default Hold Section -->
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Default Hold</h2>
|
||||
<p class="text-base-content/70">Select where your container images will be stored.</p>
|
||||
|
||||
<form hx-post="/api/profile/default-hold"
|
||||
hx-target="#hold-status"
|
||||
hx-swap="innerHTML"
|
||||
id="hold-form"
|
||||
class="space-y-4">
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="sr-only">Storage hold selection</legend>
|
||||
<label class="label" for="default-hold">
|
||||
<span class="label-text">Storage Hold</span>
|
||||
</label>
|
||||
<select id="default-hold" name="hold_did" class="select select-bordered w-full" autocomplete="off">
|
||||
<option value="{{ .AppViewDefaultHoldDID }}"{{ if or (eq .CurrentHoldDID "") (eq .CurrentHoldDID .AppViewDefaultHoldDID) }} selected{{ end }}>AppView Default ({{ .AppViewDefaultHoldDisplay }}{{ if .AppViewDefaultRegion }}, {{ .AppViewDefaultRegion }}{{ end }})</option>
|
||||
|
||||
{{ if .ShowCurrentHold }}
|
||||
<option value="{{ .CurrentHoldDID }}" selected>Current ({{ .CurrentHoldDisplay }})</option>
|
||||
{{ end }}
|
||||
|
||||
{{ if .OwnedHolds }}
|
||||
<optgroup label="Your Holds">
|
||||
{{ range .OwnedHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .CrewHolds }}
|
||||
<optgroup label="Crew Member">
|
||||
{{ range .CrewHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .EligibleHolds }}
|
||||
<optgroup label="Open Registration">
|
||||
{{ range .EligibleHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}{{ if eq .Status "offline" }} disabled{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}{{ if eq .Status "offline" }} [offline]{{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
</select>
|
||||
<p class="text-sm text-base-content/60 mt-1">Your images will be stored on the selected hold</p>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</form>
|
||||
|
||||
<div id="hold-status"></div>
|
||||
|
||||
<!-- Hold details panel (shows when hold selected) -->
|
||||
<div id="hold-details" class="hidden mt-4 p-4 bg-base-200 rounded-lg">
|
||||
<h3 class="font-semibold mb-3">Hold Details</h3>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dt class="text-base-content/70">DID:</dt>
|
||||
<dd id="hold-did" class="font-mono"></dd>
|
||||
<dt class="text-base-content/70">Region:</dt>
|
||||
<dd id="hold-region"></dd>
|
||||
<dt class="text-base-content/70">Status:</dt>
|
||||
<dd id="hold-status-badge"></dd>
|
||||
<dt class="text-base-content/70">Your Access:</dt>
|
||||
<dd id="hold-access"></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Storage Usage Section -->
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Stowage</h2>
|
||||
<p class="text-base-content/70">Estimated storage usage on your default hold.</p>
|
||||
<div id="storage-stats" hx-get="/api/storage" hx-trigger="tab:storage from:body once" hx-swap="innerHTML">
|
||||
<p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading...</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Subscription Section -->
|
||||
<div id="subscription-wrapper" hx-get="/api/subscription" hx-trigger="tab:storage from:body once" hx-swap="innerHTML">
|
||||
<section id="subscription-section" class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Subscription</h2>
|
||||
<p class="text-base-content/70">Manage your storage tier and billing.</p>
|
||||
<p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading subscription info...</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WEBHOOKS TAB -->
|
||||
<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 HTTP notifications when vulnerability scans complete.</p>
|
||||
<p class="text-base-content/70 mt-1">Get notified when vulnerability scans complete on any of your images.</p>
|
||||
</div>
|
||||
<div id="webhooks-content"
|
||||
hx-get="/api/webhooks"
|
||||
hx-trigger="tab:webhooks from:body once"
|
||||
hx-swap="innerHTML">
|
||||
<p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading webhooks...</p>
|
||||
<div id="webhooks-content">
|
||||
{{ template "webhooks_list" .WebhooksData }}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -302,16 +215,12 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Hold data from server (for details panel)
|
||||
const holdData = {{ .HoldDataJSON }};
|
||||
|
||||
// Tab switching
|
||||
(function() {
|
||||
var validTabs = ['identity', 'devices', 'storage', 'webhooks', 'advanced'];
|
||||
var validTabs = ['storage', 'devices', 'webhooks', 'advanced'];
|
||||
|
||||
function switchSettingsTab(tabId) {
|
||||
// Hide all panels
|
||||
@@ -356,8 +265,8 @@
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Read initial tab from hash
|
||||
var hash = window.location.hash.replace('#', '') || 'identity';
|
||||
if (validTabs.indexOf(hash) === -1) hash = 'identity';
|
||||
var hash = window.location.hash.replace('#', '') || 'storage';
|
||||
if (validTabs.indexOf(hash) === -1) hash = 'storage';
|
||||
|
||||
// Mobile tab click handlers
|
||||
document.querySelectorAll('.settings-tab-mobile').forEach(function(btn) {
|
||||
@@ -383,90 +292,13 @@
|
||||
|
||||
// Handle browser back/forward
|
||||
window.addEventListener('hashchange', function() {
|
||||
var hash = window.location.hash.replace('#', '') || 'identity';
|
||||
var hash = window.location.hash.replace('#', '') || 'storage';
|
||||
if (validTabs.indexOf(hash) !== -1) {
|
||||
switchSettingsTab(hash);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// Hold Selection and Details Display
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const holdSelect = document.getElementById('default-hold');
|
||||
const holdDetails = document.getElementById('hold-details');
|
||||
const holdForm = document.getElementById('hold-form');
|
||||
|
||||
if (holdSelect) {
|
||||
holdSelect.addEventListener('change', function() {
|
||||
const selectedDID = this.value;
|
||||
|
||||
if (!selectedDID || !holdData[selectedDID]) {
|
||||
holdDetails.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const hold = holdData[selectedDID];
|
||||
|
||||
document.getElementById('hold-did').textContent = hold.did;
|
||||
document.getElementById('hold-region').textContent = hold.region || 'Unknown';
|
||||
|
||||
// Set status badge
|
||||
const statusEl = document.getElementById('hold-status-badge');
|
||||
if (hold.status === 'offline') {
|
||||
statusEl.innerHTML = '<span class="badge badge-sm badge-warning">Offline</span>';
|
||||
} else if (hold.status === 'online') {
|
||||
statusEl.innerHTML = '<span class="badge badge-sm badge-success">Online</span>';
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="text-base-content/60">Unknown</span>';
|
||||
}
|
||||
|
||||
// Set access level with badge
|
||||
const accessEl = document.getElementById('hold-access');
|
||||
const accessLabel = {
|
||||
'owner': 'Owner (Full Control)',
|
||||
'crew': 'Crew Member',
|
||||
'eligible': 'Open Registration',
|
||||
'public': 'Public Access'
|
||||
}[hold.membership] || hold.membership;
|
||||
|
||||
const badgeColor = {
|
||||
'owner': 'badge-primary',
|
||||
'crew': 'badge-secondary',
|
||||
'eligible': 'badge-accent',
|
||||
'public': 'badge-ghost'
|
||||
}[hold.membership] || '';
|
||||
|
||||
accessEl.innerHTML = '<span class="badge badge-sm ' + badgeColor + '">' + accessLabel + '</span>';
|
||||
|
||||
// Show permissions for crew members
|
||||
if (hold.membership === 'crew' && hold.permissions && hold.permissions.length > 0) {
|
||||
accessEl.innerHTML += '<br><span class="text-xs text-base-content/60">Permissions: ' + hold.permissions.join(', ') + '</span>';
|
||||
}
|
||||
|
||||
holdDetails.style.display = 'block';
|
||||
});
|
||||
|
||||
// Re-fetch stowage and subscription when hold selection changes
|
||||
holdSelect.addEventListener('change', function() {
|
||||
var params = this.value ? '?hold_did=' + encodeURIComponent(this.value) : '';
|
||||
htmx.ajax('GET', '/api/storage' + params, '#storage-stats');
|
||||
htmx.ajax('GET', '/api/subscription' + params, {target: '#subscription-wrapper', swap: 'innerHTML'});
|
||||
});
|
||||
|
||||
// Trigger on page load if a hold is already selected
|
||||
if (holdSelect.value) {
|
||||
holdSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
// HTMX success handler - no icon reinitialization needed with SVG sprites
|
||||
if (holdForm) {
|
||||
holdForm.addEventListener('htmx:afterSwap', function(event) {
|
||||
// SVG sprites don't need reinitialization
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Account Deletion JavaScript
|
||||
(function() {
|
||||
const deleteBtn = document.getElementById('delete-account-btn');
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{{ define "hold_card" }}
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<!-- Header -->
|
||||
<div class="p-4 flex flex-wrap items-center gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-warning" title="Active hold">★</span>
|
||||
<h3 class="font-semibold text-lg truncate">{{ .DisplayName }}</h3>
|
||||
<span class="badge badge-sm badge-warning">Active</span>
|
||||
{{ if eq .Membership "owner" }}<span class="badge badge-sm badge-primary">Owner</span>
|
||||
{{ else }}<span class="badge badge-sm badge-secondary">Crew</span>{{ end }}
|
||||
{{ if eq .Status "online" }}<span class="badge badge-sm badge-success gap-1">● Online</span>
|
||||
{{ else if eq .Status "offline" }}<span class="badge badge-sm badge-error gap-1">● Offline</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
<code class="text-xs text-base-content/50 break-all">{{ .DID }}</code>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Stats (always visible, lazy-loaded) -->
|
||||
<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-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>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,36 @@
|
||||
{{ define "hold_selector" }}
|
||||
<div class="card bg-base-100 shadow-sm p-4">
|
||||
<form hx-post="/api/profile/default-hold" hx-swap="none" class="flex items-center gap-3 flex-wrap">
|
||||
<label class="text-sm font-medium whitespace-nowrap" for="hold-select">Active Hold:</label>
|
||||
<select id="hold-select" name="hold_did" class="select select-bordered select-sm flex-1 min-w-0"
|
||||
onchange="this.form.requestSubmit()">
|
||||
{{ if not .ActiveHold }}
|
||||
<option value="" selected>-- Select a hold --</option>
|
||||
{{ end }}
|
||||
|
||||
<optgroup label="Your Holds">
|
||||
{{ range .AllHolds }}
|
||||
{{ if ne .Membership "eligible" }}
|
||||
<option value="{{ .DID }}" {{ if .IsActive }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if eq .Membership "owner" }} (Owner){{ else }} (Crew){{ end }}{{ if .Region }} · {{ .Region }}{{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
|
||||
{{ range .AllHolds }}{{ if eq .Membership "eligible" }}
|
||||
<optgroup label="Available Holds">
|
||||
{{ range $.AllHolds }}
|
||||
{{ if eq .Membership "eligible" }}
|
||||
<option value="{{ .DID }}">
|
||||
{{ .DisplayName }}{{ if .Region }} · {{ .Region }}{{ end }} (Join)
|
||||
</option>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ break }}{{ end }}{{ end }}
|
||||
</select>
|
||||
<noscript><button type="submit" class="btn btn-sm btn-primary">Switch</button></noscript>
|
||||
</form>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -0,0 +1,47 @@
|
||||
{{ define "other_holds_table" }}
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="p-4 pb-2">
|
||||
<h3 class="text-sm font-semibold text-base-content/70">Other Holds</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hold</th>
|
||||
<th>Role</th>
|
||||
<th class="text-center">Status</th>
|
||||
<th class="text-right">Storage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range . }}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="font-medium">{{ .DisplayName }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ if eq .Membership "owner" }}<span class="badge badge-xs badge-primary">Owner</span>
|
||||
{{ else }}<span class="badge badge-xs badge-secondary">Crew</span>{{ end }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{{ if eq .Status "online" }}<span class="text-success" title="Online">●</span>
|
||||
{{ else if eq .Status "offline" }}<span class="text-error" title="Offline">●</span>
|
||||
{{ else }}<span class="text-base-content/30" title="Unknown">●</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<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-swap="innerHTML"
|
||||
class="text-sm font-mono">
|
||||
...
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -5,12 +5,6 @@
|
||||
<span class="text-base-content/60">Tier:</span>
|
||||
<span class="badge badge-xs badge-{{ .Tier }} font-semibold">{{ .Tier }}</span>
|
||||
</div>
|
||||
{{ if .HasSupporterBadge }}
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-base-content/60">Profile Badge:</span>
|
||||
<span class="text-sm text-success flex items-center gap-1">{{ icon "badge-check" "size-4" }} Visible on your profile</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-base-content/60">Storage:</span>
|
||||
|
||||
@@ -1,65 +1,55 @@
|
||||
{{ define "subscription_info" }}
|
||||
{{ if .HideBilling }}
|
||||
<!-- subscription: billing not available for this hold -->
|
||||
{{ else }}
|
||||
<section id="subscription-section" class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h2 class="text-xl font-semibold">Subscription</h2>
|
||||
<p class="text-base-content/70">Manage your storage tier and billing.{{ if .HoldDisplayName }} Storage provided by <strong>{{ .HoldDisplayName }}</strong>.{{ end }}</p>
|
||||
|
||||
{{ if .Error }}
|
||||
<div class="alert alert-error">
|
||||
{{ icon "alert-circle" "size-5" }} {{ .Error }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<!-- Current Plan -->
|
||||
<div class="bg-base-200 p-4 rounded-lg mb-4">
|
||||
<div class="flex justify-between py-2 border-b border-base-300">
|
||||
<span class="text-base-content/70">Current Tier:</span>
|
||||
<span class="font-bold capitalize">{{ .CurrentTier }}</span>
|
||||
</div>
|
||||
{{ if and .CrewTier (ne .CrewTier .CurrentTier) }}
|
||||
<div class="flex justify-between items-center py-2 border border-warning bg-warning/10 rounded-lg px-2 my-1">
|
||||
<span class="text-base-content/70">Crew Record Tier:</span>
|
||||
<span class="font-bold capitalize">{{ .CrewTier }}<span class="text-xs text-warning ml-2">(pending sync)</span></span>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .SubscriptionID }}
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-base-content/70">Billing:</span>
|
||||
<span class="font-bold">{{ .BillingInterval }}</span>
|
||||
</div>
|
||||
<a href="/settings/subscription/portal" class="btn btn-outline btn-primary mt-4">Manage Billing</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Available Tiers -->
|
||||
{{ if .Tiers }}
|
||||
<h3 class="font-semibold">Available Plans</h3>
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4 mt-4">
|
||||
{{ define "subscription_plans" }}
|
||||
{{ if not .HideBilling }}
|
||||
{{ if .Tiers }}
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
|
||||
<h3 class="text-xl font-semibold">Available Plans</h3>
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4">
|
||||
{{ range .Tiers }}
|
||||
<div class="border rounded-lg p-5 bg-base-200 relative flex flex-col{{ if .IsCurrent }} border-primary border-2{{ else }} border-base-300{{ end }}">
|
||||
{{ if .IsCurrent }}<span class="badge badge-primary badge-sm absolute -top-2 right-4">Current</span>{{ end }}
|
||||
<div class="text-xl font-bold capitalize mb-2">{{ .Name }}</div>
|
||||
<div class="text-2xl font-bold text-primary">{{ .QuotaFormatted }}</div>
|
||||
{{ if .Description }}
|
||||
<div class="text-sm text-base-content/70 mb-4">{{ .Description }}</div>
|
||||
<div class="text-lg font-bold capitalize">{{ .Name }}</div>
|
||||
{{ if .Description }}<p class="text-sm text-base-content/60 mt-1">{{ .Description }}</p>{{ end }}
|
||||
{{ if .Features }}
|
||||
<ul class="text-sm text-base-content/60 mt-2 list-disc list-inside space-y-0.5">
|
||||
{{ range .Features }}<li>{{ . }}</li>{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
<div class="flex-1"></div>
|
||||
<div class="text-base-content/70 my-2">{{ .PriceFormatted }}</div>
|
||||
<div class="mt-4">
|
||||
{{ if and .IsCurrent (not $.SubscriptionID) (or .PriceCentsMonthly .PriceCentsYearly) }}
|
||||
{{/* Current tier with optional support pricing */}}
|
||||
<div class="text-2xl font-bold text-success">Free</div>
|
||||
{{ if .PriceMonthly }}<div class="text-sm text-base-content/60">{{ .PriceMonthly }} to support</div>
|
||||
{{ else if .PriceYearly }}<div class="text-sm text-base-content/60">{{ .PriceYearly }} to support</div>{{ end }}
|
||||
{{ else if .PriceMonthly }}
|
||||
<div class="text-2xl font-bold">{{ .PriceMonthly }}</div>
|
||||
{{ if .PriceYearly }}<div class="text-sm text-base-content/60">or {{ .PriceYearly }}</div>{{ end }}
|
||||
{{ else if .PriceYearly }}
|
||||
<div class="text-2xl font-bold">{{ .PriceYearly }}</div>
|
||||
{{ else }}
|
||||
<div class="text-2xl font-bold text-success">Free</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ if not .IsCurrent }}
|
||||
{{ if or .PriceCentsMonthly .PriceCentsYearly }}
|
||||
{{ if $.SubscriptionID }}
|
||||
<a href="/settings/subscription/portal" class="btn btn-primary w-full">Change Plan</a>
|
||||
<a href="/settings/subscription/portal" class="btn btn-primary w-full mt-3">Change Plan</a>
|
||||
{{ else }}
|
||||
<a href="/settings/subscription/checkout?tier={{ .ID }}" class="btn btn-primary w-full">Upgrade</a>
|
||||
<a href="/settings/subscription/checkout?tier={{ .ID }}" class="btn btn-primary w-full mt-3">Upgrade</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ else if and (not $.SubscriptionID) (or .PriceCentsMonthly .PriceCentsYearly) }}
|
||||
<a href="/settings/subscription/checkout?tier={{ .ID }}" class="btn btn-outline btn-primary w-full mt-3">Become a Supporter</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .SubscriptionID }}
|
||||
<div class="flex justify-end">
|
||||
<a href="/settings/subscription/portal" class="btn btn-outline btn-primary btn-sm">Manage Billing</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</section>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Add Webhook Form -->
|
||||
<form hx-post="/api/webhooks"
|
||||
hx-target="#webhooks-content"
|
||||
hx-target="#{{ .ContainerID }}"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-4 bg-base-200 rounded-lg p-4">
|
||||
<h3 class="font-semibold">Add Webhook</h3>
|
||||
@@ -79,13 +79,13 @@
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<button class="btn btn-xs btn-ghost"
|
||||
onclick="testWebhook('{{ .Rkey }}')"
|
||||
onclick="testWebhook('{{ .ID }}')"
|
||||
title="Send test payload">
|
||||
Test
|
||||
</button>
|
||||
<button class="btn btn-xs btn-error btn-ghost"
|
||||
hx-delete="/api/webhooks/{{ .Rkey }}"
|
||||
hx-target="#webhooks-content"
|
||||
hx-delete="/api/webhooks/{{ .ID }}"
|
||||
hx-target="#{{ $.ContainerID }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Delete this webhook?"
|
||||
title="Delete webhook">
|
||||
|
||||
@@ -195,8 +195,10 @@ func Templates(overrides *BrandingOverrides) (*template.Template, error) {
|
||||
// Replace special CSS selector characters with dashes
|
||||
// e.g., "sha256:abc123" becomes "sha256-abc123"
|
||||
// e.g., "v0.0.2" becomes "v0-0-2"
|
||||
// e.g., "did:web:172.28.0.3%3A8080" becomes "did-web-172-28-0-3-3A8080"
|
||||
s = strings.ReplaceAll(s, ":", "-")
|
||||
s = strings.ReplaceAll(s, ".", "-")
|
||||
s = strings.ReplaceAll(s, "%", "-")
|
||||
return s
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// Dispatcher handles webhook delivery for scan notifications.
|
||||
// It reads webhooks from the appview DB and delivers payloads
|
||||
// with Discord/Slack formatting and HMAC signing.
|
||||
type Dispatcher struct {
|
||||
db db.DBTX
|
||||
meta atproto.AppviewMetadata
|
||||
}
|
||||
|
||||
// NewDispatcher creates a new webhook dispatcher
|
||||
func NewDispatcher(database db.DBTX, meta atproto.AppviewMetadata) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
db: database,
|
||||
meta: meta,
|
||||
}
|
||||
}
|
||||
|
||||
// DispatchForScan fires matching webhooks after a scan record arrives via Jetstream.
|
||||
// previousScan is nil for first-time scans. userHandle is used for payload enrichment.
|
||||
func (d *Dispatcher) DispatchForScan(ctx context.Context, scan, previousScan *db.Scan, userHandle, tag, holdEndpoint string) {
|
||||
webhooks, err := db.GetWebhooksForUser(d.db, scan.UserDID)
|
||||
if err != nil || len(webhooks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
isFirst := previousScan == nil
|
||||
isChanged := previousScan != nil && vulnCountsChanged(scan, previousScan)
|
||||
|
||||
scanInfo := WebhookScanInfo{
|
||||
ScannedAt: scan.ScannedAt.Format(time.RFC3339),
|
||||
ScannerVersion: scan.ScannerVersion,
|
||||
Vulnerabilities: WebhookVulnCounts{
|
||||
Critical: scan.Critical,
|
||||
High: scan.High,
|
||||
Medium: scan.Medium,
|
||||
Low: scan.Low,
|
||||
Total: scan.Total,
|
||||
},
|
||||
}
|
||||
|
||||
manifestInfo := WebhookManifestInfo{
|
||||
Digest: scan.ManifestDigest,
|
||||
Repository: scan.Repository,
|
||||
Tag: tag,
|
||||
UserDID: scan.UserDID,
|
||||
UserHandle: userHandle,
|
||||
}
|
||||
|
||||
for _, wh := range webhooks {
|
||||
// Check each trigger condition against bitmask
|
||||
var triggers []string
|
||||
if wh.Triggers&TriggerFirst != 0 && isFirst {
|
||||
triggers = append(triggers, "scan:first")
|
||||
}
|
||||
if wh.Triggers&TriggerAll != 0 {
|
||||
triggers = append(triggers, "scan:all")
|
||||
}
|
||||
if wh.Triggers&TriggerChanged != 0 && isChanged {
|
||||
triggers = append(triggers, "scan:changed")
|
||||
}
|
||||
|
||||
for _, trigger := range triggers {
|
||||
payload := WebhookPayload{
|
||||
Trigger: trigger,
|
||||
HoldDID: scan.HoldDID,
|
||||
HoldEndpoint: holdEndpoint,
|
||||
Manifest: manifestInfo,
|
||||
Scan: scanInfo,
|
||||
}
|
||||
|
||||
// Include previous counts for scan:changed
|
||||
if trigger == "scan:changed" && previousScan != nil {
|
||||
payload.Previous = &WebhookVulnCounts{
|
||||
Critical: previousScan.Critical,
|
||||
High: previousScan.High,
|
||||
Medium: previousScan.Medium,
|
||||
Low: previousScan.Low,
|
||||
Total: previousScan.Total,
|
||||
}
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal webhook payload", "error", err)
|
||||
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)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if wh.UserDID != userDID {
|
||||
return false, fmt.Errorf("unauthorized")
|
||||
}
|
||||
|
||||
// Randomize vulnerability counts so each test shows a different severity color
|
||||
critical := rand.IntN(3)
|
||||
high := rand.IntN(5)
|
||||
medium := rand.IntN(8)
|
||||
low := rand.IntN(10)
|
||||
total := critical + high + medium + low
|
||||
|
||||
payload := WebhookPayload{
|
||||
Trigger: "test",
|
||||
Manifest: WebhookManifestInfo{
|
||||
Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
Repository: "test-repo",
|
||||
Tag: "latest",
|
||||
UserDID: userDID,
|
||||
UserHandle: userHandle,
|
||||
},
|
||||
Scan: WebhookScanInfo{
|
||||
ScannedAt: time.Now().Format(time.RFC3339),
|
||||
ScannerVersion: "atcr-scanner-v1.0.0",
|
||||
Vulnerabilities: WebhookVulnCounts{
|
||||
Critical: critical, High: high, Medium: medium, Low: low, Total: total,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
success := d.attemptDelivery(wh.URL, wh.Secret, payloadBytes)
|
||||
return success, nil
|
||||
}
|
||||
|
||||
// deliverWithRetry attempts to deliver a webhook with exponential backoff
|
||||
func (d *Dispatcher) deliverWithRetry(webhookURL, secret string, payload []byte) {
|
||||
delays := []time.Duration{0, 30 * time.Second, 2 * time.Minute, 8 * time.Minute}
|
||||
for attempt, delay := range delays {
|
||||
if attempt > 0 {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
if d.attemptDelivery(webhookURL, secret, payload) {
|
||||
return
|
||||
}
|
||||
}
|
||||
slog.Warn("Webhook delivery failed after retries", "url", maskURL(webhookURL))
|
||||
}
|
||||
|
||||
// attemptDelivery sends a single webhook HTTP POST
|
||||
func (d *Dispatcher) attemptDelivery(webhookURL, secret string, payload []byte) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, strings.NewReader(string(sendPayload)))
|
||||
if err != nil {
|
||||
slog.Warn("Failed to create webhook request", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", d.meta.ClientShortName+"-Webhook/1.0")
|
||||
|
||||
// HMAC signing if secret is set (signs the actual payload sent)
|
||||
if secret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(sendPayload)
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
req.Header.Set("X-Webhook-Signature-256", "sha256="+sig)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("Webhook delivery attempt failed", "url", maskURL(webhookURL), "error", err)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
slog.Info("Webhook delivered successfully", "url", maskURL(webhookURL), "status", resp.StatusCode)
|
||||
return true
|
||||
}
|
||||
|
||||
// Read response body for debugging
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
slog.Warn("Webhook delivery got non-2xx response",
|
||||
"url", maskURL(webhookURL),
|
||||
"status", resp.StatusCode,
|
||||
"body", string(body))
|
||||
return false
|
||||
}
|
||||
|
||||
// vulnCountsChanged checks if vulnerability counts differ between scans
|
||||
func vulnCountsChanged(current, previous *db.Scan) bool {
|
||||
return current.Critical != previous.Critical ||
|
||||
current.High != previous.High ||
|
||||
current.Medium != previous.Medium ||
|
||||
current.Low != previous.Low
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// maskURL masks a URL for display (shows scheme + host, hides path/query)
|
||||
func maskURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
if len(rawURL) > 30 {
|
||||
return rawURL[:30] + "***"
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
masked := u.Scheme + "://" + u.Host
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
masked += "/***"
|
||||
}
|
||||
return masked
|
||||
}
|
||||
|
||||
// isDiscordWebhook checks if the URL points to a Discord webhook endpoint
|
||||
func isDiscordWebhook(rawURL string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Host == "discord.com" || strings.HasSuffix(u.Host, ".discord.com")
|
||||
}
|
||||
|
||||
// isSlackWebhook checks if the URL points to a Slack webhook endpoint
|
||||
func isSlackWebhook(rawURL string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Host == "hooks.slack.com"
|
||||
}
|
||||
|
||||
// webhookSeverityColor returns a color int based on the highest severity present
|
||||
func webhookSeverityColor(vulns WebhookVulnCounts) int {
|
||||
switch {
|
||||
case vulns.Critical > 0:
|
||||
return 0xED4245 // red
|
||||
case vulns.High > 0:
|
||||
return 0xFFA500 // orange
|
||||
case vulns.Medium > 0:
|
||||
return 0xFEE75C // yellow
|
||||
case vulns.Low > 0:
|
||||
return 0x57F287 // green
|
||||
default:
|
||||
return 0x95A5A6 // grey
|
||||
}
|
||||
}
|
||||
|
||||
// webhookSeverityHex returns a hex color string (e.g., "#ED4245")
|
||||
func webhookSeverityHex(vulns WebhookVulnCounts) string {
|
||||
return fmt.Sprintf("#%06X", webhookSeverityColor(vulns))
|
||||
}
|
||||
|
||||
// formatVulnDescription builds a vulnerability summary with colored square emojis
|
||||
func formatVulnDescription(v WebhookVulnCounts, digest string) string {
|
||||
var lines []string
|
||||
|
||||
if len(digest) > 19 {
|
||||
lines = append(lines, fmt.Sprintf("Digest: `%s`", digest[:19]+"..."))
|
||||
}
|
||||
|
||||
if v.Total == 0 {
|
||||
lines = append(lines, "🟩 No vulnerabilities found")
|
||||
} else {
|
||||
if v.Critical > 0 {
|
||||
lines = append(lines, fmt.Sprintf("🟥 Critical: %d", v.Critical))
|
||||
}
|
||||
if v.High > 0 {
|
||||
lines = append(lines, fmt.Sprintf("🟧 High: %d", v.High))
|
||||
}
|
||||
if v.Medium > 0 {
|
||||
lines = append(lines, fmt.Sprintf("🟨 Medium: %d", v.Medium))
|
||||
}
|
||||
if v.Low > 0 {
|
||||
lines = append(lines, fmt.Sprintf("🟫 Low: %d", v.Low))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// formatDiscordPayload wraps an ATCR webhook payload in Discord's embed format
|
||||
func formatDiscordPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
|
||||
appviewURL := meta.BaseURL
|
||||
title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag)
|
||||
|
||||
description := formatVulnDescription(p.Scan.Vulnerabilities, p.Manifest.Digest)
|
||||
|
||||
// Add previous counts for scan:changed
|
||||
if p.Trigger == "scan:changed" && p.Previous != nil {
|
||||
description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d",
|
||||
p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low)
|
||||
}
|
||||
|
||||
embed := map[string]any{
|
||||
"title": title,
|
||||
"url": appviewURL,
|
||||
"description": description,
|
||||
"color": webhookSeverityColor(p.Scan.Vulnerabilities),
|
||||
"footer": map[string]string{
|
||||
"text": meta.ClientShortName,
|
||||
"icon_url": meta.FaviconURL,
|
||||
},
|
||||
"timestamp": p.Scan.ScannedAt,
|
||||
}
|
||||
|
||||
// Add author, repo link, and OG image when handle is available
|
||||
if p.Manifest.UserHandle != "" {
|
||||
embed["url"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
|
||||
embed["author"] = map[string]string{
|
||||
"name": p.Manifest.UserHandle,
|
||||
"url": appviewURL + "/u/" + p.Manifest.UserHandle,
|
||||
}
|
||||
embed["image"] = map[string]string{
|
||||
"url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository),
|
||||
}
|
||||
} else {
|
||||
embed["image"] = map[string]string{
|
||||
"url": appviewURL + "/og/home",
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"username": meta.ClientShortName,
|
||||
"avatar_url": meta.FaviconURL,
|
||||
"embeds": []any{embed},
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
// formatSlackPayload wraps an ATCR webhook payload in Slack's message format
|
||||
func formatSlackPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
|
||||
appviewURL := meta.BaseURL
|
||||
title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag)
|
||||
|
||||
v := p.Scan.Vulnerabilities
|
||||
fallback := fmt.Sprintf("%s — %d critical, %d high, %d medium, %d low",
|
||||
title, v.Critical, v.High, v.Medium, v.Low)
|
||||
|
||||
description := formatVulnDescription(v, p.Manifest.Digest)
|
||||
|
||||
// Add previous counts for scan:changed
|
||||
if p.Trigger == "scan:changed" && p.Previous != nil {
|
||||
description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d",
|
||||
p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low)
|
||||
}
|
||||
|
||||
attachment := map[string]any{
|
||||
"fallback": fallback,
|
||||
"color": webhookSeverityHex(v),
|
||||
"title": title,
|
||||
"text": description,
|
||||
"footer": meta.ClientShortName,
|
||||
"footer_icon": meta.FaviconURL,
|
||||
"ts": p.Scan.ScannedAt,
|
||||
}
|
||||
|
||||
// Add repo link when handle is available
|
||||
if p.Manifest.UserHandle != "" {
|
||||
attachment["title_link"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
|
||||
attachment["image_url"] = fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
|
||||
attachment["author_name"] = p.Manifest.UserHandle
|
||||
attachment["author_link"] = appviewURL + "/u/" + p.Manifest.UserHandle
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"text": fallback,
|
||||
"attachments": []any{attachment},
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Package webhooks provides webhook dispatch and formatting for scan notifications.
|
||||
package webhooks
|
||||
|
||||
// Webhook trigger bitmask constants
|
||||
const (
|
||||
TriggerFirst = 0x01 // First-time scan (no previous scan record)
|
||||
TriggerAll = 0x02 // Every scan completion
|
||||
TriggerChanged = 0x04 // Vulnerability counts changed from previous
|
||||
)
|
||||
|
||||
// WebhookPayload is the JSON body sent to webhook URLs
|
||||
type WebhookPayload struct {
|
||||
Trigger string `json:"trigger"`
|
||||
HoldDID string `json:"holdDid"`
|
||||
HoldEndpoint string `json:"holdEndpoint"`
|
||||
Manifest WebhookManifestInfo `json:"manifest"`
|
||||
Scan WebhookScanInfo `json:"scan"`
|
||||
Previous *WebhookVulnCounts `json:"previous"`
|
||||
}
|
||||
|
||||
// WebhookManifestInfo describes the scanned manifest
|
||||
type WebhookManifestInfo struct {
|
||||
Digest string `json:"digest"`
|
||||
Repository string `json:"repository"`
|
||||
Tag string `json:"tag"`
|
||||
UserDID string `json:"userDid"`
|
||||
UserHandle string `json:"userHandle,omitempty"`
|
||||
}
|
||||
|
||||
// WebhookScanInfo describes the scan results
|
||||
type WebhookScanInfo struct {
|
||||
ScannedAt string `json:"scannedAt"`
|
||||
ScannerVersion string `json:"scannerVersion"`
|
||||
Vulnerabilities WebhookVulnCounts `json:"vulnerabilities"`
|
||||
}
|
||||
|
||||
// WebhookVulnCounts contains vulnerability counts by severity
|
||||
type WebhookVulnCounts struct {
|
||||
Critical int `json:"critical"`
|
||||
High int `json:"high"`
|
||||
Medium int `json:"medium"`
|
||||
Low int `json:"low"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
+2
-298
@@ -377,7 +377,7 @@ func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
|
||||
}
|
||||
|
||||
cw := cbg.NewCborWriter(w)
|
||||
fieldCount := 9
|
||||
fieldCount := 8
|
||||
|
||||
if t.Region == "" {
|
||||
fieldCount--
|
||||
@@ -387,10 +387,6 @@ func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
|
||||
fieldCount--
|
||||
}
|
||||
|
||||
if t.SupporterBadgeTiers == nil {
|
||||
fieldCount--
|
||||
}
|
||||
|
||||
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -563,42 +559,6 @@ func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
|
||||
if err := cbg.WriteBool(w, t.EnableBlueskyPosts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// t.SupporterBadgeTiers ([]string) (slice)
|
||||
if t.SupporterBadgeTiers != nil {
|
||||
|
||||
if len("supporterBadgeTiers") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"supporterBadgeTiers\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("supporterBadgeTiers"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("supporterBadgeTiers")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.SupporterBadgeTiers) > 8192 {
|
||||
return xerrors.Errorf("Slice value in field t.SupporterBadgeTiers was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.SupporterBadgeTiers))); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, v := range t.SupporterBadgeTiers {
|
||||
if len(v) > 8192 {
|
||||
return xerrors.Errorf("Value in field v was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(v)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -627,7 +587,7 @@ func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) {
|
||||
|
||||
n := extra
|
||||
|
||||
nameBuf := make([]byte, 19)
|
||||
nameBuf := make([]byte, 18)
|
||||
for i := uint64(0); i < n; i++ {
|
||||
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
|
||||
if err != nil {
|
||||
@@ -752,46 +712,6 @@ func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) {
|
||||
default:
|
||||
return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra)
|
||||
}
|
||||
// t.SupporterBadgeTiers ([]string) (slice)
|
||||
case "supporterBadgeTiers":
|
||||
|
||||
maj, extra, err = cr.ReadHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if extra > 8192 {
|
||||
return fmt.Errorf("t.SupporterBadgeTiers: array too large (%d)", extra)
|
||||
}
|
||||
|
||||
if maj != cbg.MajArray {
|
||||
return fmt.Errorf("expected cbor array")
|
||||
}
|
||||
|
||||
if extra > 0 {
|
||||
t.SupporterBadgeTiers = make([]string, extra)
|
||||
}
|
||||
|
||||
for i := 0; i < int(extra); i++ {
|
||||
{
|
||||
var maj byte
|
||||
var extra uint64
|
||||
var err error
|
||||
_ = maj
|
||||
_ = extra
|
||||
_ = err
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.SupporterBadgeTiers[i] = string(sval)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Field doesn't exist on this type, so ignore it
|
||||
@@ -2505,219 +2425,3 @@ func (t *ScanRecord) UnmarshalCBOR(r io.Reader) (err error) {
|
||||
|
||||
return nil
|
||||
}
|
||||
func (t *HoldWebhookRecord) MarshalCBOR(w io.Writer) error {
|
||||
if t == nil {
|
||||
_, err := w.Write(cbg.CborNull)
|
||||
return err
|
||||
}
|
||||
|
||||
cw := cbg.NewCborWriter(w)
|
||||
|
||||
if _, err := cw.Write([]byte{164}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// t.Type (string) (string)
|
||||
if len("$type") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"$type\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("$type")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.Type) > 8192 {
|
||||
return xerrors.Errorf("Value in field t.Type was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(t.Type)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// t.UserDID (string) (string)
|
||||
if len("userDid") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"userDid\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("userDid"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("userDid")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.UserDID) > 8192 {
|
||||
return xerrors.Errorf("Value in field t.UserDID was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.UserDID))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(t.UserDID)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// t.Triggers (int64) (int64)
|
||||
if len("triggers") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"triggers\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("triggers"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("triggers")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if t.Triggers >= 0 {
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Triggers)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Triggers-1)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// t.CreatedAt (string) (string)
|
||||
if len("createdAt") > 8192 {
|
||||
return xerrors.Errorf("Value in field \"createdAt\" was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string("createdAt")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(t.CreatedAt) > 8192 {
|
||||
return xerrors.Errorf("Value in field t.CreatedAt was too long")
|
||||
}
|
||||
|
||||
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *HoldWebhookRecord) UnmarshalCBOR(r io.Reader) (err error) {
|
||||
*t = HoldWebhookRecord{}
|
||||
|
||||
cr := cbg.NewCborReader(r)
|
||||
|
||||
maj, extra, err := cr.ReadHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err == io.EOF {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
}()
|
||||
|
||||
if maj != cbg.MajMap {
|
||||
return fmt.Errorf("cbor input should be of type map")
|
||||
}
|
||||
|
||||
if extra > cbg.MaxLength {
|
||||
return fmt.Errorf("HoldWebhookRecord: map struct too large (%d)", extra)
|
||||
}
|
||||
|
||||
n := extra
|
||||
|
||||
nameBuf := make([]byte, 9)
|
||||
for i := uint64(0); i < n; i++ {
|
||||
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !ok {
|
||||
// Field doesn't exist on this type, so ignore it
|
||||
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch string(nameBuf[:nameLen]) {
|
||||
// t.Type (string) (string)
|
||||
case "$type":
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.Type = string(sval)
|
||||
}
|
||||
// t.UserDID (string) (string)
|
||||
case "userDid":
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.UserDID = string(sval)
|
||||
}
|
||||
// t.Triggers (int64) (int64)
|
||||
case "triggers":
|
||||
{
|
||||
maj, extra, err := cr.ReadHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var extraI int64
|
||||
switch maj {
|
||||
case cbg.MajUnsignedInt:
|
||||
extraI = int64(extra)
|
||||
if extraI < 0 {
|
||||
return fmt.Errorf("int64 positive overflow")
|
||||
}
|
||||
case cbg.MajNegativeInt:
|
||||
extraI = int64(extra)
|
||||
if extraI < 0 {
|
||||
return fmt.Errorf("int64 negative overflow")
|
||||
}
|
||||
extraI = -1 - extraI
|
||||
default:
|
||||
return fmt.Errorf("wrong type for int64 field: %d", maj)
|
||||
}
|
||||
|
||||
t.Triggers = int64(extraI)
|
||||
}
|
||||
// t.CreatedAt (string) (string)
|
||||
case "createdAt":
|
||||
|
||||
{
|
||||
sval, err := cbg.ReadStringWithMax(cr, 8192)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.CreatedAt = string(sval)
|
||||
}
|
||||
|
||||
default:
|
||||
// Field doesn't exist on this type, so ignore it
|
||||
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+16
-26
@@ -85,32 +85,6 @@ const (
|
||||
// Auth: Shared secret (query param or header)
|
||||
// Response: Stream of scan job events (JSON)
|
||||
HoldSubscribeScanJobs = "/xrpc/io.atcr.hold.subscribeScanJobs"
|
||||
|
||||
// HoldListWebhooks lists webhook configurations for a user.
|
||||
// Method: GET
|
||||
// Query: userDid={did}
|
||||
// Response: {webhooks: [...], limits: {max, allTriggers}}
|
||||
HoldListWebhooks = "/xrpc/io.atcr.hold.listWebhooks"
|
||||
|
||||
// HoldAddWebhook creates a new webhook configuration.
|
||||
// Method: POST
|
||||
// Request: {userDid, url, secret, triggers}
|
||||
// Response: {rkey, cid}
|
||||
HoldAddWebhook = "/xrpc/io.atcr.hold.addWebhook"
|
||||
|
||||
// HoldDeleteWebhook deletes a webhook configuration.
|
||||
// Method: POST
|
||||
// Request: {rkey}
|
||||
// Response: {success: true}
|
||||
HoldDeleteWebhook = "/xrpc/io.atcr.hold.deleteWebhook"
|
||||
|
||||
// HoldTestWebhook sends a test payload to a webhook.
|
||||
// Method: POST
|
||||
// Request: {rkey}
|
||||
// Response: {statusCode, success}
|
||||
HoldTestWebhook = "/xrpc/io.atcr.hold.testWebhook"
|
||||
|
||||
// Future: HoldDelegateAccess = "/xrpc/io.atcr.hold.delegateAccess"
|
||||
)
|
||||
|
||||
// ATProto sync endpoints (com.atproto.sync.*)
|
||||
@@ -269,6 +243,22 @@ const (
|
||||
IdentityResolveHandle = "/xrpc/com.atproto.identity.resolveHandle"
|
||||
)
|
||||
|
||||
// Hold billing/tier endpoints (io.atcr.hold.*)
|
||||
//
|
||||
// These endpoints manage billing tiers on hold services.
|
||||
const (
|
||||
// HoldUpdateCrewTier updates a crew member's tier. Only accepts requests from the trusted appview.
|
||||
// Method: POST
|
||||
// Request: {"userDid": "did:...", "tierRank": 0}
|
||||
// Response: {"tierName": "deckhand"}
|
||||
HoldUpdateCrewTier = "/xrpc/io.atcr.hold.updateCrewTier"
|
||||
|
||||
// HoldListTiers lists the hold's available tiers with storage quotas and features.
|
||||
// Method: GET
|
||||
// Response: {"tiers": [{"name": "deckhand", "quotaBytes": 5368709120, "quotaFormatted": "5.0 GB", "scanOnPush": false}]}
|
||||
HoldListTiers = "/xrpc/io.atcr.hold.listTiers"
|
||||
)
|
||||
|
||||
// Appview metadata endpoint (io.atcr.*)
|
||||
const (
|
||||
// AppviewGetMetadata returns appview branding and configuration metadata.
|
||||
|
||||
@@ -33,7 +33,6 @@ func main() {
|
||||
atproto.TangledProfileRecord{},
|
||||
atproto.StatsRecord{},
|
||||
atproto.ScanRecord{},
|
||||
atproto.HoldWebhookRecord{},
|
||||
); err != nil {
|
||||
fmt.Printf("Failed to generate CBOR encoders: %v\n", err)
|
||||
os.Exit(1)
|
||||
|
||||
+8
-65
@@ -64,12 +64,6 @@ const (
|
||||
// RepoPageCollection is the collection name for repository page metadata
|
||||
// Stored in user's PDS with rkey = repository name
|
||||
RepoPageCollection = "io.atcr.repo.page"
|
||||
|
||||
// SailorWebhookCollection is the collection name for webhook configs in user's PDS
|
||||
SailorWebhookCollection = "io.atcr.sailor.webhook"
|
||||
|
||||
// WebhookCollection is the collection name for webhook records in hold's embedded PDS
|
||||
WebhookCollection = "io.atcr.hold.webhook"
|
||||
)
|
||||
|
||||
// ManifestRecord represents a container image manifest stored in ATProto
|
||||
@@ -667,15 +661,14 @@ func (t *TagRecord) GetManifestDigest() (string, error) {
|
||||
// Stored in the hold's embedded PDS to identify the hold owner and settings
|
||||
// Uses CBOR encoding for efficient storage in hold's carstore
|
||||
type CaptainRecord struct {
|
||||
Type string `json:"$type" cborgen:"$type"`
|
||||
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
|
||||
Public bool `json:"public" cborgen:"public"` // Public read access
|
||||
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
|
||||
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
|
||||
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
|
||||
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
|
||||
Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect)
|
||||
SupporterBadgeTiers []string `json:"supporterBadgeTiers,omitempty" cborgen:"supporterBadgeTiers,omitempty"` // Tier names that earn a supporter badge on profiles
|
||||
Type string `json:"$type" cborgen:"$type"`
|
||||
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
|
||||
Public bool `json:"public" cborgen:"public"` // Public read access
|
||||
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
|
||||
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
|
||||
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
|
||||
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
|
||||
Successor string `json:"successor,omitempty" cborgen:"successor,omitempty"` // DID of successor hold (migration redirect)
|
||||
}
|
||||
|
||||
// CrewRecord represents a crew member in the hold
|
||||
@@ -822,56 +815,6 @@ func ScanRecordKey(manifestDigest string) string {
|
||||
return strings.TrimPrefix(manifestDigest, "sha256:")
|
||||
}
|
||||
|
||||
// Webhook trigger bitmask constants
|
||||
const (
|
||||
TriggerFirst = 0x01 // First-time scan (no previous scan record)
|
||||
TriggerAll = 0x02 // Every scan completion
|
||||
TriggerChanged = 0x04 // Vulnerability counts changed from previous
|
||||
)
|
||||
|
||||
// SailorWebhookRecord represents a webhook config in the user's PDS
|
||||
// Links to a private HoldWebhookRecord via privateCid
|
||||
type SailorWebhookRecord struct {
|
||||
Type string `json:"$type"`
|
||||
HoldDID string `json:"holdDid"`
|
||||
Triggers int `json:"triggers"`
|
||||
PrivateCID string `json:"privateCid"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// NewSailorWebhookRecord creates a new sailor webhook record
|
||||
func NewSailorWebhookRecord(holdDID string, triggers int, privateCID string) *SailorWebhookRecord {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
return &SailorWebhookRecord{
|
||||
Type: SailorWebhookCollection,
|
||||
HoldDID: holdDID,
|
||||
Triggers: triggers,
|
||||
PrivateCID: privateCID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// HoldWebhookRecord represents a webhook record in the hold's embedded PDS
|
||||
// The actual URL and secret are stored in SQLite (never in ATProto records)
|
||||
type HoldWebhookRecord struct {
|
||||
Type string `json:"$type" cborgen:"$type"`
|
||||
UserDID string `json:"userDid" cborgen:"userDid"`
|
||||
Triggers int64 `json:"triggers" cborgen:"triggers"`
|
||||
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
|
||||
}
|
||||
|
||||
// NewHoldWebhookRecord creates a new hold webhook record
|
||||
func NewHoldWebhookRecord(userDID string, triggers int) *HoldWebhookRecord {
|
||||
return &HoldWebhookRecord{
|
||||
Type: WebhookCollection,
|
||||
UserDID: userDID,
|
||||
Triggers: int64(triggers),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// TangledProfileRecord represents a Tangled profile for the hold
|
||||
// Collection: sh.tangled.actor.profile (singleton record at rkey "self")
|
||||
// Stored in the hold's embedded PDS
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// CreateAppviewServiceToken creates a short-lived ES256 JWT for appview→hold communication.
|
||||
// The token authenticates the appview when calling hold XRPC endpoints like updateCrewTier.
|
||||
//
|
||||
// Claims:
|
||||
// - iss: appview DID (e.g. did:web:atcr.io)
|
||||
// - aud: hold DID (e.g. did:web:hold01.atcr.io)
|
||||
// - sub: user DID being acted upon
|
||||
// - exp: now + 60s
|
||||
// - iat: now
|
||||
func CreateAppviewServiceToken(privateKey *atcrypto.PrivateKeyP256, appviewDID, holdDID, userDID string) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
claims := jwt.RegisteredClaims{
|
||||
Issuer: appviewDID,
|
||||
Audience: jwt.ClaimStrings{holdDID},
|
||||
Subject: userDID,
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(60 * time.Second)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
|
||||
ecKey, err := P256ToECDSA(privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to extract ECDSA key: %w", err)
|
||||
}
|
||||
|
||||
signed, err := token.SignedString(ecKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign token: %w", err)
|
||||
}
|
||||
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
// P256ToECDSA converts an atcrypto P-256 private key to a stdlib *ecdsa.PrivateKey.
|
||||
// This is needed because golang-jwt requires stdlib crypto types, while atcrypto
|
||||
// wraps them in its own types. We re-parse via PKCS8 encoding round-trip.
|
||||
func P256ToECDSA(key *atcrypto.PrivateKeyP256) (*ecdsa.PrivateKey, error) {
|
||||
rawBytes := key.Bytes() // 32-byte raw scalar
|
||||
|
||||
// Parse raw bytes as ecdh key, then convert via PKCS8 round-trip (same as atcrypto does)
|
||||
ecdhKey, err := ecdh.P256().NewPrivateKey(rawBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse P-256 raw bytes: %w", err)
|
||||
}
|
||||
|
||||
pkcs8, err := x509.MarshalPKCS8PrivateKey(ecdhKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal PKCS8: %w", err)
|
||||
}
|
||||
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(pkcs8)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse PKCS8: %w", err)
|
||||
}
|
||||
|
||||
ecdsaKey, ok := parsed.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("parsed key is not ECDSA")
|
||||
}
|
||||
|
||||
return ecdsaKey, nil
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
//go:build billing
|
||||
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/holdclient"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
|
||||
"github.com/stripe/stripe-go/v84"
|
||||
portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
|
||||
"github.com/stripe/stripe-go/v84/checkout/session"
|
||||
"github.com/stripe/stripe-go/v84/customer"
|
||||
"github.com/stripe/stripe-go/v84/price"
|
||||
"github.com/stripe/stripe-go/v84/subscription"
|
||||
"github.com/stripe/stripe-go/v84/webhook"
|
||||
)
|
||||
|
||||
// Manager handles Stripe billing and pushes tier updates to managed holds.
|
||||
type Manager struct {
|
||||
cfg *Config
|
||||
privateKey *atcrypto.PrivateKeyP256
|
||||
appviewDID string
|
||||
managedHolds []string
|
||||
baseURL string
|
||||
stripeKey string
|
||||
webhookSecret string
|
||||
|
||||
// Captain checker: bypasses billing for hold owners
|
||||
captainChecker CaptainChecker
|
||||
|
||||
// Customer cache: DID → Stripe customer
|
||||
customerCache map[string]*cachedCustomer
|
||||
customerCacheMu sync.RWMutex
|
||||
|
||||
// Price cache: Stripe price ID → unit amount in cents
|
||||
priceCache map[string]*cachedPrice
|
||||
priceCacheMu sync.RWMutex
|
||||
|
||||
// Hold tier cache: holdDID → tier list
|
||||
holdTierCache map[string]*cachedHoldTiers
|
||||
holdTierCacheMu sync.RWMutex
|
||||
}
|
||||
|
||||
type cachedHoldTiers struct {
|
||||
tiers []holdclient.HoldTierInfo
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type cachedCustomer struct {
|
||||
customer *stripe.Customer
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type cachedPrice struct {
|
||||
unitAmount int64
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
const customerCacheTTL = 10 * time.Minute
|
||||
const priceCacheTTL = 1 * time.Hour
|
||||
|
||||
// New creates a new billing manager with Stripe integration.
|
||||
// Env vars STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET take precedence over config values.
|
||||
func New(cfg *Config, privateKey *atcrypto.PrivateKeyP256, appviewDID string, managedHolds []string, baseURL string) *Manager {
|
||||
stripeKey := os.Getenv("STRIPE_SECRET_KEY")
|
||||
if stripeKey == "" {
|
||||
stripeKey = cfg.StripeSecretKey
|
||||
}
|
||||
if stripeKey != "" {
|
||||
stripe.Key = stripeKey
|
||||
}
|
||||
|
||||
webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET")
|
||||
if webhookSecret == "" {
|
||||
webhookSecret = cfg.WebhookSecret
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
cfg: cfg,
|
||||
privateKey: privateKey,
|
||||
appviewDID: appviewDID,
|
||||
managedHolds: managedHolds,
|
||||
baseURL: baseURL,
|
||||
stripeKey: stripeKey,
|
||||
webhookSecret: webhookSecret,
|
||||
customerCache: make(map[string]*cachedCustomer),
|
||||
priceCache: make(map[string]*cachedPrice),
|
||||
holdTierCache: make(map[string]*cachedHoldTiers),
|
||||
}
|
||||
}
|
||||
|
||||
// SetCaptainChecker sets a callback that checks if a user is a hold captain.
|
||||
// Captains bypass all billing feature gates.
|
||||
func (m *Manager) SetCaptainChecker(fn CaptainChecker) {
|
||||
m.captainChecker = fn
|
||||
}
|
||||
|
||||
func (m *Manager) isCaptain(userDID string) bool {
|
||||
return m.captainChecker != nil && userDID != "" && m.captainChecker(userDID)
|
||||
}
|
||||
|
||||
// Enabled returns true if billing is properly configured.
|
||||
func (m *Manager) Enabled() bool {
|
||||
return m.cfg != nil && m.stripeKey != "" && len(m.cfg.Tiers) > 0
|
||||
}
|
||||
|
||||
// GetWebhookLimits returns webhook limits for a user based on their subscription tier.
|
||||
// Returns (maxWebhooks, allTriggers). Defaults to the lowest tier's limits.
|
||||
// Hold captains get unlimited webhooks with all triggers.
|
||||
func (m *Manager) GetWebhookLimits(userDID string) (int, bool) {
|
||||
if m.isCaptain(userDID) {
|
||||
return -1, true // unlimited
|
||||
}
|
||||
if !m.Enabled() {
|
||||
return 1, false
|
||||
}
|
||||
|
||||
info, err := m.GetSubscriptionInfo(userDID)
|
||||
if err != nil || info == nil {
|
||||
return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers
|
||||
}
|
||||
|
||||
rank := info.TierRank
|
||||
if rank >= 0 && rank < len(m.cfg.Tiers) {
|
||||
return m.cfg.Tiers[rank].MaxWebhooks, m.cfg.Tiers[rank].WebhookAllTriggers
|
||||
}
|
||||
|
||||
return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers
|
||||
}
|
||||
|
||||
// GetSupporterBadge returns the supporter badge tier name for a user based on their subscription.
|
||||
// Returns the tier name if the user's current tier has supporter badges enabled, empty string otherwise.
|
||||
// Hold captains get a "Captain" badge.
|
||||
func (m *Manager) GetSupporterBadge(userDID string) string {
|
||||
if m.isCaptain(userDID) {
|
||||
return "Captain"
|
||||
}
|
||||
if !m.Enabled() {
|
||||
return ""
|
||||
}
|
||||
|
||||
info, err := m.GetSubscriptionInfo(userDID)
|
||||
if err != nil || info == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, tier := range info.Tiers {
|
||||
if tier.ID == info.CurrentTier && tier.SupporterBadge {
|
||||
return info.CurrentTier
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetSubscriptionInfo returns subscription and tier information for a user.
|
||||
// Hold captains see a special "Captain" tier with all features unlocked.
|
||||
func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error) {
|
||||
if m.isCaptain(userDID) {
|
||||
return &SubscriptionInfo{
|
||||
UserDID: userDID,
|
||||
CurrentTier: "Captain",
|
||||
TierRank: -1, // above all configured tiers
|
||||
Tiers: []TierInfo{{
|
||||
ID: "Captain",
|
||||
Name: "Captain",
|
||||
Description: "Hold operator",
|
||||
Features: []string{"Unlimited storage", "Unlimited webhooks", "All webhook triggers", "Scan on push"},
|
||||
Rank: -1,
|
||||
MaxWebhooks: -1,
|
||||
WebhookAllTriggers: true,
|
||||
SupporterBadge: true,
|
||||
IsCurrent: true,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
if !m.Enabled() {
|
||||
return nil, ErrBillingDisabled
|
||||
}
|
||||
|
||||
info := &SubscriptionInfo{
|
||||
UserDID: userDID,
|
||||
PaymentsEnabled: true,
|
||||
CurrentTier: m.cfg.Tiers[0].Name, // default to lowest
|
||||
TierRank: 0,
|
||||
}
|
||||
|
||||
// Build tier list with live Stripe prices
|
||||
info.Tiers = make([]TierInfo, len(m.cfg.Tiers))
|
||||
for i, tier := range m.cfg.Tiers {
|
||||
// Dynamic features: hold-derived first, then webhook limits, then static config
|
||||
features := m.aggregateHoldFeatures(i)
|
||||
features = append(features, webhookFeatures(tier.MaxWebhooks, tier.WebhookAllTriggers)...)
|
||||
if tier.SupporterBadge {
|
||||
features = append(features, "Supporter badge")
|
||||
}
|
||||
features = append(features, tier.Features...)
|
||||
info.Tiers[i] = TierInfo{
|
||||
ID: tier.Name,
|
||||
Name: tier.Name,
|
||||
Description: tier.Description,
|
||||
Features: features,
|
||||
Rank: i,
|
||||
MaxWebhooks: tier.MaxWebhooks,
|
||||
WebhookAllTriggers: tier.WebhookAllTriggers,
|
||||
SupporterBadge: tier.SupporterBadge,
|
||||
}
|
||||
if tier.StripePriceMonthly != "" {
|
||||
if amount, err := m.fetchPrice(tier.StripePriceMonthly); err == nil {
|
||||
info.Tiers[i].PriceCentsMonthly = int(amount)
|
||||
}
|
||||
}
|
||||
if tier.StripePriceYearly != "" {
|
||||
if amount, err := m.fetchPrice(tier.StripePriceYearly); err == nil {
|
||||
info.Tiers[i].PriceCentsYearly = int(amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if userDID == "" {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Find Stripe customer for this user
|
||||
cust, err := m.findCustomerByDID(userDID)
|
||||
if err != nil {
|
||||
slog.Debug("No Stripe customer found", "userDID", userDID, "error", err)
|
||||
return info, nil
|
||||
}
|
||||
info.CustomerID = cust.ID
|
||||
|
||||
// Find active subscription
|
||||
params := &stripe.SubscriptionListParams{}
|
||||
params.Filters.AddFilter("customer", "", cust.ID)
|
||||
params.Filters.AddFilter("status", "", "active")
|
||||
iter := subscription.List(params)
|
||||
|
||||
for iter.Next() {
|
||||
sub := iter.Subscription()
|
||||
info.SubscriptionID = sub.ID
|
||||
|
||||
if sub.Items != nil && len(sub.Items.Data) > 0 {
|
||||
priceID := sub.Items.Data[0].Price.ID
|
||||
tierName, tierRank := m.cfg.GetTierByPriceID(priceID)
|
||||
if tierName != "" {
|
||||
info.CurrentTier = tierName
|
||||
info.TierRank = tierRank
|
||||
}
|
||||
|
||||
if sub.Items.Data[0].Price.Recurring != nil {
|
||||
switch sub.Items.Data[0].Price.Recurring.Interval {
|
||||
case stripe.PriceRecurringIntervalMonth:
|
||||
info.BillingInterval = "monthly"
|
||||
case stripe.PriceRecurringIntervalYear:
|
||||
info.BillingInterval = "yearly"
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Mark current tier
|
||||
for i := range info.Tiers {
|
||||
info.Tiers[i].IsCurrent = info.Tiers[i].ID == info.CurrentTier
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// CreateCheckoutSession creates a Stripe checkout session for a subscription.
|
||||
func (m *Manager) CreateCheckoutSession(r *http.Request, userDID, userHandle string, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
|
||||
if !m.Enabled() {
|
||||
return nil, ErrBillingDisabled
|
||||
}
|
||||
|
||||
// Find the tier config
|
||||
rank := m.cfg.TierRank(req.Tier)
|
||||
if rank < 0 {
|
||||
return nil, fmt.Errorf("unknown tier: %s", req.Tier)
|
||||
}
|
||||
tierCfg := m.cfg.Tiers[rank]
|
||||
|
||||
// Determine price ID: prefer monthly so Stripe upsell can offer yearly toggle,
|
||||
// fall back to yearly if no monthly price exists.
|
||||
var priceID string
|
||||
if req.Interval == "yearly" && tierCfg.StripePriceYearly != "" {
|
||||
priceID = tierCfg.StripePriceYearly
|
||||
} else if tierCfg.StripePriceMonthly != "" {
|
||||
priceID = tierCfg.StripePriceMonthly
|
||||
} else if tierCfg.StripePriceYearly != "" {
|
||||
priceID = tierCfg.StripePriceYearly
|
||||
}
|
||||
if priceID == "" {
|
||||
return nil, fmt.Errorf("tier %s has no Stripe price configured", req.Tier)
|
||||
}
|
||||
|
||||
// Get or create Stripe customer
|
||||
cust, err := m.getOrCreateCustomer(userDID, userHandle)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get/create customer: %w", err)
|
||||
}
|
||||
|
||||
// Build success/cancel URLs
|
||||
successURL := strings.ReplaceAll(m.cfg.SuccessURL, "{base_url}", m.baseURL)
|
||||
cancelURL := strings.ReplaceAll(m.cfg.CancelURL, "{base_url}", m.baseURL)
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Customer: stripe.String(cust.ID),
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
SuccessURL: stripe.String(successURL),
|
||||
CancelURL: stripe.String(cancelURL),
|
||||
}
|
||||
|
||||
s, err := session.New(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create checkout session: %w", err)
|
||||
}
|
||||
|
||||
return &CheckoutSessionResponse{
|
||||
CheckoutURL: s.URL,
|
||||
SessionID: s.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetBillingPortalURL creates a Stripe billing portal session.
|
||||
func (m *Manager) GetBillingPortalURL(userDID, returnURL string) (*BillingPortalResponse, error) {
|
||||
if !m.Enabled() {
|
||||
return nil, ErrBillingDisabled
|
||||
}
|
||||
|
||||
cust, err := m.findCustomerByDID(userDID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no billing account found")
|
||||
}
|
||||
|
||||
params := &stripe.BillingPortalSessionParams{
|
||||
Customer: stripe.String(cust.ID),
|
||||
ReturnURL: stripe.String(returnURL),
|
||||
}
|
||||
|
||||
s, err := portalsession.New(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create portal session: %w", err)
|
||||
}
|
||||
|
||||
return &BillingPortalResponse{PortalURL: s.URL}, nil
|
||||
}
|
||||
|
||||
// HandleWebhook processes a Stripe webhook event.
|
||||
// On subscription changes, it pushes tier updates to all managed holds.
|
||||
func (m *Manager) HandleWebhook(r *http.Request) error {
|
||||
if !m.Enabled() {
|
||||
return ErrBillingDisabled
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read webhook body: %w", err)
|
||||
}
|
||||
|
||||
event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), m.webhookSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "checkout.session.completed":
|
||||
m.handleCheckoutCompleted(event)
|
||||
case "customer.subscription.created",
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.deleted",
|
||||
"customer.subscription.paused",
|
||||
"customer.subscription.resumed":
|
||||
m.handleSubscriptionChange(event)
|
||||
default:
|
||||
slog.Debug("Ignoring Stripe event", "type", event.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCheckoutCompleted processes a checkout.session.completed event.
|
||||
func (m *Manager) handleCheckoutCompleted(event stripe.Event) {
|
||||
var cs stripe.CheckoutSession
|
||||
if err := json.Unmarshal(event.Data.Raw, &cs); err != nil {
|
||||
slog.Error("Failed to parse checkout session", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Checkout completed", "customerID", cs.Customer.ID, "subscriptionID", cs.Subscription.ID)
|
||||
|
||||
// The subscription.created event will handle the tier update
|
||||
}
|
||||
|
||||
// handleSubscriptionChange processes subscription lifecycle events.
|
||||
func (m *Manager) handleSubscriptionChange(event stripe.Event) {
|
||||
var sub stripe.Subscription
|
||||
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||
slog.Error("Failed to parse subscription", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user DID from customer metadata
|
||||
userDID := m.getCustomerDID(sub.Customer.ID)
|
||||
if userDID == "" {
|
||||
slog.Warn("No user DID found for Stripe customer", "customerID", sub.Customer.ID)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine new tier from subscription
|
||||
var tierName string
|
||||
var tierRank int
|
||||
|
||||
switch sub.Status {
|
||||
case stripe.SubscriptionStatusActive:
|
||||
if sub.Items != nil && len(sub.Items.Data) > 0 {
|
||||
priceID := sub.Items.Data[0].Price.ID
|
||||
tierName, tierRank = m.cfg.GetTierByPriceID(priceID)
|
||||
}
|
||||
case stripe.SubscriptionStatusCanceled, stripe.SubscriptionStatusPaused:
|
||||
// Revert to free tier (rank 0)
|
||||
tierName = m.cfg.Tiers[0].Name
|
||||
tierRank = 0
|
||||
default:
|
||||
slog.Debug("Ignoring subscription status", "status", sub.Status)
|
||||
return
|
||||
}
|
||||
|
||||
if tierName == "" {
|
||||
slog.Warn("Could not resolve tier from subscription", "priceID", sub.Items.Data[0].Price.ID)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Pushing tier update to managed holds",
|
||||
"userDID", userDID,
|
||||
"tierName", tierName,
|
||||
"tierRank", tierRank,
|
||||
"event", event.Type,
|
||||
)
|
||||
|
||||
// Push tier update to all managed holds
|
||||
go holdclient.UpdateCrewTierOnAllHolds(
|
||||
context.Background(),
|
||||
m.managedHolds,
|
||||
userDID,
|
||||
tierRank,
|
||||
m.privateKey,
|
||||
m.appviewDID,
|
||||
)
|
||||
|
||||
// Invalidate customer cache
|
||||
m.customerCacheMu.Lock()
|
||||
delete(m.customerCache, userDID)
|
||||
m.customerCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// getOrCreateCustomer finds or creates a Stripe customer for a DID.
|
||||
func (m *Manager) getOrCreateCustomer(userDID, userHandle string) (*stripe.Customer, error) {
|
||||
// Check cache
|
||||
m.customerCacheMu.RLock()
|
||||
if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) {
|
||||
m.customerCacheMu.RUnlock()
|
||||
return cached.customer, nil
|
||||
}
|
||||
m.customerCacheMu.RUnlock()
|
||||
|
||||
// Search Stripe
|
||||
cust, err := m.findCustomerByDID(userDID)
|
||||
if err == nil {
|
||||
m.cacheCustomer(userDID, cust)
|
||||
return cust, nil
|
||||
}
|
||||
|
||||
// Create new customer
|
||||
params := &stripe.CustomerParams{
|
||||
Params: stripe.Params{
|
||||
Metadata: map[string]string{
|
||||
"user_did": userDID,
|
||||
},
|
||||
},
|
||||
}
|
||||
if userHandle != "" {
|
||||
params.Name = stripe.String(userHandle)
|
||||
}
|
||||
|
||||
cust, err = customer.New(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Stripe customer: %w", err)
|
||||
}
|
||||
|
||||
m.cacheCustomer(userDID, cust)
|
||||
return cust, nil
|
||||
}
|
||||
|
||||
// findCustomerByDID searches Stripe for a customer with matching DID metadata.
|
||||
func (m *Manager) findCustomerByDID(userDID string) (*stripe.Customer, error) {
|
||||
params := &stripe.CustomerSearchParams{
|
||||
SearchParams: stripe.SearchParams{
|
||||
Query: fmt.Sprintf("metadata['user_did']:'%s'", userDID),
|
||||
},
|
||||
}
|
||||
|
||||
iter := customer.Search(params)
|
||||
for iter.Next() {
|
||||
return iter.Customer(), nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("customer not found for DID %s", userDID)
|
||||
}
|
||||
|
||||
// getCustomerDID retrieves the user DID from a Stripe customer's metadata.
|
||||
func (m *Manager) getCustomerDID(customerID string) string {
|
||||
cust, err := customer.Get(customerID, nil)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get customer", "customerID", customerID, "error", err)
|
||||
return ""
|
||||
}
|
||||
return cust.Metadata["user_did"]
|
||||
}
|
||||
|
||||
// cacheCustomer stores a customer in the in-memory cache.
|
||||
func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) {
|
||||
m.customerCacheMu.Lock()
|
||||
m.customerCache[userDID] = &cachedCustomer{
|
||||
customer: cust,
|
||||
expiresAt: time.Now().Add(customerCacheTTL),
|
||||
}
|
||||
m.customerCacheMu.Unlock()
|
||||
}
|
||||
|
||||
const holdTierCacheTTL = 30 * time.Minute
|
||||
|
||||
// RefreshHoldTiers queries all managed holds for their tier definitions and caches the results.
|
||||
// It runs once immediately (with retries for holds that aren't ready yet) and then
|
||||
// periodically in the background.
|
||||
// Safe to call from a goroutine.
|
||||
func (m *Manager) RefreshHoldTiers() {
|
||||
if !m.Enabled() || len(m.managedHolds) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// On startup, retry a few times with backoff in case holds aren't ready yet.
|
||||
// This is common in docker-compose where appview starts before the hold.
|
||||
const maxRetries = 5
|
||||
const initialDelay = 3 * time.Second
|
||||
|
||||
for attempt := range maxRetries {
|
||||
m.refreshHoldTiersOnce()
|
||||
|
||||
// Check if all managed holds are cached
|
||||
m.holdTierCacheMu.RLock()
|
||||
allCached := len(m.holdTierCache) == len(m.managedHolds)
|
||||
m.holdTierCacheMu.RUnlock()
|
||||
|
||||
if allCached {
|
||||
break
|
||||
}
|
||||
|
||||
if attempt < maxRetries-1 {
|
||||
delay := initialDelay * time.Duration(1<<attempt) // 3s, 6s, 12s, 24s
|
||||
slog.Info("Some managed holds not yet reachable, retrying",
|
||||
"attempt", attempt+1, "maxRetries", maxRetries, "retryIn", delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(holdTierCacheTTL)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
m.refreshHoldTiersOnce()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) refreshHoldTiersOnce() {
|
||||
for _, holdDID := range m.managedHolds {
|
||||
resp, err := holdclient.ListTiers(context.Background(), holdDID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch tiers from hold", "holdDID", holdDID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
m.holdTierCacheMu.Lock()
|
||||
m.holdTierCache[holdDID] = &cachedHoldTiers{
|
||||
tiers: resp.Tiers,
|
||||
expiresAt: time.Now().Add(holdTierCacheTTL),
|
||||
}
|
||||
m.holdTierCacheMu.Unlock()
|
||||
|
||||
slog.Debug("Cached tier data from hold", "holdDID", holdDID, "tierCount", len(resp.Tiers))
|
||||
}
|
||||
}
|
||||
|
||||
// aggregateHoldFeatures generates dynamic feature strings for a tier rank
|
||||
// by aggregating data from all cached managed holds.
|
||||
// Returns nil if no hold data is available.
|
||||
func (m *Manager) aggregateHoldFeatures(rank int) []string {
|
||||
m.holdTierCacheMu.RLock()
|
||||
defer m.holdTierCacheMu.RUnlock()
|
||||
|
||||
if len(m.holdTierCache) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
minQuota int64 = -1
|
||||
maxQuota int64
|
||||
scanCount int
|
||||
totalHolds int
|
||||
)
|
||||
|
||||
for _, cached := range m.holdTierCache {
|
||||
if time.Now().After(cached.expiresAt) {
|
||||
continue
|
||||
}
|
||||
if rank >= len(cached.tiers) {
|
||||
continue
|
||||
}
|
||||
totalHolds++
|
||||
tier := cached.tiers[rank]
|
||||
|
||||
if minQuota < 0 || tier.QuotaBytes < minQuota {
|
||||
minQuota = tier.QuotaBytes
|
||||
}
|
||||
if tier.QuotaBytes > maxQuota {
|
||||
maxQuota = tier.QuotaBytes
|
||||
}
|
||||
if tier.ScanOnPush {
|
||||
scanCount++
|
||||
}
|
||||
}
|
||||
|
||||
if totalHolds == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var features []string
|
||||
|
||||
// Storage feature
|
||||
if minQuota == maxQuota {
|
||||
features = append(features, formatBytes(minQuota)+" storage")
|
||||
} else {
|
||||
features = append(features, formatBytes(minQuota)+"-"+formatBytes(maxQuota)+" storage")
|
||||
}
|
||||
|
||||
// Scan on push feature
|
||||
if scanCount == totalHolds {
|
||||
features = append(features, "Scan on push")
|
||||
} else if scanCount*2 >= totalHolds {
|
||||
features = append(features, "Scan on push (most regions)")
|
||||
} else if scanCount > 0 {
|
||||
features = append(features, "Scan on push (some regions)")
|
||||
}
|
||||
|
||||
return features
|
||||
}
|
||||
|
||||
// webhookFeatures generates feature bullet strings for webhook limits.
|
||||
func webhookFeatures(maxWebhooks int, allTriggers bool) []string {
|
||||
var features []string
|
||||
switch {
|
||||
case maxWebhooks < 0:
|
||||
features = append(features, "Unlimited webhooks")
|
||||
case maxWebhooks == 1:
|
||||
features = append(features, "1 webhook")
|
||||
case maxWebhooks > 1:
|
||||
features = append(features, fmt.Sprintf("%d webhooks", maxWebhooks))
|
||||
}
|
||||
if allTriggers {
|
||||
features = append(features, "All webhook triggers")
|
||||
}
|
||||
return features
|
||||
}
|
||||
|
||||
// formatBytes formats bytes as a human-readable string (e.g. "5.0 GB").
|
||||
func formatBytes(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
units := []string{"KB", "MB", "GB", "TB", "PB"}
|
||||
return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp])
|
||||
}
|
||||
|
||||
// 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()
|
||||
if cached, ok := m.priceCache[priceID]; ok && time.Now().Before(cached.expiresAt) {
|
||||
m.priceCacheMu.RUnlock()
|
||||
return cached.unitAmount, nil
|
||||
}
|
||||
m.priceCacheMu.RUnlock()
|
||||
|
||||
p, err := price.Get(priceID, nil)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch Stripe price", "priceID", priceID, "error", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
m.priceCacheMu.Lock()
|
||||
m.priceCache[priceID] = &cachedPrice{
|
||||
unitAmount: p.UnitAmount,
|
||||
expiresAt: time.Now().Add(priceCacheTTL),
|
||||
}
|
||||
m.priceCacheMu.Unlock()
|
||||
|
||||
return p.UnitAmount, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//go:build !billing
|
||||
|
||||
package billing
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Manager is a no-op billing manager when billing is not compiled in.
|
||||
type Manager struct {
|
||||
captainChecker CaptainChecker
|
||||
}
|
||||
|
||||
// New creates a no-op billing manager.
|
||||
func New(_ *Config, _ *atcrypto.PrivateKeyP256, _ string, _ []string, _ string) *Manager {
|
||||
return &Manager{}
|
||||
}
|
||||
|
||||
// SetCaptainChecker sets a callback that checks if a user is a hold captain.
|
||||
func (m *Manager) SetCaptainChecker(fn CaptainChecker) {
|
||||
m.captainChecker = fn
|
||||
}
|
||||
|
||||
// Enabled returns false when billing is not compiled in.
|
||||
func (m *Manager) Enabled() bool { return false }
|
||||
|
||||
// GetWebhookLimits returns default limits when billing is not compiled in.
|
||||
// Hold captains get unlimited webhooks with all triggers.
|
||||
func (m *Manager) GetWebhookLimits(userDID string) (int, bool) {
|
||||
if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) {
|
||||
return -1, true
|
||||
}
|
||||
return 1, false
|
||||
}
|
||||
|
||||
// GetSubscriptionInfo returns an error when billing is not compiled in.
|
||||
func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) {
|
||||
return nil, ErrBillingDisabled
|
||||
}
|
||||
|
||||
// CreateCheckoutSession returns an error when billing is not compiled in.
|
||||
func (m *Manager) CreateCheckoutSession(_ *http.Request, _, _ string, _ *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
|
||||
return nil, ErrBillingDisabled
|
||||
}
|
||||
|
||||
// GetBillingPortalURL returns an error when billing is not compiled in.
|
||||
func (m *Manager) GetBillingPortalURL(_ string, _ string) (*BillingPortalResponse, error) {
|
||||
return nil, ErrBillingDisabled
|
||||
}
|
||||
|
||||
// HandleWebhook returns an error when billing is not compiled in.
|
||||
func (m *Manager) HandleWebhook(_ *http.Request) error {
|
||||
return ErrBillingDisabled
|
||||
}
|
||||
|
||||
// GetSupporterBadge returns empty string when billing is not compiled in.
|
||||
// Hold captains get a "Captain" badge.
|
||||
func (m *Manager) GetSupporterBadge(userDID string) string {
|
||||
if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) {
|
||||
return "Captain"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RegisterRoutes is a no-op when billing is not compiled in.
|
||||
func (m *Manager) RegisterRoutes(_ chi.Router) {}
|
||||
|
||||
// RefreshHoldTiers is a no-op when billing is not compiled in.
|
||||
func (m *Manager) RefreshHoldTiers() {}
|
||||
@@ -0,0 +1,83 @@
|
||||
package billing
|
||||
|
||||
// Config holds appview billing/Stripe configuration.
|
||||
// Parsed from the appview config YAML's billing section.
|
||||
type Config struct {
|
||||
// Stripe secret key (sk_test_... or sk_live_...).
|
||||
// Can also be set via STRIPE_SECRET_KEY env var (takes precedence over config).
|
||||
// Billing is enabled automatically when this key is set (requires -tags billing build).
|
||||
StripeSecretKey string `yaml:"stripe_secret_key" comment:"Stripe secret key. Can also be set via STRIPE_SECRET_KEY env var (takes precedence). Billing is enabled automatically when set."`
|
||||
|
||||
// Stripe webhook signing secret (whsec_...).
|
||||
// Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence over config).
|
||||
WebhookSecret string `yaml:"webhook_secret" comment:"Stripe webhook signing secret. Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence)."`
|
||||
|
||||
// Currency code for Stripe checkout (e.g. "usd").
|
||||
Currency string `yaml:"currency" comment:"ISO 4217 currency code (e.g. \"usd\")."`
|
||||
|
||||
// URL to redirect after successful checkout. {base_url} is replaced at runtime.
|
||||
SuccessURL string `yaml:"success_url" comment:"Redirect URL after successful checkout. Use {base_url} placeholder."`
|
||||
|
||||
// URL to redirect after cancelled checkout. {base_url} is replaced at runtime.
|
||||
CancelURL string `yaml:"cancel_url" comment:"Redirect URL after cancelled checkout. Use {base_url} placeholder."`
|
||||
|
||||
// Subscription tiers with Stripe price IDs.
|
||||
Tiers []BillingTierConfig `yaml:"tiers" comment:"Subscription tiers ordered by rank (lowest to highest)."`
|
||||
|
||||
// Whether hold owners get a supporter badge on their profile.
|
||||
OwnerBadge bool `yaml:"owner_badge" comment:"Show supporter badge on hold owner profiles."`
|
||||
}
|
||||
|
||||
// BillingTierConfig represents a single tier with optional Stripe pricing.
|
||||
type BillingTierConfig struct {
|
||||
// Tier name (matches hold quota tier names for rank mapping).
|
||||
Name string `yaml:"name" comment:"Tier name. Position in list determines rank (0-based)."`
|
||||
|
||||
// Short description shown on the plan card.
|
||||
Description string `yaml:"description,omitempty" comment:"Short description shown on the plan card."`
|
||||
|
||||
// List of features included in this tier (rendered as bullet points).
|
||||
Features []string `yaml:"features,omitempty" comment:"List of features included in this tier."`
|
||||
|
||||
// Stripe price ID for monthly billing. Empty = free tier.
|
||||
StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty" comment:"Stripe price ID for monthly billing. Empty = free tier."`
|
||||
|
||||
// Stripe price ID for yearly billing.
|
||||
StripePriceYearly string `yaml:"stripe_price_yearly,omitempty" comment:"Stripe price ID for yearly billing."`
|
||||
|
||||
// Maximum number of webhooks for this tier (-1 = unlimited).
|
||||
MaxWebhooks int `yaml:"max_webhooks" comment:"Maximum webhooks for this tier (-1 = unlimited)."`
|
||||
|
||||
// Whether all webhook trigger types are available (not just first-scan).
|
||||
WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types (not just first-scan)."`
|
||||
|
||||
// Whether this tier earns a supporter badge on user profiles.
|
||||
SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for subscribers at this tier."`
|
||||
}
|
||||
|
||||
// GetTierByPriceID finds the tier that contains the given Stripe price ID.
|
||||
// Returns the tier name and rank, or empty string and -1 if not found.
|
||||
func (c *Config) GetTierByPriceID(priceID string) (string, int) {
|
||||
if c == nil || priceID == "" {
|
||||
return "", -1
|
||||
}
|
||||
for i, tier := range c.Tiers {
|
||||
if tier.StripePriceMonthly == priceID || tier.StripePriceYearly == priceID {
|
||||
return tier.Name, i
|
||||
}
|
||||
}
|
||||
return "", -1
|
||||
}
|
||||
|
||||
// TierRank returns the 0-based rank of a tier by name, or -1 if not found.
|
||||
func (c *Config) TierRank(name string) int {
|
||||
if c == nil {
|
||||
return -1
|
||||
}
|
||||
for i, tier := range c.Tiers {
|
||||
if tier.Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//go:build billing
|
||||
|
||||
package billing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// RegisterRoutes registers billing HTTP routes on the router.
|
||||
// These routes handle subscription management and Stripe webhooks.
|
||||
func (m *Manager) RegisterRoutes(r chi.Router) {
|
||||
if !m.Enabled() {
|
||||
slog.Info("Billing routes disabled (not configured)")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Registering billing routes")
|
||||
|
||||
// Stripe webhook (public, verified by Stripe signature)
|
||||
r.Post("/api/stripe/webhook", m.handleStripeWebhook)
|
||||
}
|
||||
|
||||
// handleStripeWebhook processes incoming Stripe webhook events.
|
||||
func (m *Manager) handleStripeWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if err := m.HandleWebhook(r); err != nil {
|
||||
slog.Error("Stripe webhook error", "error", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write([]byte(`{"received": true}`)); err != nil {
|
||||
slog.Error("Failed to write webhook response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetSubscription is an HTTP handler that returns subscription info as JSON.
|
||||
// Used by the settings page HTMX endpoint.
|
||||
func (m *Manager) HandleGetSubscription(w http.ResponseWriter, r *http.Request, userDID string) {
|
||||
info, err := m.GetSubscriptionInfo(userDID)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, writeErr := w.Write([]byte("")); writeErr != nil {
|
||||
slog.Error("Failed to write empty response", "error", writeErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(info); err != nil {
|
||||
slog.Error("Failed to encode subscription info", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Package billing provides optional Stripe billing integration for the appview.
|
||||
// Build with -tags billing to enable Stripe integration.
|
||||
// Without the tag, no-op stubs are compiled instead.
|
||||
package billing
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrBillingDisabled is returned when billing operations are attempted
|
||||
// but billing is not compiled in or not configured.
|
||||
var ErrBillingDisabled = errors.New("billing not enabled")
|
||||
|
||||
// CaptainChecker returns true if a user DID is the captain (owner) of a managed hold.
|
||||
// Used to bypass billing feature gates for hold operators.
|
||||
type CaptainChecker func(userDID string) bool
|
||||
|
||||
// SubscriptionInfo contains subscription information for a user.
|
||||
type SubscriptionInfo struct {
|
||||
UserDID string `json:"userDid"`
|
||||
CurrentTier string `json:"currentTier"` // tier from Stripe subscription (or default)
|
||||
TierRank int `json:"tierRank"` // 0-based rank index
|
||||
PaymentsEnabled bool `json:"paymentsEnabled"` // whether billing is active
|
||||
Tiers []TierInfo `json:"tiers"` // available tiers with pricing
|
||||
SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID if active
|
||||
CustomerID string `json:"customerId,omitempty"` // Stripe customer ID if exists
|
||||
BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly"
|
||||
}
|
||||
|
||||
// TierInfo describes a single tier available for subscription.
|
||||
type TierInfo struct {
|
||||
ID string `json:"id"` // tier key
|
||||
Name string `json:"name"` // display name
|
||||
Description string `json:"description,omitempty"` // short description for the plan card
|
||||
Features []string `json:"features,omitempty"` // feature bullet points
|
||||
Rank int `json:"rank"` // 0-based rank
|
||||
PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"` // monthly price in cents (0 = free)
|
||||
PriceCentsYearly int `json:"priceCentsYearly,omitempty"` // yearly price in cents (0 = not available)
|
||||
MaxWebhooks int `json:"maxWebhooks"` // max webhooks (-1 = unlimited)
|
||||
WebhookAllTriggers bool `json:"webhookAllTriggers,omitempty"` // all trigger types available
|
||||
SupporterBadge bool `json:"supporterBadge,omitempty"` // earns supporter badge on profile
|
||||
IsCurrent bool `json:"isCurrent,omitempty"` // whether this is user's current tier
|
||||
}
|
||||
|
||||
// CheckoutSessionRequest is the request to create a Stripe checkout session.
|
||||
type CheckoutSessionRequest struct {
|
||||
Tier string `json:"tier"`
|
||||
Interval string `json:"interval,omitempty"` // "monthly" or "yearly"
|
||||
ReturnURL string `json:"returnUrl,omitempty"` // URL to return to after checkout
|
||||
}
|
||||
|
||||
// CheckoutSessionResponse is the response with the Stripe checkout URL.
|
||||
type CheckoutSessionResponse struct {
|
||||
CheckoutURL string `json:"checkoutUrl"`
|
||||
SessionID string `json:"sessionId"`
|
||||
}
|
||||
|
||||
// BillingPortalResponse is the response with the Stripe billing portal URL.
|
||||
type BillingPortalResponse struct {
|
||||
PortalURL string `json:"portalUrl"`
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
<symbol id="arrow-down-to-line" viewBox="0 0 24 24"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></symbol>
|
||||
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
|
||||
<symbol id="arrow-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
|
||||
<symbol id="badge-check" viewBox="0 0 24 24"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></symbol>
|
||||
<symbol id="box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol>
|
||||
<symbol id="check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
|
||||
<symbol id="check-circle" viewBox="0 0 24 24"><path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/></symbol>
|
||||
@@ -19,6 +18,7 @@
|
||||
<symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
|
||||
<symbol id="database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></symbol>
|
||||
<symbol id="download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol>
|
||||
<symbol id="external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
|
||||
<symbol id="eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="file-plus" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M9 15h6"/><path d="M12 18v-6"/></symbol>
|
||||
<symbol id="file-x" viewBox="0 0 24 24"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="m14.5 12.5-5 5"/><path d="m9.5 12.5 5 5"/></symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,565 +0,0 @@
|
||||
//go:build billing
|
||||
|
||||
package billing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/stripe/stripe-go/v84"
|
||||
portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
|
||||
"github.com/stripe/stripe-go/v84/checkout/session"
|
||||
"github.com/stripe/stripe-go/v84/customer"
|
||||
"github.com/stripe/stripe-go/v84/price"
|
||||
"github.com/stripe/stripe-go/v84/subscription"
|
||||
"github.com/stripe/stripe-go/v84/webhook"
|
||||
|
||||
"atcr.io/pkg/hold/quota"
|
||||
)
|
||||
|
||||
// Manager handles Stripe billing integration.
|
||||
type Manager struct {
|
||||
quotaMgr *quota.Manager
|
||||
billingCfg *BillingConfig
|
||||
holdPublicURL string
|
||||
stripeKey string
|
||||
webhookSecret string
|
||||
publishableKey string
|
||||
|
||||
// In-memory cache for customer lookups (DID -> customer)
|
||||
customerCache map[string]*cachedCustomer
|
||||
customerCacheMu sync.RWMutex
|
||||
}
|
||||
|
||||
type cachedCustomer struct {
|
||||
customer *stripe.Customer
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
const customerCacheTTL = 10 * time.Minute
|
||||
|
||||
// New creates a new billing manager with Stripe integration.
|
||||
// configPath is the path to the hold config YAML file (for billing config parsing).
|
||||
func New(quotaMgr *quota.Manager, holdPublicURL string, configPath string) *Manager {
|
||||
stripeKey := os.Getenv("STRIPE_SECRET_KEY")
|
||||
if stripeKey != "" {
|
||||
stripe.Key = stripeKey
|
||||
}
|
||||
|
||||
billingCfg, err := LoadBillingConfig(configPath)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to load billing config", "error", err)
|
||||
}
|
||||
|
||||
// Validate billing tier names against quota tiers
|
||||
if billingCfg != nil && billingCfg.Enabled {
|
||||
for tierName := range billingCfg.Tiers {
|
||||
if quotaMgr.GetTierLimit(tierName) == nil && tierName != quotaMgr.GetDefaultTier() {
|
||||
slog.Warn("Billing tier has no matching quota tier", "tier", tierName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
quotaMgr: quotaMgr,
|
||||
billingCfg: billingCfg,
|
||||
holdPublicURL: holdPublicURL,
|
||||
stripeKey: stripeKey,
|
||||
webhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
|
||||
publishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
|
||||
customerCache: make(map[string]*cachedCustomer),
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled returns true if billing is properly configured.
|
||||
func (m *Manager) Enabled() bool {
|
||||
return m.billingCfg != nil && m.billingCfg.Enabled && m.stripeKey != ""
|
||||
}
|
||||
|
||||
// GetSubscriptionInfo returns subscription and quota information for a user.
|
||||
func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error) {
|
||||
if !m.Enabled() {
|
||||
return nil, ErrBillingDisabled
|
||||
}
|
||||
|
||||
info := &SubscriptionInfo{
|
||||
UserDID: userDID,
|
||||
PaymentsEnabled: true,
|
||||
Tiers: m.buildTierList(userDID),
|
||||
}
|
||||
|
||||
// Try to find existing customer
|
||||
cust, err := m.findCustomerByDID(userDID)
|
||||
if err != nil {
|
||||
slog.Debug("No Stripe customer found for user", "userDid", userDID)
|
||||
} else if cust != nil {
|
||||
info.CustomerID = cust.ID
|
||||
|
||||
// Get active subscription if any (check all nil pointers)
|
||||
if cust.Subscriptions != nil && len(cust.Subscriptions.Data) > 0 {
|
||||
sub := cust.Subscriptions.Data[0]
|
||||
info.SubscriptionID = sub.ID
|
||||
|
||||
// Safely access subscription items
|
||||
if sub.Items != nil && len(sub.Items.Data) > 0 && sub.Items.Data[0].Price != nil {
|
||||
info.CurrentTier = m.billingCfg.GetTierByPriceID(sub.Items.Data[0].Price.ID)
|
||||
|
||||
if sub.Items.Data[0].Price.Recurring != nil {
|
||||
switch sub.Items.Data[0].Price.Recurring.Interval {
|
||||
case stripe.PriceRecurringIntervalMonth:
|
||||
info.BillingInterval = "monthly"
|
||||
case stripe.PriceRecurringIntervalYear:
|
||||
info.BillingInterval = "yearly"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no subscription, use default tier
|
||||
if info.CurrentTier == "" {
|
||||
info.CurrentTier = m.quotaMgr.GetDefaultTier()
|
||||
}
|
||||
|
||||
// Get quota limit for current tier
|
||||
limit := m.quotaMgr.GetTierLimit(info.CurrentTier)
|
||||
info.CurrentLimit = limit
|
||||
|
||||
// Mark current tier in tier list
|
||||
for i := range info.Tiers {
|
||||
if info.Tiers[i].ID == info.CurrentTier {
|
||||
info.Tiers[i].IsCurrent = true
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// buildTierList creates the list of available tiers by merging quota limits
|
||||
// from the quota manager with billing metadata from the billing config.
|
||||
func (m *Manager) buildTierList(userDID string) []TierInfo {
|
||||
quotaTiers := m.quotaMgr.ListTiers()
|
||||
if len(quotaTiers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]TierInfo, 0, len(quotaTiers))
|
||||
for _, qt := range quotaTiers {
|
||||
var quotaBytes int64
|
||||
if qt.Limit != nil {
|
||||
quotaBytes = *qt.Limit
|
||||
}
|
||||
|
||||
// Capitalize tier ID for display name (e.g., "swabbie" -> "Swabbie")
|
||||
name := strings.ToUpper(qt.Key[:1]) + qt.Key[1:]
|
||||
|
||||
tier := TierInfo{
|
||||
ID: qt.Key,
|
||||
Name: name,
|
||||
QuotaBytes: quotaBytes,
|
||||
QuotaFormatted: quota.FormatHumanBytes(quotaBytes),
|
||||
}
|
||||
|
||||
// Merge billing metadata if available
|
||||
if bt := m.billingCfg.GetTierPricing(qt.Key); bt != nil {
|
||||
tier.Description = bt.Description
|
||||
|
||||
// Fetch actual prices from Stripe
|
||||
if bt.StripePriceMonthly != "" {
|
||||
if p, err := price.Get(bt.StripePriceMonthly, nil); err == nil && p != nil {
|
||||
tier.PriceCentsMonthly = int(p.UnitAmount)
|
||||
} else {
|
||||
slog.Debug("Failed to fetch monthly price", "priceId", bt.StripePriceMonthly, "error", err)
|
||||
tier.PriceCentsMonthly = -1
|
||||
}
|
||||
}
|
||||
if bt.StripePriceYearly != "" {
|
||||
if p, err := price.Get(bt.StripePriceYearly, nil); err == nil && p != nil {
|
||||
tier.PriceCentsYearly = int(p.UnitAmount)
|
||||
} else {
|
||||
slog.Debug("Failed to fetch yearly price", "priceId", bt.StripePriceYearly, "error", err)
|
||||
tier.PriceCentsYearly = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, tier)
|
||||
}
|
||||
|
||||
// Sort tiers by quota size (ascending)
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].QuotaBytes < result[j].QuotaBytes
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// CreateCheckoutSession creates a Stripe checkout session for subscription.
|
||||
func (m *Manager) CreateCheckoutSession(r *http.Request, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
|
||||
if !m.Enabled() {
|
||||
return nil, ErrBillingDisabled
|
||||
}
|
||||
|
||||
// Get user DID from request context (set by auth middleware)
|
||||
userDID := r.Header.Get("X-User-DID")
|
||||
if userDID == "" {
|
||||
return nil, errors.New("user not authenticated")
|
||||
}
|
||||
|
||||
// Get tier config
|
||||
tierCfg := m.billingCfg.GetTierPricing(req.Tier)
|
||||
if tierCfg == nil {
|
||||
return nil, fmt.Errorf("tier not found: %s", req.Tier)
|
||||
}
|
||||
|
||||
// Determine price ID - prefer requested interval, fall back to what's available
|
||||
var priceID string
|
||||
switch req.Interval {
|
||||
case "monthly":
|
||||
priceID = tierCfg.StripePriceMonthly
|
||||
case "yearly":
|
||||
priceID = tierCfg.StripePriceYearly
|
||||
default:
|
||||
// No interval specified - prefer monthly, fall back to yearly
|
||||
if tierCfg.StripePriceMonthly != "" {
|
||||
priceID = tierCfg.StripePriceMonthly
|
||||
} else {
|
||||
priceID = tierCfg.StripePriceYearly
|
||||
}
|
||||
}
|
||||
|
||||
if priceID == "" {
|
||||
return nil, fmt.Errorf("tier %s has no Stripe price configured", req.Tier)
|
||||
}
|
||||
|
||||
// Get or create customer
|
||||
cust, err := m.getOrCreateCustomer(userDID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get/create customer: %w", err)
|
||||
}
|
||||
|
||||
// Build success/cancel URLs
|
||||
successURL := strings.ReplaceAll(m.billingCfg.SuccessURL, "{hold_url}", m.holdPublicURL)
|
||||
cancelURL := strings.ReplaceAll(m.billingCfg.CancelURL, "{hold_url}", m.holdPublicURL)
|
||||
|
||||
if req.ReturnURL != "" {
|
||||
successURL = req.ReturnURL + "?success=true"
|
||||
cancelURL = req.ReturnURL + "?cancelled=true"
|
||||
}
|
||||
|
||||
// Create checkout session
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Customer: stripe.String(cust.ID),
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
SuccessURL: stripe.String(successURL),
|
||||
CancelURL: stripe.String(cancelURL),
|
||||
}
|
||||
|
||||
sess, err := session.New(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create checkout session: %w", err)
|
||||
}
|
||||
|
||||
return &CheckoutSessionResponse{
|
||||
CheckoutURL: sess.URL,
|
||||
SessionID: sess.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetBillingPortalURL returns a URL to the Stripe billing portal.
|
||||
func (m *Manager) GetBillingPortalURL(userDID string, returnURL string) (*BillingPortalResponse, error) {
|
||||
if !m.Enabled() {
|
||||
return nil, ErrBillingDisabled
|
||||
}
|
||||
|
||||
// Find existing customer
|
||||
cust, err := m.findCustomerByDID(userDID)
|
||||
if err != nil || cust == nil {
|
||||
return nil, errors.New("no billing account found")
|
||||
}
|
||||
|
||||
if returnURL == "" {
|
||||
returnURL = m.holdPublicURL
|
||||
}
|
||||
|
||||
params := &stripe.BillingPortalSessionParams{
|
||||
Customer: stripe.String(cust.ID),
|
||||
ReturnURL: stripe.String(returnURL),
|
||||
}
|
||||
|
||||
sess, err := portalsession.New(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create portal session: %w", err)
|
||||
}
|
||||
|
||||
return &BillingPortalResponse{
|
||||
PortalURL: sess.URL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandleWebhook processes a Stripe webhook event.
|
||||
func (m *Manager) HandleWebhook(r *http.Request) (*WebhookEvent, error) {
|
||||
if !m.Enabled() {
|
||||
return nil, ErrBillingDisabled
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read request body: %w", err)
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), m.webhookSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify webhook signature: %w", err)
|
||||
}
|
||||
|
||||
result := &WebhookEvent{
|
||||
Type: string(event.Type),
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "checkout.session.completed":
|
||||
var sess stripe.CheckoutSession
|
||||
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse checkout session: %w", err)
|
||||
}
|
||||
|
||||
result.CustomerID = sess.Customer.ID
|
||||
result.SubscriptionID = sess.Subscription.ID
|
||||
result.Status = "active"
|
||||
|
||||
// Fetch customer to get DID from metadata
|
||||
result.UserDID = m.getCustomerDID(sess.Customer.ID)
|
||||
|
||||
// Get subscription to find the price/tier
|
||||
if sess.Subscription != nil && sess.Subscription.ID != "" {
|
||||
if sub, err := m.getSubscription(sess.Subscription.ID); err == nil && sub != nil {
|
||||
if len(sub.Items.Data) > 0 {
|
||||
result.PriceID = sub.Items.Data[0].Price.ID
|
||||
result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.UserDID != "" && result.NewTier != "" {
|
||||
slog.Info("Checkout completed",
|
||||
"userDid", result.UserDID,
|
||||
"tier", result.NewTier,
|
||||
"subscriptionId", result.SubscriptionID,
|
||||
)
|
||||
}
|
||||
|
||||
case "customer.subscription.created", "customer.subscription.updated":
|
||||
var sub stripe.Subscription
|
||||
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse subscription: %w", err)
|
||||
}
|
||||
|
||||
result.SubscriptionID = sub.ID
|
||||
result.CustomerID = sub.Customer.ID
|
||||
result.Status = string(sub.Status)
|
||||
|
||||
if len(sub.Items.Data) > 0 {
|
||||
result.PriceID = sub.Items.Data[0].Price.ID
|
||||
result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID)
|
||||
}
|
||||
|
||||
// Fetch customer to get DID from metadata (webhook doesn't include expanded customer)
|
||||
result.UserDID = m.getCustomerDID(sub.Customer.ID)
|
||||
|
||||
// If we have user DID and new tier, this signals that crew tier should be updated
|
||||
if result.UserDID != "" && result.NewTier != "" && sub.Status == stripe.SubscriptionStatusActive {
|
||||
slog.Info("Subscription activated",
|
||||
"userDid", result.UserDID,
|
||||
"tier", result.NewTier,
|
||||
"subscriptionId", result.SubscriptionID,
|
||||
)
|
||||
}
|
||||
|
||||
case "customer.subscription.deleted", "customer.subscription.paused":
|
||||
var sub stripe.Subscription
|
||||
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse subscription: %w", err)
|
||||
}
|
||||
|
||||
result.SubscriptionID = sub.ID
|
||||
result.CustomerID = sub.Customer.ID
|
||||
if event.Type == "customer.subscription.deleted" {
|
||||
result.Status = "cancelled"
|
||||
} else {
|
||||
result.Status = "paused"
|
||||
}
|
||||
|
||||
// Fetch customer to get DID from metadata
|
||||
result.UserDID = m.getCustomerDID(sub.Customer.ID)
|
||||
|
||||
// Set tier to default (downgrade on cancellation/pause)
|
||||
result.NewTier = m.quotaMgr.GetDefaultTier()
|
||||
|
||||
if result.UserDID != "" {
|
||||
slog.Info("Subscription inactive, downgrading to default tier",
|
||||
"userDid", result.UserDID,
|
||||
"tier", result.NewTier,
|
||||
"status", result.Status,
|
||||
)
|
||||
}
|
||||
|
||||
case "customer.subscription.resumed":
|
||||
var sub stripe.Subscription
|
||||
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse subscription: %w", err)
|
||||
}
|
||||
|
||||
result.SubscriptionID = sub.ID
|
||||
result.CustomerID = sub.Customer.ID
|
||||
result.Status = "active"
|
||||
|
||||
if len(sub.Items.Data) > 0 {
|
||||
result.PriceID = sub.Items.Data[0].Price.ID
|
||||
result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID)
|
||||
}
|
||||
|
||||
// Fetch customer to get DID from metadata
|
||||
result.UserDID = m.getCustomerDID(sub.Customer.ID)
|
||||
|
||||
if result.UserDID != "" && result.NewTier != "" {
|
||||
slog.Info("Subscription resumed, restoring tier",
|
||||
"userDid", result.UserDID,
|
||||
"tier", result.NewTier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getOrCreateCustomer finds or creates a Stripe customer for the given DID.
|
||||
func (m *Manager) getOrCreateCustomer(userDID string) (*stripe.Customer, error) {
|
||||
// Check cache first
|
||||
m.customerCacheMu.RLock()
|
||||
if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) {
|
||||
m.customerCacheMu.RUnlock()
|
||||
return cached.customer, nil
|
||||
}
|
||||
m.customerCacheMu.RUnlock()
|
||||
|
||||
// Try to find existing customer
|
||||
cust, err := m.findCustomerByDID(userDID)
|
||||
if err == nil && cust != nil {
|
||||
m.cacheCustomer(userDID, cust)
|
||||
return cust, nil
|
||||
}
|
||||
|
||||
// Create new customer
|
||||
params := &stripe.CustomerParams{
|
||||
Metadata: map[string]string{
|
||||
"user_did": userDID,
|
||||
"hold_did": m.holdPublicURL, // Not actually a DID but useful for tracking
|
||||
},
|
||||
}
|
||||
|
||||
cust, err = customer.New(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create customer: %w", err)
|
||||
}
|
||||
|
||||
m.cacheCustomer(userDID, cust)
|
||||
return cust, nil
|
||||
}
|
||||
|
||||
// findCustomerByDID searches Stripe for a customer with the given DID in metadata.
|
||||
func (m *Manager) findCustomerByDID(userDID string) (*stripe.Customer, error) {
|
||||
// Check cache first
|
||||
m.customerCacheMu.RLock()
|
||||
if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) {
|
||||
m.customerCacheMu.RUnlock()
|
||||
return cached.customer, nil
|
||||
}
|
||||
m.customerCacheMu.RUnlock()
|
||||
|
||||
// Search Stripe by metadata
|
||||
params := &stripe.CustomerSearchParams{
|
||||
SearchParams: stripe.SearchParams{
|
||||
Query: fmt.Sprintf("metadata['user_did']:'%s'", userDID),
|
||||
},
|
||||
}
|
||||
params.AddExpand("data.subscriptions")
|
||||
|
||||
iter := customer.Search(params)
|
||||
if iter.Next() {
|
||||
cust := iter.Customer()
|
||||
m.cacheCustomer(userDID, cust)
|
||||
return cust, nil
|
||||
}
|
||||
|
||||
if err := iter.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil // Not found
|
||||
}
|
||||
|
||||
// cacheCustomer adds a customer to the in-memory cache.
|
||||
func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) {
|
||||
m.customerCacheMu.Lock()
|
||||
defer m.customerCacheMu.Unlock()
|
||||
|
||||
m.customerCache[userDID] = &cachedCustomer{
|
||||
customer: cust,
|
||||
expiresAt: time.Now().Add(customerCacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateCustomerCache removes a customer from the cache.
|
||||
func (m *Manager) InvalidateCustomerCache(userDID string) {
|
||||
m.customerCacheMu.Lock()
|
||||
defer m.customerCacheMu.Unlock()
|
||||
|
||||
delete(m.customerCache, userDID)
|
||||
}
|
||||
|
||||
// getCustomerDID fetches a customer by ID and returns the user_did from metadata.
|
||||
func (m *Manager) getCustomerDID(customerID string) string {
|
||||
if customerID == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
cust, err := customer.Get(customerID, nil)
|
||||
if err != nil {
|
||||
slog.Debug("Failed to fetch customer", "customerId", customerID, "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
if cust.Metadata != nil {
|
||||
return cust.Metadata["user_did"]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getSubscription fetches a subscription by ID.
|
||||
func (m *Manager) getSubscription(subscriptionID string) (*stripe.Subscription, error) {
|
||||
if subscriptionID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
params := &stripe.SubscriptionParams{}
|
||||
params.AddExpand("items.data.price")
|
||||
|
||||
return subscription.Get(subscriptionID, params)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
//go:build !billing
|
||||
|
||||
package billing
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"atcr.io/pkg/hold/pds"
|
||||
"atcr.io/pkg/hold/quota"
|
||||
)
|
||||
|
||||
// Manager is a no-op billing manager when billing is not compiled in.
|
||||
type Manager struct{}
|
||||
|
||||
// New creates a new no-op billing manager.
|
||||
// This is used when the billing build tag is not set.
|
||||
func New(_ *quota.Manager, _ string, _ string) *Manager {
|
||||
return &Manager{}
|
||||
}
|
||||
|
||||
// Enabled returns false when billing is not compiled in.
|
||||
func (m *Manager) Enabled() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// RegisterHandlers is a no-op when billing is not compiled in.
|
||||
func (m *Manager) RegisterHandlers(_ chi.Router) {}
|
||||
|
||||
// GetSubscriptionInfo returns an error when billing is not compiled in.
|
||||
func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) {
|
||||
return nil, ErrBillingDisabled
|
||||
}
|
||||
|
||||
// CreateCheckoutSession returns an error when billing is not compiled in.
|
||||
func (m *Manager) CreateCheckoutSession(_ *http.Request, _ *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
|
||||
return nil, ErrBillingDisabled
|
||||
}
|
||||
|
||||
// GetBillingPortalURL returns an error when billing is not compiled in.
|
||||
func (m *Manager) GetBillingPortalURL(_ string, _ string) (*BillingPortalResponse, error) {
|
||||
return nil, ErrBillingDisabled
|
||||
}
|
||||
|
||||
// HandleWebhook returns an error when billing is not compiled in.
|
||||
func (m *Manager) HandleWebhook(_ *http.Request) (*WebhookEvent, error) {
|
||||
return nil, ErrBillingDisabled
|
||||
}
|
||||
|
||||
// XRPCHandler is a no-op handler when billing is not compiled in.
|
||||
type XRPCHandler struct{}
|
||||
|
||||
// NewXRPCHandler creates a new no-op XRPC handler.
|
||||
func NewXRPCHandler(_ *Manager, _ *pds.HoldPDS, _ *http.Client) *XRPCHandler {
|
||||
return &XRPCHandler{}
|
||||
}
|
||||
|
||||
// RegisterHandlers is a no-op when billing is not compiled in.
|
||||
func (h *XRPCHandler) RegisterHandlers(_ chi.Router) {}
|
||||
@@ -1,132 +0,0 @@
|
||||
//go:build billing
|
||||
|
||||
package billing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// BillingConfig holds billing/Stripe settings parsed from the hold config YAML.
|
||||
// The billing section is a top-level key in the YAML file, separate from quota.
|
||||
type BillingConfig struct {
|
||||
Enabled bool
|
||||
Currency string
|
||||
SuccessURL string
|
||||
CancelURL string
|
||||
|
||||
// Tier-level billing info keyed by tier name (same keys as quota tiers).
|
||||
Tiers map[string]BillingTierConfig
|
||||
|
||||
// Tier assigned to plankowner crew members.
|
||||
PlankOwnerCrewTier string
|
||||
}
|
||||
|
||||
// BillingTierConfig holds Stripe pricing for a single tier.
|
||||
type BillingTierConfig struct {
|
||||
Description string `yaml:"description,omitempty"`
|
||||
StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty"`
|
||||
StripePriceYearly string `yaml:"stripe_price_yearly,omitempty"`
|
||||
}
|
||||
|
||||
// billingYAML is the top-level YAML structure for extracting the billing section.
|
||||
type billingYAML struct {
|
||||
Billing rawBillingConfig `yaml:"billing"`
|
||||
}
|
||||
|
||||
type rawBillingConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Currency string `yaml:"currency,omitempty"`
|
||||
SuccessURL string `yaml:"success_url,omitempty"`
|
||||
CancelURL string `yaml:"cancel_url,omitempty"`
|
||||
PlankOwnerCrewTier string `yaml:"plankowner_crew_tier,omitempty"`
|
||||
Tiers map[string]BillingTierConfig `yaml:"tiers,omitempty"`
|
||||
}
|
||||
|
||||
// LoadBillingConfig reads the hold config YAML and extracts billing fields.
|
||||
// Returns (nil, nil) if the file is missing or billing is not enabled.
|
||||
// Returns (nil, err) if the file exists with billing enabled but is misconfigured.
|
||||
func LoadBillingConfig(configPath string) (*BillingConfig, error) {
|
||||
if configPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||
}
|
||||
|
||||
return parseBillingConfig(data)
|
||||
}
|
||||
|
||||
// parseBillingConfig extracts billing fields from hold config YAML bytes.
|
||||
// Returns (nil, nil) if billing is not enabled.
|
||||
// Returns (nil, err) if billing is enabled but misconfigured.
|
||||
func parseBillingConfig(data []byte) (*BillingConfig, error) {
|
||||
var raw billingYAML
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
if !raw.Billing.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cfg := &BillingConfig{
|
||||
Enabled: true,
|
||||
Currency: raw.Billing.Currency,
|
||||
SuccessURL: raw.Billing.SuccessURL,
|
||||
CancelURL: raw.Billing.CancelURL,
|
||||
PlankOwnerCrewTier: raw.Billing.PlankOwnerCrewTier,
|
||||
Tiers: raw.Billing.Tiers,
|
||||
}
|
||||
|
||||
if cfg.Tiers == nil {
|
||||
cfg.Tiers = make(map[string]BillingTierConfig)
|
||||
}
|
||||
|
||||
// Validate: billing enabled but no tiers have any Stripe prices configured
|
||||
hasAnyPrice := false
|
||||
for _, tier := range cfg.Tiers {
|
||||
if tier.StripePriceMonthly != "" || tier.StripePriceYearly != "" {
|
||||
hasAnyPrice = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAnyPrice {
|
||||
return nil, fmt.Errorf("billing is enabled but no tiers have Stripe prices configured")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// GetTierPricing returns billing info for a tier, or nil if not found.
|
||||
func (c *BillingConfig) GetTierPricing(tierKey string) *BillingTierConfig {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
t, ok := c.Tiers[tierKey]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
// GetTierByPriceID finds the tier key that contains the given Stripe price ID.
|
||||
// Returns empty string if no match.
|
||||
func (c *BillingConfig) GetTierByPriceID(priceID string) string {
|
||||
if c == nil || priceID == "" {
|
||||
return ""
|
||||
}
|
||||
for key, tier := range c.Tiers {
|
||||
if tier.StripePriceMonthly == priceID || tier.StripePriceYearly == priceID {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
//go:build billing
|
||||
|
||||
package billing
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBillingConfig_Disabled(t *testing.T) {
|
||||
yaml := []byte(`
|
||||
billing:
|
||||
enabled: false
|
||||
`)
|
||||
cfg, err := parseBillingConfig(yaml)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Error("expected nil config when billing disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBillingConfig_NoBillingSection(t *testing.T) {
|
||||
yaml := []byte(`
|
||||
quota:
|
||||
tiers:
|
||||
deckhand:
|
||||
quota: 5GB
|
||||
`)
|
||||
cfg, err := parseBillingConfig(yaml)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Error("expected nil config when no billing section")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBillingConfig_Enabled(t *testing.T) {
|
||||
yaml := []byte(`
|
||||
billing:
|
||||
enabled: true
|
||||
currency: usd
|
||||
success_url: "{hold_url}/billing/success"
|
||||
cancel_url: "{hold_url}/billing/cancel"
|
||||
plankowner_crew_tier: bosun
|
||||
tiers:
|
||||
deckhand:
|
||||
description: Starter tier
|
||||
bosun:
|
||||
description: Standard tier
|
||||
stripe_price_monthly: price_bosun_monthly
|
||||
stripe_price_yearly: price_bosun_yearly
|
||||
`)
|
||||
cfg, err := parseBillingConfig(yaml)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("expected non-nil config")
|
||||
}
|
||||
|
||||
if !cfg.Enabled {
|
||||
t.Error("expected Enabled=true")
|
||||
}
|
||||
if cfg.Currency != "usd" {
|
||||
t.Errorf("expected currency 'usd', got %q", cfg.Currency)
|
||||
}
|
||||
if cfg.PlankOwnerCrewTier != "bosun" {
|
||||
t.Errorf("expected plankowner_crew_tier 'bosun', got %q", cfg.PlankOwnerCrewTier)
|
||||
}
|
||||
if cfg.SuccessURL != "{hold_url}/billing/success" {
|
||||
t.Errorf("unexpected success_url: %q", cfg.SuccessURL)
|
||||
}
|
||||
|
||||
// Check tier pricing
|
||||
bosun := cfg.GetTierPricing("bosun")
|
||||
if bosun == nil {
|
||||
t.Fatal("expected bosun tier pricing")
|
||||
}
|
||||
if bosun.StripePriceMonthly != "price_bosun_monthly" {
|
||||
t.Errorf("expected bosun monthly price 'price_bosun_monthly', got %q", bosun.StripePriceMonthly)
|
||||
}
|
||||
if bosun.StripePriceYearly != "price_bosun_yearly" {
|
||||
t.Errorf("expected bosun yearly price 'price_bosun_yearly', got %q", bosun.StripePriceYearly)
|
||||
}
|
||||
if bosun.Description != "Standard tier" {
|
||||
t.Errorf("expected bosun description 'Standard tier', got %q", bosun.Description)
|
||||
}
|
||||
|
||||
// Deckhand has no prices
|
||||
deckhand := cfg.GetTierPricing("deckhand")
|
||||
if deckhand == nil {
|
||||
t.Fatal("expected deckhand tier pricing entry")
|
||||
}
|
||||
if deckhand.StripePriceMonthly != "" {
|
||||
t.Error("expected no monthly price for deckhand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBillingConfig_EnabledButNoPrices(t *testing.T) {
|
||||
yaml := []byte(`
|
||||
billing:
|
||||
enabled: true
|
||||
currency: usd
|
||||
`)
|
||||
cfg, err := parseBillingConfig(yaml)
|
||||
if err == nil {
|
||||
t.Error("expected error when billing enabled but no prices configured")
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Error("expected nil config on error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTierByPriceID(t *testing.T) {
|
||||
cfg := &BillingConfig{
|
||||
Tiers: map[string]BillingTierConfig{
|
||||
"deckhand": {},
|
||||
"bosun": {StripePriceMonthly: "price_m", StripePriceYearly: "price_y"},
|
||||
},
|
||||
}
|
||||
|
||||
if got := cfg.GetTierByPriceID("price_m"); got != "bosun" {
|
||||
t.Errorf("expected 'bosun' for monthly price, got %q", got)
|
||||
}
|
||||
if got := cfg.GetTierByPriceID("price_y"); got != "bosun" {
|
||||
t.Errorf("expected 'bosun' for yearly price, got %q", got)
|
||||
}
|
||||
if got := cfg.GetTierByPriceID("price_unknown"); got != "" {
|
||||
t.Errorf("expected empty for unknown price, got %q", got)
|
||||
}
|
||||
if got := cfg.GetTierByPriceID(""); got != "" {
|
||||
t.Errorf("expected empty for empty price, got %q", got)
|
||||
}
|
||||
|
||||
// nil receiver
|
||||
var nilCfg *BillingConfig
|
||||
if got := nilCfg.GetTierByPriceID("price_m"); got != "" {
|
||||
t.Errorf("expected empty from nil config, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTierPricing_NilConfig(t *testing.T) {
|
||||
var cfg *BillingConfig
|
||||
if cfg.GetTierPricing("anything") != nil {
|
||||
t.Error("expected nil from nil config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBillingConfig_MissingFile(t *testing.T) {
|
||||
cfg, err := LoadBillingConfig("/nonexistent/config.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for missing file, got: %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Error("expected nil config for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBillingConfig_EmptyPath(t *testing.T) {
|
||||
cfg, err := LoadBillingConfig("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Error("expected nil config for empty path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBillingConfig_FromFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
|
||||
content := `
|
||||
billing:
|
||||
enabled: true
|
||||
currency: usd
|
||||
tiers:
|
||||
bosun:
|
||||
stripe_price_monthly: price_test
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := LoadBillingConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("expected non-nil config")
|
||||
}
|
||||
if cfg.GetTierByPriceID("price_test") != "bosun" {
|
||||
t.Error("expected bosun tier for price_test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBillingConfig_TopLevelTiers(t *testing.T) {
|
||||
yaml := []byte(`
|
||||
billing:
|
||||
enabled: true
|
||||
currency: usd
|
||||
tiers:
|
||||
deckhand:
|
||||
description: "Starter tier"
|
||||
bosun:
|
||||
description: "Standard tier"
|
||||
stripe_price_monthly: price_bosun_m
|
||||
stripe_price_yearly: price_bosun_y
|
||||
quartermaster:
|
||||
description: "Pro tier"
|
||||
stripe_price_monthly: price_qm_m
|
||||
`)
|
||||
cfg, err := parseBillingConfig(yaml)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("expected non-nil config")
|
||||
}
|
||||
if len(cfg.Tiers) != 3 {
|
||||
t.Errorf("expected 3 tiers, got %d", len(cfg.Tiers))
|
||||
}
|
||||
|
||||
bosun := cfg.GetTierPricing("bosun")
|
||||
if bosun == nil {
|
||||
t.Fatal("expected bosun tier")
|
||||
}
|
||||
if bosun.Description != "Standard tier" {
|
||||
t.Errorf("expected description 'Standard tier', got %q", bosun.Description)
|
||||
}
|
||||
if bosun.StripePriceMonthly != "price_bosun_m" {
|
||||
t.Errorf("expected monthly price 'price_bosun_m', got %q", bosun.StripePriceMonthly)
|
||||
}
|
||||
if bosun.StripePriceYearly != "price_bosun_y" {
|
||||
t.Errorf("expected yearly price 'price_bosun_y', got %q", bosun.StripePriceYearly)
|
||||
}
|
||||
|
||||
qm := cfg.GetTierPricing("quartermaster")
|
||||
if qm == nil {
|
||||
t.Fatal("expected quartermaster tier")
|
||||
}
|
||||
if qm.StripePriceMonthly != "price_qm_m" {
|
||||
t.Errorf("expected monthly price 'price_qm_m', got %q", qm.StripePriceMonthly)
|
||||
}
|
||||
|
||||
deckhand := cfg.GetTierPricing("deckhand")
|
||||
if deckhand == nil {
|
||||
t.Fatal("expected deckhand tier")
|
||||
}
|
||||
if deckhand.Description != "Starter tier" {
|
||||
t.Errorf("expected description 'Starter tier', got %q", deckhand.Description)
|
||||
}
|
||||
if deckhand.StripePriceMonthly != "" {
|
||||
t.Error("expected no monthly price for deckhand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBillingConfig_PlankOwnerCrewTier(t *testing.T) {
|
||||
yaml := []byte(`
|
||||
billing:
|
||||
enabled: true
|
||||
currency: usd
|
||||
plankowner_crew_tier: bosun
|
||||
tiers:
|
||||
bosun:
|
||||
stripe_price_monthly: price_bosun_m
|
||||
`)
|
||||
cfg, err := parseBillingConfig(yaml)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("expected non-nil config")
|
||||
}
|
||||
if cfg.PlankOwnerCrewTier != "bosun" {
|
||||
t.Errorf("expected plankowner_crew_tier 'bosun', got %q", cfg.PlankOwnerCrewTier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBillingConfig_IgnoresQuotaSection(t *testing.T) {
|
||||
// Billing parser should work even if quota section is missing entirely
|
||||
yaml := []byte(`
|
||||
billing:
|
||||
enabled: true
|
||||
currency: usd
|
||||
tiers:
|
||||
bosun:
|
||||
stripe_price_monthly: price_bosun_m
|
||||
`)
|
||||
cfg, err := parseBillingConfig(yaml)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatal("expected non-nil config")
|
||||
}
|
||||
|
||||
// Also works with quota present but unrelated
|
||||
yaml2 := []byte(`
|
||||
quota:
|
||||
tiers:
|
||||
swabbie:
|
||||
quota: 1GB
|
||||
billing:
|
||||
enabled: true
|
||||
currency: usd
|
||||
tiers:
|
||||
bosun:
|
||||
stripe_price_monthly: price_bosun_m
|
||||
`)
|
||||
cfg2, err := parseBillingConfig(yaml2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg2 == nil {
|
||||
t.Fatal("expected non-nil config")
|
||||
}
|
||||
// Billing should only see its own tiers, not quota tiers
|
||||
if cfg2.GetTierPricing("swabbie") != nil {
|
||||
t.Error("billing should not contain quota-only tiers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBillingConfig_EmptyTiers(t *testing.T) {
|
||||
// Billing enabled with explicit empty tiers
|
||||
yaml := []byte(`
|
||||
billing:
|
||||
enabled: true
|
||||
currency: usd
|
||||
tiers: {}
|
||||
`)
|
||||
cfg, err := parseBillingConfig(yaml)
|
||||
if err == nil {
|
||||
t.Error("expected error when billing enabled with empty tiers")
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Error("expected nil config on error")
|
||||
}
|
||||
|
||||
// Billing enabled with tiers omitted entirely
|
||||
yaml2 := []byte(`
|
||||
billing:
|
||||
enabled: true
|
||||
currency: usd
|
||||
`)
|
||||
cfg2, err := parseBillingConfig(yaml2)
|
||||
if err == nil {
|
||||
t.Error("expected error when billing enabled with no tiers")
|
||||
}
|
||||
if cfg2 != nil {
|
||||
t.Error("expected nil config on error")
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
//go:build billing
|
||||
|
||||
package billing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"atcr.io/pkg/hold/pds"
|
||||
)
|
||||
|
||||
// XRPCHandler handles billing-related XRPC endpoints.
|
||||
type XRPCHandler struct {
|
||||
manager *Manager
|
||||
pdsServer *pds.HoldPDS
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewXRPCHandler creates a new billing XRPC handler.
|
||||
func NewXRPCHandler(manager *Manager, pdsServer *pds.HoldPDS, httpClient *http.Client) *XRPCHandler {
|
||||
return &XRPCHandler{
|
||||
manager: manager,
|
||||
pdsServer: pdsServer,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterHandlers registers billing XRPC endpoints on the router.
|
||||
func (m *Manager) RegisterHandlers(r chi.Router) {
|
||||
// This is a no-op for the Manager itself
|
||||
// Use NewXRPCHandler and call its RegisterHandlers method
|
||||
}
|
||||
|
||||
// RegisterHandlers registers billing endpoints on the router.
|
||||
func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
|
||||
if !h.manager.Enabled() {
|
||||
slog.Info("Billing endpoints disabled (not configured)")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Registering billing XRPC endpoints")
|
||||
|
||||
// Public endpoint - get subscription info (auth optional for tiers list)
|
||||
r.Get("/xrpc/io.atcr.hold.getSubscriptionInfo", h.HandleGetSubscriptionInfo)
|
||||
|
||||
// Authenticated endpoints
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(h.requireAuth)
|
||||
r.Post("/xrpc/io.atcr.hold.createCheckoutSession", h.HandleCreateCheckoutSession)
|
||||
r.Get("/xrpc/io.atcr.hold.getBillingPortalUrl", h.HandleGetBillingPortalURL)
|
||||
})
|
||||
|
||||
// Stripe webhook (authenticated by Stripe signature)
|
||||
r.Post("/xrpc/io.atcr.hold.stripeWebhook", h.HandleStripeWebhook)
|
||||
}
|
||||
|
||||
// requireAuth is middleware that validates user authentication.
|
||||
func (h *XRPCHandler) requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Use the same auth validation as other hold endpoints
|
||||
user, err := pds.ValidateDPoPRequest(r, h.httpClient)
|
||||
if err != nil {
|
||||
// Try service token
|
||||
user, err = pds.ValidateServiceToken(r, h.pdsServer.DID(), h.httpClient)
|
||||
}
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Store user DID in header for handlers
|
||||
r.Header.Set("X-User-DID", user.DID)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// HandleGetSubscriptionInfo returns subscription and quota information.
|
||||
// GET /xrpc/io.atcr.hold.getSubscriptionInfo?userDid=did:plc:xxx
|
||||
func (h *XRPCHandler) HandleGetSubscriptionInfo(w http.ResponseWriter, r *http.Request) {
|
||||
userDID := r.URL.Query().Get("userDid")
|
||||
|
||||
// If no userDID provided, try to get from auth
|
||||
if userDID == "" {
|
||||
// Try to authenticate (optional)
|
||||
user, err := pds.ValidateDPoPRequest(r, h.httpClient)
|
||||
if err != nil {
|
||||
user, _ = pds.ValidateServiceToken(r, h.pdsServer.DID(), h.httpClient)
|
||||
}
|
||||
if user != nil {
|
||||
userDID = user.DID
|
||||
}
|
||||
}
|
||||
|
||||
info, err := h.manager.GetSubscriptionInfo(userDID)
|
||||
if err != nil {
|
||||
if err == ErrBillingDisabled {
|
||||
// Return basic info with payments disabled
|
||||
respondJSON(w, http.StatusOK, &SubscriptionInfo{
|
||||
UserDID: userDID,
|
||||
PaymentsEnabled: false,
|
||||
Tiers: h.manager.buildTierList(userDID),
|
||||
})
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Get current usage and crew tier from PDS quota stats
|
||||
if userDID != "" {
|
||||
stats, err := h.pdsServer.GetQuotaForUserWithTier(r.Context(), userDID, h.manager.quotaMgr)
|
||||
if err == nil {
|
||||
info.CurrentUsage = stats.TotalSize
|
||||
info.CrewTier = stats.Tier // tier from local crew record (what's actually enforced)
|
||||
info.CurrentLimit = stats.Limit
|
||||
|
||||
// If no subscription but crew has a tier, show that as current
|
||||
if info.SubscriptionID == "" && info.CrewTier != "" {
|
||||
info.CurrentTier = info.CrewTier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark which tier is actually current (use crew tier if available, otherwise subscription tier)
|
||||
effectiveTier := info.CurrentTier
|
||||
if info.CrewTier != "" {
|
||||
effectiveTier = info.CrewTier
|
||||
}
|
||||
for i := range info.Tiers {
|
||||
info.Tiers[i].IsCurrent = info.Tiers[i].ID == effectiveTier
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
// HandleCreateCheckoutSession creates a Stripe checkout session.
|
||||
// POST /xrpc/io.atcr.hold.createCheckoutSession
|
||||
func (h *XRPCHandler) HandleCreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||
var req CheckoutSessionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Tier == "" {
|
||||
respondError(w, http.StatusBadRequest, "tier is required")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.manager.CreateCheckoutSession(r, &req)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create checkout session", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// HandleGetBillingPortalURL returns a URL to the Stripe billing portal.
|
||||
// GET /xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=https://...
|
||||
func (h *XRPCHandler) HandleGetBillingPortalURL(w http.ResponseWriter, r *http.Request) {
|
||||
userDID := r.Header.Get("X-User-DID")
|
||||
returnURL := r.URL.Query().Get("returnUrl")
|
||||
|
||||
resp, err := h.manager.GetBillingPortalURL(userDID, returnURL)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get billing portal URL", "error", err, "userDid", userDID)
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// HandleStripeWebhook processes Stripe webhook events.
|
||||
// POST /xrpc/io.atcr.hold.stripeWebhook
|
||||
func (h *XRPCHandler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
event, err := h.manager.HandleWebhook(r)
|
||||
if err != nil {
|
||||
slog.Error("Failed to process webhook", "error", err)
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// If we have a tier update, apply it to the crew record
|
||||
if event.UserDID != "" && event.NewTier != "" {
|
||||
if err := h.pdsServer.UpdateCrewMemberTier(r.Context(), event.UserDID, event.NewTier); err != nil {
|
||||
slog.Error("Failed to update crew tier", "error", err, "userDid", event.UserDID, "tier", event.NewTier)
|
||||
// Don't fail the webhook - Stripe will retry
|
||||
} else {
|
||||
slog.Info("Updated crew tier from subscription",
|
||||
"userDid", event.UserDID,
|
||||
"tier", event.NewTier,
|
||||
"event", event.Type,
|
||||
)
|
||||
}
|
||||
|
||||
// Invalidate customer cache since subscription changed
|
||||
h.manager.InvalidateCustomerCache(event.UserDID)
|
||||
}
|
||||
|
||||
// Return 200 to acknowledge receipt
|
||||
respondJSON(w, http.StatusOK, map[string]string{"received": "true"})
|
||||
}
|
||||
|
||||
// respondJSON writes a JSON response.
|
||||
func respondJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("Failed to encode JSON response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// respondError writes a JSON error response.
|
||||
func respondError(w http.ResponseWriter, status int, message string) {
|
||||
respondJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// Package billing provides optional Stripe billing integration for hold services.
|
||||
// This package uses build tags to conditionally compile Stripe support.
|
||||
// Build with -tags billing to enable Stripe integration.
|
||||
package billing
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrBillingDisabled is returned when billing operations are attempted
|
||||
// but billing is not enabled (either not compiled in or disabled at runtime).
|
||||
var ErrBillingDisabled = errors.New("billing not enabled")
|
||||
|
||||
// SubscriptionInfo contains subscription and quota information for a user.
|
||||
type SubscriptionInfo struct {
|
||||
UserDID string `json:"userDid"`
|
||||
CurrentTier string `json:"currentTier"` // tier from Stripe subscription (or default)
|
||||
CrewTier string `json:"crewTier,omitempty"` // tier from local crew record (what's actually enforced)
|
||||
CurrentUsage int64 `json:"currentUsage"` // bytes used
|
||||
CurrentLimit *int64 `json:"currentLimit,omitempty"` // nil = unlimited
|
||||
PaymentsEnabled bool `json:"paymentsEnabled"` // whether online payments are available
|
||||
Tiers []TierInfo `json:"tiers"` // available tiers
|
||||
SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID if active
|
||||
CustomerID string `json:"customerId,omitempty"` // Stripe customer ID if exists
|
||||
BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly"
|
||||
}
|
||||
|
||||
// TierInfo describes a single tier available for subscription.
|
||||
type TierInfo struct {
|
||||
ID string `json:"id"` // tier key (e.g., "deckhand", "bosun")
|
||||
Name string `json:"name"` // display name (same as ID if not specified)
|
||||
Description string `json:"description,omitempty"` // human-readable description
|
||||
QuotaBytes int64 `json:"quotaBytes"` // quota limit in bytes
|
||||
QuotaFormatted string `json:"quotaFormatted"` // human-readable quota (e.g., "5 GB")
|
||||
PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"` // monthly price in cents (0 = free)
|
||||
PriceCentsYearly int `json:"priceCentsYearly,omitempty"` // yearly price in cents (0 = not available)
|
||||
IsCurrent bool `json:"isCurrent,omitempty"` // whether this is user's current tier
|
||||
}
|
||||
|
||||
// CheckoutSessionRequest is the request to create a Stripe checkout session.
|
||||
type CheckoutSessionRequest struct {
|
||||
Tier string `json:"tier"` // tier to subscribe to
|
||||
Interval string `json:"interval,omitempty"` // "monthly" or "yearly" (default: monthly)
|
||||
ReturnURL string `json:"returnUrl,omitempty"` // URL to return to after checkout
|
||||
}
|
||||
|
||||
// CheckoutSessionResponse is the response with the Stripe checkout URL.
|
||||
type CheckoutSessionResponse struct {
|
||||
CheckoutURL string `json:"checkoutUrl"`
|
||||
SessionID string `json:"sessionId"`
|
||||
}
|
||||
|
||||
// BillingPortalResponse is the response with the Stripe billing portal URL.
|
||||
type BillingPortalResponse struct {
|
||||
PortalURL string `json:"portalUrl"`
|
||||
}
|
||||
|
||||
// WebhookEvent represents a processed Stripe webhook event.
|
||||
type WebhookEvent struct {
|
||||
Type string `json:"type"` // e.g., "customer.subscription.updated"
|
||||
CustomerID string `json:"customerId"` // Stripe customer ID
|
||||
UserDID string `json:"userDid"` // user's DID from customer metadata
|
||||
SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID
|
||||
PriceID string `json:"priceId,omitempty"` // Stripe price ID
|
||||
NewTier string `json:"newTier,omitempty"` // resolved tier name
|
||||
Status string `json:"status,omitempty"` // subscription status
|
||||
}
|
||||
+29
-7
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
@@ -19,6 +20,23 @@ import (
|
||||
"atcr.io/pkg/hold/quota"
|
||||
)
|
||||
|
||||
// URLFromDIDWeb converts a did:web identifier to an HTTPS URL.
|
||||
// This is the inverse of the did:web spec encoding:
|
||||
//
|
||||
// "did:web:atcr.io" → "https://atcr.io"
|
||||
// "did:web:localhost%3A8080" → "https://localhost:8080"
|
||||
//
|
||||
// Returns empty string for non-did:web identifiers.
|
||||
func URLFromDIDWeb(did string) string {
|
||||
if !strings.HasPrefix(did, "did:web:") {
|
||||
return ""
|
||||
}
|
||||
host := strings.TrimPrefix(did, "did:web:")
|
||||
// Per did:web spec, %3A encodes port colon
|
||||
host = strings.ReplaceAll(host, "%3A", ":")
|
||||
return "https://" + host
|
||||
}
|
||||
|
||||
// Config represents the hold service configuration
|
||||
type Config struct {
|
||||
Version string `yaml:"version" comment:"Configuration format version."`
|
||||
@@ -128,8 +146,8 @@ type ServerConfig struct {
|
||||
// Request crawl from this relay on startup.
|
||||
RelayEndpoint string `yaml:"relay_endpoint" comment:"Request crawl from this relay on startup to make the embedded PDS discoverable."`
|
||||
|
||||
// Preferred appview URL for links in webhooks and Bluesky posts.
|
||||
AppviewURL string `yaml:"appview_url" comment:"Preferred appview URL for links in webhooks and Bluesky posts, e.g. \"https://seamark.dev\"."`
|
||||
// DID of the appview this hold is managed by. Resolved via did:web for URL and public key discovery.
|
||||
AppviewDID string `yaml:"appview_did" comment:"DID of the appview this hold is managed by (e.g. did:web:atcr.io). Resolved via did:web for URL and public key."`
|
||||
|
||||
// ReadTimeout for HTTP requests.
|
||||
ReadTimeout time.Duration `yaml:"read_timeout" comment:"Read timeout for HTTP requests."`
|
||||
@@ -138,6 +156,11 @@ type ServerConfig struct {
|
||||
WriteTimeout time.Duration `yaml:"write_timeout" comment:"Write timeout for HTTP requests."`
|
||||
}
|
||||
|
||||
// AppviewURL derives the appview base URL from AppviewDID.
|
||||
func (s ServerConfig) AppviewURL() string {
|
||||
return URLFromDIDWeb(s.AppviewDID)
|
||||
}
|
||||
|
||||
// ScannerConfig defines vulnerability scanner settings
|
||||
type ScannerConfig struct {
|
||||
// Shared secret for scanner WebSocket authentication. Empty disables scanning.
|
||||
@@ -189,7 +212,7 @@ func setHoldDefaults(v *viper.Viper) {
|
||||
v.SetDefault("server.successor", "")
|
||||
v.SetDefault("server.test_mode", false)
|
||||
v.SetDefault("server.relay_endpoint", "")
|
||||
v.SetDefault("server.appview_url", "https://atcr.io")
|
||||
v.SetDefault("server.appview_did", "did:web:atcr.io")
|
||||
v.SetDefault("server.read_timeout", "5m")
|
||||
v.SetDefault("server.write_timeout", "5m")
|
||||
|
||||
@@ -252,13 +275,12 @@ func ExampleYAML() ([]byte, error) {
|
||||
// Populate example quota tiers so operators see the structure
|
||||
cfg.Quota = quota.Config{
|
||||
Tiers: []quota.TierConfig{
|
||||
{Name: "deckhand", Quota: "5GB", MaxWebhooks: 1},
|
||||
{Name: "bosun", Quota: "50GB", ScanOnPush: true, MaxWebhooks: 5, WebhookAllTriggers: true, SupporterBadge: true},
|
||||
{Name: "quartermaster", Quota: "100GB", ScanOnPush: true, MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true},
|
||||
{Name: "deckhand", Quota: "5GB"},
|
||||
{Name: "bosun", Quota: "50GB", ScanOnPush: true},
|
||||
{Name: "quartermaster", Quota: "100GB", ScanOnPush: true},
|
||||
},
|
||||
Defaults: quota.DefaultsConfig{
|
||||
NewCrewTier: "deckhand",
|
||||
OwnerBadge: true,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -529,6 +529,158 @@ func ValidateServiceToken(r *http.Request, holdDID string, httpClient HTTPClient
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateAppviewToken validates a JWT signed by the trusted appview using ES256 (P-256).
|
||||
// It resolves the appview's DID document to extract the P-256 public key, then verifies
|
||||
// the JWT signature, issuer (iss), and audience (aud).
|
||||
//
|
||||
// Returns the subject (sub) claim which is the user DID being acted upon.
|
||||
func ValidateAppviewToken(r *http.Request, appviewDID, holdDID string) (string, error) {
|
||||
// Extract Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return "", ErrMissingAuthHeader
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return "", fmt.Errorf("expected Bearer authorization scheme")
|
||||
}
|
||||
|
||||
tokenString := parts[1]
|
||||
if tokenString == "" {
|
||||
return "", ErrMissingToken
|
||||
}
|
||||
|
||||
// Manually parse JWT
|
||||
tokenParts := strings.Split(tokenString, ".")
|
||||
if len(tokenParts) != 3 {
|
||||
return "", ErrInvalidJWTFormat
|
||||
}
|
||||
|
||||
// Decode and parse claims
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(tokenParts[1])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode JWT payload: %w", err)
|
||||
}
|
||||
|
||||
var claims ServiceTokenClaims
|
||||
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal claims: %w", err)
|
||||
}
|
||||
|
||||
// Verify issuer matches configured appview DID
|
||||
if claims.Issuer != appviewDID {
|
||||
return "", fmt.Errorf("token issuer mismatch: expected %s, got %s", appviewDID, claims.Issuer)
|
||||
}
|
||||
|
||||
// Verify audience matches this hold's DID
|
||||
audiences, err := claims.GetAudience()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get audience: %w", err)
|
||||
}
|
||||
if len(audiences) == 0 || audiences[0] != holdDID {
|
||||
return "", fmt.Errorf("token audience mismatch: expected %s, got %v", holdDID, audiences)
|
||||
}
|
||||
|
||||
// Verify expiration
|
||||
exp, err := claims.GetExpirationTime()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get expiration: %w", err)
|
||||
}
|
||||
if exp != nil && time.Now().After(exp.Time) {
|
||||
return "", ErrTokenExpired
|
||||
}
|
||||
|
||||
// Get subject (user DID)
|
||||
subject, err := claims.GetSubject()
|
||||
if err != nil || subject == "" {
|
||||
return "", ErrMissingSubClaim
|
||||
}
|
||||
|
||||
// Fetch P-256 public key from appview DID document
|
||||
pubKey, err := fetchP256PublicKeyFromDID(r.Context(), appviewDID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch appview public key: %w", err)
|
||||
}
|
||||
|
||||
// Verify JWT signature with P-256 key
|
||||
signedData := []byte(tokenParts[0] + "." + tokenParts[1])
|
||||
signature, err := base64.RawURLEncoding.DecodeString(tokenParts[2])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode signature: %w", err)
|
||||
}
|
||||
|
||||
if err := pubKey.HashAndVerifyLenient(signedData, signature); err != nil {
|
||||
return "", fmt.Errorf("signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("Validated appview service token", "appviewDID", appviewDID, "userDID", subject)
|
||||
return subject, nil
|
||||
}
|
||||
|
||||
// fetchP256PublicKeyFromDID fetches a P-256 public key from a did:web DID document.
|
||||
// It resolves the DID document and looks for a Multikey verification method with P-256 prefix.
|
||||
func fetchP256PublicKeyFromDID(ctx context.Context, did string) (*atcrypto.PublicKeyP256, error) {
|
||||
if !strings.HasPrefix(did, "did:web:") {
|
||||
return nil, fmt.Errorf("only did:web is supported for appview DID, got %s", did)
|
||||
}
|
||||
|
||||
// Resolve did:web to URL
|
||||
host := strings.TrimPrefix(did, "did:web:")
|
||||
host = strings.ReplaceAll(host, "%3A", ":")
|
||||
scheme := "https"
|
||||
if atproto.IsTestMode() {
|
||||
scheme = "http"
|
||||
}
|
||||
didDocURL := fmt.Sprintf("%s://%s/.well-known/did.json", scheme, host)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", didDocURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch DID document: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("DID document fetch returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var doc struct {
|
||||
VerificationMethod []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
PublicKeyMultibase string `json:"publicKeyMultibase"`
|
||||
} `json:"verificationMethod"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode DID document: %w", err)
|
||||
}
|
||||
|
||||
// Find a Multikey verification method with P-256 public key
|
||||
for _, vm := range doc.VerificationMethod {
|
||||
if vm.Type != "Multikey" || vm.PublicKeyMultibase == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try parsing as P-256 key via atcrypto's multibase parser
|
||||
pubKey, err := atcrypto.ParsePublicMultibase(vm.PublicKeyMultibase)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
p256Key, ok := pubKey.(*atcrypto.PublicKeyP256)
|
||||
if ok {
|
||||
return p256Key, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no P-256 public key found in DID document for %s", did)
|
||||
}
|
||||
|
||||
// fetchPublicKeyFromDID fetches the public key from a DID document
|
||||
// Supports did:plc and did:web
|
||||
// Returns the atcrypto.PublicKey for signature verification
|
||||
|
||||
@@ -145,11 +145,6 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, relayEndpoint, dbPath str
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to initialize scan_jobs schema: %w", err)
|
||||
}
|
||||
if err := sb.initWebhookSchema(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to initialize webhook schema: %w", err)
|
||||
}
|
||||
|
||||
// Start re-dispatch loop for timed-out jobs
|
||||
sb.wg.Add(1)
|
||||
go sb.reDispatchLoop()
|
||||
@@ -191,10 +186,6 @@ func NewScanBroadcasterWithDB(holdDID, holdEndpoint, secret, relayEndpoint strin
|
||||
if err := sb.initSchema(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize scan_jobs schema: %w", err)
|
||||
}
|
||||
if err := sb.initWebhookSchema(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize webhook schema: %w", err)
|
||||
}
|
||||
|
||||
sb.wg.Add(1)
|
||||
go sb.reDispatchLoop()
|
||||
|
||||
@@ -517,13 +508,6 @@ func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage)
|
||||
|
||||
// Store scan result as a record in the hold's embedded PDS
|
||||
if msg.Summary != nil {
|
||||
// Check for existing scan record before creating new one (for webhook dispatch)
|
||||
var previousScan *atproto.ScanRecord
|
||||
_, prevScan, err := sb.pds.GetScanRecord(ctx, manifestDigest)
|
||||
if err == nil {
|
||||
previousScan = prevScan
|
||||
}
|
||||
|
||||
scanRecord := atproto.NewScanRecord(
|
||||
manifestDigest, repository, userDID,
|
||||
sbomBlob, vulnReportBlob,
|
||||
@@ -545,8 +529,6 @@ func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage)
|
||||
"total", msg.Summary.Total)
|
||||
}
|
||||
|
||||
// Dispatch webhooks after scan record is stored
|
||||
go sb.dispatchWebhooks(manifestDigest, repository, tag, userDID, userHandle, msg.Summary, previousScan)
|
||||
}
|
||||
|
||||
// Mark job as completed
|
||||
|
||||
@@ -32,7 +32,6 @@ func init() {
|
||||
lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
|
||||
lexutil.RegisterType(atproto.StatsCollection, &atproto.StatsRecord{})
|
||||
lexutil.RegisterType(atproto.ScanCollection, &atproto.ScanRecord{})
|
||||
lexutil.RegisterType(atproto.WebhookCollection, &atproto.HoldWebhookRecord{})
|
||||
}
|
||||
|
||||
// HoldPDS is a minimal ATProto PDS implementation for a hold service
|
||||
@@ -50,7 +49,7 @@ type HoldPDS struct {
|
||||
recordsIndex *RecordsIndex
|
||||
}
|
||||
|
||||
// AppviewURL returns the configured appview base URL for links in webhooks and posts.
|
||||
// AppviewURL returns the configured appview base URL for links in Bluesky posts.
|
||||
func (p *HoldPDS) AppviewURL() string { return p.appviewURL }
|
||||
|
||||
// AppviewMeta returns cached appview metadata, or defaults derived from the appview URL.
|
||||
|
||||
@@ -1,831 +0,0 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/ipfs/go-cid"
|
||||
)
|
||||
|
||||
// webhookConfig represents a webhook for list/display (masked URL, no secret)
|
||||
type webhookConfig struct {
|
||||
Rkey string `json:"rkey"`
|
||||
Triggers int `json:"triggers"`
|
||||
URL string `json:"url"` // masked
|
||||
HasSecret bool `json:"hasSecret"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// activeWebhook is the internal representation with secret for dispatch
|
||||
type activeWebhook struct {
|
||||
Rkey string
|
||||
URL string
|
||||
Secret string
|
||||
Triggers int
|
||||
}
|
||||
|
||||
// WebhookPayload is the JSON body sent to webhook URLs
|
||||
type WebhookPayload struct {
|
||||
Trigger string `json:"trigger"`
|
||||
HoldDID string `json:"holdDid"`
|
||||
HoldEndpoint string `json:"holdEndpoint"`
|
||||
Manifest WebhookManifestInfo `json:"manifest"`
|
||||
Scan WebhookScanInfo `json:"scan"`
|
||||
Previous *WebhookVulnCounts `json:"previous"`
|
||||
}
|
||||
|
||||
// WebhookManifestInfo describes the scanned manifest
|
||||
type WebhookManifestInfo struct {
|
||||
Digest string `json:"digest"`
|
||||
Repository string `json:"repository"`
|
||||
Tag string `json:"tag"`
|
||||
UserDID string `json:"userDid"`
|
||||
UserHandle string `json:"userHandle,omitempty"`
|
||||
}
|
||||
|
||||
// WebhookScanInfo describes the scan results
|
||||
type WebhookScanInfo struct {
|
||||
ScannedAt string `json:"scannedAt"`
|
||||
ScannerVersion string `json:"scannerVersion"`
|
||||
Vulnerabilities WebhookVulnCounts `json:"vulnerabilities"`
|
||||
}
|
||||
|
||||
// WebhookVulnCounts contains vulnerability counts by severity
|
||||
type WebhookVulnCounts struct {
|
||||
Critical int `json:"critical"`
|
||||
High int `json:"high"`
|
||||
Medium int `json:"medium"`
|
||||
Low int `json:"low"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// initWebhookSchema creates the webhook_secrets table.
|
||||
// Called from ScanBroadcaster init alongside scan_jobs table.
|
||||
func (sb *ScanBroadcaster) initWebhookSchema() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS webhook_secrets (
|
||||
rkey TEXT PRIMARY KEY,
|
||||
user_did TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_webhook_secrets_user ON webhook_secrets(user_did)`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := sb.db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("failed to create webhook_secrets table: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountWebhooks returns the number of webhooks configured for a user
|
||||
func (sb *ScanBroadcaster) CountWebhooks(userDID string) (int, error) {
|
||||
var count int
|
||||
err := sb.db.QueryRow(`SELECT COUNT(*) FROM webhook_secrets WHERE user_did = ?`, userDID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ListWebhookConfigs returns webhook configurations for display (masked URLs)
|
||||
func (sb *ScanBroadcaster) ListWebhookConfigs(userDID string) ([]webhookConfig, error) {
|
||||
rows, err := sb.db.Query(`
|
||||
SELECT rkey, url, secret FROM webhook_secrets WHERE user_did = ?
|
||||
`, userDID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var configs []webhookConfig
|
||||
for rows.Next() {
|
||||
var rkey, rawURL, secret string
|
||||
if err := rows.Scan(&rkey, &rawURL, &secret); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get triggers from PDS record
|
||||
triggers := 0
|
||||
_, val, err := sb.pds.repomgr.GetRecord(context.Background(), sb.pds.uid, atproto.WebhookCollection, rkey, cid.Undef)
|
||||
if err == nil {
|
||||
if rec, ok := val.(*atproto.HoldWebhookRecord); ok {
|
||||
triggers = int(rec.Triggers)
|
||||
}
|
||||
}
|
||||
|
||||
// Get createdAt from PDS record
|
||||
createdAt := ""
|
||||
if val != nil {
|
||||
if rec, ok := val.(*atproto.HoldWebhookRecord); ok {
|
||||
createdAt = rec.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
configs = append(configs, webhookConfig{
|
||||
Rkey: rkey,
|
||||
Triggers: triggers,
|
||||
URL: maskURL(rawURL),
|
||||
HasSecret: secret != "",
|
||||
CreatedAt: createdAt,
|
||||
})
|
||||
}
|
||||
if configs == nil {
|
||||
configs = []webhookConfig{}
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// AddWebhookConfig creates a new webhook: stores secret in SQLite, record in PDS
|
||||
func (sb *ScanBroadcaster) AddWebhookConfig(userDID, webhookURL, secret string, triggers int) (string, cid.Cid, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Use TID for rkey — avoids collisions after delete+re-add
|
||||
rkey := sb.pds.repomgr.NextTID()
|
||||
|
||||
// Create PDS record
|
||||
record := atproto.NewHoldWebhookRecord(userDID, triggers)
|
||||
_, recordCID, err := sb.pds.repomgr.PutRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey, record)
|
||||
if err != nil {
|
||||
return "", cid.Undef, fmt.Errorf("failed to create webhook PDS record: %w", err)
|
||||
}
|
||||
|
||||
// Store secret in SQLite
|
||||
_, err = sb.db.Exec(`
|
||||
INSERT INTO webhook_secrets (rkey, user_did, url, secret) VALUES (?, ?, ?, ?)
|
||||
`, rkey, userDID, webhookURL, secret)
|
||||
if err != nil {
|
||||
// Try to clean up PDS record on SQLite failure
|
||||
_ = sb.pds.repomgr.DeleteRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey)
|
||||
return "", cid.Undef, fmt.Errorf("failed to store webhook secret: %w", err)
|
||||
}
|
||||
|
||||
return rkey, recordCID, nil
|
||||
}
|
||||
|
||||
// DeleteWebhookConfig deletes a webhook by rkey (validates ownership)
|
||||
func (sb *ScanBroadcaster) DeleteWebhookConfig(userDID, rkey string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Validate ownership
|
||||
var owner string
|
||||
err := sb.db.QueryRow(`SELECT user_did FROM webhook_secrets WHERE rkey = ?`, rkey).Scan(&owner)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook not found")
|
||||
}
|
||||
if owner != userDID {
|
||||
return fmt.Errorf("unauthorized: webhook belongs to a different user")
|
||||
}
|
||||
|
||||
// Delete SQLite row
|
||||
if _, err := sb.db.Exec(`DELETE FROM webhook_secrets WHERE rkey = ?`, rkey); err != nil {
|
||||
return fmt.Errorf("failed to delete webhook secret: %w", err)
|
||||
}
|
||||
|
||||
// Delete PDS record
|
||||
if err := sb.pds.repomgr.DeleteRecord(ctx, sb.pds.uid, atproto.WebhookCollection, rkey); err != nil {
|
||||
slog.Warn("Failed to delete webhook PDS record (secret already removed)", "rkey", rkey, "error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWebhooksForUser returns all active webhooks with secrets for dispatch
|
||||
func (sb *ScanBroadcaster) GetWebhooksForUser(userDID string) ([]activeWebhook, error) {
|
||||
rows, err := sb.db.Query(`
|
||||
SELECT rkey, url, secret FROM webhook_secrets WHERE user_did = ?
|
||||
`, userDID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var webhooks []activeWebhook
|
||||
for rows.Next() {
|
||||
var w activeWebhook
|
||||
if err := rows.Scan(&w.Rkey, &w.URL, &w.Secret); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get triggers from PDS record
|
||||
_, val, err := sb.pds.repomgr.GetRecord(context.Background(), sb.pds.uid, atproto.WebhookCollection, w.Rkey, cid.Undef)
|
||||
if err == nil {
|
||||
if rec, ok := val.(*atproto.HoldWebhookRecord); ok {
|
||||
w.Triggers = int(rec.Triggers)
|
||||
}
|
||||
}
|
||||
|
||||
webhooks = append(webhooks, w)
|
||||
}
|
||||
return webhooks, nil
|
||||
}
|
||||
|
||||
// dispatchWebhooks fires matching webhooks after a scan completes
|
||||
func (sb *ScanBroadcaster) dispatchWebhooks(manifestDigest, repository, tag, userDID, userHandle string, summary *VulnerabilitySummary, previousScan *atproto.ScanRecord) {
|
||||
webhooks, err := sb.GetWebhooksForUser(userDID)
|
||||
if err != nil || len(webhooks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
isFirst := previousScan == nil
|
||||
isChanged := previousScan != nil && vulnCountsChanged(summary, previousScan)
|
||||
|
||||
scanInfo := WebhookScanInfo{
|
||||
ScannedAt: time.Now().Format(time.RFC3339),
|
||||
ScannerVersion: "atcr-scanner-v1.0.0",
|
||||
Vulnerabilities: WebhookVulnCounts{
|
||||
Critical: summary.Critical,
|
||||
High: summary.High,
|
||||
Medium: summary.Medium,
|
||||
Low: summary.Low,
|
||||
Total: summary.Total,
|
||||
},
|
||||
}
|
||||
|
||||
manifestInfo := WebhookManifestInfo{
|
||||
Digest: manifestDigest,
|
||||
Repository: repository,
|
||||
Tag: tag,
|
||||
UserDID: userDID,
|
||||
UserHandle: userHandle,
|
||||
}
|
||||
|
||||
for _, wh := range webhooks {
|
||||
// Check each trigger condition
|
||||
triggers := []string{}
|
||||
if wh.Triggers&atproto.TriggerFirst != 0 && isFirst {
|
||||
triggers = append(triggers, "scan:first")
|
||||
}
|
||||
if wh.Triggers&atproto.TriggerAll != 0 {
|
||||
triggers = append(triggers, "scan:all")
|
||||
}
|
||||
if wh.Triggers&atproto.TriggerChanged != 0 && isChanged {
|
||||
triggers = append(triggers, "scan:changed")
|
||||
}
|
||||
|
||||
for _, trigger := range triggers {
|
||||
payload := WebhookPayload{
|
||||
Trigger: trigger,
|
||||
HoldDID: sb.holdDID,
|
||||
HoldEndpoint: sb.holdEndpoint,
|
||||
Manifest: manifestInfo,
|
||||
Scan: scanInfo,
|
||||
}
|
||||
|
||||
// Include previous counts for scan:changed
|
||||
if trigger == "scan:changed" && previousScan != nil {
|
||||
payload.Previous = &WebhookVulnCounts{
|
||||
Critical: int(previousScan.Critical),
|
||||
High: int(previousScan.High),
|
||||
Medium: int(previousScan.Medium),
|
||||
Low: int(previousScan.Low),
|
||||
Total: int(previousScan.Total),
|
||||
}
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal webhook payload", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
go sb.deliverWithRetry(wh.URL, wh.Secret, payloadBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deliverWithRetry attempts to deliver a webhook with exponential backoff
|
||||
func (sb *ScanBroadcaster) deliverWithRetry(webhookURL, secret string, payload []byte) {
|
||||
delays := []time.Duration{0, 30 * time.Second, 2 * time.Minute, 8 * time.Minute}
|
||||
for attempt, delay := range delays {
|
||||
if attempt > 0 {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
if sb.attemptDelivery(webhookURL, secret, payload) {
|
||||
return
|
||||
}
|
||||
}
|
||||
slog.Warn("Webhook delivery failed after retries", "url", maskURL(webhookURL))
|
||||
}
|
||||
|
||||
// attemptDelivery sends a single webhook HTTP POST
|
||||
func (sb *ScanBroadcaster) attemptDelivery(webhookURL, secret string, payload []byte) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Reformat payload for platform-specific webhook APIs
|
||||
meta := sb.pds.AppviewMeta()
|
||||
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, meta)
|
||||
} else {
|
||||
formatted, fmtErr = formatSlackPayload(p, meta)
|
||||
}
|
||||
if fmtErr == nil {
|
||||
sendPayload = formatted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, strings.NewReader(string(sendPayload)))
|
||||
if err != nil {
|
||||
slog.Warn("Failed to create webhook request", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", meta.ClientShortName+"-Webhook/1.0")
|
||||
|
||||
// HMAC signing if secret is set (signs the actual payload sent)
|
||||
if secret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(sendPayload)
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
req.Header.Set("X-Webhook-Signature-256", "sha256="+sig)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("Webhook delivery attempt failed", "url", maskURL(webhookURL), "error", err)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
slog.Info("Webhook delivered successfully", "url", maskURL(webhookURL), "status", resp.StatusCode)
|
||||
return true
|
||||
}
|
||||
|
||||
// Read response body for debugging (e.g., Discord returns error details)
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
slog.Warn("Webhook delivery got non-2xx response",
|
||||
"url", maskURL(webhookURL),
|
||||
"status", resp.StatusCode,
|
||||
"body", string(body))
|
||||
return false
|
||||
}
|
||||
|
||||
// vulnCountsChanged checks if vulnerability counts differ between current scan and previous
|
||||
func vulnCountsChanged(current *VulnerabilitySummary, previous *atproto.ScanRecord) bool {
|
||||
return current.Critical != int(previous.Critical) ||
|
||||
current.High != int(previous.High) ||
|
||||
current.Medium != int(previous.Medium) ||
|
||||
current.Low != int(previous.Low)
|
||||
}
|
||||
|
||||
// maskURL masks a URL for display (shows scheme + host, hides path/query)
|
||||
func maskURL(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
if len(rawURL) > 30 {
|
||||
return rawURL[:30] + "***"
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
masked := u.Scheme + "://" + u.Host
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
masked += "/***"
|
||||
}
|
||||
return masked
|
||||
}
|
||||
|
||||
// isDiscordWebhook checks if the URL points to a Discord webhook endpoint
|
||||
func isDiscordWebhook(rawURL string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Host == "discord.com" || strings.HasSuffix(u.Host, ".discord.com")
|
||||
}
|
||||
|
||||
// isSlackWebhook checks if the URL points to a Slack webhook endpoint
|
||||
func isSlackWebhook(rawURL string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Host == "hooks.slack.com"
|
||||
}
|
||||
|
||||
// webhookSeverityColor returns a color int based on the highest severity present
|
||||
func webhookSeverityColor(vulns WebhookVulnCounts) int {
|
||||
switch {
|
||||
case vulns.Critical > 0:
|
||||
return 0xED4245 // red
|
||||
case vulns.High > 0:
|
||||
return 0xFFA500 // orange
|
||||
case vulns.Medium > 0:
|
||||
return 0xFEE75C // yellow
|
||||
case vulns.Low > 0:
|
||||
return 0x57F287 // green
|
||||
default:
|
||||
return 0x95A5A6 // grey
|
||||
}
|
||||
}
|
||||
|
||||
// webhookSeverityHex returns a hex color string (e.g., "#ED4245")
|
||||
func webhookSeverityHex(vulns WebhookVulnCounts) string {
|
||||
return fmt.Sprintf("#%06X", webhookSeverityColor(vulns))
|
||||
}
|
||||
|
||||
// formatVulnDescription builds a vulnerability summary with colored square emojis
|
||||
func formatVulnDescription(v WebhookVulnCounts, digest string) string {
|
||||
var lines []string
|
||||
|
||||
if len(digest) > 19 {
|
||||
lines = append(lines, fmt.Sprintf("Digest: `%s`", digest[:19]+"..."))
|
||||
}
|
||||
|
||||
if v.Total == 0 {
|
||||
lines = append(lines, "🟩 No vulnerabilities found")
|
||||
} else {
|
||||
if v.Critical > 0 {
|
||||
lines = append(lines, fmt.Sprintf("🟥 Critical: %d", v.Critical))
|
||||
}
|
||||
if v.High > 0 {
|
||||
lines = append(lines, fmt.Sprintf("🟧 High: %d", v.High))
|
||||
}
|
||||
if v.Medium > 0 {
|
||||
lines = append(lines, fmt.Sprintf("🟨 Medium: %d", v.Medium))
|
||||
}
|
||||
if v.Low > 0 {
|
||||
lines = append(lines, fmt.Sprintf("🟫 Low: %d", v.Low))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// formatDiscordPayload wraps an ATCR webhook payload in Discord's embed format
|
||||
func formatDiscordPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
|
||||
appviewURL := meta.BaseURL
|
||||
title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag)
|
||||
|
||||
description := formatVulnDescription(p.Scan.Vulnerabilities, p.Manifest.Digest)
|
||||
|
||||
// Add previous counts for scan:changed
|
||||
if p.Trigger == "scan:changed" && p.Previous != nil {
|
||||
description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d",
|
||||
p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low)
|
||||
}
|
||||
|
||||
embed := map[string]any{
|
||||
"title": title,
|
||||
"url": appviewURL,
|
||||
"description": description,
|
||||
"color": webhookSeverityColor(p.Scan.Vulnerabilities),
|
||||
"footer": map[string]string{
|
||||
"text": meta.ClientShortName,
|
||||
"icon_url": meta.FaviconURL,
|
||||
},
|
||||
"timestamp": p.Scan.ScannedAt,
|
||||
}
|
||||
|
||||
// Add author, repo link, and OG image when handle is available
|
||||
if p.Manifest.UserHandle != "" {
|
||||
embed["url"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
|
||||
embed["author"] = map[string]string{
|
||||
"name": p.Manifest.UserHandle,
|
||||
"url": appviewURL + "/u/" + p.Manifest.UserHandle,
|
||||
}
|
||||
embed["image"] = map[string]string{
|
||||
"url": fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository),
|
||||
}
|
||||
} else {
|
||||
embed["image"] = map[string]string{
|
||||
"url": appviewURL + "/og/home",
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"username": meta.ClientShortName,
|
||||
"avatar_url": meta.FaviconURL,
|
||||
"embeds": []any{embed},
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
// formatSlackPayload wraps an ATCR webhook payload in Slack's message format
|
||||
func formatSlackPayload(p WebhookPayload, meta atproto.AppviewMetadata) ([]byte, error) {
|
||||
appviewURL := meta.BaseURL
|
||||
title := fmt.Sprintf("%s:%s", p.Manifest.Repository, p.Manifest.Tag)
|
||||
|
||||
v := p.Scan.Vulnerabilities
|
||||
fallback := fmt.Sprintf("%s — %d critical, %d high, %d medium, %d low",
|
||||
title, v.Critical, v.High, v.Medium, v.Low)
|
||||
|
||||
description := formatVulnDescription(v, p.Manifest.Digest)
|
||||
|
||||
// Add previous counts for scan:changed
|
||||
if p.Trigger == "scan:changed" && p.Previous != nil {
|
||||
description += fmt.Sprintf("\n\nPrevious: 🟥 %d 🟧 %d 🟨 %d 🟫 %d",
|
||||
p.Previous.Critical, p.Previous.High, p.Previous.Medium, p.Previous.Low)
|
||||
}
|
||||
|
||||
attachment := map[string]any{
|
||||
"fallback": fallback,
|
||||
"color": webhookSeverityHex(v),
|
||||
"title": title,
|
||||
"text": description,
|
||||
"footer": meta.ClientShortName,
|
||||
"footer_icon": meta.FaviconURL,
|
||||
"ts": p.Scan.ScannedAt,
|
||||
}
|
||||
|
||||
// Add repo link when handle is available
|
||||
if p.Manifest.UserHandle != "" {
|
||||
attachment["title_link"] = fmt.Sprintf("%s/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
|
||||
attachment["image_url"] = fmt.Sprintf("%s/og/r/%s/%s", appviewURL, p.Manifest.UserHandle, p.Manifest.Repository)
|
||||
attachment["author_name"] = p.Manifest.UserHandle
|
||||
attachment["author_link"] = appviewURL + "/u/" + p.Manifest.UserHandle
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"text": fallback,
|
||||
"attachments": []any{attachment},
|
||||
}
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
// isCaptain checks if the given DID is the hold captain (owner)
|
||||
func (h *XRPCHandler) isCaptain(ctx context.Context, did string) bool {
|
||||
_, captain, err := h.pds.GetCaptainRecord(ctx)
|
||||
if err != nil {
|
||||
slog.Debug("isCaptain: failed to get captain record", "error", err)
|
||||
return false
|
||||
}
|
||||
if captain == nil {
|
||||
slog.Debug("isCaptain: captain record is nil")
|
||||
return false
|
||||
}
|
||||
match := captain.Owner == did
|
||||
if !match {
|
||||
slog.Debug("isCaptain: DID mismatch", "captain.Owner", captain.Owner, "user.DID", did)
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
// ---- XRPC Handlers ----
|
||||
|
||||
// HandleListWebhooks returns webhook configs for a user
|
||||
func (h *XRPCHandler) HandleListWebhooks(w http.ResponseWriter, r *http.Request) {
|
||||
user := getUserFromContext(r)
|
||||
if user == nil {
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if h.scanBroadcaster == nil {
|
||||
render.JSON(w, r, map[string]any{
|
||||
"webhooks": []any{},
|
||||
"limits": map[string]any{"max": 0, "allTriggers": false},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
configs, err := h.scanBroadcaster.ListWebhookConfigs(user.DID)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to list webhooks: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get tier limits — captains get unlimited access
|
||||
maxWebhooks, allTriggers := 1, false
|
||||
if h.isCaptain(r.Context(), user.DID) {
|
||||
maxWebhooks, allTriggers = -1, true
|
||||
} else if h.quotaMgr != nil {
|
||||
_, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID)
|
||||
tierKey := ""
|
||||
if crew != nil {
|
||||
tierKey = crew.Tier
|
||||
}
|
||||
maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey)
|
||||
}
|
||||
|
||||
render.JSON(w, r, map[string]any{
|
||||
"webhooks": configs,
|
||||
"limits": map[string]any{
|
||||
"max": maxWebhooks,
|
||||
"allTriggers": allTriggers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// HandleAddWebhook creates a new webhook configuration
|
||||
func (h *XRPCHandler) HandleAddWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
user := getUserFromContext(r)
|
||||
if user == nil {
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if h.scanBroadcaster == nil {
|
||||
http.Error(w, "scanning not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
URL string `json:"url"`
|
||||
Secret string `json:"secret"`
|
||||
Triggers int `json:"triggers"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate HTTPS URL
|
||||
u, err := url.Parse(req.URL)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
|
||||
http.Error(w, "invalid webhook URL: must be https", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Tier enforcement — captains get unlimited access
|
||||
maxWebhooks, allTriggers := 1, false
|
||||
if h.isCaptain(r.Context(), user.DID) {
|
||||
maxWebhooks, allTriggers = -1, true
|
||||
} else {
|
||||
tierKey := ""
|
||||
_, crew, _ := h.pds.GetCrewMemberByDID(r.Context(), user.DID)
|
||||
if crew != nil {
|
||||
tierKey = crew.Tier
|
||||
}
|
||||
if h.quotaMgr != nil {
|
||||
maxWebhooks, allTriggers = h.quotaMgr.WebhookLimits(tierKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Check webhook count limit
|
||||
count, err := h.scanBroadcaster.CountWebhooks(user.DID)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to check webhook count", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if maxWebhooks >= 0 && count >= maxWebhooks {
|
||||
http.Error(w, fmt.Sprintf("webhook limit reached (%d/%d)", count, maxWebhooks), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger bitmask enforcement: free users can only set TriggerFirst
|
||||
if !allTriggers && req.Triggers & ^atproto.TriggerFirst != 0 {
|
||||
http.Error(w, "trigger types beyond scan:first require a paid tier", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
rkey, recordCID, err := h.scanBroadcaster.AddWebhookConfig(user.DID, req.URL, req.Secret, req.Triggers)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to add webhook: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusCreated)
|
||||
render.JSON(w, r, map[string]any{
|
||||
"rkey": rkey,
|
||||
"cid": recordCID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// HandleDeleteWebhook deletes a webhook configuration
|
||||
func (h *XRPCHandler) HandleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
user := getUserFromContext(r)
|
||||
if user == nil {
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if h.scanBroadcaster == nil {
|
||||
http.Error(w, "scanning not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Rkey string `json:"rkey"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.scanBroadcaster.DeleteWebhookConfig(user.DID, req.Rkey); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "unauthorized") {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
} else {
|
||||
http.Error(w, fmt.Sprintf("failed to delete webhook: %v", err), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
render.JSON(w, r, map[string]any{"success": true})
|
||||
}
|
||||
|
||||
// HandleTestWebhook sends a test payload to a webhook
|
||||
func (h *XRPCHandler) HandleTestWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
user := getUserFromContext(r)
|
||||
if user == nil {
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if h.scanBroadcaster == nil {
|
||||
http.Error(w, "scanning not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Rkey string `json:"rkey"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Look up webhook URL and secret
|
||||
var webhookURL, secret, owner string
|
||||
err := h.scanBroadcaster.db.QueryRow(`
|
||||
SELECT url, secret, user_did FROM webhook_secrets WHERE rkey = ?
|
||||
`, req.Rkey).Scan(&webhookURL, &secret, &owner)
|
||||
if err != nil {
|
||||
http.Error(w, "webhook not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if owner != user.DID {
|
||||
http.Error(w, "unauthorized", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve handle if not available from auth context
|
||||
userHandle := user.Handle
|
||||
if userHandle == "" {
|
||||
if _, handle, _, err := atproto.ResolveIdentity(r.Context(), user.DID); err == nil {
|
||||
userHandle = handle
|
||||
}
|
||||
}
|
||||
|
||||
// Randomize vulnerability counts so each test shows a different severity color
|
||||
critical := rand.IntN(3)
|
||||
high := rand.IntN(5)
|
||||
medium := rand.IntN(8)
|
||||
low := rand.IntN(10)
|
||||
total := critical + high + medium + low
|
||||
|
||||
// Build test payload
|
||||
payload := WebhookPayload{
|
||||
Trigger: "test",
|
||||
HoldDID: h.scanBroadcaster.holdDID,
|
||||
HoldEndpoint: h.scanBroadcaster.holdEndpoint,
|
||||
Manifest: WebhookManifestInfo{
|
||||
Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
Repository: "test-repo",
|
||||
Tag: "latest",
|
||||
UserDID: user.DID,
|
||||
UserHandle: userHandle,
|
||||
},
|
||||
Scan: WebhookScanInfo{
|
||||
ScannedAt: time.Now().Format(time.RFC3339),
|
||||
ScannerVersion: "atcr-scanner-v1.0.0",
|
||||
Vulnerabilities: WebhookVulnCounts{
|
||||
Critical: critical, High: high, Medium: medium, Low: low, Total: total,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
|
||||
// Deliver test payload synchronously
|
||||
success := h.scanBroadcaster.attemptDelivery(webhookURL, secret, payloadBytes)
|
||||
|
||||
render.JSON(w, r, map[string]any{
|
||||
"success": success,
|
||||
})
|
||||
}
|
||||
|
||||
// registerWebhookHandlers registers webhook XRPC handlers on the router.
|
||||
// Called from RegisterHandlers.
|
||||
func (h *XRPCHandler) registerWebhookHandlers(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(h.requireAuth)
|
||||
r.Get(atproto.HoldListWebhooks, h.HandleListWebhooks)
|
||||
r.Post(atproto.HoldAddWebhook, h.HandleAddWebhook)
|
||||
r.Post(atproto.HoldDeleteWebhook, h.HandleDeleteWebhook)
|
||||
r.Post(atproto.HoldTestWebhook, h.HandleTestWebhook)
|
||||
})
|
||||
}
|
||||
+116
-2
@@ -49,6 +49,7 @@ type XRPCHandler struct {
|
||||
scanBroadcaster *ScanBroadcaster // Scan job dispatcher for connected scanners
|
||||
httpClient HTTPClient // For testing - allows injecting mock HTTP client
|
||||
quotaMgr *quota.Manager // Quota manager for tier-based limits
|
||||
appviewDID string // DID of the trusted appview (for tier updates)
|
||||
}
|
||||
|
||||
// PartInfo represents a completed part in a multipart upload
|
||||
@@ -76,6 +77,11 @@ func NewXRPCHandler(pds *HoldPDS, s3Service s3.S3Service, broadcaster *EventBroa
|
||||
}
|
||||
}
|
||||
|
||||
// SetAppviewDID sets the trusted appview DID for tier update authentication.
|
||||
func (h *XRPCHandler) SetAppviewDID(did string) {
|
||||
h.appviewDID = did
|
||||
}
|
||||
|
||||
// SetScanBroadcaster sets the scan broadcaster for dispatching scan jobs to scanners
|
||||
func (h *XRPCHandler) SetScanBroadcaster(sb *ScanBroadcaster) {
|
||||
h.scanBroadcaster = sb
|
||||
@@ -209,11 +215,15 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
|
||||
// Public quota endpoint (no auth - quota is per-user, just needs userDid param)
|
||||
r.Get(atproto.HoldGetQuota, h.HandleGetQuota)
|
||||
|
||||
// Public tier list endpoint (no auth)
|
||||
r.Get(atproto.HoldListTiers, h.HandleListTiers)
|
||||
|
||||
// Appview-authenticated endpoints (appview JWT auth)
|
||||
r.Post(atproto.HoldUpdateCrewTier, h.HandleUpdateCrewTier)
|
||||
|
||||
// Scanner WebSocket endpoint (shared secret auth)
|
||||
r.Get(atproto.HoldSubscribeScanJobs, h.HandleSubscribeScanJobs)
|
||||
|
||||
// Webhook management endpoints (service token auth)
|
||||
h.registerWebhookHandlers(r)
|
||||
}
|
||||
|
||||
// HandleHealth returns health check information
|
||||
@@ -1604,6 +1614,110 @@ func (h *XRPCHandler) HandleGetQuota(w http.ResponseWriter, r *http.Request) {
|
||||
render.JSON(w, r, stats)
|
||||
}
|
||||
|
||||
// HandleListTiers returns the hold's available tiers with storage quotas.
|
||||
// This is a public endpoint (no auth) so the appview UI can display "3-10 GB depending on region."
|
||||
func (h *XRPCHandler) HandleListTiers(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.quotaMgr.IsEnabled() {
|
||||
render.JSON(w, r, map[string]any{"tiers": []any{}})
|
||||
return
|
||||
}
|
||||
|
||||
tierInfos := h.quotaMgr.ListTiers()
|
||||
tiers := make([]map[string]any, 0, len(tierInfos))
|
||||
for _, t := range tierInfos {
|
||||
var quotaBytes int64
|
||||
if t.Limit != nil {
|
||||
quotaBytes = *t.Limit
|
||||
}
|
||||
tiers = append(tiers, map[string]any{
|
||||
"name": t.Key,
|
||||
"quotaBytes": quotaBytes,
|
||||
"quotaFormatted": quota.FormatHumanBytes(quotaBytes),
|
||||
"scanOnPush": t.ScanOnPush,
|
||||
})
|
||||
}
|
||||
|
||||
render.JSON(w, r, map[string]any{"tiers": tiers})
|
||||
}
|
||||
|
||||
// HandleUpdateCrewTier updates a crew member's tier. Only accepts requests from the trusted appview.
|
||||
// Auth: Bearer token signed by the appview's P-256 key (ES256 JWT).
|
||||
func (h *XRPCHandler) HandleUpdateCrewTier(w http.ResponseWriter, r *http.Request) {
|
||||
if h.appviewDID == "" {
|
||||
http.Error(w, "appview DID not configured on this hold", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate appview token
|
||||
userDID, err := ValidateAppviewToken(r, h.appviewDID, h.pds.DID())
|
||||
if err != nil {
|
||||
slog.Warn("Appview token validation failed for updateCrewTier", "error", err)
|
||||
http.Error(w, fmt.Sprintf("authentication failed: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req struct {
|
||||
UserDID string `json:"userDid"`
|
||||
TierRank int `json:"tierRank"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the userDid in the body matches the sub claim
|
||||
if req.UserDID != "" && req.UserDID != userDID {
|
||||
// Use the body's userDid (the sub claim was the appview-specified user)
|
||||
userDID = req.UserDID
|
||||
}
|
||||
|
||||
if !atproto.IsDID(userDID) {
|
||||
http.Error(w, "invalid userDid format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Map tier rank to tier name
|
||||
tierName := h.resolveTierByRank(req.TierRank)
|
||||
if tierName == "" {
|
||||
http.Error(w, "no tiers configured on this hold", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the crew member's tier
|
||||
if err := h.pds.UpdateCrewMemberTier(r.Context(), userDID, tierName); err != nil {
|
||||
slog.Error("Failed to update crew tier", "userDid", userDID, "tier", tierName, "error", err)
|
||||
http.Error(w, fmt.Sprintf("failed to update tier: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Updated crew tier via appview", "userDid", userDID, "tierRank", req.TierRank, "tierName", tierName)
|
||||
|
||||
render.JSON(w, r, map[string]string{"tierName": tierName})
|
||||
}
|
||||
|
||||
// resolveTierByRank maps a 0-based rank index to a tier name from the quota config.
|
||||
// If the rank exceeds the number of tiers, it clamps to the highest tier.
|
||||
func (h *XRPCHandler) resolveTierByRank(rank int) string {
|
||||
if !h.quotaMgr.IsEnabled() {
|
||||
return ""
|
||||
}
|
||||
|
||||
tiers := h.quotaMgr.ListTiers()
|
||||
if len(tiers) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if rank < 0 {
|
||||
rank = 0
|
||||
}
|
||||
if rank >= len(tiers) {
|
||||
rank = len(tiers) - 1
|
||||
}
|
||||
|
||||
return tiers[rank].Key
|
||||
}
|
||||
|
||||
// HoldUserDataExport represents the GDPR data export from a hold service
|
||||
type HoldUserDataExport struct {
|
||||
ExportedAt time.Time `json:"exported_at"`
|
||||
|
||||
@@ -40,24 +40,12 @@ type TierConfig struct {
|
||||
|
||||
// Whether pushing triggers an immediate vulnerability scan.
|
||||
ScanOnPush bool `yaml:"scan_on_push" comment:"Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling."`
|
||||
|
||||
// Maximum number of webhook URLs a user can configure. 0 = none, -1 = unlimited.
|
||||
MaxWebhooks int `yaml:"max_webhooks" comment:"Maximum webhook URLs (0=none, -1=unlimited). Default: 1."`
|
||||
|
||||
// Whether all trigger types are allowed. Free tiers only get scan:first.
|
||||
WebhookAllTriggers bool `yaml:"webhook_all_triggers" comment:"Allow all webhook trigger types. Free tiers only get scan:first."`
|
||||
|
||||
// Whether this tier earns a supporter badge on user profiles.
|
||||
SupporterBadge bool `yaml:"supporter_badge" comment:"Show supporter badge on user profiles for members at this tier."`
|
||||
}
|
||||
|
||||
// DefaultsConfig represents default settings.
|
||||
type DefaultsConfig struct {
|
||||
// Name of the tier assigned to new crew members.
|
||||
NewCrewTier string `yaml:"new_crew_tier" comment:"Tier assigned to new crew members who don't have an explicit tier."`
|
||||
|
||||
// Whether the hold owner (captain) gets a supporter badge on their profile.
|
||||
OwnerBadge bool `yaml:"owner_badge" comment:"Show supporter badge on the hold owner's profile."`
|
||||
}
|
||||
|
||||
// Manager manages quota configuration and tier resolution
|
||||
@@ -220,59 +208,6 @@ func (m *Manager) ScanOnPush(tierKey string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// WebhookLimits returns the webhook limits for a tier.
|
||||
// Returns (maxWebhooks, allTriggers). Default when no config: (1, false).
|
||||
// Follows the same fallback logic as GetTierLimit.
|
||||
func (m *Manager) WebhookLimits(tierKey string) (maxWebhooks int, allTriggers bool) {
|
||||
if !m.IsEnabled() {
|
||||
return 1, false
|
||||
}
|
||||
|
||||
if tierKey != "" {
|
||||
if tier := m.config.TierByName(tierKey); tier != nil {
|
||||
max := tier.MaxWebhooks
|
||||
if max == 0 {
|
||||
max = 1 // default
|
||||
}
|
||||
return max, tier.WebhookAllTriggers
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to default tier
|
||||
if m.config.Defaults.NewCrewTier != "" {
|
||||
if tier := m.config.TierByName(m.config.Defaults.NewCrewTier); tier != nil {
|
||||
max := tier.MaxWebhooks
|
||||
if max == 0 {
|
||||
max = 1
|
||||
}
|
||||
return max, tier.WebhookAllTriggers
|
||||
}
|
||||
}
|
||||
|
||||
return 1, false
|
||||
}
|
||||
|
||||
// BadgeTiers returns the names of tiers that have supporter badges enabled,
|
||||
// ordered from highest rank to lowest. Includes "owner" first if
|
||||
// defaults.owner_badge is true.
|
||||
// Returns nil if quotas are disabled or no tiers have badges.
|
||||
func (m *Manager) BadgeTiers() []string {
|
||||
if !m.IsEnabled() {
|
||||
return nil
|
||||
}
|
||||
var tiers []string
|
||||
if m.config.Defaults.OwnerBadge {
|
||||
tiers = append(tiers, "owner")
|
||||
}
|
||||
// Iterate in reverse: highest rank first
|
||||
for i := len(m.config.Tiers) - 1; i >= 0; i-- {
|
||||
if m.config.Tiers[i].SupporterBadge {
|
||||
tiers = append(tiers, m.config.Tiers[i].Name)
|
||||
}
|
||||
}
|
||||
return tiers
|
||||
}
|
||||
|
||||
// TierCount returns the number of configured tiers
|
||||
func (m *Manager) TierCount() int {
|
||||
return len(m.tiers)
|
||||
@@ -280,8 +215,9 @@ func (m *Manager) TierCount() int {
|
||||
|
||||
// TierInfo represents tier information for listing
|
||||
type TierInfo struct {
|
||||
Key string
|
||||
Limit *int64
|
||||
Key string
|
||||
Limit *int64
|
||||
ScanOnPush bool
|
||||
}
|
||||
|
||||
// ListTiers returns all configured tiers with their limits, in rank order
|
||||
@@ -299,8 +235,9 @@ func (m *Manager) ListTiers() []TierInfo {
|
||||
}
|
||||
limitCopy := limit
|
||||
tiers = append(tiers, TierInfo{
|
||||
Key: tier.Name,
|
||||
Limit: &limitCopy,
|
||||
Key: tier.Name,
|
||||
Limit: &limitCopy,
|
||||
ScanOnPush: tier.ScanOnPush,
|
||||
})
|
||||
}
|
||||
return tiers
|
||||
|
||||
@@ -427,33 +427,6 @@ defaults:
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadgeTiers_RankOrder(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Tiers: []TierConfig{
|
||||
{Name: "deckhand", Quota: "5GB"},
|
||||
{Name: "bosun", Quota: "50GB", SupporterBadge: true},
|
||||
{Name: "quartermaster", Quota: "100GB", SupporterBadge: true},
|
||||
},
|
||||
Defaults: DefaultsConfig{OwnerBadge: true},
|
||||
}
|
||||
m, err := NewManagerFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tiers := m.BadgeTiers()
|
||||
// Expected: owner first, then highest rank first
|
||||
expected := []string{"owner", "quartermaster", "bosun"}
|
||||
if len(tiers) != len(expected) {
|
||||
t.Fatalf("got %v, want %v", tiers, expected)
|
||||
}
|
||||
for i := range expected {
|
||||
if tiers[i] != expected[i] {
|
||||
t.Errorf("tiers[%d] = %q, want %q", i, tiers[i], expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTiers_PreservesOrder(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Tiers: []TierConfig{
|
||||
|
||||
+18
-51
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/hold/admin"
|
||||
"atcr.io/pkg/hold/billing"
|
||||
holddb "atcr.io/pkg/hold/db"
|
||||
"atcr.io/pkg/hold/gc"
|
||||
"atcr.io/pkg/hold/oci"
|
||||
@@ -105,7 +104,7 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
}
|
||||
|
||||
// Use shared DB for all subsystems
|
||||
s.PDS, err = pds.NewHoldPDSWithDB(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL, cfg.Database.Path, cfg.Database.KeyPath, cfg.Registration.EnableBlueskyPosts, s.holdDB.DB)
|
||||
s.PDS, err = pds.NewHoldPDSWithDB(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL(), cfg.Database.Path, cfg.Database.KeyPath, cfg.Registration.EnableBlueskyPosts, s.holdDB.DB)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize embedded PDS: %w", err)
|
||||
}
|
||||
@@ -113,7 +112,7 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
s.broadcaster = pds.NewEventBroadcasterWithDB(holdDID, 100, s.holdDB.DB)
|
||||
} else {
|
||||
// In-memory mode (tests): each subsystem opens its own connection
|
||||
s.PDS, err = pds.NewHoldPDS(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL, cfg.Database.Path, cfg.Database.KeyPath, cfg.Registration.EnableBlueskyPosts)
|
||||
s.PDS, err = pds.NewHoldPDS(ctx, holdDID, cfg.Server.PublicURL, cfg.Server.AppviewURL(), cfg.Database.Path, cfg.Database.KeyPath, cfg.Registration.EnableBlueskyPosts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize embedded PDS: %w", err)
|
||||
}
|
||||
@@ -188,26 +187,13 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
slog.Info("Quota enforcement disabled (no quota tiers configured)")
|
||||
}
|
||||
|
||||
// Sync supporter badge tiers from quota config into captain record
|
||||
if s.PDS != nil {
|
||||
badgeTiers := s.QuotaManager.BadgeTiers()
|
||||
badgeCtx := context.Background()
|
||||
if _, captain, err := s.PDS.GetCaptainRecord(badgeCtx); err == nil {
|
||||
if !stringSlicesEqual(captain.SupporterBadgeTiers, badgeTiers) {
|
||||
captain.SupporterBadgeTiers = badgeTiers
|
||||
if _, err := s.PDS.UpdateCaptainRecord(badgeCtx, captain); err != nil {
|
||||
slog.Warn("Failed to sync supporter badge tiers", "error", err)
|
||||
} else {
|
||||
slog.Info("Synced supporter badge tiers from quota config", "tiers", badgeTiers)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create XRPC handlers
|
||||
var ociHandler *oci.XRPCHandler
|
||||
if s.PDS != nil {
|
||||
xrpcHandler = pds.NewXRPCHandler(s.PDS, *s3Service, s.broadcaster, nil, s.QuotaManager)
|
||||
if cfg.Server.AppviewDID != "" {
|
||||
xrpcHandler.SetAppviewDID(cfg.Server.AppviewDID)
|
||||
}
|
||||
ociHandler = oci.NewXRPCHandler(s.PDS, *s3Service, cfg.Registration.EnableBlueskyPosts, nil, s.QuotaManager)
|
||||
|
||||
// Initialize scan broadcaster if scanner secret is configured
|
||||
@@ -240,7 +226,9 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
// Setup HTTP routes with chi router
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Maybe(middleware.Logger, func(r *http.Request) bool {
|
||||
return r.URL.Path != "/xrpc/_health"
|
||||
}))
|
||||
|
||||
if xrpcHandler != nil {
|
||||
r.Use(xrpcHandler.CORSMiddleware())
|
||||
@@ -252,6 +240,12 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
fmt.Fprintf(w, "This is a hold server. More info at https://atcr.io")
|
||||
})
|
||||
|
||||
// Robots.txt - disallow crawling of all endpoints except root
|
||||
r.Get("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprint(w, "User-agent: *\nAllow: /\nDisallow: /xrpc/\nDisallow: /admin/\n")
|
||||
})
|
||||
|
||||
// Register XRPC/ATProto PDS endpoints
|
||||
if xrpcHandler != nil {
|
||||
slog.Info("Registering ATProto PDS endpoints")
|
||||
@@ -283,20 +277,6 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize billing manager (compile-time optional via -tags billing)
|
||||
billingMgr := billing.New(s.QuotaManager, cfg.Server.PublicURL, cfg.ConfigPath())
|
||||
if billingMgr.Enabled() {
|
||||
slog.Info("Billing enabled (Stripe integration active)")
|
||||
} else {
|
||||
slog.Info("Billing disabled (not compiled or not configured)")
|
||||
}
|
||||
|
||||
// Register billing endpoints (if configured and PDS available)
|
||||
if s.PDS != nil && billingMgr.Enabled() {
|
||||
billingHandler := billing.NewXRPCHandler(billingMgr, s.PDS, http.DefaultClient)
|
||||
billingHandler.RegisterHandlers(r)
|
||||
}
|
||||
|
||||
s.Router = r
|
||||
|
||||
return s, nil
|
||||
@@ -334,11 +314,11 @@ func (s *HoldServer) Serve() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch appview metadata for branding (webhook embeds, posts)
|
||||
if s.Config.Server.AppviewURL != "" {
|
||||
meta, err := atproto.FetchAppviewMetadata(context.Background(), s.Config.Server.AppviewURL)
|
||||
// Fetch appview metadata for branding (Bluesky posts)
|
||||
if s.Config.Server.AppviewURL() != "" {
|
||||
meta, err := atproto.FetchAppviewMetadata(context.Background(), s.Config.Server.AppviewURL())
|
||||
if err != nil {
|
||||
slog.Warn("Failed to fetch appview metadata, using defaults", "appview_url", s.Config.Server.AppviewURL, "error", err)
|
||||
slog.Warn("Failed to fetch appview metadata, using defaults", "appview_url", s.Config.Server.AppviewURL(), "error", err)
|
||||
} else {
|
||||
s.PDS.SetAppviewMeta(meta)
|
||||
slog.Info("Fetched appview metadata", "clientName", meta.ClientName, "clientShortName", meta.ClientShortName)
|
||||
@@ -439,16 +419,3 @@ func (s *HoldServer) shutdown() {
|
||||
|
||||
logging.Shutdown()
|
||||
}
|
||||
|
||||
// stringSlicesEqual returns true if two string slices have the same elements.
|
||||
func stringSlicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime/debug"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -38,6 +39,12 @@ Use --config to specify a YAML configuration file.
|
||||
Environment variables always override file values (SCANNER_ prefix).`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Set a soft memory limit so the GC gets aggressive before the OOM
|
||||
// killer intervenes. GOMEMLIMIT env var overrides this default.
|
||||
if os.Getenv("GOMEMLIMIT") == "" {
|
||||
debug.SetMemoryLimit(512 * 1024 * 1024) // 512 MiB
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
|
||||
@@ -18,6 +18,10 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// httpClient is used for blob downloads and presigned URL requests
|
||||
// with a timeout to prevent stalled connections from leaking memory.
|
||||
var httpClient = &http.Client{Timeout: 5 * time.Minute}
|
||||
|
||||
// HoldClient manages the WebSocket connection to a hold service
|
||||
type HoldClient struct {
|
||||
holdURL string
|
||||
@@ -95,6 +99,7 @@ func (c *HoldClient) connectOnce(cursor int64) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
c.mu.Lock()
|
||||
c.conn = conn
|
||||
@@ -229,7 +234,7 @@ func GetBlobPresignedURL(holdEndpoint, holdDID, digest, secret string) (string,
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get presigned URL: %w", err)
|
||||
}
|
||||
@@ -252,7 +257,7 @@ func GetBlobPresignedURL(holdEndpoint, holdDID, digest, secret string) (string,
|
||||
|
||||
// DownloadBlob downloads a blob from a presigned URL to a local file
|
||||
func DownloadBlob(presignedURL, destPath string) error {
|
||||
resp, err := http.Get(presignedURL)
|
||||
resp, err := httpClient.Get(presignedURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download blob: %w", err)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func setScannerDefaults(v *viper.Viper) {
|
||||
v.SetDefault("hold.secret", "")
|
||||
|
||||
// Scanner defaults
|
||||
v.SetDefault("scanner.workers", 2)
|
||||
v.SetDefault("scanner.workers", 1)
|
||||
v.SetDefault("scanner.queue_size", 100)
|
||||
|
||||
// Vuln defaults
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
scanner "atcr.io/scanner"
|
||||
@@ -34,7 +35,8 @@ import (
|
||||
var (
|
||||
vulnDB vulnerability.Provider
|
||||
vulnDBLock sync.RWMutex
|
||||
vulnDBLoaded time.Time // when the current vulnDB was loaded
|
||||
vulnDBLoaded time.Time // when the current vulnDB was loaded
|
||||
vulnDBScans atomic.Int64 // scan counter for periodic reload
|
||||
)
|
||||
|
||||
// vulnDBRefreshAge is how long a cached DB is considered fresh.
|
||||
@@ -140,7 +142,17 @@ func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Pro
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if vulnDB != nil && time.Since(vulnDBLoaded) < vulnDBRefreshAge {
|
||||
return vulnDB, nil
|
||||
// Periodic reload: close and reopen DB every 50 scans to flush
|
||||
// SQLite's page cache and mmap region.
|
||||
n := vulnDBScans.Add(1)
|
||||
if n%50 == 0 {
|
||||
slog.Info("Periodic vulnDB reload to release memory", "scans", n)
|
||||
vulnDB.Close()
|
||||
vulnDB = nil
|
||||
// Fall through to reload below
|
||||
} else {
|
||||
return vulnDB, nil
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("Loading Grype vulnerability database", "path", vulnDBPath)
|
||||
@@ -178,6 +190,9 @@ func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Pro
|
||||
"built", status.Built,
|
||||
"schemaVersion", status.SchemaVersion)
|
||||
|
||||
if vulnDB != nil {
|
||||
vulnDB.Close()
|
||||
}
|
||||
vulnDB = store
|
||||
vulnDBLoaded = time.Now()
|
||||
return vulnDB, nil
|
||||
|
||||
@@ -29,13 +29,14 @@ func generateSBOM(ctx context.Context, ociLayoutDir string) (*sbom.SBOM, []byte,
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to load OCI image: %w", err)
|
||||
}
|
||||
defer img.Cleanup()
|
||||
|
||||
if err := img.Read(); err != nil {
|
||||
img.Cleanup()
|
||||
return nil, nil, "", fmt.Errorf("failed to read OCI image: %w", err)
|
||||
}
|
||||
|
||||
// Wrap in Syft source
|
||||
// Wrap in Syft source — src.Close() calls img.Cleanup() internally,
|
||||
// so we don't defer img.Cleanup() separately.
|
||||
src := stereoscopesource.New(img, stereoscopesource.ImageConfig{
|
||||
Reference: ociLayoutDir,
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -93,15 +94,27 @@ func (wp *WorkerPool) worker(ctx context.Context, id int) {
|
||||
"repository", job.Repository,
|
||||
"error", err)
|
||||
wp.client.SendError(job.Seq, err.Error())
|
||||
continue
|
||||
} else {
|
||||
wp.client.SendResult(job.Seq, result)
|
||||
|
||||
slog.Info("Scan job completed",
|
||||
"worker_id", id,
|
||||
"repository", job.Repository,
|
||||
"vulnerabilities", result.Summary.Total)
|
||||
}
|
||||
|
||||
wp.client.SendResult(job.Seq, result)
|
||||
// Free large scan artifacts and trigger GC before the cooldown
|
||||
// so memory is reclaimed between jobs. Syft/Grype allocate heavily
|
||||
// and Go's GC needs idle time to catch up under sustained load.
|
||||
result = nil
|
||||
runtime.GC()
|
||||
|
||||
slog.Info("Scan job completed",
|
||||
"worker_id", id,
|
||||
"repository", job.Repository,
|
||||
"vulnerabilities", result.Summary.Total)
|
||||
// Cooldown between scans to reduce sustained memory pressure
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +181,7 @@ func (wp *WorkerPool) processJob(ctx context.Context, job *scanner.ScanJob) (*sc
|
||||
result.VulnDigest = vulnDigest
|
||||
result.Summary = &summary
|
||||
}
|
||||
sbomResult = nil // release SBOM catalog for GC
|
||||
|
||||
duration := time.Since(startTime)
|
||||
slog.Info("Scan pipeline completed",
|
||||
|
||||
Reference in New Issue
Block a user