mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 13:17:09 +00:00
more improvements on repo page rendering. allow for repo avatar image uploads (requires new scopes)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user