From d11356cd180f61eea3a054ab7d2d2934344ecbf9 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sun, 21 Dec 2025 21:51:44 -0600 Subject: [PATCH] more improvements on repo page rendering. allow for repo avatar image uploads (requires new scopes) --- cmd/appview/serve.go | 6 +- pkg/appview/db/queries.go | 45 ++++- pkg/appview/handlers/images.go | 114 +++++++++++ pkg/appview/handlers/repository.go | 17 +- pkg/appview/jetstream/backfill.go | 205 +++++++++++++++++++- pkg/appview/jetstream/worker.go | 4 +- pkg/appview/routes/routes.go | 5 + pkg/appview/static/css/style.css | 29 +++ pkg/appview/static/js/app.js | 63 ++++++ pkg/appview/storage/manifest_store.go | 23 +-- pkg/appview/templates/pages/repository.html | 19 +- pkg/atproto/client.go | 5 +- pkg/auth/oauth/client.go | 6 +- 13 files changed, 496 insertions(+), 45 deletions(-) diff --git a/cmd/appview/serve.go b/cmd/appview/serve.go index 64f9886..9822bc3 100644 --- a/cmd/appview/serve.go +++ b/cmd/appview/serve.go @@ -159,7 +159,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error { slog.Info("Hold authorizer initialized with database caching") // Initialize Jetstream workers (background services before HTTP routes) - initializeJetstream(uiDatabase, &cfg.Jetstream, defaultHoldDID, testMode) + initializeJetstream(uiDatabase, &cfg.Jetstream, defaultHoldDID, testMode, refresher) // Create main chi router mainRouter := chi.NewRouter() @@ -514,7 +514,7 @@ func createTokenIssuer(cfg *appview.Config) (*token.Issuer, error) { } // initializeJetstream initializes the Jetstream workers for real-time events and backfill -func initializeJetstream(database *sql.DB, jetstreamCfg *appview.JetstreamConfig, defaultHoldDID string, testMode bool) { +func initializeJetstream(database *sql.DB, jetstreamCfg *appview.JetstreamConfig, defaultHoldDID string, testMode bool, refresher *oauth.Refresher) { // Start Jetstream worker jetstreamURL := jetstreamCfg.URL @@ -538,7 +538,7 @@ func initializeJetstream(database *sql.DB, jetstreamCfg *appview.JetstreamConfig // Get relay endpoint for sync API (defaults to Bluesky's relay) relayEndpoint := jetstreamCfg.RelayEndpoint - backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint, defaultHoldDID, testMode) + backfillWorker, err := jetstream.NewBackfillWorker(database, relayEndpoint, defaultHoldDID, testMode, refresher) if err != nil { slog.Warn("Failed to create backfill worker", "component", "jetstream/backfill", "error", err) } else { diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 6db1c8e..37c7ed7 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -7,6 +7,12 @@ import ( "time" ) +// BlobCDNURL returns the CDN URL for an ATProto blob +// This is a local copy to avoid importing atproto (prevents circular dependencies) +func BlobCDNURL(did, cid string) string { + return fmt.Sprintf("https://imgs.blue/%s/%s", did, cid) +} + // escapeLikePattern escapes SQL LIKE wildcards (%, _) and backslash for safe searching. // It also sanitizes the input to prevent injection attacks via special characters. func escapeLikePattern(s string) string { @@ -46,11 +52,13 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string, currentUs COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0), t.created_at, - m.hold_endpoint + m.hold_endpoint, + COALESCE(rp.avatar_cid, '') FROM tags t JOIN users u ON t.did = u.did JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository + LEFT JOIN repo_pages rp ON t.did = rp.did AND t.repository = rp.repository ` args := []any{currentUserDID} @@ -73,10 +81,15 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string, currentUs for rows.Next() { var p Push var isStarredInt int - 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 { + var avatarCID string + 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 { return nil, 0, err } p.IsStarred = isStarredInt > 0 + // Prefer repo page avatar over annotation icon + if avatarCID != "" { + p.IconURL = BlobCDNURL(p.DID, avatarCID) + } pushes = append(pushes, p) } @@ -119,11 +132,13 @@ func SearchPushes(db *sql.DB, query string, limit, offset int, currentUserDID st COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0), t.created_at, - m.hold_endpoint + m.hold_endpoint, + COALESCE(rp.avatar_cid, '') FROM tags t JOIN users u ON t.did = u.did JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository + LEFT JOIN repo_pages rp ON t.did = rp.did AND t.repository = rp.repository WHERE u.handle LIKE ? ESCAPE '\' OR u.did = ? OR t.repository LIKE ? ESCAPE '\' @@ -146,10 +161,15 @@ func SearchPushes(db *sql.DB, query string, limit, offset int, currentUserDID st for rows.Next() { var p Push var isStarredInt int - 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 { + var avatarCID string + 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 { return nil, 0, err } p.IsStarred = isStarredInt > 0 + // Prefer repo page avatar over annotation icon + if avatarCID != "" { + p.IconURL = BlobCDNURL(p.DID, avatarCID) + } pushes = append(pushes, p) } @@ -293,6 +313,12 @@ func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) { r.IconURL = annotations["io.atcr.icon"] r.ReadmeURL = annotations["io.atcr.readme"] + // Check for repo page avatar (overrides annotation icon) + repoPage, err := GetRepoPage(db, did, r.Name) + if err == nil && repoPage != nil && repoPage.AvatarCID != "" { + r.IconURL = BlobCDNURL(did, repoPage.AvatarCID) + } + repos = append(repos, r) } @@ -1660,11 +1686,13 @@ func GetFeaturedRepositories(db *sql.DB, limit int, currentUserDID string) ([]Fe COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''), rs.pull_count, rs.star_count, - COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0) + COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0), + COALESCE(rp.avatar_cid, '') FROM latest_manifests lm JOIN manifests m ON lm.latest_id = m.id JOIN users u ON m.did = u.did JOIN repo_stats rs ON m.did = rs.did AND m.repository = rs.repository + LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository ORDER BY rs.score DESC, rs.star_count DESC, rs.pull_count DESC, m.created_at DESC LIMIT ? ` @@ -1679,12 +1707,17 @@ func GetFeaturedRepositories(db *sql.DB, limit int, currentUserDID string) ([]Fe for rows.Next() { var f FeaturedRepository var isStarredInt int + var avatarCID string if err := rows.Scan(&f.OwnerDID, &f.OwnerHandle, &f.Repository, - &f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt); err != nil { + &f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt, &avatarCID); err != nil { return nil, err } f.IsStarred = isStarredInt > 0 + // Prefer repo page avatar over annotation icon + if avatarCID != "" { + f.IconURL = BlobCDNURL(f.OwnerDID, avatarCID) + } featured = append(featured, f) } diff --git a/pkg/appview/handlers/images.go b/pkg/appview/handlers/images.go index 7e4949c..b7a0ca7 100644 --- a/pkg/appview/handlers/images.go +++ b/pkg/appview/handlers/images.go @@ -3,9 +3,12 @@ package handlers import ( "database/sql" "encoding/json" + "errors" "fmt" + "io" "net/http" "strings" + "time" "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/middleware" @@ -155,3 +158,114 @@ func (h *DeleteManifestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request w.WriteHeader(http.StatusOK) } + +// UploadAvatarHandler handles uploading/updating a repository avatar +type UploadAvatarHandler struct { + DB *sql.DB + Refresher *oauth.Refresher +} + +// validImageTypes are the allowed MIME types for avatars (matches lexicon) +var validImageTypes = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/webp": true, +} + +func (h *UploadAvatarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + repo := chi.URLParam(r, "repository") + + // Parse multipart form (max 3MB to match lexicon maxSize) + if err := r.ParseMultipartForm(3 << 20); err != nil { + http.Error(w, "File too large (max 3MB)", http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("avatar") + if err != nil { + http.Error(w, "No file provided", http.StatusBadRequest) + return + } + defer file.Close() + + // Validate MIME type + contentType := header.Header.Get("Content-Type") + if !validImageTypes[contentType] { + http.Error(w, "Invalid file type. Must be PNG, JPEG, or WebP", http.StatusBadRequest) + return + } + + // Read file data + data, err := io.ReadAll(io.LimitReader(file, 3<<20+1)) // Read up to 3MB + 1 byte + if err != nil { + http.Error(w, "Failed to read file", http.StatusInternalServerError) + return + } + if len(data) > 3<<20 { + http.Error(w, "File too large (max 3MB)", http.StatusBadRequest) + return + } + + // Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety) + pdsClient := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) + + // Upload blob to PDS + blobRef, err := pdsClient.UploadBlob(r.Context(), data, contentType) + if err != nil { + if handleOAuthError(r.Context(), h.Refresher, user.DID, err) { + http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized) + return + } + http.Error(w, fmt.Sprintf("Failed to upload image: %v", err), http.StatusInternalServerError) + return + } + + // Fetch existing repo page record to preserve description + var existingDescription string + var existingCreatedAt time.Time + record, err := pdsClient.GetRecord(r.Context(), atproto.RepoPageCollection, repo) + if err == nil { + // Parse existing record to preserve description + var existingRecord atproto.RepoPageRecord + if jsonErr := json.Unmarshal(record.Value, &existingRecord); jsonErr == nil { + existingDescription = existingRecord.Description + existingCreatedAt = existingRecord.CreatedAt + } + } else if !errors.Is(err, atproto.ErrRecordNotFound) { + // Some other error - check if OAuth error + if handleOAuthError(r.Context(), h.Refresher, user.DID, err) { + http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized) + return + } + // Log but continue - we'll create a new record + } + + // Create updated repo page record + repoPage := atproto.NewRepoPageRecord(repo, existingDescription, blobRef) + // Preserve original createdAt if record existed + if !existingCreatedAt.IsZero() { + repoPage.CreatedAt = existingCreatedAt + } + + // Save record to PDS + _, err = pdsClient.PutRecord(r.Context(), atproto.RepoPageCollection, repo, repoPage) + if err != nil { + if handleOAuthError(r.Context(), h.Refresher, user.DID, err) { + http.Error(w, "Authentication failed, please log in again", http.StatusUnauthorized) + return + } + http.Error(w, fmt.Sprintf("Failed to update repository page: %v", err), http.StatusInternalServerError) + return + } + + // Return new avatar URL + avatarURL := atproto.BlobCDNURL(user.DID, blobRef.Ref.Link) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"avatarURL": avatarURL}) +} diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index a684f8c..255e122 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -195,9 +195,13 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request // Try repo page record from database (synced from PDS via Jetstream) repoPage, err := db.GetRepoPage(h.DB, owner.DID, repository) - if err == nil && repoPage != nil && repoPage.Description != "" { - // Use repo page data - if h.ReadmeFetcher != nil { + if err == nil && repoPage != nil { + // Use repo page avatar if present + if repoPage.AvatarCID != "" { + repo.IconURL = atproto.BlobCDNURL(owner.DID, repoPage.AvatarCID) + } + // Render description as markdown if present + if repoPage.Description != "" && h.ReadmeFetcher != nil { html, err := h.ReadmeFetcher.RenderMarkdown([]byte(repoPage.Description)) if err != nil { slog.Warn("Failed to render repo page description", "error", err) @@ -205,10 +209,9 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request readmeHTML = template.HTML(html) } } - if repoPage.AvatarCID != "" { - repo.IconURL = atproto.BlobCDNURL(owner.DID, repoPage.AvatarCID) - } - } else if h.ReadmeFetcher != nil { + } + // Fall back to fetching README from URL annotations if no description in repo page + if readmeHTML == "" && h.ReadmeFetcher != nil { // Fall back to fetching from URL annotations readmeURL := repo.ReadmeURL if readmeURL == "" && repo.SourceURL != "" { diff --git a/pkg/appview/jetstream/backfill.go b/pkg/appview/jetstream/backfill.go index 307b02d..cde7d84 100644 --- a/pkg/appview/jetstream/backfill.go +++ b/pkg/appview/jetstream/backfill.go @@ -5,21 +5,26 @@ import ( "database/sql" "encoding/json" "fmt" + "io" "log/slog" + "net/http" "strings" "time" "atcr.io/pkg/appview/db" + "atcr.io/pkg/appview/readme" "atcr.io/pkg/atproto" + "atcr.io/pkg/auth/oauth" ) // BackfillWorker uses com.atproto.sync.listReposByCollection to backfill historical data type BackfillWorker struct { db *sql.DB client *atproto.Client - processor *Processor // Shared processor for DB operations - defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io") - testMode bool // If true, suppress warnings for external holds + processor *Processor // Shared processor for DB operations + defaultHoldDID string // Default hold DID from AppView config (e.g., "did:web:hold01.atcr.io") + testMode bool // If true, suppress warnings for external holds + refresher *oauth.Refresher // OAuth refresher for PDS writes (optional, can be nil) } // BackfillState tracks backfill progress @@ -36,7 +41,8 @@ type BackfillState struct { // NewBackfillWorker creates a backfill worker using sync API // defaultHoldDID should be in format "did:web:hold01.atcr.io" // To find a hold's DID, visit: https://hold-url/.well-known/did.json -func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, testMode bool) (*BackfillWorker, error) { +// refresher is optional - if provided, backfill will try to update PDS records when fetching README content +func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, testMode bool, refresher *oauth.Refresher) (*BackfillWorker, error) { // Create client for relay - used only for listReposByCollection client := atproto.NewClient(relayEndpoint, "", "") @@ -46,6 +52,7 @@ func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, t processor: NewProcessor(database, false), // No cache for batch processing defaultHoldDID: defaultHoldDID, testMode: testMode, + refresher: refresher, }, nil } @@ -218,6 +225,13 @@ func (b *BackfillWorker) backfillRepo(ctx context.Context, did, collection strin } } + // After processing repo pages, fetch descriptions from external sources if empty + if collection == atproto.RepoPageCollection { + if err := b.reconcileRepoPageDescriptions(ctx, did, pdsEndpoint); err != nil { + slog.Warn("Backfill failed to reconcile repo page descriptions", "did", did, "error", err) + } + } + return recordCount, nil } @@ -417,3 +431,186 @@ func (b *BackfillWorker) reconcileAnnotations(ctx context.Context, did string, p return nil } + +// reconcileRepoPageDescriptions fetches README content from external sources for repo pages with empty descriptions +// If the user has an OAuth session, it updates the PDS record (source of truth) +// Otherwise, it just stores the fetched content in the database +func (b *BackfillWorker) reconcileRepoPageDescriptions(ctx context.Context, did, pdsEndpoint string) error { + // Get all repo pages for this DID + repoPages, err := db.GetRepoPagesByDID(b.db, did) + if err != nil { + return fmt.Errorf("failed to get repo pages: %w", err) + } + + for _, page := range repoPages { + // Skip pages that already have a description + if page.Description != "" { + continue + } + + // Get annotations from the repository's manifest + annotations, err := db.GetRepositoryAnnotations(b.db, did, page.Repository) + if err != nil { + slog.Debug("Failed to get annotations for repo page", "did", did, "repository", page.Repository, "error", err) + continue + } + + // Try to fetch README content from external sources + description := b.fetchReadmeContent(ctx, annotations) + if description == "" { + // No README content available, skip + continue + } + + slog.Info("Fetched README for repo page", "did", did, "repository", page.Repository, "descriptionLength", len(description)) + + // Try to update PDS if we have OAuth session + pdsUpdated := false + if b.refresher != nil { + if err := b.updateRepoPageInPDS(ctx, did, pdsEndpoint, page.Repository, description, page.AvatarCID); err != nil { + slog.Debug("Could not update repo page in PDS, falling back to DB-only", "did", did, "repository", page.Repository, "error", err) + } else { + pdsUpdated = true + slog.Info("Updated repo page in PDS with fetched description", "did", did, "repository", page.Repository) + } + } + + // Always update database with the fetched content + if err := db.UpsertRepoPage(b.db, did, page.Repository, description, page.AvatarCID, page.CreatedAt, time.Now()); err != nil { + slog.Warn("Failed to update repo page in database", "did", did, "repository", page.Repository, "error", err) + } else if !pdsUpdated { + slog.Info("Updated repo page in database (PDS not updated)", "did", did, "repository", page.Repository) + } + } + + return nil +} + +// fetchReadmeContent attempts to fetch README content from external sources based on annotations +// Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source +func (b *BackfillWorker) fetchReadmeContent(ctx context.Context, annotations map[string]string) string { + // Create a context with timeout for README fetching + fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + // Priority 1: Direct README URL from io.atcr.readme annotation + if readmeURL := annotations["io.atcr.readme"]; readmeURL != "" { + content, err := b.fetchRawReadme(fetchCtx, readmeURL) + if err != nil { + slog.Debug("Failed to fetch README from io.atcr.readme annotation", "url", readmeURL, "error", err) + } else if content != "" { + return content + } + } + + // Priority 2: Derive README URL from org.opencontainers.image.source + if sourceURL := annotations["org.opencontainers.image.source"]; sourceURL != "" { + // Try main branch first, then master + for _, branch := range []string{"main", "master"} { + readmeURL := readme.DeriveReadmeURL(sourceURL, branch) + if readmeURL == "" { + continue + } + + content, err := b.fetchRawReadme(fetchCtx, readmeURL) + if err != nil { + // Only log non-404 errors (404 is expected when trying main vs master) + if !readme.Is404(err) { + slog.Debug("Failed to fetch README from source URL", "url", readmeURL, "branch", branch, "error", err) + } + continue + } + + if content != "" { + return content + } + } + } + + return "" +} + +// fetchRawReadme fetches raw markdown content from a URL +func (b *BackfillWorker) fetchRawReadme(ctx context.Context, readmeURL string) (string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", readmeURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", "ATCR-Backfill-README-Fetcher/1.0") + + client := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return fmt.Errorf("too many redirects") + } + return nil + }, + } + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("status %d", resp.StatusCode) + } + + // Limit content size to 100KB + limitedReader := io.LimitReader(resp.Body, 100*1024) + content, err := io.ReadAll(limitedReader) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } + + return string(content), nil +} + +// updateRepoPageInPDS updates the repo page record in the user's PDS using OAuth +func (b *BackfillWorker) updateRepoPageInPDS(ctx context.Context, did, pdsEndpoint, repository, description, avatarCID string) error { + if b.refresher == nil { + return fmt.Errorf("no OAuth refresher available") + } + + // Create ATProto client with session provider + pdsClient := atproto.NewClientWithSessionProvider(pdsEndpoint, did, b.refresher) + + // Get existing repo page record to preserve other fields + existingRecord, err := pdsClient.GetRecord(ctx, atproto.RepoPageCollection, repository) + var createdAt time.Time + var avatarRef *atproto.ATProtoBlobRef + + if err == nil && existingRecord != nil { + // Parse existing record + var existingPage atproto.RepoPageRecord + if err := json.Unmarshal(existingRecord.Value, &existingPage); err == nil { + createdAt = existingPage.CreatedAt + avatarRef = existingPage.Avatar + } + } + + if createdAt.IsZero() { + createdAt = time.Now() + } + + // Create updated repo page record + repoPage := &atproto.RepoPageRecord{ + Type: atproto.RepoPageCollection, + Repository: repository, + Description: description, + Avatar: avatarRef, + CreatedAt: createdAt, + UpdatedAt: time.Now(), + } + + // Write to PDS - this will use DoWithSession internally + _, err = pdsClient.PutRecord(ctx, atproto.RepoPageCollection, repository, repoPage) + if err != nil { + return fmt.Errorf("failed to write to PDS: %w", err) + } + + return nil +} diff --git a/pkg/appview/jetstream/worker.go b/pkg/appview/jetstream/worker.go index 25419da..8a11867 100644 --- a/pkg/appview/jetstream/worker.go +++ b/pkg/appview/jetstream/worker.go @@ -61,9 +61,7 @@ func NewWorker(database *sql.DB, jetstreamURL string, startCursor int64) *Worker jetstreamURL: jetstreamURL, startCursor: startCursor, wantedCollections: []string{ - atproto.ManifestCollection, // io.atcr.manifest - atproto.TagCollection, // io.atcr.tag - atproto.StarCollection, // io.atcr.sailor.star + "io.atcr.*", // Subscribe to all ATCR collections }, processor: NewProcessor(database, true), // Use cache for live streaming } diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index 540d202..6f7a60c 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -188,6 +188,11 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { Refresher: deps.Refresher, }).ServeHTTP) + r.Post("/api/images/{repository}/avatar", (&uihandlers.UploadAvatarHandler{ + DB: deps.Database, + Refresher: deps.Refresher, + }).ServeHTTP) + // Device approval page (authenticated) r.Get("/device", (&uihandlers.DeviceApprovalPageHandler{ Store: deps.DeviceStore, diff --git a/pkg/appview/static/css/style.css b/pkg/appview/static/css/style.css index d4a2ada..f0eed57 100644 --- a/pkg/appview/static/css/style.css +++ b/pkg/appview/static/css/style.css @@ -1236,6 +1236,35 @@ a.license-badge:hover { flex-shrink: 0; } +.repo-hero-icon-wrapper { + position: relative; + display: inline-block; + flex-shrink: 0; +} + +.avatar-upload-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + border-radius: 12px; + opacity: 0; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.avatar-upload-overlay i { + color: white; + width: 24px; + height: 24px; +} + +.repo-hero-icon-wrapper:hover .avatar-upload-overlay { + opacity: 1; +} + .repo-hero-info { flex: 1; } diff --git a/pkg/appview/static/js/app.js b/pkg/appview/static/js/app.js index 10c8baa..83503ee 100644 --- a/pkg/appview/static/js/app.js +++ b/pkg/appview/static/js/app.js @@ -434,6 +434,69 @@ function removeManifestElement(sanitizedId) { } } +// Upload repository avatar +async function uploadAvatar(input, repository) { + const file = input.files[0]; + if (!file) return; + + // Client-side validation + const validTypes = ['image/png', 'image/jpeg', 'image/webp']; + if (!validTypes.includes(file.type)) { + alert('Please select a PNG, JPEG, or WebP image'); + return; + } + if (file.size > 3 * 1024 * 1024) { + alert('Image must be less than 3MB'); + return; + } + + const formData = new FormData(); + formData.append('avatar', file); + + try { + const response = await fetch(`/api/images/${repository}/avatar`, { + method: 'POST', + credentials: 'include', + body: formData + }); + + if (response.status === 401) { + window.location.href = '/auth/oauth/login'; + return; + } + + if (!response.ok) { + const error = await response.text(); + throw new Error(error); + } + + const data = await response.json(); + + // Update the avatar image on the page + const wrapper = document.querySelector('.repo-hero-icon-wrapper'); + if (!wrapper) return; + + const existingImg = wrapper.querySelector('.repo-hero-icon'); + const placeholder = wrapper.querySelector('.repo-hero-icon-placeholder'); + + if (existingImg) { + existingImg.src = data.avatarURL; + } else if (placeholder) { + const newImg = document.createElement('img'); + newImg.src = data.avatarURL; + newImg.alt = repository; + newImg.className = 'repo-hero-icon'; + placeholder.replaceWith(newImg); + } + } catch (err) { + console.error('Error uploading avatar:', err); + alert('Failed to upload avatar: ' + err.message); + } + + // Clear input so same file can be selected again + input.value = ''; +} + // Close modal when clicking outside document.addEventListener('DOMContentLoaded', () => { const modal = document.getElementById('manifest-delete-modal'); diff --git a/pkg/appview/storage/manifest_store.go b/pkg/appview/storage/manifest_store.go index 315525e..2a4d2bb 100644 --- a/pkg/appview/storage/manifest_store.go +++ b/pkg/appview/storage/manifest_store.go @@ -429,11 +429,6 @@ func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRec // This syncs repository metadata from manifest annotations to the io.atcr.repo.page collection // Only creates a new record if one doesn't exist (doesn't overwrite user's custom content) func (s *ManifestStore) ensureRepoPage(ctx context.Context, manifestRecord *atproto.ManifestRecord) { - // Skip if no annotations - if manifestRecord.Annotations == nil { - return - } - // Check if repo page already exists (don't overwrite user's custom content) rkey := s.ctx.Repository _, err := s.ctx.ATProtoClient.GetRecord(ctx, atproto.RepoPageCollection, rkey) @@ -449,27 +444,27 @@ func (s *ManifestStore) ensureRepoPage(ctx context.Context, manifestRecord *atpr return } + // Get annotations (may be nil if image has no OCI labels) + annotations := manifestRecord.Annotations + if annotations == nil { + annotations = make(map[string]string) + } + // Try to fetch README content from external sources // Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source > org.opencontainers.image.description - description := s.fetchReadmeContent(ctx, manifestRecord.Annotations) + description := s.fetchReadmeContent(ctx, annotations) // If no README content could be fetched, fall back to description annotation if description == "" { - description = manifestRecord.Annotations["org.opencontainers.image.description"] + description = annotations["org.opencontainers.image.description"] } // Try to fetch and upload icon from io.atcr.icon annotation var avatarRef *atproto.ATProtoBlobRef - if iconURL := manifestRecord.Annotations["io.atcr.icon"]; iconURL != "" { + if iconURL := annotations["io.atcr.icon"]; iconURL != "" { avatarRef = s.fetchAndUploadIcon(ctx, iconURL) } - // If no description and no icon, nothing to create - if description == "" && avatarRef == nil { - slog.Debug("No README, description, or icon found for repo page", "did", s.ctx.DID, "repository", s.ctx.Repository) - return - } - // Create new repo page record with description and optional avatar repoPage := atproto.NewRepoPageRecord(s.ctx.Repository, description, avatarRef) diff --git a/pkg/appview/templates/pages/repository.html b/pkg/appview/templates/pages/repository.html index 6ba9221..b84ed0b 100644 --- a/pkg/appview/templates/pages/repository.html +++ b/pkg/appview/templates/pages/repository.html @@ -27,11 +27,20 @@
- {{ if .Repository.IconURL }} - {{ .Repository.Name }} - {{ else }} -
{{ firstChar .Repository.Name }}
- {{ end }} +
+ {{ if .Repository.IconURL }} + {{ .Repository.Name }} + {{ else }} +
{{ firstChar .Repository.Name }}
+ {{ end }} + {{ if $.IsOwner }} + + + {{ end }} +

{{ .Owner.Handle }} diff --git a/pkg/atproto/client.go b/pkg/atproto/client.go index ab80f98..fa1d74f 100644 --- a/pkg/atproto/client.go +++ b/pkg/atproto/client.go @@ -310,12 +310,15 @@ func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) ( 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, - data, + bytes.NewReader(data), &result, ) }) diff --git a/pkg/auth/oauth/client.go b/pkg/auth/oauth/client.go index e2fe5ff..9167893 100644 --- a/pkg/auth/oauth/client.go +++ b/pkg/auth/oauth/client.go @@ -77,6 +77,8 @@ func RedirectURI(baseURL string) string { func GetDefaultScopes(did string) []string { scopes := []string{ "atproto", + // Used for service token validation on holds + "rpc:com.atproto.repo.getRecord?aud=*", // Image manifest types (single-arch) "blob:application/vnd.oci.image.manifest.v1+json", "blob:application/vnd.docker.distribution.manifest.v2+json", @@ -85,8 +87,8 @@ func GetDefaultScopes(did string) []string { "blob:application/vnd.docker.distribution.manifest.list.v2+json", // OCI artifact manifests (for cosign signatures, SBOMs, attestations) "blob:application/vnd.cncf.oras.artifact.manifest.v1+json", - // Used for service token validation on holds - "rpc:com.atproto.repo.getRecord?aud=*", + // image avatars + "blob:image/*", } // Add repo scopes