Files

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
}