This repository has no description
0

Configure Feed

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

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