mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 00:34:16 +00:00
230 lines
7.1 KiB
Go
230 lines
7.1 KiB
Go
package pds
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
bsky "github.com/bluesky-social/indigo/api/bsky"
|
|
lexutil "github.com/bluesky-social/indigo/lex/util"
|
|
"github.com/distribution/distribution/v3/registry/storage/driver"
|
|
"github.com/ipfs/go-cid"
|
|
"github.com/multiformats/go-multihash"
|
|
)
|
|
|
|
const (
|
|
// ProfileRkey is the fixed rkey for the profile record (singleton)
|
|
ProfileRkey = "self"
|
|
|
|
// ProfileCollection is the collection name for Bluesky actor profiles
|
|
ProfileCollection = "app.bsky.actor.profile"
|
|
|
|
// TangledProfileRkey is the fixed rkey for the tangled profile record (singleton)
|
|
TangledProfileRkey = "self"
|
|
|
|
// TangledProfileCollection is the collection name for Tangled actor profiles
|
|
TangledProfileCollection = "sh.tangled.actor.profile"
|
|
)
|
|
|
|
// downloadImage downloads an image from a URL and returns the data and content type
|
|
func downloadImage(ctx context.Context, url string) ([]byte, string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
client := &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to download image: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, "", fmt.Errorf("download failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
// Read image data
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to read image data: %w", err)
|
|
}
|
|
|
|
// Get content type from response header
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
|
|
return data, contentType, nil
|
|
}
|
|
|
|
// uploadBlobToStorage uploads a blob to the hold's storage and returns a blob reference
|
|
// This stores the blob at the ATProto path for the hold's DID
|
|
func uploadBlobToStorage(ctx context.Context, storageDriver driver.StorageDriver, did string, data []byte, mimeType string) (*lexutil.LexBlob, error) {
|
|
if len(data) == 0 {
|
|
return nil, fmt.Errorf("empty blob data")
|
|
}
|
|
|
|
size := int64(len(data))
|
|
|
|
// Compute SHA-256 hash
|
|
hash := sha256.Sum256(data)
|
|
|
|
// Create CIDv1 with SHA-256 multihash
|
|
mh, err := multihash.EncodeName(hash[:], "sha2-256")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encode multihash: %w", err)
|
|
}
|
|
|
|
// Create CIDv1 with raw codec (0x55)
|
|
// ATProto uses CIDv1 with raw codec for blobs
|
|
blobCID := cid.NewCidV1(0x55, mh)
|
|
|
|
// Store blob via distribution driver at ATProto path
|
|
path := atprotoBlobPath(did, blobCID.String())
|
|
|
|
// Write blob to storage using distribution driver
|
|
writer, err := storageDriver.Writer(ctx, path, false)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create writer: %w", err)
|
|
}
|
|
|
|
// Write data
|
|
n, err := io.Copy(writer, bytes.NewReader(data))
|
|
if err != nil {
|
|
writer.Cancel(ctx)
|
|
return nil, fmt.Errorf("failed to write blob: %w", err)
|
|
}
|
|
|
|
// Commit the write
|
|
if err := writer.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("failed to commit blob: %w", err)
|
|
}
|
|
|
|
if n != size {
|
|
return nil, fmt.Errorf("size mismatch: wrote %d bytes, expected %d", n, size)
|
|
}
|
|
|
|
// Create blob reference in the format expected by bsky.ActorProfile
|
|
// LexLink is a type alias for cid.Cid
|
|
lexLink := lexutil.LexLink(blobCID)
|
|
blob := &lexutil.LexBlob{
|
|
Ref: lexLink,
|
|
MimeType: mimeType,
|
|
Size: size,
|
|
}
|
|
|
|
return blob, nil
|
|
}
|
|
|
|
// CreateProfileRecord creates the app.bsky.actor.profile record for the hold
|
|
// This will FAIL if the profile record already exists.
|
|
func (p *HoldPDS) CreateProfileRecord(ctx context.Context, storageDriver driver.StorageDriver, displayName, description, avatarURL string) (cid.Cid, error) {
|
|
// Create profile struct
|
|
profile := &bsky.ActorProfile{
|
|
DisplayName: &displayName,
|
|
Description: &description,
|
|
}
|
|
|
|
// Download and upload avatar if URL is provided
|
|
if avatarURL != "" {
|
|
slog.Debug("Downloading avatar", "url", avatarURL)
|
|
imageData, mimeType, err := downloadImage(ctx, avatarURL)
|
|
if err != nil {
|
|
return cid.Undef, fmt.Errorf("failed to download avatar: %w", err)
|
|
}
|
|
|
|
slog.Debug("Uploading avatar blob",
|
|
"size", len(imageData),
|
|
"mimeType", mimeType)
|
|
avatarBlob, err := uploadBlobToStorage(ctx, storageDriver, p.did, imageData, mimeType)
|
|
if err != nil {
|
|
return cid.Undef, fmt.Errorf("failed to upload avatar blob: %w", err)
|
|
}
|
|
|
|
profile.Avatar = avatarBlob
|
|
slog.Info("Avatar uploaded successfully", "ref", avatarBlob.Ref.String())
|
|
}
|
|
|
|
// Use repomgr.PutRecord - creates with explicit rkey, fails if already exists
|
|
recordPath, recordCID, err := p.repomgr.PutRecord(ctx, p.uid, ProfileCollection, ProfileRkey, profile)
|
|
if err != nil {
|
|
return cid.Undef, fmt.Errorf("failed to create profile record: %w", err)
|
|
}
|
|
|
|
slog.Info("Created profile record",
|
|
"path", recordPath,
|
|
"cid", recordCID.String())
|
|
return recordCID, nil
|
|
}
|
|
|
|
// GetProfileRecord retrieves the app.bsky.actor.profile record
|
|
func (p *HoldPDS) GetProfileRecord(ctx context.Context) (cid.Cid, *bsky.ActorProfile, error) {
|
|
// Use repomgr.GetRecord
|
|
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, ProfileCollection, ProfileRkey, cid.Undef)
|
|
if err != nil {
|
|
return cid.Undef, nil, fmt.Errorf("failed to get profile record: %w", err)
|
|
}
|
|
|
|
// Type assert to bsky.ActorProfile
|
|
profileRecord, ok := val.(*bsky.ActorProfile)
|
|
if !ok {
|
|
return cid.Undef, nil, fmt.Errorf("unexpected type for profile record: %T", val)
|
|
}
|
|
|
|
return recordCID, profileRecord, nil
|
|
}
|
|
|
|
// CreateTangledProfileRecord creates the sh.tangled.actor.profile record for the hold
|
|
// This will FAIL if the tangled profile record already exists.
|
|
func (p *HoldPDS) CreateTangledProfileRecord(ctx context.Context, links []string, description string) (cid.Cid, error) {
|
|
// Create tangled profile struct
|
|
profile := &atproto.TangledProfileRecord{
|
|
Type: atproto.TangledProfileCollection,
|
|
Links: links,
|
|
Stats: []string{}, // Empty for now
|
|
Bluesky: true,
|
|
Location: "",
|
|
Description: description,
|
|
PinnedRepositories: []string{}, // Empty for now
|
|
}
|
|
|
|
// Use repomgr.PutRecord - creates with explicit rkey, fails if already exists
|
|
recordPath, recordCID, err := p.repomgr.PutRecord(ctx, p.uid, TangledProfileCollection, TangledProfileRkey, profile)
|
|
if err != nil {
|
|
return cid.Undef, fmt.Errorf("failed to create tangled profile record: %w", err)
|
|
}
|
|
|
|
slog.Info("Created tangled profile record",
|
|
"path", recordPath,
|
|
"cid", recordCID.String())
|
|
return recordCID, nil
|
|
}
|
|
|
|
// GetTangledProfileRecord retrieves the sh.tangled.actor.profile record
|
|
func (p *HoldPDS) GetTangledProfileRecord(ctx context.Context) (cid.Cid, *atproto.TangledProfileRecord, error) {
|
|
// Use repomgr.GetRecord
|
|
recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, TangledProfileCollection, TangledProfileRkey, cid.Undef)
|
|
if err != nil {
|
|
return cid.Undef, nil, fmt.Errorf("failed to get tangled profile record: %w", err)
|
|
}
|
|
|
|
// Type assert to TangledProfileRecord
|
|
profileRecord, ok := val.(*atproto.TangledProfileRecord)
|
|
if !ok {
|
|
return cid.Undef, nil, fmt.Errorf("unexpected type for tangled profile record: %T", val)
|
|
}
|
|
|
|
return recordCID, profileRecord, nil
|
|
}
|