mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
more work on webhook, implement getMetadata endpoint for appview and link holds to a preferred appview
This commit is contained in:
+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.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)
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -21,6 +21,7 @@ server:
|
||||
successor: ""
|
||||
test_mode: false
|
||||
relay_endpoint: ""
|
||||
appview_url: https://seamark.dev
|
||||
read_timeout: 5m0s
|
||||
write_timeout: 5m0s
|
||||
registration:
|
||||
|
||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
`"`, // 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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user