diff --git a/deploy/upcloud/provision.go b/deploy/upcloud/provision.go index 171e80c..0e74abf 100644 --- a/deploy/upcloud/provision.go +++ b/deploy/upcloud/provision.go @@ -303,6 +303,16 @@ func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bo return fmt.Errorf("LB forwarded headers: %w", err) } + // Ensure route-hold rule includes forwarded headers action + if err := ensureLBHoldForwardedHeaders(ctx, svc, state.LB.UUID, holdDomain); err != nil { + return fmt.Errorf("LB hold forwarded headers: %w", err) + } + + // Always reconcile scanner block rule + if err := ensureLBScannerBlock(ctx, svc, state.LB.UUID); err != nil { + return fmt.Errorf("LB scanner block: %w", err) + } + // Always reconcile TLS certs (handles partial failures and re-runs) tlsDomains := []string{cfg.BaseDomain} tlsDomains = append(tlsDomains, cfg.RegistryDomains...) @@ -715,6 +725,7 @@ func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraCon }, }, Actions: []upcloud.LoadBalancerAction{ + request.NewLoadBalancerSetForwardedHeadersAction(), { Type: upcloud.LoadBalancerActionTypeUseBackend, UseBackend: &upcloud.LoadBalancerActionUseBackend{ @@ -871,8 +882,23 @@ func ensureLBForwardedHeaders(ctx context.Context, svc *service.Service, lbUUID for _, r := range rules { if r.Name == "set-forwarded-headers" { - fmt.Println(" Forwarded headers rule: exists") - return nil + // Verify it has the set_forwarded_headers action + for _, a := range r.Actions { + if a.SetForwardedHeaders != nil { + fmt.Println(" Forwarded headers rule: exists and valid") + return nil + } + } + // Rule exists but is misconfigured — delete and recreate + fmt.Println(" Forwarded headers rule: exists but misconfigured, recreating") + if err := svc.DeleteLoadBalancerFrontendRule(ctx, &request.DeleteLoadBalancerFrontendRuleRequest{ + ServiceUUID: lbUUID, + FrontendName: "https", + Name: r.Name, + }); err != nil { + return fmt.Errorf("delete misconfigured forwarded headers rule: %w", err) + } + break } } @@ -896,6 +922,142 @@ func ensureLBForwardedHeaders(ctx context.Context, svc *service.Service, lbUUID return nil } +// ensureLBHoldForwardedHeaders ensures the "route-hold" rule includes a +// set_forwarded_headers action alongside use_backend. Without this, the LB +// doesn't set X-Forwarded-For on hold-routed traffic. +func ensureLBHoldForwardedHeaders(ctx context.Context, svc *service.Service, lbUUID, holdDomain string) error { + rules, err := svc.GetLoadBalancerFrontendRules(ctx, &request.GetLoadBalancerFrontendRulesRequest{ + ServiceUUID: lbUUID, + FrontendName: "https", + }) + if err != nil { + return fmt.Errorf("get frontend rules: %w", err) + } + + for _, r := range rules { + if r.Name == "route-hold" { + hasForwarded := false + for _, a := range r.Actions { + if a.SetForwardedHeaders != nil { + hasForwarded = true + break + } + } + if hasForwarded { + fmt.Println(" Route-hold forwarded headers: exists") + return nil + } + // Delete and recreate with both actions + fmt.Println(" Route-hold forwarded headers: missing, recreating rule") + if err := svc.DeleteLoadBalancerFrontendRule(ctx, &request.DeleteLoadBalancerFrontendRuleRequest{ + ServiceUUID: lbUUID, + FrontendName: "https", + Name: r.Name, + }); err != nil { + return fmt.Errorf("delete route-hold rule: %w", err) + } + break + } + } + + _, err = svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{ + ServiceUUID: lbUUID, + FrontendName: "https", + Rule: request.LoadBalancerFrontendRule{ + Name: "route-hold", + Priority: 10, + Matchers: []upcloud.LoadBalancerMatcher{ + { + Type: upcloud.LoadBalancerMatcherTypeHost, + Host: &upcloud.LoadBalancerMatcherHost{ + Value: holdDomain, + }, + }, + }, + Actions: []upcloud.LoadBalancerAction{ + request.NewLoadBalancerSetForwardedHeadersAction(), + { + Type: upcloud.LoadBalancerActionTypeUseBackend, + UseBackend: &upcloud.LoadBalancerActionUseBackend{ + Backend: "hold", + }, + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("create route-hold rule: %w", err) + } + fmt.Println(" Route-hold forwarded headers: created") + + return nil +} + +// ensureLBScannerBlock ensures the "https" frontend has a rule that returns 403 +// for common scanner paths (.php, .asp, .aspx, .jsp, .cgi, .env). +func ensureLBScannerBlock(ctx context.Context, svc *service.Service, lbUUID string) error { + rules, err := svc.GetLoadBalancerFrontendRules(ctx, &request.GetLoadBalancerFrontendRulesRequest{ + ServiceUUID: lbUUID, + FrontendName: "https", + }) + if err != nil { + return fmt.Errorf("get frontend rules: %w", err) + } + + for _, r := range rules { + if r.Name == "block-scanners" { + for _, a := range r.Actions { + if a.HTTPReturn != nil { + fmt.Println(" Scanner block rule: exists and valid") + return nil + } + } + fmt.Println(" Scanner block rule: exists but misconfigured, recreating") + if err := svc.DeleteLoadBalancerFrontendRule(ctx, &request.DeleteLoadBalancerFrontendRuleRequest{ + ServiceUUID: lbUUID, + FrontendName: "https", + Name: r.Name, + }); err != nil { + return fmt.Errorf("delete misconfigured scanner block rule: %w", err) + } + break + } + } + + ignoreCase := true + _, err = svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{ + ServiceUUID: lbUUID, + FrontendName: "https", + Rule: request.LoadBalancerFrontendRule{ + Name: "block-scanners", + Priority: 2, + Matchers: []upcloud.LoadBalancerMatcher{ + request.NewLoadBalancerPathMatcher( + upcloud.LoadBalancerStringMatcherMethodRegexp, + `\.(php|asp|aspx|jsp|cgi|env)$`, + &ignoreCase, + ), + }, + Actions: []upcloud.LoadBalancerAction{ + { + Type: upcloud.LoadBalancerActionTypeHTTPReturn, + HTTPReturn: &upcloud.LoadBalancerActionHTTPReturn{ + Status: 403, + ContentType: "text/plain", + Payload: base64.StdEncoding.EncodeToString([]byte("Forbidden")), + }, + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("create scanner block rule: %w", err) + } + fmt.Println(" Scanner block rule: created") + + return nil +} + // lookupObjectStorage discovers details of an existing Managed Object Storage. func lookupObjectStorage(ctx context.Context, svc *service.Service, uuid string) (ObjectStorageState, error) { storage, err := svc.GetManagedObjectStorage(ctx, &request.GetManagedObjectStorageRequest{ diff --git a/lexicons/io/atcr/tag.json b/lexicons/io/atcr/tag.json index a5035f2..543d7f9 100644 --- a/lexicons/io/atcr/tag.json +++ b/lexicons/io/atcr/tag.json @@ -25,6 +25,11 @@ "format": "at-uri", "description": "AT-URI of the manifest this tag points to (e.g., 'at://did:plc:xyz/io.atcr.manifest/abc123'). Preferred over manifestDigest for new records." }, + "mediaType": { + "type": "string", + "description": "OCI media type of the manifest (e.g., 'application/vnd.oci.image.manifest.v1+json' or 'application/vnd.oci.image.index.v1+json')", + "maxLength": 255 + }, "manifestDigest": { "type": "string", "description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead.", diff --git a/pkg/appview/db/migrations/0018_add_oci_client.yaml b/pkg/appview/db/migrations/0018_add_oci_client.yaml new file mode 100644 index 0000000..32f82df --- /dev/null +++ b/pkg/appview/db/migrations/0018_add_oci_client.yaml @@ -0,0 +1,3 @@ +description: Add oci_client column to users table for OCI client preference +query: | + ALTER TABLE users ADD COLUMN oci_client TEXT DEFAULT ''; diff --git a/pkg/appview/db/models.go b/pkg/appview/db/models.go index 062024b..d4a8188 100644 --- a/pkg/appview/db/models.go +++ b/pkg/appview/db/models.go @@ -9,6 +9,7 @@ type User struct { PDSEndpoint string Avatar string DefaultHoldDID string + OciClient string LastSeen time.Time } @@ -113,6 +114,7 @@ type RepoCardData struct { Digest string // Latest manifest digest (sha256:...) LastUpdated time.Time // When the repository was last pushed to RegistryURL string // Registry URL for docker commands (e.g., "atcr.io" or "127.0.0.1:5000") + OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman") } // SetRegistryURL sets the RegistryURL field on all cards in the slice @@ -122,6 +124,13 @@ func SetRegistryURL(cards []RepoCardData, registryURL string) { } } +// SetOciClient sets the OciClient field on all cards in the slice +func SetOciClient(cards []RepoCardData, ociClient string) { + for i := range cards { + cards[i].OciClient = ociClient + } +} + // PlatformInfo represents platform information (OS/Architecture) type PlatformInfo struct { OS string diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 4a372b5..a5e6282 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -319,12 +319,12 @@ func GetRepositoryMetadata(db DBTX, did string, repository string) (map[string]s // GetUserByDID retrieves a user by DID func GetUserByDID(db DBTX, did string) (*User, error) { var user User - var avatar, defaultHoldDID sql.NullString + var avatar, defaultHoldDID, ociClient sql.NullString err := db.QueryRow(` - SELECT did, handle, pds_endpoint, avatar, default_hold_did, last_seen + SELECT did, handle, pds_endpoint, avatar, default_hold_did, oci_client, last_seen FROM users WHERE did = ? - `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &user.LastSeen) + `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &ociClient, &user.LastSeen) if err == sql.ErrNoRows { return nil, nil @@ -339,6 +339,9 @@ func GetUserByDID(db DBTX, did string) (*User, error) { if defaultHoldDID.Valid { user.DefaultHoldDID = defaultHoldDID.String } + if ociClient.Valid { + user.OciClient = ociClient.String + } return &user, nil } @@ -346,12 +349,12 @@ func GetUserByDID(db DBTX, did string) (*User, error) { // GetUserByHandle retrieves a user by handle func GetUserByHandle(db DBTX, handle string) (*User, error) { var user User - var avatar, defaultHoldDID sql.NullString + var avatar, defaultHoldDID, ociClient sql.NullString err := db.QueryRow(` - SELECT did, handle, pds_endpoint, avatar, default_hold_did, last_seen + SELECT did, handle, pds_endpoint, avatar, default_hold_did, oci_client, last_seen FROM users WHERE handle = ? - `, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &user.LastSeen) + `, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &ociClient, &user.LastSeen) if err == sql.ErrNoRows { return nil, nil @@ -366,6 +369,9 @@ func GetUserByHandle(db DBTX, handle string) (*User, error) { if defaultHoldDID.Valid { user.DefaultHoldDID = defaultHoldDID.String } + if ociClient.Valid { + user.OciClient = ociClient.String + } return &user, nil } @@ -454,6 +460,14 @@ func UpdateUserDefaultHold(db DBTX, did string, holdDID string) error { return err } +// UpdateUserOciClient updates a user's cached OCI client preference +func UpdateUserOciClient(db DBTX, did string, ociClient string) error { + _, err := db.Exec(` + UPDATE users SET oci_client = ? WHERE did = ? + `, ociClient, did) + return err +} + // GetUserHoldDID returns the hold DID for a user. Uses cached default_hold_did // if available, otherwise falls back to the most recent manifest's hold_endpoint. func GetUserHoldDID(db DBTX, did string) string { diff --git a/pkg/appview/db/schema.sql b/pkg/appview/db/schema.sql index a600f7d..70875f6 100644 --- a/pkg/appview/db/schema.sql +++ b/pkg/appview/db/schema.sql @@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS users ( pds_endpoint TEXT NOT NULL, avatar TEXT, default_hold_did TEXT, + oci_client TEXT DEFAULT '', last_seen TIMESTAMP NOT NULL, UNIQUE(handle) ); diff --git a/pkg/appview/handlers/common.go b/pkg/appview/handlers/common.go index bca9db5..228b705 100644 --- a/pkg/appview/handlers/common.go +++ b/pkg/appview/handlers/common.go @@ -16,17 +16,24 @@ type PageData struct { SiteURL string // Website domain (e.g., "seamark.dev") ClientName string // Brand name for templates (e.g., "AT Container Registry") ClientShortName string // Brand name for templates (e.g., "ATCR") + OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman") } // NewPageData creates a PageData struct with common fields populated from the request func NewPageData(r *http.Request, h *BaseUIHandler) PageData { + user := middleware.GetUser(r) + var ociClient string + if user != nil { + ociClient = user.OciClient + } return PageData{ - User: middleware.GetUser(r), + User: user, Query: r.URL.Query().Get("q"), RegistryURL: h.RegistryURL, SiteURL: h.SiteURL, ClientName: h.ClientName, ClientShortName: h.ClientShortName, + OciClient: ociClient, } } diff --git a/pkg/appview/handlers/home.go b/pkg/appview/handlers/home.go index cc8c279..b5cb53a 100644 --- a/pkg/appview/handlers/home.go +++ b/pkg/appview/handlers/home.go @@ -39,13 +39,17 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } db.SetRegistryURL(recentCards, h.RegistryURL) + pageData := NewPageData(r, &h.BaseUIHandler) + db.SetOciClient(featuredCards, pageData.OciClient) + db.SetOciClient(recentCards, pageData.OciClient) + data := struct { PageData Meta *PageMeta FeaturedRepos []db.RepoCardData RecentRepos []db.RepoCardData }{ - PageData: NewPageData(r, &h.BaseUIHandler), + PageData: pageData, Meta: NewPageMeta( h.ClientShortName+" - Distributed Container Registry", "Push and pull Docker images on the AT Protocol. Same Docker, decentralized.", diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index f10b655..3e437b3 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -419,6 +419,12 @@ func (h *RepositoryTagsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request hasMore := offset+pageSize < totalTags isFirstPage := offset == 0 + // Get OCI client preference from logged-in user + var ociClient string + if user := middleware.GetUser(r); user != nil { + ociClient = user.OciClient + } + data := struct { Owner *db.User Repository *db.Repository @@ -426,6 +432,7 @@ func (h *RepositoryTagsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request IsOwner bool ScanBatchParams []template.HTML RegistryURL string + OciClient string HasMore bool NextOffset int IsFirstPage bool @@ -436,6 +443,7 @@ func (h *RepositoryTagsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request IsOwner: isOwner, ScanBatchParams: scanBatchParams, RegistryURL: h.RegistryURL, + OciClient: ociClient, HasMore: hasMore, NextOffset: offset + pageSize, IsFirstPage: isFirstPage, diff --git a/pkg/appview/handlers/search.go b/pkg/appview/handlers/search.go index 52b72b0..4b8ad89 100644 --- a/pkg/appview/handlers/search.go +++ b/pkg/appview/handlers/search.go @@ -100,8 +100,10 @@ func (h *SearchResultsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } - // Set registry URL on all cards + // Set registry URL and OCI client on all cards db.SetRegistryURL(repos, h.RegistryURL) + pageData := NewPageData(r, &h.BaseUIHandler) + db.SetOciClient(repos, pageData.OciClient) data := struct { PageData @@ -110,7 +112,7 @@ func (h *SearchResultsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) HasMore bool NextOffset int }{ - PageData: NewPageData(r, &h.BaseUIHandler), + PageData: pageData, Repositories: repos, SearchQuery: query, HasMore: offset+limit < total, diff --git a/pkg/appview/handlers/settings.go b/pkg/appview/handlers/settings.go index 4e536da..7206942 100644 --- a/pkg/appview/handlers/settings.go +++ b/pkg/appview/handlers/settings.go @@ -137,6 +137,7 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { PDSEndpoint string DefaultHold string AutoRemoveUntagged bool + OciClient string } ActiveHold *HoldDisplay OtherHolds []HoldDisplay @@ -158,6 +159,7 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { data.Profile.PDSEndpoint = user.PDSEndpoint data.Profile.DefaultHold = profile.DefaultHold data.Profile.AutoRemoveUntagged = profile.AutoRemoveUntagged + data.Profile.OciClient = profile.OciClient if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -417,6 +419,64 @@ func (h *UpdateAutoRemoveUntaggedHandler) ServeHTTP(w http.ResponseWriter, r *ht w.WriteHeader(http.StatusNoContent) } +// validOciClients is the set of allowed OCI client values +var validOciClients = map[string]bool{ + "docker": true, + "podman": true, + "buildah": true, + "nerdctl": true, + "crane": true, +} + +// UpdateOciClientHandler handles updating the preferred OCI client +type UpdateOciClientHandler struct { + BaseUIHandler +} + +func (h *UpdateOciClientHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + ociClient := r.FormValue("oci_client") + if !validOciClients[ociClient] { + http.Error(w, "Invalid OCI client", http.StatusBadRequest) + return + } + + // Create ATProto client with session provider + client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) + + // Fetch existing profile + profile, err := storage.GetProfile(r.Context(), client) + if err != nil || profile == nil { + http.Error(w, "Failed to fetch profile", http.StatusInternalServerError) + return + } + + // Update OCI client preference (store empty string for "docker" as it's the default) + if ociClient == "docker" { + profile.OciClient = "" + } else { + profile.OciClient = ociClient + } + profile.UpdatedAt = time.Now() + + if err := storage.UpdateProfile(r.Context(), client, profile); err != nil { + http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError) + return + } + + // Cache locally + if h.DB != nil { + _ = db.UpdateUserOciClient(h.DB, user.DID, ociClient) + } + + w.WriteHeader(http.StatusNoContent) +} + // refreshCaptainRecord fetches a hold's captain record via XRPC and caches it locally. // This ensures badge tiers and other captain metadata are available immediately // without waiting for Jetstream or the next backfill cycle. diff --git a/pkg/appview/handlers/user.go b/pkg/appview/handlers/user.go index bfe59d4..9ebbf8a 100644 --- a/pkg/appview/handlers/user.go +++ b/pkg/appview/handlers/user.go @@ -62,6 +62,9 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } db.SetRegistryURL(cards, h.RegistryURL) + pageData := NewPageData(r, &h.BaseUIHandler) + db.SetOciClient(cards, pageData.OciClient) + // Check for supporter badge based on billing subscription supporterBadge := h.BillingManager.GetSupporterBadge(viewedUser.DID) @@ -84,7 +87,7 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { HasProfile bool SupporterBadge string }{ - PageData: NewPageData(r, &h.BaseUIHandler), + PageData: pageData, Meta: meta, ViewedUser: viewedUser, Repositories: cards, diff --git a/pkg/appview/jetstream/processor.go b/pkg/appview/jetstream/processor.go index c169169..409938e 100644 --- a/pkg/appview/jetstream/processor.go +++ b/pkg/appview/jetstream/processor.go @@ -513,7 +513,14 @@ func (p *Processor) ProcessSailorProfile(ctx context.Context, did string, record return fmt.Errorf("failed to unmarshal sailor profile: %w", err) } - // Skip if no default hold set + // Cache OCI client preference (always, even if no default hold) + if profileRecord.OciClient != "" { + if err := db.UpdateUserOciClient(p.db, did, profileRecord.OciClient); err != nil { + slog.Warn("Failed to cache OCI client preference", "component", "processor", "did", did, "ociClient", profileRecord.OciClient, "error", err) + } + } + + // Skip hold processing if no default hold set if profileRecord.DefaultHold == "" { return nil } diff --git a/pkg/appview/jetstream/processor_test.go b/pkg/appview/jetstream/processor_test.go index f5e177f..3171ba6 100644 --- a/pkg/appview/jetstream/processor_test.go +++ b/pkg/appview/jetstream/processor_test.go @@ -42,6 +42,7 @@ func setupTestDB(t *testing.T) *sql.DB { pds_endpoint TEXT NOT NULL, avatar TEXT, default_hold_did TEXT, + oci_client TEXT DEFAULT '', last_seen TIMESTAMP NOT NULL ); diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index aa4689e..6a7e9ea 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -167,6 +167,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { r.Get("/api/storage", (&uihandlers.StorageHandler{BaseUIHandler: base}).ServeHTTP) r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP) r.Post("/api/profile/auto-remove-untagged", (&uihandlers.UpdateAutoRemoveUntaggedHandler{BaseUIHandler: base}).ServeHTTP) + r.Post("/api/profile/oci-client", (&uihandlers.UpdateOciClientHandler{BaseUIHandler: base}).ServeHTTP) // Subscription management r.Get("/settings/subscription/checkout", (&uihandlers.SubscriptionCheckoutHandler{BaseUIHandler: base}).ServeHTTP) diff --git a/pkg/appview/server.go b/pkg/appview/server.go index 137b60d..f6e5976 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -290,6 +290,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, // Create main chi router mainRouter := chi.NewRouter() + mainRouter.Use(chimiddleware.RealIP) mainRouter.Use(chimiddleware.Logger) mainRouter.Use(chimiddleware.Recoverer) mainRouter.Use(chimiddleware.GetHead) diff --git a/pkg/appview/storage/manifest_store.go b/pkg/appview/storage/manifest_store.go index 0ac5b4d..3e4c84d 100644 --- a/pkg/appview/storage/manifest_store.go +++ b/pkg/appview/storage/manifest_store.go @@ -228,7 +228,7 @@ func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest, } } - tagRecord := atproto.NewTagRecord(s.ctx.ATProtoClient.DID(), s.ctx.Repository, tag, dgst.String()) + tagRecord := atproto.NewTagRecord(s.ctx.ATProtoClient.DID(), s.ctx.Repository, tag, dgst.String(), mediaType) _, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.TagCollection, tagRKey, tagRecord) if err != nil { return "", fmt.Errorf("failed to store tag in ATProto: %w", err) diff --git a/pkg/appview/storage/tag_store.go b/pkg/appview/storage/tag_store.go index 4430744..efd3ec7 100644 --- a/pkg/appview/storage/tag_store.go +++ b/pkg/appview/storage/tag_store.go @@ -54,16 +54,22 @@ func (s *TagStore) Get(ctx context.Context, tag string) (distribution.Descriptor } // Return descriptor pointing to the manifest + // Use stored media type, fallback for old records without it + mediaType := tagRecord.MediaType + if mediaType == "" { + mediaType = "application/vnd.oci.image.manifest.v1+json" + } + return distribution.Descriptor{ Digest: dgst, - MediaType: "application/vnd.oci.image.manifest.v1+json", + MediaType: mediaType, }, nil } // Tag associates a tag with a descriptor (manifest digest) func (s *TagStore) Tag(ctx context.Context, tag string, desc distribution.Descriptor) error { // Create tag record with manifest AT-URI - tagRecord := atproto.NewTagRecord(s.client.DID(), s.repository, tag, desc.Digest.String()) + tagRecord := atproto.NewTagRecord(s.client.DID(), s.repository, tag, desc.Digest.String(), desc.MediaType) // Store in ATProto rkey := atproto.RepositoryTagToRKey(s.repository, tag) diff --git a/pkg/appview/storage/tag_store_test.go b/pkg/appview/storage/tag_store_test.go index be542e8..93f2cf8 100644 --- a/pkg/appview/storage/tag_store_test.go +++ b/pkg/appview/storage/tag_store_test.go @@ -195,6 +195,111 @@ func TestTagStore_Get_NewManifestField(t *testing.T) { } } +// TestTagStore_Get_ReturnsStoredMediaType tests that Get returns the stored mediaType from the tag record +func TestTagStore_Get_ReturnsStoredMediaType(t *testing.T) { + tests := []struct { + name string + mediaType string + wantMediaType string + }{ + { + name: "OCI image index", + mediaType: "application/vnd.oci.image.index.v1+json", + wantMediaType: "application/vnd.oci.image.index.v1+json", + }, + { + name: "Docker manifest list", + mediaType: "application/vnd.docker.distribution.manifest.list.v2+json", + wantMediaType: "application/vnd.docker.distribution.manifest.list.v2+json", + }, + { + name: "OCI image manifest", + mediaType: "application/vnd.oci.image.manifest.v1+json", + wantMediaType: "application/vnd.oci.image.manifest.v1+json", + }, + { + name: "missing mediaType falls back to default", + mediaType: "", + wantMediaType: "application/vnd.oci.image.manifest.v1+json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaTypeField := "" + if tt.mediaType != "" { + mediaTypeField = `"mediaType": "` + tt.mediaType + `",` + } + response := `{ + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", + "cid": "bafytest", + "value": { + "$type": "io.atcr.tag", + "repository": "myapp", + "tag": "latest", + ` + mediaTypeField + ` + "manifest": "at://did:plc:test123/io.atcr.manifest/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + "updatedAt": "2025-01-01T00:00:00Z" + } + }` + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + })) + defer server.Close() + + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") + store := NewTagStore(client, "myapp") + + desc, err := store.Get(context.Background(), "latest") + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + if desc.MediaType != tt.wantMediaType { + t.Errorf("MediaType = %v, want %v", desc.MediaType, tt.wantMediaType) + } + }) + } +} + +// TestTagStore_Tag_SendsMediaType tests that Tag() includes the mediaType in the record +func TestTagStore_Tag_SendsMediaType(t *testing.T) { + var sentMediaType string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + + if recordData, ok := body["record"].(map[string]any); ok { + if mt, ok := recordData["mediaType"].(string); ok { + sentMediaType = mt + } + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.tag/myapp_latest","cid":"bafytest"}`)) + })) + defer server.Close() + + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") + store := NewTagStore(client, "myapp") + + desc := distribution.Descriptor{ + Digest: "sha256:abc123def456", + MediaType: "application/vnd.oci.image.index.v1+json", + } + + err := store.Tag(context.Background(), "latest", desc) + if err != nil { + t.Fatalf("Tag() error = %v", err) + } + + if sentMediaType != "application/vnd.oci.image.index.v1+json" { + t.Errorf("sent mediaType = %v, want application/vnd.oci.image.index.v1+json", sentMediaType) + } +} + // TestTagStore_Tag tests creating/updating a tag func TestTagStore_Tag(t *testing.T) { tests := []struct { diff --git a/pkg/appview/templates/components/repo-card.html b/pkg/appview/templates/components/repo-card.html index 5ab6340..137221b 100644 --- a/pkg/appview/templates/components/repo-card.html +++ b/pkg/appview/templates/components/repo-card.html @@ -53,9 +53,9 @@ {{ end }} {{ else }} {{ if .Tag }} - {{ template "docker-command" (printf "docker pull %s/%s/%s:%s" .RegistryURL .OwnerHandle .Repository .Tag) }} + {{ template "docker-command" (printf "%s pull %s/%s/%s:%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository .Tag) }} {{ else }} - {{ template "docker-command" (printf "docker pull %s/%s/%s" .RegistryURL .OwnerHandle .Repository) }} + {{ template "docker-command" (printf "%s pull %s/%s/%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository) }} {{ end }} {{ end }} diff --git a/pkg/appview/templates/pages/digest.html b/pkg/appview/templates/pages/digest.html index 718b118..5614c43 100644 --- a/pkg/appview/templates/pages/digest.html +++ b/pkg/appview/templates/pages/digest.html @@ -109,7 +109,7 @@ ") }, + // ociClientName returns the OCI client name, defaulting to "docker" if empty. + // Usage: {{ ociClientName .OciClient }} + "ociClientName": func(client string) string { + if client == "" { + return "docker" + } + return client + }, + // extraCSS returns a