Monorepo for Tangled
0
fork

Configure Feed

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

at master 725 lines 18 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 func() { 174 tx.Rollback() 175 s.Enforcer.E.LoadPolicy() 176 }() 177 178 err = db.AddSpindle(tx, models.Spindle{ 179 Owner: syntax.DID(user.Did), 180 Instance: instance, 181 }) 182 if err != nil { 183 l.Error("failed to insert", "err", err) 184 fail() 185 return 186 } 187 188 err = s.Enforcer.AddSpindle(instance) 189 if err != nil { 190 l.Error("failed to create spindle", "err", err) 191 fail() 192 return 193 } 194 195 // create record on pds 196 client, err := s.OAuth.AuthorizedClient(r) 197 if err != nil { 198 l.Error("failed to authorize client", "err", err) 199 fail() 200 return 201 } 202 203 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 204 var exCid *string 205 if ex != nil { 206 exCid = ex.Cid 207 } 208 209 // re-announce by registering under same rkey 210 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 211 Collection: tangled.SpindleNSID, 212 Repo: user.Did, 213 Rkey: instance, 214 Record: &lexutil.LexiconTypeDecoder{ 215 Val: &tangled.Spindle{ 216 CreatedAt: time.Now().Format(time.RFC3339), 217 }, 218 }, 219 SwapRecord: exCid, 220 }) 221 222 if err != nil { 223 l.Error("failed to put record", "err", err) 224 fail() 225 return 226 } 227 228 err = tx.Commit() 229 if err != nil { 230 l.Error("failed to commit transaction", "err", err) 231 fail() 232 return 233 } 234 235 err = s.Enforcer.E.SavePolicy() 236 if err != nil { 237 l.Error("failed to update ACL", "err", err) 238 s.Pages.HxRefresh(w) 239 return 240 } 241 242 // begin verification 243 err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 244 if err != nil { 245 l.Error("verification failed", "err", err) 246 s.Pages.HxRefresh(w) 247 return 248 } 249 250 _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 251 if err != nil { 252 l.Error("failed to mark verified", "err", err) 253 s.Pages.HxRefresh(w) 254 return 255 } 256 257 // ok 258 s.Pages.HxRefresh(w) 259} 260 261func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { 262 user := s.OAuth.GetMultiAccountUser(r) 263 l := s.Logger.With("handler", "delete") 264 265 noticeId := "operation-error" 266 defaultErr := "Failed to delete spindle. Try again later." 267 fail := func() { 268 s.Pages.Notice(w, noticeId, defaultErr) 269 } 270 271 instance := chi.URLParam(r, "instance") 272 if instance == "" { 273 l.Error("empty instance") 274 fail() 275 return 276 } 277 278 spindles, err := db.GetSpindles( 279 r.Context(), 280 s.Db, 281 orm.FilterEq("owner", user.Did), 282 orm.FilterEq("instance", instance), 283 ) 284 if err != nil || len(spindles) != 1 { 285 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 286 fail() 287 return 288 } 289 290 if string(spindles[0].Owner) != user.Did { 291 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 292 s.Pages.Notice(w, noticeId, "Failed to delete spindle, unauthorized deletion attempt.") 293 return 294 } 295 296 tx, err := s.Db.Begin() 297 if err != nil { 298 l.Error("failed to start txn", "err", err) 299 fail() 300 return 301 } 302 defer func() { 303 tx.Rollback() 304 s.Enforcer.E.LoadPolicy() 305 }() 306 307 // remove spindle members first 308 err = db.RemoveSpindleMember( 309 tx, 310 orm.FilterEq("did", user.Did), 311 orm.FilterEq("instance", instance), 312 ) 313 if err != nil { 314 l.Error("failed to remove spindle members", "err", err) 315 fail() 316 return 317 } 318 319 err = db.DeleteSpindle( 320 tx, 321 orm.FilterEq("owner", user.Did), 322 orm.FilterEq("instance", instance), 323 ) 324 if err != nil { 325 l.Error("failed to delete spindle", "err", err) 326 fail() 327 return 328 } 329 330 // delete from enforcer 331 if spindles[0].Verified != nil { 332 err = s.Enforcer.RemoveSpindle(instance) 333 if err != nil { 334 l.Error("failed to update ACL", "err", err) 335 fail() 336 return 337 } 338 } 339 340 client, err := s.OAuth.AuthorizedClient(r) 341 if err != nil { 342 l.Error("failed to authorize client", "err", err) 343 fail() 344 return 345 } 346 347 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 348 Collection: tangled.SpindleNSID, 349 Repo: user.Did, 350 Rkey: instance, 351 }) 352 if err != nil { 353 // non-fatal 354 l.Error("failed to delete record", "err", err) 355 } 356 357 err = tx.Commit() 358 if err != nil { 359 l.Error("failed to delete spindle", "err", err) 360 fail() 361 return 362 } 363 364 err = s.Enforcer.E.SavePolicy() 365 if err != nil { 366 l.Error("failed to update ACL", "err", err) 367 s.Pages.HxRefresh(w) 368 return 369 } 370 371 shouldRedirect := r.Header.Get("shouldRedirect") 372 if shouldRedirect == "true" { 373 s.Pages.HxRedirect(w, "/settings/spindles") 374 return 375 } 376 377 w.Write([]byte{}) 378} 379 380func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) { 381 user := s.OAuth.GetMultiAccountUser(r) 382 l := s.Logger.With("handler", "retry") 383 384 noticeId := "operation-error" 385 defaultErr := "Failed to verify spindle. Try again later." 386 fail := func() { 387 s.Pages.Notice(w, noticeId, defaultErr) 388 } 389 390 instance := chi.URLParam(r, "instance") 391 if instance == "" { 392 l.Error("empty instance") 393 fail() 394 return 395 } 396 l = l.With("instance", instance) 397 l = l.With("user", user.Did) 398 399 spindles, err := db.GetSpindles( 400 r.Context(), 401 s.Db, 402 orm.FilterEq("owner", user.Did), 403 orm.FilterEq("instance", instance), 404 ) 405 if err != nil || len(spindles) != 1 { 406 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 407 fail() 408 return 409 } 410 411 if string(spindles[0].Owner) != user.Did { 412 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 413 s.Pages.Notice(w, noticeId, "Failed to verify spindle, unauthorized verification attempt.") 414 return 415 } 416 417 // begin verification 418 err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 419 if err != nil { 420 l.Error("verification failed", "err", err) 421 422 if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 423 s.Pages.Notice(w, noticeId, "Failed to verify spindle, XRPC queries are unsupported on this spindle, consider upgrading!") 424 return 425 } 426 427 if e, ok := err.(*serververify.OwnerMismatch); ok { 428 s.Pages.Notice(w, noticeId, e.Error()) 429 return 430 } 431 432 fail() 433 return 434 } 435 436 rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 437 if err != nil { 438 l.Error("failed to mark verified", "err", err) 439 s.Pages.Notice(w, noticeId, err.Error()) 440 return 441 } 442 443 verifiedSpindle, err := db.GetSpindles( 444 r.Context(), 445 s.Db, 446 orm.FilterEq("id", rowId), 447 ) 448 if err != nil || len(verifiedSpindle) != 1 { 449 l.Error("failed get new spindle", "err", err) 450 s.Pages.HxRefresh(w) 451 return 452 } 453 454 shouldRefresh := r.Header.Get("shouldRefresh") 455 if shouldRefresh == "true" { 456 s.Pages.HxRefresh(w) 457 return 458 } 459 460 w.Header().Set("HX-Reswap", "outerHTML") 461 s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]}) 462} 463 464func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) { 465 user := s.OAuth.GetMultiAccountUser(r) 466 l := s.Logger.With("handler", "addMember") 467 468 instance := chi.URLParam(r, "instance") 469 if instance == "" { 470 l.Error("empty instance") 471 http.Error(w, "Not found", http.StatusNotFound) 472 return 473 } 474 l = l.With("instance", instance) 475 l = l.With("user", user.Did) 476 477 spindles, err := db.GetSpindles( 478 r.Context(), 479 s.Db, 480 orm.FilterEq("owner", user.Did), 481 orm.FilterEq("instance", instance), 482 ) 483 if err != nil || len(spindles) != 1 { 484 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 485 http.Error(w, "Not found", http.StatusNotFound) 486 return 487 } 488 489 noticeId := fmt.Sprintf("add-member-error-%d", spindles[0].Id) 490 defaultErr := "Failed to add member. Try again later." 491 fail := func() { 492 s.Pages.Notice(w, noticeId, defaultErr) 493 } 494 495 if string(spindles[0].Owner) != user.Did { 496 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 497 s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 498 return 499 } 500 501 member := r.FormValue("member") 502 member = strings.TrimPrefix(member, "@") 503 if member == "" { 504 l.Error("empty member") 505 s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 506 return 507 } 508 l = l.With("member", member) 509 510 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 511 if err != nil { 512 l.Error("failed to resolve member identity to handle", "err", err) 513 s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 514 return 515 } 516 if memberId.Handle.IsInvalidHandle() { 517 l.Error("failed to resolve member identity to handle") 518 s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 519 return 520 } 521 522 // write to pds 523 client, err := s.OAuth.AuthorizedClient(r) 524 if err != nil { 525 l.Error("failed to authorize client", "err", err) 526 fail() 527 return 528 } 529 530 tx, err := s.Db.Begin() 531 if err != nil { 532 l.Error("failed to start txn", "err", err) 533 fail() 534 return 535 } 536 defer func() { 537 tx.Rollback() 538 s.Enforcer.E.LoadPolicy() 539 }() 540 541 rkey := tid.TID() 542 543 // add member to db 544 if err = db.AddSpindleMember(tx, models.SpindleMember{ 545 Did: syntax.DID(user.Did), 546 Rkey: rkey, 547 Instance: instance, 548 Subject: memberId.DID, 549 }); err != nil { 550 l.Error("failed to add spindle member", "err", err) 551 fail() 552 return 553 } 554 555 if err = s.Enforcer.AddSpindleMember(instance, memberId.DID.String()); err != nil { 556 l.Error("failed to add member to ACLs") 557 fail() 558 return 559 } 560 561 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 562 Collection: tangled.SpindleMemberNSID, 563 Repo: user.Did, 564 Rkey: rkey, 565 Record: &lexutil.LexiconTypeDecoder{ 566 Val: &tangled.SpindleMember{ 567 CreatedAt: time.Now().Format(time.RFC3339), 568 Instance: instance, 569 Subject: memberId.DID.String(), 570 }, 571 }, 572 }) 573 if err != nil { 574 l.Error("failed to add record to PDS", "err", err) 575 s.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 576 return 577 } 578 579 if err = tx.Commit(); err != nil { 580 l.Error("failed to commit txn", "err", err) 581 fail() 582 return 583 } 584 585 if err = s.Enforcer.E.SavePolicy(); err != nil { 586 l.Error("failed to add member to ACLs", "err", err) 587 fail() 588 return 589 } 590 591 // success 592 s.Pages.HxRedirect(w, fmt.Sprintf("/settings/spindles/%s", instance)) 593} 594 595func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { 596 user := s.OAuth.GetMultiAccountUser(r) 597 l := s.Logger.With("handler", "removeMember") 598 599 noticeId := "operation-error" 600 defaultErr := "Failed to remove member. Try again later." 601 fail := func() { 602 s.Pages.Notice(w, noticeId, defaultErr) 603 } 604 605 instance := chi.URLParam(r, "instance") 606 if instance == "" { 607 l.Error("empty instance") 608 fail() 609 return 610 } 611 l = l.With("instance", instance) 612 l = l.With("user", user.Did) 613 614 spindles, err := db.GetSpindles( 615 r.Context(), 616 s.Db, 617 orm.FilterEq("owner", user.Did), 618 orm.FilterEq("instance", instance), 619 ) 620 if err != nil || len(spindles) != 1 { 621 l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 622 fail() 623 return 624 } 625 626 if string(spindles[0].Owner) != user.Did { 627 l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 628 s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 629 return 630 } 631 632 member := r.FormValue("member") 633 member = strings.TrimPrefix(member, "@") 634 if member == "" { 635 l.Error("empty member") 636 s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 637 return 638 } 639 l = l.With("member", member) 640 641 memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 642 if err != nil { 643 l.Error("failed to resolve member identity to handle", "err", err) 644 s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 645 return 646 } 647 648 tx, err := s.Db.Begin() 649 if err != nil { 650 l.Error("failed to start txn", "err", err) 651 fail() 652 return 653 } 654 defer func() { 655 tx.Rollback() 656 s.Enforcer.E.LoadPolicy() 657 }() 658 659 // get the record from the DB first: 660 members, err := db.GetSpindleMembers( 661 s.Db, 662 orm.FilterEq("did", user.Did), 663 orm.FilterEq("instance", instance), 664 orm.FilterEq("subject", memberId.DID), 665 ) 666 if err != nil || len(members) != 1 { 667 l.Error("failed to get member", "err", err) 668 fail() 669 return 670 } 671 672 // remove from db 673 if err = db.RemoveSpindleMember( 674 tx, 675 orm.FilterEq("did", user.Did), 676 orm.FilterEq("instance", instance), 677 orm.FilterEq("subject", memberId.DID), 678 ); err != nil { 679 l.Error("failed to remove spindle member", "err", err) 680 fail() 681 return 682 } 683 684 // remove from enforcer 685 if err = s.Enforcer.RemoveSpindleMember(instance, memberId.DID.String()); err != nil { 686 l.Error("failed to update ACLs", "err", err) 687 fail() 688 return 689 } 690 691 client, err := s.OAuth.AuthorizedClient(r) 692 if err != nil { 693 l.Error("failed to authorize client", "err", err) 694 fail() 695 return 696 } 697 698 // remove from pds 699 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 700 Collection: tangled.SpindleMemberNSID, 701 Repo: user.Did, 702 Rkey: members[0].Rkey, 703 }) 704 if err != nil { 705 // non-fatal 706 l.Error("failed to delete record", "err", err) 707 } 708 709 // commit everything 710 if err = tx.Commit(); err != nil { 711 l.Error("failed to commit txn", "err", err) 712 fail() 713 return 714 } 715 716 // commit everything 717 if err = s.Enforcer.E.SavePolicy(); err != nil { 718 l.Error("failed to save ACLs", "err", err) 719 fail() 720 return 721 } 722 723 // ok 724 s.Pages.HxRefresh(w) 725}