mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-30 12:46:57 +00:00
fix quirks on repo and digest pages. fix ips not showing in server logs. add basic spam blocking to LB. add setting to configure your oci (docker) client.
This commit is contained in:
+164
-2
@@ -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{
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 '';
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }}
|
||||
</div>
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
<script>
|
||||
(function() {
|
||||
// Toggle empty layers (ENV, LABEL, ENTRYPOINT, etc.)
|
||||
var showEmpty = localStorage.getItem('showEmptyLayers') === 'true';
|
||||
var showEmpty = localStorage.getItem('showEmptyLayers') !== 'false';
|
||||
var checkbox = document.getElementById('show-empty-layers');
|
||||
if (checkbox) checkbox.checked = showEmpty;
|
||||
|
||||
|
||||
@@ -82,9 +82,9 @@
|
||||
{{ else }}
|
||||
<p class="font-semibold">Pull this image</p>
|
||||
{{ if .LatestTag }}
|
||||
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .LatestTag) }}
|
||||
{{ template "docker-command" (print (ociClientName .OciClient) " pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .LatestTag) }}
|
||||
{{ else }}
|
||||
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }}
|
||||
{{ template "docker-command" (print (ociClientName .OciClient) " pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
@@ -93,17 +93,17 @@
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-base-300">
|
||||
<nav class="flex gap-0" role="tablist">
|
||||
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors"
|
||||
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors cursor-pointer"
|
||||
data-tab="overview"
|
||||
role="tab"
|
||||
onclick="switchRepoTab('overview')">
|
||||
Overview
|
||||
</button>
|
||||
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors"
|
||||
data-tab="tags"
|
||||
<button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors cursor-pointer"
|
||||
data-tab="artifacts"
|
||||
role="tab"
|
||||
id="tags-tab-btn"
|
||||
onclick="switchRepoTab('tags')">
|
||||
id="artifacts-tab-btn"
|
||||
onclick="switchRepoTab('artifacts')">
|
||||
Artifacts
|
||||
</button>
|
||||
</nav>
|
||||
@@ -351,7 +351,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Tags Panel -->
|
||||
<div id="tab-tags" class="repo-panel hidden">
|
||||
<div id="tab-artifacts" class="repo-panel hidden">
|
||||
<div id="tags-content">
|
||||
<div class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
@@ -414,7 +414,7 @@
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var validTabs = ['overview', 'tags'];
|
||||
var validTabs = ['overview', 'artifacts'];
|
||||
var tagsLoading = false;
|
||||
|
||||
function loadTags() {
|
||||
@@ -447,7 +447,7 @@
|
||||
});
|
||||
|
||||
history.replaceState(null, '', '#' + tabId);
|
||||
if (tabId === 'tags') loadTags();
|
||||
if (tabId === 'artifacts') loadTags();
|
||||
};
|
||||
|
||||
window.sortTags = function(method) {
|
||||
@@ -473,7 +473,7 @@
|
||||
};
|
||||
|
||||
// Prefetch on hover
|
||||
document.getElementById('tags-tab-btn').addEventListener('mouseenter', loadTags, { once: true });
|
||||
document.getElementById('artifacts-tab-btn').addEventListener('mouseenter', loadTags, { once: true });
|
||||
|
||||
// Initialize tab from hash
|
||||
var hash = window.location.hash.replace('#', '') || 'overview';
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
|
||||
<!-- Mobile tab bar (below lg) -->
|
||||
<div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6">
|
||||
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="user">
|
||||
{{ icon "user" "size-4" }} User
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage">
|
||||
{{ icon "hard-drive" "size-4" }} Storage
|
||||
</button>
|
||||
@@ -37,6 +40,7 @@
|
||||
<!-- Sidebar (lg and above) -->
|
||||
<aside class="hidden lg:block w-56 shrink-0">
|
||||
<ul class="menu bg-base-200 rounded-box w-full">
|
||||
<li data-tab="user"><a href="#user">{{ icon "user" "size-4" }} User</a></li>
|
||||
<li data-tab="storage"><a href="#storage">{{ icon "hard-drive" "size-4" }} Storage</a></li>
|
||||
<li data-tab="devices"><a href="#devices">{{ icon "terminal" "size-4" }} Devices</a></li>
|
||||
<li data-tab="webhooks"><a href="#webhooks">{{ icon "webhook" "size-4" }} Webhooks</a></li>
|
||||
@@ -51,6 +55,38 @@
|
||||
<!-- Tab content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
|
||||
<!-- USER TAB -->
|
||||
<div id="tab-user" class="settings-panel hidden space-y-6">
|
||||
<section class="card bg-base-100 shadow-sm p-6 space-y-6">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Preferences</h2>
|
||||
<p class="text-base-content/70 mt-1">Customize your experience across the site.</p>
|
||||
</div>
|
||||
|
||||
<!-- OCI Client Selector -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium">OCI Client</label>
|
||||
<p class="text-xs text-base-content/60">Changes how pull commands are displayed across the site.</p>
|
||||
</div>
|
||||
{{ $oci := .Profile.OciClient }}
|
||||
<details class="dropdown dropdown-end" id="oci-client-dropdown">
|
||||
<summary class="btn btn-sm btn-outline m-0 min-w-40 justify-between">
|
||||
<span id="oci-client-label">{{ if eq $oci "podman" }}Podman{{ else if eq $oci "buildah" }}Buildah{{ else if eq $oci "nerdctl" }}nerdctl{{ else if eq $oci "crane" }}crane{{ else }}Docker{{ end }}</span>
|
||||
{{ icon "chevron-down" "size-4" }}
|
||||
</summary>
|
||||
<ul class="menu dropdown-content bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm mt-1">
|
||||
<li><button class="oci-client-option{{ if or (eq $oci "") (eq $oci "docker") }} active{{ end }}" data-value="docker" onclick="selectOciClient(this, 'docker', 'Docker')">Docker</button></li>
|
||||
<li><button class="oci-client-option{{ if eq $oci "podman" }} active{{ end }}" data-value="podman" onclick="selectOciClient(this, 'podman', 'Podman')">Podman</button></li>
|
||||
<li><button class="oci-client-option{{ if eq $oci "buildah" }} active{{ end }}" data-value="buildah" onclick="selectOciClient(this, 'buildah', 'Buildah')">Buildah</button></li>
|
||||
<li><button class="oci-client-option{{ if eq $oci "nerdctl" }} active{{ end }}" data-value="nerdctl" onclick="selectOciClient(this, 'nerdctl', 'nerdctl')">nerdctl</button></li>
|
||||
<li><button class="oci-client-option{{ if eq $oci "crane" }} active{{ end }}" data-value="crane" onclick="selectOciClient(this, 'crane', 'crane')">crane</button></li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- STORAGE TAB -->
|
||||
<div id="tab-storage" class="settings-panel hidden space-y-4">
|
||||
<!-- Available Plans -->
|
||||
@@ -124,7 +160,7 @@
|
||||
}</code></pre>
|
||||
</li>
|
||||
<li>Run any Docker command:
|
||||
<div class="mt-2">{{ template "docker-command" (print "docker pull " .RegistryURL "/" .Profile.Handle "/myimage") }}</div>
|
||||
<div class="mt-2">{{ template "docker-command" (print (ociClientName .OciClient) " pull " .RegistryURL "/" .Profile.Handle "/myimage") }}</div>
|
||||
</li>
|
||||
<li>Browser will open for authorization - click Approve</li>
|
||||
<li>Done! Device is automatically authorized</li>
|
||||
@@ -237,9 +273,24 @@
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// OCI client dropdown
|
||||
function selectOciClient(btn, value, label) {
|
||||
// Update label
|
||||
document.getElementById('oci-client-label').textContent = label;
|
||||
// Update active state
|
||||
document.querySelectorAll('.oci-client-option').forEach(function(b) {
|
||||
b.classList.remove('active');
|
||||
});
|
||||
btn.classList.add('active');
|
||||
// Close dropdown
|
||||
document.getElementById('oci-client-dropdown').removeAttribute('open');
|
||||
// Save via HTMX
|
||||
htmx.ajax('POST', '/api/profile/oci-client', {values: {oci_client: value}, swap: 'none'});
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
(function() {
|
||||
var validTabs = ['storage', 'devices', 'webhooks', 'advanced'];
|
||||
var validTabs = ['user', 'storage', 'devices', 'webhooks', 'advanced'];
|
||||
|
||||
function switchSettingsTab(tabId) {
|
||||
// Hide all panels
|
||||
@@ -284,7 +335,7 @@
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Read initial tab from hash
|
||||
var hash = window.location.hash.replace('#', '') || 'storage';
|
||||
var hash = window.location.hash.replace('#', '') || 'user';
|
||||
if (validTabs.indexOf(hash) === -1) hash = 'storage';
|
||||
|
||||
// Mobile tab click handlers
|
||||
@@ -309,7 +360,7 @@
|
||||
|
||||
// Handle browser back/forward
|
||||
window.addEventListener('hashchange', function() {
|
||||
var hash = window.location.hash.replace('#', '') || 'storage';
|
||||
var hash = window.location.hash.replace('#', '') || 'user';
|
||||
if (validTabs.indexOf(hash) !== -1) {
|
||||
switchSettingsTab(hash);
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
{{ if .Entry.IsTagged }}
|
||||
{{ template "docker-command" (print "docker pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Entry.Label) }}
|
||||
{{ template "docker-command" (print (ociClientName .OciClient) " pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Entry.Label) }}
|
||||
{{ else }}
|
||||
{{ template "docker-command" (print "docker pull " .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }}
|
||||
{{ template "docker-command" (print (ociClientName .OciClient) " pull " .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<span class="text-base-content text-sm flex items-center gap-1" title="{{ .Entry.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">{{ icon "history" "size-4" }}{{ timeAgoShort .Entry.CreatedAt }}</span>
|
||||
@@ -155,7 +155,7 @@
|
||||
<div class="card bg-base-100 shadow-sm border border-base-300">
|
||||
<div class="divide-y divide-base-200" id="tags-list">
|
||||
{{ range .Entries }}
|
||||
{{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "IsOwner" $.IsOwner) }}
|
||||
{{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "OciClient" $.OciClient "IsOwner" $.IsOwner) }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ template "load-more-button" . }}
|
||||
@@ -170,7 +170,7 @@
|
||||
|
||||
{{ define "repo-tags-page" }}
|
||||
{{ range .Entries }}
|
||||
{{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "IsOwner" $.IsOwner) }}
|
||||
{{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "OciClient" $.OciClient "IsOwner" $.IsOwner) }}
|
||||
{{ end }}
|
||||
{{ template "load-more-button" . }}
|
||||
{{ template "scan-batch-triggers" . }}
|
||||
|
||||
@@ -285,6 +285,15 @@ func Templates(overrides *BrandingOverrides) (*template.Template, error) {
|
||||
return template.HTML("<script type=\"application/ld+json\">\n " + string(jsonBytes) + "\n </script>")
|
||||
},
|
||||
|
||||
// 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 <style> block with consumer CSS overrides, or empty string.
|
||||
"extraCSS": func() template.HTML {
|
||||
if extraCSS == "" {
|
||||
|
||||
@@ -586,6 +586,7 @@ func TestTemplateExecution_RepoCard(t *testing.T) {
|
||||
Digest string
|
||||
LastUpdated time.Time
|
||||
RegistryURL string
|
||||
OciClient string
|
||||
}{
|
||||
OwnerHandle: "alice.bsky.social",
|
||||
OwnerAvatarURL: "",
|
||||
|
||||
+10
-1
@@ -277,6 +277,10 @@ type TagRecord struct {
|
||||
// Preferred over ManifestDigest for new records
|
||||
Manifest string `json:"manifest,omitempty"`
|
||||
|
||||
// MediaType is the OCI media type of the manifest this tag points to
|
||||
// e.g., "application/vnd.oci.image.manifest.v1+json" or "application/vnd.oci.image.index.v1+json"
|
||||
MediaType string `json:"mediaType,omitempty"`
|
||||
|
||||
// ManifestDigest is the digest of the manifest this tag points to (DEPRECATED)
|
||||
// Kept for backward compatibility with old records
|
||||
// New records should use Manifest field instead
|
||||
@@ -291,7 +295,7 @@ type TagRecord struct {
|
||||
// repository: The repository name (e.g., "myapp")
|
||||
// tag: The tag name (e.g., "latest", "v1.0.0")
|
||||
// manifestDigest: The manifest digest (e.g., "sha256:abc123...")
|
||||
func NewTagRecord(did, repository, tag, manifestDigest string) *TagRecord {
|
||||
func NewTagRecord(did, repository, tag, manifestDigest, mediaType string) *TagRecord {
|
||||
// Build AT-URI for the manifest
|
||||
// Format: at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>
|
||||
manifestURI := BuildManifestURI(did, manifestDigest)
|
||||
@@ -301,6 +305,7 @@ func NewTagRecord(did, repository, tag, manifestDigest string) *TagRecord {
|
||||
Repository: repository,
|
||||
Tag: tag,
|
||||
Manifest: manifestURI,
|
||||
MediaType: mediaType,
|
||||
// Note: ManifestDigest is not set for new records (only for backward compat with old records)
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -344,6 +349,10 @@ type SailorProfileRecord struct {
|
||||
// overwrite) are deleted from PDS, and their layers are cleaned up by hold GC.
|
||||
AutoRemoveUntagged bool `json:"autoRemoveUntagged,omitempty"`
|
||||
|
||||
// OciClient is the preferred OCI client for pull commands (docker, podman, buildah, nerdctl, crane).
|
||||
// Defaults to "docker" if empty.
|
||||
OciClient string `json:"ociClient,omitempty"`
|
||||
|
||||
// CreatedAt timestamp
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
|
||||
@@ -269,7 +269,7 @@ func TestNewManifestRecord(t *testing.T) {
|
||||
func TestNewTagRecord(t *testing.T) {
|
||||
did := "did:plc:test123"
|
||||
before := time.Now()
|
||||
record := NewTagRecord(did, "myapp", "latest", "sha256:abc123")
|
||||
record := NewTagRecord(did, "myapp", "latest", "sha256:abc123", "application/vnd.oci.image.manifest.v1+json")
|
||||
after := time.Now()
|
||||
|
||||
if record.Type != TagCollection {
|
||||
@@ -290,6 +290,11 @@ func TestNewTagRecord(t *testing.T) {
|
||||
t.Errorf("Manifest = %v, want %v", record.Manifest, expectedURI)
|
||||
}
|
||||
|
||||
// New records should have media type
|
||||
if record.MediaType != "application/vnd.oci.image.manifest.v1+json" {
|
||||
t.Errorf("MediaType = %v, want application/vnd.oci.image.manifest.v1+json", record.MediaType)
|
||||
}
|
||||
|
||||
// New records should NOT have manifestDigest field
|
||||
if record.ManifestDigest != "" {
|
||||
t.Errorf("ManifestDigest should be empty for new records, got %v", record.ManifestDigest)
|
||||
|
||||
Reference in New Issue
Block a user