forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
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}