diff --git a/cmd/credential-helper/cmd_update.go b/cmd/credential-helper/cmd_update.go index b8260c6..34572bf 100644 --- a/cmd/credential-helper/cmd_update.go +++ b/cmd/credential-helper/cmd_update.go @@ -1,12 +1,13 @@ package main import ( - "encoding/json" "fmt" "io" "net/http" + "net/url" "os" "os/exec" + "path" "path/filepath" "runtime" "strconv" @@ -16,13 +17,10 @@ import ( "github.com/spf13/cobra" ) -// VersionAPIResponse is the response from /api/credential-helper/version -type VersionAPIResponse struct { - Latest string `json:"latest"` - DownloadURLs map[string]string `json:"download_urls"` - Checksums map[string]string `json:"checksums"` - ReleaseNotes string `json:"release_notes,omitempty"` -} +// tangledReleasesBase is the tangled.org path for the credential-helper's +// release repository. /tags/latest issues a 302 redirect to the latest tag, +// and /tags/{version}/download/{filename} serves goreleaser artifacts directly. +const tangledReleasesBase = "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64" func newUpdateCmd() *cobra.Command { cmd := &cobra.Command{ @@ -37,35 +35,23 @@ func newUpdateCmd() *cobra.Command { func runUpdate(cmd *cobra.Command, args []string) error { checkOnly, _ := cmd.Flags().GetBool("check") - // Default API URL - apiURL := "https://atcr.io/api/credential-helper/version" - - // Try to get AppView URL from stored credentials - cfg, _ := loadConfig() - if cfg != nil { - for url := range cfg.Registries { - apiURL = url + "/api/credential-helper/version" - break - } - } - - versionInfo, err := fetchVersionInfo(apiURL) + latest, err := fetchLatestVersion() if err != nil { return fmt.Errorf("checking for updates: %w", err) } - if !isNewerVersion(versionInfo.Latest, version) { + if !isNewerVersion(latest, version) { fmt.Printf("You're already running the latest version (%s)\n", version) return nil } - fmt.Printf("New version available: %s (current: %s)\n", versionInfo.Latest, version) + fmt.Printf("New version available: %s (current: %s)\n", latest, version) if checkOnly { return nil } - if err := performUpdate(versionInfo); err != nil { + if err := performUpdate(latest); err != nil { return fmt.Errorf("update failed: %w", err) } @@ -73,28 +59,45 @@ func runUpdate(cmd *cobra.Command, args []string) error { return nil } -// fetchVersionInfo fetches version info from the AppView API -func fetchVersionInfo(apiURL string) (*VersionAPIResponse, error) { +// fetchLatestVersion resolves the latest released version by reading the +// redirect Location header of {tangledReleasesBase}/tags/latest. +func fetchLatestVersion() (string, error) { client := &http.Client{ Timeout: 10 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, } - resp, err := client.Get(apiURL) + resp, err := client.Get(tangledReleasesBase + "/tags/latest") if err != nil { - return nil, fmt.Errorf("fetching version info: %w", err) + return "", fmt.Errorf("fetching latest tag: %w", err) } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("version API returned status %d", resp.StatusCode) + switch resp.StatusCode { + case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, + http.StatusTemporaryRedirect, http.StatusPermanentRedirect: + default: + return "", fmt.Errorf("expected redirect from tags/latest, got status %d", resp.StatusCode) } - var versionInfo VersionAPIResponse - if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil { - return nil, fmt.Errorf("parsing version info: %w", err) + location := resp.Header.Get("Location") + if location == "" { + return "", fmt.Errorf("tags/latest returned redirect with no Location header") } - return &versionInfo, nil + u, err := url.Parse(location) + if err != nil { + return "", fmt.Errorf("parsing redirect location %q: %w", location, err) + } + + tag := path.Base(u.Path) + if !strings.HasPrefix(tag, "v") { + return "", fmt.Errorf("unexpected tag in redirect location %q", location) + } + + return tag, nil } // isNewerVersion compares two version strings (simple semver comparison) @@ -130,21 +133,30 @@ func isNewerVersion(newVersion, currentVersion string) bool { return len(newParts) > len(curParts) } -// getPlatformKey returns the platform key for the current OS/arch -func getPlatformKey() string { - return fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH) +// goreleaserArchiveName returns the archive filename goreleaser publishes for +// the given version and the current platform. The naming template lives in +// .goreleaser.yaml: docker-credential-atcr_{Version}_{Title(OS)}_{Arch} with +// amd64→x86_64 and 386→i386. +func goreleaserArchiveName(version string) string { + versionNoV := strings.TrimPrefix(version, "v") + + os := strings.ToUpper(runtime.GOOS[:1]) + runtime.GOOS[1:] + + arch := runtime.GOARCH + switch arch { + case "amd64": + arch = "x86_64" + case "386": + arch = "i386" + } + + return fmt.Sprintf("docker-credential-atcr_%s_%s_%s.tar.gz", versionNoV, os, arch) } // performUpdate downloads and installs the new version -func performUpdate(versionInfo *VersionAPIResponse) error { - platformKey := getPlatformKey() - - downloadURL, ok := versionInfo.DownloadURLs[platformKey] - if !ok { - return fmt.Errorf("no download available for platform %s", platformKey) - } - - expectedChecksum := versionInfo.Checksums[platformKey] +func performUpdate(latest string) error { + filename := goreleaserArchiveName(latest) + downloadURL := fmt.Sprintf("%s/tags/%s/download/%s", tangledReleasesBase, latest, filename) fmt.Printf("Downloading update from %s...\n", downloadURL) @@ -155,34 +167,17 @@ func performUpdate(versionInfo *VersionAPIResponse) error { defer os.RemoveAll(tmpDir) archivePath := filepath.Join(tmpDir, "archive.tar.gz") - if strings.HasSuffix(downloadURL, ".zip") { - archivePath = filepath.Join(tmpDir, "archive.zip") - } - if err := downloadFile(downloadURL, archivePath); err != nil { return fmt.Errorf("downloading: %w", err) } - if expectedChecksum != "" { - if err := verifyChecksum(archivePath, expectedChecksum); err != nil { - return fmt.Errorf("checksum verification failed: %w", err) - } - fmt.Println("Checksum verified.") - } - binaryPath := filepath.Join(tmpDir, "docker-credential-atcr") if runtime.GOOS == "windows" { binaryPath += ".exe" } - if strings.HasSuffix(archivePath, ".zip") { - if err := extractZip(archivePath, tmpDir); err != nil { - return fmt.Errorf("extracting archive: %w", err) - } - } else { - if err := extractTarGz(archivePath, tmpDir); err != nil { - return fmt.Errorf("extracting archive: %w", err) - } + if err := extractTarGz(archivePath, tmpDir); err != nil { + return fmt.Errorf("extracting archive: %w", err) } currentPath, err := os.Executable() @@ -244,15 +239,6 @@ func downloadFile(url, destPath string) error { return err } -// verifyChecksum verifies the SHA256 checksum of a file -func verifyChecksum(filePath, expected string) error { - if expected == "" { - return nil - } - // Checksums are optional until configured - return nil -} - // extractTarGz extracts a .tar.gz archive func extractTarGz(archivePath, destDir string) error { cmd := exec.Command("tar", "-xzf", archivePath, "-C", destDir) @@ -262,15 +248,6 @@ func extractTarGz(archivePath, destDir string) error { return nil } -// extractZip extracts a .zip archive -func extractZip(archivePath, destDir string) error { - cmd := exec.Command("unzip", "-o", archivePath, "-d", destDir) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("unzip failed: %s: %w", string(output), err) - } - return nil -} - // copyFile copies a file from src to dst func copyFile(src, dst string) error { input, err := os.ReadFile(src) diff --git a/cmd/credential-helper/protocol.go b/cmd/credential-helper/protocol.go index 47b1709..2660f18 100644 --- a/cmd/credential-helper/protocol.go +++ b/cmd/credential-helper/protocol.go @@ -105,7 +105,7 @@ func runGet(cmd *cobra.Command, args []string) error { } // Check for updates (cached, non-blocking) - checkAndNotifyUpdate(appViewURL) + checkAndNotifyUpdate() // Return credentials for Docker creds := Credentials{ @@ -200,7 +200,7 @@ func runList(cmd *cobra.Command, args []string) error { } // checkAndNotifyUpdate checks for updates in the background and notifies the user -func checkAndNotifyUpdate(appViewURL string) { +func checkAndNotifyUpdate() { cache := loadUpdateCheckCache() if cache != nil && cache.Current == version { // Cache is fresh and for current version @@ -214,21 +214,19 @@ func checkAndNotifyUpdate(appViewURL string) { } } - // Fetch version info - apiURL := appViewURL + "/api/credential-helper/version" - versionInfo, err := fetchVersionInfo(apiURL) + latest, err := fetchLatestVersion() if err != nil { return // Silently fail } saveUpdateCheckCache(&UpdateCheckCache{ CheckedAt: timeNow(), - Latest: versionInfo.Latest, + Latest: latest, Current: version, }) - if isNewerVersion(versionInfo.Latest, version) { - fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", versionInfo.Latest, version) + if isNewerVersion(latest, version) { + fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", latest, version) fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr update\n\n") } } diff --git a/config-appview.example.yaml b/config-appview.example.yaml index 69ea4c3..c06f919 100644 --- a/config-appview.example.yaml +++ b/config-appview.example.yaml @@ -81,10 +81,6 @@ auth: key_path: /var/lib/atcr/auth/private-key.pem # X.509 certificate matching the JWT signing key. cert_path: /var/lib/atcr/auth/private-key.crt -# Credential helper download settings. -credential_helper: - # Tangled repository URL for credential helper downloads. - tangled_repo: "" # Legal page customization for self-hosted instances. legal: # Organization name for Terms of Service and Privacy Policy. Defaults to server.client_name. diff --git a/deploy/upcloud/configs/appview.yaml.tmpl b/deploy/upcloud/configs/appview.yaml.tmpl index e70bb1d..a48b756 100644 --- a/deploy/upcloud/configs/appview.yaml.tmpl +++ b/deploy/upcloud/configs/appview.yaml.tmpl @@ -41,8 +41,6 @@ jetstream: auth: key_path: "{{.BasePath}}/auth/private-key.pem" cert_path: "{{.BasePath}}/auth/private-key.crt" -credential_helper: - tangled_repo: "" legal: company_name: Seamark jurisdiction: State of Texas, United States diff --git a/docs/appview.md b/docs/appview.md index dfe6c14..0d07b7d 100644 --- a/docs/appview.md +++ b/docs/appview.md @@ -141,7 +141,6 @@ jetstream.backfill_enabled → ATCR_JETSTREAM_BACKFILL_ENABLED | `health` | Hold health check interval and cache TTL | Sensible defaults (15m) | | `log_shipper` | Remote log shipping (Victoria, OpenSearch, Loki) | Disabled by default | | `legal` | Terms/privacy page customization | Optional | -| `credential_helper` | Credential helper download source | Optional | ### Auto-generated files diff --git a/pkg/appview/config.go b/pkg/appview/config.go index 5e2ebb8..d52bf28 100644 --- a/pkg/appview/config.go +++ b/pkg/appview/config.go @@ -22,19 +22,18 @@ import ( // Config represents the AppView service configuration type Config struct { - Version string `yaml:"version" comment:"Configuration format version."` - LogLevel string `yaml:"log_level" comment:"Log level: debug, info, warn, error."` - LogShipper config.LogShipperConfig `yaml:"log_shipper" comment:"Remote log shipping settings."` - Server ServerConfig `yaml:"server" comment:"HTTP server and identity settings."` - UI UIConfig `yaml:"ui" comment:"Web UI settings."` - Health HealthConfig `yaml:"health" comment:"Health check and cache settings."` - Jetstream JetstreamConfig `yaml:"jetstream" comment:"ATProto Jetstream event stream settings."` - Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."` - CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper download settings."` - Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."` - AI AIConfig `yaml:"ai" comment:"AI-powered image advisor settings."` - Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."` - Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility + Version string `yaml:"version" comment:"Configuration format version."` + LogLevel string `yaml:"log_level" comment:"Log level: debug, info, warn, error."` + LogShipper config.LogShipperConfig `yaml:"log_shipper" comment:"Remote log shipping settings."` + Server ServerConfig `yaml:"server" comment:"HTTP server and identity settings."` + UI UIConfig `yaml:"ui" comment:"Web UI settings."` + Health HealthConfig `yaml:"health" comment:"Health check and cache settings."` + Jetstream JetstreamConfig `yaml:"jetstream" comment:"ATProto Jetstream event stream settings."` + Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."` + Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."` + AI AIConfig `yaml:"ai" comment:"AI-powered image advisor settings."` + Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."` + Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility } // ServerConfig defines server settings @@ -126,12 +125,6 @@ type AuthConfig struct { ServiceName string `yaml:"-"` } -// CredentialHelperConfig defines credential helper download settings -type CredentialHelperConfig struct { - // TangledRepo is the Tangled repository URL for downloads - TangledRepo string `yaml:"tangled_repo" comment:"Tangled repository URL for credential helper downloads."` -} - // LegalConfig defines legal page customization for self-hosted instances type LegalConfig struct { // Organization name for legal pages. Defaults to ClientName. @@ -258,7 +251,6 @@ func LoadConfig(yamlPath string) (*Config, error) { // Post-load: fixed values cfg.Auth.TokenExpiration = 5 * time.Minute cfg.Auth.ServiceName = deriveServiceName(cfg) - cfg.CredentialHelper.TangledRepo = "https://tangled.org/evan.jarrett.net/at-container-registry" // Post-load: CompanyName defaults to ClientName if cfg.Legal.CompanyName == "" { diff --git a/pkg/appview/db/hold_store.go b/pkg/appview/db/hold_store.go index 3b3faaa..7b6ce94 100644 --- a/pkg/appview/db/hold_store.go +++ b/pkg/appview/db/hold_store.go @@ -387,6 +387,54 @@ func GetAvailableHolds(db DBTX, userDID string) ([]AvailableHold, error) { return holds, nil } +// GetAccessibleHoldDIDs returns the set of hold DIDs whose content the viewer +// is allowed to see in listings. If viewerDID is empty (anonymous), this +// returns holds with public=1 OR allow_all_crew=1. For signed-in viewers it +// additionally includes holds where the viewer is owner or crew. +// +// The returned slice is suitable for use in an IN (...) clause against +// manifests.hold_endpoint / tags.hold_endpoint (which store the hold DID). +func GetAccessibleHoldDIDs(db DBTX, viewerDID string) ([]string, error) { + var rows *sql.Rows + var err error + + if viewerDID == "" { + rows, err = db.Query(` + SELECT hold_did + FROM hold_captain_records + WHERE public = 1 OR allow_all_crew = 1 + `) + } else { + rows, err = db.Query(` + SELECT DISTINCT h.hold_did + FROM hold_captain_records h + LEFT JOIN hold_crew_members c + ON h.hold_did = c.hold_did AND c.member_did = ?1 + WHERE h.public = 1 + OR h.allow_all_crew = 1 + OR h.owner_did = ?1 + OR c.member_did IS NOT NULL + `, viewerDID) + } + if err != nil { + return nil, fmt.Errorf("failed to query accessible holds: %w", err) + } + defer rows.Close() + + var dids []string + for rows.Next() { + var did string + if err := rows.Scan(&did); err != nil { + return nil, fmt.Errorf("failed to scan accessible hold: %w", err) + } + dids = append(dids, did) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating accessible holds: %w", err) + } + return dids, nil +} + // GetCrewMemberships returns all holds where a user is a crew member func GetCrewMemberships(db DBTX, memberDID string) ([]CrewMember, error) { query := ` diff --git a/pkg/appview/db/hold_store_test.go b/pkg/appview/db/hold_store_test.go index 3df0a98..7a4e789 100644 --- a/pkg/appview/db/hold_store_test.go +++ b/pkg/appview/db/hold_store_test.go @@ -464,3 +464,94 @@ func TestListHoldDIDs_OrderByUpdatedAt(t *testing.T) { } } } + +// TestGetAccessibleHoldDIDs tests the viewer→hold visibility computation +// used to filter listings to what the viewer is allowed to see. +func TestGetAccessibleHoldDIDs(t *testing.T) { + db := setupHoldTestDB(t) + + // Seed 4 captain records covering each visibility combo + records := []*HoldCaptainRecord{ + {HoldDID: "did:web:public.example", OwnerDID: "did:plc:alice", Public: true, AllowAllCrew: false, UpdatedAt: time.Now()}, + {HoldDID: "did:web:selfserv.example", OwnerDID: "did:plc:bob", Public: false, AllowAllCrew: true, UpdatedAt: time.Now()}, + {HoldDID: "did:web:invite.example", OwnerDID: "did:plc:carol", Public: false, AllowAllCrew: false, UpdatedAt: time.Now()}, + {HoldDID: "did:web:carol-hold.example", OwnerDID: "did:plc:carol", Public: false, AllowAllCrew: false, UpdatedAt: time.Now()}, + } + for _, r := range records { + if err := UpsertCaptainRecord(db, r); err != nil { + t.Fatalf("seed captain %s: %v", r.HoldDID, err) + } + } + + // dave is crew of did:web:invite.example + if err := UpsertCrewMember(db, &CrewMember{ + HoldDID: "did:web:invite.example", MemberDID: "did:plc:dave", Rkey: "rk1", + }); err != nil { + t.Fatalf("seed crew: %v", err) + } + + contains := func(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false + } + + t.Run("anonymous viewer sees public + self-service only", func(t *testing.T) { + dids, err := GetAccessibleHoldDIDs(db, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(dids) != 2 { + t.Fatalf("expected 2 DIDs (public+self-service), got %d: %v", len(dids), dids) + } + if !contains(dids, "did:web:public.example") { + t.Errorf("missing public hold: %v", dids) + } + if !contains(dids, "did:web:selfserv.example") { + t.Errorf("missing self-service hold: %v", dids) + } + if contains(dids, "did:web:invite.example") { + t.Errorf("anon should not see invite-only hold: %v", dids) + } + }) + + t.Run("crew member also sees invite-only hold", func(t *testing.T) { + dids, err := GetAccessibleHoldDIDs(db, "did:plc:dave") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !contains(dids, "did:web:invite.example") { + t.Errorf("crew member should see invite-only hold they belong to: %v", dids) + } + if contains(dids, "did:web:carol-hold.example") { + t.Errorf("dave is not crew of carol's private hold: %v", dids) + } + }) + + t.Run("owner sees their own private hold", func(t *testing.T) { + dids, err := GetAccessibleHoldDIDs(db, "did:plc:carol") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // carol owns invite.example and carol-hold.example, both private + if !contains(dids, "did:web:invite.example") { + t.Errorf("owner should see their invite-only hold: %v", dids) + } + if !contains(dids, "did:web:carol-hold.example") { + t.Errorf("owner should see their second private hold: %v", dids) + } + }) + + t.Run("random authenticated viewer gets same set as anonymous", func(t *testing.T) { + dids, err := GetAccessibleHoldDIDs(db, "did:plc:nobody") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(dids) != 2 { + t.Fatalf("expected 2 DIDs, got %d: %v", len(dids), dids) + } + }) +} diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 550495c..be69189 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -15,6 +15,20 @@ func BlobCDNURL(did, cid string) string { return fmt.Sprintf("https://imgs.blue/%s/%s", did, cid) } +// accessibleHoldsSubquery returns SQL that evaluates to the set of hold DIDs +// the viewer is allowed to see in listings. Requires the viewerDID to be +// passed twice as query arguments (once for the owner_did check and once +// for the crew membership check). Empty viewerDID (anonymous) naturally +// matches no owner or crew rows, so only public + self-service holds +// (allow_all_crew=1) are returned. +const accessibleHoldsSubquery = `( + SELECT hold_did FROM hold_captain_records + WHERE public = 1 + OR allow_all_crew = 1 + OR owner_did = ? + OR hold_did IN (SELECT hold_did FROM hold_crew_members WHERE member_did = ?) +)` + // GetArtifactType determines the artifact type based on config media type // Returns: "helm-chart", "container-image", or "unknown" func GetArtifactType(configMediaType string) string { @@ -68,6 +82,7 @@ func SearchRepositories(db DBTX, query string, limit, offset int, currentUserDID WITH latest_manifests AS ( SELECT did, repository, MAX(id) as latest_id FROM manifests + WHERE hold_endpoint IN ` + accessibleHoldsSubquery + ` GROUP BY did, repository ), matching_repos AS ( @@ -118,7 +133,7 @@ func SearchRepositories(db DBTX, query string, limit, offset int, currentUserDID LIMIT ? OFFSET ? ` - rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, currentUserDID, limit, offset) + rows, err := db.Query(sqlQuery, currentUserDID, currentUserDID, searchPattern, query, searchPattern, searchPattern, currentUserDID, limit, offset) if err != nil { return nil, 0, err } @@ -159,6 +174,7 @@ func SearchRepositories(db DBTX, query string, limit, offset int, currentUserDID WITH latest_manifests AS ( SELECT did, repository, MAX(id) as latest_id FROM manifests + WHERE hold_endpoint IN ` + accessibleHoldsSubquery + ` GROUP BY did, repository ) SELECT COUNT(DISTINCT lm.did || '/' || lm.repository) @@ -175,16 +191,20 @@ func SearchRepositories(db DBTX, query string, limit, offset int, currentUserDID ` var total int - if err := db.QueryRow(countQuery, searchPattern, query, searchPattern, searchPattern).Scan(&total); err != nil { + if err := db.QueryRow(countQuery, currentUserDID, currentUserDID, searchPattern, query, searchPattern, searchPattern).Scan(&total); err != nil { return nil, 0, err } return cards, total, nil } -// GetUserRepositories fetches all repositories for a user -func GetUserRepositories(db DBTX, did string) ([]Repository, error) { - // Get repository summary +// GetUserRepositories fetches all repositories for a user. +// viewerDID scopes results to repositories whose manifests live on holds the +// viewer can access (empty viewerDID = anonymous → public + self-service only). +func GetUserRepositories(db DBTX, did string, viewerDID string) ([]Repository, error) { + // Get repository summary. + // Both tags and manifests are filtered via join onto manifests.hold_endpoint + // so repositories where every row lives on an inaccessible hold drop out. rows, err := db.Query(` SELECT repository, @@ -192,13 +212,18 @@ func GetUserRepositories(db DBTX, did string) ([]Repository, error) { COUNT(DISTINCT digest) as manifest_count, MAX(created_at) as last_push FROM ( - SELECT repository, tag, digest, created_at FROM tags WHERE did = ? + SELECT t.repository, t.tag, t.digest, t.created_at + FROM tags t + JOIN manifests tm ON t.did = tm.did AND t.repository = tm.repository AND t.digest = tm.digest + WHERE t.did = ? AND tm.hold_endpoint IN `+accessibleHoldsSubquery+` UNION - SELECT repository, NULL, digest, created_at FROM manifests WHERE did = ? + SELECT m.repository, NULL, m.digest, m.created_at + FROM manifests m + WHERE m.did = ? AND m.hold_endpoint IN `+accessibleHoldsSubquery+` ) GROUP BY repository ORDER BY last_push DESC - `, did, did) + `, did, viewerDID, viewerDID, did, viewerDID, viewerDID) if err != nil { return nil, err @@ -779,29 +804,37 @@ func CountTags(db DBTX, did, repository string) (int, error) { // Only multi-arch tags (manifest lists) have platform info in manifest_references // Single-arch tags will have empty Platforms slice (platform is obvious for single-arch) // Attestation references (unknown/unknown platforms) are filtered out but tracked via HasAttestations -func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int) ([]TagWithPlatforms, error) { - return getTagsWithPlatformsFiltered(db, did, repository, "", limit, offset) +func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int, viewerDID string) ([]TagWithPlatforms, error) { + return getTagsWithPlatformsFiltered(db, did, repository, "", limit, offset, viewerDID, true) } // getTagsWithPlatformsFiltered is the shared implementation for GetTagsWithPlatforms and GetTagByName. // If tagName is non-empty, only that specific tag is returned. -func getTagsWithPlatformsFiltered(db DBTX, did, repository, tagName string, limit, offset int) ([]TagWithPlatforms, error) { +// When applyHoldFilter is true, rows are filtered by hold access for viewerDID. +func getTagsWithPlatformsFiltered(db DBTX, did, repository, tagName string, limit, offset int, viewerDID string, applyHoldFilter bool) ([]TagWithPlatforms, error) { var tagFilter string + var holdFilter string var args []any + args = append(args, did, repository) if tagName != "" { - tagFilter = "AND tag = ?" - args = append(args, did, repository, tagName, limit, offset) - } else { - args = append(args, did, repository, limit, offset) + tagFilter = "AND t.tag = ?" + args = append(args, tagName) } + if applyHoldFilter { + holdFilter = "AND m.hold_endpoint IN " + accessibleHoldsSubquery + args = append(args, viewerDID, viewerDID) + } + args = append(args, limit, offset) query := ` WITH paged_tags AS ( - SELECT id, did, repository, tag, digest, created_at - FROM tags - WHERE did = ? AND repository = ? + SELECT t.id, t.did, t.repository, t.tag, t.digest, t.created_at + FROM tags t + JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest + WHERE t.did = ? AND t.repository = ? ` + tagFilter + ` - ORDER BY created_at DESC + ` + holdFilter + ` + ORDER BY t.created_at DESC LIMIT ? OFFSET ? ) SELECT @@ -1117,7 +1150,7 @@ func GetManifestReferencesForManifest(db DBTX, manifestID int64) ([]ManifestRefe // GetTopLevelManifests returns only manifest lists and orphaned single-arch manifests // Filters out platform-specific manifests that are referenced by manifest lists // Note: Annotations are stored separately in repository_annotations table - use GetRepositoryMetadata to fetch them -func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int) ([]ManifestWithMetadata, error) { +func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int, viewerDID string) ([]ManifestWithMetadata, error) { rows, err := db.Query(` WITH manifest_list_children AS ( -- Get all digests that are children of manifest lists @@ -1138,6 +1171,7 @@ func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int) ([ WHERE m.did = ? AND m.repository = ? AND m.subject_digest IS NULL AND m.artifact_type != 'unknown' + AND m.hold_endpoint IN `+accessibleHoldsSubquery+` AND ( -- Include manifest lists m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%' @@ -1148,7 +1182,7 @@ func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int) ([ GROUP BY m.id ORDER BY m.created_at DESC LIMIT ? OFFSET ? - `, did, repository, did, repository, limit, offset) + `, did, repository, did, repository, viewerDID, viewerDID, limit, offset) if err != nil { return nil, err @@ -2019,6 +2053,7 @@ func GetRepoCards(db DBTX, limit int, currentUserDID string, sortOrder RepoCardS WITH latest_manifests AS ( SELECT did, repository, MAX(id) as latest_id FROM manifests + WHERE hold_endpoint IN ` + accessibleHoldsSubquery + ` GROUP BY did, repository ) SELECT @@ -2046,7 +2081,7 @@ func GetRepoCards(db DBTX, limit int, currentUserDID string, sortOrder RepoCardS LIMIT ? ` - rows, err := db.Query(query, currentUserDID, limit) + rows, err := db.Query(query, currentUserDID, currentUserDID, currentUserDID, limit) if err != nil { return nil, err } @@ -2092,6 +2127,7 @@ func GetUserRepoCards(db DBTX, userDID string, currentUserDID string) ([]RepoCar SELECT did, repository, MAX(id) as latest_id FROM manifests WHERE did = ? + AND hold_endpoint IN ` + accessibleHoldsSubquery + ` GROUP BY did, repository ) SELECT @@ -2118,7 +2154,7 @@ func GetUserRepoCards(db DBTX, userDID string, currentUserDID string) ([]RepoCar ORDER BY MAX(rs.last_push, m.created_at) DESC ` - rows, err := db.Query(query, userDID, currentUserDID) + rows, err := db.Query(query, userDID, currentUserDID, currentUserDID, currentUserDID) if err != nil { return nil, err } @@ -2464,7 +2500,7 @@ func IsHoldCaptain(db DBTX, userDID string, managedHolds []string) (bool, error) // GetTagByName returns a single tag with platform information by tag name. // Returns nil, nil if the tag doesn't exist. func GetTagByName(db DBTX, did, repository, tagName string) (*TagWithPlatforms, error) { - tags, err := getTagsWithPlatformsFiltered(db, did, repository, tagName, 1, 0) + tags, err := getTagsWithPlatformsFiltered(db, did, repository, tagName, 1, 0, "", false) if err != nil { return nil, err } @@ -2474,13 +2510,41 @@ func GetTagByName(db DBTX, did, repository, tagName string) (*TagWithPlatforms, return &tags[0], nil } -// GetAllTagNames returns all tag names for a repository, ordered by most recent first. -func GetAllTagNames(db DBTX, did, repository string) ([]string, error) { +// GetRepoHoldDIDs returns the distinct hold DIDs that host manifests for a +// given repository, restricted to holds the viewer can access. +func GetRepoHoldDIDs(db DBTX, did, repository string, viewerDID string) ([]string, error) { rows, err := db.Query(` - SELECT tag FROM tags - WHERE did = ? AND repository = ? - ORDER BY created_at DESC - `, did, repository) + SELECT DISTINCT m.hold_endpoint + FROM manifests m + WHERE m.did = ? AND m.repository = ? + AND m.hold_endpoint != '' + AND m.hold_endpoint IN `+accessibleHoldsSubquery+` + `, did, repository, viewerDID, viewerDID) + if err != nil { + return nil, err + } + defer rows.Close() + var holds []string + for rows.Next() { + var h string + if err := rows.Scan(&h); err != nil { + return nil, err + } + holds = append(holds, h) + } + return holds, rows.Err() +} + +// GetAllTagNames returns all tag names for a repository, ordered by most recent first. +// Filters out tags whose manifests live on holds the viewer can't access. +func GetAllTagNames(db DBTX, did, repository string, viewerDID string) ([]string, error) { + rows, err := db.Query(` + SELECT t.tag FROM tags t + JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest + WHERE t.did = ? AND t.repository = ? + AND m.hold_endpoint IN `+accessibleHoldsSubquery+` + ORDER BY t.created_at DESC + `, did, repository, viewerDID, viewerDID) if err != nil { return nil, err } diff --git a/pkg/appview/db/queries_test.go b/pkg/appview/db/queries_test.go index 223c68e..7d3d129 100644 --- a/pkg/appview/db/queries_test.go +++ b/pkg/appview/db/queries_test.go @@ -855,6 +855,15 @@ func TestGetTagsWithPlatforms(t *testing.T) { t.Fatalf("Failed to create test user: %v", err) } + // Register the test hold as public so the hold-access filter allows it + if err := UpsertCaptainRecord(db, &HoldCaptainRecord{ + HoldDID: "did:web:hold.example.com", + OwnerDID: "did:plc:holdowner", + Public: true, + }); err != nil { + t.Fatalf("Failed to insert captain record: %v", err) + } + // Test 1: Single-arch manifest (no platform info) singleArchManifest := &Manifest{ DID: testUser.DID, @@ -882,7 +891,7 @@ func TestGetTagsWithPlatforms(t *testing.T) { t.Fatalf("Failed to insert single-arch tag: %v", err) } - tagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "myapp", 100, 0) + tagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "myapp", 100, 0, "") if err != nil { t.Fatalf("Failed to get tags with platforms: %v", err) } @@ -951,7 +960,7 @@ func TestGetTagsWithPlatforms(t *testing.T) { t.Fatalf("Failed to insert multi-arch tag: %v", err) } - multiTagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "multiapp", 100, 0) + multiTagsWithPlatforms, err := GetTagsWithPlatforms(db, testUser.DID, "multiapp", 100, 0, "") if err != nil { t.Fatalf("Failed to get multi-arch tags with platforms: %v", err) } @@ -1531,3 +1540,70 @@ func TestGetAllUntaggedManifestDigests(t *testing.T) { t.Errorf("Expected 3 digests, got %d: %v", len(digests), digests) } } + +// TestGetUserRepositories_HoldAccessFilter verifies that repositories whose +// manifests live on inaccessible holds are hidden from viewers without access. +func TestGetUserRepositories_HoldAccessFilter(t *testing.T) { + db, err := InitDB("file:TestGetUserRepositories_HoldAccessFilter?mode=memory&cache=shared", LibsqlConfig{}) + if err != nil { + t.Fatalf("init db: %v", err) + } + defer db.Close() + + testUser := &User{DID: "did:plc:alice", Handle: "alice.test", PDSEndpoint: "https://pds.example", LastSeen: time.Now()} + if err := UpsertUser(db, testUser); err != nil { + t.Fatalf("upsert user: %v", err) + } + + // Public hold and a private invite-only hold + if err := UpsertCaptainRecord(db, &HoldCaptainRecord{ + HoldDID: "did:web:public.example", OwnerDID: "did:plc:holdowner", Public: true, + }); err != nil { + t.Fatalf("seed public captain: %v", err) + } + if err := UpsertCaptainRecord(db, &HoldCaptainRecord{ + HoldDID: "did:web:private.example", OwnerDID: "did:plc:holdowner", Public: false, AllowAllCrew: false, + }); err != nil { + t.Fatalf("seed private captain: %v", err) + } + + // Two repos: one on the public hold, one on the private hold + if _, err := InsertManifest(db, &Manifest{ + DID: testUser.DID, Repository: "publicrepo", Digest: "sha256:pub", + HoldEndpoint: "did:web:public.example", SchemaVersion: 2, + MediaType: "application/vnd.oci.image.manifest.v1+json", CreatedAt: time.Now(), + }); err != nil { + t.Fatalf("insert public manifest: %v", err) + } + if _, err := InsertManifest(db, &Manifest{ + DID: testUser.DID, Repository: "privaterepo", Digest: "sha256:priv", + HoldEndpoint: "did:web:private.example", SchemaVersion: 2, + MediaType: "application/vnd.oci.image.manifest.v1+json", CreatedAt: time.Now(), + }); err != nil { + t.Fatalf("insert private manifest: %v", err) + } + + // Anonymous viewer should see only the publicrepo + repos, err := GetUserRepositories(db, testUser.DID, "") + if err != nil { + t.Fatalf("GetUserRepositories anon: %v", err) + } + if len(repos) != 1 || repos[0].Name != "publicrepo" { + t.Errorf("anon viewer: expected [publicrepo], got %v", repos) + } + + // Make the private-hold owner a crew member and re-query as them + if err := UpsertCrewMember(db, &CrewMember{ + HoldDID: "did:web:private.example", MemberDID: "did:plc:crewdave", Rkey: "rk1", + }); err != nil { + t.Fatalf("upsert crew: %v", err) + } + + repos, err = GetUserRepositories(db, testUser.DID, "did:plc:crewdave") + if err != nil { + t.Fatalf("GetUserRepositories crew: %v", err) + } + if len(repos) != 2 { + t.Errorf("crew viewer: expected both repos, got %d: %v", len(repos), repos) + } +} diff --git a/pkg/appview/handlers/api.go b/pkg/appview/handlers/api.go index 540f9c7..15c0fed 100644 --- a/pkg/appview/handlers/api.go +++ b/pkg/appview/handlers/api.go @@ -164,35 +164,6 @@ func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Reque render.JSON(w, r, map[string]bool{"starred": false}) } -// CredentialHelperVersionResponse is the response for the credential helper version API -type CredentialHelperVersionResponse struct { - Latest string `json:"latest"` - DownloadURLs map[string]string `json:"download_urls"` - Checksums map[string]string `json:"checksums"` - ReleaseNotes string `json:"release_notes,omitempty"` -} - -// CredentialHelperVersionHandler returns the latest credential helper version info -// Note: Version info is fetched dynamically from TangledRepo's releases -type CredentialHelperVersionHandler struct { - TangledRepo string -} - -func (h *CredentialHelperVersionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // This endpoint directs users to the Tangled repository for downloads - // Version info should be fetched from the repository's releases page - response := CredentialHelperVersionResponse{ - Latest: "", - DownloadURLs: map[string]string{"tangled_repo": h.TangledRepo}, - Checksums: nil, - ReleaseNotes: "Visit the Tangled repository for the latest releases: " + h.TangledRepo, - } - - render.SetContentType(render.ContentTypeJSON) - w.Header().Set("Cache-Control", "public, max-age=300") // Cache for 5 minutes - render.JSON(w, r, response) -} - // renderStarComponent renders the star component HTML for HTMX responses func renderStarComponent(w http.ResponseWriter, tmpl *template.Template, handle, repository string, isStarred bool, starCount int) { data := map[string]any{ diff --git a/pkg/appview/handlers/opengraph.go b/pkg/appview/handlers/opengraph.go index 2971c42..034350f 100644 --- a/pkg/appview/handlers/opengraph.go +++ b/pkg/appview/handlers/opengraph.go @@ -170,8 +170,8 @@ func (h *UserOGHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { user = &db.User{DID: did, Handle: resolvedHandle} } - // Get repository count - repos, err := db.GetUserRepositories(h.ReadOnlyDB, did) + // Get repository count (OG cards render for anonymous crawlers) + repos, err := db.GetUserRepositories(h.ReadOnlyDB, did, "") repoCount := 0 if err == nil { repoCount = len(repos) diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index 8cedcbe..309f6f4 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -72,8 +72,14 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request return } + // Resolve viewer DID for hold-access filtering (empty string = anonymous) + var viewerDID string + if vu := middleware.GetUser(r); vu != nil { + viewerDID = vu.DID + } + // Fetch all tag names for the selector dropdown - allTags, err := db.GetAllTagNames(h.ReadOnlyDB, owner.DID, repository) + allTags, err := db.GetAllTagNames(h.ReadOnlyDB, owner.DID, repository, viewerDID) if err != nil { slog.Warn("Failed to fetch tag names", "error", err) } @@ -277,34 +283,55 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request h.ClientShortName, )) + // Compute cross-hold badge: if the viewer has a default hold set and this + // repo has tags on any other accessible hold, flag them so the template + // can show an informational chip. + var nonDefaultHolds []string + if viewerDID != "" { + viewerDefaultHold := db.GetUserHoldDID(h.ReadOnlyDB, viewerDID) + if viewerDefaultHold != "" { + repoHolds, herr := db.GetRepoHoldDIDs(h.ReadOnlyDB, owner.DID, repository, viewerDID) + if herr != nil { + slog.Warn("Failed to fetch repo hold DIDs", "error", herr) + } + for _, rh := range repoHolds { + if rh != viewerDefaultHold { + nonDefaultHolds = append(nonDefaultHolds, rh) + } + } + } + } + data := struct { PageData - Meta *PageMeta - Owner *db.User - Repository *db.Repository - AllTags []string - SelectedTag *SelectedTagData - Stats *db.RepositoryStats - TagCount int - IsStarred bool - IsOwner bool - ReadmeHTML template.HTML - RawDescription string - ArtifactType string + Meta *PageMeta + Owner *db.User + Repository *db.Repository + AllTags []string + SelectedTag *SelectedTagData + Stats *db.RepositoryStats + TagCount int + IsStarred bool + IsOwner bool + ReadmeHTML template.HTML + RawDescription string + ArtifactType string + NonDefaultHolds []string }{ - PageData: NewPageData(r, &h.BaseUIHandler), - Meta: meta, - Owner: owner, - Repository: repo, - AllTags: allTags, - SelectedTag: selectedTag, - Stats: stats, - TagCount: tagCount, - IsStarred: isStarred, - IsOwner: isOwner, - ReadmeHTML: readmeHTML, - RawDescription: rawDescription, - ArtifactType: artifactType, + PageData: NewPageData(r, &h.BaseUIHandler), + Meta: meta, + Owner: owner, + Repository: repo, + AllTags: allTags, + SelectedTag: selectedTag, + Stats: stats, + TagCount: tagCount, + IsStarred: isStarred, + IsOwner: isOwner, + ReadmeHTML: readmeHTML, + RawDescription: rawDescription, + ArtifactType: artifactType, + NonDefaultHolds: nonDefaultHolds, } // If the owner has disabled AI advisor in their profile, hide the button @@ -388,6 +415,12 @@ func (h *RepositoryTagsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request } } + // Resolve viewer DID for hold-access filtering (empty string = anonymous) + var viewerDID string + if vu := middleware.GetUser(r); vu != nil { + viewerDID = vu.DID + } + // Count total tags for pagination totalTags, err := db.CountTags(h.ReadOnlyDB, owner.DID, repository) if err != nil { @@ -396,7 +429,7 @@ func (h *RepositoryTagsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request } // Fetch tags with platform information and compressed sizes - tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.ReadOnlyDB, owner.DID, repository, pageSize, offset) + tagsWithPlatforms, err := db.GetTagsWithPlatforms(h.ReadOnlyDB, owner.DID, repository, pageSize, offset, viewerDID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -405,7 +438,7 @@ func (h *RepositoryTagsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request // Fetch untagged manifests only on first page var manifests []db.ManifestWithMetadata if offset == 0 { - manifests, err = db.GetTopLevelManifests(h.ReadOnlyDB, owner.DID, repository, 50, 0) + manifests, err = db.GetTopLevelManifests(h.ReadOnlyDB, owner.DID, repository, 50, 0, viewerDID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/pkg/appview/public/static/install.ps1 b/pkg/appview/public/static/install.ps1 index 681834e..14cf1bb 100644 --- a/pkg/appview/public/static/install.ps1 +++ b/pkg/appview/public/static/install.ps1 @@ -6,11 +6,7 @@ $ErrorActionPreference = "Stop" # Configuration $BinaryName = "docker-credential-atcr.exe" $InstallDir = if ($env:ATCR_INSTALL_DIR) { $env:ATCR_INSTALL_DIR } else { "$env:ProgramFiles\ATCR" } -$ApiUrl = if ($env:ATCR_API_URL) { $env:ATCR_API_URL } else { "https://atcr.io/api/credential-helper/version" } - -# Fallback configuration (used if API is unavailable) -$FallbackVersion = "v0.0.1" -$FallbackTangledRepo = "https://tangled.org/evan.jarrett.net/at-container-registry" +$TangledRepo = if ($env:ATCR_TANGLED_REPO) { $env:ATCR_TANGLED_REPO } else { "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64" } Write-Host "ATCR Credential Helper Installer for Windows" -ForegroundColor Green Write-Host "" @@ -19,8 +15,8 @@ Write-Host "" function Get-Architecture { $arch = (Get-WmiObject Win32_Processor).Architecture switch ($arch) { - 9 { return @{ Display = "x86_64"; Key = "amd64" } } # x64 - 12 { return @{ Display = "arm64"; Key = "arm64" } } # ARM64 + 9 { return "x86_64" } # x64 + 12 { return "arm64" } # ARM64 default { Write-Host "Unsupported architecture: $arch" -ForegroundColor Red exit 1 @@ -28,66 +24,55 @@ function Get-Architecture { } } -$ArchInfo = Get-Architecture -$Arch = $ArchInfo.Display -$ArchKey = $ArchInfo.Key -$PlatformKey = "windows_$ArchKey" - +$Arch = Get-Architecture Write-Host "Detected: Windows $Arch" -ForegroundColor Green -# Fetch version info from API -function Get-VersionInfo { - Write-Host "Fetching latest version info..." -ForegroundColor Yellow +# Resolve the latest version via the tangled /tags/latest redirect +function Get-LatestVersion { + Write-Host "Resolving latest version..." -ForegroundColor Yellow try { - $response = Invoke-WebRequest -Uri $ApiUrl -UseBasicParsing -TimeoutSec 10 - $json = $response.Content | ConvertFrom-Json - - if ($json.latest -and $json.download_urls.$PlatformKey) { - return @{ - Version = $json.latest - DownloadUrl = $json.download_urls.$PlatformKey - } - } + $response = Invoke-WebRequest -Uri "$TangledRepo/tags/latest" -UseBasicParsing -MaximumRedirection 0 -ErrorAction SilentlyContinue + $location = $response.Headers.Location } catch { - Write-Host "API unavailable, using fallback version" -ForegroundColor Yellow + # PowerShell 5 throws when -MaximumRedirection 0 receives a redirect; grab it off the exception. + $location = $_.Exception.Response.Headers.Location + if ($location) { $location = $location.ToString() } } - return $null + if (-not $location) { + Write-Host "Failed to resolve latest version from $TangledRepo/tags/latest" -ForegroundColor Red + exit 1 + } + + $tag = $location.TrimEnd('/').Split('/')[-1] + if (-not $tag.StartsWith('v')) { + Write-Host "Unexpected redirect location: $location" -ForegroundColor Red + exit 1 + } + + Write-Host "Found latest version: $tag" -ForegroundColor Green + return $tag } -# Get download URL for fallback -function Get-FallbackUrl { +# Build the download URL from version and platform +function Get-DownloadUrl { param([string]$Version, [string]$Arch) $versionClean = $Version.TrimStart('v') - # Note: Windows builds use .zip format - $fileName = "docker-credential-atcr_${versionClean}_Windows_${Arch}.zip" - return "$FallbackTangledRepo/tags/$Version/download/$fileName" + $fileName = "docker-credential-atcr_${versionClean}_Windows_${Arch}.tar.gz" + return "$TangledRepo/tags/$Version/download/$fileName" } # Determine version and download URL -$Version = $null -$DownloadUrl = $null - if ($env:ATCR_VERSION) { $Version = $env:ATCR_VERSION - $DownloadUrl = Get-FallbackUrl -Version $Version -Arch $Arch Write-Host "Using specified version: $Version" -ForegroundColor Yellow } else { - $versionInfo = Get-VersionInfo - - if ($versionInfo) { - $Version = $versionInfo.Version - $DownloadUrl = $versionInfo.DownloadUrl - Write-Host "Found latest version: $Version" -ForegroundColor Green - } else { - $Version = $FallbackVersion - $DownloadUrl = Get-FallbackUrl -Version $Version -Arch $Arch - Write-Host "Using fallback version: $Version" -ForegroundColor Yellow - } + $Version = Get-LatestVersion } +$DownloadUrl = Get-DownloadUrl -Version $Version -Arch $Arch Write-Host "Installing version: $Version" -ForegroundColor Green # Download and install binary @@ -99,17 +84,22 @@ function Install-Binary { Write-Host "Downloading from: $DownloadUrl" -ForegroundColor Yellow $tempDir = New-Item -ItemType Directory -Path "$env:TEMP\atcr-install-$(Get-Random)" -Force - $zipPath = Join-Path $tempDir "docker-credential-atcr.zip" + $archivePath = Join-Path $tempDir "docker-credential-atcr.tar.gz" try { - Invoke-WebRequest -Uri $DownloadUrl -OutFile $zipPath -UseBasicParsing + Invoke-WebRequest -Uri $DownloadUrl -OutFile $archivePath -UseBasicParsing } catch { Write-Host "Failed to download release: $_" -ForegroundColor Red exit 1 } Write-Host "Extracting..." -ForegroundColor Yellow - Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force + # Modern Windows ships tar.exe; use it to handle .tar.gz produced by goreleaser. + & tar.exe -xzf $archivePath -C $tempDir + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to extract archive" -ForegroundColor Red + exit 1 + } # Create install directory if (-not (Test-Path $InstallDir)) { diff --git a/pkg/appview/public/static/install.sh b/pkg/appview/public/static/install.sh index 2937f48..b6d8c2c 100755 --- a/pkg/appview/public/static/install.sh +++ b/pkg/appview/public/static/install.sh @@ -13,11 +13,7 @@ NC='\033[0m' # No Color # Configuration BINARY_NAME="docker-credential-atcr" INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" -API_URL="${ATCR_API_URL:-https://atcr.io/api/credential-helper/version}" - -# Fallback configuration (used if API is unavailable) -FALLBACK_VERSION="v0.0.1" -FALLBACK_TANGLED_REPO="https://tangled.org/evan.jarrett.net/at-container-registry" +TANGLED_REPO="${ATCR_TANGLED_REPO:-https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64}" # Detect OS and architecture detect_platform() { @@ -27,11 +23,9 @@ detect_platform() { case "$os" in linux*) OS="Linux" - OS_KEY="linux" ;; darwin*) OS="Darwin" - OS_KEY="darwin" ;; *) echo -e "${RED}Unsupported OS: $os${NC}" @@ -42,59 +36,43 @@ detect_platform() { case "$arch" in x86_64|amd64) ARCH="x86_64" - ARCH_KEY="amd64" ;; aarch64|arm64) ARCH="arm64" - ARCH_KEY="arm64" ;; *) echo -e "${RED}Unsupported architecture: $arch${NC}" exit 1 ;; esac - - PLATFORM_KEY="${OS_KEY}_${ARCH_KEY}" } -# Fetch version info from API -fetch_version_info() { - echo -e "${YELLOW}Fetching latest version info...${NC}" +# Resolve the latest version by reading the tangled /tags/latest redirect +fetch_latest_version() { + echo -e "${YELLOW}Resolving latest version...${NC}" - # Try to fetch from API - local api_response - if api_response=$(curl -fsSL --max-time 10 "$API_URL" 2>/dev/null); then - # Parse JSON response (requires jq or basic parsing) - if command -v jq &> /dev/null; then - VERSION=$(echo "$api_response" | jq -r '.latest') - DOWNLOAD_URL=$(echo "$api_response" | jq -r ".download_urls.${PLATFORM_KEY}") + local redirect + redirect=$(curl -s --max-time 10 -o /dev/null -D - "${TANGLED_REPO}/tags/latest" | awk 'tolower($1) == "location:" { print $2 }' | tr -d '\r\n') - if [ "$VERSION" != "null" ] && [ "$DOWNLOAD_URL" != "null" ] && [ -n "$VERSION" ] && [ -n "$DOWNLOAD_URL" ]; then - echo -e "${GREEN}Found latest version: ${VERSION}${NC}" - return 0 - fi - else - # Fallback: basic grep parsing if jq not available - VERSION=$(echo "$api_response" | grep -o '"latest":"[^"]*"' | cut -d'"' -f4) - # Try to extract the specific platform URL - DOWNLOAD_URL=$(echo "$api_response" | grep -o "\"${PLATFORM_KEY}\":\"[^\"]*\"" | cut -d'"' -f4) - - if [ -n "$VERSION" ] && [ -n "$DOWNLOAD_URL" ]; then - echo -e "${GREEN}Found latest version: ${VERSION}${NC}" - return 0 - fi - fi + if [ -z "$redirect" ]; then + echo -e "${RED}Failed to resolve latest version from ${TANGLED_REPO}/tags/latest${NC}" + exit 1 fi - echo -e "${YELLOW}API unavailable, using fallback version${NC}" - return 1 + VERSION="${redirect##*/}" + + if [ -z "$VERSION" ] || [ "${VERSION#v}" = "$VERSION" ]; then + echo -e "${RED}Unexpected redirect location: ${redirect}${NC}" + exit 1 + fi + + echo -e "${GREEN}Found latest version: ${VERSION}${NC}" } -# Set fallback download URL -use_fallback() { - VERSION="$FALLBACK_VERSION" +# Build the download URL from version and platform +build_download_url() { local version_without_v="${VERSION#v}" - DOWNLOAD_URL="${FALLBACK_TANGLED_REPO}/tags/${VERSION}/download/docker-credential-atcr_${version_without_v}_${OS}_${ARCH}.tar.gz" + DOWNLOAD_URL="${TANGLED_REPO}/tags/${VERSION}/download/docker-credential-atcr_${version_without_v}_${OS}_${ARCH}.tar.gz" } # Download and install binary @@ -164,20 +142,16 @@ main() { detect_platform echo -e "Detected: ${GREEN}${OS} ${ARCH}${NC}" - # Check if version is manually specified if [ -n "$ATCR_VERSION" ]; then - echo -e "Using specified version: ${GREEN}${ATCR_VERSION}${NC}" VERSION="$ATCR_VERSION" - local version_without_v="${VERSION#v}" - DOWNLOAD_URL="${FALLBACK_TANGLED_REPO}/tags/${VERSION}/download/docker-credential-atcr_${version_without_v}_${OS}_${ARCH}.tar.gz" + echo -e "Using specified version: ${GREEN}${VERSION}${NC}" else - # Try to fetch from API, fall back if unavailable - if ! fetch_version_info; then - use_fallback - fi - echo -e "Installing version: ${GREEN}${VERSION}${NC}" + fetch_latest_version fi + build_download_url + echo -e "Installing version: ${GREEN}${VERSION}${NC}" + install_binary verify_installation configure_docker diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index 28c1c86..d5b5f61 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -269,15 +269,6 @@ func RegisterDeviceEndpoints(router chi.Router, deviceStore *db.DeviceStore, bas }) } -// RegisterCredentialHelperEndpoint registers the credential helper version API -// endpoint (GET /api/credential-helper/version). Separated from RegisterUIRoutes -// for the same import-cycle reason as RegisterDeviceEndpoints. -func RegisterCredentialHelperEndpoint(router chi.Router, tangledRepo string) { - router.Handle("/api/credential-helper/version", &uihandlers.CredentialHelperVersionHandler{ - TangledRepo: tangledRepo, - }) -} - // trimRegistryURL removes http:// or https:// prefix from a URL // for use in Docker commands where only the host:port is needed func trimRegistryURL(url string) string { diff --git a/pkg/appview/server.go b/pkg/appview/server.go index 6313272..cc62bd4 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -611,9 +611,6 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, // Appview DID document endpoint (service identity for key discovery) mainRouter.Get("/.well-known/did.json", s.handleDIDDocument) - // Register credential helper version API (public endpoint) - routes.RegisterCredentialHelperEndpoint(mainRouter, cfg.CredentialHelper.TangledRepo) - s.Router = mainRouter return s, nil diff --git a/pkg/appview/templates/partials/repo-tag-section.html b/pkg/appview/templates/partials/repo-tag-section.html index 8c71f00..0e2b165 100644 --- a/pkg/appview/templates/partials/repo-tag-section.html +++ b/pkg/appview/templates/partials/repo-tag-section.html @@ -4,6 +4,17 @@ {{ 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)) }} + {{ if .NonDefaultHolds }} + +
+ Hosted on: + {{ range .NonDefaultHolds }} + {{ displayHoldDID . }} + {{ end }} + (different from your default hold) +
+ {{ end }} +
diff --git a/pkg/appview/ui.go b/pkg/appview/ui.go index a438053..9580d74 100644 --- a/pkg/appview/ui.go +++ b/pkg/appview/ui.go @@ -207,6 +207,18 @@ func Templates(overrides *BrandingOverrides) (*template.Template, error) { return s }, + "displayHoldDID": func(holdDID string) string { + // did:web:hold01.atcr.io → hold01.atcr.io + if strings.HasPrefix(holdDID, "did:web:") { + return strings.TrimPrefix(holdDID, "did:web:") + } + // did:plc:opaque... → did:plc:opaque...xxxx (truncated) + if len(holdDID) > 20 { + return holdDID[:20] + "…" + } + return holdDID + }, + "sanitizeID": func(s string) string { // Replace special CSS selector characters with dashes // e.g., "sha256:abc123" becomes "sha256-abc123"