A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
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