mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-10 04:06:06 +00:00
varies fixes for indigo xrpc calls, avatars broken on bsku profile change, opengraph card fixes, other ui improvements
This commit is contained in:
Generated
+48
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"daisyui": "^5.5.14",
|
||||
"esbuild": "^0.27.2",
|
||||
"tailwindcss": "^4.1"
|
||||
@@ -1096,12 +1097,38 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/actor-typeahead": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/actor-typeahead/-/actor-typeahead-0.1.2.tgz",
|
||||
"integrity": "sha512-I97YqqNl7Kar0J/bIJvgY/KmHpssHcDElhfwVTLP7wRFlkxso2ZLBqiS2zol5A8UVUJbQK2JXYaqNpZXz8Uk2A==",
|
||||
"license": "MPL-2.0"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
"version": "5.5.14",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.14.tgz",
|
||||
@@ -1538,6 +1565,20 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -1568,6 +1609,13 @@
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/cli": "^4.1.18",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"daisyui": "^5.5.14",
|
||||
"esbuild": "^0.27.2",
|
||||
"tailwindcss": "^4.1"
|
||||
|
||||
+21
-12
@@ -130,18 +130,27 @@ type RepositoryWithStats struct {
|
||||
|
||||
// RepoCardData contains all data needed to render a repository card
|
||||
type RepoCardData struct {
|
||||
OwnerHandle string
|
||||
Repository string
|
||||
Title string
|
||||
Description string
|
||||
IconURL string
|
||||
StarCount int
|
||||
PullCount int
|
||||
IsStarred bool // Whether the current user has starred this repository
|
||||
ArtifactType string // container-image, helm-chart, unknown
|
||||
Tag string // Latest tag name (e.g., "latest", "v1.0.0")
|
||||
Digest string // Latest manifest digest (sha256:...)
|
||||
LastUpdated time.Time // When the repository was last pushed to
|
||||
OwnerHandle string
|
||||
OwnerAvatarURL string // Owner's profile avatar URL (fallback when no repo icon)
|
||||
Repository string
|
||||
Title string
|
||||
Description string
|
||||
IconURL string
|
||||
StarCount int
|
||||
PullCount int
|
||||
IsStarred bool // Whether the current user has starred this repository
|
||||
ArtifactType string // container-image, helm-chart, unknown
|
||||
Tag string // Latest tag name (e.g., "latest", "v1.0.0")
|
||||
Digest string // Latest manifest digest (sha256:...)
|
||||
LastUpdated time.Time // When the repository was last pushed to
|
||||
RegistryURL string // Registry URL for docker commands (e.g., "atcr.io" or "127.0.0.1:5000")
|
||||
}
|
||||
|
||||
// SetRegistryURL sets the RegistryURL field on all cards in the slice
|
||||
func SetRegistryURL(cards []RepoCardData, registryURL string) {
|
||||
for i := range cards {
|
||||
cards[i].RegistryURL = registryURL
|
||||
}
|
||||
}
|
||||
|
||||
// PlatformInfo represents platform information (OS/Architecture)
|
||||
|
||||
@@ -446,6 +446,15 @@ func UpdateUserHandle(db *sql.DB, did string, newHandle string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateUserAvatar updates a user's avatar URL when a profile change is detected
|
||||
// This is called when Jetstream receives an app.bsky.actor.profile update
|
||||
func UpdateUserAvatar(db *sql.DB, did string, avatarURL string) error {
|
||||
_, err := db.Exec(`
|
||||
UPDATE users SET avatar = ?, last_seen = ? WHERE did = ?
|
||||
`, avatarURL, time.Now(), did)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetManifestDigestsForDID returns all manifest digests for a DID
|
||||
func GetManifestDigestsForDID(db *sql.DB, did string) ([]string, error) {
|
||||
rows, err := db.Query(`
|
||||
@@ -1776,6 +1785,7 @@ func GetRepoCards(db *sql.DB, limit int, currentUserDID string, sortOrder RepoCa
|
||||
SELECT
|
||||
m.did,
|
||||
u.handle,
|
||||
COALESCE(u.avatar, ''),
|
||||
m.repository,
|
||||
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
|
||||
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
|
||||
@@ -1812,7 +1822,7 @@ func GetRepoCards(db *sql.DB, limit int, currentUserDID string, sortOrder RepoCa
|
||||
var avatarCID string
|
||||
var lastUpdatedStr sql.NullString
|
||||
|
||||
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.Repository, &c.Title, &c.Description, &c.IconURL,
|
||||
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.OwnerAvatarURL, &c.Repository, &c.Title, &c.Description, &c.IconURL,
|
||||
&c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1858,6 +1868,7 @@ func GetUserRepoCards(db *sql.DB, userDID string, currentUserDID string) ([]Repo
|
||||
SELECT
|
||||
m.did,
|
||||
u.handle,
|
||||
COALESCE(u.avatar, ''),
|
||||
m.repository,
|
||||
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
|
||||
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
|
||||
@@ -1893,7 +1904,7 @@ func GetUserRepoCards(db *sql.DB, userDID string, currentUserDID string) ([]Repo
|
||||
var avatarCID string
|
||||
var lastUpdatedStr sql.NullString
|
||||
|
||||
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.Repository, &c.Title, &c.Description, &c.IconURL,
|
||||
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.OwnerAvatarURL, &c.Repository, &c.Title, &c.Description, &c.IconURL,
|
||||
&c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Error fetching featured repos: %v", err)
|
||||
featuredCards = []db.RepoCardData{}
|
||||
}
|
||||
db.SetRegistryURL(featuredCards, h.RegistryURL)
|
||||
|
||||
// Fetch recently updated repositories (top 18 by last push - 6 rows)
|
||||
recentCards, err := db.GetRepoCards(h.DB, 18, currentUserDID, db.SortByLastUpdate)
|
||||
@@ -49,6 +50,7 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Error fetching recent repos: %v", err)
|
||||
recentCards = []db.RepoCardData{}
|
||||
}
|
||||
db.SetRegistryURL(recentCards, h.RegistryURL)
|
||||
|
||||
benefits := []BenefitCard{
|
||||
{Icon: "ship", Title: "Works with Docker", Description: "Use docker push & pull. No new tools to learn."},
|
||||
|
||||
@@ -55,7 +55,6 @@ func (h *RepoOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
description := metadata["org.opencontainers.image.description"]
|
||||
iconURL := metadata["io.atcr.icon"]
|
||||
version := metadata["org.opencontainers.image.version"]
|
||||
licenses := metadata["org.opencontainers.image.licenses"]
|
||||
|
||||
// Generate the OG image
|
||||
card := ogcard.NewCard()
|
||||
@@ -94,28 +93,18 @@ func (h *RepoOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
card.DrawTextWrapped(description, layout.TextX, textY, ogcard.FontDescription, ogcard.ColorMuted, layout.MaxWidth, false)
|
||||
}
|
||||
|
||||
// Badges row (version, license)
|
||||
badgeY := layout.IconY + ogcard.AvatarSize + 30
|
||||
badgeX := int(layout.TextX)
|
||||
|
||||
if version != "" {
|
||||
width := card.DrawBadge(version, badgeX, badgeY, ogcard.FontBadge, ogcard.ColorBadgeAccent, ogcard.ColorText)
|
||||
badgeX += width + ogcard.BadgeGap
|
||||
}
|
||||
|
||||
if licenses != "" {
|
||||
// Show first license if multiple
|
||||
license, _, _ := strings.Cut(licenses, ",")
|
||||
license = strings.TrimSpace(license)
|
||||
card.DrawBadge(license, badgeX, badgeY, ogcard.FontBadge, ogcard.ColorBadgeBg, ogcard.ColorText)
|
||||
}
|
||||
|
||||
// Stats at bottom
|
||||
// Stats and version at bottom
|
||||
statsX := card.DrawStatWithIcon("star", fmt.Sprintf("%d", stats.StarCount),
|
||||
ogcard.Padding, layout.StatsY, ogcard.ColorStar, ogcard.ColorText)
|
||||
card.DrawStatWithIcon("arrow-down-to-line", fmt.Sprintf("%d pulls", stats.PullCount),
|
||||
statsX = card.DrawStatWithIcon("arrow-down-to-line", fmt.Sprintf("%d pulls", stats.PullCount),
|
||||
statsX, layout.StatsY, ogcard.ColorMuted, ogcard.ColorMuted)
|
||||
|
||||
// Version badge in the stats row
|
||||
if version != "" {
|
||||
badgeY := layout.StatsY - int(ogcard.FontBadge) - 4
|
||||
card.DrawBadge(version, statsX, badgeY, ogcard.FontBadge, ogcard.ColorBadgeAccent, ogcard.ColorText)
|
||||
}
|
||||
|
||||
// ATCR branding (bottom right)
|
||||
card.DrawBranding()
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Error fetching repo cards for user %s: %v", viewedUser.DID, err)
|
||||
cards = []db.RepoCardData{}
|
||||
}
|
||||
db.SetRegistryURL(cards, h.RegistryURL)
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
|
||||
@@ -240,6 +240,12 @@ func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection strin
|
||||
if err := b.reconcileAnnotations(ctx, did, pdsClient); err != nil {
|
||||
slog.Warn("Backfill failed to reconcile annotations", "did", did, "error", err)
|
||||
}
|
||||
|
||||
// Refresh user's avatar from their Bluesky profile
|
||||
// This ensures cached avatars stay fresh even if the user changes their profile pic
|
||||
if err := b.processor.RefreshUserAvatar(ctx, did, pdsEndpoint); err != nil {
|
||||
slog.Warn("Backfill failed to refresh avatar", "did", did, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// After processing repo pages, fetch descriptions from external sources if empty
|
||||
|
||||
@@ -517,6 +517,82 @@ func (p *Processor) ProcessIdentity(ctx context.Context, did string, newHandle s
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessProfileUpdate handles app.bsky.actor.profile updates for known ATCR users
|
||||
// This refreshes the cached avatar URL when a user changes their Bluesky profile picture
|
||||
func (p *Processor) ProcessProfileUpdate(ctx context.Context, did string, recordData []byte) error {
|
||||
// Check if user exists in our database - only update if they're an ATCR user
|
||||
user, err := db.GetUserByDID(p.db, did)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check user existence: %w", err)
|
||||
}
|
||||
|
||||
// Skip if user doesn't exist - they don't have any ATCR activity
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the profile record to extract avatar
|
||||
var profile struct {
|
||||
Avatar *atproto.ATProtoBlobRef `json:"avatar"`
|
||||
}
|
||||
if err := json.Unmarshal(recordData, &profile); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal profile: %w", err)
|
||||
}
|
||||
|
||||
// Build new avatar URL
|
||||
avatarURL := ""
|
||||
if profile.Avatar != nil && profile.Avatar.Ref.Link != "" {
|
||||
avatarURL = atproto.BlobCDNURL(did, profile.Avatar.Ref.Link)
|
||||
}
|
||||
|
||||
// Update if changed
|
||||
if avatarURL != user.Avatar {
|
||||
slog.Info("Updating avatar from profile change",
|
||||
"component", "processor",
|
||||
"did", did,
|
||||
"old_avatar", user.Avatar,
|
||||
"new_avatar", avatarURL)
|
||||
return db.UpdateUserAvatar(p.db, did, avatarURL)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshUserAvatar fetches the user's current Bluesky profile and updates their cached avatar
|
||||
// This is called during backfill to ensure avatars stay fresh for existing users
|
||||
func (p *Processor) RefreshUserAvatar(ctx context.Context, did, pdsEndpoint string) error {
|
||||
// Get user from database to compare avatar
|
||||
user, err := db.GetUserByDID(p.db, did)
|
||||
if err != nil || user == nil {
|
||||
return nil // User doesn't exist, skip
|
||||
}
|
||||
|
||||
// Fetch profile from PDS
|
||||
client := atproto.NewClient(pdsEndpoint, "", "")
|
||||
profile, err := client.GetProfileRecord(ctx, did)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch profile: %w", err)
|
||||
}
|
||||
|
||||
// Build avatar URL
|
||||
avatarURL := ""
|
||||
if profile.Avatar != nil && profile.Avatar.Ref.Link != "" {
|
||||
avatarURL = atproto.BlobCDNURL(did, profile.Avatar.Ref.Link)
|
||||
}
|
||||
|
||||
// Update if changed
|
||||
if avatarURL != user.Avatar {
|
||||
slog.Info("Backfill refreshing avatar",
|
||||
"component", "processor",
|
||||
"did", did,
|
||||
"old_avatar", user.Avatar,
|
||||
"new_avatar", avatarURL)
|
||||
return db.UpdateUserAvatar(p.db, did, avatarURL)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessStats handles stats record events from hold PDSes
|
||||
// This is called when Jetstream receives a stats create/update/delete event from a hold
|
||||
// The holdDID is the DID of the hold PDS (event.DID), and the record contains ownerDID + repository
|
||||
|
||||
@@ -65,7 +65,8 @@ func NewWorker(database *sql.DB, jetstreamURL string, startCursor int64) *Worker
|
||||
jetstreamURL: jetstreamURL,
|
||||
startCursor: startCursor,
|
||||
wantedCollections: []string{
|
||||
"io.atcr.*", // Subscribe to all ATCR collections
|
||||
"io.atcr.*", // Subscribe to all ATCR collections
|
||||
"app.bsky.actor.profile", // Subscribe to Bluesky profile updates for avatar sync
|
||||
},
|
||||
statsCache: statsCache,
|
||||
processor: NewProcessor(database, true, statsCache), // Use cache for live streaming
|
||||
@@ -309,18 +310,12 @@ func (w *Worker) processMessage(message []byte) error {
|
||||
w.debugCollectionCount++
|
||||
}
|
||||
|
||||
// Check if this is an ATCR collection we care about
|
||||
if !isATCRCollection(commit.Collection) {
|
||||
return nil // Ignore non-ATCR collections
|
||||
// Check if this is a collection we care about
|
||||
if !isRelevantCollection(commit.Collection) {
|
||||
return nil // Ignore irrelevant collections
|
||||
}
|
||||
|
||||
slog.Info("Jetstream processing event",
|
||||
"collection", commit.Collection,
|
||||
"did", commit.DID,
|
||||
"operation", commit.Operation,
|
||||
"rkey", commit.RKey)
|
||||
|
||||
// Marshal record to bytes for unified processing
|
||||
// Marshal record to bytes for processing
|
||||
var recordBytes []byte
|
||||
if commit.Record != nil {
|
||||
var err error
|
||||
@@ -330,6 +325,22 @@ func (w *Worker) processMessage(message []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Bluesky profile updates separately (for avatar sync)
|
||||
if commit.Collection == BlueskyProfileCollection {
|
||||
// Only process creates/updates, not deletes (we don't clear avatars on profile delete)
|
||||
if commit.Operation == "delete" {
|
||||
return nil
|
||||
}
|
||||
return w.processor.ProcessProfileUpdate(context.Background(), commit.DID, recordBytes)
|
||||
}
|
||||
|
||||
// Log ATCR events (but not Bluesky profile events - too noisy)
|
||||
slog.Info("Jetstream processing event",
|
||||
"collection", commit.Collection,
|
||||
"did", commit.DID,
|
||||
"operation", commit.Operation,
|
||||
"rkey", commit.RKey)
|
||||
|
||||
isDelete := commit.Operation == "delete"
|
||||
return w.processor.ProcessRecord(context.Background(), commit.DID, commit.Collection, commit.RKey, recordBytes, isDelete, nil)
|
||||
|
||||
@@ -373,8 +384,11 @@ func (w *Worker) processAccount(event *JetstreamEvent) error {
|
||||
return w.processor.ProcessAccount(context.Background(), account.DID, account.Active, account.Status)
|
||||
}
|
||||
|
||||
// isATCRCollection returns true if the collection is one we process
|
||||
func isATCRCollection(collection string) bool {
|
||||
// BlueskyProfileCollection is the collection for Bluesky actor profiles
|
||||
const BlueskyProfileCollection = "app.bsky.actor.profile"
|
||||
|
||||
// isRelevantCollection returns true if the collection is one we process
|
||||
func isRelevantCollection(collection string) bool {
|
||||
switch collection {
|
||||
case atproto.ManifestCollection,
|
||||
atproto.TagCollection,
|
||||
@@ -383,7 +397,8 @@ func isATCRCollection(collection string) bool {
|
||||
atproto.SailorProfileCollection,
|
||||
atproto.StatsCollection,
|
||||
atproto.CaptainCollection,
|
||||
atproto.CrewCollection:
|
||||
atproto.CrewCollection,
|
||||
BlueskyProfileCollection: // For avatar sync
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,6 +3,8 @@
|
||||
======================================== */
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
/*@layer base {
|
||||
.container {
|
||||
max-width: 1920px;
|
||||
@@ -60,6 +62,31 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
TYPOGRAPHY (PROSE) THEME INTEGRATION
|
||||
======================================== */
|
||||
@layer base {
|
||||
/* Make prose inherit DaisyUI theme colors */
|
||||
.prose {
|
||||
--tw-prose-body: var(--color-base-content);
|
||||
--tw-prose-headings: var(--color-base-content);
|
||||
--tw-prose-lead: var(--color-base-content);
|
||||
--tw-prose-links: var(--color-primary);
|
||||
--tw-prose-bold: var(--color-base-content);
|
||||
--tw-prose-counters: var(--color-base-content);
|
||||
--tw-prose-bullets: var(--color-base-content);
|
||||
--tw-prose-hr: var(--color-base-300);
|
||||
--tw-prose-quotes: var(--color-base-content);
|
||||
--tw-prose-quote-borders: var(--color-base-300);
|
||||
--tw-prose-captions: var(--color-base-content);
|
||||
--tw-prose-code: var(--color-base-content);
|
||||
--tw-prose-pre-code: var(--color-base-content);
|
||||
--tw-prose-pre-bg: var(--color-base-200);
|
||||
--tw-prose-th-borders: var(--color-base-300);
|
||||
--tw-prose-td-borders: var(--color-base-300);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
CUSTOM COMPONENTS (Not in DaisyUI)
|
||||
======================================== */
|
||||
|
||||
@@ -346,8 +346,9 @@ func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRec
|
||||
// Add config if present (not present in manifest lists/indexes)
|
||||
if manifestRecord.Config != nil {
|
||||
manifestData["config"] = map[string]any{
|
||||
"digest": manifestRecord.Config.Digest,
|
||||
"size": manifestRecord.Config.Size,
|
||||
"digest": manifestRecord.Config.Digest,
|
||||
"size": manifestRecord.Config.Size,
|
||||
"mediaType": manifestRecord.Config.MediaType,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
{{ define "footer" }}
|
||||
<footer class="footer footer-center bg-base-200 text-base-content p-6 mt-auto">
|
||||
<nav class="flex flex-wrap justify-center gap-4 text-sm">
|
||||
<a href="/privacy" class="link link-hover inline-flex items-center gap-1">
|
||||
<i data-lucide="shield" class="size-4"></i> Privacy
|
||||
</a>
|
||||
<a href="/terms" class="link link-hover inline-flex items-center gap-1">
|
||||
<i data-lucide="scroll-text" class="size-4"></i> Terms
|
||||
</a>
|
||||
<nav class="flex flex-wrap justify-center items-center gap-x-2 gap-y-1 text-sm">
|
||||
<a href="/privacy" class="link link-hover">Privacy</a>
|
||||
<span class="text-base-content/30">·</span>
|
||||
<a href="/terms" class="link link-hover">Terms</a>
|
||||
<span class="text-base-content/30">·</span>
|
||||
<a href="https://bsky.app/profile/atcr.io" target="_blank" rel="noopener" class="link link-hover inline-flex items-center gap-1">
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 568 501"><path fill="currentColor" d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/></svg>
|
||||
<svg class="size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 568 501"><path fill="currentColor" d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/></svg>
|
||||
Bluesky
|
||||
</a>
|
||||
<span class="text-base-content/30">·</span>
|
||||
<a href="https://tangled.org/evan.jarrett.net/at-container-registry" target="_blank" rel="noopener" class="link link-hover inline-flex items-center gap-1">
|
||||
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" alt="" class="size-4 icon-light">
|
||||
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" alt="" class="size-4 icon-dark">
|
||||
<img src="https://assets.tangled.network/tangled_dolly_face_only_black_on_trans.svg" alt="" class="size-3.5 icon-light">
|
||||
<img src="https://assets.tangled.network/tangled_dolly_face_only_white_on_trans.svg" alt="" class="size-3.5 icon-dark">
|
||||
Source
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
Expects: db.RepoCardData struct with fields:
|
||||
- OwnerHandle: string - Repository owner's handle
|
||||
- OwnerAvatarURL: string (optional) - Owner's profile avatar URL (fallback when no repo icon)
|
||||
- Repository: string - Repository name
|
||||
- Title: string (optional) - Display title
|
||||
- Description: string (optional) - Repository description
|
||||
@@ -14,11 +15,14 @@
|
||||
- Tag: string (optional) - Latest tag name
|
||||
- Digest: string (optional) - Latest manifest digest
|
||||
- LastUpdated: time.Time (optional) - Last push time
|
||||
- RegistryURL: string - Registry URL for docker commands (e.g., "atcr.io")
|
||||
*/}}
|
||||
<div class="card card-border card-interactive bg-base-100 p-6 flex flex-col justify-between min-h-60 w-full" onclick="window.location='/r/{{ .OwnerHandle }}/{{ .Repository }}'">
|
||||
<div class="flex gap-4 items-start">
|
||||
{{ if .IconURL }}
|
||||
<img src="{{ .IconURL }}" alt="{{ .Repository }}" class="w-12 rounded-lg object-cover shrink-0">
|
||||
{{ else if .OwnerAvatarURL }}
|
||||
<img src="{{ .OwnerAvatarURL }}" alt="{{ .OwnerHandle }}" class="w-12 rounded-lg object-cover shrink-0">
|
||||
{{ else }}
|
||||
<div class="avatar avatar-placeholder">
|
||||
<div class="bg-neutral text-neutral-content w-12 rounded-lg shadow-sm uppercase">
|
||||
@@ -43,15 +47,15 @@
|
||||
<div class="flex-1 flex flex-col justify-end py-2 min-w-0">
|
||||
{{ if eq .ArtifactType "helm-chart" }}
|
||||
{{ if .Tag }}
|
||||
{{ template "docker-command" (printf "helm pull oci://atcr.io/%s/%s --version %s" .OwnerHandle .Repository .Tag) }}
|
||||
{{ template "docker-command" (printf "helm pull oci://%s/%s/%s --version %s" .RegistryURL .OwnerHandle .Repository .Tag) }}
|
||||
{{ else }}
|
||||
{{ template "docker-command" (printf "helm pull oci://atcr.io/%s/%s" .OwnerHandle .Repository) }}
|
||||
{{ template "docker-command" (printf "helm pull oci://%s/%s/%s" .RegistryURL .OwnerHandle .Repository) }}
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
{{ if .Tag }}
|
||||
{{ template "docker-command" (printf "docker pull atcr.io/%s/%s:%s" .OwnerHandle .Repository .Tag) }}
|
||||
{{ template "docker-command" (printf "docker pull %s/%s/%s:%s" .RegistryURL .OwnerHandle .Repository .Tag) }}
|
||||
{{ else }}
|
||||
{{ template "docker-command" (printf "docker pull atcr.io/%s/%s" .OwnerHandle .Repository) }}
|
||||
{{ template "docker-command" (printf "docker pull %s/%s/%s" .RegistryURL .OwnerHandle .Repository) }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
+182
-439
@@ -3,16 +3,18 @@ package atproto
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
comatproto "github.com/bluesky-social/indigo/api/atproto"
|
||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
"github.com/bluesky-social/indigo/atproto/atclient"
|
||||
indigo_oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
lexutil "github.com/bluesky-social/indigo/lex/util"
|
||||
"github.com/bluesky-social/indigo/xrpc"
|
||||
)
|
||||
|
||||
// Sentinel errors
|
||||
@@ -20,6 +22,63 @@ var (
|
||||
ErrRecordNotFound = errors.New("record not found")
|
||||
)
|
||||
|
||||
// ClientProvider abstracts OAuth vs Basic Auth client creation.
|
||||
// This allows the same code path for all PDS operations regardless of auth type.
|
||||
type ClientProvider interface {
|
||||
// DoWithClient executes fn with a configured lexicon client.
|
||||
// For OAuth: uses session.APIClient() with DPoP handling
|
||||
// For Basic Auth: uses xrpc.Client with bearer token
|
||||
DoWithClient(ctx context.Context, did string, fn func(client lexutil.LexClient) error) error
|
||||
}
|
||||
|
||||
// OAuthClientProvider wraps a SessionProvider to provide a lexicon client
|
||||
type OAuthClientProvider struct {
|
||||
sessionProvider SessionProvider
|
||||
}
|
||||
|
||||
// NewOAuthClientProvider creates a ClientProvider that uses OAuth sessions
|
||||
func NewOAuthClientProvider(sp SessionProvider) *OAuthClientProvider {
|
||||
return &OAuthClientProvider{sessionProvider: sp}
|
||||
}
|
||||
|
||||
// DoWithClient executes fn with an OAuth session's APIClient
|
||||
func (p *OAuthClientProvider) DoWithClient(ctx context.Context, did string, fn func(lexutil.LexClient) error) error {
|
||||
return p.sessionProvider.DoWithSession(ctx, did, func(session *indigo_oauth.ClientSession) error {
|
||||
return fn(session.APIClient())
|
||||
})
|
||||
}
|
||||
|
||||
// BasicAuthClientProvider provides an xrpc.Client for Basic Auth (app passwords)
|
||||
type BasicAuthClientProvider struct {
|
||||
pdsEndpoint string
|
||||
accessToken string
|
||||
did string
|
||||
}
|
||||
|
||||
// NewBasicAuthClientProvider creates a ClientProvider that uses app passwords
|
||||
func NewBasicAuthClientProvider(pdsEndpoint, did, accessToken string) *BasicAuthClientProvider {
|
||||
return &BasicAuthClientProvider{
|
||||
pdsEndpoint: pdsEndpoint,
|
||||
accessToken: accessToken,
|
||||
did: did,
|
||||
}
|
||||
}
|
||||
|
||||
// DoWithClient executes fn with an xrpc.Client configured for Basic Auth
|
||||
func (p *BasicAuthClientProvider) DoWithClient(ctx context.Context, did string, fn func(lexutil.LexClient) error) error {
|
||||
client := &xrpc.Client{
|
||||
Host: p.pdsEndpoint,
|
||||
}
|
||||
// Only set Auth if we have a token (empty token = unauthenticated request)
|
||||
if p.accessToken != "" {
|
||||
client.Auth = &xrpc.AuthInfo{
|
||||
AccessJwt: p.accessToken,
|
||||
Did: p.did,
|
||||
}
|
||||
}
|
||||
return fn(client)
|
||||
}
|
||||
|
||||
// SessionProvider provides locked OAuth sessions for PDS operations.
|
||||
// This interface allows the ATProto client to use DoWithSession() for each PDS call,
|
||||
// preventing DPoP nonce race conditions during concurrent operations.
|
||||
@@ -31,20 +90,19 @@ type SessionProvider interface {
|
||||
|
||||
// Client wraps ATProto operations for the registry
|
||||
type Client struct {
|
||||
pdsEndpoint string
|
||||
did string
|
||||
accessToken string // For Basic Auth only
|
||||
httpClient *http.Client
|
||||
sessionProvider SessionProvider // For locked OAuth sessions (prevents DPoP nonce races)
|
||||
pdsEndpoint string
|
||||
did string
|
||||
clientProvider ClientProvider // Unified provider for OAuth or Basic Auth
|
||||
httpClient *http.Client // Used for methods not yet migrated to indigo
|
||||
}
|
||||
|
||||
// NewClient creates a new ATProto client for Basic Auth tokens (app passwords)
|
||||
func NewClient(pdsEndpoint, did, accessToken string) *Client {
|
||||
return &Client{
|
||||
pdsEndpoint: pdsEndpoint,
|
||||
did: did,
|
||||
accessToken: accessToken,
|
||||
httpClient: &http.Client{},
|
||||
pdsEndpoint: pdsEndpoint,
|
||||
did: did,
|
||||
clientProvider: NewBasicAuthClientProvider(pdsEndpoint, did, accessToken),
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +116,10 @@ func NewClient(pdsEndpoint, did, accessToken string) *Client {
|
||||
// - Concurrent manifest operations don't cause nonce thrashing
|
||||
func NewClientWithSessionProvider(pdsEndpoint, did string, sessionProvider SessionProvider) *Client {
|
||||
return &Client{
|
||||
pdsEndpoint: pdsEndpoint,
|
||||
did: did,
|
||||
sessionProvider: sessionProvider,
|
||||
httpClient: &http.Client{},
|
||||
pdsEndpoint: pdsEndpoint,
|
||||
did: did,
|
||||
clientProvider: NewOAuthClientProvider(sessionProvider),
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,50 +139,13 @@ func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record
|
||||
"record": record,
|
||||
}
|
||||
|
||||
// Use session provider (locked OAuth with DPoP) - prevents nonce races
|
||||
if c.sessionProvider != nil {
|
||||
var result Record
|
||||
err := c.sessionProvider.DoWithSession(ctx, c.did, func(session *indigo_oauth.ClientSession) error {
|
||||
apiClient := session.APIClient()
|
||||
return apiClient.Post(ctx, "com.atproto.repo.putRecord", payload, &result)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("putRecord failed: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// Basic Auth (app passwords)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal record: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s%s", c.pdsEndpoint, RepoPutRecord)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to put record: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("put record failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result Record
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
err := c.clientProvider.DoWithClient(ctx, c.did, func(client lexutil.LexClient) error {
|
||||
return client.LexDo(ctx, "POST", "application/json", "com.atproto.repo.putRecord", nil, payload, &result)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("putRecord failed: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
@@ -136,66 +157,29 @@ func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Recor
|
||||
"rkey": rkey,
|
||||
}
|
||||
|
||||
// Use session provider (locked OAuth with DPoP) - prevents nonce races
|
||||
if c.sessionProvider != nil {
|
||||
var result Record
|
||||
err := c.sessionProvider.DoWithSession(ctx, c.did, func(session *indigo_oauth.ClientSession) error {
|
||||
apiClient := session.APIClient()
|
||||
return apiClient.Get(ctx, "com.atproto.repo.getRecord", params, &result)
|
||||
})
|
||||
if err != nil {
|
||||
// Check for RecordNotFound error from indigo's APIError type
|
||||
var apiErr *atclient.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
if apiErr.StatusCode == 404 || apiErr.Name == "RecordNotFound" {
|
||||
return nil, ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("getRecord failed: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// Basic Auth (app passwords)
|
||||
url := fmt.Sprintf("%s%s?repo=%s&collection=%s&rkey=%s",
|
||||
c.pdsEndpoint, RepoGetRecord, c.did, collection, rkey)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
var result Record
|
||||
err := c.clientProvider.DoWithClient(ctx, c.did, func(client lexutil.LexClient) error {
|
||||
return client.LexDo(ctx, "GET", "", "com.atproto.repo.getRecord", params, nil, &result)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Only set Authorization header if we have a token
|
||||
// Empty Bearer tokens will be rejected by PDS
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get record: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrRecordNotFound
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
bodyStr := string(bodyBytes)
|
||||
// Check for RecordNotFound error (PDS returns 400 with this error)
|
||||
if strings.Contains(bodyStr, "RecordNotFound") {
|
||||
// Check for xrpc.Error with 404 status code
|
||||
var xrpcErr *xrpc.Error
|
||||
if errors.As(err, &xrpcErr) && xrpcErr.StatusCode == 404 {
|
||||
return nil, ErrRecordNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("get record failed with status %d: %s", resp.StatusCode, bodyStr)
|
||||
// Check for RecordNotFound error from indigo's APIError type
|
||||
var apiErr *atclient.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
if apiErr.StatusCode == 404 || apiErr.Name == "RecordNotFound" {
|
||||
return nil, ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
// Also check error message for RecordNotFound (some error formats)
|
||||
if strings.Contains(err.Error(), "RecordNotFound") {
|
||||
return nil, ErrRecordNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("getRecord failed: %w", err)
|
||||
}
|
||||
|
||||
var result Record
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
@@ -207,82 +191,33 @@ func (c *Client) DeleteRecord(ctx context.Context, collection, rkey string) erro
|
||||
"rkey": rkey,
|
||||
}
|
||||
|
||||
// Use session provider (locked OAuth with DPoP) - prevents nonce races
|
||||
if c.sessionProvider != nil {
|
||||
err := c.sessionProvider.DoWithSession(ctx, c.did, func(session *indigo_oauth.ClientSession) error {
|
||||
apiClient := session.APIClient()
|
||||
var result map[string]any // deleteRecord returns empty object on success
|
||||
return apiClient.Post(ctx, "com.atproto.repo.deleteRecord", payload, &result)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleteRecord failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Basic Auth (app passwords)
|
||||
body, err := json.Marshal(payload)
|
||||
err := c.clientProvider.DoWithClient(ctx, c.did, func(client lexutil.LexClient) error {
|
||||
var result map[string]any // deleteRecord returns empty object on success
|
||||
return client.LexDo(ctx, "POST", "application/json", "com.atproto.repo.deleteRecord", nil, payload, &result)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal delete request: %w", err)
|
||||
return fmt.Errorf("deleteRecord failed: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s%s", c.pdsEndpoint, RepoDeleteRecord)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete record: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("delete record failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRecords lists records in a collection
|
||||
func (c *Client) ListRecords(ctx context.Context, collection string, limit int) ([]Record, error) {
|
||||
url := fmt.Sprintf("%s%s?repo=%s&collection=%s&limit=%d",
|
||||
c.pdsEndpoint, RepoListRecords, c.did, collection, limit)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Only set Authorization header if we have a token
|
||||
// Empty Bearer tokens will be rejected by PDS
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list records: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("list records failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
params := map[string]any{
|
||||
"repo": c.did,
|
||||
"collection": collection,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Records []Record `json:"records"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
err := c.clientProvider.DoWithClient(ctx, c.did, func(client lexutil.LexClient) error {
|
||||
return client.LexDo(ctx, "GET", "", "com.atproto.repo.listRecords", params, nil, &result)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listRecords failed: %w", err)
|
||||
}
|
||||
|
||||
return result.Records, nil
|
||||
}
|
||||
|
||||
@@ -302,120 +237,35 @@ type Link struct {
|
||||
|
||||
// UploadBlob uploads binary data to the PDS and returns a blob reference
|
||||
func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*ATProtoBlobRef, error) {
|
||||
// Use session provider (locked OAuth with DPoP) - prevents nonce races
|
||||
if c.sessionProvider != nil {
|
||||
var result struct {
|
||||
Blob ATProtoBlobRef `json:"blob"`
|
||||
}
|
||||
|
||||
err := c.sessionProvider.DoWithSession(ctx, c.did, func(session *indigo_oauth.ClientSession) error {
|
||||
apiClient := session.APIClient()
|
||||
// IMPORTANT: Use io.Reader for blob uploads
|
||||
// LexDo JSON-encodes []byte (base64), but streams io.Reader as raw bytes
|
||||
// Use the actual MIME type so PDS can validate against blob:image/* scope
|
||||
return apiClient.LexDo(ctx,
|
||||
"POST",
|
||||
mimeType,
|
||||
"com.atproto.repo.uploadBlob",
|
||||
nil,
|
||||
bytes.NewReader(data),
|
||||
&result,
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("uploadBlob failed: %w", err)
|
||||
}
|
||||
|
||||
return &result.Blob, nil
|
||||
}
|
||||
|
||||
// Basic Auth (app passwords)
|
||||
url := fmt.Sprintf("%s%s", c.pdsEndpoint, RepoUploadBlob)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
req.Header.Set("Content-Type", mimeType)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upload blob: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("upload blob failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Blob ATProtoBlobRef `json:"blob"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
// IMPORTANT: Use io.Reader for blob uploads
|
||||
// LexDo JSON-encodes []byte (base64), but streams io.Reader as raw bytes
|
||||
// Use the actual MIME type so PDS can validate against blob:image/* scope
|
||||
err := c.clientProvider.DoWithClient(ctx, c.did, func(client lexutil.LexClient) error {
|
||||
return client.LexDo(ctx, "POST", mimeType, "com.atproto.repo.uploadBlob", nil, bytes.NewReader(data), &result)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("uploadBlob failed: %w", err)
|
||||
}
|
||||
return &result.Blob, nil
|
||||
}
|
||||
|
||||
// GetBlob downloads a blob by its CID from the PDS
|
||||
// Note: This is a sync endpoint that returns raw binary data
|
||||
func (c *Client) GetBlob(ctx context.Context, cid string) ([]byte, error) {
|
||||
url := fmt.Sprintf("%s%s?did=%s&cid=%s",
|
||||
c.pdsEndpoint, SyncGetBlob, c.did, cid)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Note: getBlob may not require auth for public repos, but we include it anyway
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
// Public endpoint - no auth required
|
||||
client := &xrpc.Client{Host: c.pdsEndpoint}
|
||||
data, err := comatproto.SyncGetBlob(ctx, client, cid, c.did)
|
||||
if err != nil {
|
||||
var xrpcErr *xrpc.Error
|
||||
if errors.As(err, &xrpcErr) && xrpcErr.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("blob not found")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get blob: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("blob not found")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("get blob failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
// Read the blob data
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read blob data: %w", err)
|
||||
}
|
||||
|
||||
// Check if PDS returned JSON-wrapped blob (Bluesky implementation)
|
||||
// PDS may wrap blobs as JSON-encoded base64 strings
|
||||
// Detection: Check if content starts with a quote (indicating JSON string)
|
||||
if len(data) > 0 && data[0] == '"' {
|
||||
// Blob is JSON-encoded - decode it
|
||||
var base64Str string
|
||||
if err := json.Unmarshal(data, &base64Str); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal JSON-wrapped blob: %w", err)
|
||||
}
|
||||
|
||||
// Base64-decode the blob content
|
||||
decoded, err := base64.StdEncoding.DecodeString(base64Str)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to base64-decode blob: %w", err)
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// Raw blob response (expected ATProto behavior)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -431,90 +281,50 @@ type RepoRef struct {
|
||||
}
|
||||
|
||||
// ListReposByCollection lists all repos (DIDs) that have records in a collection
|
||||
// This is a network-wide query, not limited to a single PDS
|
||||
// This is a network-wide query, not limited to a single PDS (public endpoint)
|
||||
func (c *Client) ListReposByCollection(ctx context.Context, collection string, limit int, cursor string) (*ListReposByCollectionResult, error) {
|
||||
// Build URL with query parameters
|
||||
url := fmt.Sprintf("%s%s?collection=%s", c.pdsEndpoint, SyncListReposByCollection, collection)
|
||||
|
||||
params := map[string]any{"collection": collection}
|
||||
if limit > 0 {
|
||||
url += fmt.Sprintf("&limit=%d", limit)
|
||||
params["limit"] = limit
|
||||
}
|
||||
if cursor != "" {
|
||||
url += fmt.Sprintf("&cursor=%s", cursor)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// This endpoint typically doesn't require auth for public data
|
||||
// but we include it if available
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list repos by collection: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("list repos by collection failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
params["cursor"] = cursor
|
||||
}
|
||||
|
||||
// Public endpoint - no auth required
|
||||
client := &xrpc.Client{Host: c.pdsEndpoint}
|
||||
var result ListReposByCollectionResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
err := client.LexDo(ctx, "GET", "", "com.atproto.sync.listReposByCollection", params, nil, &result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listReposByCollection failed: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ListRecordsForRepo lists records in a collection for a specific repo (DID)
|
||||
// This differs from ListRecords which uses the client's DID
|
||||
// This differs from ListRecords which uses the client's DID (public endpoint)
|
||||
func (c *Client) ListRecordsForRepo(ctx context.Context, repoDID, collection string, limit int, cursor string) ([]Record, string, error) {
|
||||
url := fmt.Sprintf("%s%s?repo=%s&collection=%s",
|
||||
c.pdsEndpoint, RepoListRecords, repoDID, collection)
|
||||
|
||||
params := map[string]any{
|
||||
"repo": repoDID,
|
||||
"collection": collection,
|
||||
}
|
||||
if limit > 0 {
|
||||
url += fmt.Sprintf("&limit=%d", limit)
|
||||
params["limit"] = limit
|
||||
}
|
||||
if cursor != "" {
|
||||
url += fmt.Sprintf("&cursor=%s", cursor)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// This endpoint typically doesn't require auth for public records
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to list records: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, "", fmt.Errorf("list records failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
params["cursor"] = cursor
|
||||
}
|
||||
|
||||
// Public endpoint - no auth required
|
||||
client := &xrpc.Client{Host: c.pdsEndpoint}
|
||||
var result struct {
|
||||
Records []Record `json:"records"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode response: %w", err)
|
||||
err := client.LexDo(ctx, "GET", "", "com.atproto.repo.listRecords", params, nil, &result)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("listRecords failed: %w", err)
|
||||
}
|
||||
|
||||
return result.Records, result.Cursor, nil
|
||||
}
|
||||
|
||||
@@ -539,40 +349,30 @@ type ProfileRecord struct {
|
||||
// GetActorProfile fetches an actor's profile from their PDS
|
||||
// The actor parameter can be a DID or handle
|
||||
func (c *Client) GetActorProfile(ctx context.Context, actor string) (*ActorProfile, error) {
|
||||
// Basic Auth (app passwords) or unauthenticated
|
||||
url := fmt.Sprintf("%s/xrpc/app.bsky.actor.getProfile?actor=%s", c.pdsEndpoint, actor)
|
||||
// Public endpoint - doesn't require auth
|
||||
client := &xrpc.Client{Host: c.pdsEndpoint}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// This endpoint typically doesn't require auth for public profiles
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
resp, err := appbsky.ActorGetProfile(ctx, client, actor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get profile: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("profile not found")
|
||||
// Convert indigo's type to our ActorProfile
|
||||
profile := &ActorProfile{
|
||||
DID: resp.Did,
|
||||
Handle: resp.Handle,
|
||||
}
|
||||
if resp.DisplayName != nil {
|
||||
profile.DisplayName = *resp.DisplayName
|
||||
}
|
||||
if resp.Description != nil {
|
||||
profile.Description = *resp.Description
|
||||
}
|
||||
if resp.Avatar != nil {
|
||||
profile.Avatar = *resp.Avatar
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("get profile failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var profile ActorProfile
|
||||
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode profile: %w", err)
|
||||
}
|
||||
|
||||
return &profile, nil
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// GetProfileRecord fetches the app.bsky.actor.profile record from PDS
|
||||
@@ -584,56 +384,15 @@ func (c *Client) GetProfileRecord(ctx context.Context, did string) (*ProfileReco
|
||||
"rkey": "self",
|
||||
}
|
||||
|
||||
// Use session provider (locked OAuth with DPoP) - prevents nonce races
|
||||
if c.sessionProvider != nil {
|
||||
var result struct {
|
||||
Value ProfileRecord `json:"value"`
|
||||
}
|
||||
err := c.sessionProvider.DoWithSession(ctx, c.did, func(session *indigo_oauth.ClientSession) error {
|
||||
apiClient := session.APIClient()
|
||||
return apiClient.Get(ctx, "com.atproto.repo.getRecord", params, &result)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getRecord failed: %w", err)
|
||||
}
|
||||
return &result.Value, nil
|
||||
}
|
||||
|
||||
// Basic Auth (app passwords)
|
||||
url := fmt.Sprintf("%s%s?repo=%s&collection=app.bsky.actor.profile&rkey=self",
|
||||
c.pdsEndpoint, RepoGetRecord, did)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get profile record: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("profile record not found")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("get profile record failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Value ProfileRecord `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode profile record: %w", err)
|
||||
err := c.clientProvider.DoWithClient(ctx, c.did, func(client lexutil.LexClient) error {
|
||||
return client.LexDo(ctx, "GET", "", "com.atproto.repo.getRecord", params, nil, &result)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getRecord failed: %w", err)
|
||||
}
|
||||
|
||||
return &result.Value, nil
|
||||
}
|
||||
|
||||
@@ -691,41 +450,25 @@ func (c *Client) PDSEndpoint() string {
|
||||
// ListRecordsWithCursor lists records in a collection with cursor-based pagination.
|
||||
// Returns records, next cursor (empty if no more), and error.
|
||||
func (c *Client) ListRecordsWithCursor(ctx context.Context, collection string, limit int, cursor string) ([]Record, string, error) {
|
||||
url := fmt.Sprintf("%s%s?repo=%s&collection=%s&limit=%d",
|
||||
c.pdsEndpoint, RepoListRecords, c.did, collection, limit)
|
||||
|
||||
params := map[string]any{
|
||||
"repo": c.did,
|
||||
"collection": collection,
|
||||
"limit": limit,
|
||||
}
|
||||
if cursor != "" {
|
||||
url += "&cursor=" + cursor
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if c.accessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to list records: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, "", fmt.Errorf("list records failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
params["cursor"] = cursor
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Records []Record `json:"records"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decode response: %w", err)
|
||||
err := c.clientProvider.DoWithClient(ctx, c.did, func(client lexutil.LexClient) error {
|
||||
return client.LexDo(ctx, "GET", "", "com.atproto.repo.listRecords", params, nil, &result)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("listRecords failed: %w", err)
|
||||
}
|
||||
|
||||
return result.Records, result.Cursor, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -20,11 +20,12 @@ func TestNewClient(t *testing.T) {
|
||||
if client.did != "did:plc:test123" {
|
||||
t.Errorf("did = %v, want did:plc:test123", client.did)
|
||||
}
|
||||
if client.accessToken != "token123" {
|
||||
t.Errorf("accessToken = %v, want token123", client.accessToken)
|
||||
// Verify clientProvider is BasicAuthClientProvider
|
||||
if client.clientProvider == nil {
|
||||
t.Error("clientProvider should not be nil")
|
||||
}
|
||||
if client.sessionProvider != nil {
|
||||
t.Error("sessionProvider should be nil for Basic Auth client")
|
||||
if _, ok := client.clientProvider.(*BasicAuthClientProvider); !ok {
|
||||
t.Errorf("clientProvider should be *BasicAuthClientProvider, got %T", client.clientProvider)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,14 +438,6 @@ func TestGetBlob(t *testing.T) {
|
||||
wantData: []byte("test blob content"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "JSON-wrapped blob (Bluesky PDS format)",
|
||||
cid: "bafytest123",
|
||||
serverResponse: `"dGVzdCBibG9iIGNvbnRlbnQ="`, // base64 of "test blob content"
|
||||
contentType: "application/json",
|
||||
wantData: []byte("test blob content"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "blob not found",
|
||||
cid: "notfound",
|
||||
@@ -1049,27 +1042,5 @@ func TestGetBlobServerError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Error("Expected error from GetBlob, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed with status 500") {
|
||||
t.Errorf("Error should mention status 500, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetBlobInvalidBase64 tests error handling for invalid base64 in JSON-wrapped blob
|
||||
func TestGetBlobInvalidBase64(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Return JSON string with invalid base64
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`"not-valid-base64!!!"`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "did:plc:test123", "test-token")
|
||||
_, err := client.GetBlob(context.Background(), "bafytest")
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error from GetBlob with invalid base64, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "base64") {
|
||||
t.Errorf("Error should mention base64, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-4
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/hold/pds"
|
||||
@@ -246,9 +247,10 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
|
||||
Operation string `json:"operation"` // "push" or "pull", defaults to "push" for backward compatibility
|
||||
Manifest struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Config struct {
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
Config struct {
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
MediaType string `json:"mediaType"`
|
||||
} `json:"config"`
|
||||
Layers []struct {
|
||||
Digest string `json:"digest"`
|
||||
@@ -364,15 +366,24 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
|
||||
totalSize += req.Manifest.Config.Size // Add config blob size
|
||||
|
||||
// Extract platforms for multi-arch images
|
||||
// Filter out attestation manifests which have unknown/unknown or empty platforms
|
||||
var platforms []string
|
||||
if isMultiArch {
|
||||
for _, m := range req.Manifest.Manifests {
|
||||
if m.Platform != nil {
|
||||
if m.Platform != nil &&
|
||||
m.Platform.OS != "" && m.Platform.OS != "unknown" &&
|
||||
m.Platform.Architecture != "" && m.Platform.Architecture != "unknown" {
|
||||
platforms = append(platforms, m.Platform.OS+"/"+m.Platform.Architecture)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect artifact type from config media type
|
||||
artifactType := "container-image"
|
||||
if strings.Contains(req.Manifest.Config.MediaType, "helm.config") {
|
||||
artifactType = "helm-chart"
|
||||
}
|
||||
|
||||
// Create Bluesky post if enabled and tag is present
|
||||
// Skip posts for tagless pushes (e.g., buildx platform manifests pushed by digest)
|
||||
if postsEnabled && req.Tag != "" {
|
||||
@@ -399,6 +410,7 @@ func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Reques
|
||||
manifestDigest,
|
||||
totalSize,
|
||||
platforms,
|
||||
artifactType,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create manifest post", "error", err)
|
||||
|
||||
@@ -16,12 +16,14 @@ import (
|
||||
|
||||
// CreateManifestPost creates a Bluesky post announcing a manifest upload
|
||||
// Includes mention facet for the user and an OG card embed with thumbnail
|
||||
// artifactType is "container-image", "helm-chart", or "unknown"
|
||||
func (p *HoldPDS) CreateManifestPost(
|
||||
ctx context.Context,
|
||||
storageDriver driver.StorageDriver,
|
||||
repository, tag, userHandle, userDID, digest string,
|
||||
totalSize int64,
|
||||
platforms []string,
|
||||
artifactType string,
|
||||
) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
@@ -30,7 +32,12 @@ func (p *HoldPDS) CreateManifestPost(
|
||||
|
||||
// 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)
|
||||
var text string
|
||||
if artifactType == "helm-chart" {
|
||||
text = fmt.Sprintf("@%s pushed Helm chart %s", userHandle, repoWithTag)
|
||||
} else {
|
||||
text = fmt.Sprintf("@%s pushed %s", userHandle, repoWithTag)
|
||||
}
|
||||
|
||||
// Only build mention facet - the OG card embed provides the link
|
||||
facets := buildMentionFacet(text, userHandle, userDID)
|
||||
@@ -49,7 +56,9 @@ func (p *HoldPDS) CreateManifestPost(
|
||||
} else {
|
||||
// Build dynamic description
|
||||
var description string
|
||||
if len(platforms) > 0 {
|
||||
if artifactType == "helm-chart" {
|
||||
description = "Helm chart pushed to ATCR"
|
||||
} else if len(platforms) > 0 {
|
||||
description = fmt.Sprintf("Multi-arch: %s", strings.Join(platforms, ", "))
|
||||
} else {
|
||||
description = fmt.Sprintf("Pushed %s to ATCR", formatSize(totalSize))
|
||||
|
||||
Reference in New Issue
Block a user