A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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.

+515 -41
+164 -2
deploy/upcloud/provision.go
··· 303 303 return fmt.Errorf("LB forwarded headers: %w", err) 304 304 } 305 305 306 + // Ensure route-hold rule includes forwarded headers action 307 + if err := ensureLBHoldForwardedHeaders(ctx, svc, state.LB.UUID, holdDomain); err != nil { 308 + return fmt.Errorf("LB hold forwarded headers: %w", err) 309 + } 310 + 311 + // Always reconcile scanner block rule 312 + if err := ensureLBScannerBlock(ctx, svc, state.LB.UUID); err != nil { 313 + return fmt.Errorf("LB scanner block: %w", err) 314 + } 315 + 306 316 // Always reconcile TLS certs (handles partial failures and re-runs) 307 317 tlsDomains := []string{cfg.BaseDomain} 308 318 tlsDomains = append(tlsDomains, cfg.RegistryDomains...) ··· 715 725 }, 716 726 }, 717 727 Actions: []upcloud.LoadBalancerAction{ 728 + request.NewLoadBalancerSetForwardedHeadersAction(), 718 729 { 719 730 Type: upcloud.LoadBalancerActionTypeUseBackend, 720 731 UseBackend: &upcloud.LoadBalancerActionUseBackend{ ··· 871 882 872 883 for _, r := range rules { 873 884 if r.Name == "set-forwarded-headers" { 874 - fmt.Println(" Forwarded headers rule: exists") 875 - return nil 885 + // Verify it has the set_forwarded_headers action 886 + for _, a := range r.Actions { 887 + if a.SetForwardedHeaders != nil { 888 + fmt.Println(" Forwarded headers rule: exists and valid") 889 + return nil 890 + } 891 + } 892 + // Rule exists but is misconfigured — delete and recreate 893 + fmt.Println(" Forwarded headers rule: exists but misconfigured, recreating") 894 + if err := svc.DeleteLoadBalancerFrontendRule(ctx, &request.DeleteLoadBalancerFrontendRuleRequest{ 895 + ServiceUUID: lbUUID, 896 + FrontendName: "https", 897 + Name: r.Name, 898 + }); err != nil { 899 + return fmt.Errorf("delete misconfigured forwarded headers rule: %w", err) 900 + } 901 + break 876 902 } 877 903 } 878 904 ··· 892 918 return fmt.Errorf("create forwarded headers rule: %w", err) 893 919 } 894 920 fmt.Println(" Forwarded headers rule: created") 921 + 922 + return nil 923 + } 924 + 925 + // ensureLBHoldForwardedHeaders ensures the "route-hold" rule includes a 926 + // set_forwarded_headers action alongside use_backend. Without this, the LB 927 + // doesn't set X-Forwarded-For on hold-routed traffic. 928 + func ensureLBHoldForwardedHeaders(ctx context.Context, svc *service.Service, lbUUID, holdDomain string) error { 929 + rules, err := svc.GetLoadBalancerFrontendRules(ctx, &request.GetLoadBalancerFrontendRulesRequest{ 930 + ServiceUUID: lbUUID, 931 + FrontendName: "https", 932 + }) 933 + if err != nil { 934 + return fmt.Errorf("get frontend rules: %w", err) 935 + } 936 + 937 + for _, r := range rules { 938 + if r.Name == "route-hold" { 939 + hasForwarded := false 940 + for _, a := range r.Actions { 941 + if a.SetForwardedHeaders != nil { 942 + hasForwarded = true 943 + break 944 + } 945 + } 946 + if hasForwarded { 947 + fmt.Println(" Route-hold forwarded headers: exists") 948 + return nil 949 + } 950 + // Delete and recreate with both actions 951 + fmt.Println(" Route-hold forwarded headers: missing, recreating rule") 952 + if err := svc.DeleteLoadBalancerFrontendRule(ctx, &request.DeleteLoadBalancerFrontendRuleRequest{ 953 + ServiceUUID: lbUUID, 954 + FrontendName: "https", 955 + Name: r.Name, 956 + }); err != nil { 957 + return fmt.Errorf("delete route-hold rule: %w", err) 958 + } 959 + break 960 + } 961 + } 962 + 963 + _, err = svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{ 964 + ServiceUUID: lbUUID, 965 + FrontendName: "https", 966 + Rule: request.LoadBalancerFrontendRule{ 967 + Name: "route-hold", 968 + Priority: 10, 969 + Matchers: []upcloud.LoadBalancerMatcher{ 970 + { 971 + Type: upcloud.LoadBalancerMatcherTypeHost, 972 + Host: &upcloud.LoadBalancerMatcherHost{ 973 + Value: holdDomain, 974 + }, 975 + }, 976 + }, 977 + Actions: []upcloud.LoadBalancerAction{ 978 + request.NewLoadBalancerSetForwardedHeadersAction(), 979 + { 980 + Type: upcloud.LoadBalancerActionTypeUseBackend, 981 + UseBackend: &upcloud.LoadBalancerActionUseBackend{ 982 + Backend: "hold", 983 + }, 984 + }, 985 + }, 986 + }, 987 + }) 988 + if err != nil { 989 + return fmt.Errorf("create route-hold rule: %w", err) 990 + } 991 + fmt.Println(" Route-hold forwarded headers: created") 992 + 993 + return nil 994 + } 995 + 996 + // ensureLBScannerBlock ensures the "https" frontend has a rule that returns 403 997 + // for common scanner paths (.php, .asp, .aspx, .jsp, .cgi, .env). 998 + func ensureLBScannerBlock(ctx context.Context, svc *service.Service, lbUUID string) error { 999 + rules, err := svc.GetLoadBalancerFrontendRules(ctx, &request.GetLoadBalancerFrontendRulesRequest{ 1000 + ServiceUUID: lbUUID, 1001 + FrontendName: "https", 1002 + }) 1003 + if err != nil { 1004 + return fmt.Errorf("get frontend rules: %w", err) 1005 + } 1006 + 1007 + for _, r := range rules { 1008 + if r.Name == "block-scanners" { 1009 + for _, a := range r.Actions { 1010 + if a.HTTPReturn != nil { 1011 + fmt.Println(" Scanner block rule: exists and valid") 1012 + return nil 1013 + } 1014 + } 1015 + fmt.Println(" Scanner block rule: exists but misconfigured, recreating") 1016 + if err := svc.DeleteLoadBalancerFrontendRule(ctx, &request.DeleteLoadBalancerFrontendRuleRequest{ 1017 + ServiceUUID: lbUUID, 1018 + FrontendName: "https", 1019 + Name: r.Name, 1020 + }); err != nil { 1021 + return fmt.Errorf("delete misconfigured scanner block rule: %w", err) 1022 + } 1023 + break 1024 + } 1025 + } 1026 + 1027 + ignoreCase := true 1028 + _, err = svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{ 1029 + ServiceUUID: lbUUID, 1030 + FrontendName: "https", 1031 + Rule: request.LoadBalancerFrontendRule{ 1032 + Name: "block-scanners", 1033 + Priority: 2, 1034 + Matchers: []upcloud.LoadBalancerMatcher{ 1035 + request.NewLoadBalancerPathMatcher( 1036 + upcloud.LoadBalancerStringMatcherMethodRegexp, 1037 + `\.(php|asp|aspx|jsp|cgi|env)$`, 1038 + &ignoreCase, 1039 + ), 1040 + }, 1041 + Actions: []upcloud.LoadBalancerAction{ 1042 + { 1043 + Type: upcloud.LoadBalancerActionTypeHTTPReturn, 1044 + HTTPReturn: &upcloud.LoadBalancerActionHTTPReturn{ 1045 + Status: 403, 1046 + ContentType: "text/plain", 1047 + Payload: base64.StdEncoding.EncodeToString([]byte("Forbidden")), 1048 + }, 1049 + }, 1050 + }, 1051 + }, 1052 + }) 1053 + if err != nil { 1054 + return fmt.Errorf("create scanner block rule: %w", err) 1055 + } 1056 + fmt.Println(" Scanner block rule: created") 895 1057 896 1058 return nil 897 1059 }
+5
lexicons/io/atcr/tag.json
··· 25 25 "format": "at-uri", 26 26 "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." 27 27 }, 28 + "mediaType": { 29 + "type": "string", 30 + "description": "OCI media type of the manifest (e.g., 'application/vnd.oci.image.manifest.v1+json' or 'application/vnd.oci.image.index.v1+json')", 31 + "maxLength": 255 32 + }, 28 33 "manifestDigest": { 29 34 "type": "string", 30 35 "description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead.",
+3
pkg/appview/db/migrations/0018_add_oci_client.yaml
··· 1 + description: Add oci_client column to users table for OCI client preference 2 + query: | 3 + ALTER TABLE users ADD COLUMN oci_client TEXT DEFAULT '';
+9
pkg/appview/db/models.go
··· 9 9 PDSEndpoint string 10 10 Avatar string 11 11 DefaultHoldDID string 12 + OciClient string 12 13 LastSeen time.Time 13 14 } 14 15 ··· 113 114 Digest string // Latest manifest digest (sha256:...) 114 115 LastUpdated time.Time // When the repository was last pushed to 115 116 RegistryURL string // Registry URL for docker commands (e.g., "atcr.io" or "127.0.0.1:5000") 117 + OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman") 116 118 } 117 119 118 120 // SetRegistryURL sets the RegistryURL field on all cards in the slice 119 121 func SetRegistryURL(cards []RepoCardData, registryURL string) { 120 122 for i := range cards { 121 123 cards[i].RegistryURL = registryURL 124 + } 125 + } 126 + 127 + // SetOciClient sets the OciClient field on all cards in the slice 128 + func SetOciClient(cards []RepoCardData, ociClient string) { 129 + for i := range cards { 130 + cards[i].OciClient = ociClient 122 131 } 123 132 } 124 133
+20 -6
pkg/appview/db/queries.go
··· 319 319 // GetUserByDID retrieves a user by DID 320 320 func GetUserByDID(db DBTX, did string) (*User, error) { 321 321 var user User 322 - var avatar, defaultHoldDID sql.NullString 322 + var avatar, defaultHoldDID, ociClient sql.NullString 323 323 err := db.QueryRow(` 324 - SELECT did, handle, pds_endpoint, avatar, default_hold_did, last_seen 324 + SELECT did, handle, pds_endpoint, avatar, default_hold_did, oci_client, last_seen 325 325 FROM users 326 326 WHERE did = ? 327 - `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &user.LastSeen) 327 + `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &ociClient, &user.LastSeen) 328 328 329 329 if err == sql.ErrNoRows { 330 330 return nil, nil ··· 338 338 } 339 339 if defaultHoldDID.Valid { 340 340 user.DefaultHoldDID = defaultHoldDID.String 341 + } 342 + if ociClient.Valid { 343 + user.OciClient = ociClient.String 341 344 } 342 345 343 346 return &user, nil ··· 346 349 // GetUserByHandle retrieves a user by handle 347 350 func GetUserByHandle(db DBTX, handle string) (*User, error) { 348 351 var user User 349 - var avatar, defaultHoldDID sql.NullString 352 + var avatar, defaultHoldDID, ociClient sql.NullString 350 353 err := db.QueryRow(` 351 - SELECT did, handle, pds_endpoint, avatar, default_hold_did, last_seen 354 + SELECT did, handle, pds_endpoint, avatar, default_hold_did, oci_client, last_seen 352 355 FROM users 353 356 WHERE handle = ? 354 - `, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &user.LastSeen) 357 + `, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &ociClient, &user.LastSeen) 355 358 356 359 if err == sql.ErrNoRows { 357 360 return nil, nil ··· 365 368 } 366 369 if defaultHoldDID.Valid { 367 370 user.DefaultHoldDID = defaultHoldDID.String 371 + } 372 + if ociClient.Valid { 373 + user.OciClient = ociClient.String 368 374 } 369 375 370 376 return &user, nil ··· 451 457 _, err := db.Exec(` 452 458 UPDATE users SET default_hold_did = ? WHERE did = ? 453 459 `, holdDID, did) 460 + return err 461 + } 462 + 463 + // UpdateUserOciClient updates a user's cached OCI client preference 464 + func UpdateUserOciClient(db DBTX, did string, ociClient string) error { 465 + _, err := db.Exec(` 466 + UPDATE users SET oci_client = ? WHERE did = ? 467 + `, ociClient, did) 454 468 return err 455 469 } 456 470
+1
pkg/appview/db/schema.sql
··· 13 13 pds_endpoint TEXT NOT NULL, 14 14 avatar TEXT, 15 15 default_hold_did TEXT, 16 + oci_client TEXT DEFAULT '', 16 17 last_seen TIMESTAMP NOT NULL, 17 18 UNIQUE(handle) 18 19 );
+8 -1
pkg/appview/handlers/common.go
··· 16 16 SiteURL string // Website domain (e.g., "seamark.dev") 17 17 ClientName string // Brand name for templates (e.g., "AT Container Registry") 18 18 ClientShortName string // Brand name for templates (e.g., "ATCR") 19 + OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman") 19 20 } 20 21 21 22 // NewPageData creates a PageData struct with common fields populated from the request 22 23 func NewPageData(r *http.Request, h *BaseUIHandler) PageData { 24 + user := middleware.GetUser(r) 25 + var ociClient string 26 + if user != nil { 27 + ociClient = user.OciClient 28 + } 23 29 return PageData{ 24 - User: middleware.GetUser(r), 30 + User: user, 25 31 Query: r.URL.Query().Get("q"), 26 32 RegistryURL: h.RegistryURL, 27 33 SiteURL: h.SiteURL, 28 34 ClientName: h.ClientName, 29 35 ClientShortName: h.ClientShortName, 36 + OciClient: ociClient, 30 37 } 31 38 } 32 39
+5 -1
pkg/appview/handlers/home.go
··· 39 39 } 40 40 db.SetRegistryURL(recentCards, h.RegistryURL) 41 41 42 + pageData := NewPageData(r, &h.BaseUIHandler) 43 + db.SetOciClient(featuredCards, pageData.OciClient) 44 + db.SetOciClient(recentCards, pageData.OciClient) 45 + 42 46 data := struct { 43 47 PageData 44 48 Meta *PageMeta 45 49 FeaturedRepos []db.RepoCardData 46 50 RecentRepos []db.RepoCardData 47 51 }{ 48 - PageData: NewPageData(r, &h.BaseUIHandler), 52 + PageData: pageData, 49 53 Meta: NewPageMeta( 50 54 h.ClientShortName+" - Distributed Container Registry", 51 55 "Push and pull Docker images on the AT Protocol. Same Docker, decentralized.",
+8
pkg/appview/handlers/repository.go
··· 419 419 hasMore := offset+pageSize < totalTags 420 420 isFirstPage := offset == 0 421 421 422 + // Get OCI client preference from logged-in user 423 + var ociClient string 424 + if user := middleware.GetUser(r); user != nil { 425 + ociClient = user.OciClient 426 + } 427 + 422 428 data := struct { 423 429 Owner *db.User 424 430 Repository *db.Repository ··· 426 432 IsOwner bool 427 433 ScanBatchParams []template.HTML 428 434 RegistryURL string 435 + OciClient string 429 436 HasMore bool 430 437 NextOffset int 431 438 IsFirstPage bool ··· 436 443 IsOwner: isOwner, 437 444 ScanBatchParams: scanBatchParams, 438 445 RegistryURL: h.RegistryURL, 446 + OciClient: ociClient, 439 447 HasMore: hasMore, 440 448 NextOffset: offset + pageSize, 441 449 IsFirstPage: isFirstPage,
+4 -2
pkg/appview/handlers/search.go
··· 100 100 return 101 101 } 102 102 103 - // Set registry URL on all cards 103 + // Set registry URL and OCI client on all cards 104 104 db.SetRegistryURL(repos, h.RegistryURL) 105 + pageData := NewPageData(r, &h.BaseUIHandler) 106 + db.SetOciClient(repos, pageData.OciClient) 105 107 106 108 data := struct { 107 109 PageData ··· 110 112 HasMore bool 111 113 NextOffset int 112 114 }{ 113 - PageData: NewPageData(r, &h.BaseUIHandler), 115 + PageData: pageData, 114 116 Repositories: repos, 115 117 SearchQuery: query, 116 118 HasMore: offset+limit < total,
+60
pkg/appview/handlers/settings.go
··· 137 137 PDSEndpoint string 138 138 DefaultHold string 139 139 AutoRemoveUntagged bool 140 + OciClient string 140 141 } 141 142 ActiveHold *HoldDisplay 142 143 OtherHolds []HoldDisplay ··· 158 159 data.Profile.PDSEndpoint = user.PDSEndpoint 159 160 data.Profile.DefaultHold = profile.DefaultHold 160 161 data.Profile.AutoRemoveUntagged = profile.AutoRemoveUntagged 162 + data.Profile.OciClient = profile.OciClient 161 163 162 164 if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil { 163 165 http.Error(w, err.Error(), http.StatusInternalServerError) ··· 412 414 if err := storage.UpdateProfile(r.Context(), client, profile); err != nil { 413 415 http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError) 414 416 return 417 + } 418 + 419 + w.WriteHeader(http.StatusNoContent) 420 + } 421 + 422 + // validOciClients is the set of allowed OCI client values 423 + var validOciClients = map[string]bool{ 424 + "docker": true, 425 + "podman": true, 426 + "buildah": true, 427 + "nerdctl": true, 428 + "crane": true, 429 + } 430 + 431 + // UpdateOciClientHandler handles updating the preferred OCI client 432 + type UpdateOciClientHandler struct { 433 + BaseUIHandler 434 + } 435 + 436 + func (h *UpdateOciClientHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 437 + user := middleware.GetUser(r) 438 + if user == nil { 439 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 440 + return 441 + } 442 + 443 + ociClient := r.FormValue("oci_client") 444 + if !validOciClients[ociClient] { 445 + http.Error(w, "Invalid OCI client", http.StatusBadRequest) 446 + return 447 + } 448 + 449 + // Create ATProto client with session provider 450 + client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 451 + 452 + // Fetch existing profile 453 + profile, err := storage.GetProfile(r.Context(), client) 454 + if err != nil || profile == nil { 455 + http.Error(w, "Failed to fetch profile", http.StatusInternalServerError) 456 + return 457 + } 458 + 459 + // Update OCI client preference (store empty string for "docker" as it's the default) 460 + if ociClient == "docker" { 461 + profile.OciClient = "" 462 + } else { 463 + profile.OciClient = ociClient 464 + } 465 + profile.UpdatedAt = time.Now() 466 + 467 + if err := storage.UpdateProfile(r.Context(), client, profile); err != nil { 468 + http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError) 469 + return 470 + } 471 + 472 + // Cache locally 473 + if h.DB != nil { 474 + _ = db.UpdateUserOciClient(h.DB, user.DID, ociClient) 415 475 } 416 476 417 477 w.WriteHeader(http.StatusNoContent)
+4 -1
pkg/appview/handlers/user.go
··· 62 62 } 63 63 db.SetRegistryURL(cards, h.RegistryURL) 64 64 65 + pageData := NewPageData(r, &h.BaseUIHandler) 66 + db.SetOciClient(cards, pageData.OciClient) 67 + 65 68 // Check for supporter badge based on billing subscription 66 69 supporterBadge := h.BillingManager.GetSupporterBadge(viewedUser.DID) 67 70 ··· 84 87 HasProfile bool 85 88 SupporterBadge string 86 89 }{ 87 - PageData: NewPageData(r, &h.BaseUIHandler), 90 + PageData: pageData, 88 91 Meta: meta, 89 92 ViewedUser: viewedUser, 90 93 Repositories: cards,
+8 -1
pkg/appview/jetstream/processor.go
··· 513 513 return fmt.Errorf("failed to unmarshal sailor profile: %w", err) 514 514 } 515 515 516 - // Skip if no default hold set 516 + // Cache OCI client preference (always, even if no default hold) 517 + if profileRecord.OciClient != "" { 518 + if err := db.UpdateUserOciClient(p.db, did, profileRecord.OciClient); err != nil { 519 + slog.Warn("Failed to cache OCI client preference", "component", "processor", "did", did, "ociClient", profileRecord.OciClient, "error", err) 520 + } 521 + } 522 + 523 + // Skip hold processing if no default hold set 517 524 if profileRecord.DefaultHold == "" { 518 525 return nil 519 526 }
+1
pkg/appview/jetstream/processor_test.go
··· 42 42 pds_endpoint TEXT NOT NULL, 43 43 avatar TEXT, 44 44 default_hold_did TEXT, 45 + oci_client TEXT DEFAULT '', 45 46 last_seen TIMESTAMP NOT NULL 46 47 ); 47 48
+1
pkg/appview/routes/routes.go
··· 167 167 r.Get("/api/storage", (&uihandlers.StorageHandler{BaseUIHandler: base}).ServeHTTP) 168 168 r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP) 169 169 r.Post("/api/profile/auto-remove-untagged", (&uihandlers.UpdateAutoRemoveUntaggedHandler{BaseUIHandler: base}).ServeHTTP) 170 + r.Post("/api/profile/oci-client", (&uihandlers.UpdateOciClientHandler{BaseUIHandler: base}).ServeHTTP) 170 171 171 172 // Subscription management 172 173 r.Get("/settings/subscription/checkout", (&uihandlers.SubscriptionCheckoutHandler{BaseUIHandler: base}).ServeHTTP)
+1
pkg/appview/server.go
··· 290 290 // Create main chi router 291 291 mainRouter := chi.NewRouter() 292 292 293 + mainRouter.Use(chimiddleware.RealIP) 293 294 mainRouter.Use(chimiddleware.Logger) 294 295 mainRouter.Use(chimiddleware.Recoverer) 295 296 mainRouter.Use(chimiddleware.GetHead)
+1 -1
pkg/appview/storage/manifest_store.go
··· 228 228 } 229 229 } 230 230 231 - tagRecord := atproto.NewTagRecord(s.ctx.ATProtoClient.DID(), s.ctx.Repository, tag, dgst.String()) 231 + tagRecord := atproto.NewTagRecord(s.ctx.ATProtoClient.DID(), s.ctx.Repository, tag, dgst.String(), mediaType) 232 232 _, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.TagCollection, tagRKey, tagRecord) 233 233 if err != nil { 234 234 return "", fmt.Errorf("failed to store tag in ATProto: %w", err)
+8 -2
pkg/appview/storage/tag_store.go
··· 54 54 } 55 55 56 56 // Return descriptor pointing to the manifest 57 + // Use stored media type, fallback for old records without it 58 + mediaType := tagRecord.MediaType 59 + if mediaType == "" { 60 + mediaType = "application/vnd.oci.image.manifest.v1+json" 61 + } 62 + 57 63 return distribution.Descriptor{ 58 64 Digest: dgst, 59 - MediaType: "application/vnd.oci.image.manifest.v1+json", 65 + MediaType: mediaType, 60 66 }, nil 61 67 } 62 68 63 69 // Tag associates a tag with a descriptor (manifest digest) 64 70 func (s *TagStore) Tag(ctx context.Context, tag string, desc distribution.Descriptor) error { 65 71 // Create tag record with manifest AT-URI 66 - tagRecord := atproto.NewTagRecord(s.client.DID(), s.repository, tag, desc.Digest.String()) 72 + tagRecord := atproto.NewTagRecord(s.client.DID(), s.repository, tag, desc.Digest.String(), desc.MediaType) 67 73 68 74 // Store in ATProto 69 75 rkey := atproto.RepositoryTagToRKey(s.repository, tag)
+105
pkg/appview/storage/tag_store_test.go
··· 195 195 } 196 196 } 197 197 198 + // TestTagStore_Get_ReturnsStoredMediaType tests that Get returns the stored mediaType from the tag record 199 + func TestTagStore_Get_ReturnsStoredMediaType(t *testing.T) { 200 + tests := []struct { 201 + name string 202 + mediaType string 203 + wantMediaType string 204 + }{ 205 + { 206 + name: "OCI image index", 207 + mediaType: "application/vnd.oci.image.index.v1+json", 208 + wantMediaType: "application/vnd.oci.image.index.v1+json", 209 + }, 210 + { 211 + name: "Docker manifest list", 212 + mediaType: "application/vnd.docker.distribution.manifest.list.v2+json", 213 + wantMediaType: "application/vnd.docker.distribution.manifest.list.v2+json", 214 + }, 215 + { 216 + name: "OCI image manifest", 217 + mediaType: "application/vnd.oci.image.manifest.v1+json", 218 + wantMediaType: "application/vnd.oci.image.manifest.v1+json", 219 + }, 220 + { 221 + name: "missing mediaType falls back to default", 222 + mediaType: "", 223 + wantMediaType: "application/vnd.oci.image.manifest.v1+json", 224 + }, 225 + } 226 + 227 + for _, tt := range tests { 228 + t.Run(tt.name, func(t *testing.T) { 229 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 230 + mediaTypeField := "" 231 + if tt.mediaType != "" { 232 + mediaTypeField = `"mediaType": "` + tt.mediaType + `",` 233 + } 234 + response := `{ 235 + "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest", 236 + "cid": "bafytest", 237 + "value": { 238 + "$type": "io.atcr.tag", 239 + "repository": "myapp", 240 + "tag": "latest", 241 + ` + mediaTypeField + ` 242 + "manifest": "at://did:plc:test123/io.atcr.manifest/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", 243 + "updatedAt": "2025-01-01T00:00:00Z" 244 + } 245 + }` 246 + w.WriteHeader(http.StatusOK) 247 + w.Write([]byte(response)) 248 + })) 249 + defer server.Close() 250 + 251 + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 252 + store := NewTagStore(client, "myapp") 253 + 254 + desc, err := store.Get(context.Background(), "latest") 255 + if err != nil { 256 + t.Fatalf("Get() error = %v", err) 257 + } 258 + 259 + if desc.MediaType != tt.wantMediaType { 260 + t.Errorf("MediaType = %v, want %v", desc.MediaType, tt.wantMediaType) 261 + } 262 + }) 263 + } 264 + } 265 + 266 + // TestTagStore_Tag_SendsMediaType tests that Tag() includes the mediaType in the record 267 + func TestTagStore_Tag_SendsMediaType(t *testing.T) { 268 + var sentMediaType string 269 + 270 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 271 + var body map[string]any 272 + json.NewDecoder(r.Body).Decode(&body) 273 + 274 + if recordData, ok := body["record"].(map[string]any); ok { 275 + if mt, ok := recordData["mediaType"].(string); ok { 276 + sentMediaType = mt 277 + } 278 + } 279 + 280 + w.WriteHeader(http.StatusOK) 281 + w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.tag/myapp_latest","cid":"bafytest"}`)) 282 + })) 283 + defer server.Close() 284 + 285 + client := atproto.NewClient(server.URL, "did:plc:test123", "test-token") 286 + store := NewTagStore(client, "myapp") 287 + 288 + desc := distribution.Descriptor{ 289 + Digest: "sha256:abc123def456", 290 + MediaType: "application/vnd.oci.image.index.v1+json", 291 + } 292 + 293 + err := store.Tag(context.Background(), "latest", desc) 294 + if err != nil { 295 + t.Fatalf("Tag() error = %v", err) 296 + } 297 + 298 + if sentMediaType != "application/vnd.oci.image.index.v1+json" { 299 + t.Errorf("sent mediaType = %v, want application/vnd.oci.image.index.v1+json", sentMediaType) 300 + } 301 + } 302 + 198 303 // TestTagStore_Tag tests creating/updating a tag 199 304 func TestTagStore_Tag(t *testing.T) { 200 305 tests := []struct {
+2 -2
pkg/appview/templates/components/repo-card.html
··· 53 53 {{ end }} 54 54 {{ else }} 55 55 {{ if .Tag }} 56 - {{ template "docker-command" (printf "docker pull %s/%s/%s:%s" .RegistryURL .OwnerHandle .Repository .Tag) }} 56 + {{ template "docker-command" (printf "%s pull %s/%s/%s:%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository .Tag) }} 57 57 {{ else }} 58 - {{ template "docker-command" (printf "docker pull %s/%s/%s" .RegistryURL .OwnerHandle .Repository) }} 58 + {{ template "docker-command" (printf "%s pull %s/%s/%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository) }} 59 59 {{ end }} 60 60 {{ end }} 61 61 </div>
+1 -1
pkg/appview/templates/pages/digest.html
··· 109 109 <script> 110 110 (function() { 111 111 // Toggle empty layers (ENV, LABEL, ENTRYPOINT, etc.) 112 - var showEmpty = localStorage.getItem('showEmptyLayers') === 'true'; 112 + var showEmpty = localStorage.getItem('showEmptyLayers') !== 'false'; 113 113 var checkbox = document.getElementById('show-empty-layers'); 114 114 if (checkbox) checkbox.checked = showEmpty; 115 115
+11 -11
pkg/appview/templates/pages/repository.html
··· 82 82 {{ else }} 83 83 <p class="font-semibold">Pull this image</p> 84 84 {{ if .LatestTag }} 85 - {{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .LatestTag) }} 85 + {{ template "docker-command" (print (ociClientName .OciClient) " pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .LatestTag) }} 86 86 {{ else }} 87 - {{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }} 87 + {{ template "docker-command" (print (ociClientName .OciClient) " pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }} 88 88 {{ end }} 89 89 {{ end }} 90 90 </div> ··· 93 93 <!-- Tab Navigation --> 94 94 <div class="border-b border-base-300"> 95 95 <nav class="flex gap-0" role="tablist"> 96 - <button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors" 96 + <button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors cursor-pointer" 97 97 data-tab="overview" 98 98 role="tab" 99 99 onclick="switchRepoTab('overview')"> 100 100 Overview 101 101 </button> 102 - <button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors" 103 - data-tab="tags" 102 + <button class="repo-tab px-6 py-3 text-sm font-medium border-b-2 transition-colors cursor-pointer" 103 + data-tab="artifacts" 104 104 role="tab" 105 - id="tags-tab-btn" 106 - onclick="switchRepoTab('tags')"> 105 + id="artifacts-tab-btn" 106 + onclick="switchRepoTab('artifacts')"> 107 107 Artifacts 108 108 </button> 109 109 </nav> ··· 351 351 </div> 352 352 353 353 <!-- Tags Panel --> 354 - <div id="tab-tags" class="repo-panel hidden"> 354 + <div id="tab-artifacts" class="repo-panel hidden"> 355 355 <div id="tags-content"> 356 356 <div class="flex justify-center py-12"> 357 357 <span class="loading loading-spinner loading-lg"></span> ··· 414 414 415 415 <script> 416 416 (function() { 417 - var validTabs = ['overview', 'tags']; 417 + var validTabs = ['overview', 'artifacts']; 418 418 var tagsLoading = false; 419 419 420 420 function loadTags() { ··· 447 447 }); 448 448 449 449 history.replaceState(null, '', '#' + tabId); 450 - if (tabId === 'tags') loadTags(); 450 + if (tabId === 'artifacts') loadTags(); 451 451 }; 452 452 453 453 window.sortTags = function(method) { ··· 473 473 }; 474 474 475 475 // Prefetch on hover 476 - document.getElementById('tags-tab-btn').addEventListener('mouseenter', loadTags, { once: true }); 476 + document.getElementById('artifacts-tab-btn').addEventListener('mouseenter', loadTags, { once: true }); 477 477 478 478 // Initialize tab from hash 479 479 var hash = window.location.hash.replace('#', '') || 'overview';
+55 -4
pkg/appview/templates/pages/settings.html
··· 19 19 20 20 <!-- Mobile tab bar (below lg) --> 21 21 <div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6"> 22 + <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="user"> 23 + {{ icon "user" "size-4" }} User 24 + </button> 22 25 <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage"> 23 26 {{ icon "hard-drive" "size-4" }} Storage 24 27 </button> ··· 37 40 <!-- Sidebar (lg and above) --> 38 41 <aside class="hidden lg:block w-56 shrink-0"> 39 42 <ul class="menu bg-base-200 rounded-box w-full"> 43 + <li data-tab="user"><a href="#user">{{ icon "user" "size-4" }} User</a></li> 40 44 <li data-tab="storage"><a href="#storage">{{ icon "hard-drive" "size-4" }} Storage</a></li> 41 45 <li data-tab="devices"><a href="#devices">{{ icon "terminal" "size-4" }} Devices</a></li> 42 46 <li data-tab="webhooks"><a href="#webhooks">{{ icon "webhook" "size-4" }} Webhooks</a></li> ··· 51 55 <!-- Tab content --> 52 56 <div class="flex-1 min-w-0"> 53 57 58 + <!-- USER TAB --> 59 + <div id="tab-user" class="settings-panel hidden space-y-6"> 60 + <section class="card bg-base-100 shadow-sm p-6 space-y-6"> 61 + <div> 62 + <h2 class="text-xl font-semibold">Preferences</h2> 63 + <p class="text-base-content/70 mt-1">Customize your experience across the site.</p> 64 + </div> 65 + 66 + <!-- OCI Client Selector --> 67 + <div class="flex items-center gap-4"> 68 + <div> 69 + <label class="text-sm font-medium">OCI Client</label> 70 + <p class="text-xs text-base-content/60">Changes how pull commands are displayed across the site.</p> 71 + </div> 72 + {{ $oci := .Profile.OciClient }} 73 + <details class="dropdown dropdown-end" id="oci-client-dropdown"> 74 + <summary class="btn btn-sm btn-outline m-0 min-w-40 justify-between"> 75 + <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> 76 + {{ icon "chevron-down" "size-4" }} 77 + </summary> 78 + <ul class="menu dropdown-content bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm mt-1"> 79 + <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> 80 + <li><button class="oci-client-option{{ if eq $oci "podman" }} active{{ end }}" data-value="podman" onclick="selectOciClient(this, 'podman', 'Podman')">Podman</button></li> 81 + <li><button class="oci-client-option{{ if eq $oci "buildah" }} active{{ end }}" data-value="buildah" onclick="selectOciClient(this, 'buildah', 'Buildah')">Buildah</button></li> 82 + <li><button class="oci-client-option{{ if eq $oci "nerdctl" }} active{{ end }}" data-value="nerdctl" onclick="selectOciClient(this, 'nerdctl', 'nerdctl')">nerdctl</button></li> 83 + <li><button class="oci-client-option{{ if eq $oci "crane" }} active{{ end }}" data-value="crane" onclick="selectOciClient(this, 'crane', 'crane')">crane</button></li> 84 + </ul> 85 + </details> 86 + </div> 87 + </section> 88 + </div> 89 + 54 90 <!-- STORAGE TAB --> 55 91 <div id="tab-storage" class="settings-panel hidden space-y-4"> 56 92 <!-- Available Plans --> ··· 124 160 }</code></pre> 125 161 </li> 126 162 <li>Run any Docker command: 127 - <div class="mt-2">{{ template "docker-command" (print "docker pull " .RegistryURL "/" .Profile.Handle "/myimage") }}</div> 163 + <div class="mt-2">{{ template "docker-command" (print (ociClientName .OciClient) " pull " .RegistryURL "/" .Profile.Handle "/myimage") }}</div> 128 164 </li> 129 165 <li>Browser will open for authorization - click Approve</li> 130 166 <li>Done! Device is automatically authorized</li> ··· 237 273 </main> 238 274 239 275 <script> 276 + // OCI client dropdown 277 + function selectOciClient(btn, value, label) { 278 + // Update label 279 + document.getElementById('oci-client-label').textContent = label; 280 + // Update active state 281 + document.querySelectorAll('.oci-client-option').forEach(function(b) { 282 + b.classList.remove('active'); 283 + }); 284 + btn.classList.add('active'); 285 + // Close dropdown 286 + document.getElementById('oci-client-dropdown').removeAttribute('open'); 287 + // Save via HTMX 288 + htmx.ajax('POST', '/api/profile/oci-client', {values: {oci_client: value}, swap: 'none'}); 289 + } 290 + 240 291 // Tab switching 241 292 (function() { 242 - var validTabs = ['storage', 'devices', 'webhooks', 'advanced']; 293 + var validTabs = ['user', 'storage', 'devices', 'webhooks', 'advanced']; 243 294 244 295 function switchSettingsTab(tabId) { 245 296 // Hide all panels ··· 284 335 285 336 document.addEventListener('DOMContentLoaded', function() { 286 337 // Read initial tab from hash 287 - var hash = window.location.hash.replace('#', '') || 'storage'; 338 + var hash = window.location.hash.replace('#', '') || 'user'; 288 339 if (validTabs.indexOf(hash) === -1) hash = 'storage'; 289 340 290 341 // Mobile tab click handlers ··· 309 360 310 361 // Handle browser back/forward 311 362 window.addEventListener('hashchange', function() { 312 - var hash = window.location.hash.replace('#', '') || 'storage'; 363 + var hash = window.location.hash.replace('#', '') || 'user'; 313 364 if (validTabs.indexOf(hash) !== -1) { 314 365 switchSettingsTab(hash); 315 366 }
+4 -4
pkg/appview/templates/partials/repo-tags.html
··· 28 28 {{ end }} 29 29 {{ else }} 30 30 {{ if .Entry.IsTagged }} 31 - {{ template "docker-command" (print "docker pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Entry.Label) }} 31 + {{ template "docker-command" (print (ociClientName .OciClient) " pull " .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Entry.Label) }} 32 32 {{ else }} 33 - {{ template "docker-command" (print "docker pull " .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }} 33 + {{ template "docker-command" (print (ociClientName .OciClient) " pull " .RegistryURL "/" .OwnerHandle "/" .RepoName "@" .Entry.Digest) }} 34 34 {{ end }} 35 35 {{ end }} 36 36 <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 155 <div class="card bg-base-100 shadow-sm border border-base-300"> 156 156 <div class="divide-y divide-base-200" id="tags-list"> 157 157 {{ range .Entries }} 158 - {{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "IsOwner" $.IsOwner) }} 158 + {{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "OciClient" $.OciClient "IsOwner" $.IsOwner) }} 159 159 {{ end }} 160 160 </div> 161 161 {{ template "load-more-button" . }} ··· 170 170 171 171 {{ define "repo-tags-page" }} 172 172 {{ range .Entries }} 173 - {{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "IsOwner" $.IsOwner) }} 173 + {{ template "artifact-entry-markup" (dict "Entry" . "OwnerDID" $.Owner.DID "OwnerHandle" $.Owner.Handle "RepoName" $.Repository.Name "RegistryURL" $.RegistryURL "OciClient" $.OciClient "IsOwner" $.IsOwner) }} 174 174 {{ end }} 175 175 {{ template "load-more-button" . }} 176 176 {{ template "scan-batch-triggers" . }}
+9
pkg/appview/ui.go
··· 285 285 return template.HTML("<script type=\"application/ld+json\">\n " + string(jsonBytes) + "\n </script>") 286 286 }, 287 287 288 + // ociClientName returns the OCI client name, defaulting to "docker" if empty. 289 + // Usage: {{ ociClientName .OciClient }} 290 + "ociClientName": func(client string) string { 291 + if client == "" { 292 + return "docker" 293 + } 294 + return client 295 + }, 296 + 288 297 // extraCSS returns a <style> block with consumer CSS overrides, or empty string. 289 298 "extraCSS": func() template.HTML { 290 299 if extraCSS == "" {
+1
pkg/appview/ui_test.go
··· 586 586 Digest string 587 587 LastUpdated time.Time 588 588 RegistryURL string 589 + OciClient string 589 590 }{ 590 591 OwnerHandle: "alice.bsky.social", 591 592 OwnerAvatarURL: "",
+10 -1
pkg/atproto/lexicon.go
··· 277 277 // Preferred over ManifestDigest for new records 278 278 Manifest string `json:"manifest,omitempty"` 279 279 280 + // MediaType is the OCI media type of the manifest this tag points to 281 + // e.g., "application/vnd.oci.image.manifest.v1+json" or "application/vnd.oci.image.index.v1+json" 282 + MediaType string `json:"mediaType,omitempty"` 283 + 280 284 // ManifestDigest is the digest of the manifest this tag points to (DEPRECATED) 281 285 // Kept for backward compatibility with old records 282 286 // New records should use Manifest field instead ··· 291 295 // repository: The repository name (e.g., "myapp") 292 296 // tag: The tag name (e.g., "latest", "v1.0.0") 293 297 // manifestDigest: The manifest digest (e.g., "sha256:abc123...") 294 - func NewTagRecord(did, repository, tag, manifestDigest string) *TagRecord { 298 + func NewTagRecord(did, repository, tag, manifestDigest, mediaType string) *TagRecord { 295 299 // Build AT-URI for the manifest 296 300 // Format: at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix> 297 301 manifestURI := BuildManifestURI(did, manifestDigest) ··· 301 305 Repository: repository, 302 306 Tag: tag, 303 307 Manifest: manifestURI, 308 + MediaType: mediaType, 304 309 // Note: ManifestDigest is not set for new records (only for backward compat with old records) 305 310 UpdatedAt: time.Now(), 306 311 } ··· 343 348 // cleaned up. When true, manifests that lose all tags (e.g., after a tag 344 349 // overwrite) are deleted from PDS, and their layers are cleaned up by hold GC. 345 350 AutoRemoveUntagged bool `json:"autoRemoveUntagged,omitempty"` 351 + 352 + // OciClient is the preferred OCI client for pull commands (docker, podman, buildah, nerdctl, crane). 353 + // Defaults to "docker" if empty. 354 + OciClient string `json:"ociClient,omitempty"` 346 355 347 356 // CreatedAt timestamp 348 357 CreatedAt time.Time `json:"createdAt"`
+6 -1
pkg/atproto/lexicon_test.go
··· 269 269 func TestNewTagRecord(t *testing.T) { 270 270 did := "did:plc:test123" 271 271 before := time.Now() 272 - record := NewTagRecord(did, "myapp", "latest", "sha256:abc123") 272 + record := NewTagRecord(did, "myapp", "latest", "sha256:abc123", "application/vnd.oci.image.manifest.v1+json") 273 273 after := time.Now() 274 274 275 275 if record.Type != TagCollection { ··· 288 288 expectedURI := "at://did:plc:test123/io.atcr.manifest/abc123" 289 289 if record.Manifest != expectedURI { 290 290 t.Errorf("Manifest = %v, want %v", record.Manifest, expectedURI) 291 + } 292 + 293 + // New records should have media type 294 + if record.MediaType != "application/vnd.oci.image.manifest.v1+json" { 295 + t.Errorf("MediaType = %v, want application/vnd.oci.image.manifest.v1+json", record.MediaType) 291 296 } 292 297 293 298 // New records should NOT have manifestDigest field