+5
.gitignore
+5
.gitignore
+84
Makefile
+84
Makefile
···
···
1
+
# ATCR Makefile
2
+
# Build targets for the ATProto Container Registry
3
+
4
+
.PHONY: all build build-appview build-hold build-credential-helper build-oauth-helper \
5
+
generate test test-race test-verbose lint clean help
6
+
7
+
.DEFAULT_GOAL := help
8
+
9
+
help: ## Show this help message
10
+
@echo "ATCR Build Targets:"
11
+
@echo ""
12
+
@awk 'BEGIN {FS = ":.*##"; printf ""} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-28s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
13
+
14
+
all: generate build ## Generate assets and build all binaries (default)
15
+
16
+
# Generated asset files
17
+
GENERATED_ASSETS = \
18
+
pkg/appview/static/js/htmx.min.js \
19
+
pkg/appview/static/js/lucide.min.js \
20
+
pkg/appview/licenses/spdx-licenses.json
21
+
22
+
generate: $(GENERATED_ASSETS) ## Run go generate to download vendor assets
23
+
24
+
$(GENERATED_ASSETS):
25
+
@echo "→ Generating vendor assets and code..."
26
+
go generate ./...
27
+
28
+
##@ Build Targets
29
+
30
+
build: build-appview build-hold build-credential-helper ## Build all binaries
31
+
32
+
build-appview: $(GENERATED_ASSETS) ## Build appview binary only
33
+
@echo "→ Building appview..."
34
+
@mkdir -p bin
35
+
go build -o bin/atcr-appview ./cmd/appview
36
+
37
+
build-hold: $(GENERATED_ASSETS) ## Build hold binary only
38
+
@echo "→ Building hold..."
39
+
@mkdir -p bin
40
+
go build -o bin/atcr-hold ./cmd/hold
41
+
42
+
build-credential-helper: $(GENERATED_ASSETS) ## Build credential helper only
43
+
@echo "→ Building credential helper..."
44
+
@mkdir -p bin
45
+
go build -o bin/docker-credential-atcr ./cmd/credential-helper
46
+
47
+
build-oauth-helper: $(GENERATED_ASSETS) ## Build OAuth helper only
48
+
@echo "→ Building OAuth helper..."
49
+
@mkdir -p bin
50
+
go build -o bin/oauth-helper ./cmd/oauth-helper
51
+
52
+
##@ Test Targets
53
+
54
+
test: ## Run all tests
55
+
@echo "→ Running tests..."
56
+
go test -cover ./...
57
+
58
+
test-race: ## Run tests with race detector
59
+
@echo "→ Running tests with race detector..."
60
+
go test -race ./...
61
+
62
+
test-verbose: ## Run tests with verbose output
63
+
@echo "→ Running tests with verbose output..."
64
+
go test -v ./...
65
+
66
+
##@ Quality Targets
67
+
68
+
.PHONY: check-golangci-lint
69
+
check-golangci-lint:
70
+
@which golangci-lint > /dev/null || (echo "→ Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
71
+
72
+
lint: check-golangci-lint ## Run golangci-lint
73
+
@echo "→ Running golangci-lint..."
74
+
golangci-lint run ./...
75
+
76
+
##@ Utility Targets
77
+
78
+
clean: ## Remove built binaries and generated assets
79
+
@echo "→ Cleaning build artifacts..."
80
+
rm -rf bin/
81
+
rm -f pkg/appview/static/js/htmx.min.js
82
+
rm -f pkg/appview/static/js/lucide.min.js
83
+
rm -f pkg/appview/licenses/spdx-licenses.json
84
+
@echo "✓ Clean complete"
+18
pkg/appview/storage/context_test.go
+18
pkg/appview/storage/context_test.go
···
2
3
import (
4
"context"
5
"testing"
6
7
"atcr.io/pkg/atproto"
···
9
10
// Mock implementations for testing
11
type mockDatabaseMetrics struct {
12
pullCount int
13
pushCount int
14
}
15
16
func (m *mockDatabaseMetrics) IncrementPullCount(did, repository string) error {
17
m.pullCount++
18
return nil
19
}
20
21
func (m *mockDatabaseMetrics) IncrementPushCount(did, repository string) error {
22
m.pushCount++
23
return nil
24
}
25
26
type mockReadmeCache struct{}
···
2
3
import (
4
"context"
5
+
"sync"
6
"testing"
7
8
"atcr.io/pkg/atproto"
···
10
11
// Mock implementations for testing
12
type mockDatabaseMetrics struct {
13
+
mu sync.Mutex
14
pullCount int
15
pushCount int
16
}
17
18
func (m *mockDatabaseMetrics) IncrementPullCount(did, repository string) error {
19
+
m.mu.Lock()
20
+
defer m.mu.Unlock()
21
m.pullCount++
22
return nil
23
}
24
25
func (m *mockDatabaseMetrics) IncrementPushCount(did, repository string) error {
26
+
m.mu.Lock()
27
+
defer m.mu.Unlock()
28
m.pushCount++
29
return nil
30
+
}
31
+
32
+
func (m *mockDatabaseMetrics) getPullCount() int {
33
+
m.mu.Lock()
34
+
defer m.mu.Unlock()
35
+
return m.pullCount
36
+
}
37
+
38
+
func (m *mockDatabaseMetrics) getPushCount() int {
39
+
m.mu.Lock()
40
+
defer m.mu.Unlock()
41
+
return m.pushCount
42
}
43
44
type mockReadmeCache struct{}
+6
pkg/appview/storage/manifest_store.go
+6
pkg/appview/storage/manifest_store.go
···
11
"maps"
12
"net/http"
13
"strings"
14
"time"
15
16
"atcr.io/pkg/atproto"
···
22
// It stores manifests in ATProto as records
23
type ManifestStore struct {
24
ctx *RegistryContext // Context with user/hold info
25
lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull)
26
blobStore distribution.BlobStore // Blob store for fetching config during push
27
}
···
67
// Store the hold DID for subsequent blob requests during pull
68
// Prefer HoldDID (new format) with fallback to HoldEndpoint (legacy URL format)
69
// The routing repository will cache this for concurrent blob fetches
70
if manifestRecord.HoldDID != "" {
71
// New format: DID reference (preferred)
72
s.lastFetchedHoldDID = manifestRecord.HoldDID
···
74
// Legacy format: URL reference - convert to DID
75
s.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint)
76
}
77
78
var ociManifest []byte
79
···
232
// GetLastFetchedHoldDID returns the hold DID from the most recently fetched manifest
233
// This is used by the routing repository to cache the hold for blob requests
234
func (s *ManifestStore) GetLastFetchedHoldDID() string {
235
return s.lastFetchedHoldDID
236
}
237
···
11
"maps"
12
"net/http"
13
"strings"
14
+
"sync"
15
"time"
16
17
"atcr.io/pkg/atproto"
···
23
// It stores manifests in ATProto as records
24
type ManifestStore struct {
25
ctx *RegistryContext // Context with user/hold info
26
+
mu sync.RWMutex // Protects lastFetchedHoldDID
27
lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull)
28
blobStore distribution.BlobStore // Blob store for fetching config during push
29
}
···
69
// Store the hold DID for subsequent blob requests during pull
70
// Prefer HoldDID (new format) with fallback to HoldEndpoint (legacy URL format)
71
// The routing repository will cache this for concurrent blob fetches
72
+
s.mu.Lock()
73
if manifestRecord.HoldDID != "" {
74
// New format: DID reference (preferred)
75
s.lastFetchedHoldDID = manifestRecord.HoldDID
···
77
// Legacy format: URL reference - convert to DID
78
s.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint)
79
}
80
+
s.mu.Unlock()
81
82
var ociManifest []byte
83
···
236
// GetLastFetchedHoldDID returns the hold DID from the most recently fetched manifest
237
// This is used by the routing repository to cache the hold for blob requests
238
func (s *ManifestStore) GetLastFetchedHoldDID() string {
239
+
s.mu.RLock()
240
+
defer s.mu.RUnlock()
241
return s.lastFetchedHoldDID
242
}
243
+3
-3
pkg/appview/storage/manifest_store_test.go
+3
-3
pkg/appview/storage/manifest_store_test.go
···
669
670
if tt.expectPullIncrement {
671
// Check that IncrementPullCount was called
672
-
if mockDB.pullCount == 0 {
673
t.Error("Expected pull count to be incremented for GET request, but it wasn't")
674
}
675
} else {
676
// Check that IncrementPullCount was NOT called
677
-
if mockDB.pullCount > 0 {
678
-
t.Errorf("Expected pull count NOT to be incremented for %s request, but it was (count=%d)", tt.httpMethod, mockDB.pullCount)
679
}
680
}
681
})
···
669
670
if tt.expectPullIncrement {
671
// Check that IncrementPullCount was called
672
+
if mockDB.getPullCount() == 0 {
673
t.Error("Expected pull count to be incremented for GET request, but it wasn't")
674
}
675
} else {
676
// Check that IncrementPullCount was NOT called
677
+
if mockDB.getPullCount() > 0 {
678
+
t.Errorf("Expected pull count NOT to be incremented for %s request, but it was (count=%d)", tt.httpMethod, mockDB.getPullCount())
679
}
680
}
681
})
+11
-3
pkg/appview/storage/profile_test.go
+11
-3
pkg/appview/storage/profile_test.go
···
219
// Clear migration locks before each test
220
migrationLocks = sync.Map{}
221
222
putRecordCalled := false
223
var migrationRequest map[string]any
224
···
232
233
// PutRecord (migration)
234
if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") {
235
putRecordCalled = true
236
json.NewDecoder(r.Body).Decode(&migrationRequest)
237
w.WriteHeader(http.StatusOK)
238
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`))
239
return
···
270
// Give goroutine time to execute
271
time.Sleep(50 * time.Millisecond)
272
273
-
if !putRecordCalled {
274
t.Error("Expected migration PutRecord to be called")
275
}
276
277
-
if migrationRequest != nil {
278
-
recordData := migrationRequest["record"].(map[string]any)
279
migratedHold := recordData["defaultHold"]
280
if migratedHold != tt.expectedHoldDID {
281
t.Errorf("Migrated defaultHold = %v, want %v", migratedHold, tt.expectedHoldDID)
···
219
// Clear migration locks before each test
220
migrationLocks = sync.Map{}
221
222
+
var mu sync.Mutex
223
putRecordCalled := false
224
var migrationRequest map[string]any
225
···
233
234
// PutRecord (migration)
235
if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") {
236
+
mu.Lock()
237
putRecordCalled = true
238
json.NewDecoder(r.Body).Decode(&migrationRequest)
239
+
mu.Unlock()
240
w.WriteHeader(http.StatusOK)
241
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`))
242
return
···
273
// Give goroutine time to execute
274
time.Sleep(50 * time.Millisecond)
275
276
+
mu.Lock()
277
+
called := putRecordCalled
278
+
request := migrationRequest
279
+
mu.Unlock()
280
+
281
+
if !called {
282
t.Error("Expected migration PutRecord to be called")
283
}
284
285
+
if request != nil {
286
+
recordData := request["record"].(map[string]any)
287
migratedHold := recordData["defaultHold"]
288
if migratedHold != tt.expectedHoldDID {
289
t.Errorf("Migrated defaultHold = %v, want %v", migratedHold, tt.expectedHoldDID)
+21
-5
pkg/appview/storage/routing_repository.go
+21
-5
pkg/appview/storage/routing_repository.go
···
7
import (
8
"context"
9
"log/slog"
10
"time"
11
12
"github.com/distribution/distribution/v3"
···
17
type RoutingRepository struct {
18
distribution.Repository
19
Ctx *RegistryContext // All context and services (exported for token updates)
20
manifestStore *ManifestStore // Cached manifest store instance
21
blobStore *ProxyBlobStore // Cached blob store instance
22
}
···
31
32
// Manifests returns the ATProto-backed manifest service
33
func (r *RoutingRepository) Manifests(ctx context.Context, options ...distribution.ManifestServiceOption) (distribution.ManifestService, error) {
34
// Create or return cached manifest store
35
if r.manifestStore == nil {
36
// Ensure blob store is created first (needed for label extraction during push)
37
blobStore := r.Blobs(ctx)
38
39
-
r.manifestStore = NewManifestStore(r.Ctx, blobStore)
40
}
41
42
// After any manifest operation, cache the hold DID for blob fetches
43
// We use a goroutine to avoid blocking, and check after a short delay to allow the operation to complete
44
go func() {
45
time.Sleep(100 * time.Millisecond) // Brief delay to let manifest fetch complete
46
-
if holdDID := r.manifestStore.GetLastFetchedHoldDID(); holdDID != "" {
47
// Cache for 10 minutes - should cover typical pull operations
48
GetGlobalHoldCache().Set(r.Ctx.DID, r.Ctx.Repository, holdDID, 10*time.Minute)
49
slog.Debug("Cached hold DID", "component", "storage/routing", "did", r.Ctx.DID, "repo", r.Ctx.Repository, "hold", holdDID)
50
}
51
}()
52
53
-
return r.manifestStore, nil
54
}
55
56
// Blobs returns a proxy blob store that routes to external hold service
57
// The registry (AppView) NEVER stores blobs locally - all blobs go through hold service
58
func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore {
59
// Return cached blob store if available
60
if r.blobStore != nil {
61
slog.Debug("Returning cached blob store", "component", "storage/blobs", "did", r.Ctx.DID, "repo", r.Ctx.Repository)
62
-
return r.blobStore
63
}
64
65
// For pull operations, check if we have a cached hold DID from a recent manifest fetch
···
85
86
// Create and cache proxy blob store
87
r.blobStore = NewProxyBlobStore(r.Ctx)
88
-
return r.blobStore
89
}
90
91
// Tags returns the tag service
···
7
import (
8
"context"
9
"log/slog"
10
+
"sync"
11
"time"
12
13
"github.com/distribution/distribution/v3"
···
18
type RoutingRepository struct {
19
distribution.Repository
20
Ctx *RegistryContext // All context and services (exported for token updates)
21
+
mu sync.Mutex // Protects manifestStore and blobStore
22
manifestStore *ManifestStore // Cached manifest store instance
23
blobStore *ProxyBlobStore // Cached blob store instance
24
}
···
33
34
// Manifests returns the ATProto-backed manifest service
35
func (r *RoutingRepository) Manifests(ctx context.Context, options ...distribution.ManifestServiceOption) (distribution.ManifestService, error) {
36
+
r.mu.Lock()
37
// Create or return cached manifest store
38
if r.manifestStore == nil {
39
// Ensure blob store is created first (needed for label extraction during push)
40
+
// Release lock while calling Blobs to avoid deadlock
41
+
r.mu.Unlock()
42
blobStore := r.Blobs(ctx)
43
+
r.mu.Lock()
44
45
+
// Double-check after reacquiring lock (another goroutine might have set it)
46
+
if r.manifestStore == nil {
47
+
r.manifestStore = NewManifestStore(r.Ctx, blobStore)
48
+
}
49
}
50
+
manifestStore := r.manifestStore
51
+
r.mu.Unlock()
52
53
// After any manifest operation, cache the hold DID for blob fetches
54
// We use a goroutine to avoid blocking, and check after a short delay to allow the operation to complete
55
go func() {
56
time.Sleep(100 * time.Millisecond) // Brief delay to let manifest fetch complete
57
+
if holdDID := manifestStore.GetLastFetchedHoldDID(); holdDID != "" {
58
// Cache for 10 minutes - should cover typical pull operations
59
GetGlobalHoldCache().Set(r.Ctx.DID, r.Ctx.Repository, holdDID, 10*time.Minute)
60
slog.Debug("Cached hold DID", "component", "storage/routing", "did", r.Ctx.DID, "repo", r.Ctx.Repository, "hold", holdDID)
61
}
62
}()
63
64
+
return manifestStore, nil
65
}
66
67
// Blobs returns a proxy blob store that routes to external hold service
68
// The registry (AppView) NEVER stores blobs locally - all blobs go through hold service
69
func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore {
70
+
r.mu.Lock()
71
// Return cached blob store if available
72
if r.blobStore != nil {
73
+
blobStore := r.blobStore
74
+
r.mu.Unlock()
75
slog.Debug("Returning cached blob store", "component", "storage/blobs", "did", r.Ctx.DID, "repo", r.Ctx.Repository)
76
+
return blobStore
77
}
78
79
// For pull operations, check if we have a cached hold DID from a recent manifest fetch
···
99
100
// Create and cache proxy blob store
101
r.blobStore = NewProxyBlobStore(r.Ctx)
102
+
blobStore := r.blobStore
103
+
r.mu.Unlock()
104
+
return blobStore
105
}
106
107
// Tags returns the tag service
+4
-4
pkg/appview/templates/components/head.html
+4
-4
pkg/appview/templates/components/head.html
···
12
<!-- Stylesheets -->
13
<link rel="stylesheet" href="/css/style.css">
14
15
-
<!-- HTMX -->
16
-
<script src="https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js"></script>
17
18
-
<!-- Lucide Icons -->
19
-
<script src="https://unpkg.com/lucide@latest"></script>
20
21
<!-- App Scripts -->
22
<script src="/js/app.js"></script>
···
12
<!-- Stylesheets -->
13
<link rel="stylesheet" href="/css/style.css">
14
15
+
<!-- HTMX (vendored) -->
16
+
<script src="/js/htmx.min.js"></script>
17
18
+
<!-- Lucide Icons (vendored) -->
19
+
<script src="/js/lucide.min.js"></script>
20
21
<!-- App Scripts -->
22
<script src="/js/app.js"></script>
+3
pkg/appview/ui.go
+3
pkg/appview/ui.go
···
12
"atcr.io/pkg/appview/licenses"
13
)
14
15
+
//go:generate curl -fsSL -o static/js/htmx.min.js https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js
16
+
//go:generate curl -fsSL -o static/js/lucide.min.js https://unpkg.com/lucide@latest/dist/umd/lucide.min.js
17
+
18
//go:embed templates/**/*.html
19
var templatesFS embed.FS
20