···8686 }
87878888 // Track pull count (increment asynchronously to avoid blocking the response)
8989+ // Only count GET requests (actual downloads), not HEAD requests (existence checks)
8990 if s.ctx.Database != nil {
9090- go func() {
9191- if err := s.ctx.Database.IncrementPullCount(s.ctx.DID, s.ctx.Repository); err != nil {
9292- slog.Warn("Failed to increment pull count", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err)
9393- }
9494- }()
9191+ // Check HTTP method from context (distribution library stores it as "http.request.method")
9292+ if method, ok := ctx.Value("http.request.method").(string); ok && method == "GET" {
9393+ go func() {
9494+ if err := s.ctx.Database.IncrementPullCount(s.ctx.DID, s.ctx.Repository); err != nil {
9595+ slog.Warn("Failed to increment pull count", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err)
9696+ }
9797+ }()
9898+ }
9599 }
9610097101 // Parse the manifest based on media type
+77
pkg/appview/storage/manifest_store_test.go
···77 "net/http"
88 "net/http/httptest"
99 "testing"
1010+ "time"
10111112 "atcr.io/pkg/atproto"
1213 "github.com/distribution/distribution/v3"
···600601 gotHoldDID := store.GetLastFetchedHoldDID()
601602 if gotHoldDID != tt.expectedHoldDID {
602603 t.Errorf("GetLastFetchedHoldDID() = %v, want %v", gotHoldDID, tt.expectedHoldDID)
604604+ }
605605+ })
606606+ }
607607+}
608608+609609+// TestManifestStore_Get_OnlyCountsGETRequests verifies that HEAD requests don't increment pull count
610610+func TestManifestStore_Get_OnlyCountsGETRequests(t *testing.T) {
611611+ ociManifest := []byte(`{"schemaVersion":2}`)
612612+613613+ tests := []struct {
614614+ name string
615615+ httpMethod string
616616+ expectPullIncrement bool
617617+ }{
618618+ {
619619+ name: "GET request increments pull count",
620620+ httpMethod: "GET",
621621+ expectPullIncrement: true,
622622+ },
623623+ {
624624+ name: "HEAD request does not increment pull count",
625625+ httpMethod: "HEAD",
626626+ expectPullIncrement: false,
627627+ },
628628+ {
629629+ name: "POST request does not increment pull count",
630630+ httpMethod: "POST",
631631+ expectPullIncrement: false,
632632+ },
633633+ }
634634+635635+ for _, tt := range tests {
636636+ t.Run(tt.name, func(t *testing.T) {
637637+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
638638+ if r.URL.Path == atproto.SyncGetBlob {
639639+ w.Write(ociManifest)
640640+ return
641641+ }
642642+ w.Write([]byte(`{
643643+ "uri": "at://did:plc:test123/io.atcr.manifest/abc123",
644644+ "value": {
645645+ "$type":"io.atcr.manifest",
646646+ "holdDid":"did:web:hold01.atcr.io",
647647+ "mediaType":"application/vnd.oci.image.manifest.v1+json",
648648+ "manifestBlob":{"ref":{"$link":"bafytest"},"size":100}
649649+ }
650650+ }`))
651651+ }))
652652+ defer server.Close()
653653+654654+ client := atproto.NewClient(server.URL, "did:plc:test123", "token")
655655+ mockDB := &mockDatabaseMetrics{}
656656+ ctx := mockRegistryContext(client, "myapp", "did:web:hold01.atcr.io", "did:plc:test123", "test.handle", mockDB)
657657+ store := NewManifestStore(ctx, nil)
658658+659659+ // Create a context with the HTTP method stored (as distribution library does)
660660+ testCtx := context.WithValue(context.Background(), "http.request.method", tt.httpMethod)
661661+662662+ _, err := store.Get(testCtx, "sha256:abc123")
663663+ if err != nil {
664664+ t.Fatalf("Get() error = %v", err)
665665+ }
666666+667667+ // Wait for async goroutine to complete (metrics are incremented asynchronously)
668668+ time.Sleep(50 * time.Millisecond)
669669+670670+ if tt.expectPullIncrement {
671671+ // Check that IncrementPullCount was called
672672+ if mockDB.pullCount == 0 {
673673+ t.Error("Expected pull count to be incremented for GET request, but it wasn't")
674674+ }
675675+ } else {
676676+ // Check that IncrementPullCount was NOT called
677677+ if mockDB.pullCount > 0 {
678678+ t.Errorf("Expected pull count NOT to be incremented for %s request, but it was (count=%d)", tt.httpMethod, mockDB.pullCount)
679679+ }
603680 }
604681 })
605682 }
+2-2
pkg/appview/templates/pages/install.html
···8181 <p>You can also use <code>docker login</code> with your ATProto app password:</p>
82828383 <ol>
8484- <li>Generate an app password in your ATProto account settings</li>
8484+ <li>Generate an app password at <a href="https://bsky.app/settings/app-passwords" target="_blank">bsky.app/settings/app-passwords</a></li>
8585 <li>Run: <code>docker login {{ .RegistryURL }}</code></li>
8686 <li>Enter your handle as username</li>
8787 <li>Enter your app password</li>
8888 </ol>
89899090 <div class="note">
9191- <strong>Note:</strong> App passwords are available in your Bluesky account settings under "App Passwords".
9191+ <strong>Note:</strong> Create an app password at <a href="https://bsky.app/settings/app-passwords" target="_blank">bsky.app/settings/app-passwords</a>.
9292 </div>
9393 </div>
9494