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 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
116 Collection: tangled.SpindleNSID,
117 Repo: user.Did,
118 Rkey: instance,
119 Record: &lexutil.LexiconTypeDecoder{
120 Val: &tangled.Spindle{
121 CreatedAt: time.Now().Format(time.RFC3339),
122 },
123 },
124 })
125 if err != nil {
126 l.Error("failed to put record", "err", err)
127 fail()
128 return
129 }
130
131 err = tx.Commit()
132 if err != nil {
133 l.Error("failed to commit transaction", "err", err)
134 fail()
135 return
136 }
137
138 // begin verification
139 expectedOwner, err := fetchOwner(r.Context(), instance, s.Config.Core.Dev)
140 if err != nil {
141 l.Error("verification failed", "err", err)
142
143 // just refresh the page
144 s.Pages.HxRefresh(w)
145 return
146 }
147
148 if expectedOwner != user.Did {
149 // verification failed
150 l.Error("verification failed", "expectedOwner", expectedOwner, "observedOwner", user.Did)
151 s.Pages.HxRefresh(w)
152 return
153 }
154
155 tx, err = s.Db.Begin()
156 if err != nil {
157 l.Error("failed to commit verification info", "err", err)
158 s.Pages.HxRefresh(w)
159 return
160 }
161 defer func() {
162 tx.Rollback()
163 s.Enforcer.E.LoadPolicy()
164 }()
165
166 // mark this spindle as verified in the db
167 _, err = db.VerifySpindle(
168 tx,
169 db.FilterEq("owner", user.Did),
170 db.FilterEq("instance", instance),
171 )
172
173 err = s.Enforcer.AddSpindleOwner(instance, user.Did)
174 if err != nil {
175 l.Error("failed to update ACL", "err", err)
176 s.Pages.HxRefresh(w)
177 return
178 }
179
180 err = tx.Commit()
181 if err != nil {
182 l.Error("failed to commit verification info", "err", err)
183 s.Pages.HxRefresh(w)
184 return
185 }
186
187 err = s.Enforcer.E.SavePolicy()
188 if err != nil {
189 l.Error("failed to update ACL", "err", err)
190 s.Pages.HxRefresh(w)
191 return
192 }
193
194 // ok
195 s.Pages.HxRefresh(w)
196 return
197}
198
199func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) {
200 user := s.OAuth.GetUser(r)
201 l := s.Logger.With("handler", "register")
202
203 noticeId := "operation-error"
204 defaultErr := "Failed to delete spindle. Try again later."
205 fail := func() {
206 s.Pages.Notice(w, noticeId, defaultErr)
207 }
208
209 instance := chi.URLParam(r, "instance")
210 if instance == "" {
211 l.Error("empty instance")
212 fail()
213 return
214 }
215
216 tx, err := s.Db.Begin()
217 if err != nil {
218 l.Error("failed to start txn", "err", err)
219 fail()
220 return
221 }
222 defer tx.Rollback()
223
224 err = db.DeleteSpindle(
225 tx,
226 db.FilterEq("owner", user.Did),
227 db.FilterEq("instance", instance),
228 )
229 if err != nil {
230 l.Error("failed to delete spindle", "err", err)
231 fail()
232 return
233 }
234
235 client, err := s.OAuth.AuthorizedClient(r)
236 if err != nil {
237 l.Error("failed to authorize client", "err", err)
238 fail()
239 return
240 }
241
242 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
243 Collection: tangled.SpindleNSID,
244 Repo: user.Did,
245 Rkey: instance,
246 })
247 if err != nil {
248 // non-fatal
249 l.Error("failed to delete record", "err", err)
250 }
251
252 err = tx.Commit()
253 if err != nil {
254 l.Error("failed to delete spindle", "err", err)
255 fail()
256 return
257 }
258
259 w.Write([]byte{})
260}
261
262func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) {
263 user := s.OAuth.GetUser(r)
264 l := s.Logger.With("handler", "register")
265
266 noticeId := "operation-error"
267 defaultErr := "Failed to verify spindle. Try again later."
268 fail := func() {
269 s.Pages.Notice(w, noticeId, defaultErr)
270 }
271
272 instance := chi.URLParam(r, "instance")
273 if instance == "" {
274 l.Error("empty instance")
275 fail()
276 return
277 }
278
279 // begin verification
280 expectedOwner, err := fetchOwner(r.Context(), instance, s.Config.Core.Dev)
281 if err != nil {
282 l.Error("verification failed", "err", err)
283 fail()
284 return
285 }
286
287 if expectedOwner != user.Did {
288 l.Error("verification failed", "expectedOwner", expectedOwner, "observedOwner", user.Did)
289 s.Pages.Notice(w, noticeId, fmt.Sprintf("Owner did not match, expected %s, got %s", expectedOwner, user.Did))
290 return
291 }
292
293 // mark this spindle as verified in the db
294 rowId, err := db.VerifySpindle(
295 s.Db,
296 db.FilterEq("owner", user.Did),
297 db.FilterEq("instance", instance),
298 )
299 if err != nil {
300 l.Error("verification failed", "err", err)
301 fail()
302 return
303 }
304
305 verifiedSpindle := db.Spindle{
306 Id: int(rowId),
307 Owner: syntax.DID(user.Did),
308 Instance: instance,
309 }
310
311 w.Header().Set("HX-Reswap", "outerHTML")
312 s.Pages.SpindleListing(w, pages.SpindleListingParams{
313 LoggedInUser: user,
314 Spindle: verifiedSpindle,
315 })
316}
317
318func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
319 scheme := "https"
320 if dev {
321 scheme = "http"
322 }
323
324 url := fmt.Sprintf("%s://%s/owner", scheme, domain)
325 req, err := http.NewRequest("GET", url, nil)
326 if err != nil {
327 return "", err
328 }
329
330 client := &http.Client{
331 Timeout: 1 * time.Second,
332 }
333
334 resp, err := client.Do(req.WithContext(ctx))
335 if err != nil || resp.StatusCode != 200 {
336 return "", errors.New("failed to fetch /owner")
337 }
338
339 body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
340 if err != nil {
341 return "", fmt.Errorf("failed to read /owner response: %w", err)
342 }
343
344 did := strings.TrimSpace(string(body))
345 if did == "" {
346 return "", errors.New("empty DID in /owner response")
347 }
348
349 return did, nil
350}