A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
81
fork

Configure Feed

Select the types of activity you want to include in your feed.

more improvements on repo page rendering. allow for repo avatar image uploads (requires new scopes)

+496 -45
+3 -3
cmd/appview/serve.go
··· 159 159 slog.Info("Hold authorizer initialized with database caching") 160 160 161 161 // Initialize Jetstream workers (background services before HTTP routes) 162 - initializeJetstream(uiDatabase, &cfg.Jetstream, defaultHoldDID, testMode) 162 + initializeJetstream(uiDatabase, &cfg.Jetstream, defaultHoldDID, testMode, refresher) 163 163 164 164 // Create main chi router 165 165 mainRouter := chi.NewRouter() ··· 514 514 } 515 515 516 516 // initializeJetstream initializes the Jetstream workers for real-time events and backfill 517 - func initializeJetstream(database *sql.DB, jetstreamCfg *appview.JetstreamConfig, defaultHoldDID string, testMode bool) { 517 + func initializeJetstream(database *sql.DB, jetstreamCfg *appview.JetstreamConfig, defaultHoldDID string, testMode bool, refresher *oauth.Refresher) { 518 518 // Start Jetstream worker 519 519 jetstreamURL := jetstreamCfg.URL 520 520 ··· 538 538 // Get relay endpoint for sync API (defaults to Bluesky's relay) 539 539 relayEndpoint := jetstreamCfg.RelayEndpoint 540 540 541 - backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint, defaultHoldDID, testMode) 541 + backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint, defaultHoldDID, testMode, refresher) 542 542 if err != nil { 543 543 slog.Warn("Failed to create backfill worker", "component", "jetstream/backfill", "error", err) 544 544 } else {
+39 -6
pkg/appview/db/queries.go
··· 7 7 "time" 8 8 ) 9 9 10 + // BlobCDNURL returns the CDN URL for an ATProto blob 11 + // This is a local copy to avoid importing atproto (prevents circular dependencies) 12 + func BlobCDNURL(did, cid string) string { 13 + return fmt.Sprintf("https://imgs.blue/%s/%s", did, cid) 14 + } 15 + 10 16 // escapeLikePattern escapes SQL LIKE wildcards (%, _) and backslash for safe searching. 11 17 // It also sanitizes the input to prevent injection attacks via special characters. 12 18 func escapeLikePattern(s string) string { ··· 46 52 COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), 47 53 COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0), 48 54 t.created_at, 49 - m.hold_endpoint 55 + m.hold_endpoint, 56 + COALESCE(rp.avatar_cid, '') 50 57 FROM tags t 51 58 JOIN users u ON t.did = u.did 52 59 JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest 53 60 LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository 61 + LEFT JOIN repo_pages rp ON t.did = rp.did AND t.repository = rp.repository 54 62 ` 55 63 56 64 args := []any{currentUserDID} ··· 73 81 for rows.Next() { 74 82 var p Push 75 83 var isStarredInt int 76 - if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint); err != nil { 84 + var avatarCID string 85 + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint, &avatarCID); err != nil { 77 86 return nil, 0, err 78 87 } 79 88 p.IsStarred = isStarredInt > 0 89 + // Prefer repo page avatar over annotation icon 90 + if avatarCID != "" { 91 + p.IconURL = BlobCDNURL(p.DID, avatarCID) 92 + } 80 93 pushes = append(pushes, p) 81 94 } 82 95 ··· 119 132 COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), 120 133 COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0), 121 134 t.created_at, 122 - m.hold_endpoint 135 + m.hold_endpoint, 136 + COALESCE(rp.avatar_cid, '') 123 137 FROM tags t 124 138 JOIN users u ON t.did = u.did 125 139 JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest 126 140 LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository 141 + LEFT JOIN repo_pages rp ON t.did = rp.did AND t.repository = rp.repository 127 142 WHERE u.handle LIKE ? ESCAPE '\' 128 143 OR u.did = ? 129 144 OR t.repository LIKE ? ESCAPE '\' ··· 146 161 for rows.Next() { 147 162 var p Push 148 163 var isStarredInt int 149 - if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint); err != nil { 164 + var avatarCID string 165 + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint, &avatarCID); err != nil { 150 166 return nil, 0, err 151 167 } 152 168 p.IsStarred = isStarredInt > 0 169 + // Prefer repo page avatar over annotation icon 170 + if avatarCID != "" { 171 + p.IconURL = BlobCDNURL(p.DID, avatarCID) 172 + } 153 173 pushes = append(pushes, p) 154 174 } 155 175 ··· 292 312 r.Licenses = annotations["org.opencontainers.image.licenses"] 293 313 r.IconURL = annotations["io.atcr.icon"] 294 314 r.ReadmeURL = annotations["io.atcr.readme"] 315 + 316 + // Check for repo page avatar (overrides annotation icon) 317 + repoPage, err := GetRepoPage(db, did, r.Name) 318 + if err == nil && repoPage != nil && repoPage.AvatarCID != "" { 319 + r.IconURL = BlobCDNURL(did, repoPage.AvatarCID) 320 + } 295 321 296 322 repos = append(repos, r) 297 323 } ··· 1660 1686 COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''), 1661 1687 rs.pull_count, 1662 1688 rs.star_count, 1663 - COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0) 1689 + COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0), 1690 + COALESCE(rp.avatar_cid, '') 1664 1691 FROM latest_manifests lm 1665 1692 JOIN manifests m ON lm.latest_id = m.id 1666 1693 JOIN users u ON m.did = u.did 1667 1694 JOIN repo_stats rs ON m.did = rs.did AND m.repository = rs.repository 1695 + LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository 1668 1696 ORDER BY rs.score DESC, rs.star_count DESC, rs.pull_count DESC, m.created_at DESC 1669 1697 LIMIT ? 1670 1698 ` ··· 1679 1707 for rows.Next() { 1680 1708 var f FeaturedRepository 1681 1709 var isStarredInt int 1710 + var avatarCID string 1682 1711 1683 1712 if err := rows.Scan(&f.OwnerDID, &f.OwnerHandle, &f.Repository, 1684 - &f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt); err != nil { 1713 + &f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt, &avatarCID); err != nil { 1685 1714 return nil, err 1686 1715 } 1687 1716 f.IsStarred = isStarredInt > 0 1717 + // Prefer repo page avatar over annotation icon 1718 + if avatarCID != "" { 1719 + f.IconURL = BlobCDNURL(f.OwnerDID, avatarCID) 1720 + } 1688 1721 1689 1722 featured = append(featured, f) 1690 1723 }
+114
pkg/appview/handlers/images.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 8 + "io" 7 9 "net/http" 8 10 "strings" 11 + "time" 9 12 10 13 "atcr.io/pkg/appview/db" 11 14 "atcr.io/pkg/appview/middleware" ··· 155 158 156 159 w.WriteHeader(http.StatusOK) 157 160 } 161 + 162 + // UploadAvatarHandler handles uploading/updating a repository avatar 163 + type UploadAvatarHandler struct { 164 + DB *sql.DB 165 + Refresher *oauth.Refresher 166 + } 167 + 168 + // validImageTypes are the allowed MIME types for avatars (matches lexicon) 169 + var validImageTypes = map[string]bool{ 170 + "image/png": true, 171 + "image/jpeg": true, 172 + "image/webp": true, 173 + } 174 + 175 + func (h *UploadAvatarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 176 + user := middleware.GetUser(r) 177 + if user == nil { 178 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 179 + return 180 + } 181 + 182 + repo := chi.URLParam(r, "repository") 183 + 184 + // Parse multipart form (max 3MB to match lexicon maxSize) 185 + if err := r.ParseMultipartForm(3 << 20); err != nil { 186 + http.Error(w, "File too large (max 3MB)", http.StatusBadRequest) 187 + return 188 + } 189 + 190 + file, header, err := r.FormFile("avatar") 191 + if err != nil { 192 + http.Error(w, "No file provided", http.StatusBadRequest) 193 + return 194 + } 195 + defer file.Close() 196 + 197 + // Validate MIME type 198 + contentType := header.Header.Get("Content-Type") 199 + if !validImageTypes[contentType] { 200 + http.Error(w, "Invalid file type. Must be PNG, JPEG, or WebP", http.StatusBadRequest) 201 + return 202 + } 203 + 204 + // Read file data 205 + data, err := io.ReadAll(io.LimitReader(file, 3<<20+1)) // Read up to 3MB + 1 byte 206 + if err != nil { 207 + http.Error(w, "Failed to read file", http.StatusInternalServerError) 208 + return 209 + } 210 + if len(data) > 3<<20 { 211 + http.Error(w, "File too large (max 3MB)", http.StatusBadRequest) 212 + return 213 + } 214 + 215 + // Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety) 216 + pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 217 + 218 + // Upload blob to PDS 219 + blobRef, err := pdsClient.UploadBlob(r.Context(), data, contentType) 220 + if err != nil { 221 + if handleOAuthError(r.Context(), h.Refresher, user.DID, err) { 222 + http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized) 223 + return 224 + } 225 + http.Error(w, fmt.Sprintf("Failed to upload image: %v", err), http.StatusInternalServerError) 226 + return 227 + } 228 + 229 + // Fetch existing repo page record to preserve description 230 + var existingDescription string 231 + var existingCreatedAt time.Time 232 + record, err := pdsClient.GetRecord(r.Context(), atproto.RepoPageCollection, repo) 233 + if err == nil { 234 + // Parse existing record to preserve description 235 + var existingRecord atproto.RepoPageRecord 236 + if jsonErr := json.Unmarshal(record.Value, &existingRecord); jsonErr == nil { 237 + existingDescription = existingRecord.Description 238 + existingCreatedAt = existingRecord.CreatedAt 239 + } 240 + } else if !errors.Is(err, atproto.ErrRecordNotFound) { 241 + // Some other error - check if OAuth error 242 + if handleOAuthError(r.Context(), h.Refresher, user.DID, err) { 243 + http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized) 244 + return 245 + } 246 + // Log but continue - we'll create a new record 247 + } 248 + 249 + // Create updated repo page record 250 + repoPage := atproto.NewRepoPageRecord(repo, existingDescription, blobRef) 251 + // Preserve original createdAt if record existed 252 + if !existingCreatedAt.IsZero() { 253 + repoPage.CreatedAt = existingCreatedAt 254 + } 255 + 256 + // Save record to PDS 257 + _, err = pdsClient.PutRecord(r.Context(), atproto.RepoPageCollection, repo, repoPage) 258 + if err != nil { 259 + if handleOAuthError(r.Context(), h.Refresher, user.DID, err) { 260 + http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized) 261 + return 262 + } 263 + http.Error(w, fmt.Sprintf("Failed to update repository page: %v", err), http.StatusInternalServerError) 264 + return 265 + } 266 + 267 + // Return new avatar URL 268 + avatarURL := atproto.BlobCDNURL(user.DID, blobRef.Ref.Link) 269 + w.Header().Set("Content-Type", "application/json") 270 + json.NewEncoder(w).Encode(map[string]string{"avatarURL": avatarURL}) 271 + }
+10 -7
pkg/appview/handlers/repository.go
··· 195 195 196 196 // Try repo page record from database (synced from PDS via Jetstream) 197 197 repoPage, err := db.GetRepoPage(h.DB, owner.DID, repository) 198 - if err == nil && repoPage != nil && repoPage.Description != "" { 199 - // Use repo page data 200 - if h.ReadmeFetcher != nil { 198 + if err == nil && repoPage != nil { 199 + // Use repo page avatar if present 200 + if repoPage.AvatarCID != "" { 201 + repo.IconURL = atproto.BlobCDNURL(owner.DID, repoPage.AvatarCID) 202 + } 203 + // Render description as markdown if present 204 + if repoPage.Description != "" && h.ReadmeFetcher != nil { 201 205 html, err := h.ReadmeFetcher.RenderMarkdown([]byte(repoPage.Description)) 202 206 if err != nil { 203 207 slog.Warn("Failed to render repo page description", "error", err) ··· 205 209 readmeHTML = template.HTML(html) 206 210 } 207 211 } 208 - if repoPage.AvatarCID != "" { 209 - repo.IconURL = atproto.BlobCDNURL(owner.DID, repoPage.AvatarCID) 210 - } 211 - } else if h.ReadmeFetcher != nil { 212 + } 213 + // Fall back to fetching README from URL annotations if no description in repo page 214 + if readmeHTML == "" && h.ReadmeFetcher != nil { 212 215 // Fall back to fetching from URL annotations 213 216 readmeURL := repo.ReadmeURL 214 217 if readmeURL == "" && repo.SourceURL != "" {
+201 -4
pkg/appview/jetstream/backfill.go
··· 5 5 "database/sql" 6 6 "encoding/json" 7 7 "fmt" 8 + "io" 8 9 "log/slog" 10 + "net/http" 9 11 "strings" 10 12 "time" 11 13 12 14 "atcr.io/pkg/appview/db" 15 + "atcr.io/pkg/appview/readme" 13 16 "atcr.io/pkg/atproto" 17 + "atcr.io/pkg/auth/oauth" 14 18 ) 15 19 16 20 // BackfillWorker uses com.atproto.sync.listReposByCollection to backfill historical data 17 21 type BackfillWorker struct { 18 22 db *sql.DB 19 23 client *atproto.Client 20 - processor *Processor // Shared processor for DB operations 21 - defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io") 22 - testMode bool // If true, suppress warnings for external holds 24 + processor *Processor // Shared processor for DB operations 25 + defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io") 26 + testMode bool // If true, suppress warnings for external holds 27 + refresher *oauth.Refresher // OAuth refresher for PDS writes (optional, can be nil) 23 28 } 24 29 25 30 // BackfillState tracks backfill progress ··· 36 41 // NewBackfillWorker creates a backfill worker using sync API 37 42 // defaultHoldDID should be in format "did:web:hold01.atcr.io" 38 43 // To find a hold's DID, visit: https://hold-url/.well-known/did.json 39 - func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, testMode bool) (*BackfillWorker, error) { 44 + // refresher is optional - if provided, backfill will try to update PDS records when fetching README content 45 + func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, testMode bool, refresher *oauth.Refresher) (*BackfillWorker, error) { 40 46 // Create client for relay - used only for listReposByCollection 41 47 client := atproto.NewClient(relayEndpoint, "", "") 42 48 ··· 46 52 processor: NewProcessor(database, false), // No cache for batch processing 47 53 defaultHoldDID: defaultHoldDID, 48 54 testMode: testMode, 55 + refresher: refresher, 49 56 }, nil 50 57 } 51 58 ··· 215 222 // This fixes out-of-order backfill where older manifests can overwrite newer annotations 216 223 if err := b.reconcileAnnotations(ctx, did, pdsClient); err != nil { 217 224 slog.Warn("Backfill failed to reconcile annotations", "did", did, "error", err) 225 + } 226 + } 227 + 228 + // After processing repo pages, fetch descriptions from external sources if empty 229 + if collection == atproto.RepoPageCollection { 230 + if err := b.reconcileRepoPageDescriptions(ctx, did, pdsEndpoint); err != nil { 231 + slog.Warn("Backfill failed to reconcile repo page descriptions", "did", did, "error", err) 218 232 } 219 233 } 220 234 ··· 417 431 418 432 return nil 419 433 } 434 + 435 + // reconcileRepoPageDescriptions fetches README content from external sources for repo pages with empty descriptions 436 + // If the user has an OAuth session, it updates the PDS record (source of truth) 437 + // Otherwise, it just stores the fetched content in the database 438 + func (b *BackfillWorker) reconcileRepoPageDescriptions(ctx context.Context, did, pdsEndpoint string) error { 439 + // Get all repo pages for this DID 440 + repoPages, err := db.GetRepoPagesByDID(b.db, did) 441 + if err != nil { 442 + return fmt.Errorf("failed to get repo pages: %w", err) 443 + } 444 + 445 + for _, page := range repoPages { 446 + // Skip pages that already have a description 447 + if page.Description != "" { 448 + continue 449 + } 450 + 451 + // Get annotations from the repository's manifest 452 + annotations, err := db.GetRepositoryAnnotations(b.db, did, page.Repository) 453 + if err != nil { 454 + slog.Debug("Failed to get annotations for repo page", "did", did, "repository", page.Repository, "error", err) 455 + continue 456 + } 457 + 458 + // Try to fetch README content from external sources 459 + description := b.fetchReadmeContent(ctx, annotations) 460 + if description == "" { 461 + // No README content available, skip 462 + continue 463 + } 464 + 465 + slog.Info("Fetched README for repo page", "did", did, "repository", page.Repository, "descriptionLength", len(description)) 466 + 467 + // Try to update PDS if we have OAuth session 468 + pdsUpdated := false 469 + if b.refresher != nil { 470 + if err := b.updateRepoPageInPDS(ctx, did, pdsEndpoint, page.Repository, description, page.AvatarCID); err != nil { 471 + slog.Debug("Could not update repo page in PDS, falling back to DB-only", "did", did, "repository", page.Repository, "error", err) 472 + } else { 473 + pdsUpdated = true 474 + slog.Info("Updated repo page in PDS with fetched description", "did", did, "repository", page.Repository) 475 + } 476 + } 477 + 478 + // Always update database with the fetched content 479 + if err := db.UpsertRepoPage(b.db, did, page.Repository, description, page.AvatarCID, page.CreatedAt, time.Now()); err != nil { 480 + slog.Warn("Failed to update repo page in database", "did", did, "repository", page.Repository, "error", err) 481 + } else if !pdsUpdated { 482 + slog.Info("Updated repo page in database (PDS not updated)", "did", did, "repository", page.Repository) 483 + } 484 + } 485 + 486 + return nil 487 + } 488 + 489 + // fetchReadmeContent attempts to fetch README content from external sources based on annotations 490 + // Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source 491 + func (b *BackfillWorker) fetchReadmeContent(ctx context.Context, annotations map[string]string) string { 492 + // Create a context with timeout for README fetching 493 + fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second) 494 + defer cancel() 495 + 496 + // Priority 1: Direct README URL from io.atcr.readme annotation 497 + if readmeURL := annotations["io.atcr.readme"]; readmeURL != "" { 498 + content, err := b.fetchRawReadme(fetchCtx, readmeURL) 499 + if err != nil { 500 + slog.Debug("Failed to fetch README from io.atcr.readme annotation", "url", readmeURL, "error", err) 501 + } else if content != "" { 502 + return content 503 + } 504 + } 505 + 506 + // Priority 2: Derive README URL from org.opencontainers.image.source 507 + if sourceURL := annotations["org.opencontainers.image.source"]; sourceURL != "" { 508 + // Try main branch first, then master 509 + for _, branch := range []string{"main", "master"} { 510 + readmeURL := readme.DeriveReadmeURL(sourceURL, branch) 511 + if readmeURL == "" { 512 + continue 513 + } 514 + 515 + content, err := b.fetchRawReadme(fetchCtx, readmeURL) 516 + if err != nil { 517 + // Only log non-404 errors (404 is expected when trying main vs master) 518 + if !readme.Is404(err) { 519 + slog.Debug("Failed to fetch README from source URL", "url", readmeURL, "branch", branch, "error", err) 520 + } 521 + continue 522 + } 523 + 524 + if content != "" { 525 + return content 526 + } 527 + } 528 + } 529 + 530 + return "" 531 + } 532 + 533 + // fetchRawReadme fetches raw markdown content from a URL 534 + func (b *BackfillWorker) fetchRawReadme(ctx context.Context, readmeURL string) (string, error) { 535 + req, err := http.NewRequestWithContext(ctx, "GET", readmeURL, nil) 536 + if err != nil { 537 + return "", fmt.Errorf("failed to create request: %w", err) 538 + } 539 + 540 + req.Header.Set("User-Agent", "ATCR-Backfill-README-Fetcher/1.0") 541 + 542 + client := &http.Client{ 543 + Timeout: 10 * time.Second, 544 + CheckRedirect: func(req *http.Request, via []*http.Request) error { 545 + if len(via) >= 5 { 546 + return fmt.Errorf("too many redirects") 547 + } 548 + return nil 549 + }, 550 + } 551 + 552 + resp, err := client.Do(req) 553 + if err != nil { 554 + return "", fmt.Errorf("failed to fetch URL: %w", err) 555 + } 556 + defer resp.Body.Close() 557 + 558 + if resp.StatusCode != http.StatusOK { 559 + return "", fmt.Errorf("status %d", resp.StatusCode) 560 + } 561 + 562 + // Limit content size to 100KB 563 + limitedReader := io.LimitReader(resp.Body, 100*1024) 564 + content, err := io.ReadAll(limitedReader) 565 + if err != nil { 566 + return "", fmt.Errorf("failed to read response body: %w", err) 567 + } 568 + 569 + return string(content), nil 570 + } 571 + 572 + // updateRepoPageInPDS updates the repo page record in the user's PDS using OAuth 573 + func (b *BackfillWorker) updateRepoPageInPDS(ctx context.Context, did, pdsEndpoint, repository, description, avatarCID string) error { 574 + if b.refresher == nil { 575 + return fmt.Errorf("no OAuth refresher available") 576 + } 577 + 578 + // Create ATProto client with session provider 579 + pdsClient := atproto.NewClientWithSessionProvider(pdsEndpoint, did, b.refresher) 580 + 581 + // Get existing repo page record to preserve other fields 582 + existingRecord, err := pdsClient.GetRecord(ctx, atproto.RepoPageCollection, repository) 583 + var createdAt time.Time 584 + var avatarRef *atproto.ATProtoBlobRef 585 + 586 + if err == nil && existingRecord != nil { 587 + // Parse existing record 588 + var existingPage atproto.RepoPageRecord 589 + if err := json.Unmarshal(existingRecord.Value, &existingPage); err == nil { 590 + createdAt = existingPage.CreatedAt 591 + avatarRef = existingPage.Avatar 592 + } 593 + } 594 + 595 + if createdAt.IsZero() { 596 + createdAt = time.Now() 597 + } 598 + 599 + // Create updated repo page record 600 + repoPage := &atproto.RepoPageRecord{ 601 + Type: atproto.RepoPageCollection, 602 + Repository: repository, 603 + Description: description, 604 + Avatar: avatarRef, 605 + CreatedAt: createdAt, 606 + UpdatedAt: time.Now(), 607 + } 608 + 609 + // Write to PDS - this will use DoWithSession internally 610 + _, err = pdsClient.PutRecord(ctx, atproto.RepoPageCollection, repository, repoPage) 611 + if err != nil { 612 + return fmt.Errorf("failed to write to PDS: %w", err) 613 + } 614 + 615 + return nil 616 + }
+1 -3
pkg/appview/jetstream/worker.go
··· 61 61 jetstreamURL: jetstreamURL, 62 62 startCursor: startCursor, 63 63 wantedCollections: []string{ 64 - atproto.ManifestCollection, // io.atcr.manifest 65 - atproto.TagCollection, // io.atcr.tag 66 - atproto.StarCollection, // io.atcr.sailor.star 64 + "io.atcr.*", // Subscribe to all ATCR collections 67 65 }, 68 66 processor: NewProcessor(database, true), // Use cache for live streaming 69 67 }
+5
pkg/appview/routes/routes.go
··· 188 188 Refresher: deps.Refresher, 189 189 }).ServeHTTP) 190 190 191 + r.Post("/api/images/{repository}/avatar", (&uihandlers.UploadAvatarHandler{ 192 + DB: deps.Database, 193 + Refresher: deps.Refresher, 194 + }).ServeHTTP) 195 + 191 196 // Device approval page (authenticated) 192 197 r.Get("/device", (&uihandlers.DeviceApprovalPageHandler{ 193 198 Store: deps.DeviceStore,
+29
pkg/appview/static/css/style.css
··· 1236 1236 flex-shrink: 0; 1237 1237 } 1238 1238 1239 + .repo-hero-icon-wrapper { 1240 + position: relative; 1241 + display: inline-block; 1242 + flex-shrink: 0; 1243 + } 1244 + 1245 + .avatar-upload-overlay { 1246 + position: absolute; 1247 + inset: 0; 1248 + display: flex; 1249 + align-items: center; 1250 + justify-content: center; 1251 + background: rgba(0, 0, 0, 0.5); 1252 + border-radius: 12px; 1253 + opacity: 0; 1254 + cursor: pointer; 1255 + transition: opacity 0.2s ease; 1256 + } 1257 + 1258 + .avatar-upload-overlay i { 1259 + color: white; 1260 + width: 24px; 1261 + height: 24px; 1262 + } 1263 + 1264 + .repo-hero-icon-wrapper:hover .avatar-upload-overlay { 1265 + opacity: 1; 1266 + } 1267 + 1239 1268 .repo-hero-info { 1240 1269 flex: 1; 1241 1270 }
+63
pkg/appview/static/js/app.js
··· 434 434 } 435 435 } 436 436 437 + // Upload repository avatar 438 + async function uploadAvatar(input, repository) { 439 + const file = input.files[0]; 440 + if (!file) return; 441 + 442 + // Client-side validation 443 + const validTypes = ['image/png', 'image/jpeg', 'image/webp']; 444 + if (!validTypes.includes(file.type)) { 445 + alert('Please select a PNG, JPEG, or WebP image'); 446 + return; 447 + } 448 + if (file.size > 3 * 1024 * 1024) { 449 + alert('Image must be less than 3MB'); 450 + return; 451 + } 452 + 453 + const formData = new FormData(); 454 + formData.append('avatar', file); 455 + 456 + try { 457 + const response = await fetch(`/api/images/${repository}/avatar`, { 458 + method: 'POST', 459 + credentials: 'include', 460 + body: formData 461 + }); 462 + 463 + if (response.status === 401) { 464 + window.location.href = '/auth/oauth/login'; 465 + return; 466 + } 467 + 468 + if (!response.ok) { 469 + const error = await response.text(); 470 + throw new Error(error); 471 + } 472 + 473 + const data = await response.json(); 474 + 475 + // Update the avatar image on the page 476 + const wrapper = document.querySelector('.repo-hero-icon-wrapper'); 477 + if (!wrapper) return; 478 + 479 + const existingImg = wrapper.querySelector('.repo-hero-icon'); 480 + const placeholder = wrapper.querySelector('.repo-hero-icon-placeholder'); 481 + 482 + if (existingImg) { 483 + existingImg.src = data.avatarURL; 484 + } else if (placeholder) { 485 + const newImg = document.createElement('img'); 486 + newImg.src = data.avatarURL; 487 + newImg.alt = repository; 488 + newImg.className = 'repo-hero-icon'; 489 + placeholder.replaceWith(newImg); 490 + } 491 + } catch (err) { 492 + console.error('Error uploading avatar:', err); 493 + alert('Failed to upload avatar: ' + err.message); 494 + } 495 + 496 + // Clear input so same file can be selected again 497 + input.value = ''; 498 + } 499 + 437 500 // Close modal when clicking outside 438 501 document.addEventListener('DOMContentLoaded', () => { 439 502 const modal = document.getElementById('manifest-delete-modal');
+9 -14
pkg/appview/storage/manifest_store.go
··· 429 429 // This syncs repository metadata from manifest annotations to the io.atcr.repo.page collection 430 430 // Only creates a new record if one doesn't exist (doesn't overwrite user's custom content) 431 431 func (s *ManifestStore) ensureRepoPage(ctx context.Context, manifestRecord *atproto.ManifestRecord) { 432 - // Skip if no annotations 433 - if manifestRecord.Annotations == nil { 434 - return 435 - } 436 - 437 432 // Check if repo page already exists (don't overwrite user's custom content) 438 433 rkey := s.ctx.Repository 439 434 _, err := s.ctx.ATProtoClient.GetRecord(ctx, atproto.RepoPageCollection, rkey) ··· 449 444 return 450 445 } 451 446 447 + // Get annotations (may be nil if image has no OCI labels) 448 + annotations := manifestRecord.Annotations 449 + if annotations == nil { 450 + annotations = make(map[string]string) 451 + } 452 + 452 453 // Try to fetch README content from external sources 453 454 // Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source > org.opencontainers.image.description 454 - description := s.fetchReadmeContent(ctx, manifestRecord.Annotations) 455 + description := s.fetchReadmeContent(ctx, annotations) 455 456 456 457 // If no README content could be fetched, fall back to description annotation 457 458 if description == "" { 458 - description = manifestRecord.Annotations["org.opencontainers.image.description"] 459 + description = annotations["org.opencontainers.image.description"] 459 460 } 460 461 461 462 // Try to fetch and upload icon from io.atcr.icon annotation 462 463 var avatarRef *atproto.ATProtoBlobRef 463 - if iconURL := manifestRecord.Annotations["io.atcr.icon"]; iconURL != "" { 464 + if iconURL := annotations["io.atcr.icon"]; iconURL != "" { 464 465 avatarRef = s.fetchAndUploadIcon(ctx, iconURL) 465 - } 466 - 467 - // If no description and no icon, nothing to create 468 - if description == "" && avatarRef == nil { 469 - slog.Debug("No README, description, or icon found for repo page", "did", s.ctx.DID, "repository", s.ctx.Repository) 470 - return 471 466 } 472 467 473 468 // Create new repo page record with description and optional avatar
+14 -5
pkg/appview/templates/pages/repository.html
··· 27 27 <!-- Repository Header --> 28 28 <div class="repository-header"> 29 29 <div class="repo-hero"> 30 - {{ if .Repository.IconURL }} 31 - <img src="{{ .Repository.IconURL }}" alt="{{ .Repository.Name }}" class="repo-hero-icon"> 32 - {{ else }} 33 - <div class="repo-hero-icon-placeholder">{{ firstChar .Repository.Name }}</div> 34 - {{ end }} 30 + <div class="repo-hero-icon-wrapper"> 31 + {{ if .Repository.IconURL }} 32 + <img src="{{ .Repository.IconURL }}" alt="{{ .Repository.Name }}" class="repo-hero-icon"> 33 + {{ else }} 34 + <div class="repo-hero-icon-placeholder">{{ firstChar .Repository.Name }}</div> 35 + {{ end }} 36 + {{ if $.IsOwner }} 37 + <label class="avatar-upload-overlay" for="avatar-upload"> 38 + <i data-lucide="plus"></i> 39 + </label> 40 + <input type="file" id="avatar-upload" accept="image/png,image/jpeg,image/webp" 41 + onchange="uploadAvatar(this, '{{ .Repository.Name }}')" hidden> 42 + {{ end }} 43 + </div> 35 44 <div class="repo-hero-info"> 36 45 <h1> 37 46 <a href="/u/{{ .Owner.Handle }}" class="owner-link">{{ .Owner.Handle }}</a>
+4 -1
pkg/atproto/client.go
··· 310 310 311 311 err := c.sessionProvider.DoWithSession(ctx, c.did, func(session *indigo_oauth.ClientSession) error { 312 312 apiClient := session.APIClient() 313 + // IMPORTANT: Use io.Reader for blob uploads 314 + // LexDo JSON-encodes []byte (base64), but streams io.Reader as raw bytes 315 + // Use the actual MIME type so PDS can validate against blob:image/* scope 313 316 return apiClient.LexDo(ctx, 314 317 "POST", 315 318 mimeType, 316 319 "com.atproto.repo.uploadBlob", 317 320 nil, 318 - data, 321 + bytes.NewReader(data), 319 322 &result, 320 323 ) 321 324 })
+4 -2
pkg/auth/oauth/client.go
··· 77 77 func GetDefaultScopes(did string) []string { 78 78 scopes := []string{ 79 79 "atproto", 80 + // Used for service token validation on holds 81 + "rpc:com.atproto.repo.getRecord?aud=*", 80 82 // Image manifest types (single-arch) 81 83 "blob:application/vnd.oci.image.manifest.v1+json", 82 84 "blob:application/vnd.docker.distribution.manifest.v2+json", ··· 85 87 "blob:application/vnd.docker.distribution.manifest.list.v2+json", 86 88 // OCI artifact manifests (for cosign signatures, SBOMs, attestations) 87 89 "blob:application/vnd.cncf.oras.artifact.manifest.v1+json", 88 - // Used for service token validation on holds 89 - "rpc:com.atproto.repo.getRecord?aud=*", 90 + // image avatars 91 + "blob:image/*", 90 92 } 91 93 92 94 // Add repo scopes