A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
72
fork

Configure Feed

Select the types of activity you want to include in your feed.

add middleware logging to xprc requests in hold. add tangled profile creation

+516 -7
+4
cmd/hold/main.go
··· 17 17 _ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws" 18 18 19 19 "github.com/go-chi/chi/v5" 20 + "github.com/go-chi/chi/v5/middleware" 20 21 ) 21 22 22 23 func main() { ··· 91 92 92 93 // Setup HTTP routes with chi router 93 94 r := chi.NewRouter() 95 + 96 + // Add logging middleware to log all HTTP requests 97 + r.Use(middleware.Logger) 94 98 95 99 // Root page 96 100 r.Get("/", func(w http.ResponseWriter, r *http.Request) {
+421
pkg/atproto/cbor_gen.go
··· 612 612 613 613 return nil 614 614 } 615 + func (t *TangledProfileRecord) MarshalCBOR(w io.Writer) error { 616 + if t == nil { 617 + _, err := w.Write(cbg.CborNull) 618 + return err 619 + } 620 + 621 + cw := cbg.NewCborWriter(w) 622 + 623 + if _, err := cw.Write([]byte{167}); err != nil { 624 + return err 625 + } 626 + 627 + // t.Type (string) (string) 628 + if len("$type") > 8192 { 629 + return xerrors.Errorf("Value in field \"$type\" was too long") 630 + } 631 + 632 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 633 + return err 634 + } 635 + if _, err := cw.WriteString(string("$type")); err != nil { 636 + return err 637 + } 638 + 639 + if len(t.Type) > 8192 { 640 + return xerrors.Errorf("Value in field t.Type was too long") 641 + } 642 + 643 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Type))); err != nil { 644 + return err 645 + } 646 + if _, err := cw.WriteString(string(t.Type)); err != nil { 647 + return err 648 + } 649 + 650 + // t.Links ([]string) (slice) 651 + if len("links") > 8192 { 652 + return xerrors.Errorf("Value in field \"links\" was too long") 653 + } 654 + 655 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("links"))); err != nil { 656 + return err 657 + } 658 + if _, err := cw.WriteString(string("links")); err != nil { 659 + return err 660 + } 661 + 662 + if len(t.Links) > 8192 { 663 + return xerrors.Errorf("Slice value in field t.Links was too long") 664 + } 665 + 666 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Links))); err != nil { 667 + return err 668 + } 669 + for _, v := range t.Links { 670 + if len(v) > 8192 { 671 + return xerrors.Errorf("Value in field v was too long") 672 + } 673 + 674 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 675 + return err 676 + } 677 + if _, err := cw.WriteString(string(v)); err != nil { 678 + return err 679 + } 680 + 681 + } 682 + 683 + // t.Stats ([]string) (slice) 684 + if len("stats") > 8192 { 685 + return xerrors.Errorf("Value in field \"stats\" was too long") 686 + } 687 + 688 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("stats"))); err != nil { 689 + return err 690 + } 691 + if _, err := cw.WriteString(string("stats")); err != nil { 692 + return err 693 + } 694 + 695 + if len(t.Stats) > 8192 { 696 + return xerrors.Errorf("Slice value in field t.Stats was too long") 697 + } 698 + 699 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Stats))); err != nil { 700 + return err 701 + } 702 + for _, v := range t.Stats { 703 + if len(v) > 8192 { 704 + return xerrors.Errorf("Value in field v was too long") 705 + } 706 + 707 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 708 + return err 709 + } 710 + if _, err := cw.WriteString(string(v)); err != nil { 711 + return err 712 + } 713 + 714 + } 715 + 716 + // t.Bluesky (bool) (bool) 717 + if len("bluesky") > 8192 { 718 + return xerrors.Errorf("Value in field \"bluesky\" was too long") 719 + } 720 + 721 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("bluesky"))); err != nil { 722 + return err 723 + } 724 + if _, err := cw.WriteString(string("bluesky")); err != nil { 725 + return err 726 + } 727 + 728 + if err := cbg.WriteBool(w, t.Bluesky); err != nil { 729 + return err 730 + } 731 + 732 + // t.Location (string) (string) 733 + if len("location") > 8192 { 734 + return xerrors.Errorf("Value in field \"location\" was too long") 735 + } 736 + 737 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("location"))); err != nil { 738 + return err 739 + } 740 + if _, err := cw.WriteString(string("location")); err != nil { 741 + return err 742 + } 743 + 744 + if len(t.Location) > 8192 { 745 + return xerrors.Errorf("Value in field t.Location was too long") 746 + } 747 + 748 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Location))); err != nil { 749 + return err 750 + } 751 + if _, err := cw.WriteString(string(t.Location)); err != nil { 752 + return err 753 + } 754 + 755 + // t.Description (string) (string) 756 + if len("description") > 8192 { 757 + return xerrors.Errorf("Value in field \"description\" was too long") 758 + } 759 + 760 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { 761 + return err 762 + } 763 + if _, err := cw.WriteString(string("description")); err != nil { 764 + return err 765 + } 766 + 767 + if len(t.Description) > 8192 { 768 + return xerrors.Errorf("Value in field t.Description was too long") 769 + } 770 + 771 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil { 772 + return err 773 + } 774 + if _, err := cw.WriteString(string(t.Description)); err != nil { 775 + return err 776 + } 777 + 778 + // t.PinnedRepositories ([]string) (slice) 779 + if len("pinnedRepositories") > 8192 { 780 + return xerrors.Errorf("Value in field \"pinnedRepositories\" was too long") 781 + } 782 + 783 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pinnedRepositories"))); err != nil { 784 + return err 785 + } 786 + if _, err := cw.WriteString(string("pinnedRepositories")); err != nil { 787 + return err 788 + } 789 + 790 + if len(t.PinnedRepositories) > 8192 { 791 + return xerrors.Errorf("Slice value in field t.PinnedRepositories was too long") 792 + } 793 + 794 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.PinnedRepositories))); err != nil { 795 + return err 796 + } 797 + for _, v := range t.PinnedRepositories { 798 + if len(v) > 8192 { 799 + return xerrors.Errorf("Value in field v was too long") 800 + } 801 + 802 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 803 + return err 804 + } 805 + if _, err := cw.WriteString(string(v)); err != nil { 806 + return err 807 + } 808 + 809 + } 810 + return nil 811 + } 812 + 813 + func (t *TangledProfileRecord) UnmarshalCBOR(r io.Reader) (err error) { 814 + *t = TangledProfileRecord{} 815 + 816 + cr := cbg.NewCborReader(r) 817 + 818 + maj, extra, err := cr.ReadHeader() 819 + if err != nil { 820 + return err 821 + } 822 + defer func() { 823 + if err == io.EOF { 824 + err = io.ErrUnexpectedEOF 825 + } 826 + }() 827 + 828 + if maj != cbg.MajMap { 829 + return fmt.Errorf("cbor input should be of type map") 830 + } 831 + 832 + if extra > cbg.MaxLength { 833 + return fmt.Errorf("TangledProfileRecord: map struct too large (%d)", extra) 834 + } 835 + 836 + n := extra 837 + 838 + nameBuf := make([]byte, 18) 839 + for i := uint64(0); i < n; i++ { 840 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 8192) 841 + if err != nil { 842 + return err 843 + } 844 + 845 + if !ok { 846 + // Field doesn't exist on this type, so ignore it 847 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 848 + return err 849 + } 850 + continue 851 + } 852 + 853 + switch string(nameBuf[:nameLen]) { 854 + // t.Type (string) (string) 855 + case "$type": 856 + 857 + { 858 + sval, err := cbg.ReadStringWithMax(cr, 8192) 859 + if err != nil { 860 + return err 861 + } 862 + 863 + t.Type = string(sval) 864 + } 865 + // t.Links ([]string) (slice) 866 + case "links": 867 + 868 + maj, extra, err = cr.ReadHeader() 869 + if err != nil { 870 + return err 871 + } 872 + 873 + if extra > 8192 { 874 + return fmt.Errorf("t.Links: array too large (%d)", extra) 875 + } 876 + 877 + if maj != cbg.MajArray { 878 + return fmt.Errorf("expected cbor array") 879 + } 880 + 881 + if extra > 0 { 882 + t.Links = make([]string, extra) 883 + } 884 + 885 + for i := 0; i < int(extra); i++ { 886 + { 887 + var maj byte 888 + var extra uint64 889 + var err error 890 + _ = maj 891 + _ = extra 892 + _ = err 893 + 894 + { 895 + sval, err := cbg.ReadStringWithMax(cr, 8192) 896 + if err != nil { 897 + return err 898 + } 899 + 900 + t.Links[i] = string(sval) 901 + } 902 + 903 + } 904 + } 905 + // t.Stats ([]string) (slice) 906 + case "stats": 907 + 908 + maj, extra, err = cr.ReadHeader() 909 + if err != nil { 910 + return err 911 + } 912 + 913 + if extra > 8192 { 914 + return fmt.Errorf("t.Stats: array too large (%d)", extra) 915 + } 916 + 917 + if maj != cbg.MajArray { 918 + return fmt.Errorf("expected cbor array") 919 + } 920 + 921 + if extra > 0 { 922 + t.Stats = make([]string, extra) 923 + } 924 + 925 + for i := 0; i < int(extra); i++ { 926 + { 927 + var maj byte 928 + var extra uint64 929 + var err error 930 + _ = maj 931 + _ = extra 932 + _ = err 933 + 934 + { 935 + sval, err := cbg.ReadStringWithMax(cr, 8192) 936 + if err != nil { 937 + return err 938 + } 939 + 940 + t.Stats[i] = string(sval) 941 + } 942 + 943 + } 944 + } 945 + // t.Bluesky (bool) (bool) 946 + case "bluesky": 947 + 948 + maj, extra, err = cr.ReadHeader() 949 + if err != nil { 950 + return err 951 + } 952 + if maj != cbg.MajOther { 953 + return fmt.Errorf("booleans must be major type 7") 954 + } 955 + switch extra { 956 + case 20: 957 + t.Bluesky = false 958 + case 21: 959 + t.Bluesky = true 960 + default: 961 + return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 962 + } 963 + // t.Location (string) (string) 964 + case "location": 965 + 966 + { 967 + sval, err := cbg.ReadStringWithMax(cr, 8192) 968 + if err != nil { 969 + return err 970 + } 971 + 972 + t.Location = string(sval) 973 + } 974 + // t.Description (string) (string) 975 + case "description": 976 + 977 + { 978 + sval, err := cbg.ReadStringWithMax(cr, 8192) 979 + if err != nil { 980 + return err 981 + } 982 + 983 + t.Description = string(sval) 984 + } 985 + // t.PinnedRepositories ([]string) (slice) 986 + case "pinnedRepositories": 987 + 988 + maj, extra, err = cr.ReadHeader() 989 + if err != nil { 990 + return err 991 + } 992 + 993 + if extra > 8192 { 994 + return fmt.Errorf("t.PinnedRepositories: array too large (%d)", extra) 995 + } 996 + 997 + if maj != cbg.MajArray { 998 + return fmt.Errorf("expected cbor array") 999 + } 1000 + 1001 + if extra > 0 { 1002 + t.PinnedRepositories = make([]string, extra) 1003 + } 1004 + 1005 + for i := 0; i < int(extra); i++ { 1006 + { 1007 + var maj byte 1008 + var extra uint64 1009 + var err error 1010 + _ = maj 1011 + _ = extra 1012 + _ = err 1013 + 1014 + { 1015 + sval, err := cbg.ReadStringWithMax(cr, 8192) 1016 + if err != nil { 1017 + return err 1018 + } 1019 + 1020 + t.PinnedRepositories[i] = string(sval) 1021 + } 1022 + 1023 + } 1024 + } 1025 + 1026 + default: 1027 + // Field doesn't exist on this type, so ignore it 1028 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1029 + return err 1030 + } 1031 + } 1032 + } 1033 + 1034 + return nil 1035 + }
+2 -1
pkg/atproto/generate.go
··· 25 25 ) 26 26 27 27 func main() { 28 - // Generate map-style encoders for CrewRecord and CaptainRecord 28 + // Generate map-style encoders for CrewRecord, CaptainRecord, and TangledProfileRecord 29 29 if err := cbg.WriteMapEncodersToFile("cbor_gen.go", "atproto", 30 30 atproto.CrewRecord{}, 31 31 atproto.CaptainRecord{}, 32 + atproto.TangledProfileRecord{}, 32 33 ); err != nil { 33 34 fmt.Printf("Failed to generate CBOR encoders: %v\n", err) 34 35 os.Exit(1)
+18
pkg/atproto/lexicon.go
··· 34 34 // Note: Uses same collection name as HoldCrewCollection but stored in different PDS (hold's PDS vs owner's PDS) 35 35 CrewCollection = "io.atcr.hold.crew" 36 36 37 + // TangledProfileCollection is the collection name for tangled profiles 38 + // Stored in hold's embedded PDS (singleton record at rkey "self") 39 + TangledProfileCollection = "sh.tangled.actor.profile" 40 + 37 41 // SailorProfileCollection is the collection name for user profiles 38 42 SailorProfileCollection = "io.atcr.sailor.profile" 39 43 ··· 515 519 Permissions []string `json:"permissions" cborgen:"permissions"` 516 520 AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp 517 521 } 522 + 523 + // TangledProfileRecord represents a Tangled profile for the hold 524 + // Collection: sh.tangled.actor.profile (singleton record at rkey "self") 525 + // Stored in the hold's embedded PDS 526 + // Uses CBOR encoding for efficient storage in hold's carstore 527 + type TangledProfileRecord struct { 528 + Type string `json:"$type" cborgen:"$type"` 529 + Links []string `json:"links" cborgen:"links"` 530 + Stats []string `json:"stats" cborgen:"stats"` 531 + Bluesky bool `json:"bluesky" cborgen:"bluesky"` 532 + Location string `json:"location" cborgen:"location"` 533 + Description string `json:"description" cborgen:"description"` 534 + PinnedRepositories []string `json:"pinnedRepositories" cborgen:"pinnedRepositories"` 535 + }
+48
pkg/hold/pds/profile.go
··· 9 9 "net/http" 10 10 "time" 11 11 12 + "atcr.io/pkg/atproto" 12 13 bsky "github.com/bluesky-social/indigo/api/bsky" 13 14 lexutil "github.com/bluesky-social/indigo/lex/util" 14 15 "github.com/distribution/distribution/v3/registry/storage/driver" ··· 22 23 23 24 // ProfileCollection is the collection name for Bluesky actor profiles 24 25 ProfileCollection = "app.bsky.actor.profile" 26 + 27 + // TangledProfileRkey is the fixed rkey for the tangled profile record (singleton) 28 + TangledProfileRkey = "self" 29 + 30 + // TangledProfileCollection is the collection name for Tangled actor profiles 31 + TangledProfileCollection = "sh.tangled.actor.profile" 25 32 ) 26 33 27 34 // downloadImage downloads an image from a URL and returns the data and content type ··· 172 179 173 180 return recordCID, profileRecord, nil 174 181 } 182 + 183 + // CreateTangledProfileRecord creates the sh.tangled.actor.profile record for the hold 184 + // This will FAIL if the tangled profile record already exists. 185 + func (p *HoldPDS) CreateTangledProfileRecord(ctx context.Context, links []string, description string) (cid.Cid, error) { 186 + // Create tangled profile struct 187 + profile := &atproto.TangledProfileRecord{ 188 + Type: atproto.TangledProfileCollection, 189 + Links: links, 190 + Stats: []string{}, // Empty for now 191 + Bluesky: true, 192 + Location: "", 193 + Description: description, 194 + PinnedRepositories: []string{}, // Empty for now 195 + } 196 + 197 + // Use repomgr.PutRecord - creates with explicit rkey, fails if already exists 198 + recordPath, recordCID, err := p.repomgr.PutRecord(ctx, p.uid, TangledProfileCollection, TangledProfileRkey, profile) 199 + if err != nil { 200 + return cid.Undef, fmt.Errorf("failed to create tangled profile record: %w", err) 201 + } 202 + 203 + fmt.Printf("Created tangled profile record at %s, cid: %s\n", recordPath, recordCID) 204 + return recordCID, nil 205 + } 206 + 207 + // GetTangledProfileRecord retrieves the sh.tangled.actor.profile record 208 + func (p *HoldPDS) GetTangledProfileRecord(ctx context.Context) (cid.Cid, *atproto.TangledProfileRecord, error) { 209 + // Use repomgr.GetRecord 210 + recordCID, val, err := p.repomgr.GetRecord(ctx, p.uid, TangledProfileCollection, TangledProfileRkey, cid.Undef) 211 + if err != nil { 212 + return cid.Undef, nil, fmt.Errorf("failed to get tangled profile record: %w", err) 213 + } 214 + 215 + // Type assert to TangledProfileRecord 216 + profileRecord, ok := val.(*atproto.TangledProfileRecord) 217 + if !ok { 218 + return cid.Undef, nil, fmt.Errorf("unexpected type for tangled profile record: %T", val) 219 + } 220 + 221 + return recordCID, profileRecord, nil 222 + }
+23 -6
pkg/hold/pds/server.go
··· 20 20 // init registers our custom ATProto types with indigo's lexutil type registry 21 21 // This allows repomgr.GetRecord to automatically unmarshal our types 22 22 func init() { 23 - // Register captain and crew record types 23 + // Register captain, crew, and tangled profile record types 24 24 // These must match the $type field in the records 25 25 lexutil.RegisterType(atproto.CaptainCollection, &atproto.CaptainRecord{}) 26 26 lexutil.RegisterType(atproto.CrewCollection, &atproto.CrewRecord{}) 27 + lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{}) 27 28 } 28 29 29 30 // HoldPDS is a minimal ATProto PDS implementation for a hold service ··· 157 158 fmt.Printf("✅ Added %s as hold admin\n", ownerDID) 158 159 } 159 160 160 - // Create profile record (idempotent - check if exists first) 161 + // Create Bluesky profile record (idempotent - check if exists first) 161 162 // This runs even if captain exists (for existing holds being upgraded) 162 163 // Skip if no storage driver (e.g., in tests) 163 164 if storageDriver != nil { 164 165 _, _, err = p.GetProfileRecord(ctx) 165 166 if err != nil { 166 - // Profile doesn't exist, create it 167 + // Bluesky profile doesn't exist, create it 167 168 displayName := "Cargo Hold" 168 169 description := "ahoy from the cargo hold" 169 170 170 171 _, err = p.CreateProfileRecord(ctx, storageDriver, displayName, description, avatarURL) 171 172 if err != nil { 172 - return fmt.Errorf("failed to create profile record: %w", err) 173 + return fmt.Errorf("failed to create bluesky profile record: %w", err) 173 174 } 174 - fmt.Printf("✅ Created profile record (displayName=%s)\n", displayName) 175 + fmt.Printf("✅ Created Bluesky profile record (displayName=%s)\n", displayName) 175 176 } else { 176 - fmt.Printf("✅ Profile record already exists, skipping\n") 177 + fmt.Printf("✅ Bluesky profile record already exists, skipping\n") 178 + } 179 + 180 + // Create Tangled profile record (idempotent - check if exists first) 181 + _, _, err = p.GetTangledProfileRecord(ctx) 182 + if err != nil { 183 + // Tangled profile doesn't exist, create it 184 + description := "ahoy from the cargo hold" 185 + links := []string{"https://atcr.io"} 186 + 187 + _, err = p.CreateTangledProfileRecord(ctx, links, description) 188 + if err != nil { 189 + return fmt.Errorf("failed to create tangled profile record: %w", err) 190 + } 191 + fmt.Printf("✅ Created Tangled profile record\n") 192 + } else { 193 + fmt.Printf("✅ Tangled profile record already exists, skipping\n") 177 194 } 178 195 } 179 196