mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
update credential helper to pull latest update from tangled directly
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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 := `
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user