From 23303c21874d9f1c1f8bc294f5f093e32fc418ab Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Fri, 19 Dec 2025 10:24:09 -0600 Subject: [PATCH] have Holds post with new og card --- pkg/hold/oci/xrpc.go | 1 + pkg/hold/pds/manifest_post.go | 165 ++++++++-------- pkg/hold/pds/manifest_post_test.go | 294 +++++++++++------------------ 3 files changed, 196 insertions(+), 264 deletions(-) diff --git a/pkg/hold/oci/xrpc.go b/pkg/hold/oci/xrpc.go index 84800c3..8a4b68a 100644 --- a/pkg/hold/oci/xrpc.go +++ b/pkg/hold/oci/xrpc.go @@ -317,6 +317,7 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques postURI, err = h.pds.CreateManifestPost( ctx, + h.driver, req.Repository, req.Tag, req.UserHandle, diff --git a/pkg/hold/pds/manifest_post.go b/pkg/hold/pds/manifest_post.go index c07b1ff..2bc73d2 100644 --- a/pkg/hold/pds/manifest_post.go +++ b/pkg/hold/pds/manifest_post.go @@ -3,18 +3,21 @@ package pds import ( "context" "fmt" + "io" "log/slog" + "net/http" "strings" "time" bsky "github.com/bluesky-social/indigo/api/bsky" + "github.com/distribution/distribution/v3/registry/storage/driver" ) // CreateManifestPost creates a Bluesky post announcing a manifest upload -// Includes facets for clickable mentions and links -// For multi-arch images (platforms non-empty), shows platforms instead of size +// Includes mention facet for the user and an OG card embed with thumbnail func (p *HoldPDS) CreateManifestPost( ctx context.Context, + storageDriver driver.StorageDriver, repository, tag, userHandle, userDID, digest string, totalSize int64, platforms []string, @@ -24,31 +27,55 @@ func (p *HoldPDS) CreateManifestPost( // Build AppView repository URL appViewURL := fmt.Sprintf("https://atcr.io/r/%s/%s", userHandle, repository) - // Format post text components - digestShort := formatDigest(digest) + // Build simplified text with mention - OG card handles the link repoWithTag := fmt.Sprintf("%s:%s", repository, tag) + text := fmt.Sprintf("@%s pushed %s", userHandle, repoWithTag) - // Build text based on whether this is multi-arch or single-arch - var text string - if len(platforms) > 0 { - // Multi-arch: show platforms - platformsStr := strings.Join(platforms, ", ") - text = fmt.Sprintf("@%s just pushed %s\nDigest: %s Platforms: %s", userHandle, repoWithTag, digestShort, platformsStr) + // Only build mention facet - the OG card embed provides the link + facets := buildMentionFacet(text, userHandle, userDID) + + // Build embed with OG card + var embed *bsky.FeedPost_Embed + + ogImageData, err := fetchOGImage(ctx, userHandle, repository) + if err != nil { + slog.Warn("Failed to fetch OG image, posting without embed", "error", err) } else { - // Single-arch: show size - sizeStr := formatSize(totalSize) - text = fmt.Sprintf("@%s just pushed %s\nDigest: %s Size: %s", userHandle, repoWithTag, digestShort, sizeStr) + // Upload OG image as blob + thumbBlob, err := uploadBlobToStorage(ctx, storageDriver, p.did, ogImageData, "image/png") + if err != nil { + slog.Warn("Failed to upload OG image blob", "error", err) + } else { + // Build dynamic description + var description string + if len(platforms) > 0 { + description = fmt.Sprintf("Multi-arch: %s", strings.Join(platforms, ", ")) + } else { + description = fmt.Sprintf("Pushed %s to ATCR", formatSize(totalSize)) + } + + embed = &bsky.FeedPost_Embed{ + EmbedExternal: &bsky.EmbedExternal{ + LexiconTypeID: "app.bsky.embed.external", + External: &bsky.EmbedExternal_External{ + Uri: appViewURL, + Title: fmt.Sprintf("%s/%s:%s", userHandle, repository, tag), + Description: description, + Thumb: thumbBlob, + }, + }, + } + } } - // Create facets for mentions and links - facets := buildFacets(text, userHandle, userDID, repoWithTag, appViewURL) - - // Create post struct with facets + // Create post struct with facets and embed post := &bsky.FeedPost{ LexiconTypeID: "app.bsky.feed.post", Text: text, Facets: facets, + Embed: embed, CreatedAt: now.Format(time.RFC3339), + Langs: []string{"en"}, } // Create record with auto-generated TID @@ -73,19 +100,52 @@ func (p *HoldPDS) CreateManifestPost( return postURI, nil } -// formatDigest truncates digest to first 10 chars -// Example: sha256:abc1234567890fedcba9876543210 -> sha256:abc1234567... -func formatDigest(digest string) string { - if !strings.HasPrefix(digest, "sha256:") { - return digest // Return as-is if not sha256 +// 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) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err } - hash := strings.TrimPrefix(digest, "sha256:") - if len(hash) <= 10 { - return digest // Too short to truncate + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("OG image fetch failed: %d", resp.StatusCode) } - return fmt.Sprintf("sha256:%s...", hash[:10]) + return io.ReadAll(resp.Body) +} + +// buildMentionFacet creates a mention facet for the user handle +// IMPORTANT: Byte offsets must be calculated for UTF-8 encoded text +func buildMentionFacet(text, userHandle, userDID string) []*bsky.RichtextFacet { + mentionText := "@" + userHandle + mentionStart := strings.Index(text, mentionText) + if mentionStart < 0 { + return nil + } + + byteStart := int64(len(text[:mentionStart])) + byteEnd := int64(len(text[:mentionStart+len(mentionText)])) + + return []*bsky.RichtextFacet{{ + Index: &bsky.RichtextFacet_ByteSlice{ + ByteStart: byteStart, + ByteEnd: byteEnd, + }, + Features: []*bsky.RichtextFacet_Features_Elem{{ + RichtextFacet_Mention: &bsky.RichtextFacet_Mention{ + Did: userDID, + }, + }}, + }} } // formatSize converts bytes to human-readable format @@ -108,56 +168,3 @@ func formatSize(bytes int64) string { return fmt.Sprintf("%d B", bytes) } } - -// buildFacets creates mention and link facets for rich text -// IMPORTANT: Byte offsets must be calculated for UTF-8 encoded text -func buildFacets(text, userHandle, userDID, repoWithTag, appViewURL string) []*bsky.RichtextFacet { - facets := []*bsky.RichtextFacet{} - - // Find mention: "@alice.bsky.social" - mentionText := "@" + userHandle - mentionStart := strings.Index(text, mentionText) - if mentionStart >= 0 { - // Calculate byte offsets (not character offsets!) - byteStart := int64(len(text[:mentionStart])) - byteEnd := int64(len(text[:mentionStart+len(mentionText)])) - - facets = append(facets, &bsky.RichtextFacet{ - Index: &bsky.RichtextFacet_ByteSlice{ - ByteStart: byteStart, - ByteEnd: byteEnd, - }, - Features: []*bsky.RichtextFacet_Features_Elem{ - { - RichtextFacet_Mention: &bsky.RichtextFacet_Mention{ - Did: userDID, - }, - }, - }, - }) - } - - // Find repository link: "hsm-secrets-operator:latest" - linkStart := strings.Index(text, repoWithTag) - if linkStart >= 0 { - // Calculate byte offsets - byteStart := int64(len(text[:linkStart])) - byteEnd := int64(len(text[:linkStart+len(repoWithTag)])) - - facets = append(facets, &bsky.RichtextFacet{ - Index: &bsky.RichtextFacet_ByteSlice{ - ByteStart: byteStart, - ByteEnd: byteEnd, - }, - Features: []*bsky.RichtextFacet_Features_Elem{ - { - RichtextFacet_Link: &bsky.RichtextFacet_Link{ - Uri: appViewURL, - }, - }, - }, - }) - } - - return facets -} diff --git a/pkg/hold/pds/manifest_post_test.go b/pkg/hold/pds/manifest_post_test.go index 4d2c409..f602686 100644 --- a/pkg/hold/pds/manifest_post_test.go +++ b/pkg/hold/pds/manifest_post_test.go @@ -7,44 +7,6 @@ import ( bsky "github.com/bluesky-social/indigo/api/bsky" ) -func TestFormatDigest(t *testing.T) { - tests := []struct { - name string - digest string - expected string - }{ - { - name: "standard sha256 digest", - digest: "sha256:abc1234567890fedcba9876543210", - expected: "sha256:abc1234567...", // First 10 chars - }, - { - name: "short digest (no truncation)", - digest: "sha256:abc123", - expected: "sha256:abc123", - }, - { - name: "non-sha256 digest", - digest: "sha512:abc123", - expected: "sha512:abc123", - }, - { - name: "real sha256 digest", - digest: "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f", - expected: "sha256:e692418e4c...", // First 10 chars - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := formatDigest(tt.digest) - if result != tt.expected { - t.Errorf("formatDigest(%q) = %q, want %q", tt.digest, result, tt.expected) - } - }) - } -} - func TestFormatSize(t *testing.T) { tests := []struct { name string @@ -103,56 +65,47 @@ func TestFormatSize(t *testing.T) { } } -func TestBuildFacets(t *testing.T) { +func TestBuildMentionFacet(t *testing.T) { tests := []struct { - name string - text string - userHandle string - userDID string - repoWithTag string - appViewURL string - wantFacets int // number of facets expected + name string + text string + userHandle string + userDID string + wantFacets int // number of facets expected }{ { - name: "standard post with mention and link", - text: "@alice.bsky.social just pushed myapp:latest\nDigest: sha256:abc...def Size: 12.2 MB", - userHandle: "alice.bsky.social", - userDID: "did:plc:alice123", - repoWithTag: "myapp:latest", - appViewURL: "https://atcr.io/r/alice.bsky.social/myapp", - wantFacets: 2, + name: "standard post with mention", + text: "@alice.bsky.social pushed myapp:latest", + userHandle: "alice.bsky.social", + userDID: "did:plc:alice123", + wantFacets: 1, }, { - name: "no matches found", - text: "random text", - userHandle: "alice.bsky.social", - userDID: "did:plc:alice123", - repoWithTag: "myapp:latest", - appViewURL: "https://atcr.io/r/alice.bsky.social/myapp", - wantFacets: 0, + name: "no mention found", + text: "random text", + userHandle: "alice.bsky.social", + userDID: "did:plc:alice123", + wantFacets: 0, }, { - name: "only mention found", - text: "@alice.bsky.social did something", - userHandle: "alice.bsky.social", - userDID: "did:plc:alice123", - repoWithTag: "myapp:latest", - appViewURL: "https://atcr.io/r/alice.bsky.social/myapp", - wantFacets: 1, + name: "mention at start", + text: "@alice.bsky.social did something", + userHandle: "alice.bsky.social", + userDID: "did:plc:alice123", + wantFacets: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - facets := buildFacets(tt.text, tt.userHandle, tt.userDID, tt.repoWithTag, tt.appViewURL) + facets := buildMentionFacet(tt.text, tt.userHandle, tt.userDID) if len(facets) != tt.wantFacets { - t.Errorf("buildFacets() returned %d facets, want %d", len(facets), tt.wantFacets) + t.Errorf("buildMentionFacet() returned %d facets, want %d", len(facets), tt.wantFacets) } // Verify facet structure for standard case - if tt.name == "standard post with mention and link" && len(facets) == 2 { - // Check mention facet + if tt.wantFacets > 0 && len(facets) > 0 { mentionFacet := facets[0] if mentionFacet.Index == nil { t.Error("mention facet has nil Index") @@ -163,38 +116,24 @@ func TestBuildFacets(t *testing.T) { if mentionFacet.Features[0].RichtextFacet_Mention == nil { t.Error("mention facet feature is not a mention") } - - // Check link facet - linkFacet := facets[1] - if linkFacet.Index == nil { - t.Error("link facet has nil Index") - } - if len(linkFacet.Features) != 1 { - t.Errorf("link facet has %d features, want 1", len(linkFacet.Features)) - } - if linkFacet.Features[0].RichtextFacet_Link == nil { - t.Error("link facet feature is not a link") - } - if linkFacet.Features[0].RichtextFacet_Link.Uri != tt.appViewURL { - t.Errorf("link facet URI = %q, want %q", linkFacet.Features[0].RichtextFacet_Link.Uri, tt.appViewURL) + if mentionFacet.Features[0].RichtextFacet_Mention.Did != tt.userDID { + t.Errorf("mention DID = %q, want %q", mentionFacet.Features[0].RichtextFacet_Mention.Did, tt.userDID) } } }) } } -func TestBuildFacets_ByteOffsets(t *testing.T) { +func TestBuildMentionFacet_ByteOffsets(t *testing.T) { // Test that byte offsets are correctly calculated - text := "@alice.bsky.social just pushed myapp:latest" + text := "@alice.bsky.social pushed myapp:latest" userHandle := "alice.bsky.social" userDID := "did:plc:alice123" - repoWithTag := "myapp:latest" - appViewURL := "https://atcr.io/r/alice.bsky.social/myapp" - facets := buildFacets(text, userHandle, userDID, repoWithTag, appViewURL) + facets := buildMentionFacet(text, userHandle, userDID) - if len(facets) != 2 { - t.Fatalf("expected 2 facets, got %d", len(facets)) + if len(facets) != 1 { + t.Fatalf("expected 1 facet, got %d", len(facets)) } // Check mention facet byte offsets @@ -215,39 +154,18 @@ func TestBuildFacets_ByteOffsets(t *testing.T) { if extractedMention != mentionText { t.Errorf("extracted mention = %q, want %q", extractedMention, mentionText) } - - // Check link facet byte offsets - linkFacet := facets[1] - linkStart := len("@alice.bsky.social just pushed ") - expectedLinkStart := int64(linkStart) - expectedLinkEnd := int64(linkStart + len(repoWithTag)) - - if linkFacet.Index.ByteStart != expectedLinkStart { - t.Errorf("link ByteStart = %d, want %d", linkFacet.Index.ByteStart, expectedLinkStart) - } - if linkFacet.Index.ByteEnd != expectedLinkEnd { - t.Errorf("link ByteEnd = %d, want %d", linkFacet.Index.ByteEnd, expectedLinkEnd) - } - - // Verify the link text extraction - extractedLink := text[linkFacet.Index.ByteStart:linkFacet.Index.ByteEnd] - if extractedLink != repoWithTag { - t.Errorf("extracted link = %q, want %q", extractedLink, repoWithTag) - } } -func TestBuildFacets_UTF8Handling(t *testing.T) { +func TestBuildMentionFacet_UTF8Handling(t *testing.T) { // Test with Unicode characters to ensure byte offsets work correctly - text := "@alice.bsky.social just pushed 🚀myapp:latest" + text := "@alice.bsky.social pushed 🚀myapp:latest" userHandle := "alice.bsky.social" userDID := "did:plc:alice123" - repoWithTag := "🚀myapp:latest" // Note: emoji is multi-byte - appViewURL := "https://atcr.io/r/alice.bsky.social/myapp" - facets := buildFacets(text, userHandle, userDID, repoWithTag, appViewURL) + facets := buildMentionFacet(text, userHandle, userDID) - if len(facets) != 2 { - t.Fatalf("expected 2 facets, got %d", len(facets)) + if len(facets) != 1 { + t.Fatalf("expected 1 facet, got %d", len(facets)) } // Verify that byte extraction works with UTF-8 @@ -257,59 +175,23 @@ func TestBuildFacets_UTF8Handling(t *testing.T) { if extractedMention != expectedMention { t.Errorf("extracted mention = %q, want %q", extractedMention, expectedMention) } - - linkFacet := facets[1] - extractedLink := text[linkFacet.Index.ByteStart:linkFacet.Index.ByteEnd] - if extractedLink != repoWithTag { - t.Errorf("extracted link = %q, want %q", extractedLink, repoWithTag) - } } -func TestBuildFacets_NoOverlap(t *testing.T) { - // Ensure facets don't overlap - text := "@alice.bsky.social just pushed myapp:latest" - userHandle := "alice.bsky.social" - userDID := "did:plc:alice123" - repoWithTag := "myapp:latest" - appViewURL := "https://atcr.io/r/alice.bsky.social/myapp" - - facets := buildFacets(text, userHandle, userDID, repoWithTag, appViewURL) - - if len(facets) != 2 { - t.Fatalf("expected 2 facets, got %d", len(facets)) - } - - // Facets should not overlap - facet1 := facets[0] - facet2 := facets[1] - - if facet1.Index.ByteEnd > facet2.Index.ByteStart { - t.Errorf("facets overlap: facet1 ends at %d, facet2 starts at %d", - facet1.Index.ByteEnd, facet2.Index.ByteStart) - } -} - -func TestBuildFacets_RealWorldExample(t *testing.T) { - // Test with the actual example from the requirements +func TestSimplifiedPostFormat(t *testing.T) { + // Test the new simplified post format: "@user pushed repo:tag" repository := "hsm-secrets-operator" tag := "latest" userHandle := "evan.jarrett.net" userDID := "did:plc:pddp4xt5lgnv2qsegbzzs4xg" - digest := "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f" - totalSize := int64(12800000) // ~12.2 MB repoWithTag := repository + ":" + tag - digestShort := formatDigest(digest) - sizeStr := formatSize(totalSize) + text := "@" + userHandle + " pushed " + repoWithTag - text := "@" + userHandle + " just pushed " + repoWithTag + "\nDigest: " + digestShort + " Size: " + sizeStr - appViewURL := "https://atcr.io/r/" + userHandle + "/" + repository + facets := buildMentionFacet(text, userHandle, userDID) - facets := buildFacets(text, userHandle, userDID, repoWithTag, appViewURL) - - // Should have 2 facets: mention and link - if len(facets) != 2 { - t.Fatalf("expected 2 facets, got %d", len(facets)) + // Should have 1 facet: mention only (link is provided by embed) + if len(facets) != 1 { + t.Fatalf("expected 1 facet, got %d", len(facets)) } // Verify the complete post structure @@ -317,22 +199,21 @@ func TestBuildFacets_RealWorldExample(t *testing.T) { LexiconTypeID: "app.bsky.feed.post", Text: text, Facets: facets, + Langs: []string{"en"}, } if post.Text == "" { t.Error("post text is empty") } - if len(post.Facets) != 2 { - t.Errorf("post has %d facets, want 2", len(post.Facets)) + if len(post.Facets) != 1 { + t.Errorf("post has %d facets, want 1", len(post.Facets)) } // Verify text contains expected components expectedTexts := []string{ "@" + userHandle, repoWithTag, - digestShort, - sizeStr, } for _, expected := range expectedTexts { @@ -340,29 +221,31 @@ func TestBuildFacets_RealWorldExample(t *testing.T) { t.Errorf("post text missing expected component: %q", expected) } } + + // Verify post does NOT contain digest or size (now in embed description) + if strings.Contains(text, "Digest:") { + t.Error("simplified post should not contain Digest:") + } + if strings.Contains(text, "Size:") { + t.Error("simplified post should not contain Size:") + } } -func TestBuildFacets_MultiArchExample(t *testing.T) { - // Test with a multi-arch manifest (platforms instead of size) +func TestSimplifiedPostFormat_MultiArch(t *testing.T) { + // Test the new simplified post format for multi-arch images repository := "myapp" tag := "latest" userHandle := "alice.bsky.social" userDID := "did:plc:alice123" - digest := "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f" - platforms := []string{"linux/amd64", "linux/arm64"} repoWithTag := repository + ":" + tag - digestShort := formatDigest(digest) - platformsStr := strings.Join(platforms, ", ") + text := "@" + userHandle + " pushed " + repoWithTag - text := "@" + userHandle + " just pushed " + repoWithTag + "\nDigest: " + digestShort + " Platforms: " + platformsStr - appViewURL := "https://atcr.io/r/" + userHandle + "/" + repository + facets := buildMentionFacet(text, userHandle, userDID) - facets := buildFacets(text, userHandle, userDID, repoWithTag, appViewURL) - - // Should have 2 facets: mention and link - if len(facets) != 2 { - t.Fatalf("expected 2 facets, got %d", len(facets)) + // Should have 1 facet: mention only + if len(facets) != 1 { + t.Fatalf("expected 1 facet, got %d", len(facets)) } // Verify the complete post structure @@ -370,6 +253,7 @@ func TestBuildFacets_MultiArchExample(t *testing.T) { LexiconTypeID: "app.bsky.feed.post", Text: text, Facets: facets, + Langs: []string{"en"}, } if post.Text == "" { @@ -380,10 +264,6 @@ func TestBuildFacets_MultiArchExample(t *testing.T) { expectedTexts := []string{ "@" + userHandle, repoWithTag, - digestShort, - "Platforms:", - "linux/amd64", - "linux/arm64", } for _, expected := range expectedTexts { @@ -392,8 +272,52 @@ func TestBuildFacets_MultiArchExample(t *testing.T) { } } - // Verify Size is NOT in multi-arch post - if strings.Contains(post.Text, "Size:") { - t.Error("multi-arch post should not contain Size:") + // Verify Platforms is NOT in text (now in embed description) + if strings.Contains(post.Text, "Platforms:") { + t.Error("simplified post should not contain Platforms:") + } +} + +func TestEmbedDescription(t *testing.T) { + // Test the dynamic description generation for embeds + tests := []struct { + name string + platforms []string + totalSize int64 + wantContain string + }{ + { + name: "single-arch with size", + platforms: []string{}, + totalSize: 12800000, // ~12.2 MB + wantContain: "Pushed 12.2 MB to ATCR", + }, + { + name: "multi-arch with platforms", + platforms: []string{"linux/amd64", "linux/arm64"}, + totalSize: 0, + wantContain: "Multi-arch: linux/amd64, linux/arm64", + }, + { + name: "single platform", + platforms: []string{"linux/amd64"}, + totalSize: 0, + wantContain: "Multi-arch: linux/amd64", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var description string + if len(tt.platforms) > 0 { + description = "Multi-arch: " + strings.Join(tt.platforms, ", ") + } else { + description = "Pushed " + formatSize(tt.totalSize) + " to ATCR" + } + + if !strings.Contains(description, tt.wantContain) { + t.Errorf("description = %q, want to contain %q", description, tt.wantContain) + } + }) } }