Go boilerplate library for building atproto apps
atproto go
1
fork

Configure Feed

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

at main 330 lines 9.7 kB view raw
1package atp 2 3import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "net" 9 "net/http" 10 "net/url" 11 "slices" 12 "strings" 13 "sync" 14 "time" 15) 16 17const ( 18 // PublicAPIBase is the Bluesky public API endpoint used for profile and handle lookups. 19 PublicAPIBase = "https://public.api.bsky.app" 20 21 // PLCDirectory is used to resolve did:plc identifiers to DID documents. 22 PLCDirectory = "https://plc.directory" 23) 24 25// ErrSSRFBlocked is returned when a request is blocked due to a private/internal destination. 26var ErrSSRFBlocked = errors.New("request blocked: potential SSRF detected") 27 28// ResolveHandle is a convenience that resolves an AT Protocol handle to a DID 29// without needing to construct a PublicClient. 30func ResolveHandle(ctx context.Context, handle string) (string, error) { 31 return NewPublicClient().ResolveHandle(ctx, handle) 32} 33 34// PublicClient provides unauthenticated read access to public AT Protocol APIs. 35// Use this to resolve handles, look up profiles, and read public records without 36// requiring an OAuth session. 37type PublicClient struct { 38 httpClient *http.Client 39 pdsCache map[string]string 40 pdsCacheMu sync.RWMutex 41} 42 43// NewPublicClient creates a PublicClient with a 30-second timeout. 44// To add OTel instrumentation, use NewPublicClientWithHTTP and pass an 45// otelhttp-wrapped transport. 46func NewPublicClient() *PublicClient { 47 return NewPublicClientWithHTTP(&http.Client{ 48 Timeout: 30 * time.Second, 49 }) 50} 51 52// NewPublicClientWithHTTP creates a PublicClient using the provided http.Client. 53// This lets callers inject custom transports (e.g. with OTel or rate limiting). 54func NewPublicClientWithHTTP(hc *http.Client) *PublicClient { 55 return &PublicClient{ 56 httpClient: hc, 57 pdsCache: make(map[string]string), 58 } 59} 60 61// ResolveHandle resolves an AT Protocol handle to a DID string. 62func (c *PublicClient) ResolveHandle(ctx context.Context, handle string) (string, error) { 63 reqURL := fmt.Sprintf("%s/xrpc/com.atproto.identity.resolveHandle?handle=%s", 64 PublicAPIBase, url.QueryEscape(handle)) 65 66 req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) 67 if err != nil { 68 return "", fmt.Errorf("build request: %w", err) 69 } 70 71 resp, err := c.httpClient.Do(req) 72 if err != nil { 73 return "", fmt.Errorf("resolve handle: %w", err) 74 } 75 defer resp.Body.Close() 76 77 if resp.StatusCode == http.StatusNotFound { 78 return "", fmt.Errorf("handle not found: %s", handle) 79 } 80 if resp.StatusCode != http.StatusOK { 81 return "", fmt.Errorf("resolve handle: HTTP %d", resp.StatusCode) 82 } 83 84 var result struct { 85 DID string `json:"did"` 86 } 87 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 88 return "", fmt.Errorf("decode response: %w", err) 89 } 90 return result.DID, nil 91} 92 93// GetPDSEndpoint resolves a DID to the user's PDS base URL. 94// Results are cached in-memory for the lifetime of the client. 95func (c *PublicClient) GetPDSEndpoint(ctx context.Context, did string) (string, error) { 96 c.pdsCacheMu.RLock() 97 if pds, ok := c.pdsCache[did]; ok { 98 c.pdsCacheMu.RUnlock() 99 return pds, nil 100 } 101 c.pdsCacheMu.RUnlock() 102 103 var pdsEndpoint string 104 105 switch { 106 case strings.HasPrefix(did, "did:plc:"): 107 reqURL := fmt.Sprintf("%s/%s", PLCDirectory, did) 108 req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) 109 if err != nil { 110 return "", fmt.Errorf("build request: %w", err) 111 } 112 resp, err := c.httpClient.Do(req) 113 if err != nil { 114 return "", fmt.Errorf("fetch DID document: %w", err) 115 } 116 defer resp.Body.Close() 117 118 if resp.StatusCode != http.StatusOK { 119 return "", fmt.Errorf("DID resolution: HTTP %d", resp.StatusCode) 120 } 121 122 var didDoc struct { 123 Service []struct { 124 ID string `json:"id"` 125 Type string `json:"type"` 126 ServiceEndpoint string `json:"serviceEndpoint"` 127 } `json:"service"` 128 } 129 if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil { 130 return "", fmt.Errorf("decode DID document: %w", err) 131 } 132 for _, svc := range didDoc.Service { 133 if svc.ID == "#atproto_pds" || svc.Type == "AtprotoPersonalDataServer" { 134 pdsEndpoint = svc.ServiceEndpoint 135 break 136 } 137 } 138 139 case strings.HasPrefix(did, "did:web:"): 140 domain := strings.TrimPrefix(did, "did:web:") 141 domain = strings.ReplaceAll(domain, "%3A", ":") 142 if idx := strings.Index(domain, "/"); idx != -1 { 143 domain = domain[:idx] 144 } 145 host := domain 146 if h, _, err := net.SplitHostPort(domain); err == nil { 147 host = h 148 } 149 if err := validateDomain(host); err != nil { 150 return "", err 151 } 152 pdsEndpoint = "https://" + domain 153 } 154 155 if pdsEndpoint == "" { 156 return "", fmt.Errorf("could not resolve PDS endpoint for %s", did) 157 } 158 159 c.pdsCacheMu.Lock() 160 c.pdsCache[did] = pdsEndpoint 161 c.pdsCacheMu.Unlock() 162 163 return pdsEndpoint, nil 164} 165 166// PublicProfile is a user's public profile as returned by the Bluesky public API. 167type PublicProfile struct { 168 DID string `json:"did"` 169 Handle string `json:"handle"` 170 DisplayName *string `json:"displayName,omitempty"` 171 Avatar *string `json:"avatar,omitempty"` 172} 173 174// GetProfile fetches a user's public profile by DID or handle. 175func (c *PublicClient) GetProfile(ctx context.Context, actor string) (*PublicProfile, error) { 176 reqURL := fmt.Sprintf("%s/xrpc/app.bsky.actor.getProfile?actor=%s", 177 PublicAPIBase, url.QueryEscape(actor)) 178 179 req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) 180 if err != nil { 181 return nil, fmt.Errorf("build request: %w", err) 182 } 183 184 resp, err := c.httpClient.Do(req) 185 if err != nil { 186 return nil, fmt.Errorf("fetch profile: %w", err) 187 } 188 defer resp.Body.Close() 189 190 if resp.StatusCode != http.StatusOK { 191 return nil, fmt.Errorf("get profile: HTTP %d", resp.StatusCode) 192 } 193 194 var profile PublicProfile 195 if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { 196 return nil, fmt.Errorf("decode profile: %w", err) 197 } 198 return &profile, nil 199} 200 201// ListPublicRecordsOpts configures a ListPublicRecords call. 202// All fields are optional; the zero value yields the lexicon defaults. 203type ListPublicRecordsOpts struct { 204 Limit int // 0 means server default 205 Cursor string // empty for first page 206 Reverse bool // true returns newest-first 207} 208 209// ListPublicRecords fetches records from a public collection. 210// Queries the user's PDS directly, so it works with any collection NSID. 211func (c *PublicClient) ListPublicRecords(ctx context.Context, did, collection string, opts ListPublicRecordsOpts) ([]Record, string, error) { 212 pdsEndpoint, err := c.GetPDSEndpoint(ctx, did) 213 if err != nil { 214 return nil, "", fmt.Errorf("resolve PDS: %w", err) 215 } 216 217 q := url.Values{} 218 q.Set("repo", did) 219 q.Set("collection", collection) 220 if opts.Limit > 0 { 221 q.Set("limit", fmt.Sprintf("%d", opts.Limit)) 222 } 223 if opts.Cursor != "" { 224 q.Set("cursor", opts.Cursor) 225 } 226 if opts.Reverse { 227 q.Set("reverse", "true") 228 } 229 reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?%s", pdsEndpoint, q.Encode()) 230 231 req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) 232 if err != nil { 233 return nil, "", fmt.Errorf("build request: %w", err) 234 } 235 236 resp, err := c.httpClient.Do(req) 237 if err != nil { 238 return nil, "", fmt.Errorf("list records: %w", err) 239 } 240 defer resp.Body.Close() 241 242 if resp.StatusCode != http.StatusOK { 243 return nil, "", fmt.Errorf("list records: HTTP %d", resp.StatusCode) 244 } 245 246 var result struct { 247 Records []struct { 248 URI string `json:"uri"` 249 CID string `json:"cid"` 250 Value map[string]any `json:"value"` 251 } `json:"records"` 252 Cursor string `json:"cursor"` 253 } 254 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 255 return nil, "", fmt.Errorf("decode records: %w", err) 256 } 257 258 records := make([]Record, len(result.Records)) 259 for i, r := range result.Records { 260 records[i] = Record{URI: r.URI, CID: r.CID, Value: r.Value} 261 } 262 return records, result.Cursor, nil 263} 264 265// GetPublicRecord fetches a single public record from a user's PDS. 266func (c *PublicClient) GetPublicRecord(ctx context.Context, did, collection, rkey string) (*Record, error) { 267 pdsEndpoint, err := c.GetPDSEndpoint(ctx, did) 268 if err != nil { 269 return nil, fmt.Errorf("resolve PDS: %w", err) 270 } 271 272 reqURL := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 273 pdsEndpoint, url.QueryEscape(did), url.QueryEscape(collection), url.QueryEscape(rkey)) 274 275 req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) 276 if err != nil { 277 return nil, fmt.Errorf("build request: %w", err) 278 } 279 280 resp, err := c.httpClient.Do(req) 281 if err != nil { 282 return nil, fmt.Errorf("get record: %w", err) 283 } 284 defer resp.Body.Close() 285 286 if resp.StatusCode != http.StatusOK { 287 return nil, fmt.Errorf("get record: HTTP %d", resp.StatusCode) 288 } 289 290 var r struct { 291 URI string `json:"uri"` 292 CID string `json:"cid"` 293 Value map[string]any `json:"value"` 294 } 295 if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { 296 return nil, fmt.Errorf("decode record: %w", err) 297 } 298 return &Record{URI: r.URI, CID: r.CID, Value: r.Value}, nil 299} 300 301// isPrivateIP reports whether ip is in a private/reserved range. 302func isPrivateIP(ip net.IP) bool { 303 return ip.IsLoopback() || 304 ip.IsLinkLocalUnicast() || 305 ip.IsLinkLocalMulticast() || 306 ip.IsPrivate() || 307 ip.IsUnspecified() || 308 ip.Equal(net.ParseIP("169.254.169.254")) // cloud metadata 309} 310 311// validateDomain blocks requests to private/internal hosts. 312func validateDomain(domain string) error { 313 if domain == "localhost" || strings.HasSuffix(domain, ".local") { 314 return ErrSSRFBlocked 315 } 316 if ip := net.ParseIP(domain); ip != nil { 317 if isPrivateIP(ip) { 318 return ErrSSRFBlocked 319 } 320 return nil 321 } 322 ips, err := net.LookupIP(domain) 323 if err != nil { 324 return nil // let the HTTP request fail naturally 325 } 326 if slices.ContainsFunc(ips, isPrivateIP) { 327 return ErrSSRFBlocked 328 } 329 return nil 330}