Monorepo for Tangled tangled.org
6

Configure Feed

Select the types of activity you want to include in your feed.

at icy/yovxsu 15 kB View raw
1package spindles 2 3import ( 4 "errors" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "slices" 9 "strings" 10 "time" 11 12 "github.com/go-chi/chi/v5" 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview/config" 15 "tangled.org/core/appview/db" 16 "tangled.org/core/appview/middleware" 17 "tangled.org/core/appview/models" 18 "tangled.org/core/appview/oauth" 19 "tangled.org/core/appview/pages" 20 "tangled.org/core/appview/serververify" 21 "tangled.org/core/appview/xrpcclient" 22 "tangled.org/core/idresolver" 23 "tangled.org/core/orm" 24 "tangled.org/core/rbac" 25 "tangled.org/core/tid" 26 27 comatproto "github.com/bluesky-social/indigo/api/atproto" 28 "github.com/bluesky-social/indigo/atproto/syntax" 29 lexutil "github.com/bluesky-social/indigo/lex/util" 30) 31 32type Spindles struct { 33 Db *db.DB 34 OAuth *oauth.OAuth 35 Pages *pages.Pages 36 Config *config.Config 37 Enforcer *rbac.Enforcer 38 IdResolver *idresolver.Resolver 39 Logger *slog.Logger 40} 41 42func (s *Spindles) Router() http.Handler { 43 r := chi.NewRouter() 44 45 r.With(middleware.AuthMiddleware(s.OAuth)).Get("/", s.spindles) 46 r.With(middleware.AuthMiddleware(s.OAuth)).Post("/register", s.register) 47 48 r.With(middleware.AuthMiddleware(s.OAuth)).Get("/{instance}", s.dashboard) 49 r.With(middleware.AuthMiddleware(s.OAuth)).Delete("/{instance}", s.delete) 50 51 r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/retry", s.retry) 52 r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/add", s.addMember) 53 r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/remove", s.removeMember) 54 55 return r 56} 57 58func (s *Spindles) spindles(w http.ResponseWriter, r *http.Request) { 59 user := s.OAuth.GetMultiAccountUser(r) 60 all, err := db.GetSpindles( 61 r.Context(), 62 s.Db, 63 orm.FilterEq("owner", user.Did), 64 ) 65 if err != nil { 66 s.Logger.Error("failed to fetch spindles", "err", err) 67 w.WriteHeader(http.StatusInternalServerError) 68 return 69 } 70 71 s.Pages.Spindles(w, pages.SpindlesParams{ 72 LoggedInUser: user, 73 Spindles: all, 74 Tab: "spindles", 75 }) 76} 77 78func (s *Spindles) dashboard(w http.ResponseWriter, r *http.Request) { 79 l := s.Logger.With("handler", "dashboard") 80 81 user := s.OAuth.GetMultiAccountUser(r) 82 l = l.With("user", user.Did) 83 84 instance := chi.URLParam(r, "instance") 85 if instance == "" { 86 return 87 } 88 l = l.With("instance", instance) 89 90 spindles, err := db.GetSpindles( 91 r.Context(), 92 s.Db, 93 orm.FilterEq("instance", instance), 94 orm.FilterEq("owner", user.Did), 95 orm.FilterIsNot("verified", "null"), 96 ) 97 if err != nil || len(spindles) != 1 { 98 l.Error("failed to get spindle", "err", err, "len(spindles)", len(spindles)) 99 http.Error(w, "Not found", http.StatusNotFound) 100 return 101 } 102 103 spindle := spindles[0] 104 members, err := s.Enforcer.GetSpindleUsersByRole("server:member", spindle.Instance) 105 if err != nil { 106 l.Error("failed to get spindle members", "err", err) 107 http.Error(w, "Not found", http.StatusInternalServerError) 108 return 109 } 110 slices.Sort(members) 111 112 repos, err := db.GetRepos( 113 s.Db, 114 orm.FilterEq("spindle", instance), 115 ) 116 if err != nil { 117 l.Error("failed to get spindle repos", "err", err) 118 http.Error(w, "Not found", http.StatusInternalServerError) 119 return 120 } 121 122 // organize repos by did 123 repoMap := make(map[string][]models.Repo) 124 for _, r := range repos { 125 repoMap[r.Did] = append(repoMap[r.Did], r) 126 } 127 128 s.Pages.SpindleDashboard(w, pages.SpindleDashboardParams{ 129 LoggedInUser: user, 130 Spindle: spindle, 131 Members: members, 132 Repos: repoMap, 133 Tab: "spindles", 134 }) 135} 136 137// this endpoint inserts a record on behalf of the user to register that domain 138// 139// when registered, it also makes a request to see if the spindle declares this users as its owner, 140// and if so, marks the spindle as verified. 141// 142// if the spindle is not up yet, the user is free to retry verification at a later point 143func (s *Spindles) register(w http.ResponseWriter, r *http.Request) { 144 user := s.OAuth.GetMultiAccountUser(r) 145 l := s.Logger.With("handler", "register") 146 147 noticeId := "register-error" 148 defaultErr := "Failed to register spindle. Try again later." 149 fail := func() { 150 s.Pages.Notice(w, noticeId, defaultErr) 151 } 152 153 instance := r.FormValue("instance") 154 // Strip protocol, trailing slashes, and whitespace 155 // Rkey cannot contain slashes 156 instance = strings.TrimSpace(instance) 157 instance = strings.TrimPrefix(instance, "https://") 158 instance = strings.TrimPrefix(instance, "http://") 159 instance = strings.TrimSuffix(instance, "/") 160 if instance == "" { 161 s.Pages.Notice(w, noticeId, "Incomplete form.") 162 return 163 } 164 l = l.With("instance", instance) 165 l = l.With("user", user.Did) 166 167 tx, err := s.Db.Begin() 168 if err != nil { 169 l.Error("failed to start transaction", "err", err) 170 fail() 171 return 172 } 173 defer tx.Rollback() 174 175 if err := db.AddSpindle(tx, models.Spindle{ 176 Owner: syntax.DID(user.Did), 177 Instance: instance, 178 }); err != nil { 179 l.Error("failed to insert", "err", err) 180 fail() 181 return 182 } 183 184 client, err := s.OAuth.AuthorizedClient(r) 185 if err != nil { 186 l.Error("failed to authorize client", "err", err) 187 fail() 188 return 189 } 190 191 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 192 var exCid *string 193 if ex != nil { 194 exCid = ex.Cid 195 } 196 197 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 198 Collection: tangled.SpindleNSID, 199 Repo: user.Did, 200 Rkey: instance, 201 Record: &lexutil.LexiconTypeDecoder{ 202 Val: &tangled.Spindle{ 203 CreatedAt: time.Now().Format(time.RFC3339), 204 }, 205 }, 206 SwapRecord: exCid, 207 }) 208 if err != nil { 209 l.Error("failed to put record", "err", err) 210 fail() 211 return 212 } 213 214 if err := tx.Commit(); err != nil { 215 l.Error("failed to commit transaction", "err", err) 216 fail() 217 return 218 } 219 220 s.Pages.HxRefresh(w) 221} 222 223func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { 224 user := s.OAuth.GetMultiAccountUser(r) 225 l := s.Logger.With("handler", "delete") 226 227 noticeId := "operation-error" 228 defaultErr := "Failed to delete spindle. Try again later." 229 fail := func() { 230 s.Pages.Notice(w, noticeId, defaultErr) 231 } 232 233 instance := chi.URLParam(r, "instance") 234 if instance == "" { 235 l.Error("empty instance") 236 fail() 237 return 238 } 239 240 spindles, err := db.GetSpindles( 241 r.Context(), 242 s.Db, 243 orm.FilterEq("owner", user.Did), 244 orm.FilterEq("instance", instance), 245 ) 246 if err != nil || len(spindles) != 1 { 247 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 248 fail() 249 return 250 } 251 252 if string(spindles[0].Owner) != user.Did { 253 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 254 s.Pages.Notice(w, noticeId, "Failed to delete spindle, unauthorized deletion attempt.") 255 return 256 } 257 258 tx, err := s.Db.Begin() 259 if err != nil { 260 l.Error("failed to start txn", "err", err) 261 fail() 262 return 263 } 264 defer func() { 265 tx.Rollback() 266 s.Enforcer.E.LoadPolicy() 267 }() 268 269 // remove spindle members first 270 err = db.RemoveSpindleMember( 271 tx, 272 orm.FilterEq("did", user.Did), 273 orm.FilterEq("instance", instance), 274 ) 275 if err != nil { 276 l.Error("failed to remove spindle members", "err", err) 277 fail() 278 return 279 } 280 281 err = db.DeleteSpindle( 282 tx, 283 orm.FilterEq("owner", user.Did), 284 orm.FilterEq("instance", instance), 285 ) 286 if err != nil { 287 l.Error("failed to delete spindle", "err", err) 288 fail() 289 return 290 } 291 292 // delete from enforcer 293 if spindles[0].Verified != nil { 294 err = s.Enforcer.RemoveSpindle(instance) 295 if err != nil { 296 l.Error("failed to update ACL", "err", err) 297 fail() 298 return 299 } 300 } 301 302 client, err := s.OAuth.AuthorizedClient(r) 303 if err != nil { 304 l.Error("failed to authorize client", "err", err) 305 fail() 306 return 307 } 308 309 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 310 Collection: tangled.SpindleNSID, 311 Repo: user.Did, 312 Rkey: instance, 313 }) 314 if err != nil { 315 // non-fatal 316 l.Error("failed to delete record", "err", err) 317 } 318 319 err = tx.Commit() 320 if err != nil { 321 l.Error("failed to delete spindle", "err", err) 322 fail() 323 return 324 } 325 326 err = s.Enforcer.E.SavePolicy() 327 if err != nil { 328 l.Error("failed to update ACL", "err", err) 329 s.Pages.HxRefresh(w) 330 return 331 } 332 333 shouldRedirect := r.Header.Get("shouldRedirect") 334 if shouldRedirect == "true" { 335 s.Pages.HxRedirect(w, "/settings/spindles") 336 return 337 } 338 339 w.Write([]byte{}) 340} 341 342func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) { 343 user := s.OAuth.GetMultiAccountUser(r) 344 l := s.Logger.With("handler", "retry") 345 346 noticeId := "operation-error" 347 defaultErr := "Failed to verify spindle. Try again later." 348 fail := func() { 349 s.Pages.Notice(w, noticeId, defaultErr) 350 } 351 352 instance := chi.URLParam(r, "instance") 353 if instance == "" { 354 l.Error("empty instance") 355 fail() 356 return 357 } 358 l = l.With("instance", instance) 359 l = l.With("user", user.Did) 360 361 spindles, err := db.GetSpindles( 362 r.Context(), 363 s.Db, 364 orm.FilterEq("owner", user.Did), 365 orm.FilterEq("instance", instance), 366 ) 367 if err != nil || len(spindles) != 1 { 368 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 369 fail() 370 return 371 } 372 373 if string(spindles[0].Owner) != user.Did { 374 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 375 s.Pages.Notice(w, noticeId, "Failed to verify spindle, unauthorized verification attempt.") 376 return 377 } 378 379 // begin verification 380 err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 381 if err != nil { 382 l.Error("verification failed", "err", err) 383 384 if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 385 s.Pages.Notice(w, noticeId, "Failed to verify spindle, XRPC queries are unsupported on this spindle, consider upgrading!") 386 return 387 } 388 389 if e, ok := err.(*serververify.OwnerMismatch); ok { 390 s.Pages.Notice(w, noticeId, e.Error()) 391 return 392 } 393 394 fail() 395 return 396 } 397 398 rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 399 if err != nil { 400 l.Error("failed to mark verified", "err", err) 401 s.Pages.Notice(w, noticeId, err.Error()) 402 return 403 } 404 405 verifiedSpindle, err := db.GetSpindles( 406 r.Context(), 407 s.Db, 408 orm.FilterEq("id", rowId), 409 ) 410 if err != nil || len(verifiedSpindle) != 1 { 411 l.Error("failed get new spindle", "err", err) 412 s.Pages.HxRefresh(w) 413 return 414 } 415 416 shouldRefresh := r.Header.Get("shouldRefresh") 417 if shouldRefresh == "true" { 418 s.Pages.HxRefresh(w) 419 return 420 } 421 422 w.Header().Set("HX-Reswap", "outerHTML") 423 s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]}) 424} 425 426func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) { 427 user := s.OAuth.GetMultiAccountUser(r) 428 l := s.Logger.With("handler", "addMember") 429 430 instance := chi.URLParam(r, "instance") 431 if instance == "" { 432 l.Error("empty instance") 433 http.Error(w, "Not found", http.StatusNotFound) 434 return 435 } 436 l = l.With("instance", instance) 437 l = l.With("user", user.Did) 438 439 spindles, err := db.GetSpindles( 440 r.Context(), 441 s.Db, 442 orm.FilterEq("owner", user.Did), 443 orm.FilterEq("instance", instance), 444 ) 445 if err != nil || len(spindles) != 1 { 446 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 447 http.Error(w, "Not found", http.StatusNotFound) 448 return 449 } 450 451 noticeId := fmt.Sprintf("add-member-error-%d", spindles[0].Id) 452 defaultErr := "Failed to add member. Try again later." 453 fail := func() { 454 s.Pages.Notice(w, noticeId, defaultErr) 455 } 456 457 if string(spindles[0].Owner) != user.Did { 458 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 459 s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 460 return 461 } 462 463 member := r.FormValue("member") 464 member = strings.TrimPrefix(member, "@") 465 if member == "" { 466 l.Error("empty member") 467 s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 468 return 469 } 470 l = l.With("member", member) 471 472 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 473 if err != nil { 474 l.Error("failed to resolve member identity to handle", "err", err) 475 s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 476 return 477 } 478 if memberId.Handle.IsInvalidHandle() { 479 l.Error("failed to resolve member identity to handle") 480 s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 481 return 482 } 483 484 // write to pds 485 client, err := s.OAuth.AuthorizedClient(r) 486 if err != nil { 487 l.Error("failed to authorize client", "err", err) 488 fail() 489 return 490 } 491 492 rkey := tid.TID() 493 494 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 495 Collection: tangled.SpindleMemberNSID, 496 Repo: user.Did, 497 Rkey: rkey, 498 Record: &lexutil.LexiconTypeDecoder{ 499 Val: &tangled.SpindleMember{ 500 CreatedAt: time.Now().Format(time.RFC3339), 501 Instance: instance, 502 Subject: memberId.DID.String(), 503 }, 504 }, 505 }) 506 if err != nil { 507 l.Error("failed to add record to PDS", "err", err) 508 s.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 509 return 510 } 511 512 // success 513 s.Pages.HxRedirect(w, fmt.Sprintf("/settings/spindles/%s", instance)) 514} 515 516func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { 517 user := s.OAuth.GetMultiAccountUser(r) 518 l := s.Logger.With("handler", "removeMember") 519 520 noticeId := "operation-error" 521 defaultErr := "Failed to remove member. Try again later." 522 fail := func() { 523 s.Pages.Notice(w, noticeId, defaultErr) 524 } 525 526 instance := chi.URLParam(r, "instance") 527 if instance == "" { 528 l.Error("empty instance") 529 fail() 530 return 531 } 532 l = l.With("instance", instance) 533 l = l.With("user", user.Did) 534 535 spindles, err := db.GetSpindles( 536 r.Context(), 537 s.Db, 538 orm.FilterEq("owner", user.Did), 539 orm.FilterEq("instance", instance), 540 ) 541 if err != nil || len(spindles) != 1 { 542 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 543 fail() 544 return 545 } 546 547 if string(spindles[0].Owner) != user.Did { 548 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 549 s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 550 return 551 } 552 553 member := r.FormValue("member") 554 member = strings.TrimPrefix(member, "@") 555 if member == "" { 556 l.Error("empty member") 557 s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 558 return 559 } 560 l = l.With("member", member) 561 562 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 563 if err != nil { 564 l.Error("failed to resolve member identity to handle", "err", err) 565 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 566 return 567 } 568 569 members, err := db.GetSpindleMembers( 570 s.Db, 571 orm.FilterEq("did", user.Did), 572 orm.FilterEq("instance", instance), 573 orm.FilterEq("subject", memberId.DID), 574 ) 575 if err != nil || len(members) != 1 { 576 l.Error("failed to get member", "err", err) 577 fail() 578 return 579 } 580 581 client, err := s.OAuth.AuthorizedClient(r) 582 if err != nil { 583 l.Error("failed to authorize client", "err", err) 584 fail() 585 return 586 } 587 588 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 589 Collection: tangled.SpindleMemberNSID, 590 Repo: user.Did, 591 Rkey: members[0].Rkey, 592 }) 593 if err != nil { 594 l.Error("failed to delete record", "err", err) 595 fail() 596 return 597 } 598 599 s.Pages.HxRefresh(w) 600}