1package spindleresolver
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net/http"
10 "strings"
11 "time"
12
13 "tangled.sh/tangled.sh/core/api/tangled"
14 "tangled.sh/tangled.sh/core/appview/cache"
15 "tangled.sh/tangled.sh/core/appview/idresolver"
16
17 "github.com/bluesky-social/indigo/api/atproto"
18 "github.com/bluesky-social/indigo/xrpc"
19)
20
21type ResolutionStatus string
22
23const (
24 StatusOK ResolutionStatus = "ok"
25 StatusError ResolutionStatus = "error"
26 StatusInvalid ResolutionStatus = "invalid"
27)
28
29type Resolution struct {
30 Status ResolutionStatus `json:"status"`
31 OwnerDID string `json:"ownerDid,omitempty"`
32 VerifiedAt time.Time `json:"verifiedAt"`
33}
34
35type Resolver struct {
36 cache *cache.Cache
37 http *http.Client
38 config Config
39 idResolver *idresolver.Resolver
40}
41
42type Config struct {
43 HitTTL time.Duration
44 ErrTTL time.Duration
45 InvalidTTL time.Duration
46 Dev bool
47}
48
49func NewResolver(cache *cache.Cache, client *http.Client, config Config) *Resolver {
50 if client == nil {
51 client = &http.Client{
52 Timeout: 2 * time.Second,
53 }
54 }
55 return &Resolver{
56 cache: cache,
57 http: client,
58 config: config,
59 }
60}
61
62func DefaultResolver(cache *cache.Cache) *Resolver {
63 return NewResolver(
64 cache,
65 &http.Client{
66 Timeout: 2 * time.Second,
67 },
68 Config{
69 HitTTL: 24 * time.Hour,
70 ErrTTL: 30 * time.Second,
71 InvalidTTL: 1 * time.Minute,
72 },
73 )
74}
75
76func (r *Resolver) ResolveInstance(ctx context.Context, domain string) (*Resolution, error) {
77 key := "spindle:" + domain
78
79 val, err := r.cache.Get(ctx, key).Result()
80 if err == nil {
81 var cached Resolution
82 if err := json.Unmarshal([]byte(val), &cached); err == nil {
83 return &cached, nil
84 }
85 }
86
87 resolution, ttl := r.verify(ctx, domain)
88
89 data, _ := json.Marshal(resolution)
90 r.cache.Set(ctx, key, data, ttl)
91
92 if resolution.Status == StatusOK {
93 return resolution, nil
94 }
95
96 return resolution, fmt.Errorf("verification failed: %s", resolution.Status)
97}
98
99func (r *Resolver) verify(ctx context.Context, domain string) (*Resolution, time.Duration) {
100 owner, err := r.fetchOwner(ctx, domain)
101 if err != nil {
102 return &Resolution{Status: StatusError, VerifiedAt: time.Now()}, r.config.ErrTTL
103 }
104
105 record, err := r.fetchRecord(ctx, owner, domain)
106 if err != nil {
107 return &Resolution{Status: StatusError, VerifiedAt: time.Now()}, r.config.ErrTTL
108 }
109
110 if record.Instance == domain {
111 return &Resolution{
112 Status: StatusOK,
113 OwnerDID: owner,
114 VerifiedAt: time.Now(),
115 }, r.config.HitTTL
116 }
117
118 return &Resolution{
119 Status: StatusInvalid,
120 OwnerDID: owner,
121 VerifiedAt: time.Now(),
122 }, r.config.InvalidTTL
123}
124
125func (r *Resolver) fetchOwner(ctx context.Context, domain string) (string, error) {
126 scheme := "https"
127 if r.config.Dev {
128 scheme = "http"
129 }
130
131 url := fmt.Sprintf("%s://%s/owner", scheme, domain)
132 req, err := http.NewRequest("GET", url, nil)
133 if err != nil {
134 return "", err
135 }
136
137 resp, err := r.http.Do(req.WithContext(ctx))
138 if err != nil || resp.StatusCode != 200 {
139 return "", errors.New("failed to fetch /owner")
140 }
141
142 body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
143 if err != nil {
144 return "", fmt.Errorf("failed to read /owner response: %w", err)
145 }
146
147 did := strings.TrimSpace(string(body))
148 if did == "" {
149 return "", errors.New("empty DID in /owner response")
150 }
151
152 return did, nil
153}
154
155func (r *Resolver) fetchRecord(ctx context.Context, did, rkey string) (*tangled.Spindle, error) {
156 ident, err := r.idResolver.ResolveIdent(ctx, did)
157 if err != nil {
158 return nil, err
159 }
160
161 xrpcc := xrpc.Client{
162 Host: ident.PDSEndpoint(),
163 }
164
165 rec, err := atproto.RepoGetRecord(ctx, &xrpcc, "", tangled.SpindleNSID, did, rkey)
166 if err != nil {
167 return nil, err
168 }
169
170 out, ok := rec.Value.Val.(*tangled.Spindle)
171 if !ok {
172 return nil, fmt.Errorf("invalid record returned")
173 }
174
175 return out, nil
176}