update credential helper to pull latest update from tangled directly

This commit is contained in:
Evan Jarrett
2026-04-04 20:59:50 -05:00
parent 90b0b42766
commit 20202aa0df
19 changed files with 539 additions and 321 deletions
+60 -83
View File
@@ -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)
+6 -8
View File
@@ -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")
}
}
-4
View File
@@ -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.
-2
View File
@@ -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
-1
View File
@@ -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
+12 -20
View File
@@ -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 == "" {
+48
View File
@@ -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 := `
+91
View File
@@ -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)
}
})
}
+94 -30
View File
@@ -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
}
+78 -2
View File
@@ -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)
}
}
-29
View File
@@ -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{
+2 -2
View File
@@ -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)
+61 -28
View File
@@ -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
+39 -49
View File
@@ -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)) {
+25 -51
View File
@@ -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
-9
View File
@@ -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 {
-3
View File
@@ -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
@@ -4,6 +4,17 @@
<!-- Pull Command with Client Switcher -->
{{ 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 }}
<!-- Informational badge: this repo has content on a hold other than the viewer's default -->
<div class="mt-2 flex flex-wrap gap-2 items-center text-xs text-base-content/70">
<span>Hosted on:</span>
{{ range .NonDefaultHolds }}
<span class="badge badge-outline badge-sm" title="{{ . }}">{{ displayHoldDID . }}</span>
{{ end }}
<span class="text-base-content/50">(different from your default hold)</span>
</div>
{{ end }}
<!-- Stats Cards -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
<!-- Size & Layers -->
+12
View File
@@ -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"