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