varies fixes for indigo xrpc calls, avatars broken on bsku profile change, opengraph card fixes, other ui improvements

This commit is contained in:
Evan Jarrett
2026-01-14 23:14:43 -06:00
parent 23a9b52619
commit 055b34af71
19 changed files with 467 additions and 543 deletions
+48
View File
@@ -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"
}
}
}
+1
View File
@@ -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
View File
@@ -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)
+13 -2
View File
@@ -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
}
+2
View File
@@ -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."},
+8 -19
View File
@@ -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()
+1
View File
@@ -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
+6
View File
@@ -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
+76
View File
@@ -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
+29 -14
View File
@@ -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
+27
View File
@@ -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)
======================================== */
+3 -2
View File
@@ -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,
}
}
+9 -10
View File
@@ -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
View File
@@ -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
}
+5 -34
View File
@@ -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
View File
@@ -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)
+11 -2
View File
@@ -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))