A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
81
fork

Configure Feed

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

update credential helper to pull latest update from tangled directly

+536 -318
+59 -82
cmd/credential-helper/cmd_update.go
··· 1 1 package main 2 2 3 3 import ( 4 - "encoding/json" 5 4 "fmt" 6 5 "io" 7 6 "net/http" 7 + "net/url" 8 8 "os" 9 9 "os/exec" 10 + "path" 10 11 "path/filepath" 11 12 "runtime" 12 13 "strconv" ··· 16 17 "github.com/spf13/cobra" 17 18 ) 18 19 19 - // VersionAPIResponse is the response from /api/credential-helper/version 20 - type VersionAPIResponse struct { 21 - Latest string `json:"latest"` 22 - DownloadURLs map[string]string `json:"download_urls"` 23 - Checksums map[string]string `json:"checksums"` 24 - ReleaseNotes string `json:"release_notes,omitempty"` 25 - } 20 + // tangledReleasesBase is the tangled.org path for the credential-helper's 21 + // release repository. /tags/latest issues a 302 redirect to the latest tag, 22 + // and /tags/{version}/download/{filename} serves goreleaser artifacts directly. 23 + const tangledReleasesBase = "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64" 26 24 27 25 func newUpdateCmd() *cobra.Command { 28 26 cmd := &cobra.Command{ ··· 37 35 func runUpdate(cmd *cobra.Command, args []string) error { 38 36 checkOnly, _ := cmd.Flags().GetBool("check") 39 37 40 - // Default API URL 41 - apiURL := "https://atcr.io/api/credential-helper/version" 42 - 43 - // Try to get AppView URL from stored credentials 44 - cfg, _ := loadConfig() 45 - if cfg != nil { 46 - for url := range cfg.Registries { 47 - apiURL = url + "/api/credential-helper/version" 48 - break 49 - } 50 - } 51 - 52 - versionInfo, err := fetchVersionInfo(apiURL) 38 + latest, err := fetchLatestVersion() 53 39 if err != nil { 54 40 return fmt.Errorf("checking for updates: %w", err) 55 41 } 56 42 57 - if !isNewerVersion(versionInfo.Latest, version) { 43 + if !isNewerVersion(latest, version) { 58 44 fmt.Printf("You're already running the latest version (%s)\n", version) 59 45 return nil 60 46 } 61 47 62 - fmt.Printf("New version available: %s (current: %s)\n", versionInfo.Latest, version) 48 + fmt.Printf("New version available: %s (current: %s)\n", latest, version) 63 49 64 50 if checkOnly { 65 51 return nil 66 52 } 67 53 68 - if err := performUpdate(versionInfo); err != nil { 54 + if err := performUpdate(latest); err != nil { 69 55 return fmt.Errorf("update failed: %w", err) 70 56 } 71 57 ··· 73 59 return nil 74 60 } 75 61 76 - // fetchVersionInfo fetches version info from the AppView API 77 - func fetchVersionInfo(apiURL string) (*VersionAPIResponse, error) { 62 + // fetchLatestVersion resolves the latest released version by reading the 63 + // redirect Location header of {tangledReleasesBase}/tags/latest. 64 + func fetchLatestVersion() (string, error) { 78 65 client := &http.Client{ 79 66 Timeout: 10 * time.Second, 67 + CheckRedirect: func(req *http.Request, via []*http.Request) error { 68 + return http.ErrUseLastResponse 69 + }, 80 70 } 81 71 82 - resp, err := client.Get(apiURL) 72 + resp, err := client.Get(tangledReleasesBase + "/tags/latest") 83 73 if err != nil { 84 - return nil, fmt.Errorf("fetching version info: %w", err) 74 + return "", fmt.Errorf("fetching latest tag: %w", err) 85 75 } 86 76 defer resp.Body.Close() 87 77 88 - if resp.StatusCode != http.StatusOK { 89 - return nil, fmt.Errorf("version API returned status %d", resp.StatusCode) 78 + switch resp.StatusCode { 79 + case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, 80 + http.StatusTemporaryRedirect, http.StatusPermanentRedirect: 81 + default: 82 + return "", fmt.Errorf("expected redirect from tags/latest, got status %d", resp.StatusCode) 83 + } 84 + 85 + location := resp.Header.Get("Location") 86 + if location == "" { 87 + return "", fmt.Errorf("tags/latest returned redirect with no Location header") 88 + } 89 + 90 + u, err := url.Parse(location) 91 + if err != nil { 92 + return "", fmt.Errorf("parsing redirect location %q: %w", location, err) 90 93 } 91 94 92 - var versionInfo VersionAPIResponse 93 - if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil { 94 - return nil, fmt.Errorf("parsing version info: %w", err) 95 + tag := path.Base(u.Path) 96 + if !strings.HasPrefix(tag, "v") { 97 + return "", fmt.Errorf("unexpected tag in redirect location %q", location) 95 98 } 96 99 97 - return &versionInfo, nil 100 + return tag, nil 98 101 } 99 102 100 103 // isNewerVersion compares two version strings (simple semver comparison) ··· 130 133 return len(newParts) > len(curParts) 131 134 } 132 135 133 - // getPlatformKey returns the platform key for the current OS/arch 134 - func getPlatformKey() string { 135 - return fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH) 136 - } 136 + // goreleaserArchiveName returns the archive filename goreleaser publishes for 137 + // the given version and the current platform. The naming template lives in 138 + // .goreleaser.yaml: docker-credential-atcr_{Version}_{Title(OS)}_{Arch} with 139 + // amd64→x86_64 and 386→i386. 140 + func goreleaserArchiveName(version string) string { 141 + versionNoV := strings.TrimPrefix(version, "v") 137 142 138 - // performUpdate downloads and installs the new version 139 - func performUpdate(versionInfo *VersionAPIResponse) error { 140 - platformKey := getPlatformKey() 143 + os := strings.ToUpper(runtime.GOOS[:1]) + runtime.GOOS[1:] 141 144 142 - downloadURL, ok := versionInfo.DownloadURLs[platformKey] 143 - if !ok { 144 - return fmt.Errorf("no download available for platform %s", platformKey) 145 + arch := runtime.GOARCH 146 + switch arch { 147 + case "amd64": 148 + arch = "x86_64" 149 + case "386": 150 + arch = "i386" 145 151 } 146 152 147 - expectedChecksum := versionInfo.Checksums[platformKey] 153 + return fmt.Sprintf("docker-credential-atcr_%s_%s_%s.tar.gz", versionNoV, os, arch) 154 + } 155 + 156 + // performUpdate downloads and installs the new version 157 + func performUpdate(latest string) error { 158 + filename := goreleaserArchiveName(latest) 159 + downloadURL := fmt.Sprintf("%s/tags/%s/download/%s", tangledReleasesBase, latest, filename) 148 160 149 161 fmt.Printf("Downloading update from %s...\n", downloadURL) 150 162 ··· 155 167 defer os.RemoveAll(tmpDir) 156 168 157 169 archivePath := filepath.Join(tmpDir, "archive.tar.gz") 158 - if strings.HasSuffix(downloadURL, ".zip") { 159 - archivePath = filepath.Join(tmpDir, "archive.zip") 160 - } 161 - 162 170 if err := downloadFile(downloadURL, archivePath); err != nil { 163 171 return fmt.Errorf("downloading: %w", err) 164 172 } 165 173 166 - if expectedChecksum != "" { 167 - if err := verifyChecksum(archivePath, expectedChecksum); err != nil { 168 - return fmt.Errorf("checksum verification failed: %w", err) 169 - } 170 - fmt.Println("Checksum verified.") 171 - } 172 - 173 174 binaryPath := filepath.Join(tmpDir, "docker-credential-atcr") 174 175 if runtime.GOOS == "windows" { 175 176 binaryPath += ".exe" 176 177 } 177 178 178 - if strings.HasSuffix(archivePath, ".zip") { 179 - if err := extractZip(archivePath, tmpDir); err != nil { 180 - return fmt.Errorf("extracting archive: %w", err) 181 - } 182 - } else { 183 - if err := extractTarGz(archivePath, tmpDir); err != nil { 184 - return fmt.Errorf("extracting archive: %w", err) 185 - } 179 + if err := extractTarGz(archivePath, tmpDir); err != nil { 180 + return fmt.Errorf("extracting archive: %w", err) 186 181 } 187 182 188 183 currentPath, err := os.Executable() ··· 244 239 return err 245 240 } 246 241 247 - // verifyChecksum verifies the SHA256 checksum of a file 248 - func verifyChecksum(filePath, expected string) error { 249 - if expected == "" { 250 - return nil 251 - } 252 - // Checksums are optional until configured 253 - return nil 254 - } 255 - 256 242 // extractTarGz extracts a .tar.gz archive 257 243 func extractTarGz(archivePath, destDir string) error { 258 244 cmd := exec.Command("tar", "-xzf", archivePath, "-C", destDir) 259 245 if output, err := cmd.CombinedOutput(); err != nil { 260 246 return fmt.Errorf("tar failed: %s: %w", string(output), err) 261 - } 262 - return nil 263 - } 264 - 265 - // extractZip extracts a .zip archive 266 - func extractZip(archivePath, destDir string) error { 267 - cmd := exec.Command("unzip", "-o", archivePath, "-d", destDir) 268 - if output, err := cmd.CombinedOutput(); err != nil { 269 - return fmt.Errorf("unzip failed: %s: %w", string(output), err) 270 247 } 271 248 return nil 272 249 }
+6 -8
cmd/credential-helper/protocol.go
··· 105 105 } 106 106 107 107 // Check for updates (cached, non-blocking) 108 - checkAndNotifyUpdate(appViewURL) 108 + checkAndNotifyUpdate() 109 109 110 110 // Return credentials for Docker 111 111 creds := Credentials{ ··· 200 200 } 201 201 202 202 // checkAndNotifyUpdate checks for updates in the background and notifies the user 203 - func checkAndNotifyUpdate(appViewURL string) { 203 + func checkAndNotifyUpdate() { 204 204 cache := loadUpdateCheckCache() 205 205 if cache != nil && cache.Current == version { 206 206 // Cache is fresh and for current version ··· 214 214 } 215 215 } 216 216 217 - // Fetch version info 218 - apiURL := appViewURL + "/api/credential-helper/version" 219 - versionInfo, err := fetchVersionInfo(apiURL) 217 + latest, err := fetchLatestVersion() 220 218 if err != nil { 221 219 return // Silently fail 222 220 } 223 221 224 222 saveUpdateCheckCache(&UpdateCheckCache{ 225 223 CheckedAt: timeNow(), 226 - Latest: versionInfo.Latest, 224 + Latest: latest, 227 225 Current: version, 228 226 }) 229 227 230 - if isNewerVersion(versionInfo.Latest, version) { 231 - fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", versionInfo.Latest, version) 228 + if isNewerVersion(latest, version) { 229 + fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", latest, version) 232 230 fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr update\n\n") 233 231 } 234 232 }
-4
config-appview.example.yaml
··· 81 81 key_path: /var/lib/atcr/auth/private-key.pem 82 82 # X.509 certificate matching the JWT signing key. 83 83 cert_path: /var/lib/atcr/auth/private-key.crt 84 - # Credential helper download settings. 85 - credential_helper: 86 - # Tangled repository URL for credential helper downloads. 87 - tangled_repo: "" 88 84 # Legal page customization for self-hosted instances. 89 85 legal: 90 86 # Organization name for Terms of Service and Privacy Policy. Defaults to server.client_name.
-2
deploy/upcloud/configs/appview.yaml.tmpl
··· 41 41 auth: 42 42 key_path: "{{.BasePath}}/auth/private-key.pem" 43 43 cert_path: "{{.BasePath}}/auth/private-key.crt" 44 - credential_helper: 45 - tangled_repo: "" 46 44 legal: 47 45 company_name: Seamark 48 46 jurisdiction: State of Texas, United States
-1
docs/appview.md
··· 141 141 | `health` | Hold health check interval and cache TTL | Sensible defaults (15m) | 142 142 | `log_shipper` | Remote log shipping (Victoria, OpenSearch, Loki) | Disabled by default | 143 143 | `legal` | Terms/privacy page customization | Optional | 144 - | `credential_helper` | Credential helper download source | Optional | 145 144 146 145 ### Auto-generated files 147 146
+12 -20
pkg/appview/config.go
··· 22 22 23 23 // Config represents the AppView service configuration 24 24 type Config struct { 25 - Version string `yaml:"version" comment:"Configuration format version."` 26 - LogLevel string `yaml:"log_level" comment:"Log level: debug, info, warn, error."` 27 - LogShipper config.LogShipperConfig `yaml:"log_shipper" comment:"Remote log shipping settings."` 28 - Server ServerConfig `yaml:"server" comment:"HTTP server and identity settings."` 29 - UI UIConfig `yaml:"ui" comment:"Web UI settings."` 30 - Health HealthConfig `yaml:"health" comment:"Health check and cache settings."` 31 - Jetstream JetstreamConfig `yaml:"jetstream" comment:"ATProto Jetstream event stream settings."` 32 - Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."` 33 - CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper download settings."` 34 - Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."` 35 - AI AIConfig `yaml:"ai" comment:"AI-powered image advisor settings."` 36 - Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."` 37 - Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility 25 + Version string `yaml:"version" comment:"Configuration format version."` 26 + LogLevel string `yaml:"log_level" comment:"Log level: debug, info, warn, error."` 27 + LogShipper config.LogShipperConfig `yaml:"log_shipper" comment:"Remote log shipping settings."` 28 + Server ServerConfig `yaml:"server" comment:"HTTP server and identity settings."` 29 + UI UIConfig `yaml:"ui" comment:"Web UI settings."` 30 + Health HealthConfig `yaml:"health" comment:"Health check and cache settings."` 31 + Jetstream JetstreamConfig `yaml:"jetstream" comment:"ATProto Jetstream event stream settings."` 32 + Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."` 33 + Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."` 34 + AI AIConfig `yaml:"ai" comment:"AI-powered image advisor settings."` 35 + Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."` 36 + Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility 38 37 } 39 38 40 39 // ServerConfig defines server settings ··· 124 123 // ServiceName is the service name used for JWT issuer and service fields. 125 124 // Derived from base URL hostname (e.g., "atcr.io") 126 125 ServiceName string `yaml:"-"` 127 - } 128 - 129 - // CredentialHelperConfig defines credential helper download settings 130 - type CredentialHelperConfig struct { 131 - // TangledRepo is the Tangled repository URL for downloads 132 - TangledRepo string `yaml:"tangled_repo" comment:"Tangled repository URL for credential helper downloads."` 133 126 } 134 127 135 128 // LegalConfig defines legal page customization for self-hosted instances ··· 258 251 // Post-load: fixed values 259 252 cfg.Auth.TokenExpiration = 5 * time.Minute 260 253 cfg.Auth.ServiceName = deriveServiceName(cfg) 261 - cfg.CredentialHelper.TangledRepo = "https://tangled.org/evan.jarrett.net/at-container-registry" 262 254 263 255 // Post-load: CompanyName defaults to ClientName 264 256 if cfg.Legal.CompanyName == "" {
+48
pkg/appview/db/hold_store.go
··· 387 387 return holds, nil 388 388 } 389 389 390 + // GetAccessibleHoldDIDs returns the set of hold DIDs whose content the viewer 391 + // is allowed to see in listings. If viewerDID is empty (anonymous), this 392 + // returns holds with public=1 OR allow_all_crew=1. For signed-in viewers it 393 + // additionally includes holds where the viewer is owner or crew. 394 + // 395 + // The returned slice is suitable for use in an IN (...) clause against 396 + // manifests.hold_endpoint / tags.hold_endpoint (which store the hold DID). 397 + func GetAccessibleHoldDIDs(db DBTX, viewerDID string) ([]string, error) { 398 + var rows *sql.Rows 399 + var err error 400 + 401 + if viewerDID == "" { 402 + rows, err = db.Query(` 403 + SELECT hold_did 404 + FROM hold_captain_records 405 + WHERE public = 1 OR allow_all_crew = 1 406 + `) 407 + } else { 408 + rows, err = db.Query(` 409 + SELECT DISTINCT h.hold_did 410 + FROM hold_captain_records h 411 + LEFT JOIN hold_crew_members c 412 + ON h.hold_did = c.hold_did AND c.member_did = ?1 413 + WHERE h.public = 1 414 + OR h.allow_all_crew = 1 415 + OR h.owner_did = ?1 416 + OR c.member_did IS NOT NULL 417 + `, viewerDID) 418 + } 419 + if err != nil { 420 + return nil, fmt.Errorf("failed to query accessible holds: %w", err) 421 + } 422 + defer rows.Close() 423 + 424 + var dids []string 425 + for rows.Next() { 426 + var did string 427 + if err := rows.Scan(&did); err != nil { 428 + return nil, fmt.Errorf("failed to scan accessible hold: %w", err) 429 + } 430 + dids = append(dids, did) 431 + } 432 + if err := rows.Err(); err != nil { 433 + return nil, fmt.Errorf("error iterating accessible holds: %w", err) 434 + } 435 + return dids, nil 436 + } 437 + 390 438 // GetCrewMemberships returns all holds where a user is a crew member 391 439 func GetCrewMemberships(db DBTX, memberDID string) ([]CrewMember, error) { 392 440 query := `
+91
pkg/appview/db/hold_store_test.go
··· 464 464 } 465 465 } 466 466 } 467 + 468 + // TestGetAccessibleHoldDIDs tests the viewer→hold visibility computation 469 + // used to filter listings to what the viewer is allowed to see. 470 + func TestGetAccessibleHoldDIDs(t *testing.T) { 471 + db := setupHoldTestDB(t) 472 + 473 + // Seed 4 captain records covering each visibility combo 474 + records := []*HoldCaptainRecord{ 475 + {HoldDID: "did:web:public.example", OwnerDID: "did:plc:alice", Public: true, AllowAllCrew: false, UpdatedAt: time.Now()}, 476 + {HoldDID: "did:web:selfserv.example", OwnerDID: "did:plc:bob", Public: false, AllowAllCrew: true, UpdatedAt: time.Now()}, 477 + {HoldDID: "did:web:invite.example", OwnerDID: "did:plc:carol", Public: false, AllowAllCrew: false, UpdatedAt: time.Now()}, 478 + {HoldDID: "did:web:carol-hold.example", OwnerDID: "did:plc:carol", Public: false, AllowAllCrew: false, UpdatedAt: time.Now()}, 479 + } 480 + for _, r := range records { 481 + if err := UpsertCaptainRecord(db, r); err != nil { 482 + t.Fatalf("seed captain %s: %v", r.HoldDID, err) 483 + } 484 + } 485 + 486 + // dave is crew of did:web:invite.example 487 + if err := UpsertCrewMember(db, &CrewMember{ 488 + HoldDID: "did:web:invite.example", MemberDID: "did:plc:dave", Rkey: "rk1", 489 + }); err != nil { 490 + t.Fatalf("seed crew: %v", err) 491 + } 492 + 493 + contains := func(haystack []string, needle string) bool { 494 + for _, s := range haystack { 495 + if s == needle { 496 + return true 497 + } 498 + } 499 + return false 500 + } 501 + 502 + t.Run("anonymous viewer sees public + self-service only", func(t *testing.T) { 503 + dids, err := GetAccessibleHoldDIDs(db, "") 504 + if err != nil { 505 + t.Fatalf("unexpected error: %v", err) 506 + } 507 + if len(dids) != 2 { 508 + t.Fatalf("expected 2 DIDs (public+self-service), got %d: %v", len(dids), dids) 509 + } 510 + if !contains(dids, "did:web:public.example") { 511 + t.Errorf("missing public hold: %v", dids) 512 + } 513 + if !contains(dids, "did:web:selfserv.example") { 514 + t.Errorf("missing self-service hold: %v", dids) 515 + } 516 + if contains(dids, "did:web:invite.example") { 517 + t.Errorf("anon should not see invite-only hold: %v", dids) 518 + } 519 + }) 520 + 521 + t.Run("crew member also sees invite-only hold", func(t *testing.T) { 522 + dids, err := GetAccessibleHoldDIDs(db, "did:plc:dave") 523 + if err != nil { 524 + t.Fatalf("unexpected error: %v", err) 525 + } 526 + if !contains(dids, "did:web:invite.example") { 527 + t.Errorf("crew member should see invite-only hold they belong to: %v", dids) 528 + } 529 + if contains(dids, "did:web:carol-hold.example") { 530 + t.Errorf("dave is not crew of carol's private hold: %v", dids) 531 + } 532 + }) 533 + 534 + t.Run("owner sees their own private hold", func(t *testing.T) { 535 + dids, err := GetAccessibleHoldDIDs(db, "did:plc:carol") 536 + if err != nil { 537 + t.Fatalf("unexpected error: %v", err) 538 + } 539 + // carol owns invite.example and carol-hold.example, both private 540 + if !contains(dids, "did:web:invite.example") { 541 + t.Errorf("owner should see their invite-only hold: %v", dids) 542 + } 543 + if !contains(dids, "did:web:carol-hold.example") { 544 + t.Errorf("owner should see their second private hold: %v", dids) 545 + } 546 + }) 547 + 548 + t.Run("random authenticated viewer gets same set as anonymous", func(t *testing.T) { 549 + dids, err := GetAccessibleHoldDIDs(db, "did:plc:nobody") 550 + if err != nil { 551 + t.Fatalf("unexpected error: %v", err) 552 + } 553 + if len(dids) != 2 { 554 + t.Fatalf("expected 2 DIDs, got %d: %v", len(dids), dids) 555 + } 556 + }) 557 + }
+93 -29
pkg/appview/db/queries.go
··· 15 15 return fmt.Sprintf("https://imgs.blue/%s/%s", did, cid) 16 16 } 17 17 18 + // accessibleHoldsSubquery returns SQL that evaluates to the set of hold DIDs 19 + // the viewer is allowed to see in listings. Requires the viewerDID to be 20 + // passed twice as query arguments (once for the owner_did check and once 21 + // for the crew membership check). Empty viewerDID (anonymous) naturally 22 + // matches no owner or crew rows, so only public + self-service holds 23 + // (allow_all_crew=1) are returned. 24 + const accessibleHoldsSubquery = `( 25 + SELECT hold_did FROM hold_captain_records 26 + WHERE public = 1 27 + OR allow_all_crew = 1 28 + OR owner_did = ? 29 + OR hold_did IN (SELECT hold_did FROM hold_crew_members WHERE member_did = ?) 30 + )` 31 + 18 32 // GetArtifactType determines the artifact type based on config media type 19 33 // Returns: "helm-chart", "container-image", or "unknown" 20 34 func GetArtifactType(configMediaType string) string { ··· 68 82 WITH latest_manifests AS ( 69 83 SELECT did, repository, MAX(id) as latest_id 70 84 FROM manifests 85 + WHERE hold_endpoint IN ` + accessibleHoldsSubquery + ` 71 86 GROUP BY did, repository 72 87 ), 73 88 matching_repos AS ( ··· 118 133 LIMIT ? OFFSET ? 119 134 ` 120 135 121 - rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, currentUserDID, limit, offset) 136 + rows, err := db.Query(sqlQuery, currentUserDID, currentUserDID, searchPattern, query, searchPattern, searchPattern, currentUserDID, limit, offset) 122 137 if err != nil { 123 138 return nil, 0, err 124 139 } ··· 159 174 WITH latest_manifests AS ( 160 175 SELECT did, repository, MAX(id) as latest_id 161 176 FROM manifests 177 + WHERE hold_endpoint IN ` + accessibleHoldsSubquery + ` 162 178 GROUP BY did, repository 163 179 ) 164 180 SELECT COUNT(DISTINCT lm.did || '/' || lm.repository) ··· 175 191 ` 176 192 177 193 var total int 178 - if err := db.QueryRow(countQuery, searchPattern, query, searchPattern, searchPattern).Scan(&total); err != nil { 194 + if err := db.QueryRow(countQuery, currentUserDID, currentUserDID, searchPattern, query, searchPattern, searchPattern).Scan(&total); err != nil { 179 195 return nil, 0, err 180 196 } 181 197 182 198 return cards, total, nil 183 199 } 184 200 185 - // GetUserRepositories fetches all repositories for a user 186 - func GetUserRepositories(db DBTX, did string) ([]Repository, error) { 187 - // Get repository summary 201 + // GetUserRepositories fetches all repositories for a user. 202 + // viewerDID scopes results to repositories whose manifests live on holds the 203 + // viewer can access (empty viewerDID = anonymous → public + self-service only). 204 + func GetUserRepositories(db DBTX, did string, viewerDID string) ([]Repository, error) { 205 + // Get repository summary. 206 + // Both tags and manifests are filtered via join onto manifests.hold_endpoint 207 + // so repositories where every row lives on an inaccessible hold drop out. 188 208 rows, err := db.Query(` 189 209 SELECT 190 210 repository, ··· 192 212 COUNT(DISTINCT digest) as manifest_count, 193 213 MAX(created_at) as last_push 194 214 FROM ( 195 - SELECT repository, tag, digest, created_at FROM tags WHERE did = ? 215 + SELECT t.repository, t.tag, t.digest, t.created_at 216 + FROM tags t 217 + JOIN manifests tm ON t.did = tm.did AND t.repository = tm.repository AND t.digest = tm.digest 218 + WHERE t.did = ? AND tm.hold_endpoint IN `+accessibleHoldsSubquery+` 196 219 UNION 197 - SELECT repository, NULL, digest, created_at FROM manifests WHERE did = ? 220 + SELECT m.repository, NULL, m.digest, m.created_at 221 + FROM manifests m 222 + WHERE m.did = ? AND m.hold_endpoint IN `+accessibleHoldsSubquery+` 198 223 ) 199 224 GROUP BY repository 200 225 ORDER BY last_push DESC 201 - `, did, did) 226 + `, did, viewerDID, viewerDID, did, viewerDID, viewerDID) 202 227 203 228 if err != nil { 204 229 return nil, err ··· 779 804 // Only multi-arch tags (manifest lists) have platform info in manifest_references 780 805 // Single-arch tags will have empty Platforms slice (platform is obvious for single-arch) 781 806 // Attestation references (unknown/unknown platforms) are filtered out but tracked via HasAttestations 782 - func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int) ([]TagWithPlatforms, error) { 783 - return getTagsWithPlatformsFiltered(db, did, repository, "", limit, offset) 807 + func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int, viewerDID string) ([]TagWithPlatforms, error) { 808 + return getTagsWithPlatformsFiltered(db, did, repository, "", limit, offset, viewerDID, true) 784 809 } 785 810 786 811 // getTagsWithPlatformsFiltered is the shared implementation for GetTagsWithPlatforms and GetTagByName. 787 812 // If tagName is non-empty, only that specific tag is returned. 788 - func getTagsWithPlatformsFiltered(db DBTX, did, repository, tagName string, limit, offset int) ([]TagWithPlatforms, error) { 813 + // When applyHoldFilter is true, rows are filtered by hold access for viewerDID. 814 + func getTagsWithPlatformsFiltered(db DBTX, did, repository, tagName string, limit, offset int, viewerDID string, applyHoldFilter bool) ([]TagWithPlatforms, error) { 789 815 var tagFilter string 816 + var holdFilter string 790 817 var args []any 818 + args = append(args, did, repository) 791 819 if tagName != "" { 792 - tagFilter = "AND tag = ?" 793 - args = append(args, did, repository, tagName, limit, offset) 794 - } else { 795 - args = append(args, did, repository, limit, offset) 820 + tagFilter = "AND t.tag = ?" 821 + args = append(args, tagName) 796 822 } 823 + if applyHoldFilter { 824 + holdFilter = "AND m.hold_endpoint IN " + accessibleHoldsSubquery 825 + args = append(args, viewerDID, viewerDID) 826 + } 827 + args = append(args, limit, offset) 797 828 798 829 query := ` 799 830 WITH paged_tags AS ( 800 - SELECT id, did, repository, tag, digest, created_at 801 - FROM tags 802 - WHERE did = ? AND repository = ? 831 + SELECT t.id, t.did, t.repository, t.tag, t.digest, t.created_at 832 + FROM tags t 833 + JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest 834 + WHERE t.did = ? AND t.repository = ? 803 835 ` + tagFilter + ` 804 - ORDER BY created_at DESC 836 + ` + holdFilter + ` 837 + ORDER BY t.created_at DESC 805 838 LIMIT ? OFFSET ? 806 839 ) 807 840 SELECT ··· 1117 1150 // GetTopLevelManifests returns only manifest lists and orphaned single-arch manifests 1118 1151 // Filters out platform-specific manifests that are referenced by manifest lists 1119 1152 // Note: Annotations are stored separately in repository_annotations table - use GetRepositoryMetadata to fetch them 1120 - func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int) ([]ManifestWithMetadata, error) { 1153 + func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int, viewerDID string) ([]ManifestWithMetadata, error) { 1121 1154 rows, err := db.Query(` 1122 1155 WITH manifest_list_children AS ( 1123 1156 -- Get all digests that are children of manifest lists ··· 1138 1171 WHERE m.did = ? AND m.repository = ? 1139 1172 AND m.subject_digest IS NULL 1140 1173 AND m.artifact_type != 'unknown' 1174 + AND m.hold_endpoint IN `+accessibleHoldsSubquery+` 1141 1175 AND ( 1142 1176 -- Include manifest lists 1143 1177 m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%' ··· 1148 1182 GROUP BY m.id 1149 1183 ORDER BY m.created_at DESC 1150 1184 LIMIT ? OFFSET ? 1151 - `, did, repository, did, repository, limit, offset) 1185 + `, did, repository, did, repository, viewerDID, viewerDID, limit, offset) 1152 1186 1153 1187 if err != nil { 1154 1188 return nil, err ··· 2019 2053 WITH latest_manifests AS ( 2020 2054 SELECT did, repository, MAX(id) as latest_id 2021 2055 FROM manifests 2056 + WHERE hold_endpoint IN ` + accessibleHoldsSubquery + ` 2022 2057 GROUP BY did, repository 2023 2058 ) 2024 2059 SELECT ··· 2046 2081 LIMIT ? 2047 2082 ` 2048 2083 2049 - rows, err := db.Query(query, currentUserDID, limit) 2084 + rows, err := db.Query(query, currentUserDID, currentUserDID, currentUserDID, limit) 2050 2085 if err != nil { 2051 2086 return nil, err 2052 2087 } ··· 2092 2127 SELECT did, repository, MAX(id) as latest_id 2093 2128 FROM manifests 2094 2129 WHERE did = ? 2130 + AND hold_endpoint IN ` + accessibleHoldsSubquery + ` 2095 2131 GROUP BY did, repository 2096 2132 ) 2097 2133 SELECT ··· 2118 2154 ORDER BY MAX(rs.last_push, m.created_at) DESC 2119 2155 ` 2120 2156 2121 - rows, err := db.Query(query, userDID, currentUserDID) 2157 + rows, err := db.Query(query, userDID, currentUserDID, currentUserDID, currentUserDID) 2122 2158 if err != nil { 2123 2159 return nil, err 2124 2160 } ··· 2464 2500 // GetTagByName returns a single tag with platform information by tag name. 2465 2501 // Returns nil, nil if the tag doesn't exist. 2466 2502 func GetTagByName(db DBTX, did, repository, tagName string) (*TagWithPlatforms, error) { 2467 - tags, err := getTagsWithPlatformsFiltered(db, did, repository, tagName, 1, 0) 2503 + tags, err := getTagsWithPlatformsFiltered(db, did, repository, tagName, 1, 0, "", false) 2468 2504 if err != nil { 2469 2505 return nil, err 2470 2506 } ··· 2474 2510 return &tags[0], nil 2475 2511 } 2476 2512 2513 + // GetRepoHoldDIDs returns the distinct hold DIDs that host manifests for a 2514 + // given repository, restricted to holds the viewer can access. 2515 + func GetRepoHoldDIDs(db DBTX, did, repository string, viewerDID string) ([]string, error) { 2516 + rows, err := db.Query(` 2517 + SELECT DISTINCT m.hold_endpoint 2518 + FROM manifests m 2519 + WHERE m.did = ? AND m.repository = ? 2520 + AND m.hold_endpoint != '' 2521 + AND m.hold_endpoint IN `+accessibleHoldsSubquery+` 2522 + `, did, repository, viewerDID, viewerDID) 2523 + if err != nil { 2524 + return nil, err 2525 + } 2526 + defer rows.Close() 2527 + var holds []string 2528 + for rows.Next() { 2529 + var h string 2530 + if err := rows.Scan(&h); err != nil { 2531 + return nil, err 2532 + } 2533 + holds = append(holds, h) 2534 + } 2535 + return holds, rows.Err() 2536 + } 2537 + 2477 2538 // GetAllTagNames returns all tag names for a repository, ordered by most recent first. 2478 - func GetAllTagNames(db DBTX, did, repository string) ([]string, error) { 2539 + // Filters out tags whose manifests live on holds the viewer can't access. 2540 + func GetAllTagNames(db DBTX, did, repository string, viewerDID string) ([]string, error) { 2479 2541 rows, err := db.Query(` 2480 - SELECT tag FROM tags 2481 - WHERE did = ? AND repository = ? 2482 - ORDER BY created_at DESC 2483 - `, did, repository) 2542 + SELECT t.tag FROM tags t 2543 + JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest 2544 + WHERE t.did = ? AND t.repository = ? 2545 + AND m.hold_endpoint IN `+accessibleHoldsSubquery+` 2546 + ORDER BY t.created_at DESC 2547 + `, did, repository, viewerDID, viewerDID) 2484 2548 if err != nil { 2485 2549 return nil, err 2486 2550 }
+78 -2
pkg/appview/db/queries_test.go
··· 855 855 t.Fatalf("Failed to create test user: %v", err) 856 856 } 857 857 858 + // Register the test hold as public so the hold-access filter allows it 859 + if err := UpsertCaptainRecord(db, &HoldCaptainRecord{ 860 + HoldDID: "did:web:hold.example.com", 861 + OwnerDID: "did:plc:holdowner", 862 + Public: true, 863 + }); err != nil { 864 + t.Fatalf("Failed to insert captain record: %v", err) 865 + } 866 + 858 867 // Test 1: Single-arch manifest (no platform info) 859 868 singleArchManifest := &Manifest{ 860 869 DID: testUser.DID, ··· 882 891 t.Fatalf("Failed to insert single-arch tag: %v", err) 883 892 } 884 893 885 - tagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "myapp", 100, 0) 894 + tagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "myapp", 100, 0, "") 886 895 if err != nil { 887 896 t.Fatalf("Failed to get tags with platforms: %v", err) 888 897 } ··· 951 960 t.Fatalf("Failed to insert multi-arch tag: %v", err) 952 961 } 953 962 954 - multiTagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "multiapp", 100, 0) 963 + multiTagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "multiapp", 100, 0, "") 955 964 if err != nil { 956 965 t.Fatalf("Failed to get multi-arch tags with platforms: %v", err) 957 966 } ··· 1531 1540 t.Errorf("Expected 3 digests, got %d: %v", len(digests), digests) 1532 1541 } 1533 1542 } 1543 + 1544 + // TestGetUserRepositories_HoldAccessFilter verifies that repositories whose 1545 + // manifests live on inaccessible holds are hidden from viewers without access. 1546 + func TestGetUserRepositories_HoldAccessFilter(t *testing.T) { 1547 + db, err := InitDB("file:TestGetUserRepositories_HoldAccessFilter?mode=memory&cache=shared", LibsqlConfig{}) 1548 + if err != nil { 1549 + t.Fatalf("init db: %v", err) 1550 + } 1551 + defer db.Close() 1552 + 1553 + testUser := &User{DID: "did:plc:alice", Handle: "alice.test", PDSEndpoint: "https://pds.example", LastSeen: time.Now()} 1554 + if err := UpsertUser(db, testUser); err != nil { 1555 + t.Fatalf("upsert user: %v", err) 1556 + } 1557 + 1558 + // Public hold and a private invite-only hold 1559 + if err := UpsertCaptainRecord(db, &HoldCaptainRecord{ 1560 + HoldDID: "did:web:public.example", OwnerDID: "did:plc:holdowner", Public: true, 1561 + }); err != nil { 1562 + t.Fatalf("seed public captain: %v", err) 1563 + } 1564 + if err := UpsertCaptainRecord(db, &HoldCaptainRecord{ 1565 + HoldDID: "did:web:private.example", OwnerDID: "did:plc:holdowner", Public: false, AllowAllCrew: false, 1566 + }); err != nil { 1567 + t.Fatalf("seed private captain: %v", err) 1568 + } 1569 + 1570 + // Two repos: one on the public hold, one on the private hold 1571 + if _, err := InsertManifest(db, &Manifest{ 1572 + DID: testUser.DID, Repository: "publicrepo", Digest: "sha256:pub", 1573 + HoldEndpoint: "did:web:public.example", SchemaVersion: 2, 1574 + MediaType: "application/vnd.oci.image.manifest.v1+json", CreatedAt: time.Now(), 1575 + }); err != nil { 1576 + t.Fatalf("insert public manifest: %v", err) 1577 + } 1578 + if _, err := InsertManifest(db, &Manifest{ 1579 + DID: testUser.DID, Repository: "privaterepo", Digest: "sha256:priv", 1580 + HoldEndpoint: "did:web:private.example", SchemaVersion: 2, 1581 + MediaType: "application/vnd.oci.image.manifest.v1+json", CreatedAt: time.Now(), 1582 + }); err != nil { 1583 + t.Fatalf("insert private manifest: %v", err) 1584 + } 1585 + 1586 + // Anonymous viewer should see only the publicrepo 1587 + repos, err := GetUserRepositories(db, testUser.DID, "") 1588 + if err != nil { 1589 + t.Fatalf("GetUserRepositories anon: %v", err) 1590 + } 1591 + if len(repos) != 1 || repos[0].Name != "publicrepo" { 1592 + t.Errorf("anon viewer: expected [publicrepo], got %v", repos) 1593 + } 1594 + 1595 + // Make the private-hold owner a crew member and re-query as them 1596 + if err := UpsertCrewMember(db, &CrewMember{ 1597 + HoldDID: "did:web:private.example", MemberDID: "did:plc:crewdave", Rkey: "rk1", 1598 + }); err != nil { 1599 + t.Fatalf("upsert crew: %v", err) 1600 + } 1601 + 1602 + repos, err = GetUserRepositories(db, testUser.DID, "did:plc:crewdave") 1603 + if err != nil { 1604 + t.Fatalf("GetUserRepositories crew: %v", err) 1605 + } 1606 + if len(repos) != 2 { 1607 + t.Errorf("crew viewer: expected both repos, got %d: %v", len(repos), repos) 1608 + } 1609 + }
-29
pkg/appview/handlers/api.go
··· 164 164 render.JSON(w, r, map[string]bool{"starred": false}) 165 165 } 166 166 167 - // CredentialHelperVersionResponse is the response for the credential helper version API 168 - type CredentialHelperVersionResponse struct { 169 - Latest string `json:"latest"` 170 - DownloadURLs map[string]string `json:"download_urls"` 171 - Checksums map[string]string `json:"checksums"` 172 - ReleaseNotes string `json:"release_notes,omitempty"` 173 - } 174 - 175 - // CredentialHelperVersionHandler returns the latest credential helper version info 176 - // Note: Version info is fetched dynamically from TangledRepo's releases 177 - type CredentialHelperVersionHandler struct { 178 - TangledRepo string 179 - } 180 - 181 - func (h *CredentialHelperVersionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 182 - // This endpoint directs users to the Tangled repository for downloads 183 - // Version info should be fetched from the repository's releases page 184 - response := CredentialHelperVersionResponse{ 185 - Latest: "", 186 - DownloadURLs: map[string]string{"tangled_repo": h.TangledRepo}, 187 - Checksums: nil, 188 - ReleaseNotes: "Visit the Tangled repository for the latest releases: " + h.TangledRepo, 189 - } 190 - 191 - render.SetContentType(render.ContentTypeJSON) 192 - w.Header().Set("Cache-Control", "public, max-age=300") // Cache for 5 minutes 193 - render.JSON(w, r, response) 194 - } 195 - 196 167 // renderStarComponent renders the star component HTML for HTMX responses 197 168 func renderStarComponent(w http.ResponseWriter, tmpl *template.Template, handle, repository string, isStarred bool, starCount int) { 198 169 data := map[string]any{
+2 -2
pkg/appview/handlers/opengraph.go
··· 170 170 user = &db.User{DID: did, Handle: resolvedHandle} 171 171 } 172 172 173 - // Get repository count 174 - repos, err := db.GetUserRepositories(h.ReadOnlyDB, did) 173 + // Get repository count (OG cards render for anonymous crawlers) 174 + repos, err := db.GetUserRepositories(h.ReadOnlyDB, did, "") 175 175 repoCount := 0 176 176 if err == nil { 177 177 repoCount = len(repos)
+61 -28
pkg/appview/handlers/repository.go
··· 72 72 return 73 73 } 74 74 75 + // Resolve viewer DID for hold-access filtering (empty string = anonymous) 76 + var viewerDID string 77 + if vu := middleware.GetUser(r); vu != nil { 78 + viewerDID = vu.DID 79 + } 80 + 75 81 // Fetch all tag names for the selector dropdown 76 - allTags, err := db.GetAllTagNames(h.ReadOnlyDB, owner.DID, repository) 82 + allTags, err := db.GetAllTagNames(h.ReadOnlyDB, owner.DID, repository, viewerDID) 77 83 if err != nil { 78 84 slog.Warn("Failed to fetch tag names", "error", err) 79 85 } ··· 277 283 h.ClientShortName, 278 284 )) 279 285 286 + // Compute cross-hold badge: if the viewer has a default hold set and this 287 + // repo has tags on any other accessible hold, flag them so the template 288 + // can show an informational chip. 289 + var nonDefaultHolds []string 290 + if viewerDID != "" { 291 + viewerDefaultHold := db.GetUserHoldDID(h.ReadOnlyDB, viewerDID) 292 + if viewerDefaultHold != "" { 293 + repoHolds, herr := db.GetRepoHoldDIDs(h.ReadOnlyDB, owner.DID, repository, viewerDID) 294 + if herr != nil { 295 + slog.Warn("Failed to fetch repo hold DIDs", "error", herr) 296 + } 297 + for _, rh := range repoHolds { 298 + if rh != viewerDefaultHold { 299 + nonDefaultHolds = append(nonDefaultHolds, rh) 300 + } 301 + } 302 + } 303 + } 304 + 280 305 data := struct { 281 306 PageData 282 - Meta *PageMeta 283 - Owner *db.User 284 - Repository *db.Repository 285 - AllTags []string 286 - SelectedTag *SelectedTagData 287 - Stats *db.RepositoryStats 288 - TagCount int 289 - IsStarred bool 290 - IsOwner bool 291 - ReadmeHTML template.HTML 292 - RawDescription string 293 - ArtifactType string 307 + Meta *PageMeta 308 + Owner *db.User 309 + Repository *db.Repository 310 + AllTags []string 311 + SelectedTag *SelectedTagData 312 + Stats *db.RepositoryStats 313 + TagCount int 314 + IsStarred bool 315 + IsOwner bool 316 + ReadmeHTML template.HTML 317 + RawDescription string 318 + ArtifactType string 319 + NonDefaultHolds []string 294 320 }{ 295 - PageData: NewPageData(r, &h.BaseUIHandler), 296 - Meta: meta, 297 - Owner: owner, 298 - Repository: repo, 299 - AllTags: allTags, 300 - SelectedTag: selectedTag, 301 - Stats: stats, 302 - TagCount: tagCount, 303 - IsStarred: isStarred, 304 - IsOwner: isOwner, 305 - ReadmeHTML: readmeHTML, 306 - RawDescription: rawDescription, 307 - ArtifactType: artifactType, 321 + PageData: NewPageData(r, &h.BaseUIHandler), 322 + Meta: meta, 323 + Owner: owner, 324 + Repository: repo, 325 + AllTags: allTags, 326 + SelectedTag: selectedTag, 327 + Stats: stats, 328 + TagCount: tagCount, 329 + IsStarred: isStarred, 330 + IsOwner: isOwner, 331 + ReadmeHTML: readmeHTML, 332 + RawDescription: rawDescription, 333 + ArtifactType: artifactType, 334 + NonDefaultHolds: nonDefaultHolds, 308 335 } 309 336 310 337 // If the owner has disabled AI advisor in their profile, hide the button ··· 388 415 } 389 416 } 390 417 418 + // Resolve viewer DID for hold-access filtering (empty string = anonymous) 419 + var viewerDID string 420 + if vu := middleware.GetUser(r); vu != nil { 421 + viewerDID = vu.DID 422 + } 423 + 391 424 // Count total tags for pagination 392 425 totalTags, err := db.CountTags(h.ReadOnlyDB, owner.DID, repository) 393 426 if err != nil { ··· 396 429 } 397 430 398 431 // Fetch tags with platform information and compressed sizes 399 - tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.ReadOnlyDB, owner.DID, repository, pageSize, offset) 432 + tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.ReadOnlyDB, owner.DID, repository, pageSize, offset, viewerDID) 400 433 if err != nil { 401 434 http.Error(w, err.Error(), http.StatusInternalServerError) 402 435 return ··· 405 438 // Fetch untagged manifests only on first page 406 439 var manifests []db.ManifestWithMetadata 407 440 if offset == 0 { 408 - manifests, err = db.GetTopLevelManifests(h.ReadOnlyDB, owner.DID, repository, 50, 0) 441 + manifests, err = db.GetTopLevelManifests(h.ReadOnlyDB, owner.DID, repository, 50, 0, viewerDID) 409 442 if err != nil { 410 443 http.Error(w, err.Error(), http.StatusInternalServerError) 411 444 return
+39 -49
pkg/appview/public/static/install.ps1
··· 6 6 # Configuration 7 7 $BinaryName = "docker-credential-atcr.exe" 8 8 $InstallDir = if ($env:ATCR_INSTALL_DIR) { $env:ATCR_INSTALL_DIR } else { "$env:ProgramFiles\ATCR" } 9 - $ApiUrl = if ($env:ATCR_API_URL) { $env:ATCR_API_URL } else { "https://atcr.io/api/credential-helper/version" } 10 - 11 - # Fallback configuration (used if API is unavailable) 12 - $FallbackVersion = "v0.0.1" 13 - $FallbackTangledRepo = "https://tangled.org/evan.jarrett.net/at-container-registry" 9 + $TangledRepo = if ($env:ATCR_TANGLED_REPO) { $env:ATCR_TANGLED_REPO } else { "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64" } 14 10 15 11 Write-Host "ATCR Credential Helper Installer for Windows" -ForegroundColor Green 16 12 Write-Host "" ··· 19 15 function Get-Architecture { 20 16 $arch = (Get-WmiObject Win32_Processor).Architecture 21 17 switch ($arch) { 22 - 9 { return @{ Display = "x86_64"; Key = "amd64" } } # x64 23 - 12 { return @{ Display = "arm64"; Key = "arm64" } } # ARM64 18 + 9 { return "x86_64" } # x64 19 + 12 { return "arm64" } # ARM64 24 20 default { 25 21 Write-Host "Unsupported architecture: $arch" -ForegroundColor Red 26 22 exit 1 ··· 28 24 } 29 25 } 30 26 31 - $ArchInfo = Get-Architecture 32 - $Arch = $ArchInfo.Display 33 - $ArchKey = $ArchInfo.Key 34 - $PlatformKey = "windows_$ArchKey" 35 - 27 + $Arch = Get-Architecture 36 28 Write-Host "Detected: Windows $Arch" -ForegroundColor Green 37 29 38 - # Fetch version info from API 39 - function Get-VersionInfo { 40 - Write-Host "Fetching latest version info..." -ForegroundColor Yellow 30 + # Resolve the latest version via the tangled /tags/latest redirect 31 + function Get-LatestVersion { 32 + Write-Host "Resolving latest version..." -ForegroundColor Yellow 41 33 42 34 try { 43 - $response = Invoke-WebRequest -Uri $ApiUrl -UseBasicParsing -TimeoutSec 10 44 - $json = $response.Content | ConvertFrom-Json 45 - 46 - if ($json.latest -and $json.download_urls.$PlatformKey) { 47 - return @{ 48 - Version = $json.latest 49 - DownloadUrl = $json.download_urls.$PlatformKey 50 - } 51 - } 35 + $response = Invoke-WebRequest -Uri "$TangledRepo/tags/latest" -UseBasicParsing -MaximumRedirection 0 -ErrorAction SilentlyContinue 36 + $location = $response.Headers.Location 52 37 } catch { 53 - Write-Host "API unavailable, using fallback version" -ForegroundColor Yellow 38 + # PowerShell 5 throws when -MaximumRedirection 0 receives a redirect; grab it off the exception. 39 + $location = $_.Exception.Response.Headers.Location 40 + if ($location) { $location = $location.ToString() } 41 + } 42 + 43 + if (-not $location) { 44 + Write-Host "Failed to resolve latest version from $TangledRepo/tags/latest" -ForegroundColor Red 45 + exit 1 54 46 } 55 47 56 - return $null 48 + $tag = $location.TrimEnd('/').Split('/')[-1] 49 + if (-not $tag.StartsWith('v')) { 50 + Write-Host "Unexpected redirect location: $location" -ForegroundColor Red 51 + exit 1 52 + } 53 + 54 + Write-Host "Found latest version: $tag" -ForegroundColor Green 55 + return $tag 57 56 } 58 57 59 - # Get download URL for fallback 60 - function Get-FallbackUrl { 58 + # Build the download URL from version and platform 59 + function Get-DownloadUrl { 61 60 param([string]$Version, [string]$Arch) 62 61 63 62 $versionClean = $Version.TrimStart('v') 64 - # Note: Windows builds use .zip format 65 - $fileName = "docker-credential-atcr_${versionClean}_Windows_${Arch}.zip" 66 - return "$FallbackTangledRepo/tags/$Version/download/$fileName" 63 + $fileName = "docker-credential-atcr_${versionClean}_Windows_${Arch}.tar.gz" 64 + return "$TangledRepo/tags/$Version/download/$fileName" 67 65 } 68 66 69 67 # Determine version and download URL 70 - $Version = $null 71 - $DownloadUrl = $null 72 - 73 68 if ($env:ATCR_VERSION) { 74 69 $Version = $env:ATCR_VERSION 75 - $DownloadUrl = Get-FallbackUrl -Version $Version -Arch $Arch 76 70 Write-Host "Using specified version: $Version" -ForegroundColor Yellow 77 71 } else { 78 - $versionInfo = Get-VersionInfo 79 - 80 - if ($versionInfo) { 81 - $Version = $versionInfo.Version 82 - $DownloadUrl = $versionInfo.DownloadUrl 83 - Write-Host "Found latest version: $Version" -ForegroundColor Green 84 - } else { 85 - $Version = $FallbackVersion 86 - $DownloadUrl = Get-FallbackUrl -Version $Version -Arch $Arch 87 - Write-Host "Using fallback version: $Version" -ForegroundColor Yellow 88 - } 72 + $Version = Get-LatestVersion 89 73 } 90 74 75 + $DownloadUrl = Get-DownloadUrl -Version $Version -Arch $Arch 91 76 Write-Host "Installing version: $Version" -ForegroundColor Green 92 77 93 78 # Download and install binary ··· 99 84 Write-Host "Downloading from: $DownloadUrl" -ForegroundColor Yellow 100 85 101 86 $tempDir = New-Item -ItemType Directory -Path "$env:TEMP\atcr-install-$(Get-Random)" -Force 102 - $zipPath = Join-Path $tempDir "docker-credential-atcr.zip" 87 + $archivePath = Join-Path $tempDir "docker-credential-atcr.tar.gz" 103 88 104 89 try { 105 - Invoke-WebRequest -Uri $DownloadUrl -OutFile $zipPath -UseBasicParsing 90 + Invoke-WebRequest -Uri $DownloadUrl -OutFile $archivePath -UseBasicParsing 106 91 } catch { 107 92 Write-Host "Failed to download release: $_" -ForegroundColor Red 108 93 exit 1 109 94 } 110 95 111 96 Write-Host "Extracting..." -ForegroundColor Yellow 112 - Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force 97 + # Modern Windows ships tar.exe; use it to handle .tar.gz produced by goreleaser. 98 + & tar.exe -xzf $archivePath -C $tempDir 99 + if ($LASTEXITCODE -ne 0) { 100 + Write-Host "Failed to extract archive" -ForegroundColor Red 101 + exit 1 102 + } 113 103 114 104 # Create install directory 115 105 if (-not (Test-Path $InstallDir)) {
+24 -50
pkg/appview/public/static/install.sh
··· 13 13 # Configuration 14 14 BINARY_NAME="docker-credential-atcr" 15 15 INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" 16 - API_URL="${ATCR_API_URL:-https://atcr.io/api/credential-helper/version}" 17 - 18 - # Fallback configuration (used if API is unavailable) 19 - FALLBACK_VERSION="v0.0.1" 20 - FALLBACK_TANGLED_REPO="https://tangled.org/evan.jarrett.net/at-container-registry" 16 + TANGLED_REPO="${ATCR_TANGLED_REPO:-https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64}" 21 17 22 18 # Detect OS and architecture 23 19 detect_platform() { ··· 27 23 case "$os" in 28 24 linux*) 29 25 OS="Linux" 30 - OS_KEY="linux" 31 26 ;; 32 27 darwin*) 33 28 OS="Darwin" 34 - OS_KEY="darwin" 35 29 ;; 36 30 *) 37 31 echo -e "${RED}Unsupported OS: $os${NC}" ··· 42 36 case "$arch" in 43 37 x86_64|amd64) 44 38 ARCH="x86_64" 45 - ARCH_KEY="amd64" 46 39 ;; 47 40 aarch64|arm64) 48 41 ARCH="arm64" 49 - ARCH_KEY="arm64" 50 42 ;; 51 43 *) 52 44 echo -e "${RED}Unsupported architecture: $arch${NC}" 53 45 exit 1 54 46 ;; 55 47 esac 56 - 57 - PLATFORM_KEY="${OS_KEY}_${ARCH_KEY}" 58 48 } 59 49 60 - # Fetch version info from API 61 - fetch_version_info() { 62 - echo -e "${YELLOW}Fetching latest version info...${NC}" 50 + # Resolve the latest version by reading the tangled /tags/latest redirect 51 + fetch_latest_version() { 52 + echo -e "${YELLOW}Resolving latest version...${NC}" 63 53 64 - # Try to fetch from API 65 - local api_response 66 - if api_response=$(curl -fsSL --max-time 10 "$API_URL" 2>/dev/null); then 67 - # Parse JSON response (requires jq or basic parsing) 68 - if command -v jq &> /dev/null; then 69 - VERSION=$(echo "$api_response" | jq -r '.latest') 70 - DOWNLOAD_URL=$(echo "$api_response" | jq -r ".download_urls.${PLATFORM_KEY}") 54 + local redirect 55 + redirect=$(curl -s --max-time 10 -o /dev/null -D - "${TANGLED_REPO}/tags/latest" | awk 'tolower($1) == "location:" { print $2 }' | tr -d '\r\n') 71 56 72 - if [ "$VERSION" != "null" ] && [ "$DOWNLOAD_URL" != "null" ] && [ -n "$VERSION" ] && [ -n "$DOWNLOAD_URL" ]; then 73 - echo -e "${GREEN}Found latest version: ${VERSION}${NC}" 74 - return 0 75 - fi 76 - else 77 - # Fallback: basic grep parsing if jq not available 78 - VERSION=$(echo "$api_response" | grep -o '"latest":"[^"]*"' | cut -d'"' -f4) 79 - # Try to extract the specific platform URL 80 - DOWNLOAD_URL=$(echo "$api_response" | grep -o "\"${PLATFORM_KEY}\":\"[^\"]*\"" | cut -d'"' -f4) 57 + if [ -z "$redirect" ]; then 58 + echo -e "${RED}Failed to resolve latest version from ${TANGLED_REPO}/tags/latest${NC}" 59 + exit 1 60 + fi 81 61 82 - if [ -n "$VERSION" ] && [ -n "$DOWNLOAD_URL" ]; then 83 - echo -e "${GREEN}Found latest version: ${VERSION}${NC}" 84 - return 0 85 - fi 86 - fi 62 + VERSION="${redirect##*/}" 63 + 64 + if [ -z "$VERSION" ] || [ "${VERSION#v}" = "$VERSION" ]; then 65 + echo -e "${RED}Unexpected redirect location: ${redirect}${NC}" 66 + exit 1 87 67 fi 88 68 89 - echo -e "${YELLOW}API unavailable, using fallback version${NC}" 90 - return 1 69 + echo -e "${GREEN}Found latest version: ${VERSION}${NC}" 91 70 } 92 71 93 - # Set fallback download URL 94 - use_fallback() { 95 - VERSION="$FALLBACK_VERSION" 72 + # Build the download URL from version and platform 73 + build_download_url() { 96 74 local version_without_v="${VERSION#v}" 97 - DOWNLOAD_URL="${FALLBACK_TANGLED_REPO}/tags/${VERSION}/download/docker-credential-atcr_${version_without_v}_${OS}_${ARCH}.tar.gz" 75 + DOWNLOAD_URL="${TANGLED_REPO}/tags/${VERSION}/download/docker-credential-atcr_${version_without_v}_${OS}_${ARCH}.tar.gz" 98 76 } 99 77 100 78 # Download and install binary ··· 164 142 detect_platform 165 143 echo -e "Detected: ${GREEN}${OS} ${ARCH}${NC}" 166 144 167 - # Check if version is manually specified 168 145 if [ -n "$ATCR_VERSION" ]; then 169 - echo -e "Using specified version: ${GREEN}${ATCR_VERSION}${NC}" 170 146 VERSION="$ATCR_VERSION" 171 - local version_without_v="${VERSION#v}" 172 - DOWNLOAD_URL="${FALLBACK_TANGLED_REPO}/tags/${VERSION}/download/docker-credential-atcr_${version_without_v}_${OS}_${ARCH}.tar.gz" 147 + echo -e "Using specified version: ${GREEN}${VERSION}${NC}" 173 148 else 174 - # Try to fetch from API, fall back if unavailable 175 - if ! fetch_version_info; then 176 - use_fallback 177 - fi 178 - echo -e "Installing version: ${GREEN}${VERSION}${NC}" 149 + fetch_latest_version 179 150 fi 151 + 152 + build_download_url 153 + echo -e "Installing version: ${GREEN}${VERSION}${NC}" 180 154 181 155 install_binary 182 156 verify_installation
-9
pkg/appview/routes/routes.go
··· 269 269 }) 270 270 } 271 271 272 - // RegisterCredentialHelperEndpoint registers the credential helper version API 273 - // endpoint (GET /api/credential-helper/version). Separated from RegisterUIRoutes 274 - // for the same import-cycle reason as RegisterDeviceEndpoints. 275 - func RegisterCredentialHelperEndpoint(router chi.Router, tangledRepo string) { 276 - router.Handle("/api/credential-helper/version", &uihandlers.CredentialHelperVersionHandler{ 277 - TangledRepo: tangledRepo, 278 - }) 279 - } 280 - 281 272 // trimRegistryURL removes http:// or https:// prefix from a URL 282 273 // for use in Docker commands where only the host:port is needed 283 274 func trimRegistryURL(url string) string {
-3
pkg/appview/server.go
··· 611 611 // Appview DID document endpoint (service identity for key discovery) 612 612 mainRouter.Get("/.well-known/did.json", s.handleDIDDocument) 613 613 614 - // Register credential helper version API (public endpoint) 615 - routes.RegisterCredentialHelperEndpoint(mainRouter, cfg.CredentialHelper.TangledRepo) 616 - 617 614 s.Router = mainRouter 618 615 619 616 return s, nil
+11
pkg/appview/templates/partials/repo-tag-section.html
··· 4 4 <!-- Pull Command with Client Switcher --> 5 5 {{ template "pull-command-switcher" (dict "RegistryURL" .RegistryURL "OwnerHandle" .Owner.Handle "RepoName" .Repository.Name "Tag" .SelectedTag.Info.Tag.Tag "ArtifactType" .ArtifactType "OciClient" .OciClient "IsLoggedIn" (ne .User nil)) }} 6 6 7 + {{ if .NonDefaultHolds }} 8 + <!-- Informational badge: this repo has content on a hold other than the viewer's default --> 9 + <div class="mt-2 flex flex-wrap gap-2 items-center text-xs text-base-content/70"> 10 + <span>Hosted on:</span> 11 + {{ range .NonDefaultHolds }} 12 + <span class="badge badge-outline badge-sm" title="{{ . }}">{{ displayHoldDID . }}</span> 13 + {{ end }} 14 + <span class="text-base-content/50">(different from your default hold)</span> 15 + </div> 16 + {{ end }} 17 + 7 18 <!-- Stats Cards --> 8 19 <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4"> 9 20 <!-- Size & Layers -->
+12
pkg/appview/ui.go
··· 207 207 return s 208 208 }, 209 209 210 + "displayHoldDID": func(holdDID string) string { 211 + // did:web:hold01.atcr.io → hold01.atcr.io 212 + if strings.HasPrefix(holdDID, "did:web:") { 213 + return strings.TrimPrefix(holdDID, "did:web:") 214 + } 215 + // did:plc:opaque... → did:plc:opaque...xxxx (truncated) 216 + if len(holdDID) > 20 { 217 + return holdDID[:20] + "…" 218 + } 219 + return holdDID 220 + }, 221 + 210 222 "sanitizeID": func(s string) string { 211 223 // Replace special CSS selector characters with dashes 212 224 // e.g., "sha256:abc123" becomes "sha256-abc123"