this repo has no description
1package knots 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/eventconsumer" 23 "tangled.org/core/idresolver" 24 "tangled.org/core/rbac" 25 "tangled.org/core/tid" 26 27 comatproto "github.com/bluesky-social/indigo/api/atproto" 28 lexutil "github.com/bluesky-social/indigo/lex/util" 29) 30 31type Knots struct { 32 Db *db.DB 33 OAuth *oauth.OAuth 34 Pages *pages.Pages 35 Config *config.Config 36 Enforcer *rbac.Enforcer 37 IdResolver *idresolver.Resolver 38 Logger *slog.Logger 39 Knotstream *eventconsumer.Consumer 40} 41 42type tab = map[string]any 43 44var ( 45 knotsTabs []tab = []tab{ 46 {"Name": "profile", "Icon": "user"}, 47 {"Name": "keys", "Icon": "key"}, 48 {"Name": "emails", "Icon": "mail"}, 49 {"Name": "notifications", "Icon": "bell"}, 50 {"Name": "knots", "Icon": "volleyball"}, 51 {"Name": "spindles", "Icon": "spool"}, 52 } 53) 54 55func (k *Knots) Router() http.Handler { 56 r := chi.NewRouter() 57 58 r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots) 59 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register) 60 61 r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard) 62 r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete) 63 64 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 65 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 66 r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 67 68 return r 69} 70 71func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 72 user := k.OAuth.GetUser(r) 73 registrations, err := db.GetRegistrations( 74 k.Db, 75 db.FilterEq("did", user.Did), 76 ) 77 if err != nil { 78 k.Logger.Error("failed to fetch knot registrations", "err", err) 79 w.WriteHeader(http.StatusInternalServerError) 80 return 81 } 82 83 k.Pages.Knots(w, pages.KnotsParams{ 84 LoggedInUser: user, 85 Registrations: registrations, 86 Tabs: knotsTabs, 87 Tab: "knots", 88 }) 89} 90 91func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 92 l := k.Logger.With("handler", "dashboard") 93 94 user := k.OAuth.GetUser(r) 95 l = l.With("user", user.Did) 96 97 domain := chi.URLParam(r, "domain") 98 if domain == "" { 99 return 100 } 101 l = l.With("domain", domain) 102 103 registrations, err := db.GetRegistrations( 104 k.Db, 105 db.FilterEq("did", user.Did), 106 db.FilterEq("domain", domain), 107 ) 108 if err != nil { 109 l.Error("failed to get registrations", "err", err) 110 http.Error(w, "Not found", http.StatusNotFound) 111 return 112 } 113 if len(registrations) != 1 { 114 l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 115 return 116 } 117 registration := registrations[0] 118 119 members, err := k.Enforcer.GetUserByRole("server:member", domain) 120 if err != nil { 121 l.Error("failed to get knot members", "err", err) 122 http.Error(w, "Not found", http.StatusInternalServerError) 123 return 124 } 125 slices.Sort(members) 126 127 repos, err := db.GetRepos( 128 k.Db, 129 0, 130 db.FilterEq("knot", domain), 131 ) 132 if err != nil { 133 l.Error("failed to get knot repos", "err", err) 134 http.Error(w, "Not found", http.StatusInternalServerError) 135 return 136 } 137 138 // organize repos by did 139 repoMap := make(map[string][]models.Repo) 140 for _, r := range repos { 141 repoMap[r.Did] = append(repoMap[r.Did], r) 142 } 143 144 k.Pages.Knot(w, pages.KnotParams{ 145 LoggedInUser: user, 146 Registration: &registration, 147 Members: members, 148 Repos: repoMap, 149 IsOwner: true, 150 Tabs: knotsTabs, 151 Tab: "knots", 152 }) 153} 154 155func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 156 user := k.OAuth.GetUser(r) 157 l := k.Logger.With("handler", "register") 158 159 noticeId := "register-error" 160 defaultErr := "Failed to register knot. Try again later." 161 fail := func() { 162 k.Pages.Notice(w, noticeId, defaultErr) 163 } 164 165 domain := r.FormValue("domain") 166 // Strip protocol, trailing slashes, and whitespace 167 // Rkey cannot contain slashes 168 domain = strings.TrimSpace(domain) 169 domain = strings.TrimPrefix(domain, "https://") 170 domain = strings.TrimPrefix(domain, "http://") 171 domain = strings.TrimSuffix(domain, "/") 172 if domain == "" { 173 k.Pages.Notice(w, noticeId, "Incomplete form.") 174 return 175 } 176 l = l.With("domain", domain) 177 l = l.With("user", user.Did) 178 179 tx, err := k.Db.Begin() 180 if err != nil { 181 l.Error("failed to start transaction", "err", err) 182 fail() 183 return 184 } 185 defer func() { 186 tx.Rollback() 187 k.Enforcer.E.LoadPolicy() 188 }() 189 190 err = db.AddKnot(tx, domain, user.Did) 191 if err != nil { 192 l.Error("failed to insert", "err", err) 193 fail() 194 return 195 } 196 197 err = k.Enforcer.AddKnot(domain) 198 if err != nil { 199 l.Error("failed to create knot", "err", err) 200 fail() 201 return 202 } 203 204 // create record on pds 205 client, err := k.OAuth.AuthorizedClient(r) 206 if err != nil { 207 l.Error("failed to authorize client", "err", err) 208 fail() 209 return 210 } 211 212 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 213 var exCid *string 214 if ex != nil { 215 exCid = ex.Cid 216 } 217 218 // re-announce by registering under same rkey 219 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 220 Collection: tangled.KnotNSID, 221 Repo: user.Did, 222 Rkey: domain, 223 Record: &lexutil.LexiconTypeDecoder{ 224 Val: &tangled.Knot{ 225 CreatedAt: time.Now().Format(time.RFC3339), 226 }, 227 }, 228 SwapRecord: exCid, 229 }) 230 231 if err != nil { 232 l.Error("failed to put record", "err", err) 233 fail() 234 return 235 } 236 237 err = tx.Commit() 238 if err != nil { 239 l.Error("failed to commit transaction", "err", err) 240 fail() 241 return 242 } 243 244 err = k.Enforcer.E.SavePolicy() 245 if err != nil { 246 l.Error("failed to update ACL", "err", err) 247 k.Pages.HxRefresh(w) 248 return 249 } 250 251 // begin verification 252 err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 253 if err != nil { 254 l.Error("verification failed", "err", err) 255 k.Pages.HxRefresh(w) 256 return 257 } 258 259 err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 260 if err != nil { 261 l.Error("failed to mark verified", "err", err) 262 k.Pages.HxRefresh(w) 263 return 264 } 265 266 // add this knot to knotstream 267 go k.Knotstream.AddSource( 268 r.Context(), 269 eventconsumer.NewKnotSource(domain), 270 ) 271 272 // ok 273 k.Pages.HxRefresh(w) 274} 275 276func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 277 user := k.OAuth.GetUser(r) 278 l := k.Logger.With("handler", "delete") 279 280 noticeId := "operation-error" 281 defaultErr := "Failed to delete knot. Try again later." 282 fail := func() { 283 k.Pages.Notice(w, noticeId, defaultErr) 284 } 285 286 domain := chi.URLParam(r, "domain") 287 if domain == "" { 288 l.Error("empty domain") 289 fail() 290 return 291 } 292 293 // get record from db first 294 registrations, err := db.GetRegistrations( 295 k.Db, 296 db.FilterEq("did", user.Did), 297 db.FilterEq("domain", domain), 298 ) 299 if err != nil { 300 l.Error("failed to get registration", "err", err) 301 fail() 302 return 303 } 304 if len(registrations) != 1 { 305 l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 306 fail() 307 return 308 } 309 registration := registrations[0] 310 311 tx, err := k.Db.Begin() 312 if err != nil { 313 l.Error("failed to start txn", "err", err) 314 fail() 315 return 316 } 317 defer func() { 318 tx.Rollback() 319 k.Enforcer.E.LoadPolicy() 320 }() 321 322 err = db.DeleteKnot( 323 tx, 324 db.FilterEq("did", user.Did), 325 db.FilterEq("domain", domain), 326 ) 327 if err != nil { 328 l.Error("failed to delete registration", "err", err) 329 fail() 330 return 331 } 332 333 // delete from enforcer if it was registered 334 if registration.Registered != nil { 335 err = k.Enforcer.RemoveKnot(domain) 336 if err != nil { 337 l.Error("failed to update ACL", "err", err) 338 fail() 339 return 340 } 341 } 342 343 client, err := k.OAuth.AuthorizedClient(r) 344 if err != nil { 345 l.Error("failed to authorize client", "err", err) 346 fail() 347 return 348 } 349 350 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 351 Collection: tangled.KnotNSID, 352 Repo: user.Did, 353 Rkey: domain, 354 }) 355 if err != nil { 356 // non-fatal 357 l.Error("failed to delete record", "err", err) 358 } 359 360 err = tx.Commit() 361 if err != nil { 362 l.Error("failed to delete knot", "err", err) 363 fail() 364 return 365 } 366 367 err = k.Enforcer.E.SavePolicy() 368 if err != nil { 369 l.Error("failed to update ACL", "err", err) 370 k.Pages.HxRefresh(w) 371 return 372 } 373 374 shouldRedirect := r.Header.Get("shouldRedirect") 375 if shouldRedirect == "true" { 376 k.Pages.HxRedirect(w, "/knots") 377 return 378 } 379 380 w.Write([]byte{}) 381} 382 383func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 384 user := k.OAuth.GetUser(r) 385 l := k.Logger.With("handler", "retry") 386 387 noticeId := "operation-error" 388 defaultErr := "Failed to verify knot. Try again later." 389 fail := func() { 390 k.Pages.Notice(w, noticeId, defaultErr) 391 } 392 393 domain := chi.URLParam(r, "domain") 394 if domain == "" { 395 l.Error("empty domain") 396 fail() 397 return 398 } 399 l = l.With("domain", domain) 400 l = l.With("user", user.Did) 401 402 // get record from db first 403 registrations, err := db.GetRegistrations( 404 k.Db, 405 db.FilterEq("did", user.Did), 406 db.FilterEq("domain", domain), 407 ) 408 if err != nil { 409 l.Error("failed to get registration", "err", err) 410 fail() 411 return 412 } 413 if len(registrations) != 1 { 414 l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 415 fail() 416 return 417 } 418 registration := registrations[0] 419 420 // begin verification 421 err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 422 if err != nil { 423 l.Error("verification failed", "err", err) 424 425 if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 426 k.Pages.Notice(w, noticeId, "Failed to verify knot, XRPC queries are unsupported on this knot, consider upgrading!") 427 return 428 } 429 430 if e, ok := err.(*serververify.OwnerMismatch); ok { 431 k.Pages.Notice(w, noticeId, e.Error()) 432 return 433 } 434 435 fail() 436 return 437 } 438 439 err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 440 if err != nil { 441 l.Error("failed to mark verified", "err", err) 442 k.Pages.Notice(w, noticeId, err.Error()) 443 return 444 } 445 446 // if this knot requires upgrade, then emit a record too 447 // 448 // this is part of migrating from the old knot system to the new one 449 if registration.NeedsUpgrade { 450 // re-announce by registering under same rkey 451 client, err := k.OAuth.AuthorizedClient(r) 452 if err != nil { 453 l.Error("failed to authorize client", "err", err) 454 fail() 455 return 456 } 457 458 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 459 var exCid *string 460 if ex != nil { 461 exCid = ex.Cid 462 } 463 464 // ignore the error here 465 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 466 Collection: tangled.KnotNSID, 467 Repo: user.Did, 468 Rkey: domain, 469 Record: &lexutil.LexiconTypeDecoder{ 470 Val: &tangled.Knot{ 471 CreatedAt: time.Now().Format(time.RFC3339), 472 }, 473 }, 474 SwapRecord: exCid, 475 }) 476 if err != nil { 477 l.Error("non-fatal: failed to reannouce knot", "err", err) 478 } 479 } 480 481 // add this knot to knotstream 482 go k.Knotstream.AddSource( 483 r.Context(), 484 eventconsumer.NewKnotSource(domain), 485 ) 486 487 shouldRefresh := r.Header.Get("shouldRefresh") 488 if shouldRefresh == "true" { 489 k.Pages.HxRefresh(w) 490 return 491 } 492 493 // Get updated registration to show 494 registrations, err = db.GetRegistrations( 495 k.Db, 496 db.FilterEq("did", user.Did), 497 db.FilterEq("domain", domain), 498 ) 499 if err != nil { 500 l.Error("failed to get registration", "err", err) 501 fail() 502 return 503 } 504 if len(registrations) != 1 { 505 l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 506 fail() 507 return 508 } 509 updatedRegistration := registrations[0] 510 511 w.Header().Set("HX-Reswap", "outerHTML") 512 k.Pages.KnotListing(w, pages.KnotListingParams{ 513 Registration: &updatedRegistration, 514 }) 515} 516 517func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 518 user := k.OAuth.GetUser(r) 519 l := k.Logger.With("handler", "addMember") 520 521 domain := chi.URLParam(r, "domain") 522 if domain == "" { 523 l.Error("empty domain") 524 http.Error(w, "Not found", http.StatusNotFound) 525 return 526 } 527 l = l.With("domain", domain) 528 l = l.With("user", user.Did) 529 530 registrations, err := db.GetRegistrations( 531 k.Db, 532 db.FilterEq("did", user.Did), 533 db.FilterEq("domain", domain), 534 db.FilterIsNot("registered", "null"), 535 ) 536 if err != nil { 537 l.Error("failed to get registration", "err", err) 538 return 539 } 540 if len(registrations) != 1 { 541 l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 542 return 543 } 544 registration := registrations[0] 545 546 noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 547 defaultErr := "Failed to add member. Try again later." 548 fail := func() { 549 k.Pages.Notice(w, noticeId, defaultErr) 550 } 551 552 member := r.FormValue("member") 553 member = strings.TrimPrefix(member, "@") 554 if member == "" { 555 l.Error("empty member") 556 k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 557 return 558 } 559 l = l.With("member", member) 560 561 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 562 if err != nil { 563 l.Error("failed to resolve member identity to handle", "err", err) 564 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 565 return 566 } 567 if memberId.Handle.IsInvalidHandle() { 568 l.Error("failed to resolve member identity to handle") 569 k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 570 return 571 } 572 573 // write to pds 574 client, err := k.OAuth.AuthorizedClient(r) 575 if err != nil { 576 l.Error("failed to authorize client", "err", err) 577 fail() 578 return 579 } 580 581 rkey := tid.TID() 582 583 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 584 Collection: tangled.KnotMemberNSID, 585 Repo: user.Did, 586 Rkey: rkey, 587 Record: &lexutil.LexiconTypeDecoder{ 588 Val: &tangled.KnotMember{ 589 CreatedAt: time.Now().Format(time.RFC3339), 590 Domain: domain, 591 Subject: memberId.DID.String(), 592 }, 593 }, 594 }) 595 if err != nil { 596 l.Error("failed to add record to PDS", "err", err) 597 k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 598 return 599 } 600 601 err = k.Enforcer.AddKnotMember(domain, memberId.DID.String()) 602 if err != nil { 603 l.Error("failed to add member to ACLs", "err", err) 604 fail() 605 return 606 } 607 608 err = k.Enforcer.E.SavePolicy() 609 if err != nil { 610 l.Error("failed to save ACL policy", "err", err) 611 fail() 612 return 613 } 614 615 // success 616 k.Pages.HxRedirect(w, fmt.Sprintf("/settings/knots/%s", domain)) 617} 618 619func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 620 user := k.OAuth.GetUser(r) 621 l := k.Logger.With("handler", "removeMember") 622 623 noticeId := "operation-error" 624 defaultErr := "Failed to remove member. Try again later." 625 fail := func() { 626 k.Pages.Notice(w, noticeId, defaultErr) 627 } 628 629 domain := chi.URLParam(r, "domain") 630 if domain == "" { 631 l.Error("empty domain") 632 fail() 633 return 634 } 635 l = l.With("domain", domain) 636 l = l.With("user", user.Did) 637 638 registrations, err := db.GetRegistrations( 639 k.Db, 640 db.FilterEq("did", user.Did), 641 db.FilterEq("domain", domain), 642 db.FilterIsNot("registered", "null"), 643 ) 644 if err != nil { 645 l.Error("failed to get registration", "err", err) 646 return 647 } 648 if len(registrations) != 1 { 649 l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 650 return 651 } 652 653 member := r.FormValue("member") 654 member = strings.TrimPrefix(member, "@") 655 if member == "" { 656 l.Error("empty member") 657 k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 658 return 659 } 660 l = l.With("member", member) 661 662 memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 663 if err != nil { 664 l.Error("failed to resolve member identity to handle", "err", err) 665 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 666 return 667 } 668 if memberId.Handle.IsInvalidHandle() { 669 l.Error("failed to resolve member identity to handle") 670 k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 671 return 672 } 673 674 // remove from enforcer 675 err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String()) 676 if err != nil { 677 l.Error("failed to update ACLs", "err", err) 678 fail() 679 return 680 } 681 682 client, err := k.OAuth.AuthorizedClient(r) 683 if err != nil { 684 l.Error("failed to authorize client", "err", err) 685 fail() 686 return 687 } 688 689 // TODO: We need to track the rkey for knot members to delete the record 690 // For now, just remove from ACLs 691 _ = client 692 693 // commit everything 694 err = k.Enforcer.E.SavePolicy() 695 if err != nil { 696 l.Error("failed to save ACLs", "err", err) 697 fail() 698 return 699 } 700 701 // ok 702 k.Pages.HxRefresh(w) 703}