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

This commit is contained in:
Evan Jarrett
2025-12-21 21:51:44 -06:00
parent 79d1126726
commit d11356cd18
13 changed files with 496 additions and 45 deletions
+3 -3
View File
@@ -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 {
+39 -6
View File
@@ -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)
}
+114
View File
@@ -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})
}
+10 -7
View File
@@ -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 != "" {
+201 -4
View File
@@ -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
}
+1 -3
View File
@@ -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
}
+5
View File
@@ -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,
+29
View File
@@ -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;
}
+63
View File
@@ -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');
+9 -14
View File
@@ -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)
+14 -5
View File
@@ -27,11 +27,20 @@
<!-- Repository Header -->
<div class="repository-header">
<div class="repo-hero">
{{ if .Repository.IconURL }}
<img src="{{ .Repository.IconURL }}" alt="{{ .Repository.Name }}" class="repo-hero-icon">
{{ else }}
<div class="repo-hero-icon-placeholder">{{ firstChar .Repository.Name }}</div>
{{ end }}
<div class="repo-hero-icon-wrapper">
{{ if .Repository.IconURL }}
<img src="{{ .Repository.IconURL }}" alt="{{ .Repository.Name }}" class="repo-hero-icon">
{{ else }}
<div class="repo-hero-icon-placeholder">{{ firstChar .Repository.Name }}</div>
{{ end }}
{{ if $.IsOwner }}
<label class="avatar-upload-overlay" for="avatar-upload">
<i data-lucide="plus"></i>
</label>
<input type="file" id="avatar-upload" accept="image/png,image/jpeg,image/webp"
onchange="uploadAvatar(this, '{{ .Repository.Name }}')" hidden>
{{ end }}
</div>
<div class="repo-hero-info">
<h1>
<a href="/u/{{ .Owner.Handle }}" class="owner-link">{{ .Owner.Handle }}</a>
+4 -1
View File
@@ -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,
)
})
+4 -2
View File
@@ -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