have Holds post with new og card

This commit is contained in:
Evan Jarrett
2025-12-20 10:40:11 -06:00
parent e872b71d63
commit 23303c2187
3 changed files with 196 additions and 264 deletions
+1
View File
@@ -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,
+86 -79
View File
@@ -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
}
+109 -185
View File
@@ -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)
}
})
}
}