Go boilerplate library for building atproto apps
atproto
go
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}