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:
Evan Jarrett
2026-03-24 22:24:51 -05:00
parent 23db9be665
commit 22cf108a5f
28 changed files with 515 additions and 41 deletions
+164 -2
View File
@@ -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{
+5
View File
@@ -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
View File
@@ -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
+20 -6
View File
@@ -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 {
+1
View File
@@ -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)
);
+8 -1
View File
@@ -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,
}
}
+5 -1
View File
@@ -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.",
+8
View File
@@ -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,
+4 -2
View File
@@ -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,
+60
View File
@@ -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.
+4 -1
View File
@@ -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,
+8 -1
View File
@@ -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
}
+1
View File
@@ -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
);
+1
View File
@@ -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)
+1
View File
@@ -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)
+1 -1
View File
@@ -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)
+8 -2
View File
@@ -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)
+105
View File
@@ -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>
+1 -1
View File
@@ -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;
+11 -11
View File
@@ -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';
+55 -4
View File
@@ -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" . }}
+9
View File
@@ -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 == "" {
+1
View File
@@ -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
View File
@@ -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"`
+6 -1
View File
@@ -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)