more work on webhook, implement getMetadata endpoint for appview and link holds to a preferred appview

This commit is contained in:
Evan Jarrett
2026-02-22 22:49:33 -06:00
parent 1e04c91507
commit dc31ca2f35
29 changed files with 487 additions and 173 deletions
+1 -1
View File
@@ -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.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)
+3 -1
View File
@@ -47,6 +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
# Read timeout for HTTP requests.
read_timeout: 5m0s
# Write timeout for HTTP requests.
@@ -110,7 +112,7 @@ quota:
# 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: true
supporter_badge: false
- # Tier name used as the key for crew assignments.
name: bosun
# Storage quota limit (e.g. "5GB", "50GB", "1TB").
+1
View File
@@ -21,6 +21,7 @@ server:
successor: ""
test_mode: false
relay_endpoint: ""
appview_url: https://seamark.dev
read_timeout: 5m0s
write_timeout: 5m0s
registration:
File diff suppressed because one or more lines are too long
+15
View File
@@ -535,6 +535,21 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
}
})
// Appview metadata endpoint (public, used by holds for branding)
mainRouter.Get(atproto.AppviewGetMetadata, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
if err := json.NewEncoder(w).Encode(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,
}); err != nil {
http.Error(w, "encode error", http.StatusInternalServerError)
}
})
// Register credential helper version API (public endpoint)
routes.RegisterCredentialHelperEndpoint(mainRouter, cfg.CredentialHelper.TangledRepo)
+10
View File
@@ -420,6 +420,16 @@
.vuln-box-low { background-color: oklch(80% 0.1 85); color: oklch(25% 0.05 85); }
}
/* ========================================
TOAST CONTAINER
======================================== */
#toast-container {
pointer-events: none;
}
#toast-container > * {
pointer-events: auto;
}
/* ========================================
SUPPORTER BADGE TEXT COLOR OVERRIDES
Unlayered — wins over DaisyUI's layered
+42
View File
@@ -711,6 +711,46 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// Toast notifications (auto-dismiss after 3s)
function showToast(message, type) {
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast toast-end toast-bottom z-50';
document.body.appendChild(container);
}
const alertClass = type === 'error' ? 'alert-error' : 'alert-success';
const toast = document.createElement('div');
toast.className = `alert ${alertClass} shadow-lg transition-opacity duration-300`;
toast.innerHTML = `<span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Test webhook via fetch + toast
async function testWebhook(rkey) {
try {
const resp = await fetch(`/api/webhooks/${rkey}/test`, {
method: 'POST',
credentials: 'include',
});
const text = await resp.text();
if (text.includes('class="success"') || (resp.ok && !text.includes('class="error"'))) {
showToast('Test webhook delivered successfully!', 'success');
} else {
showToast('Test delivery failed \u2014 check the webhook URL', 'error');
}
} catch {
showToast('Failed to reach server', 'error');
}
}
// Export functions that are called from templates via onclick handlers
window.setTheme = setTheme;
window.toggleSearch = toggleSearch;
@@ -720,3 +760,5 @@ window.toggleOfflineManifests = toggleOfflineManifests;
window.deleteManifest = deleteManifest;
window.closeManifestDeleteModal = closeManifestDeleteModal;
window.openVulnDetails = openVulnDetails;
window.showToast = showToast;
window.testWebhook = testWebhook;
+1 -3
View File
@@ -28,8 +28,6 @@
{{/* JSON-LD */}}
{{ range .JSONLD }}
<script type="application/ld+json">
{{ jsonld . }}
</script>
{{ jsonldScript . }}
{{ end }}
{{ end }}
@@ -79,9 +79,7 @@
</div>
<div class="flex gap-2 shrink-0">
<button class="btn btn-xs btn-ghost"
hx-post="/api/webhooks/{{ .Rkey }}/test"
hx-target="closest .card"
hx-swap="afterend"
onclick="testWebhook('{{ .Rkey }}')"
title="Send test payload">
Test
</button>
+15 -10
View File
@@ -248,18 +248,23 @@ func Templates(overrides *BrandingOverrides) (*template.Template, error) {
))
},
// jsonld marshals a value to indented JSON for JSON-LD script tags
// Usage: {{ jsonld .SomeStruct }}
"jsonld": func(v any) template.HTML {
// If v is already a string, assume it's pre-formatted JSON
// jsonldScript renders a complete <script type="application/ld+json"> block.
// Returns the whole block as template.HTML to avoid html/template's JS context
// escaping that double-encodes JSON inside <script> tags.
// See https://github.com/golang/go/issues/20886
// Usage: {{ jsonldScript .SomeStruct }}
"jsonldScript": func(v any) template.HTML {
var jsonBytes []byte
if s, ok := v.(string); ok {
return template.HTML(s)
jsonBytes = []byte(s)
} else {
var err error
jsonBytes, err = json.MarshalIndent(v, " ", " ")
if err != nil {
jsonBytes = []byte("{}")
}
}
b, err := json.MarshalIndent(v, " ", " ")
if err != nil {
return template.HTML("{}")
}
return template.HTML(b)
return template.HTML("<script type=\"application/ld+json\">\n " + string(jsonBytes) + "\n </script>")
},
// extraCSS returns a <style> block with consumer CSS overrides, or empty string.
+15 -73
View File
@@ -794,7 +794,7 @@ func TestPublicHandler(t *testing.T) {
// which is typically done in integration tests
}
func TestJSONLD(t *testing.T) {
func TestJSONLDScript(t *testing.T) {
tests := []struct {
name string
input any
@@ -802,7 +802,7 @@ func TestJSONLD(t *testing.T) {
expectMissing []string
}{
{
name: "struct input - marshals to JSON",
name: "struct input - renders script block with JSON",
input: struct {
Context string `json:"@context"`
Type string `json:"@type"`
@@ -813,39 +813,27 @@ func TestJSONLD(t *testing.T) {
Name: "ATCR",
},
expectContains: []string{
`<script type="application/ld+json">`,
`"@context": "https://schema.org"`,
`"@type": "Organization"`,
`"name": "ATCR"`,
`</script>`,
},
expectMissing: []string{
`\"`, // Should NOT contain escaped quotes (double-encoding)
`\n`, // Should NOT contain escaped newlines
`&#34;`, // Should NOT contain HTML-escaped quotes
},
},
{
name: "string input - returns as-is without re-encoding",
name: "string input - returns as-is in script block",
input: `{"@context": "https://schema.org", "@type": "Thing"}`,
expectContains: []string{
`<script type="application/ld+json">`,
`{"@context": "https://schema.org", "@type": "Thing"}`,
},
expectMissing: []string{
`\"`, // Should NOT have escaped quotes
`\n`, // Should NOT have escaped newlines
`</script>`,
},
},
{
name: "pre-formatted JSON string - no double encoding",
input: "{\n \"@context\": \"https://schema.org\"\n}",
expectContains: []string{
`"@context": "https://schema.org"`,
},
expectMissing: []string{
`\\n`, // Should NOT have double-escaped newlines
`\\"`, // Should NOT have double-escaped quotes
},
},
{
name: "nested struct - proper indentation",
name: "nested struct - proper JSON nesting",
input: struct {
Context string `json:"@context"`
Author struct {
@@ -870,17 +858,12 @@ func TestJSONLD(t *testing.T) {
},
},
{
name: "empty struct - returns empty JSON object",
name: "empty struct - returns empty JSON object in script block",
input: struct{}{},
expectContains: []string{
`<script type="application/ld+json">`,
`{}`,
},
},
{
name: "empty string - returns empty string",
input: "",
expectContains: []string{
``,
`</script>`,
},
},
}
@@ -892,7 +875,7 @@ func TestJSONLD(t *testing.T) {
t.Fatalf("Templates(nil) error = %v", err)
}
templateStr := `{{ jsonld . }}`
templateStr := `{{ jsonldScript . }}`
buf := new(bytes.Buffer)
temp, err := tmpl.New("test").Parse(templateStr)
if err != nil {
@@ -908,56 +891,15 @@ func TestJSONLD(t *testing.T) {
for _, expected := range tt.expectContains {
if !strings.Contains(got, expected) {
t.Errorf("jsonld output missing expected %q\nGot: %s", expected, got)
t.Errorf("jsonldScript output missing expected %q\nGot: %s", expected, got)
}
}
for _, notExpected := range tt.expectMissing {
if strings.Contains(got, notExpected) {
t.Errorf("jsonld output should not contain %q\nGot: %s", notExpected, got)
t.Errorf("jsonldScript output should not contain %q\nGot: %s", notExpected, got)
}
}
})
}
}
func TestJSONLD_Indentation(t *testing.T) {
// Test that the indentation uses 8-space prefix (for alignment with <script> tag)
tmpl, err := Templates(nil)
if err != nil {
t.Fatalf("Templates(nil) error = %v", err)
}
input := struct {
Context string `json:"@context"`
Name string `json:"name"`
}{
Context: "https://schema.org",
Name: "Test",
}
templateStr := `{{ jsonld . }}`
buf := new(bytes.Buffer)
temp, err := tmpl.New("test").Parse(templateStr)
if err != nil {
t.Fatalf("Failed to parse template: %v", err)
}
err = temp.Execute(buf, input)
if err != nil {
t.Fatalf("Failed to execute template: %v", err)
}
got := buf.String()
// Check that lines after the first have 8-space prefix + 4-space indent
lines := strings.Split(got, "\n")
if len(lines) < 2 {
t.Fatalf("Expected multi-line output, got: %s", got)
}
// Second line should start with 8 spaces (prefix) + 4 spaces (indent) = 12 spaces
if len(lines[1]) < 12 || lines[1][:12] != " " {
t.Errorf("Expected line to start with 12 spaces (8 prefix + 4 indent), got: %q", lines[1])
}
}
+8
View File
@@ -269,6 +269,14 @@ const (
IdentityResolveHandle = "/xrpc/com.atproto.identity.resolveHandle"
)
// Appview metadata endpoint (io.atcr.*)
const (
// AppviewGetMetadata returns appview branding and configuration metadata.
// Method: GET
// Response: {"clientName": "...", "clientShortName": "...", "faviconUrl": "...", "registryDomains": [...]}
AppviewGetMetadata = "/xrpc/io.atcr.getMetadata"
)
// Bluesky app endpoints (app.bsky.actor.*)
//
// Bluesky-specific actor/profile endpoints.
-8
View File
@@ -872,14 +872,6 @@ func NewHoldWebhookRecord(userDID string, triggers int) *HoldWebhookRecord {
}
}
// WebhookRecordKey generates a deterministic rkey for a webhook record
// Uses hash of userDID + sequence number to support multiple webhooks per user
func WebhookRecordKey(userDID string, seq int) string {
combined := fmt.Sprintf("%s/webhook/%d", userDID, seq)
hash := sha256.Sum256([]byte(combined))
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]))
}
// 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
+65
View File
@@ -0,0 +1,65 @@
package atproto
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
// AppviewMetadata contains branding and configuration from the appview.
type AppviewMetadata struct {
ClientName string `json:"clientName"`
ClientShortName string `json:"clientShortName"`
BaseURL string `json:"baseUrl"`
FaviconURL string `json:"faviconUrl"`
RegistryDomains []string `json:"registryDomains,omitempty"`
}
// FetchAppviewMetadata fetches metadata from the appview's XRPC endpoint.
func FetchAppviewMetadata(ctx context.Context, appviewURL string) (*AppviewMetadata, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
reqURL := appviewURL + AppviewGetMetadata
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, 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 metadata: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("metadata endpoint returned status %d", resp.StatusCode)
}
var meta AppviewMetadata
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
return nil, fmt.Errorf("failed to decode metadata: %w", err)
}
return &meta, nil
}
// DefaultAppviewMetadata returns fallback metadata derived from the appview URL.
func DefaultAppviewMetadata(appviewURL string) AppviewMetadata {
hostname := "ATCR"
if u, err := url.Parse(appviewURL); err == nil && u.Hostname() != "" {
hostname = u.Hostname()
}
faviconURL := appviewURL + "/favicon-96x96.png"
return AppviewMetadata{
ClientName: hostname,
ClientShortName: hostname,
BaseURL: appviewURL,
FaviconURL: faviconURL,
}
}
+5 -5
View File
@@ -32,14 +32,14 @@ func TestMain(m *testing.M) {
// Create shared empty PDS (not bootstrapped)
emptyKeyPath := filepath.Join(sharedTempDir, "empty-key")
sharedEmptyPDS, err = pds.NewHoldPDS(ctx, "did:web:hold.example.com", "http://hold.example.com", ":memory:", emptyKeyPath, false)
sharedEmptyPDS, err = pds.NewHoldPDS(ctx, "did:web:hold.example.com", "http://hold.example.com", "https://atcr.io", ":memory:", emptyKeyPath, false)
if err != nil {
panic(err)
}
// Create shared public PDS
publicKeyPath := filepath.Join(sharedTempDir, "public-key")
sharedPublicPDS, err = pds.NewHoldPDS(ctx, "did:web:hold.example.com", "http://hold.example.com", ":memory:", publicKeyPath, false)
sharedPublicPDS, err = pds.NewHoldPDS(ctx, "did:web:hold.example.com", "http://hold.example.com", "https://atcr.io", ":memory:", publicKeyPath, false)
if err != nil {
panic(err)
}
@@ -50,7 +50,7 @@ func TestMain(m *testing.M) {
// Create shared private PDS
privateKeyPath := filepath.Join(sharedTempDir, "private-key")
sharedPrivatePDS, err = pds.NewHoldPDS(ctx, "did:web:hold.example.com", "http://hold.example.com", ":memory:", privateKeyPath, false)
sharedPrivatePDS, err = pds.NewHoldPDS(ctx, "did:web:hold.example.com", "http://hold.example.com", "https://atcr.io", ":memory:", privateKeyPath, false)
if err != nil {
panic(err)
}
@@ -61,7 +61,7 @@ func TestMain(m *testing.M) {
// Create shared allowAllCrew PDS
allowCrewKeyPath := filepath.Join(sharedTempDir, "allowcrew-key")
sharedAllowCrewPDS, err = pds.NewHoldPDS(ctx, "did:web:hold.example.com", "http://hold.example.com", ":memory:", allowCrewKeyPath, false)
sharedAllowCrewPDS, err = pds.NewHoldPDS(ctx, "did:web:hold.example.com", "http://hold.example.com", "https://atcr.io", ":memory:", allowCrewKeyPath, false)
if err != nil {
panic(err)
}
@@ -86,7 +86,7 @@ func createTestHoldPDS(t *testing.T, ownerDID string, public bool, allowAllCrew
keyPath := filepath.Join(tmpDir, "signing-key")
// Create in-memory PDS
holdPDS, err := pds.NewHoldPDS(ctx, "did:web:hold.example.com", "http://hold.example.com", ":memory:", keyPath, false)
holdPDS, err := pds.NewHoldPDS(ctx, "did:web:hold.example.com", "http://hold.example.com", "https://atcr.io", ":memory:", keyPath, false)
if err != nil {
t.Fatalf("Failed to create test HoldPDS: %v", err)
}
+4
View File
@@ -128,6 +128,9 @@ 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\"."`
// ReadTimeout for HTTP requests.
ReadTimeout time.Duration `yaml:"read_timeout" comment:"Read timeout for HTTP requests."`
@@ -186,6 +189,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.read_timeout", "5m")
v.SetDefault("server.write_timeout", "5m")
+2 -2
View File
@@ -93,7 +93,7 @@ func setupTestOCIHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Client
t.Fatalf("Failed to copy shared signing key: %v", err)
}
holdPDS, err := pds.NewHoldPDS(ctx, holdDID, publicURL, dbPath, keyPath, false)
holdPDS, err := pds.NewHoldPDS(ctx, holdDID, publicURL, "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create PDS: %v", err)
}
@@ -178,7 +178,7 @@ func setupTestOCIHandlerWithS3(t *testing.T) (*XRPCHandler, bool) {
t.Fatalf("Failed to copy shared signing key: %v", err)
}
holdPDS, err := pds.NewHoldPDS(ctx, holdDID, publicURL, dbPath, keyPath, false)
holdPDS, err := pds.NewHoldPDS(ctx, holdDID, publicURL, "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create PDS: %v", err)
}
+1 -1
View File
@@ -28,7 +28,7 @@ func setupTestPDS(t *testing.T) (*HoldPDS, context.Context) {
t.Fatalf("Failed to copy shared signing key: %v", err)
}
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create test PDS: %v", err)
}
+4 -4
View File
@@ -84,7 +84,7 @@ func TestGenerateDIDDocument(t *testing.T) {
keyPath := filepath.Join(tmpDir, "signing-key")
publicURL := "https://hold.example.com"
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create PDS: %v", err)
}
@@ -183,7 +183,7 @@ func TestGenerateDIDDocument_WithPort(t *testing.T) {
keyPath := filepath.Join(tmpDir, "signing-key")
publicURL := "https://hold.example.com:8443"
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com%3A8443", publicURL, dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com%3A8443", publicURL, "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create PDS: %v", err)
}
@@ -213,7 +213,7 @@ func TestMarshalDIDDocument(t *testing.T) {
keyPath := filepath.Join(tmpDir, "signing-key")
publicURL := "https://hold.example.com"
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create PDS: %v", err)
}
@@ -261,7 +261,7 @@ func TestGenerateDIDDocument_InvalidURL(t *testing.T) {
keyPath := filepath.Join(tmpDir, "signing-key")
publicURL := "https://hold.example.com"
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create PDS: %v", err)
}
+1 -1
View File
@@ -302,7 +302,7 @@ func setupTestPDSWithIndex(t *testing.T, ownerDID string) *HoldPDS {
t.Fatalf("Failed to copy shared signing key: %v", err)
}
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create test PDS: %v", err)
}
+7 -6
View File
@@ -28,7 +28,7 @@ func (p *HoldPDS) CreateManifestPost(
now := time.Now()
// Build AppView repository URL
appViewURL := fmt.Sprintf("https://atcr.io/r/%s/%s", userHandle, repository)
appViewURL := fmt.Sprintf("%s/r/%s/%s", p.appviewURL, userHandle, repository)
// Build simplified text with mention - OG card handles the link
repoWithTag := fmt.Sprintf("%s:%s", repository, tag)
@@ -45,7 +45,7 @@ func (p *HoldPDS) CreateManifestPost(
// Build embed with OG card
var embed *bsky.FeedPost_Embed
ogImageData, err := fetchOGImage(ctx, userHandle, repository)
ogImageData, err := fetchOGImage(ctx, p.appviewURL, userHandle, repository)
if err != nil {
slog.Warn("Failed to fetch OG image, posting without embed", "error", err)
} else {
@@ -55,13 +55,14 @@ func (p *HoldPDS) CreateManifestPost(
slog.Warn("Failed to upload OG image blob", "error", err)
} else {
// Build dynamic description
brandName := p.AppviewMeta().ClientShortName
var description string
if artifactType == "helm-chart" {
description = "Helm chart pushed to ATCR"
description = "Helm chart pushed to " + brandName
} else if len(platforms) > 0 {
description = fmt.Sprintf("Multi-arch: %s", strings.Join(platforms, ", "))
} else {
description = fmt.Sprintf("Pushed %s to ATCR", formatSize(totalSize))
description = fmt.Sprintf("Pushed %s to %s", formatSize(totalSize), brandName)
}
embed = &bsky.FeedPost_Embed{
@@ -111,8 +112,8 @@ func (p *HoldPDS) CreateManifestPost(
}
// fetchOGImage downloads the OG card image from AppView
func fetchOGImage(ctx context.Context, userHandle, repository string) ([]byte, error) {
url := fmt.Sprintf("https://atcr.io/og/r/%s/%s", userHandle, repository)
func fetchOGImage(ctx context.Context, appviewURL, userHandle, repository string) ([]byte, error) {
url := fmt.Sprintf("%s/og/r/%s/%s", appviewURL, userHandle, repository)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
+5
View File
@@ -86,6 +86,11 @@ type RepoManager struct {
clk *syntax.TIDClock
}
// NextTID generates a new TID for use as a record key.
func (rm *RepoManager) NextTID() string {
return rm.clk.Next().String()
}
type ActorInfo struct {
Did string
Handle string
+4 -3
View File
@@ -476,11 +476,12 @@ func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage)
repository string
tag string
userDID string
userHandle string
)
err := sb.db.QueryRow(`
SELECT manifest_digest, repository, tag, user_did
SELECT manifest_digest, repository, tag, user_did, COALESCE(user_handle, '')
FROM scan_jobs WHERE seq = ?
`, msg.Seq).Scan(&manifestDigest, &repository, &tag, &userDID)
`, msg.Seq).Scan(&manifestDigest, &repository, &tag, &userDID, &userHandle)
if err != nil {
slog.Error("Failed to get job details for result storage",
"seq", msg.Seq,
@@ -545,7 +546,7 @@ func (sb *ScanBroadcaster) handleResult(sub *ScanSubscriber, msg ScannerMessage)
}
// Dispatch webhooks after scan record is stored
go sb.dispatchWebhooks(manifestDigest, repository, tag, userDID, msg.Summary, previousScan)
go sb.dispatchWebhooks(manifestDigest, repository, tag, userDID, userHandle, msg.Summary, previousScan)
}
// Mark job as completed
+22 -2
View File
@@ -39,6 +39,8 @@ func init() {
type HoldPDS struct {
did string
PublicURL string
appviewURL string
appviewMeta *atproto.AppviewMetadata
carstore holddb.CarStore
repomgr *RepoManager
dbPath string
@@ -48,8 +50,24 @@ type HoldPDS struct {
recordsIndex *RecordsIndex
}
// AppviewURL returns the configured appview base URL for links in webhooks and posts.
func (p *HoldPDS) AppviewURL() string { return p.appviewURL }
// AppviewMeta returns cached appview metadata, or defaults derived from the appview URL.
func (p *HoldPDS) AppviewMeta() atproto.AppviewMetadata {
if p.appviewMeta != nil {
return *p.appviewMeta
}
return atproto.DefaultAppviewMetadata(p.appviewURL)
}
// SetAppviewMeta caches appview metadata fetched on startup.
func (p *HoldPDS) SetAppviewMeta(m *atproto.AppviewMetadata) {
p.appviewMeta = m
}
// NewHoldPDS creates or opens a hold PDS with SQLite carstore
func NewHoldPDS(ctx context.Context, did, publicURL, dbPath, keyPath string, enableBlueskyPosts bool) (*HoldPDS, error) {
func NewHoldPDS(ctx context.Context, did, publicURL, appviewURL, dbPath, keyPath string, enableBlueskyPosts bool) (*HoldPDS, error) {
// Generate or load signing key
signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath)
if err != nil {
@@ -116,6 +134,7 @@ func NewHoldPDS(ctx context.Context, did, publicURL, dbPath, keyPath string, ena
return &HoldPDS{
did: did,
PublicURL: publicURL,
appviewURL: appviewURL,
carstore: cs,
repomgr: rm,
dbPath: dbPath,
@@ -129,7 +148,7 @@ func NewHoldPDS(ctx context.Context, did, publicURL, dbPath, keyPath string, ena
// NewHoldPDSWithDB creates or opens a hold PDS using an existing *sql.DB connection.
// The caller is responsible for the DB lifecycle. Used when the database is
// centrally managed (e.g., with libsql embedded replicas).
func NewHoldPDSWithDB(ctx context.Context, did, publicURL, dbPath, keyPath string, enableBlueskyPosts bool, db *sql.DB) (*HoldPDS, error) {
func NewHoldPDSWithDB(ctx context.Context, did, publicURL, appviewURL, dbPath, keyPath string, enableBlueskyPosts bool, db *sql.DB) (*HoldPDS, error) {
signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath)
if err != nil {
return nil, fmt.Errorf("failed to initialize signing key: %w", err)
@@ -161,6 +180,7 @@ func NewHoldPDSWithDB(ctx context.Context, did, publicURL, dbPath, keyPath strin
return &HoldPDS{
did: did,
PublicURL: publicURL,
appviewURL: appviewURL,
carstore: cs,
repomgr: rm,
dbPath: dbPath,
+21 -21
View File
@@ -23,7 +23,7 @@ func TestNewHoldPDS_NewRepo(t *testing.T) {
did := "did:web:hold.example.com"
publicURL := "https://hold.example.com"
pds, err := NewHoldPDS(ctx, did, publicURL, dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, did, publicURL, "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -62,7 +62,7 @@ func TestNewHoldPDS_ExistingRepo(t *testing.T) {
publicURL := "https://hold.example.com"
// Create first PDS instance and bootstrap it
pds1, err := NewHoldPDS(ctx, did, publicURL, dbPath, keyPath, false)
pds1, err := NewHoldPDS(ctx, did, publicURL, "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("First NewHoldPDS failed: %v", err)
}
@@ -86,7 +86,7 @@ func TestNewHoldPDS_ExistingRepo(t *testing.T) {
pds1.Close()
// Re-open the same database
pds2, err := NewHoldPDS(ctx, did, publicURL, dbPath, keyPath, false)
pds2, err := NewHoldPDS(ctx, did, publicURL, "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Second NewHoldPDS failed: %v", err)
}
@@ -118,7 +118,7 @@ func TestBootstrap_NewRepo(t *testing.T) {
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -195,7 +195,7 @@ func TestBootstrap_Idempotent(t *testing.T) {
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -261,7 +261,7 @@ func TestBootstrap_EmptyOwner(t *testing.T) {
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -294,7 +294,7 @@ func TestLexiconTypeRegistration(t *testing.T) {
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -344,7 +344,7 @@ func TestBootstrap_DidWebOwner(t *testing.T) {
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
pds, err := NewHoldPDS(ctx, "did:web:hold01.atcr.io", "https://hold01.atcr.io", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold01.atcr.io", "https://hold01.atcr.io", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -406,7 +406,7 @@ func TestBootstrap_MixedDIDs(t *testing.T) {
// Create hold with did:web
holdDID := "did:web:hold.example.com"
pds, err := NewHoldPDS(ctx, holdDID, "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, holdDID, "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -474,7 +474,7 @@ func TestBootstrap_CrewWithoutCaptain(t *testing.T) {
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -545,7 +545,7 @@ func TestBootstrap_CaptainWithoutCrew(t *testing.T) {
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -629,7 +629,7 @@ func TestHoldPDS_RecordsIndex_Nil(t *testing.T) {
keyPath := filepath.Join(tmpDir, "signing-key")
// Create with :memory: database
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", ":memory:", keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", ":memory:", keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -649,7 +649,7 @@ func TestHoldPDS_RecordsIndex_NonNil(t *testing.T) {
keyPath := filepath.Join(tmpDir, "signing-key")
// Create with file database
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -667,7 +667,7 @@ func TestHoldPDS_Carstore(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "signing-key")
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", ":memory:", keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", ":memory:", keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -684,7 +684,7 @@ func TestHoldPDS_UID(t *testing.T) {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "signing-key")
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", ":memory:", keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", ":memory:", keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -703,7 +703,7 @@ func TestHoldPDS_CreateRecordsIndexEventHandler(t *testing.T) {
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -760,7 +760,7 @@ func TestHoldPDS_CreateRecordsIndexEventHandler_Delete(t *testing.T) {
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -812,7 +812,7 @@ func TestHoldPDS_CreateRecordsIndexEventHandler_NilBroadcaster(t *testing.T) {
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -848,7 +848,7 @@ func TestHoldPDS_BackfillRecordsIndex(t *testing.T) {
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -894,7 +894,7 @@ func TestHoldPDS_BackfillRecordsIndex_NilIndex(t *testing.T) {
keyPath := filepath.Join(tmpDir, "signing-key")
// Use :memory: to get nil index
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", ":memory:", keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", ":memory:", keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
@@ -914,7 +914,7 @@ func TestHoldPDS_BackfillRecordsIndex_SkipsWhenSynced(t *testing.T) {
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("NewHoldPDS failed: %v", err)
}
+2 -2
View File
@@ -43,7 +43,7 @@ func TestStatusPost(t *testing.T) {
did := "did:web:test.example.com"
publicURL := "https://test.example.com"
holdPDS, err := NewHoldPDS(ctx, did, publicURL, dbPath, keyPath, true)
holdPDS, err := NewHoldPDS(ctx, did, publicURL, "https://atcr.io", dbPath, keyPath, true)
if err != nil {
t.Fatalf("Failed to create test PDS: %v", err)
}
@@ -270,7 +270,7 @@ func TestMain(m *testing.M) {
// Create one shared, bootstrapped PDS for read-only tests
// Use in-memory database for speed
sharedCtx = context.Background()
sharedPDS, err = NewHoldPDS(sharedCtx, "did:web:hold.example.com", "https://hold.example.com", ":memory:", sharedTestKeyPath, true)
sharedPDS, err = NewHoldPDS(sharedCtx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", ":memory:", sharedTestKeyPath, true)
if err != nil {
panic(fmt.Sprintf("Failed to create shared PDS: %v", err))
}
+212 -18
View File
@@ -7,7 +7,9 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"math/rand/v2"
"net/http"
"net/url"
"strings"
@@ -52,6 +54,7 @@ type WebhookManifestInfo struct {
Repository string `json:"repository"`
Tag string `json:"tag"`
UserDID string `json:"userDid"`
UserHandle string `json:"userHandle,omitempty"`
}
// WebhookScanInfo describes the scan results
@@ -149,16 +152,8 @@ func (sb *ScanBroadcaster) ListWebhookConfigs(userDID string) ([]webhookConfig,
func (sb *ScanBroadcaster) AddWebhookConfig(userDID, webhookURL, secret string, triggers int) (string, cid.Cid, error) {
ctx := context.Background()
// Find next available sequence number for this user
var maxSeq int
err := sb.db.QueryRow(`
SELECT COUNT(*) FROM webhook_secrets WHERE user_did = ?
`, userDID).Scan(&maxSeq)
if err != nil {
return "", cid.Undef, fmt.Errorf("failed to count existing webhooks: %w", err)
}
rkey := atproto.WebhookRecordKey(userDID, maxSeq)
// Use TID for rkey — avoids collisions after delete+re-add
rkey := sb.pds.repomgr.NextTID()
// Create PDS record
record := atproto.NewHoldWebhookRecord(userDID, triggers)
@@ -238,7 +233,7 @@ func (sb *ScanBroadcaster) GetWebhooksForUser(userDID string) ([]activeWebhook,
}
// dispatchWebhooks fires matching webhooks after a scan completes
func (sb *ScanBroadcaster) dispatchWebhooks(manifestDigest, repository, tag, userDID string, summary *VulnerabilitySummary, previousScan *atproto.ScanRecord) {
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
@@ -264,6 +259,7 @@ func (sb *ScanBroadcaster) dispatchWebhooks(manifestDigest, repository, tag, use
Repository: repository,
Tag: tag,
UserDID: userDID,
UserHandle: userHandle,
}
for _, wh := range webhooks {
@@ -329,19 +325,38 @@ func (sb *ScanBroadcaster) attemptDelivery(webhookURL, secret string, payload []
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, strings.NewReader(string(payload)))
// 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", "ATCR-Webhook/1.0")
req.Header.Set("User-Agent", meta.ClientShortName+"-Webhook/1.0")
// HMAC signing if secret is set
// HMAC signing if secret is set (signs the actual payload sent)
if secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
mac.Write(sendPayload)
sig := hex.EncodeToString(mac.Sum(nil))
req.Header.Set("X-Webhook-Signature-256", "sha256="+sig)
}
@@ -349,7 +364,7 @@ func (sb *ScanBroadcaster) attemptDelivery(webhookURL, secret string, payload []
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
slog.Debug("Webhook delivery attempt failed", "url", maskURL(webhookURL), "error", err)
slog.Warn("Webhook delivery attempt failed", "url", maskURL(webhookURL), "error", err)
return false
}
defer resp.Body.Close()
@@ -359,7 +374,12 @@ func (sb *ScanBroadcaster) attemptDelivery(webhookURL, secret string, payload []
return true
}
slog.Debug("Webhook delivery got non-2xx response", "url", maskURL(webhookURL), "status", resp.StatusCode)
// 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
}
@@ -387,6 +407,164 @@ func maskURL(rawURL string) string {
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)
@@ -594,6 +772,21 @@ func (h *XRPCHandler) HandleTestWebhook(w http.ResponseWriter, r *http.Request)
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",
@@ -604,12 +797,13 @@ func (h *XRPCHandler) HandleTestWebhook(w http.ResponseWriter, r *http.Request)
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: 0, High: 1, Medium: 3, Low: 5, Total: 9,
Critical: critical, High: high, Medium: medium, Low: low, Total: total,
},
},
}
+4 -4
View File
@@ -43,7 +43,7 @@ func setupTestXRPCHandler(t *testing.T) (*XRPCHandler, context.Context) {
t.Fatalf("Failed to copy shared signing key: %v", err)
}
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create test PDS: %v", err)
}
@@ -96,7 +96,7 @@ func setupTestXRPCHandlerWithIndex(t *testing.T) (*XRPCHandler, context.Context)
t.Fatalf("Failed to copy shared signing key: %v", err)
}
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create test PDS: %v", err)
}
@@ -1995,7 +1995,7 @@ func setupTestXRPCHandlerWithMockS3(t *testing.T) (*XRPCHandler, *s3.MockS3Clien
t.Fatalf("Failed to copy shared signing key: %v", err)
}
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create test PDS: %v", err)
}
@@ -2052,7 +2052,7 @@ func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *s3.MockS3Client
t.Fatalf("Failed to copy shared signing key: %v", err)
}
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create test PDS: %v", err)
}
+13 -2
View File
@@ -105,7 +105,7 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
}
// Use shared DB for all subsystems
s.PDS, err = pds.NewHoldPDSWithDB(ctx, holdDID, cfg.Server.PublicURL, 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 +113,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.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)
}
@@ -334,6 +334,17 @@ 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)
if err != nil {
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)
}
}
// Request crawl from relay to make PDS discoverable
if s.Config.Server.RelayEndpoint != "" {
slog.Info("Requesting crawl from relay", "relay", s.Config.Server.RelayEndpoint)