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 }
58
59 s.Pages.Spindles(w, pages.SpindlesParams{
60 LoggedInUser: user,
61 Spindles: all,
62 })
63}
64
65// this endpoint inserts a record on behalf of the user to register that domain
66//
67// when registered, it also makes a request to see if the spindle declares this users as its owner,
68// and if so, marks the spindle as verified.
69//
70// if the spindle is not up yet, the user is free to retry verification at a later point
71func (s *Spindles) register(w http.ResponseWriter, r *http.Request) {
72 user := s.OAuth.GetUser(r)
73 l := s.Logger.With("handler", "register")
74
75 noticeId := "register-error"
76 defaultErr := "Failed to register spindle. Try again later."
77 fail := func() {
78 s.Pages.Notice(w, noticeId, defaultErr)
79 }
80
81 instance := r.FormValue("instance")
82 if instance == "" {
83 s.Pages.Notice(w, noticeId, "Incomplete form.")
84 return
85 }
86
87 tx, err := s.Db.Begin()
88 if err != nil {
89 l.Error("failed to start transaction", "err", err)
90 fail()
91 return
92 }
93 defer tx.Rollback()
94
95 err = db.AddSpindle(tx, db.Spindle{
96 Owner: syntax.DID(user.Did),
97 Instance: instance,
98 })
99 if err != nil {
100 l.Error("failed to insert", "err", err)
101 fail()
102 return
103 }
104
105 // create record on pds
106 client, err := s.OAuth.AuthorizedClient(r)
107 if err != nil {
108 l.Error("failed to authorize client", "err", err)
109 fail()
110 return
111 }
112
113 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
114 Collection: tangled.SpindleNSID,
115 Repo: user.Did,
116 Rkey: instance,
117 Record: &lexutil.LexiconTypeDecoder{
118 Val: &tangled.Spindle{
119 CreatedAt: time.Now().Format(time.RFC3339),
120 },
121 },
122 })
123 if err != nil {
124 l.Error("failed to put record", "err", err)
125 fail()
126 return
127 }
128
129 err = tx.Commit()
130 if err != nil {
131 l.Error("failed to commit transaction", "err", err)
132 fail()
133 return
134 }
135
136 // begin verification
137 expectedOwner, err := fetchOwner(r.Context(), instance, s.Config.Core.Dev)
138 if err != nil {
139 l.Error("verification failed", "err", err)
140
141 // just refresh the page
142 s.Pages.HxRefresh(w)
143 return
144 }
145
146 if expectedOwner != user.Did {
147 // verification failed
148 l.Error("verification failed", "expectedOwner", expectedOwner, "observedOwner", user.Did)
149 s.Pages.HxRefresh(w)
150 return
151 }
152
153 // ok
154 s.Pages.HxRefresh(w)
155 return
156}
157
158func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) {
159 user := s.OAuth.GetUser(r)
160 l := s.Logger.With("handler", "register")
161
162 noticeId := "operation-error"
163 defaultErr := "Failed to delete spindle. Try again later."
164 fail := func() {
165 s.Pages.Notice(w, noticeId, defaultErr)
166 }
167
168 instance := chi.URLParam(r, "instance")
169 if instance == "" {
170 l.Error("empty instance")
171 fail()
172 return
173 }
174
175 tx, err := s.Db.Begin()
176 if err != nil {
177 l.Error("failed to start txn", "err", err)
178 fail()
179 return
180 }
181 defer tx.Rollback()
182
183 err = db.DeleteSpindle(
184 tx,
185 db.FilterEq("owner", user.Did),
186 db.FilterEq("instance", instance),
187 )
188 if err != nil {
189 l.Error("failed to delete spindle", "err", err)
190 fail()
191 return
192 }
193
194 client, err := s.OAuth.AuthorizedClient(r)
195 if err != nil {
196 l.Error("failed to authorize client", "err", err)
197 fail()
198 return
199 }
200
201 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
202 Collection: tangled.SpindleNSID,
203 Repo: user.Did,
204 Rkey: instance,
205 })
206 if err != nil {
207 // non-fatal
208 l.Error("failed to delete record", "err", err)
209 }
210
211 err = tx.Commit()
212 if err != nil {
213 l.Error("failed to delete spindle", "err", err)
214 fail()
215 return
216 }
217
218 w.Write([]byte{})
219}
220
221func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) {
222 user := s.OAuth.GetUser(r)
223 l := s.Logger.With("handler", "register")
224
225 noticeId := "operation-error"
226 defaultErr := "Failed to verify spindle. Try again later."
227 fail := func() {
228 s.Pages.Notice(w, noticeId, defaultErr)
229 }
230
231 instance := chi.URLParam(r, "instance")
232 if instance == "" {
233 l.Error("empty instance")
234 fail()
235 return
236 }
237
238 // begin verification
239 expectedOwner, err := fetchOwner(r.Context(), instance, s.Config.Core.Dev)
240 if err != nil {
241 l.Error("verification failed", "err", err)
242 fail()
243 return
244 }
245
246 if expectedOwner != user.Did {
247 l.Error("verification failed", "expectedOwner", expectedOwner, "observedOwner", user.Did)
248 s.Pages.Notice(w, noticeId, fmt.Sprintf("Owner did not match, expected %s, got %s", expectedOwner, user.Did))
249 return
250 }
251
252 // mark this spindle as verified in the db
253 rowId, err := db.VerifySpindle(
254 s.Db,
255 db.FilterEq("owner", user.Did),
256 db.FilterEq("instance", instance),
257 )
258
259 verifiedSpindle := db.Spindle{
260 Id: int(rowId),
261 Owner: syntax.DID(user.Did),
262 Instance: instance,
263 }
264
265 w.Header().Set("HX-Reswap", "outerHTML")
266 s.Pages.SpindleListing(w, pages.SpindleListingParams{
267 LoggedInUser: user,
268 Spindle: verifiedSpindle,
269 })
270}
271
272func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) {
273 scheme := "https"
274 if dev {
275 scheme = "http"
276 }
277
278 url := fmt.Sprintf("%s://%s/owner", scheme, domain)
279 req, err := http.NewRequest("GET", url, nil)
280 if err != nil {
281 return "", err
282 }
283
284 client := &http.Client{
285 Timeout: 1 * time.Second,
286 }
287
288 resp, err := client.Do(req.WithContext(ctx))
289 if err != nil || resp.StatusCode != 200 {
290 return "", errors.New("failed to fetch /owner")
291 }
292
293 body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data
294 if err != nil {
295 return "", fmt.Errorf("failed to read /owner response: %w", err)
296 }
297
298 did := strings.TrimSpace(string(body))
299 if did == "" {
300 return "", errors.New("empty DID in /owner response")
301 }
302
303 return did, nil
304}