···2233import (
44 "context"
55- "encoding/json"
65 "fmt"
76 "log/slog"
87 "net/http"
···1615 "github.com/distribution/distribution/v3/registry/storage/driver"
1716 "github.com/distribution/reference"
18171818+ "atcr.io/pkg/appview/readme"
1919 "atcr.io/pkg/appview/storage"
2020 "atcr.io/pkg/atproto"
2121 "atcr.io/pkg/auth"
···208208 database storage.DatabaseMetrics // Metrics database (copied from global on init)
209209 authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
210210 validationCache *validationCache // Request-level service token cache
211211+ readmeFetcher *readme.Fetcher // README fetcher for repo pages
211212}
212213213214// initATProtoResolver initializes the name resolution middleware
···242243 database: globalDatabase,
243244 authorizer: globalAuthorizer,
244245 validationCache: newValidationCache(),
246246+ readmeFetcher: readme.NewFetcher(),
245247 }, nil
246248}
247249···458460 Database: nr.database,
459461 Authorizer: nr.authorizer,
460462 Refresher: nr.refresher,
463463+ ReadmeFetcher: nr.readmeFetcher,
461464 }
462465463466 return storage.NewRoutingRepository(repo, registryCtx), nil
···481484// findHoldDID determines which hold DID to use for blob storage
482485// Priority order:
483486// 1. User's sailor profile defaultHold (if set)
484484-// 2. User's own hold record (io.atcr.hold)
485485-// 3. AppView's default hold DID
487487+// 2. AppView's default hold DID
486488// Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none configured
487489func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string {
488490 // Create ATProto client (without auth - reading public records)
···508510 return profile.DefaultHold
509511 }
510512511511- // Profile doesn't exist or defaultHold is null/empty
512512- // Check for user's own hold records
513513- records, err := client.ListRecords(ctx, atproto.HoldCollection, 10)
514514- if err != nil {
515515- // Failed to query holds, use default
516516- return nr.defaultHoldDID
517517- }
518518-519519- // Find the first hold record
520520- for _, record := range records {
521521- var holdRecord atproto.HoldRecord
522522- if err := json.Unmarshal(record.Value, &holdRecord); err != nil {
523523- continue
524524- }
525525-526526- // Return the endpoint from the first hold (normalize to DID if URL)
527527- if holdRecord.Endpoint != "" {
528528- return atproto.ResolveHoldDIDFromURL(holdRecord.Endpoint)
529529- }
530530- }
531531-532532- // No profile defaultHold and no own hold records - use AppView default
513513+ // No profile defaultHold - use AppView default
533514 return nr.defaultHoldDID
534515}
535516
-54
pkg/appview/middleware/registry_test.go
···199199 assert.Equal(t, "did:web:user.hold.io", holdDID, "should use sailor profile's defaultHold")
200200}
201201202202-// TestFindHoldDID_LegacyHoldRecords tests legacy hold record discovery
203203-func TestFindHoldDID_LegacyHoldRecords(t *testing.T) {
204204- // Start a mock PDS server that returns hold records
205205- mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
206206- if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" {
207207- // Profile not found
208208- w.WriteHeader(http.StatusNotFound)
209209- return
210210- }
211211- if r.URL.Path == "/xrpc/com.atproto.repo.listRecords" {
212212- // Return hold record
213213- holdRecord := atproto.NewHoldRecord("https://legacy.hold.io", "alice", true)
214214- recordJSON, _ := json.Marshal(holdRecord)
215215- w.Header().Set("Content-Type", "application/json")
216216- json.NewEncoder(w).Encode(map[string]any{
217217- "records": []any{
218218- map[string]any{
219219- "uri": "at://did:plc:test123/io.atcr.hold/abc123",
220220- "value": json.RawMessage(recordJSON),
221221- },
222222- },
223223- })
224224- return
225225- }
226226- w.WriteHeader(http.StatusNotFound)
227227- }))
228228- defer mockPDS.Close()
229229-230230- resolver := &NamespaceResolver{
231231- defaultHoldDID: "did:web:default.atcr.io",
232232- }
233233-234234- ctx := context.Background()
235235- holdDID := resolver.findHoldDID(ctx, "did:plc:test123", mockPDS.URL)
236236-237237- // Legacy URL should be converted to DID
238238- assert.Equal(t, "did:web:legacy.hold.io", holdDID, "should use legacy hold record and convert to DID")
239239-}
240240-241202// TestFindHoldDID_Priority tests the priority order
242203func TestFindHoldDID_Priority(t *testing.T) {
243204 // Start a mock PDS server that returns both profile and hold records
···248209 w.Header().Set("Content-Type", "application/json")
249210 json.NewEncoder(w).Encode(map[string]any{
250211 "value": profile,
251251- })
252252- return
253253- }
254254- if r.URL.Path == "/xrpc/com.atproto.repo.listRecords" {
255255- // Return hold record (should be ignored since profile exists)
256256- holdRecord := atproto.NewHoldRecord("https://legacy.hold.io", "alice", true)
257257- recordJSON, _ := json.Marshal(holdRecord)
258258- w.Header().Set("Content-Type", "application/json")
259259- json.NewEncoder(w).Encode(map[string]any{
260260- "records": []any{
261261- map[string]any{
262262- "uri": "at://did:plc:test123/io.atcr.hold/abc123",
263263- "value": json.RawMessage(recordJSON),
264264- },
265265- },
266212 })
267213 return
268214 }
···1212 "net/http"
1313 "strings"
1414 "sync"
1515+ "time"
15161717+ "atcr.io/pkg/appview/readme"
1618 "atcr.io/pkg/atproto"
1719 "github.com/distribution/distribution/v3"
1820 "github.com/opencontainers/go-digest"
···432434 return
433435 }
434436435435- // Check for relevant annotations that we can use for repo page
436436- description := manifestRecord.Annotations["org.opencontainers.image.description"]
437437- if description == "" {
438438- // No description annotation - nothing to create
439439- return
440440- }
441441-442437 // Check if repo page already exists (don't overwrite user's custom content)
443438 rkey := s.ctx.Repository
444439 _, err := s.ctx.ATProtoClient.GetRecord(ctx, atproto.RepoPageCollection, rkey)
···454449 return
455450 }
456451457457- // Create new repo page record from manifest annotations
458458- // Note: Avatar is not extracted from annotations here - that's handled separately
459459- // (would require uploading a blob if annotation contains a URL)
460460- repoPage := atproto.NewRepoPageRecord(s.ctx.Repository, description, nil)
452452+ // Try to fetch README content from external sources
453453+ // Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source > org.opencontainers.image.description
454454+ description := s.fetchReadmeContent(ctx, manifestRecord.Annotations)
455455+456456+ // If no README content could be fetched, fall back to description annotation
457457+ if description == "" {
458458+ description = manifestRecord.Annotations["org.opencontainers.image.description"]
459459+ }
460460+461461+ // Try to fetch and upload icon from io.atcr.icon annotation
462462+ var avatarRef *atproto.ATProtoBlobRef
463463+ if iconURL := manifestRecord.Annotations["io.atcr.icon"]; iconURL != "" {
464464+ avatarRef = s.fetchAndUploadIcon(ctx, iconURL)
465465+ }
466466+467467+ // If no description and no icon, nothing to create
468468+ if description == "" && avatarRef == nil {
469469+ slog.Debug("No README, description, or icon found for repo page", "did", s.ctx.DID, "repository", s.ctx.Repository)
470470+ return
471471+ }
472472+473473+ // Create new repo page record with description and optional avatar
474474+ repoPage := atproto.NewRepoPageRecord(s.ctx.Repository, description, avatarRef)
461475462462- slog.Info("Creating repo page from manifest annotations", "did", s.ctx.DID, "repository", s.ctx.Repository)
476476+ slog.Info("Creating repo page from manifest annotations", "did", s.ctx.DID, "repository", s.ctx.Repository, "descriptionLength", len(description), "hasAvatar", avatarRef != nil)
463477464478 _, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.RepoPageCollection, rkey, repoPage)
465479 if err != nil {
···469483470484 slog.Info("Repo page created successfully", "did", s.ctx.DID, "repository", s.ctx.Repository)
471485}
486486+487487+// fetchReadmeContent attempts to fetch README content from external sources
488488+// Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source
489489+// Returns the raw markdown content, or empty string if not available
490490+func (s *ManifestStore) fetchReadmeContent(ctx context.Context, annotations map[string]string) string {
491491+ if s.ctx.ReadmeFetcher == nil {
492492+ return ""
493493+ }
494494+495495+ // Create a context with timeout for README fetching (don't block push too long)
496496+ fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
497497+ defer cancel()
498498+499499+ // Priority 1: Direct README URL from io.atcr.readme annotation
500500+ if readmeURL := annotations["io.atcr.readme"]; readmeURL != "" {
501501+ content, err := s.fetchRawReadme(fetchCtx, readmeURL)
502502+ if err != nil {
503503+ slog.Debug("Failed to fetch README from io.atcr.readme annotation", "url", readmeURL, "error", err)
504504+ } else if content != "" {
505505+ slog.Info("Fetched README from io.atcr.readme annotation", "url", readmeURL, "length", len(content))
506506+ return content
507507+ }
508508+ }
509509+510510+ // Priority 2: Derive README URL from org.opencontainers.image.source
511511+ if sourceURL := annotations["org.opencontainers.image.source"]; sourceURL != "" {
512512+ // Try main branch first, then master
513513+ for _, branch := range []string{"main", "master"} {
514514+ readmeURL := readme.DeriveReadmeURL(sourceURL, branch)
515515+ if readmeURL == "" {
516516+ continue
517517+ }
518518+519519+ content, err := s.fetchRawReadme(fetchCtx, readmeURL)
520520+ if err != nil {
521521+ // Only log non-404 errors (404 is expected when trying main vs master)
522522+ if !readme.Is404(err) {
523523+ slog.Debug("Failed to fetch README from source URL", "url", readmeURL, "branch", branch, "error", err)
524524+ }
525525+ continue
526526+ }
527527+528528+ if content != "" {
529529+ slog.Info("Fetched README from source URL", "sourceURL", sourceURL, "branch", branch, "length", len(content))
530530+ return content
531531+ }
532532+ }
533533+ }
534534+535535+ return ""
536536+}
537537+538538+// fetchRawReadme fetches raw markdown content from a URL
539539+// Returns the raw markdown (not rendered HTML) for storage in the repo page record
540540+func (s *ManifestStore) fetchRawReadme(ctx context.Context, readmeURL string) (string, error) {
541541+ // Use a simple HTTP client to fetch raw content
542542+ // We want raw markdown, not rendered HTML (the Fetcher renders to HTML)
543543+ req, err := http.NewRequestWithContext(ctx, "GET", readmeURL, nil)
544544+ if err != nil {
545545+ return "", fmt.Errorf("failed to create request: %w", err)
546546+ }
547547+548548+ req.Header.Set("User-Agent", "ATCR-README-Fetcher/1.0")
549549+550550+ client := &http.Client{
551551+ Timeout: 10 * time.Second,
552552+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
553553+ if len(via) >= 5 {
554554+ return fmt.Errorf("too many redirects")
555555+ }
556556+ return nil
557557+ },
558558+ }
559559+560560+ resp, err := client.Do(req)
561561+ if err != nil {
562562+ return "", fmt.Errorf("failed to fetch URL: %w", err)
563563+ }
564564+ defer resp.Body.Close()
565565+566566+ if resp.StatusCode != http.StatusOK {
567567+ return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
568568+ }
569569+570570+ // Limit content size to 100KB (repo page description has 100KB limit in lexicon)
571571+ limitedReader := io.LimitReader(resp.Body, 100*1024)
572572+ content, err := io.ReadAll(limitedReader)
573573+ if err != nil {
574574+ return "", fmt.Errorf("failed to read response body: %w", err)
575575+ }
576576+577577+ return string(content), nil
578578+}
579579+580580+// fetchAndUploadIcon fetches an image from a URL and uploads it as a blob to the user's PDS
581581+// Returns the blob reference for use in the repo page record, or nil on error
582582+func (s *ManifestStore) fetchAndUploadIcon(ctx context.Context, iconURL string) *atproto.ATProtoBlobRef {
583583+ // Create a context with timeout for icon fetching
584584+ fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
585585+ defer cancel()
586586+587587+ // Fetch the icon
588588+ req, err := http.NewRequestWithContext(fetchCtx, "GET", iconURL, nil)
589589+ if err != nil {
590590+ slog.Debug("Failed to create icon request", "url", iconURL, "error", err)
591591+ return nil
592592+ }
593593+594594+ req.Header.Set("User-Agent", "ATCR-Icon-Fetcher/1.0")
595595+596596+ client := &http.Client{
597597+ Timeout: 10 * time.Second,
598598+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
599599+ if len(via) >= 5 {
600600+ return fmt.Errorf("too many redirects")
601601+ }
602602+ return nil
603603+ },
604604+ }
605605+606606+ resp, err := client.Do(req)
607607+ if err != nil {
608608+ slog.Debug("Failed to fetch icon", "url", iconURL, "error", err)
609609+ return nil
610610+ }
611611+ defer resp.Body.Close()
612612+613613+ if resp.StatusCode != http.StatusOK {
614614+ slog.Debug("Icon fetch returned non-OK status", "url", iconURL, "status", resp.StatusCode)
615615+ return nil
616616+ }
617617+618618+ // Validate content type - only allow images
619619+ contentType := resp.Header.Get("Content-Type")
620620+ mimeType := detectImageMimeType(contentType, iconURL)
621621+ if mimeType == "" {
622622+ slog.Debug("Icon has unsupported content type", "url", iconURL, "contentType", contentType)
623623+ return nil
624624+ }
625625+626626+ // Limit icon size to 3MB (matching lexicon maxSize)
627627+ limitedReader := io.LimitReader(resp.Body, 3*1024*1024)
628628+ iconData, err := io.ReadAll(limitedReader)
629629+ if err != nil {
630630+ slog.Debug("Failed to read icon data", "url", iconURL, "error", err)
631631+ return nil
632632+ }
633633+634634+ if len(iconData) == 0 {
635635+ slog.Debug("Icon data is empty", "url", iconURL)
636636+ return nil
637637+ }
638638+639639+ // Upload the icon as a blob to the user's PDS
640640+ blobRef, err := s.ctx.ATProtoClient.UploadBlob(ctx, iconData, mimeType)
641641+ if err != nil {
642642+ slog.Warn("Failed to upload icon blob", "url", iconURL, "error", err)
643643+ return nil
644644+ }
645645+646646+ slog.Info("Uploaded icon blob", "url", iconURL, "size", len(iconData), "mimeType", mimeType, "cid", blobRef.Ref.Link)
647647+ return blobRef
648648+}
649649+650650+// detectImageMimeType determines the MIME type for an image
651651+// Uses Content-Type header first, then falls back to extension-based detection
652652+// Only allows types accepted by the lexicon: image/png, image/jpeg, image/webp
653653+func detectImageMimeType(contentType, url string) string {
654654+ // Check Content-Type header first
655655+ switch {
656656+ case strings.HasPrefix(contentType, "image/png"):
657657+ return "image/png"
658658+ case strings.HasPrefix(contentType, "image/jpeg"):
659659+ return "image/jpeg"
660660+ case strings.HasPrefix(contentType, "image/webp"):
661661+ return "image/webp"
662662+ }
663663+664664+ // Fall back to URL extension detection
665665+ lowerURL := strings.ToLower(url)
666666+ switch {
667667+ case strings.HasSuffix(lowerURL, ".png"):
668668+ return "image/png"
669669+ case strings.HasSuffix(lowerURL, ".jpg"), strings.HasSuffix(lowerURL, ".jpeg"):
670670+ return "image/jpeg"
671671+ case strings.HasSuffix(lowerURL, ".webp"):
672672+ return "image/webp"
673673+ }
674674+675675+ // Unknown or unsupported type - reject
676676+ return ""
677677+}
-13
pkg/atproto/lexicon.go
···1818 // TagCollection is the collection name for image tags
1919 TagCollection = "io.atcr.tag"
20202121- // HoldCollection is the collection name for storage holds (BYOS)
2222- HoldCollection = "io.atcr.hold"
2323-2421 // HoldCrewCollection is the collection name for hold crew (membership) - LEGACY BYOS model
2522 // Stored in owner's PDS for BYOS holds
2623 HoldCrewCollection = "io.atcr.hold.crew"
···313310 CreatedAt time.Time `json:"createdAt"`
314311}
315312316316-// NewHoldRecord creates a new hold record
317317-func NewHoldRecord(endpoint, owner string, public bool) *HoldRecord {
318318- return &HoldRecord{
319319- Type: HoldCollection,
320320- Endpoint: endpoint,
321321- Owner: owner,
322322- Public: public,
323323- CreatedAt: time.Now(),
324324- }
325325-}
326313327314// SailorProfileRecord represents a user's profile with registry preferences
328315// Stored in the user's PDS to configure default hold and other settings