Select the types of activity you want to include in your feed.
fix quirks on repo and digest pages. fix ips not showing in server logs. add basic spam blocking to LB. add setting to configure your oci (docker) client.
···2525 "format": "at-uri",
2626 "description": "AT-URI of the manifest this tag points to (e.g., 'at://did:plc:xyz/io.atcr.manifest/abc123'). Preferred over manifestDigest for new records."
2727 },
2828+ "mediaType": {
2929+ "type": "string",
3030+ "description": "OCI media type of the manifest (e.g., 'application/vnd.oci.image.manifest.v1+json' or 'application/vnd.oci.image.index.v1+json')",
3131+ "maxLength": 255
3232+ },
2833 "manifestDigest": {
2934 "type": "string",
3035 "description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead.",
···11+description: Add oci_client column to users table for OCI client preference
22+query: |
33+ ALTER TABLE users ADD COLUMN oci_client TEXT DEFAULT '';
+9
pkg/appview/db/models.go
···99 PDSEndpoint string
1010 Avatar string
1111 DefaultHoldDID string
1212+ OciClient string
1213 LastSeen time.Time
1314}
1415···113114 Digest string // Latest manifest digest (sha256:...)
114115 LastUpdated time.Time // When the repository was last pushed to
115116 RegistryURL string // Registry URL for docker commands (e.g., "atcr.io" or "127.0.0.1:5000")
117117+ OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman")
116118}
117119118120// SetRegistryURL sets the RegistryURL field on all cards in the slice
119121func SetRegistryURL(cards []RepoCardData, registryURL string) {
120122 for i := range cards {
121123 cards[i].RegistryURL = registryURL
124124+ }
125125+}
126126+127127+// SetOciClient sets the OciClient field on all cards in the slice
128128+func SetOciClient(cards []RepoCardData, ociClient string) {
129129+ for i := range cards {
130130+ cards[i].OciClient = ociClient
122131 }
123132}
124133
+20-6
pkg/appview/db/queries.go
···319319// GetUserByDID retrieves a user by DID
320320func GetUserByDID(db DBTX, did string) (*User, error) {
321321 var user User
322322- var avatar, defaultHoldDID sql.NullString
322322+ var avatar, defaultHoldDID, ociClient sql.NullString
323323 err := db.QueryRow(`
324324- SELECT did, handle, pds_endpoint, avatar, default_hold_did, last_seen
324324+ SELECT did, handle, pds_endpoint, avatar, default_hold_did, oci_client, last_seen
325325 FROM users
326326 WHERE did = ?
327327- `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &user.LastSeen)
327327+ `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &ociClient, &user.LastSeen)
328328329329 if err == sql.ErrNoRows {
330330 return nil, nil
···338338 }
339339 if defaultHoldDID.Valid {
340340 user.DefaultHoldDID = defaultHoldDID.String
341341+ }
342342+ if ociClient.Valid {
343343+ user.OciClient = ociClient.String
341344 }
342345343346 return &user, nil
···346349// GetUserByHandle retrieves a user by handle
347350func GetUserByHandle(db DBTX, handle string) (*User, error) {
348351 var user User
349349- var avatar, defaultHoldDID sql.NullString
352352+ var avatar, defaultHoldDID, ociClient sql.NullString
350353 err := db.QueryRow(`
351351- SELECT did, handle, pds_endpoint, avatar, default_hold_did, last_seen
354354+ SELECT did, handle, pds_endpoint, avatar, default_hold_did, oci_client, last_seen
352355 FROM users
353356 WHERE handle = ?
354354- `, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &user.LastSeen)
357357+ `, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &ociClient, &user.LastSeen)
355358356359 if err == sql.ErrNoRows {
357360 return nil, nil
···365368 }
366369 if defaultHoldDID.Valid {
367370 user.DefaultHoldDID = defaultHoldDID.String
371371+ }
372372+ if ociClient.Valid {
373373+ user.OciClient = ociClient.String
368374 }
369375370376 return &user, nil
···451457 _, err := db.Exec(`
452458 UPDATE users SET default_hold_did = ? WHERE did = ?
453459 `, holdDID, did)
460460+ return err
461461+}
462462+463463+// UpdateUserOciClient updates a user's cached OCI client preference
464464+func UpdateUserOciClient(db DBTX, did string, ociClient string) error {
465465+ _, err := db.Exec(`
466466+ UPDATE users SET oci_client = ? WHERE did = ?
467467+ `, ociClient, did)
454468 return err
455469}
456470
+1
pkg/appview/db/schema.sql
···1313 pds_endpoint TEXT NOT NULL,
1414 avatar TEXT,
1515 default_hold_did TEXT,
1616+ oci_client TEXT DEFAULT '',
1617 last_seen TIMESTAMP NOT NULL,
1718 UNIQUE(handle)
1819);
+8-1
pkg/appview/handlers/common.go
···1616 SiteURL string // Website domain (e.g., "seamark.dev")
1717 ClientName string // Brand name for templates (e.g., "AT Container Registry")
1818 ClientShortName string // Brand name for templates (e.g., "ATCR")
1919+ OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman")
1920}
20212122// NewPageData creates a PageData struct with common fields populated from the request
2223func NewPageData(r *http.Request, h *BaseUIHandler) PageData {
2424+ user := middleware.GetUser(r)
2525+ var ociClient string
2626+ if user != nil {
2727+ ociClient = user.OciClient
2828+ }
2329 return PageData{
2424- User: middleware.GetUser(r),
3030+ User: user,
2531 Query: r.URL.Query().Get("q"),
2632 RegistryURL: h.RegistryURL,
2733 SiteURL: h.SiteURL,
2834 ClientName: h.ClientName,
2935 ClientShortName: h.ClientShortName,
3636+ OciClient: ociClient,
3037 }
3138}
3239
+5-1
pkg/appview/handlers/home.go
···3939 }
4040 db.SetRegistryURL(recentCards, h.RegistryURL)
41414242+ pageData := NewPageData(r, &h.BaseUIHandler)
4343+ db.SetOciClient(featuredCards, pageData.OciClient)
4444+ db.SetOciClient(recentCards, pageData.OciClient)
4545+4246 data := struct {
4347 PageData
4448 Meta *PageMeta
4549 FeaturedRepos []db.RepoCardData
4650 RecentRepos []db.RepoCardData
4751 }{
4848- PageData: NewPageData(r, &h.BaseUIHandler),
5252+ PageData: pageData,
4953 Meta: NewPageMeta(
5054 h.ClientShortName+" - Distributed Container Registry",
5155 "Push and pull Docker images on the AT Protocol. Same Docker, decentralized.",
···290290 // Create main chi router
291291 mainRouter := chi.NewRouter()
292292293293+ mainRouter.Use(chimiddleware.RealIP)
293294 mainRouter.Use(chimiddleware.Logger)
294295 mainRouter.Use(chimiddleware.Recoverer)
295296 mainRouter.Use(chimiddleware.GetHead)
+1-1
pkg/appview/storage/manifest_store.go
···228228 }
229229 }
230230231231- tagRecord := atproto.NewTagRecord(s.ctx.ATProtoClient.DID(), s.ctx.Repository, tag, dgst.String())
231231+ tagRecord := atproto.NewTagRecord(s.ctx.ATProtoClient.DID(), s.ctx.Repository, tag, dgst.String(), mediaType)
232232 _, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.TagCollection, tagRKey, tagRecord)
233233 if err != nil {
234234 return "", fmt.Errorf("failed to store tag in ATProto: %w", err)
+8-2
pkg/appview/storage/tag_store.go
···5454 }
55555656 // Return descriptor pointing to the manifest
5757+ // Use stored media type, fallback for old records without it
5858+ mediaType := tagRecord.MediaType
5959+ if mediaType == "" {
6060+ mediaType = "application/vnd.oci.image.manifest.v1+json"
6161+ }
6262+5763 return distribution.Descriptor{
5864 Digest: dgst,
5959- MediaType: "application/vnd.oci.image.manifest.v1+json",
6565+ MediaType: mediaType,
6066 }, nil
6167}
62686369// Tag associates a tag with a descriptor (manifest digest)
6470func (s *TagStore) Tag(ctx context.Context, tag string, desc distribution.Descriptor) error {
6571 // Create tag record with manifest AT-URI
6666- tagRecord := atproto.NewTagRecord(s.client.DID(), s.repository, tag, desc.Digest.String())
7272+ tagRecord := atproto.NewTagRecord(s.client.DID(), s.repository, tag, desc.Digest.String(), desc.MediaType)
67736874 // Store in ATProto
6975 rkey := atproto.RepositoryTagToRKey(s.repository, tag)
+105
pkg/appview/storage/tag_store_test.go
···195195 }
196196}
197197198198+// TestTagStore_Get_ReturnsStoredMediaType tests that Get returns the stored mediaType from the tag record
199199+func TestTagStore_Get_ReturnsStoredMediaType(t *testing.T) {
200200+ tests := []struct {
201201+ name string
202202+ mediaType string
203203+ wantMediaType string
204204+ }{
205205+ {
206206+ name: "OCI image index",
207207+ mediaType: "application/vnd.oci.image.index.v1+json",
208208+ wantMediaType: "application/vnd.oci.image.index.v1+json",
209209+ },
210210+ {
211211+ name: "Docker manifest list",
212212+ mediaType: "application/vnd.docker.distribution.manifest.list.v2+json",
213213+ wantMediaType: "application/vnd.docker.distribution.manifest.list.v2+json",
214214+ },
215215+ {
216216+ name: "OCI image manifest",
217217+ mediaType: "application/vnd.oci.image.manifest.v1+json",
218218+ wantMediaType: "application/vnd.oci.image.manifest.v1+json",
219219+ },
220220+ {
221221+ name: "missing mediaType falls back to default",
222222+ mediaType: "",
223223+ wantMediaType: "application/vnd.oci.image.manifest.v1+json",
224224+ },
225225+ }
226226+227227+ for _, tt := range tests {
228228+ t.Run(tt.name, func(t *testing.T) {
229229+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
230230+ mediaTypeField := ""
231231+ if tt.mediaType != "" {
232232+ mediaTypeField = `"mediaType": "` + tt.mediaType + `",`
233233+ }
234234+ response := `{
235235+ "uri": "at://did:plc:test123/io.atcr.tag/myapp_latest",
236236+ "cid": "bafytest",
237237+ "value": {
238238+ "$type": "io.atcr.tag",
239239+ "repository": "myapp",
240240+ "tag": "latest",
241241+ ` + mediaTypeField + `
242242+ "manifest": "at://did:plc:test123/io.atcr.manifest/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
243243+ "updatedAt": "2025-01-01T00:00:00Z"
244244+ }
245245+ }`
246246+ w.WriteHeader(http.StatusOK)
247247+ w.Write([]byte(response))
248248+ }))
249249+ defer server.Close()
250250+251251+ client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
252252+ store := NewTagStore(client, "myapp")
253253+254254+ desc, err := store.Get(context.Background(), "latest")
255255+ if err != nil {
256256+ t.Fatalf("Get() error = %v", err)
257257+ }
258258+259259+ if desc.MediaType != tt.wantMediaType {
260260+ t.Errorf("MediaType = %v, want %v", desc.MediaType, tt.wantMediaType)
261261+ }
262262+ })
263263+ }
264264+}
265265+266266+// TestTagStore_Tag_SendsMediaType tests that Tag() includes the mediaType in the record
267267+func TestTagStore_Tag_SendsMediaType(t *testing.T) {
268268+ var sentMediaType string
269269+270270+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
271271+ var body map[string]any
272272+ json.NewDecoder(r.Body).Decode(&body)
273273+274274+ if recordData, ok := body["record"].(map[string]any); ok {
275275+ if mt, ok := recordData["mediaType"].(string); ok {
276276+ sentMediaType = mt
277277+ }
278278+ }
279279+280280+ w.WriteHeader(http.StatusOK)
281281+ w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.tag/myapp_latest","cid":"bafytest"}`))
282282+ }))
283283+ defer server.Close()
284284+285285+ client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
286286+ store := NewTagStore(client, "myapp")
287287+288288+ desc := distribution.Descriptor{
289289+ Digest: "sha256:abc123def456",
290290+ MediaType: "application/vnd.oci.image.index.v1+json",
291291+ }
292292+293293+ err := store.Tag(context.Background(), "latest", desc)
294294+ if err != nil {
295295+ t.Fatalf("Tag() error = %v", err)
296296+ }
297297+298298+ if sentMediaType != "application/vnd.oci.image.index.v1+json" {
299299+ t.Errorf("sent mediaType = %v, want application/vnd.oci.image.index.v1+json", sentMediaType)
300300+ }
301301+}
302302+198303// TestTagStore_Tag tests creating/updating a tag
199304func TestTagStore_Tag(t *testing.T) {
200305 tests := []struct {
···277277 // Preferred over ManifestDigest for new records
278278 Manifest string `json:"manifest,omitempty"`
279279280280+ // MediaType is the OCI media type of the manifest this tag points to
281281+ // e.g., "application/vnd.oci.image.manifest.v1+json" or "application/vnd.oci.image.index.v1+json"
282282+ MediaType string `json:"mediaType,omitempty"`
283283+280284 // ManifestDigest is the digest of the manifest this tag points to (DEPRECATED)
281285 // Kept for backward compatibility with old records
282286 // New records should use Manifest field instead
···291295// repository: The repository name (e.g., "myapp")
292296// tag: The tag name (e.g., "latest", "v1.0.0")
293297// manifestDigest: The manifest digest (e.g., "sha256:abc123...")
294294-func NewTagRecord(did, repository, tag, manifestDigest string) *TagRecord {
298298+func NewTagRecord(did, repository, tag, manifestDigest, mediaType string) *TagRecord {
295299 // Build AT-URI for the manifest
296300 // Format: at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>
297301 manifestURI := BuildManifestURI(did, manifestDigest)
···301305 Repository: repository,
302306 Tag: tag,
303307 Manifest: manifestURI,
308308+ MediaType: mediaType,
304309 // Note: ManifestDigest is not set for new records (only for backward compat with old records)
305310 UpdatedAt: time.Now(),
306311 }
···343348 // cleaned up. When true, manifests that lose all tags (e.g., after a tag
344349 // overwrite) are deleted from PDS, and their layers are cleaned up by hold GC.
345350 AutoRemoveUntagged bool `json:"autoRemoveUntagged,omitempty"`
351351+352352+ // OciClient is the preferred OCI client for pull commands (docker, podman, buildah, nerdctl, crane).
353353+ // Defaults to "docker" if empty.
354354+ OciClient string `json:"ociClient,omitempty"`
346355347356 // CreatedAt timestamp
348357 CreatedAt time.Time `json:"createdAt"`
+6-1
pkg/atproto/lexicon_test.go
···269269func TestNewTagRecord(t *testing.T) {
270270 did := "did:plc:test123"
271271 before := time.Now()
272272- record := NewTagRecord(did, "myapp", "latest", "sha256:abc123")
272272+ record := NewTagRecord(did, "myapp", "latest", "sha256:abc123", "application/vnd.oci.image.manifest.v1+json")
273273 after := time.Now()
274274275275 if record.Type != TagCollection {
···288288 expectedURI := "at://did:plc:test123/io.atcr.manifest/abc123"
289289 if record.Manifest != expectedURI {
290290 t.Errorf("Manifest = %v, want %v", record.Manifest, expectedURI)
291291+ }
292292+293293+ // New records should have media type
294294+ if record.MediaType != "application/vnd.oci.image.manifest.v1+json" {
295295+ t.Errorf("MediaType = %v, want application/vnd.oci.image.manifest.v1+json", record.MediaType)
291296 }
292297293298 // New records should NOT have manifestDigest field