add backlinks to tags

This commit is contained in:
Evan Jarrett
2025-10-21 09:29:40 -05:00
parent 5d52007104
commit ce7160cdca
13 changed files with 371 additions and 29 deletions
+7 -2
View File
@@ -8,7 +8,7 @@
"key": "any",
"record": {
"type": "object",
"required": ["repository", "tag", "manifestDigest", "createdAt"],
"required": ["repository", "tag", "createdAt"],
"properties": {
"repository": {
"type": "string",
@@ -20,9 +20,14 @@
"description": "Tag name (e.g., 'latest', 'v1.0.0', '12-slim')",
"maxLength": 128
},
"manifest": {
"type": "string",
"format": "at-uri",
"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."
},
"manifestDigest": {
"type": "string",
"description": "Digest of the manifest this tag points to (e.g., 'sha256:...')"
"description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead."
},
"createdAt": {
"type": "string",
+7 -1
View File
@@ -400,12 +400,18 @@ func (b *BackfillWorker) processTagRecord(did string, record *atproto.Record) er
return fmt.Errorf("failed to unmarshal tag: %w", err)
}
// Extract digest from tag record (tries manifest field first, falls back to manifestDigest)
manifestDigest, err := tagRecord.GetManifestDigest()
if err != nil {
return fmt.Errorf("failed to get manifest digest from tag record: %w", err)
}
// Insert or update tag
return db.UpsertTag(b.db, &db.Tag{
DID: did,
Repository: tagRecord.Repository,
Tag: tagRecord.Tag,
Digest: tagRecord.ManifestDigest,
Digest: manifestDigest,
CreatedAt: tagRecord.UpdatedAt,
})
}
+7 -1
View File
@@ -560,12 +560,18 @@ func (w *Worker) processTag(commit *CommitEvent) error {
return nil
}
// Extract digest from tag record (tries manifest field first, falls back to manifestDigest)
manifestDigest, err := tagRecord.GetManifestDigest()
if err != nil {
return fmt.Errorf("failed to get manifest digest from tag record: %w", err)
}
// Insert or update tag
return db.UpsertTag(w.db, &db.Tag{
DID: commit.DID,
Repository: tagRecord.Repository,
Tag: tagRecord.Tag,
Digest: tagRecord.ManifestDigest,
Digest: manifestDigest,
CreatedAt: tagRecord.UpdatedAt,
})
}
+1 -1
View File
@@ -310,7 +310,7 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
HoldDID: holdDID,
PDSEndpoint: pdsEndpoint,
Repository: repositoryName,
ServiceToken: serviceToken, // Cached service token from middleware validation
ServiceToken: serviceToken, // Cached service token from middleware validation
ATProtoClient: atprotoClient,
Database: nr.database,
Authorizer: nr.authorizer,
+5
View File
@@ -661,3 +661,8 @@ func (c *Client) FetchDIDDocument(ctx context.Context, didDocURL string) (*DIDDo
return &didDoc, nil
}
// DID returns the DID associated with this client
func (c *Client) DID() string {
return c.did
}
+80 -9
View File
@@ -241,21 +241,37 @@ type TagRecord struct {
// Tag is the tag name (e.g., "latest", "v1.0.0")
Tag string `json:"tag"`
// ManifestDigest is the digest of the manifest this tag points to
ManifestDigest string `json:"manifestDigest"`
// Manifest is the AT-URI of the manifest this tag points to
// Format: at://did:plc:xyz/io.atcr.manifest/abc123
// Preferred over ManifestDigest for new records
Manifest string `json:"manifest,omitempty"`
// ManifestDigest is the digest of the manifest this tag points to (DEPRECATED)
// Kept for backward compatibility with old records
// New records should use Manifest field instead
ManifestDigest string `json:"manifestDigest,omitempty"`
// UpdatedAt timestamp
UpdatedAt time.Time `json:"updatedAt"`
}
// NewTagRecord creates a new tag record
func NewTagRecord(repository, tag, manifestDigest string) *TagRecord {
// NewTagRecord creates a new tag record with manifest AT-URI
// did: The DID of the user (e.g., "did:plc:xyz123")
// repository: The repository name (e.g., "myapp")
// tag: The tag name (e.g., "latest", "v1.0.0")
// manifestDigest: The manifest digest (e.g., "sha256:abc123...")
func NewTagRecord(did, repository, tag, manifestDigest string) *TagRecord {
// Build AT-URI for the manifest
// Format: at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>
manifestURI := BuildManifestURI(did, manifestDigest)
return &TagRecord{
Type: TagCollection,
Repository: repository,
Tag: tag,
ManifestDigest: manifestDigest,
UpdatedAt: time.Now(),
Type: TagCollection,
Repository: repository,
Tag: tag,
Manifest: manifestURI,
// Note: ManifestDigest is not set for new records (only for backward compat with old records)
UpdatedAt: time.Now(),
}
}
@@ -412,6 +428,61 @@ func isDID(s string) bool {
return len(s) > 4 && s[:4] == "did:"
}
// BuildManifestURI creates an AT-URI for a manifest record
// did: The DID of the user (e.g., "did:plc:xyz123")
// manifestDigest: The manifest digest (e.g., "sha256:abc123...")
// Returns: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>"
func BuildManifestURI(did, manifestDigest string) string {
// Remove the "sha256:" prefix from the digest to get the rkey
rkey := strings.TrimPrefix(manifestDigest, "sha256:")
return fmt.Sprintf("at://%s/%s/%s", did, ManifestCollection, rkey)
}
// ParseManifestURI extracts the digest from a manifest AT-URI
// manifestURI: AT-URI in format "at://did:plc:xyz/io.atcr.manifest/<digest-without-sha256-prefix>"
// Returns: Full digest with "sha256:" prefix (e.g., "sha256:abc123...")
func ParseManifestURI(manifestURI string) (string, error) {
// Expected format: at://did:plc:xyz/io.atcr.manifest/<rkey>
if !strings.HasPrefix(manifestURI, "at://") {
return "", fmt.Errorf("invalid AT-URI format: must start with 'at://'")
}
// Remove "at://" prefix
remainder := strings.TrimPrefix(manifestURI, "at://")
// Split by "/"
parts := strings.Split(remainder, "/")
if len(parts) != 3 {
return "", fmt.Errorf("invalid AT-URI format: expected 3 parts (did/collection/rkey), got %d", len(parts))
}
// Validate collection
if parts[1] != ManifestCollection {
return "", fmt.Errorf("invalid AT-URI: expected collection %s, got %s", ManifestCollection, parts[1])
}
// The rkey is the digest without the "sha256:" prefix
// Add it back to get the full digest
rkey := parts[2]
return "sha256:" + rkey, nil
}
// GetManifestDigest extracts the digest from a TagRecord, preferring the manifest field
// Returns the digest with "sha256:" prefix (e.g., "sha256:abc123...")
func (t *TagRecord) GetManifestDigest() (string, error) {
// Prefer the new manifest field
if t.Manifest != "" {
return ParseManifestURI(t.Manifest)
}
// Fall back to the legacy manifestDigest field
if t.ManifestDigest != "" {
return t.ManifestDigest, nil
}
return "", fmt.Errorf("tag record has neither manifest nor manifestDigest field")
}
// =============================================================================
// Embedded PDS Types (Hold Service)
// =============================================================================
+163 -3
View File
@@ -267,8 +267,9 @@ func TestNewManifestRecord(t *testing.T) {
}
func TestNewTagRecord(t *testing.T) {
did := "did:plc:test123"
before := time.Now()
record := NewTagRecord("myapp", "latest", "sha256:abc123")
record := NewTagRecord(did, "myapp", "latest", "sha256:abc123")
after := time.Now()
if record.Type != TagCollection {
@@ -283,8 +284,15 @@ func TestNewTagRecord(t *testing.T) {
t.Errorf("Tag = %v, want latest", record.Tag)
}
if record.ManifestDigest != "sha256:abc123" {
t.Errorf("ManifestDigest = %v, want sha256:abc123", record.ManifestDigest)
// New records should have manifest field (AT-URI)
expectedURI := "at://did:plc:test123/io.atcr.manifest/abc123"
if record.Manifest != expectedURI {
t.Errorf("Manifest = %v, want %v", record.Manifest, expectedURI)
}
// New records should NOT have manifestDigest field
if record.ManifestDigest != "" {
t.Errorf("ManifestDigest should be empty for new records, got %v", record.ManifestDigest)
}
if record.UpdatedAt.Before(before) || record.UpdatedAt.After(after) {
@@ -292,6 +300,158 @@ func TestNewTagRecord(t *testing.T) {
}
}
func TestBuildManifestURI(t *testing.T) {
tests := []struct {
name string
did string
manifestDigest string
want string
}{
{
name: "standard digest",
did: "did:plc:abc123",
manifestDigest: "sha256:def456",
want: "at://did:plc:abc123/io.atcr.manifest/def456",
},
{
name: "long digest",
did: "did:web:hold.example.com",
manifestDigest: "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
want: "at://did:web:hold.example.com/io.atcr.manifest/abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := BuildManifestURI(tt.did, tt.manifestDigest)
if got != tt.want {
t.Errorf("BuildManifestURI() = %v, want %v", got, tt.want)
}
})
}
}
func TestParseManifestURI(t *testing.T) {
tests := []struct {
name string
manifestURI string
want string
wantErr bool
}{
{
name: "valid URI",
manifestURI: "at://did:plc:abc123/io.atcr.manifest/def456",
want: "sha256:def456",
wantErr: false,
},
{
name: "valid URI with did:web",
manifestURI: "at://did:web:hold.example.com/io.atcr.manifest/xyz789",
want: "sha256:xyz789",
wantErr: false,
},
{
name: "invalid prefix",
manifestURI: "https://example.com/manifest",
want: "",
wantErr: true,
},
{
name: "wrong collection",
manifestURI: "at://did:plc:abc123/io.atcr.tag/def456",
want: "",
wantErr: true,
},
{
name: "too few parts",
manifestURI: "at://did:plc:abc123/io.atcr.manifest",
want: "",
wantErr: true,
},
{
name: "too many parts",
manifestURI: "at://did:plc:abc123/io.atcr.manifest/def456/extra",
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseManifestURI(tt.manifestURI)
if (err != nil) != tt.wantErr {
t.Errorf("ParseManifestURI() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ParseManifestURI() = %v, want %v", got, tt.want)
}
})
}
}
func TestTagRecord_GetManifestDigest(t *testing.T) {
tests := []struct {
name string
record TagRecord
want string
wantErr bool
}{
{
name: "new record with manifest field",
record: TagRecord{
Manifest: "at://did:plc:test123/io.atcr.manifest/abc123",
},
want: "sha256:abc123",
wantErr: false,
},
{
name: "old record with manifestDigest field",
record: TagRecord{
ManifestDigest: "sha256:def456",
},
want: "sha256:def456",
wantErr: false,
},
{
name: "prefers manifest over manifestDigest",
record: TagRecord{
Manifest: "at://did:plc:test123/io.atcr.manifest/abc123",
ManifestDigest: "sha256:def456",
},
want: "sha256:abc123",
wantErr: false,
},
{
name: "no fields set",
record: TagRecord{},
want: "",
wantErr: true,
},
{
name: "invalid manifest URI",
record: TagRecord{
Manifest: "invalid-uri",
},
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.record.GetManifestDigest()
if (err != nil) != tt.wantErr {
t.Errorf("GetManifestDigest() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("GetManifestDigest() = %v, want %v", got, tt.want)
}
})
}
}
func TestNewHoldRecord(t *testing.T) {
tests := []struct {
name string
+1 -1
View File
@@ -184,7 +184,7 @@ func (s *ManifestStore) Put(ctx context.Context, manifest distribution.Manifest,
for _, option := range options {
if tagOpt, ok := option.(distribution.WithTagOption); ok {
tag := tagOpt.Tag
tagRecord := NewTagRecord(s.repository, tag, dgst.String())
tagRecord := NewTagRecord(s.client.DID(), s.repository, tag, dgst.String())
tagRKey := repositoryTagToRKey(s.repository, tag)
_, err = s.client.PutRecord(ctx, TagCollection, tagRKey, tagRecord)
if err != nil {
+21 -4
View File
@@ -40,8 +40,14 @@ func (s *TagStore) Get(ctx context.Context, tag string) (distribution.Descriptor
return distribution.Descriptor{}, fmt.Errorf("failed to unmarshal tag record: %w", err)
}
// Extract manifest digest (tries manifest field first, falls back to manifestDigest)
manifestDigest, err := tagRecord.GetManifestDigest()
if err != nil {
return distribution.Descriptor{}, fmt.Errorf("failed to get manifest digest from tag record: %w", err)
}
// Parse manifest digest
dgst, err := digest.Parse(tagRecord.ManifestDigest)
dgst, err := digest.Parse(manifestDigest)
if err != nil {
return distribution.Descriptor{}, fmt.Errorf("invalid manifest digest in tag record: %w", err)
}
@@ -55,8 +61,8 @@ func (s *TagStore) Get(ctx context.Context, tag string) (distribution.Descriptor
// Tag associates a tag with a descriptor (manifest digest)
func (s *TagStore) Tag(ctx context.Context, tag string, desc distribution.Descriptor) error {
// Create tag record
tagRecord := NewTagRecord(s.repository, tag, desc.Digest.String())
// Create tag record with manifest AT-URI
tagRecord := NewTagRecord(s.client.DID(), s.repository, tag, desc.Digest.String())
// Store in ATProto
rkey := repositoryTagToRKey(s.repository, tag)
@@ -116,7 +122,18 @@ func (s *TagStore) Lookup(ctx context.Context, desc distribution.Descriptor) ([]
}
// Only include tags for this repository that match the digest
if tagRecord.Repository == s.repository && tagRecord.ManifestDigest == desc.Digest.String() {
if tagRecord.Repository != s.repository {
continue
}
// Extract digest from tag record (tries manifest field first, falls back to manifestDigest)
manifestDigest, err := tagRecord.GetManifestDigest()
if err != nil {
// Skip records with invalid manifest references
continue
}
if manifestDigest == desc.Digest.String() {
tags = append(tags, tagRecord.Tag)
}
}
+74 -2
View File
@@ -128,6 +128,72 @@ func TestTagStore_Get_InvalidDigest(t *testing.T) {
}
}
// TestTagStore_Get_BackwardCompatibility tests reading old tag records with only manifestDigest field
func TestTagStore_Get_BackwardCompatibility(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Old tag record with only manifestDigest (no manifest field)
response := `{
"uri": "at://did:plc:test123/io.atcr.tag/myapp_latest",
"cid": "bafytest",
"value": {
"$type": "io.atcr.tag",
"repository": "myapp",
"tag": "latest",
"manifestDigest": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"updatedAt": "2025-01-01T00:00:00Z"
}
}`
w.WriteHeader(http.StatusOK)
w.Write([]byte(response))
}))
defer server.Close()
client := NewClient(server.URL, "did:plc:test123", "test-token")
store := NewTagStore(client, "myapp")
desc, err := store.Get(context.Background(), "latest")
if err != nil {
t.Fatalf("Get() error = %v, should handle old records", err)
}
if desc.Digest.String() != "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" {
t.Errorf("Digest = %v, want sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", desc.Digest.String())
}
}
// TestTagStore_Get_NewManifestField tests reading new tag records with manifest field
func TestTagStore_Get_NewManifestField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// New tag record with manifest field
response := `{
"uri": "at://did:plc:test123/io.atcr.tag/myapp_latest",
"cid": "bafytest",
"value": {
"$type": "io.atcr.tag",
"repository": "myapp",
"tag": "latest",
"manifest": "at://did:plc:test123/io.atcr.manifest/fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
"updatedAt": "2025-01-01T00:00:00Z"
}
}`
w.WriteHeader(http.StatusOK)
w.Write([]byte(response))
}))
defer server.Close()
client := NewClient(server.URL, "did:plc:test123", "test-token")
store := NewTagStore(client, "myapp")
desc, err := store.Get(context.Background(), "latest")
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if desc.Digest.String() != "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321" {
t.Errorf("Digest = %v, want sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321", desc.Digest.String())
}
}
// TestTagStore_Tag tests creating/updating a tag
func TestTagStore_Tag(t *testing.T) {
tests := []struct {
@@ -226,8 +292,14 @@ func TestTagStore_Tag(t *testing.T) {
if sentTagRecord.Tag != tt.tag {
t.Errorf("Tag = %v, want %v", sentTagRecord.Tag, tt.tag)
}
if sentTagRecord.ManifestDigest != tt.digest.String() {
t.Errorf("ManifestDigest = %v, want %v", sentTagRecord.ManifestDigest, tt.digest.String())
// New records should have manifest field
expectedURI := BuildManifestURI("did:plc:test123", tt.digest.String())
if sentTagRecord.Manifest != expectedURI {
t.Errorf("Manifest = %v, want %v", sentTagRecord.Manifest, expectedURI)
}
// New records should NOT have manifestDigest field
if sentTagRecord.ManifestDigest != "" {
t.Errorf("ManifestDigest should be empty for new records, got %v", sentTagRecord.ManifestDigest)
}
}
})
+1 -1
View File
@@ -1,6 +1,7 @@
package auth
import (
"atcr.io/pkg/atproto"
"bytes"
"context"
"crypto/sha256"
@@ -11,7 +12,6 @@ import (
"net/http"
"sync"
"time"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
+3 -3
View File
@@ -140,9 +140,9 @@ func GetCacheStats() map[string]interface{} {
}
return map[string]interface{}{
"total_entries": len(globalServiceTokens),
"valid_tokens": validCount,
"expired_tokens": expiredCount,
"total_entries": len(globalServiceTokens),
"valid_tokens": validCount,
"expired_tokens": expiredCount,
}
}
+1 -1
View File
@@ -11,8 +11,8 @@ import (
"strconv"
"testing"
"atcr.io/pkg/hold/pds"
"atcr.io/pkg/atproto"
"atcr.io/pkg/hold/pds"
"atcr.io/pkg/s3"
"github.com/distribution/distribution/v3/registry/storage/driver/factory"
_ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"