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