Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 186 lines 4.4 kB view raw
1package xrpc 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log" 8 "net/http" 9 "regexp" 10 "strings" 11 "time" 12 13 "margin.at/internal/config" 14 "margin.at/internal/slingshot" 15) 16 17var SlingshotClient = slingshot.NewClient() 18 19var ( 20 didPattern = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]+$`) 21 nsidPattern = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9]*(\.[a-zA-Z][a-zA-Z0-9]*)+$`) 22 rkeyPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) 23) 24 25type ATURI struct { 26 DID string 27 Collection string 28 RKey string 29} 30 31func ParseATURI(uri string) (*ATURI, error) { 32 if !strings.HasPrefix(uri, "at://") { 33 return nil, fmt.Errorf("invalid AT-URI: must start with at://") 34 } 35 36 path := strings.TrimPrefix(uri, "at://") 37 parts := strings.Split(path, "/") 38 39 if len(parts) < 1 || parts[0] == "" { 40 return nil, fmt.Errorf("invalid AT-URI: missing DID authority") 41 } 42 43 did := parts[0] 44 if !didPattern.MatchString(did) { 45 return nil, fmt.Errorf("invalid AT-URI: malformed DID %q", did) 46 } 47 48 result := &ATURI{DID: did} 49 50 if len(parts) >= 2 && parts[1] != "" { 51 collection := parts[1] 52 if !nsidPattern.MatchString(collection) { 53 return nil, fmt.Errorf("invalid AT-URI: malformed collection NSID %q", collection) 54 } 55 result.Collection = collection 56 } 57 58 if len(parts) >= 3 && parts[2] != "" { 59 rkey := parts[2] 60 if !rkeyPattern.MatchString(rkey) || strings.HasPrefix(rkey, ".") || strings.HasSuffix(rkey, ".") { 61 return nil, fmt.Errorf("invalid AT-URI: malformed record key %q", rkey) 62 } 63 if len(rkey) > 512 { 64 return nil, fmt.Errorf("invalid AT-URI: record key too long (max 512)") 65 } 66 result.RKey = rkey 67 } 68 69 if len(parts) > 3 { 70 return nil, fmt.Errorf("invalid AT-URI: too many path segments") 71 } 72 73 return result, nil 74} 75 76func (a *ATURI) String() string { 77 if a.Collection == "" { 78 return fmt.Sprintf("at://%s", a.DID) 79 } 80 if a.RKey == "" { 81 return fmt.Sprintf("at://%s/%s", a.DID, a.Collection) 82 } 83 return fmt.Sprintf("at://%s/%s/%s", a.DID, a.Collection, a.RKey) 84} 85 86func init() { 87 log.Printf("Slingshot client initialized: %s", slingshot.DefaultBaseURL) 88} 89 90func ResolveDIDToPDS(did string) (string, error) { 91 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 92 defer cancel() 93 94 if pds, err := SlingshotClient.ResolveDID(ctx, did); err == nil && pds != "" { 95 return pds, nil 96 } 97 98 return resolveDIDToPDSDirect(did) 99} 100 101func resolveDIDToPDSDirect(did string) (string, error) { 102 var docURL string 103 if strings.HasPrefix(did, "did:plc:") { 104 docURL = config.Get().PLCResolveURL(did) 105 } else if strings.HasPrefix(did, "did:web:") { 106 domain := strings.TrimPrefix(did, "did:web:") 107 docURL = fmt.Sprintf("https://%s/.well-known/did.json", domain) 108 } else { 109 return "", nil 110 } 111 112 client := &http.Client{ 113 Timeout: 10 * time.Second, 114 } 115 resp, err := client.Get(docURL) 116 if err != nil { 117 return "", err 118 } 119 defer resp.Body.Close() 120 121 if resp.StatusCode != 200 { 122 return "", fmt.Errorf("failed to fetch DID doc: %d", resp.StatusCode) 123 } 124 125 var doc struct { 126 Service []struct { 127 ID string `json:"id"` 128 Type string `json:"type"` 129 ServiceEndpoint string `json:"serviceEndpoint"` 130 } `json:"service"` 131 } 132 if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { 133 return "", err 134 } 135 136 for _, svc := range doc.Service { 137 if svc.ID == "#atproto_pds" && svc.Type == "AtprotoPersonalDataServer" { 138 return svc.ServiceEndpoint, nil 139 } 140 } 141 for _, svc := range doc.Service { 142 if svc.Type == "AtprotoPersonalDataServer" { 143 return svc.ServiceEndpoint, nil 144 } 145 } 146 return "", nil 147} 148 149func ResolveHandle(handle string) (string, error) { 150 if strings.HasPrefix(handle, "did:") { 151 return handle, nil 152 } 153 154 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 155 defer cancel() 156 157 if did, err := SlingshotClient.ResolveHandle(ctx, handle); err == nil && did != "" { 158 return did, nil 159 } 160 161 return resolveHandleDirect(handle) 162} 163 164func resolveHandleDirect(handle string) (string, error) { 165 url := config.Get().BskyResolveHandleURL(handle) 166 client := &http.Client{ 167 Timeout: 5 * time.Second, 168 } 169 resp, err := client.Get(url) 170 if err != nil { 171 return "", err 172 } 173 defer resp.Body.Close() 174 175 if resp.StatusCode != 200 { 176 return "", fmt.Errorf("failed to resolve handle: %d", resp.StatusCode) 177 } 178 179 var result struct { 180 DID string `json:"did"` 181 } 182 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 183 return "", err 184 } 185 return result.DID, nil 186}