this repo has no description
1package spindles
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "net/http"
10 "strings"
11 "time"
12
13 "github.com/go-chi/chi/v5"
14 "tangled.sh/tangled.sh/core/api/tangled"
15 "tangled.sh/tangled.sh/core/appview/config"
16 "tangled.sh/tangled.sh/core/appview/db"
17 "tangled.sh/tangled.sh/core/appview/middleware"
18 "tangled.sh/tangled.sh/core/appview/oauth"
19 "tangled.sh/tangled.sh/core/appview/pages"
20 "tangled.sh/tangled.sh/core/rbac"
21
22 comatproto "github.com/bluesky-social/indigo/api/atproto"
23 "github.com/bluesky-social/indigo/atproto/syntax"
24 lexutil "github.com/bluesky-social/indigo/lex/util"
25)
26
27type Spindles struct {
28 Db *db.DB
29 OAuth *oauth.OAuth
30 Pages *pages.Pages
31 Config *config.Config
32 Enforcer *rbac.Enforcer
33 Logger *slog.Logger
34}
35
36func (s *Spindles) Router() http.Handler {
37 r := chi.NewRouter()
38
39 r.Use(middleware.AuthMiddleware(s.OAuth))
40
41 r.Get("/", s.spindles)
42 r.Post("/register", s.register)
43 r.Delete("/{instance}", s.delete)
44 r.Post("/{instance}/retry", s.retry)
45
46 return r
47}
48
49func (s *Spindles) spindles(w http.ResponseWriter, r *http.Request) {
50 user := s.OAuth.GetUser(r)
51 all, err := db.GetSpindles(
52 s.Db,
53 db.FilterEq("owner", user.Did),
54 )
55 if err != nil {
56 s.Logger.Error("failed to fetch spindles", "err", err)
57 w.WriteHeader(http.StatusInternalServerError)
58 return
59 }
60
61 s.Pages.Spindles(w, pages.SpindlesParams{
62 LoggedInUser: user,
63 Spindles: all,
64 })
65}
66
67// this endpoint inserts a record on behalf of the user to register that domain
68//
69// when registered, it also makes a request to see if the spindle declares this users as its owner,
70// and if so, marks the spindle as verified.
71//
72// if the spindle is not up yet, the user is free to retry verification at a later point
73func (s *Spindles) register(w http.ResponseWriter, r *http.Request) {
74 user := s.OAuth.GetUser(r)
75 l := s.Logger.With("handler", "register")
76
77 noticeId := "register-error"
78 defaultErr := "Failed to register spindle. Try again later."
79 fail := func() {
80 s.Pages.Notice(w, noticeId, defaultErr)
81 }
82
83 instance := r.FormValue("instance")
84 if instance == "" {
85 s.Pages.Notice(w, noticeId, "Incomplete form.")
86 return
87 }
88
89 tx, err := s.Db.Begin()
90 if err != nil {
91 l.Error("failed to start transaction", "err", err)
92 fail()
93 return
94 }
95 defer tx.Rollback()
96
97 err = db.AddSpindle(tx, db.Spindle{
98 Owner: syntax.DID(user.Did),
99 Instance: instance,
100 })
101 if err != nil {
102 l.Error("failed to insert", "err", err)
103 fail()
104 return
105 }
106
107 // create record on pds
108 client, err := s.OAuth.AuthorizedClient(r)
109 if err != nil {
110 l.Error("failed to authorize client", "err", err)
111 fail()
112 return
113 }
114
115 ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance)
116 var exCid *string
117 if ex != nil {
118 exCid = ex.Cid
119 }
120
121 // re-announce by registering under same rkey
122 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
123 Collection: tangled.SpindleNSID,
124 Repo: user.Did,
125 Rkey: instance,
126 Record: &lexutil.LexiconTypeDecoder{
127 Val: &tangled.Spindle{
128 CreatedAt: time.Now().Format(time.RFC3339),
129 },
130 },
131 SwapRecord: exCid,
132 })
133
134 if err != nil {
135 l.Error("failed to put record", "err", err)
136 fail()
137 return
138 }
139
140 err = tx.Commit()
141 if err != nil {
142 l.Error("failed to commit transaction", "err", err)
143 fail()
144 return
145 }
146
147 // begin verification
148 expectedOwner, err := fetchOwner(r.Context(), instance, s.Config.Core.Dev)
149 if err != nil {
150 l.Error("verification failed", "err", err)
151
152 // just refresh the page
153 s.Pages.HxRefresh(w)
154 return
155 }
156
157 if expectedOwner != user.Did {
158 // verification failed
159 l.Error("verification failed", "expectedOwner", expectedOwner, "observedOwner", user.Did)
160 s.Pages.HxRefresh(w)
161 return
162 }
163
164 tx, err = s.Db.Begin()
165 if err != nil {
166 l.Error("failed to commit verification info", "err", err)
167 s.Pages.HxRefresh(w)
168 return
169 }
170 defer func() {
171 tx.Rollback()
172 s.Enforcer.E.LoadPolicy()
173 }()
174
175 // mark this spindle as verified in the db
176 _, err = db.VerifySpindle(
177 tx,
178 db.FilterEq("owner", user.Did),
179 db.FilterEq("instance", instance),
180 )
181
182 err = s.Enforcer.AddSpindleOwner(instance, user.Did)
183 if err != nil {
184 l.Error("failed to update ACL", "err", err)
185 s.Pages.HxRefresh(w)
186 return
187 }
188
189 err = tx.Commit()
190 if err != nil {
191 l.Error("failed to commit verification info", "err", err)
192 s.Pages.HxRefresh(w)
193 return
194 }
195
196 err = s.Enforcer.E.SavePolicy()
197 if err != nil {
198 l.Error("failed to update ACL", "err", err)
199 s.Pages.HxRefresh(w)
200 return
201 }
202
203 // ok
204 s.Pages.HxRefresh(w)
205 return
206}
207
208func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) {
209 user := s.OAuth.GetUser(r)
210 l := s.Logger.With("handler", "register")
211
212 noticeId := "operation-error"
213 defaultErr := "Failed to delete spindle. Try again later."
214 fail := func() {
215 s.Pages.Notice(w, noticeId, defaultErr)
216 }
217
218 instance := chi.URLParam(r, "instance")
219 if instance == "" {
220 l.Error("empty instance")
221 fail()
222 return
223 }
224
225 tx, err := s.Db.Begin()
226 if err != nil {
227 l.Error("failed to start txn", "err", err)
228 fail()
229 return
230 }
231 defer tx.Rollback()
232
233 err = db.DeleteSpindle(
234 tx,
235 db.FilterEq("owner", user.Did),
236 db.FilterEq("instance", instance),
237 )
238 if err != nil {
239 l.Error("failed to delete spindle", "err", err)
240 fail()
241 return
242 }
243
244 client, err := s.OAuth.AuthorizedClient(r)
245 if err != nil {
246 l.Error("failed to authorize client", "err", err)
247 fail()
248 return
249 }
250
251 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
252 Collection: tangled.SpindleNSID,
253 Repo: user.Did,
254 Rkey: instance,
255 })
256 if err != nil {
257 // non-fatal
258 l.Error("failed to delete record", "err", err)
259 }
260
261 err = tx.Commit()
262 if err != nil {
263 l.Error("failed to delete spindle", "err", err)
264 fail()
265 return
266 }
267
268 w.Write([]byte{})
269}
270
271func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) {
272 user := s.OAuth.GetUser(r)
273 l := s.Logger.With("handler", "register")
274
275 noticeId := "operation-error"
276 defaultErr := "Failed to verify spindle. Try again later."
277 fail := func() {
278 s.Pages.Notice(w, noticeId, defaultErr)
279 }
280
281 instance := chi.URLParam(r, "instance")
282 if instance == "" {
283 l.Error("empty instance")
284 fail()
285 return
286 }
287
288 // begin verification
289 expectedOwner, err := fetchOwner(r.Context(), instance, s.Config.Core.Dev)
290 if err != nil {
291 l.Error("verification failed", "err", err)
292 fail()
293 return
294 }
295
296 if expectedOwner != user.Did {
297 l.Error("verification failed", "expectedOwner", expectedOwner, "observedOwner", user.Did)
298 s.Pages.Notice(w, noticeId, fmt.Sprintf("Owner did not match, expected %s, got %s", expectedOwner, user.Did))
299 return
300 }
301
302 // mark this spindle as verified in the db
303 rowId, err := db.VerifySpindle(
304 s.Db,
305 db.FilterEq("owner", user.Did),
306 db.FilterEq("instance", instance),
307 )
308 if err != nil {
309 l.Error("verification failed", "err", err)
310 fail()
311 return
312 }
313
314 verifiedSpindle := db.Spindle{
315 Id: int(rowId),
316 Owner: syntax.DID(user.Did),
317 Instance: instance,
318 }
319
320 w.Header().Set("HX-Reswap", "outerHTML")
321 s.Pages.SpindleListing(w, pages.SpindleListingParams{
322 LoggedInUser: user,
323 Spindle: verifiedSpindle,
324 })
325}
326
327func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
328 scheme := "https"
329 if dev {
330 scheme = "http"
331 }
332
333 url := fmt.Sprintf("%s://%s/owner", scheme, domain)
334 req, err := http.NewRequest("GET", url, nil)
335 if err != nil {
336 return "", err
337 }
338
339 client := &http.Client{
340 Timeout: 1 * time.Second,
341 }
342
343 resp, err := client.Do(req.WithContext(ctx))
344 if err != nil || resp.StatusCode != 200 {
345 return "", errors.New("failed to fetch /owner")
346 }
347
348 body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
349 if err != nil {
350 return "", fmt.Errorf("failed to read /owner response: %w", err)
351 }
352
353 did := strings.TrimSpace(string(body))
354 if did == "" {
355 return "", errors.New("empty DID in /owner response")
356 }
357
358 return did, nil
359}