From 0e4dd9af203a3c18dd6ded9d8f53579067dc03b4 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Wed, 22 Oct 2025 17:06:10 -0500 Subject: [PATCH] add middleware logging to xprc requests in hold. add tangled profile creation --- cmd/hold/main.go | 4 + pkg/atproto/cbor_gen.go | 421 ++++++++++++++++++++++++++++++++++++++++ pkg/atproto/generate.go | 3 +- pkg/atproto/lexicon.go | 18 ++ pkg/hold/pds/profile.go | 48 +++++ pkg/hold/pds/server.go | 29 ++- 6 files changed, 516 insertions(+), 7 deletions(-) diff --git a/cmd/hold/main.go b/cmd/hold/main.go index 94cee71..adc3b11 100644 --- a/cmd/hold/main.go +++ b/cmd/hold/main.go @@ -17,6 +17,7 @@ import ( _ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws" "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" ) func main() { @@ -92,6 +93,9 @@ func main() { // Setup HTTP routes with chi router r := chi.NewRouter() + // Add logging middleware to log all HTTP requests + r.Use(middleware.Logger) + // Root page r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") diff --git a/pkg/atproto/cbor_gen.go b/pkg/atproto/cbor_gen.go index 5650fba..16366d4 100644 --- a/pkg/atproto/cbor_gen.go +++ b/pkg/atproto/cbor_gen.go @@ -612,3 +612,424 @@ func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) { return nil } +func (t *TangledProfileRecord) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + + if _, err := cw.Write([]byte{167}); err != nil { + return err + } + + // t.Type (string) (string) + if len("$type") > 8192 { + return xerrors.Errorf("Value in field \"$type\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { + return err + } + if _, err := cw.WriteString(string("$type")); err != nil { + return err + } + + if len(t.Type) > 8192 { + return xerrors.Errorf("Value in field t.Type was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Type)); err != nil { + return err + } + + // t.Links ([]string) (slice) + if len("links") > 8192 { + return xerrors.Errorf("Value in field \"links\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("links"))); err != nil { + return err + } + if _, err := cw.WriteString(string("links")); err != nil { + return err + } + + if len(t.Links) > 8192 { + return xerrors.Errorf("Slice value in field t.Links was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Links))); err != nil { + return err + } + for _, v := range t.Links { + if len(v) > 8192 { + return xerrors.Errorf("Value in field v was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + if _, err := cw.WriteString(string(v)); err != nil { + return err + } + + } + + // t.Stats ([]string) (slice) + if len("stats") > 8192 { + return xerrors.Errorf("Value in field \"stats\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("stats"))); err != nil { + return err + } + if _, err := cw.WriteString(string("stats")); err != nil { + return err + } + + if len(t.Stats) > 8192 { + return xerrors.Errorf("Slice value in field t.Stats was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Stats))); err != nil { + return err + } + for _, v := range t.Stats { + if len(v) > 8192 { + return xerrors.Errorf("Value in field v was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + if _, err := cw.WriteString(string(v)); err != nil { + return err + } + + } + + // t.Bluesky (bool) (bool) + if len("bluesky") > 8192 { + return xerrors.Errorf("Value in field \"bluesky\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("bluesky"))); err != nil { + return err + } + if _, err := cw.WriteString(string("bluesky")); err != nil { + return err + } + + if err := cbg.WriteBool(w, t.Bluesky); err != nil { + return err + } + + // t.Location (string) (string) + if len("location") > 8192 { + return xerrors.Errorf("Value in field \"location\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("location"))); err != nil { + return err + } + if _, err := cw.WriteString(string("location")); err != nil { + return err + } + + if len(t.Location) > 8192 { + return xerrors.Errorf("Value in field t.Location was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Location))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Location)); err != nil { + return err + } + + // t.Description (string) (string) + if len("description") > 8192 { + return xerrors.Errorf("Value in field \"description\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { + return err + } + if _, err := cw.WriteString(string("description")); err != nil { + return err + } + + if len(t.Description) > 8192 { + return xerrors.Errorf("Value in field t.Description was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil { + return err + } + if _, err := cw.WriteString(string(t.Description)); err != nil { + return err + } + + // t.PinnedRepositories ([]string) (slice) + if len("pinnedRepositories") > 8192 { + return xerrors.Errorf("Value in field \"pinnedRepositories\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pinnedRepositories"))); err != nil { + return err + } + if _, err := cw.WriteString(string("pinnedRepositories")); err != nil { + return err + } + + if len(t.PinnedRepositories) > 8192 { + return xerrors.Errorf("Slice value in field t.PinnedRepositories was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.PinnedRepositories))); err != nil { + return err + } + for _, v := range t.PinnedRepositories { + if len(v) > 8192 { + return xerrors.Errorf("Value in field v was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + if _, err := cw.WriteString(string(v)); err != nil { + return err + } + + } + return nil +} + +func (t *TangledProfileRecord) UnmarshalCBOR(r io.Reader) (err error) { + *t = TangledProfileRecord{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("TangledProfileRecord: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 18) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.Type (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + t.Type = string(sval) + } + // t.Links ([]string) (slice) + case "links": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Links: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Links = make([]string, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + t.Links[i] = string(sval) + } + + } + } + // t.Stats ([]string) (slice) + case "stats": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Stats: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Stats = make([]string, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + t.Stats[i] = string(sval) + } + + } + } + // t.Bluesky (bool) (bool) + case "bluesky": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + if maj != cbg.MajOther { + return fmt.Errorf("booleans must be major type 7") + } + switch extra { + case 20: + t.Bluesky = false + case 21: + t.Bluesky = true + default: + return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) + } + // t.Location (string) (string) + case "location": + + { + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + t.Location = string(sval) + } + // t.Description (string) (string) + case "description": + + { + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + t.Description = string(sval) + } + // t.PinnedRepositories ([]string) (slice) + case "pinnedRepositories": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.PinnedRepositories: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.PinnedRepositories = make([]string, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + sval, err := cbg.ReadStringWithMax(cr, 8192) + if err != nil { + return err + } + + t.PinnedRepositories[i] = string(sval) + } + + } + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} diff --git a/pkg/atproto/generate.go b/pkg/atproto/generate.go index 08227ce..3428e73 100644 --- a/pkg/atproto/generate.go +++ b/pkg/atproto/generate.go @@ -25,10 +25,11 @@ import ( ) func main() { - // Generate map-style encoders for CrewRecord and CaptainRecord + // Generate map-style encoders for CrewRecord, CaptainRecord, and TangledProfileRecord if err := cbg.WriteMapEncodersToFile("cbor_gen.go", "atproto", atproto.CrewRecord{}, atproto.CaptainRecord{}, + atproto.TangledProfileRecord{}, ); err != nil { fmt.Printf("Failed to generate CBOR encoders: %v\n", err) os.Exit(1) diff --git a/pkg/atproto/lexicon.go b/pkg/atproto/lexicon.go index 40a46bf..ed7f3b5 100644 --- a/pkg/atproto/lexicon.go +++ b/pkg/atproto/lexicon.go @@ -34,6 +34,10 @@ const ( // Note: Uses same collection name as HoldCrewCollection but stored in different PDS (hold's PDS vs owner's PDS) CrewCollection = "io.atcr.hold.crew" + // TangledProfileCollection is the collection name for tangled profiles + // Stored in hold's embedded PDS (singleton record at rkey "self") + TangledProfileCollection = "sh.tangled.actor.profile" + // SailorProfileCollection is the collection name for user profiles SailorProfileCollection = "io.atcr.sailor.profile" @@ -515,3 +519,17 @@ type CrewRecord struct { Permissions []string `json:"permissions" cborgen:"permissions"` AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp } + +// TangledProfileRecord represents a Tangled profile for the hold +// Collection: sh.tangled.actor.profile (singleton record at rkey "self") +// Stored in the hold's embedded PDS +// Uses CBOR encoding for efficient storage in hold's carstore +type TangledProfileRecord struct { + Type string `json:"$type" cborgen:"$type"` + Links []string `json:"links" cborgen:"links"` + Stats []string `json:"stats" cborgen:"stats"` + Bluesky bool `json:"bluesky" cborgen:"bluesky"` + Location string `json:"location" cborgen:"location"` + Description string `json:"description" cborgen:"description"` + PinnedRepositories []string `json:"pinnedRepositories" cborgen:"pinnedRepositories"` +} diff --git a/pkg/hold/pds/profile.go b/pkg/hold/pds/profile.go index 16411a6..f8cd4a6 100644 --- a/pkg/hold/pds/profile.go +++ b/pkg/hold/pds/profile.go @@ -9,6 +9,7 @@ import ( "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" @@ -22,6 +23,12 @@ const ( // 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 @@ -172,3 +179,44 @@ func (p *HoldPDS) GetProfileRecord(ctx context.Context) (cid.Cid, *bsky.ActorPro 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) + } + + fmt.Printf("Created tangled profile record at %s, cid: %s\n", recordPath, recordCID) + 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 +} diff --git a/pkg/hold/pds/server.go b/pkg/hold/pds/server.go index a380fff..cf7b72b 100644 --- a/pkg/hold/pds/server.go +++ b/pkg/hold/pds/server.go @@ -20,10 +20,11 @@ import ( // init registers our custom ATProto types with indigo's lexutil type registry // This allows repomgr.GetRecord to automatically unmarshal our types func init() { - // Register captain and crew record types + // Register captain, crew, and tangled profile record types // These must match the $type field in the records lexutil.RegisterType(atproto.CaptainCollection, &atproto.CaptainRecord{}) lexutil.RegisterType(atproto.CrewCollection, &atproto.CrewRecord{}) + lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{}) } // HoldPDS is a minimal ATProto PDS implementation for a hold service @@ -157,23 +158,39 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDri fmt.Printf("✅ Added %s as hold admin\n", ownerDID) } - // Create profile record (idempotent - check if exists first) + // Create Bluesky profile record (idempotent - check if exists first) // This runs even if captain exists (for existing holds being upgraded) // Skip if no storage driver (e.g., in tests) if storageDriver != nil { _, _, err = p.GetProfileRecord(ctx) if err != nil { - // Profile doesn't exist, create it + // Bluesky profile doesn't exist, create it displayName := "Cargo Hold" description := "ahoy from the cargo hold" _, err = p.CreateProfileRecord(ctx, storageDriver, displayName, description, avatarURL) if err != nil { - return fmt.Errorf("failed to create profile record: %w", err) + return fmt.Errorf("failed to create bluesky profile record: %w", err) } - fmt.Printf("✅ Created profile record (displayName=%s)\n", displayName) + fmt.Printf("✅ Created Bluesky profile record (displayName=%s)\n", displayName) } else { - fmt.Printf("✅ Profile record already exists, skipping\n") + fmt.Printf("✅ Bluesky profile record already exists, skipping\n") + } + + // Create Tangled profile record (idempotent - check if exists first) + _, _, err = p.GetTangledProfileRecord(ctx) + if err != nil { + // Tangled profile doesn't exist, create it + description := "ahoy from the cargo hold" + links := []string{"https://atcr.io"} + + _, err = p.CreateTangledProfileRecord(ctx, links, description) + if err != nil { + return fmt.Errorf("failed to create tangled profile record: %w", err) + } + fmt.Printf("✅ Created Tangled profile record\n") + } else { + fmt.Printf("✅ Tangled profile record already exists, skipping\n") } }