Monorepo for Tangled tangled.org

appview: allow timeline db queries to be filterable by users follows #626

closed opened by willdot.net targeting master from [deleted fork]: feat/filter-user-timeline

Signed-off-by: Will Andrews did:plc:dadhhalkfcq3gucaq25hjqon

Labels
enhancement
assignee

None yet.

Participants 3
AT URI
at://did:plc:dadhhalkfcq3gucaq25hjqon/sh.tangled.repo.pull/3m24d33byfj22
+648 -1484
Interdiff #2 โ†’ #3
appview/db/timeline.go

This file has not been changed.

+4 -417
appview/state/state.go
··· 5 5 6 6 7 7 8 - "log" 9 - "log/slog" 10 - "net/http" 11 - "strconv" 12 - "strings" 13 - "time" 14 8 15 9 16 10 ··· 249 243 250 244 251 245 252 - } 253 246 254 - func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 255 - filtered := getTimelineFilteredQuery(r) 256 - user := s.oauth.GetUser(r) 257 247 258 - var userDid string 259 - if user != nil { 260 - userDid = user.Did 261 - } 262 - timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) 263 - if err != nil { 264 - log.Println(err) 265 - s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 266 248 267 249 268 250 ··· 272 254 273 255 274 256 257 + if user != nil { 258 + userDid = user.Did 259 + } 260 + timeline, err := db.MakeTimeline(s.db, 50, userDid) 275 - 276 - 277 - 278 - 279 - 280 - 281 - 282 - Timeline: timeline, 283 - Repos: repos, 284 - GfiLabel: gfiLabel, 285 - Filtered: filtered, 286 - })) 287 - } 288 - 289 - 290 - 291 - 292 - 293 - 294 - 295 - 296 - 297 - 298 - 299 - 300 - 301 - 302 - 303 - 304 - 305 - 306 - 307 - 308 - 309 - 310 - 311 - 312 - 313 - 314 - 315 - 316 - 317 - 318 - 319 - 320 - 321 - 322 - 323 - 324 - 325 - } 326 - 327 - func (s *State) Home(w http.ResponseWriter, r *http.Request) { 328 - filtered := getTimelineFilteredQuery(r) 329 - timeline, err := db.MakeTimeline(s.db, 5, "", filtered) 330 261 if err != nil { 331 262 log.Println(err) 332 263 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 333 - 334 - 335 - 336 - 337 - 338 - 339 - 340 - 341 - 342 - 343 - 344 - LoggedInUser: nil, 345 - Timeline: timeline, 346 - Repos: repos, 347 - Filtered: filtered, 348 - }) 349 - } 350 - 351 - 352 - 353 - 354 - 355 - 356 - 357 - 358 - 359 - 360 - 361 - 362 - 363 - 364 - 365 - 366 - 367 - 368 - 369 - 370 - 371 - 372 - 373 - 374 - 375 - 376 - 377 - 378 - 379 - 380 - 381 - 382 - 383 - 384 - 385 - 386 - 387 - 388 - 389 - 390 - 391 - 392 - 393 - 394 - 395 - 396 - 397 - 398 - 399 - 400 - 401 - 402 - 403 - 404 - 405 - 406 - 407 - 408 - 409 - 410 - 411 - 412 - 413 - 414 - 415 - 416 - 417 - 418 - 419 - 420 - 421 - 422 - 423 - 424 - 425 - 426 - 427 - 428 - 429 - 430 - 431 - 432 - 433 - 434 - 435 - 436 - 437 - 438 - 439 - 440 - 441 - 442 - 443 - 444 - 445 - 446 - 447 - 448 - 449 - 450 - 451 - 452 - 453 - 454 - 455 - 456 - 457 - 458 - 459 - 460 - 461 - 462 - 463 - 464 - 465 - 466 - 467 - 468 - 469 - 470 - 471 - 472 - 473 - 474 - 475 - 476 - 477 - 478 - 479 - 480 - 481 - 482 - 483 - 484 - 485 - 486 - 487 - 488 - 489 - 490 - 491 - 492 - 493 - 494 - 495 - 496 - 497 - 498 - 499 - 500 - 501 - 502 - 503 - 504 - 505 - 506 - 507 - 508 - 509 - 510 - 511 - 512 - 513 - 514 - 515 - 516 - 517 - 518 - 519 - 520 - 521 - 522 - 523 - 524 - 525 - 526 - 527 - 528 - 529 - 530 - 531 - 532 - 533 - 534 - 535 - 536 - 537 - 538 - 539 - 540 - 541 - 542 - 543 - 544 - 545 - 546 - 547 - 548 - 549 - 550 - 551 - 552 - 553 - 554 - 555 - 556 - 557 - 558 - 559 - 560 - 561 - 562 - 563 - 564 - 565 - 566 - 567 - 568 - 569 - 570 - 571 - 572 - 573 - 574 - 575 - 576 - 577 - 578 - 579 - 580 - 581 - 582 - 583 - 584 - 585 - 586 - 587 - 588 - 589 - 590 - 591 - 592 - 593 - 594 - 595 - 596 - 597 - 598 - 599 - 600 - 601 - 602 - 603 - 604 - 605 - 606 - 607 - 608 - 609 - 610 - 611 - 612 - 613 - 614 - 615 - 616 - 617 - 618 - 619 - 620 - 621 - 622 - 623 - 624 - 625 - 626 - 627 - 628 - 629 - 630 - 631 - 632 - 633 - 634 - 635 - 636 - 637 - 638 - 639 - 640 - 641 - 642 - 643 - 644 - 645 - 646 - 647 - 648 - 649 - 650 - 651 - 652 - 653 - 654 - 655 - 656 - 657 - 658 - 659 - 660 - 661 - 662 - 663 - 664 - 665 - return nil 666 - } 667 - 668 - func getTimelineFilteredQuery(r *http.Request) bool { 669 - filteredStr := r.URL.Query().Get("filtered") 670 - if filteredStr == "" { 671 - return false 672 - } 673 - 674 - res, _ := strconv.ParseBool(filteredStr) 675 - return res 676 - }
appview/pages/pages.go

This patch was likely rebased, as context lines do not match.

appview/pages/templates/timeline/fragments/timeline.html

This file has not been changed.

+24 -24
appview/issues/issues.go
··· 12 12 "time" 13 13 14 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + atpclient "github.com/bluesky-social/indigo/atproto/client" 15 16 "github.com/bluesky-social/indigo/atproto/syntax" 16 17 lexutil "github.com/bluesky-social/indigo/lex/util" 17 18 "github.com/go-chi/chi/v5" ··· 26 27 "tangled.org/core/appview/pagination" 27 28 "tangled.org/core/appview/reporesolver" 28 29 "tangled.org/core/appview/validator" 29 - "tangled.org/core/appview/xrpcclient" 30 30 "tangled.org/core/idresolver" 31 31 tlog "tangled.org/core/log" 32 32 "tangled.org/core/tid" ··· 80 80 81 81 82 82 83 + return 84 + } 83 85 86 + reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) 87 + if err != nil { 88 + l.Error("failed to get issue reactions", "err", err) 89 + } 84 90 85 91 86 92 ··· 106 112 107 113 108 114 115 + Issue: issue, 116 + CommentList: issue.CommentList(), 117 + OrderedReactionKinds: models.OrderedReactionKinds, 118 + Reactions: reactionMap, 119 + UserReacted: userReactions, 120 + LabelDefs: defs, 121 + }) 109 122 110 123 111 124 ··· 150 163 151 164 152 165 153 - 154 - 155 - 156 - 157 - 158 - 159 - 160 - 161 - 162 - 163 - 164 - 165 - 166 166 return 167 167 } 168 168 169 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 169 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 170 if err != nil { 171 171 l.Error("failed to get record", "err", err) 172 172 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 173 173 return 174 174 } 175 175 176 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 176 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 177 177 Collection: tangled.RepoIssueNSID, 178 178 Repo: user.Did, 179 179 Rkey: newIssue.Rkey, ··· 241 241 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 242 return 243 243 } 244 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 244 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 245 245 Collection: tangled.RepoIssueNSID, 246 246 Repo: issue.Did, 247 247 Rkey: issue.Rkey, ··· 408 408 } 409 409 410 410 // create a record first 411 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 411 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 412 412 Collection: tangled.RepoIssueCommentNSID, 413 413 Repo: comment.Did, 414 414 Rkey: comment.Rkey, ··· 559 559 // rkey is optional, it was introduced later 560 560 if newComment.Rkey != "" { 561 561 // update the record on pds 562 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 562 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 563 563 if err != nil { 564 564 log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 565 565 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 566 566 return 567 567 } 568 568 569 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 569 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 570 570 Collection: tangled.RepoIssueCommentNSID, 571 571 Repo: user.Did, 572 572 Rkey: newComment.Rkey, ··· 733 733 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 734 734 return 735 735 } 736 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 736 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 737 737 Collection: tangled.RepoIssueCommentNSID, 738 738 Repo: user.Did, 739 739 Rkey: comment.Rkey, ··· 865 865 rp.pages.Notice(w, "issues", "Failed to create issue.") 866 866 return 867 867 } 868 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 868 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 869 869 Collection: tangled.RepoIssueNSID, 870 870 Repo: user.Did, 871 871 Rkey: issue.Rkey, ··· 923 923 // this is used to rollback changes made to the PDS 924 924 // 925 925 // it is a no-op if the provided ATURI is empty 926 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 926 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 927 927 if aturi == "" { 928 928 return nil 929 929 } ··· 934 934 repo := parsed.Authority().String() 935 935 rkey := parsed.RecordKey().String() 936 936 937 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 937 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 938 938 Collection: collection, 939 939 Repo: repo, 940 940 Rkey: rkey,
+6 -6
appview/knots/knots.go
··· 185 185 return 186 186 } 187 187 188 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 188 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 189 189 var exCid *string 190 190 if ex != nil { 191 191 exCid = ex.Cid 192 192 } 193 193 194 194 // re-announce by registering under same rkey 195 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 195 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 196 196 Collection: tangled.KnotNSID, 197 197 Repo: user.Did, 198 198 Rkey: domain, ··· 323 323 return 324 324 } 325 325 326 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 326 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 327 327 Collection: tangled.KnotNSID, 328 328 Repo: user.Did, 329 329 Rkey: domain, ··· 431 431 return 432 432 } 433 433 434 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 434 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 435 435 var exCid *string 436 436 if ex != nil { 437 437 exCid = ex.Cid 438 438 } 439 439 440 440 // ignore the error here 441 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 441 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 442 442 Collection: tangled.KnotNSID, 443 443 Repo: user.Did, 444 444 Rkey: domain, ··· 555 555 556 556 rkey := tid.TID() 557 557 558 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 558 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 559 559 Collection: tangled.KnotMemberNSID, 560 560 Repo: user.Did, 561 561 Rkey: rkey,
+9 -9
appview/labels/labels.go
··· 9 9 "net/http" 10 10 "time" 11 11 12 - comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/syntax" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - "github.com/go-chi/chi/v5" 16 - 17 12 "tangled.org/core/api/tangled" 18 13 "tangled.org/core/appview/db" 19 14 "tangled.org/core/appview/middleware" ··· 21 16 "tangled.org/core/appview/oauth" 22 17 "tangled.org/core/appview/pages" 23 18 "tangled.org/core/appview/validator" 24 - "tangled.org/core/appview/xrpcclient" 25 19 "tangled.org/core/log" 26 20 "tangled.org/core/rbac" 27 21 "tangled.org/core/tid" 22 + 23 + comatproto "github.com/bluesky-social/indigo/api/atproto" 24 + atpclient "github.com/bluesky-social/indigo/atproto/client" 25 + "github.com/bluesky-social/indigo/atproto/syntax" 26 + lexutil "github.com/bluesky-social/indigo/lex/util" 27 + "github.com/go-chi/chi/v5" 28 28 ) 29 29 30 30 type Labels struct { ··· 196 196 return 197 197 } 198 198 199 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 199 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 200 Collection: tangled.LabelOpNSID, 201 201 Repo: did, 202 202 Rkey: rkey, ··· 252 252 // this is used to rollback changes made to the PDS 253 253 // 254 254 // it is a no-op if the provided ATURI is empty 255 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 255 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 256 256 if aturi == "" { 257 257 return nil 258 258 } ··· 263 263 repo := parsed.Authority().String() 264 264 rkey := parsed.RecordKey().String() 265 265 266 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 266 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 267 267 Collection: collection, 268 268 Repo: repo, 269 269 Rkey: rkey,
+5 -14
appview/middleware/middleware.go
··· 43 43 44 44 type middlewareFunc func(http.Handler) http.Handler 45 45 46 - func (mw *Middleware) TryRefreshSession() middlewareFunc { 47 - return func(next http.Handler) http.Handler { 48 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 - _, _, _ = mw.oauth.GetSession(r) 50 - next.ServeHTTP(w, r) 51 - }) 52 - } 53 - } 54 - 55 - func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 56 47 return func(next http.Handler) http.Handler { 57 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 58 49 returnURL := "/" ··· 72 63 } 73 64 } 74 65 75 - _, auth, err := a.GetSession(r) 66 + sess, err := o.ResumeSession(r) 76 67 if err != nil { 77 - log.Println("not logged in, redirecting", "err", err) 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 78 69 redirectFunc(w, r) 79 70 return 80 71 } 81 72 82 - if !auth { 83 - log.Printf("not logged in, redirecting") 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 84 75 redirectFunc(w, r) 85 76 return 86 77 }
+18 -20
appview/notifications/notifications.go
··· 1 1 package notifications 2 2 3 3 import ( 4 - "fmt" 5 4 "log" 6 5 "net/http" 7 6 "strconv" ··· 31 30 func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 32 31 r := chi.NewRouter() 33 32 34 - r.Use(middleware.AuthMiddleware(n.oauth)) 35 - 36 - r.With(middleware.Paginate).Get("/", n.notificationsPage) 37 - 38 33 r.Get("/count", n.getUnreadCount) 39 - r.Post("/{id}/read", n.markRead) 40 - r.Post("/read-all", n.markAllRead) 41 - r.Delete("/{id}", n.deleteNotification) 34 + 35 + r.Group(func(r chi.Router) { 36 + r.Use(middleware.AuthMiddleware(n.oauth)) 37 + r.With(middleware.Paginate).Get("/", n.notificationsPage) 38 + r.Post("/{id}/read", n.markRead) 39 + r.Post("/read-all", n.markAllRead) 40 + r.Delete("/{id}", n.deleteNotification) 41 + }) 42 42 43 43 return r 44 44 } 45 45 46 46 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 47 - userDid := n.oauth.GetDid(r) 47 + user := n.oauth.GetUser(r) 48 48 49 49 page, ok := r.Context().Value("page").(pagination.Page) 50 50 if !ok { ··· 54 54 55 55 total, err := db.CountNotifications( 56 56 n.db, 57 - db.FilterEq("recipient_did", userDid), 57 + db.FilterEq("recipient_did", user.Did), 58 58 ) 59 59 if err != nil { 60 60 log.Println("failed to get total notifications:", err) ··· 65 65 notifications, err := db.GetNotificationsWithEntities( 66 66 n.db, 67 67 page, 68 - db.FilterEq("recipient_did", userDid), 68 + db.FilterEq("recipient_did", user.Did), 69 69 ) 70 70 if err != nil { 71 71 log.Println("failed to get notifications:", err) ··· 73 73 return 74 74 } 75 75 76 - err = n.db.MarkAllNotificationsRead(r.Context(), userDid) 76 + err = n.db.MarkAllNotificationsRead(r.Context(), user.Did) 77 77 if err != nil { 78 78 log.Println("failed to mark notifications as read:", err) 79 79 } 80 80 81 81 unreadCount := 0 82 82 83 - user := n.oauth.GetUser(r) 84 - if user == nil { 85 - http.Error(w, "Failed to get user", http.StatusInternalServerError) 86 - return 87 - } 88 - 89 - fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{ 83 + n.pages.Notifications(w, pages.NotificationsParams{ 90 84 LoggedInUser: user, 91 85 Notifications: notifications, 92 86 UnreadCount: unreadCount, 93 87 Page: page, 94 88 Total: total, 95 - })) 89 + }) 96 90 } 97 91 98 92 func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 99 93 user := n.oauth.GetUser(r) 94 + if user == nil { 95 + return 96 + } 97 + 100 98 count, err := db.CountNotifications( 101 99 n.db, 102 100 db.FilterEq("recipient_did", user.Did),
-24
appview/oauth/client/oauth_client.go
··· 1 - package client 2 - 3 - import ( 4 - oauth "tangled.org/anirudh.fi/atproto-oauth" 5 - "tangled.org/anirudh.fi/atproto-oauth/helpers" 6 - ) 7 - 8 - type OAuthClient struct { 9 - *oauth.Client 10 - } 11 - 12 - func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) { 13 - k, err := helpers.ParseJWKFromBytes([]byte(clientJwk)) 14 - if err != nil { 15 - return nil, err 16 - } 17 - 18 - cli, err := oauth.NewClient(oauth.ClientArgs{ 19 - ClientId: clientId, 20 - ClientJwk: k, 21 - RedirectUri: redirectUri, 22 - }) 23 - return &OAuthClient{cli}, err 24 - }
+2 -1
appview/oauth/consts.go
··· 1 1 package oauth 2 2 3 3 const ( 4 - SessionName = "appview-session" 4 + SessionName = "appview-session-v2" 5 5 SessionHandle = "handle" 6 6 SessionDid = "did" 7 + SessionId = "id" 7 8 SessionPds = "pds" 8 9 SessionAccessJwt = "accessJwt" 9 10 SessionRefreshJwt = "refreshJwt"
+65
appview/oauth/handler.go
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "net/http" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "github.com/lestrrat-go/jwx/v2/jwk" 10 + ) 11 + 12 + func (o *OAuth) Router() http.Handler { 13 + r := chi.NewRouter() 14 + 15 + r.Get("/oauth/client-metadata.json", o.clientMetadata) 16 + r.Get("/oauth/jwks.json", o.jwks) 17 + r.Get("/oauth/callback", o.callback) 18 + return r 19 + } 20 + 21 + func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 22 + doc := o.ClientApp.Config.ClientMetadata() 23 + doc.JWKSURI = &o.JwksUri 24 + 25 + w.Header().Set("Content-Type", "application/json") 26 + if err := json.NewEncoder(w).Encode(doc); err != nil { 27 + http.Error(w, err.Error(), http.StatusInternalServerError) 28 + return 29 + } 30 + } 31 + 32 + func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 33 + jwks := o.Config.OAuth.Jwks 34 + pubKey, err := pubKeyFromJwk(jwks) 35 + if err != nil { 36 + log.Printf("error parsing public key: %v", err) 37 + http.Error(w, err.Error(), http.StatusInternalServerError) 38 + return 39 + } 40 + 41 + response := map[string]any{ 42 + "keys": []jwk.Key{pubKey}, 43 + } 44 + 45 + w.Header().Set("Content-Type", "application/json") 46 + w.WriteHeader(http.StatusOK) 47 + json.NewEncoder(w).Encode(response) 48 + } 49 + 50 + func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 51 + ctx := r.Context() 52 + 53 + sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 54 + if err != nil { 55 + http.Error(w, err.Error(), http.StatusInternalServerError) 56 + return 57 + } 58 + 59 + if err := o.SaveSession(w, r, sessData); err != nil { 60 + http.Error(w, err.Error(), http.StatusInternalServerError) 61 + return 62 + } 63 + 64 + http.Redirect(w, r, "/", http.StatusFound) 65 + }
-538
appview/oauth/handler/handler.go
··· 1 - package oauth 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "encoding/json" 7 - "fmt" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "slices" 12 - "strings" 13 - "time" 14 - 15 - "github.com/go-chi/chi/v5" 16 - "github.com/gorilla/sessions" 17 - "github.com/lestrrat-go/jwx/v2/jwk" 18 - "github.com/posthog/posthog-go" 19 - "tangled.org/anirudh.fi/atproto-oauth/helpers" 20 - tangled "tangled.org/core/api/tangled" 21 - sessioncache "tangled.org/core/appview/cache/session" 22 - "tangled.org/core/appview/config" 23 - "tangled.org/core/appview/db" 24 - "tangled.org/core/appview/middleware" 25 - "tangled.org/core/appview/oauth" 26 - "tangled.org/core/appview/oauth/client" 27 - "tangled.org/core/appview/pages" 28 - "tangled.org/core/consts" 29 - "tangled.org/core/idresolver" 30 - "tangled.org/core/rbac" 31 - "tangled.org/core/tid" 32 - ) 33 - 34 - const ( 35 - oauthScope = "atproto transition:generic" 36 - ) 37 - 38 - type OAuthHandler struct { 39 - config *config.Config 40 - pages *pages.Pages 41 - idResolver *idresolver.Resolver 42 - sess *sessioncache.SessionStore 43 - db *db.DB 44 - store *sessions.CookieStore 45 - oauth *oauth.OAuth 46 - enforcer *rbac.Enforcer 47 - posthog posthog.Client 48 - } 49 - 50 - func New( 51 - config *config.Config, 52 - pages *pages.Pages, 53 - idResolver *idresolver.Resolver, 54 - db *db.DB, 55 - sess *sessioncache.SessionStore, 56 - store *sessions.CookieStore, 57 - oauth *oauth.OAuth, 58 - enforcer *rbac.Enforcer, 59 - posthog posthog.Client, 60 - ) *OAuthHandler { 61 - return &OAuthHandler{ 62 - config: config, 63 - pages: pages, 64 - idResolver: idResolver, 65 - db: db, 66 - sess: sess, 67 - store: store, 68 - oauth: oauth, 69 - enforcer: enforcer, 70 - posthog: posthog, 71 - } 72 - } 73 - 74 - func (o *OAuthHandler) Router() http.Handler { 75 - r := chi.NewRouter() 76 - 77 - r.Get("/login", o.login) 78 - r.Post("/login", o.login) 79 - 80 - r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout) 81 - 82 - r.Get("/oauth/client-metadata.json", o.clientMetadata) 83 - r.Get("/oauth/jwks.json", o.jwks) 84 - r.Get("/oauth/callback", o.callback) 85 - return r 86 - } 87 - 88 - func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { 89 - w.Header().Set("Content-Type", "application/json") 90 - w.WriteHeader(http.StatusOK) 91 - json.NewEncoder(w).Encode(o.oauth.ClientMetadata()) 92 - } 93 - 94 - func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { 95 - jwks := o.config.OAuth.Jwks 96 - pubKey, err := pubKeyFromJwk(jwks) 97 - if err != nil { 98 - log.Printf("error parsing public key: %v", err) 99 - http.Error(w, err.Error(), http.StatusInternalServerError) 100 - return 101 - } 102 - 103 - response := helpers.CreateJwksResponseObject(pubKey) 104 - 105 - w.Header().Set("Content-Type", "application/json") 106 - w.WriteHeader(http.StatusOK) 107 - json.NewEncoder(w).Encode(response) 108 - } 109 - 110 - func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 111 - switch r.Method { 112 - case http.MethodGet: 113 - returnURL := r.URL.Query().Get("return_url") 114 - o.pages.Login(w, pages.LoginParams{ 115 - ReturnUrl: returnURL, 116 - }) 117 - case http.MethodPost: 118 - handle := r.FormValue("handle") 119 - 120 - // when users copy their handle from bsky.app, it tends to have these characters around it: 121 - // 122 - // @nelind.dk: 123 - // \u202a ensures that the handle is always rendered left to right and 124 - // \u202c reverts that so the rest of the page renders however it should 125 - handle = strings.TrimPrefix(handle, "\u202a") 126 - handle = strings.TrimSuffix(handle, "\u202c") 127 - 128 - // `@` is harmless 129 - handle = strings.TrimPrefix(handle, "@") 130 - 131 - // basic handle validation 132 - if !strings.Contains(handle, ".") { 133 - log.Println("invalid handle format", "raw", handle) 134 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle)) 135 - return 136 - } 137 - 138 - resolved, err := o.idResolver.ResolveIdent(r.Context(), handle) 139 - if err != nil { 140 - log.Println("failed to resolve handle:", err) 141 - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 142 - return 143 - } 144 - self := o.oauth.ClientMetadata() 145 - oauthClient, err := client.NewClient( 146 - self.ClientID, 147 - o.config.OAuth.Jwks, 148 - self.RedirectURIs[0], 149 - ) 150 - 151 - if err != nil { 152 - log.Println("failed to create oauth client:", err) 153 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 154 - return 155 - } 156 - 157 - authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) 158 - if err != nil { 159 - log.Println("failed to resolve auth server:", err) 160 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 161 - return 162 - } 163 - 164 - authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) 165 - if err != nil { 166 - log.Println("failed to fetch auth server metadata:", err) 167 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 168 - return 169 - } 170 - 171 - dpopKey, err := helpers.GenerateKey(nil) 172 - if err != nil { 173 - log.Println("failed to generate dpop key:", err) 174 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 175 - return 176 - } 177 - 178 - dpopKeyJson, err := json.Marshal(dpopKey) 179 - if err != nil { 180 - log.Println("failed to marshal dpop key:", err) 181 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 182 - return 183 - } 184 - 185 - parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) 186 - if err != nil { 187 - log.Println("failed to send par auth request:", err) 188 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 189 - return 190 - } 191 - 192 - err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{ 193 - Did: resolved.DID.String(), 194 - PdsUrl: resolved.PDSEndpoint(), 195 - Handle: handle, 196 - AuthserverIss: authMeta.Issuer, 197 - PkceVerifier: parResp.PkceVerifier, 198 - DpopAuthserverNonce: parResp.DpopAuthserverNonce, 199 - DpopPrivateJwk: string(dpopKeyJson), 200 - State: parResp.State, 201 - ReturnUrl: r.FormValue("return_url"), 202 - }) 203 - if err != nil { 204 - log.Println("failed to save oauth request:", err) 205 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 206 - return 207 - } 208 - 209 - u, _ := url.Parse(authMeta.AuthorizationEndpoint) 210 - query := url.Values{} 211 - query.Add("client_id", self.ClientID) 212 - query.Add("request_uri", parResp.RequestUri) 213 - u.RawQuery = query.Encode() 214 - o.pages.HxRedirect(w, u.String()) 215 - } 216 - } 217 - 218 - func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { 219 - state := r.FormValue("state") 220 - 221 - oauthRequest, err := o.sess.GetRequestByState(r.Context(), state) 222 - if err != nil { 223 - log.Println("failed to get oauth request:", err) 224 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 225 - return 226 - } 227 - 228 - defer func() { 229 - err := o.sess.DeleteRequestByState(r.Context(), state) 230 - if err != nil { 231 - log.Println("failed to delete oauth request for state:", state, err) 232 - } 233 - }() 234 - 235 - error := r.FormValue("error") 236 - errorDescription := r.FormValue("error_description") 237 - if error != "" || errorDescription != "" { 238 - log.Printf("error: %s, %s", error, errorDescription) 239 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 240 - return 241 - } 242 - 243 - code := r.FormValue("code") 244 - if code == "" { 245 - log.Println("missing code for state: ", state) 246 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 247 - return 248 - } 249 - 250 - iss := r.FormValue("iss") 251 - if iss == "" { 252 - log.Println("missing iss for state: ", state) 253 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 254 - return 255 - } 256 - 257 - if iss != oauthRequest.AuthserverIss { 258 - log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 259 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 260 - return 261 - } 262 - 263 - self := o.oauth.ClientMetadata() 264 - 265 - oauthClient, err := client.NewClient( 266 - self.ClientID, 267 - o.config.OAuth.Jwks, 268 - self.RedirectURIs[0], 269 - ) 270 - 271 - if err != nil { 272 - log.Println("failed to create oauth client:", err) 273 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 274 - return 275 - } 276 - 277 - jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 278 - if err != nil { 279 - log.Println("failed to parse jwk:", err) 280 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 281 - return 282 - } 283 - 284 - tokenResp, err := oauthClient.InitialTokenRequest( 285 - r.Context(), 286 - code, 287 - oauthRequest.AuthserverIss, 288 - oauthRequest.PkceVerifier, 289 - oauthRequest.DpopAuthserverNonce, 290 - jwk, 291 - ) 292 - if err != nil { 293 - log.Println("failed to get token:", err) 294 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 295 - return 296 - } 297 - 298 - if tokenResp.Scope != oauthScope { 299 - log.Println("scope doesn't match:", tokenResp.Scope) 300 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 301 - return 302 - } 303 - 304 - err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp) 305 - if err != nil { 306 - log.Println("failed to save session:", err) 307 - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 308 - return 309 - } 310 - 311 - log.Println("session saved successfully") 312 - go o.addToDefaultKnot(oauthRequest.Did) 313 - go o.addToDefaultSpindle(oauthRequest.Did) 314 - 315 - if !o.config.Core.Dev { 316 - err = o.posthog.Enqueue(posthog.Capture{ 317 - DistinctId: oauthRequest.Did, 318 - Event: "signin", 319 - }) 320 - if err != nil { 321 - log.Println("failed to enqueue posthog event:", err) 322 - } 323 - } 324 - 325 - returnUrl := oauthRequest.ReturnUrl 326 - if returnUrl == "" { 327 - returnUrl = "/" 328 - } 329 - 330 - http.Redirect(w, r, returnUrl, http.StatusFound) 331 - } 332 - 333 - func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { 334 - err := o.oauth.ClearSession(r, w) 335 - if err != nil { 336 - log.Println("failed to clear session:", err) 337 - http.Redirect(w, r, "/", http.StatusFound) 338 - return 339 - } 340 - 341 - log.Println("session cleared successfully") 342 - o.pages.HxRedirect(w, "/login") 343 - } 344 - 345 - func pubKeyFromJwk(jwks string) (jwk.Key, error) { 346 - k, err := helpers.ParseJWKFromBytes([]byte(jwks)) 347 - if err != nil { 348 - return nil, err 349 - } 350 - pubKey, err := k.PublicKey() 351 - if err != nil { 352 - return nil, err 353 - } 354 - return pubKey, nil 355 - } 356 - 357 - func (o *OAuthHandler) addToDefaultSpindle(did string) { 358 - // use the tangled.sh app password to get an accessJwt 359 - // and create an sh.tangled.spindle.member record with that 360 - spindleMembers, err := db.GetSpindleMembers( 361 - o.db, 362 - db.FilterEq("instance", "spindle.tangled.sh"), 363 - db.FilterEq("subject", did), 364 - ) 365 - if err != nil { 366 - log.Printf("failed to get spindle members for did %s: %v", did, err) 367 - return 368 - } 369 - 370 - if len(spindleMembers) != 0 { 371 - log.Printf("did %s is already a member of the default spindle", did) 372 - return 373 - } 374 - 375 - log.Printf("adding %s to default spindle", did) 376 - session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid) 377 - if err != nil { 378 - log.Printf("failed to create session: %s", err) 379 - return 380 - } 381 - 382 - record := tangled.SpindleMember{ 383 - LexiconTypeID: "sh.tangled.spindle.member", 384 - Subject: did, 385 - Instance: consts.DefaultSpindle, 386 - CreatedAt: time.Now().Format(time.RFC3339), 387 - } 388 - 389 - if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 390 - log.Printf("failed to add member to default spindle: %s", err) 391 - return 392 - } 393 - 394 - log.Printf("successfully added %s to default spindle", did) 395 - } 396 - 397 - func (o *OAuthHandler) addToDefaultKnot(did string) { 398 - // use the tangled.sh app password to get an accessJwt 399 - // and create an sh.tangled.spindle.member record with that 400 - 401 - allKnots, err := o.enforcer.GetKnotsForUser(did) 402 - if err != nil { 403 - log.Printf("failed to get knot members for did %s: %v", did, err) 404 - return 405 - } 406 - 407 - if slices.Contains(allKnots, consts.DefaultKnot) { 408 - log.Printf("did %s is already a member of the default knot", did) 409 - return 410 - } 411 - 412 - log.Printf("adding %s to default knot", did) 413 - session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid) 414 - if err != nil { 415 - log.Printf("failed to create session: %s", err) 416 - return 417 - } 418 - 419 - record := tangled.KnotMember{ 420 - LexiconTypeID: "sh.tangled.knot.member", 421 - Subject: did, 422 - Domain: consts.DefaultKnot, 423 - CreatedAt: time.Now().Format(time.RFC3339), 424 - } 425 - 426 - if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 427 - log.Printf("failed to add member to default knot: %s", err) 428 - return 429 - } 430 - 431 - if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 432 - log.Printf("failed to set up enforcer rules: %s", err) 433 - return 434 - } 435 - 436 - log.Printf("successfully added %s to default Knot", did) 437 - } 438 - 439 - // create a session using apppasswords 440 - type session struct { 441 - AccessJwt string `json:"accessJwt"` 442 - PdsEndpoint string 443 - Did string 444 - } 445 - 446 - func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 447 - if appPassword == "" { 448 - return nil, fmt.Errorf("no app password configured, skipping member addition") 449 - } 450 - 451 - resolved, err := o.idResolver.ResolveIdent(context.Background(), did) 452 - if err != nil { 453 - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 454 - } 455 - 456 - pdsEndpoint := resolved.PDSEndpoint() 457 - if pdsEndpoint == "" { 458 - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 459 - } 460 - 461 - sessionPayload := map[string]string{ 462 - "identifier": did, 463 - "password": appPassword, 464 - } 465 - sessionBytes, err := json.Marshal(sessionPayload) 466 - if err != nil { 467 - return nil, fmt.Errorf("failed to marshal session payload: %v", err) 468 - } 469 - 470 - sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 471 - sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 472 - if err != nil { 473 - return nil, fmt.Errorf("failed to create session request: %v", err) 474 - } 475 - sessionReq.Header.Set("Content-Type", "application/json") 476 - 477 - client := &http.Client{Timeout: 30 * time.Second} 478 - sessionResp, err := client.Do(sessionReq) 479 - if err != nil { 480 - return nil, fmt.Errorf("failed to create session: %v", err) 481 - } 482 - defer sessionResp.Body.Close() 483 - 484 - if sessionResp.StatusCode != http.StatusOK { 485 - return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 486 - } 487 - 488 - var session session 489 - if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 490 - return nil, fmt.Errorf("failed to decode session response: %v", err) 491 - } 492 - 493 - session.PdsEndpoint = pdsEndpoint 494 - session.Did = did 495 - 496 - return &session, nil 497 - } 498 - 499 - func (s *session) putRecord(record any, collection string) error { 500 - recordBytes, err := json.Marshal(record) 501 - if err != nil { 502 - return fmt.Errorf("failed to marshal knot member record: %w", err) 503 - } 504 - 505 - payload := map[string]any{ 506 - "repo": s.Did, 507 - "collection": collection, 508 - "rkey": tid.TID(), 509 - "record": json.RawMessage(recordBytes), 510 - } 511 - 512 - payloadBytes, err := json.Marshal(payload) 513 - if err != nil { 514 - return fmt.Errorf("failed to marshal request payload: %w", err) 515 - } 516 - 517 - url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 518 - req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 519 - if err != nil { 520 - return fmt.Errorf("failed to create HTTP request: %w", err) 521 - } 522 - 523 - req.Header.Set("Content-Type", "application/json") 524 - req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 525 - 526 - client := &http.Client{Timeout: 30 * time.Second} 527 - resp, err := client.Do(req) 528 - if err != nil { 529 - return fmt.Errorf("failed to add user to default service: %w", err) 530 - } 531 - defer resp.Body.Close() 532 - 533 - if resp.StatusCode != http.StatusOK { 534 - return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 535 - } 536 - 537 - return nil 538 - }
+110 -205
appview/oauth/oauth.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 - "log" 6 6 "net/http" 7 - "net/url" 8 7 "time" 9 8 10 - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 9 + comatproto "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + atpclient "github.com/bluesky-social/indigo/atproto/client" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + xrpc "github.com/bluesky-social/indigo/xrpc" 11 14 "github.com/gorilla/sessions" 12 - oauth "tangled.org/anirudh.fi/atproto-oauth" 13 - "tangled.org/anirudh.fi/atproto-oauth/helpers" 14 - sessioncache "tangled.org/core/appview/cache/session" 15 + "github.com/lestrrat-go/jwx/v2/jwk" 15 16 "tangled.org/core/appview/config" 16 - "tangled.org/core/appview/oauth/client" 17 - xrpc "tangled.org/core/appview/xrpcclient" 18 17 ) 19 18 20 - type OAuth struct { 21 - store *sessions.CookieStore 22 - config *config.Config 23 - sess *sessioncache.SessionStore 24 - } 19 + func New(config *config.Config) (*OAuth, error) { 25 20 26 - func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth { 27 - return &OAuth{ 28 - store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)), 29 - config: config, 30 - sess: sess, 21 + var oauthConfig oauth.ClientConfig 22 + var clientUri string 23 + 24 + if config.Core.Dev { 25 + clientUri = "http://127.0.0.1:3000" 26 + callbackUri := clientUri + "/oauth/callback" 27 + oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"}) 28 + } else { 29 + clientUri = config.Core.AppviewHost 30 + clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri) 31 + callbackUri := clientUri + "/oauth/callback" 32 + oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"}) 31 33 } 34 + 35 + jwksUri := clientUri + "/oauth/jwks.json" 36 + 37 + authStore, err := NewRedisStore(config.Redis.ToURL()) 38 + if err != nil { 39 + return nil, err 40 + } 41 + 42 + sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) 43 + 44 + return &OAuth{ 45 + ClientApp: oauth.NewClientApp(&oauthConfig, authStore), 46 + Config: config, 47 + SessStore: sessStore, 48 + JwksUri: jwksUri, 49 + }, nil 32 50 } 33 51 34 - func (o *OAuth) Stores() *sessions.CookieStore { 35 - return o.store 52 + type OAuth struct { 53 + ClientApp *oauth.ClientApp 54 + SessStore *sessions.CookieStore 55 + Config *config.Config 56 + JwksUri string 36 57 } 37 58 38 - func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error { 59 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 39 60 // first we save the did in the user session 40 - userSession, err := o.store.Get(r, SessionName) 61 + userSession, err := o.SessStore.Get(r, SessionName) 41 62 if err != nil { 42 63 return err 43 64 } 44 65 45 - userSession.Values[SessionDid] = oreq.Did 46 - userSession.Values[SessionHandle] = oreq.Handle 47 - userSession.Values[SessionPds] = oreq.PdsUrl 66 + userSession.Values[SessionDid] = sessData.AccountDID.String() 67 + userSession.Values[SessionPds] = sessData.HostURL 68 + userSession.Values[SessionId] = sessData.SessionID 48 69 userSession.Values[SessionAuthenticated] = true 49 - err = userSession.Save(r, w) 70 + return userSession.Save(r, w) 71 + } 72 + 73 + func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { 74 + userSession, err := o.SessStore.Get(r, SessionName) 50 75 if err != nil { 51 - return fmt.Errorf("error saving user session: %w", err) 76 + return nil, fmt.Errorf("error getting user session: %w", err) 52 77 } 53 - 54 - // then save the whole thing in the db 55 - session := sessioncache.OAuthSession{ 56 - Did: oreq.Did, 57 - Handle: oreq.Handle, 58 - PdsUrl: oreq.PdsUrl, 59 - DpopAuthserverNonce: oreq.DpopAuthserverNonce, 60 - AuthServerIss: oreq.AuthserverIss, 61 - DpopPrivateJwk: oreq.DpopPrivateJwk, 62 - AccessJwt: oresp.AccessToken, 63 - RefreshJwt: oresp.RefreshToken, 64 - Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339), 78 + if userSession.IsNew { 79 + return nil, fmt.Errorf("no session available for user") 65 80 } 66 81 67 - return o.sess.SaveSession(r.Context(), session) 68 - } 69 - 70 - func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error { 71 - userSession, err := o.store.Get(r, SessionName) 72 - if err != nil || userSession.IsNew { 73 - return fmt.Errorf("error getting user session (or new session?): %w", err) 82 + d := userSession.Values[SessionDid].(string) 83 + sessDid, err := syntax.ParseDID(d) 84 + if err != nil { 85 + return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 74 86 } 75 87 76 - did := userSession.Values[SessionDid].(string) 88 + sessId := userSession.Values[SessionId].(string) 77 89 78 - err = o.sess.DeleteSession(r.Context(), did) 90 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 79 91 if err != nil { 80 - return fmt.Errorf("error deleting oauth session: %w", err) 92 + return nil, fmt.Errorf("failed to resume session: %w", err) 81 93 } 82 94 83 - userSession.Options.MaxAge = -1 84 - 85 - return userSession.Save(r, w) 95 + return clientSess, nil 86 96 } 87 97 88 - func (o *OAuth) GetSession(r *http.Request) (*sessioncache.OAuthSession, bool, error) { 89 - userSession, err := o.store.Get(r, SessionName) 90 - if err != nil || userSession.IsNew { 91 - return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err) 98 + func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 99 + userSession, err := o.SessStore.Get(r, SessionName) 100 + if err != nil { 101 + return fmt.Errorf("error getting user session: %w", err) 102 + } 103 + if userSession.IsNew { 104 + return fmt.Errorf("no session available for user") 92 105 } 93 106 94 - did := userSession.Values[SessionDid].(string) 95 - auth := userSession.Values[SessionAuthenticated].(bool) 96 - 97 - session, err := o.sess.GetSession(r.Context(), did) 107 + d := userSession.Values[SessionDid].(string) 108 + sessDid, err := syntax.ParseDID(d) 98 109 if err != nil { 99 - return nil, false, fmt.Errorf("error getting oauth session: %w", err) 110 + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 100 111 } 101 112 102 - expiry, err := time.Parse(time.RFC3339, session.Expiry) 113 + sessId := userSession.Values[SessionId].(string) 114 + 115 + // delete the session 116 + err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) 117 + 118 + // remove the cookie 119 + userSession.Options.MaxAge = -1 120 + err2 := o.SessStore.Save(r, w, userSession) 121 + 122 + return errors.Join(err1, err2) 123 + } 124 + 125 + func pubKeyFromJwk(jwks string) (jwk.Key, error) { 126 + k, err := jwk.ParseKey([]byte(jwks)) 103 127 if err != nil { 104 - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 128 + return nil, err 105 129 } 106 - if time.Until(expiry) <= 5*time.Minute { 107 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 108 - if err != nil { 109 - return nil, false, err 110 - } 111 - 112 - self := o.ClientMetadata() 113 - 114 - oauthClient, err := client.NewClient( 115 - self.ClientID, 116 - o.config.OAuth.Jwks, 117 - self.RedirectURIs[0], 118 - ) 119 - 120 - if err != nil { 121 - return nil, false, err 122 - } 123 - 124 - resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) 125 - if err != nil { 126 - return nil, false, err 127 - } 128 - 129 - newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339) 130 - err = o.sess.RefreshSession(r.Context(), did, resp.AccessToken, resp.RefreshToken, newExpiry) 131 - if err != nil { 132 - return nil, false, fmt.Errorf("error refreshing oauth session: %w", err) 133 - } 134 - 135 - // update the current session 136 - session.AccessJwt = resp.AccessToken 137 - session.RefreshJwt = resp.RefreshToken 138 - session.DpopAuthserverNonce = resp.DpopAuthserverNonce 139 - session.Expiry = newExpiry 130 + pubKey, err := k.PublicKey() 131 + if err != nil { 132 + return nil, err 140 133 } 141 - 142 - return session, auth, nil 134 + return pubKey, nil 143 135 } 144 136 145 137 type User struct { 146 - Handle string 147 - Did string 148 - Pds string 138 + Did string 139 + Pds string 149 140 } 150 141 151 - func (a *OAuth) GetUser(r *http.Request) *User { 152 - clientSession, err := a.store.Get(r, SessionName) 142 + func (o *OAuth) GetUser(r *http.Request) *User { 143 + sess, err := o.SessStore.Get(r, SessionName) 153 144 154 - if err != nil || clientSession.IsNew { 145 + if err != nil || sess.IsNew { 155 146 return nil 156 147 } 157 148 158 149 return &User{ 159 - Handle: clientSession.Values[SessionHandle].(string), 160 - Did: clientSession.Values[SessionDid].(string), 161 - Pds: clientSession.Values[SessionPds].(string), 150 + Did: sess.Values[SessionDid].(string), 151 + Pds: sess.Values[SessionPds].(string), 162 152 } 163 153 } 164 154 165 - func (a *OAuth) GetDid(r *http.Request) string { 166 - clientSession, err := a.store.Get(r, SessionName) 167 - 168 - if err != nil || clientSession.IsNew { 169 - return "" 155 + func (o *OAuth) GetDid(r *http.Request) string { 156 + if u := o.GetUser(r); u != nil { 157 + return u.Did 170 158 } 171 159 172 - return clientSession.Values[SessionDid].(string) 160 + return "" 173 161 } 174 162 175 - func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 - session, auth, err := o.GetSession(r) 163 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 164 + session, err := o.ResumeSession(r) 177 165 if err != nil { 178 166 return nil, fmt.Errorf("error getting session: %w", err) 179 167 } 180 - if !auth { 181 - return nil, fmt.Errorf("not authorized") 182 - } 183 - 184 - client := &oauth.XrpcClient{ 185 - OnDpopPdsNonceChanged: func(did, newNonce string) { 186 - err := o.sess.UpdateNonce(r.Context(), did, newNonce) 187 - if err != nil { 188 - log.Printf("error updating dpop pds nonce: %v", err) 189 - } 190 - }, 191 - } 192 - 193 - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 194 - if err != nil { 195 - return nil, fmt.Errorf("error parsing private jwk: %w", err) 196 - } 197 - 198 - xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{ 199 - Did: session.Did, 200 - PdsUrl: session.PdsUrl, 201 - DpopPdsNonce: session.PdsUrl, 202 - AccessToken: session.AccessJwt, 203 - Issuer: session.AuthServerIss, 204 - DpopPrivateJwk: privateJwk, 205 - }) 206 - 207 - return xrpcClient, nil 168 + return session.APIClient(), nil 208 169 } 209 170 210 - // use this to create a client to communicate with knots or spindles 211 - // 212 171 // this is a higher level abstraction on ServerGetServiceAuth 213 172 type ServiceClientOpts struct { 214 173 service string ··· 259 218 return scheme + s.service 260 219 } 261 220 262 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 221 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 263 222 opts := ServiceClientOpts{} 264 223 for _, o := range os { 265 224 o(&opts) 266 225 } 267 226 268 - authorizedClient, err := o.AuthorizedClient(r) 227 + client, err := o.AuthorizedClient(r) 269 228 if err != nil { 270 229 return nil, err 271 230 } ··· 276 235 opts.exp = sixty 277 236 } 278 237 279 - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 238 + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 280 239 if err != nil { 281 240 return nil, err 282 241 } 283 242 284 - return &indigo_xrpc.Client{ 285 - Auth: &indigo_xrpc.AuthInfo{ 243 + return &xrpc.Client{ 244 + Auth: &xrpc.AuthInfo{ 286 245 AccessJwt: resp.Token, 287 246 }, 288 247 Host: opts.Host(), ··· 291 250 }, 292 251 }, nil 293 252 } 294 - 295 - type ClientMetadata struct { 296 - ClientID string `json:"client_id"` 297 - ClientName string `json:"client_name"` 298 - SubjectType string `json:"subject_type"` 299 - ClientURI string `json:"client_uri"` 300 - RedirectURIs []string `json:"redirect_uris"` 301 - GrantTypes []string `json:"grant_types"` 302 - ResponseTypes []string `json:"response_types"` 303 - ApplicationType string `json:"application_type"` 304 - DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 305 - JwksURI string `json:"jwks_uri"` 306 - Scope string `json:"scope"` 307 - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 308 - TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 309 - } 310 - 311 - func (o *OAuth) ClientMetadata() ClientMetadata { 312 - makeRedirectURIs := func(c string) []string { 313 - return []string{fmt.Sprintf("%s/oauth/callback", c)} 314 - } 315 - 316 - clientURI := o.config.Core.AppviewHost 317 - clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI) 318 - redirectURIs := makeRedirectURIs(clientURI) 319 - 320 - if o.config.Core.Dev { 321 - clientURI = "http://127.0.0.1:3000" 322 - redirectURIs = makeRedirectURIs(clientURI) 323 - 324 - query := url.Values{} 325 - query.Add("redirect_uri", redirectURIs[0]) 326 - query.Add("scope", "atproto transition:generic") 327 - clientID = fmt.Sprintf("http://localhost?%s", query.Encode()) 328 - } 329 - 330 - jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI) 331 - 332 - return ClientMetadata{ 333 - ClientID: clientID, 334 - ClientName: "Tangled", 335 - SubjectType: "public", 336 - ClientURI: clientURI, 337 - RedirectURIs: redirectURIs, 338 - GrantTypes: []string{"authorization_code", "refresh_token"}, 339 - ResponseTypes: []string{"code"}, 340 - ApplicationType: "web", 341 - DpopBoundAccessTokens: true, 342 - JwksURI: jwksURI, 343 - Scope: "atproto transition:generic", 344 - TokenEndpointAuthMethod: "private_key_jwt", 345 - TokenEndpointAuthSigningAlg: "ES256", 346 - } 347 - }
+147
appview/oauth/store.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/redis/go-redis/v9" 12 + ) 13 + 14 + // redis-backed implementation of ClientAuthStore. 15 + type RedisStore struct { 16 + client *redis.Client 17 + SessionTTL time.Duration 18 + AuthRequestTTL time.Duration 19 + } 20 + 21 + var _ oauth.ClientAuthStore = &RedisStore{} 22 + 23 + func NewRedisStore(redisURL string) (*RedisStore, error) { 24 + opts, err := redis.ParseURL(redisURL) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 + } 28 + 29 + client := redis.NewClient(opts) 30 + 31 + // test the connection 32 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 + defer cancel() 34 + 35 + if err := client.Ping(ctx).Err(); err != nil { 36 + return nil, fmt.Errorf("failed to connect to redis: %w", err) 37 + } 38 + 39 + return &RedisStore{ 40 + client: client, 41 + SessionTTL: 30 * 24 * time.Hour, // 30 days 42 + AuthRequestTTL: 10 * time.Minute, // 10 minutes 43 + }, nil 44 + } 45 + 46 + func (r *RedisStore) Close() error { 47 + return r.client.Close() 48 + } 49 + 50 + func sessionKey(did syntax.DID, sessionID string) string { 51 + return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 + } 53 + 54 + func authRequestKey(state string) string { 55 + return fmt.Sprintf("oauth:auth_request:%s", state) 56 + } 57 + 58 + func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 + key := sessionKey(did, sessionID) 60 + data, err := r.client.Get(ctx, key).Bytes() 61 + if err == redis.Nil { 62 + return nil, fmt.Errorf("session not found: %s", did) 63 + } 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get session: %w", err) 66 + } 67 + 68 + var sess oauth.ClientSessionData 69 + if err := json.Unmarshal(data, &sess); err != nil { 70 + return nil, fmt.Errorf("failed to unmarshal session: %w", err) 71 + } 72 + 73 + return &sess, nil 74 + } 75 + 76 + func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 + key := sessionKey(sess.AccountDID, sess.SessionID) 78 + 79 + data, err := json.Marshal(sess) 80 + if err != nil { 81 + return fmt.Errorf("failed to marshal session: %w", err) 82 + } 83 + 84 + if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 85 + return fmt.Errorf("failed to save session: %w", err) 86 + } 87 + 88 + return nil 89 + } 90 + 91 + func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 + key := sessionKey(did, sessionID) 93 + if err := r.client.Del(ctx, key).Err(); err != nil { 94 + return fmt.Errorf("failed to delete session: %w", err) 95 + } 96 + return nil 97 + } 98 + 99 + func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 100 + key := authRequestKey(state) 101 + data, err := r.client.Get(ctx, key).Bytes() 102 + if err == redis.Nil { 103 + return nil, fmt.Errorf("request info not found: %s", state) 104 + } 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to get auth request: %w", err) 107 + } 108 + 109 + var req oauth.AuthRequestData 110 + if err := json.Unmarshal(data, &req); err != nil { 111 + return nil, fmt.Errorf("failed to unmarshal auth request: %w", err) 112 + } 113 + 114 + return &req, nil 115 + } 116 + 117 + func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 118 + key := authRequestKey(info.State) 119 + 120 + // check if already exists (to match MemStore behavior) 121 + exists, err := r.client.Exists(ctx, key).Result() 122 + if err != nil { 123 + return fmt.Errorf("failed to check auth request existence: %w", err) 124 + } 125 + if exists > 0 { 126 + return fmt.Errorf("auth request already saved for state %s", info.State) 127 + } 128 + 129 + data, err := json.Marshal(info) 130 + if err != nil { 131 + return fmt.Errorf("failed to marshal auth request: %w", err) 132 + } 133 + 134 + if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 135 + return fmt.Errorf("failed to save auth request: %w", err) 136 + } 137 + 138 + return nil 139 + } 140 + 141 + func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 142 + key := authRequestKey(state) 143 + if err := r.client.Del(ctx, key).Err(); err != nil { 144 + return fmt.Errorf("failed to delete auth request: %w", err) 145 + } 146 + return nil 147 + }
+1 -1
appview/pages/templates/layouts/fragments/topbar.html
··· 51 51 <summary 52 52 class="cursor-pointer list-none flex items-center gap-1" 53 53 > 54 - {{ $user := didOrHandle .Did .Handle }} 54 + {{ $user := .Did }} 55 55 <img 56 56 src="{{ tinyAvatar $user }}" 57 57 alt=""
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 3 id="pull-comment-card-{{ .RoundNumber }}" 4 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 6 + {{ resolve .LoggedInUser.Did }} 7 7 </div> 8 8 <form 9 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+1 -3
appview/pages/templates/user/settings/profile.html
··· 33 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 34 <span>Handle</span> 35 35 </div> 36 - {{ if .LoggedInUser.Handle }} 37 36 <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 37 + {{ resolve .LoggedInUser.Did }} 39 38 </span> 40 - {{ end }} 41 39 </div> 42 40 </div> 43 41 <div class="flex items-center justify-between p-4">
+2 -1
appview/pipelines/pipelines.go
··· 48 48 ) *Pipelines { 49 49 logger := log.New("pipelines") 50 50 51 - return &Pipelines{oauth: oauth, 51 + return &Pipelines{ 52 + oauth: oauth, 52 53 repoResolver: repoResolver, 53 54 pages: pages, 54 55 idResolver: idResolver,
+17 -17
appview/pulls/pulls.go
··· 186 186 187 187 188 188 189 + m[p.Sha] = p 190 + } 189 191 192 + reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 193 + if err != nil { 194 + log.Println("failed to get pull reactions") 195 + s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 190 196 191 197 192 198 ··· 218 224 219 225 220 226 227 + Pipelines: m, 221 228 229 + OrderedReactionKinds: models.OrderedReactionKinds, 230 + Reactions: reactionMap, 231 + UserReacted: userReactions, 222 232 233 + LabelDefs: defs, 223 234 224 235 225 236 ··· 651 662 652 663 653 664 654 - 655 - 656 - 657 - 658 - 659 - 660 - 661 - 662 - 663 - 664 - 665 665 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 666 666 return 667 667 } 668 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 668 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 669 669 Collection: tangled.RepoPullCommentNSID, 670 670 Repo: user.Did, 671 671 Rkey: tid.TID(), ··· 1142 1142 return 1143 1143 } 1144 1144 1145 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1145 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1146 1146 Collection: tangled.RepoPullNSID, 1147 1147 Repo: user.Did, 1148 1148 Rkey: rkey, ··· 1239 1239 } 1240 1240 writes = append(writes, &write) 1241 1241 } 1242 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1242 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1243 1243 Repo: user.Did, 1244 1244 Writes: writes, 1245 1245 }) ··· 1770 1770 return 1771 1771 } 1772 1772 1773 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1773 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1774 1774 if err != nil { 1775 1775 // failed to get record 1776 1776 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1793 1793 } 1794 1794 } 1795 1795 1796 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1796 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1797 1797 Collection: tangled.RepoPullNSID, 1798 1798 Repo: user.Did, 1799 1799 Rkey: pull.Rkey, ··· 2065 2065 return 2066 2066 } 2067 2067 2068 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2068 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2069 2069 Repo: user.Did, 2070 2070 Writes: writes, 2071 2071 })
+11 -10
appview/repo/artifact.go
··· 10 10 "net/url" 11 11 "time" 12 12 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 16 - "github.com/dustin/go-humanize" 17 - "github.com/go-chi/chi/v5" 18 - "github.com/go-git/go-git/v5/plumbing" 19 - "github.com/ipfs/go-cid" 20 13 "tangled.org/core/api/tangled" 21 14 "tangled.org/core/appview/db" 22 15 "tangled.org/core/appview/models" ··· 25 18 "tangled.org/core/appview/xrpcclient" 26 19 "tangled.org/core/tid" 27 20 "tangled.org/core/types" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + lexutil "github.com/bluesky-social/indigo/lex/util" 24 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 + "github.com/dustin/go-humanize" 26 + "github.com/go-chi/chi/v5" 27 + "github.com/go-git/go-git/v5/plumbing" 28 + "github.com/ipfs/go-cid" 28 29 ) 29 30 30 31 // TODO: proper statuses here on early exit ··· 60 61 return 61 62 } 62 63 63 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 64 65 if err != nil { 65 66 log.Println("failed to upload blob", err) 66 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 72 73 rkey := tid.TID() 73 74 createdAt := time.Now() 74 75 75 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 76 77 Collection: tangled.RepoArtifactNSID, 77 78 Repo: user.Did, 78 79 Rkey: rkey, ··· 249 250 return 250 251 } 251 252 252 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 253 254 Collection: tangled.RepoArtifactNSID, 254 255 Repo: user.Did, 255 256 Rkey: artifact.Rkey,
+28 -35
appview/repo/repo.go
··· 17 17 "strings" 18 18 "time" 19 19 20 - comatproto "github.com/bluesky-social/indigo/api/atproto" 21 - lexutil "github.com/bluesky-social/indigo/lex/util" 22 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 23 20 "tangled.org/core/api/tangled" 24 21 "tangled.org/core/appview/commitverify" 25 22 "tangled.org/core/appview/config" ··· 40 37 "tangled.org/core/types" 41 38 "tangled.org/core/xrpc/serviceauth" 42 39 40 + comatproto "github.com/bluesky-social/indigo/api/atproto" 41 + atpclient "github.com/bluesky-social/indigo/atproto/client" 42 + "github.com/bluesky-social/indigo/atproto/syntax" 43 + lexutil "github.com/bluesky-social/indigo/lex/util" 44 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 43 45 securejoin "github.com/cyphar/filepath-securejoin" 44 46 "github.com/go-chi/chi/v5" 45 47 "github.com/go-git/go-git/v5/plumbing" 46 - 47 - "github.com/bluesky-social/indigo/atproto/syntax" 48 48 ) 49 49 50 50 type Repo struct { ··· 307 307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 308 308 // 309 309 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 310 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 310 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 311 311 if err != nil { 312 312 // failed to get record 313 313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 314 314 return 315 315 } 316 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 316 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 317 317 Collection: tangled.RepoNSID, 318 318 Repo: newRepo.Did, 319 319 Rkey: newRepo.Rkey, ··· 863 863 user := rp.oauth.GetUser(r) 864 864 l := rp.logger.With("handler", "EditSpindle") 865 865 l = l.With("did", user.Did) 866 - l = l.With("handle", user.Handle) 867 866 868 867 errorId := "operation-error" 869 868 fail := func(msg string, err error) { ··· 916 915 return 917 916 } 918 917 919 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 918 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 920 919 if err != nil { 921 920 fail("Failed to update spindle, no record found on PDS.", err) 922 921 return 923 922 } 924 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 923 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 925 924 Collection: tangled.RepoNSID, 926 925 Repo: newRepo.Did, 927 926 Rkey: newRepo.Rkey, ··· 951 950 user := rp.oauth.GetUser(r) 952 951 l := rp.logger.With("handler", "AddLabel") 953 952 l = l.With("did", user.Did) 954 - l = l.With("handle", user.Handle) 955 953 956 954 f, err := rp.repoResolver.Resolve(r) 957 955 if err != nil { ··· 1020 1018 1021 1019 // emit a labelRecord 1022 1020 labelRecord := label.AsRecord() 1023 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1021 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1024 1022 Collection: tangled.LabelDefinitionNSID, 1025 1023 Repo: label.Did, 1026 1024 Rkey: label.Rkey, ··· 1043 1041 newRepo.Labels = append(newRepo.Labels, aturi) 1044 1042 repoRecord := newRepo.AsRecord() 1045 1043 1046 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1044 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1047 1045 if err != nil { 1048 1046 fail("Failed to update labels, no record found on PDS.", err) 1049 1047 return 1050 1048 } 1051 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1049 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1052 1050 Collection: tangled.RepoNSID, 1053 1051 Repo: newRepo.Did, 1054 1052 Rkey: newRepo.Rkey, ··· 1111 1109 user := rp.oauth.GetUser(r) 1112 1110 l := rp.logger.With("handler", "DeleteLabel") 1113 1111 l = l.With("did", user.Did) 1114 - l = l.With("handle", user.Handle) 1115 1112 1116 1113 f, err := rp.repoResolver.Resolve(r) 1117 1114 if err != nil { ··· 1141 1138 } 1142 1139 1143 1140 // delete label record from PDS 1144 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1141 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1145 1142 Collection: tangled.LabelDefinitionNSID, 1146 1143 Repo: label.Did, 1147 1144 Rkey: label.Rkey, ··· 1163 1160 newRepo.Labels = updated 1164 1161 repoRecord := newRepo.AsRecord() 1165 1162 1166 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1163 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1167 1164 if err != nil { 1168 1165 fail("Failed to update labels, no record found on PDS.", err) 1169 1166 return 1170 1167 } 1171 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1168 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1172 1169 Collection: tangled.RepoNSID, 1173 1170 Repo: newRepo.Did, 1174 1171 Rkey: newRepo.Rkey, ··· 1220 1217 user := rp.oauth.GetUser(r) 1221 1218 l := rp.logger.With("handler", "SubscribeLabel") 1222 1219 l = l.With("did", user.Did) 1223 - l = l.With("handle", user.Handle) 1224 1220 1225 1221 f, err := rp.repoResolver.Resolve(r) 1226 1222 if err != nil { ··· 1261 1257 return 1262 1258 } 1263 1259 1264 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1260 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1265 1261 if err != nil { 1266 1262 fail("Failed to update labels, no record found on PDS.", err) 1267 1263 return 1268 1264 } 1269 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1265 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1270 1266 Collection: tangled.RepoNSID, 1271 1267 Repo: newRepo.Did, 1272 1268 Rkey: newRepo.Rkey, ··· 1307 1303 user := rp.oauth.GetUser(r) 1308 1304 l := rp.logger.With("handler", "UnsubscribeLabel") 1309 1305 l = l.With("did", user.Did) 1310 - l = l.With("handle", user.Handle) 1311 1306 1312 1307 f, err := rp.repoResolver.Resolve(r) 1313 1308 if err != nil { ··· 1350 1345 return 1351 1346 } 1352 1347 1353 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1348 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1354 1349 if err != nil { 1355 1350 fail("Failed to update labels, no record found on PDS.", err) 1356 1351 return 1357 1352 } 1358 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1353 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1359 1354 Collection: tangled.RepoNSID, 1360 1355 Repo: newRepo.Did, 1361 1356 Rkey: newRepo.Rkey, ··· 1479 1474 user := rp.oauth.GetUser(r) 1480 1475 l := rp.logger.With("handler", "AddCollaborator") 1481 1476 l = l.With("did", user.Did) 1482 - l = l.With("handle", user.Handle) 1483 1477 1484 1478 f, err := rp.repoResolver.Resolve(r) 1485 1479 if err != nil { ··· 1526 1520 currentUser := rp.oauth.GetUser(r) 1527 1521 rkey := tid.TID() 1528 1522 createdAt := time.Now() 1529 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1523 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1530 1524 Collection: tangled.RepoCollaboratorNSID, 1531 1525 Repo: currentUser.Did, 1532 1526 Rkey: rkey, ··· 1617 1611 } 1618 1612 1619 1613 // remove record from pds 1620 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 1614 + atpClient, err := rp.oauth.AuthorizedClient(r) 1621 1615 if err != nil { 1622 1616 log.Println("failed to get authorized client", err) 1623 1617 return 1624 1618 } 1625 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1619 + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1626 1620 Collection: tangled.RepoNSID, 1627 1621 Repo: user.Did, 1628 1622 Rkey: f.Rkey, ··· 1764 1758 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1765 1759 user := rp.oauth.GetUser(r) 1766 1760 l := rp.logger.With("handler", "Secrets") 1767 - l = l.With("handle", user.Handle) 1768 1761 l = l.With("did", user.Did) 1769 1762 1770 1763 f, err := rp.repoResolver.Resolve(r) ··· 2179 2172 } 2180 2173 record := repo.AsRecord() 2181 2174 2182 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 2175 + atpClient, err := rp.oauth.AuthorizedClient(r) 2183 2176 if err != nil { 2184 2177 l.Error("failed to create xrpcclient", "err", err) 2185 2178 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2186 2179 return 2187 2180 } 2188 2181 2189 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2182 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 2190 2183 Collection: tangled.RepoNSID, 2191 2184 Repo: user.Did, 2192 2185 Rkey: rkey, ··· 2218 2211 rollback := func() { 2219 2212 err1 := tx.Rollback() 2220 2213 err2 := rp.enforcer.E.LoadPolicy() 2221 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 2214 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 2222 2215 2223 2216 // ignore txn complete errors, this is okay 2224 2217 if errors.Is(err1, sql.ErrTxDone) { ··· 2291 2284 aturi = "" 2292 2285 2293 2286 rp.notifier.NewRepo(r.Context(), repo) 2294 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2287 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 2295 2288 } 2296 2289 } 2297 2290 2298 2291 // this is used to rollback changes made to the PDS 2299 2292 // 2300 2293 // it is a no-op if the provided ATURI is empty 2301 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 2294 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 2302 2295 if aturi == "" { 2303 2296 return nil 2304 2297 } ··· 2309 2302 repo := parsed.Authority().String() 2310 2303 rkey := parsed.RecordKey().String() 2311 2304 2312 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 2305 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 2313 2306 Collection: collection, 2314 2307 Repo: repo, 2315 2308 Rkey: rkey,
+2 -2
appview/settings/settings.go
··· 470 470 } 471 471 472 472 // store in pds too 473 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 473 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 474 474 Collection: tangled.PublicKeyNSID, 475 475 Repo: did, 476 476 Rkey: rkey, ··· 527 527 528 528 if rkey != "" { 529 529 // remove from pds too 530 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 530 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 531 531 Collection: tangled.PublicKeyNSID, 532 532 Repo: did, 533 533 Rkey: rkey,
-2
appview/signup/signup.go
··· 20 20 "tangled.org/core/appview/models" 21 21 "tangled.org/core/appview/pages" 22 22 "tangled.org/core/appview/state/userutil" 23 - "tangled.org/core/appview/xrpcclient" 24 23 "tangled.org/core/idresolver" 25 24 ) 26 25 ··· 29 28 db *db.DB 30 29 cf *dns.Cloudflare 31 30 posthog posthog.Client 32 - xrpc *xrpcclient.Client 33 31 idResolver *idresolver.Resolver 34 32 pages *pages.Pages 35 33 l *slog.Logger
+5 -5
appview/spindles/spindles.go
··· 189 189 return 190 190 } 191 191 192 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 193 193 var exCid *string 194 194 if ex != nil { 195 195 exCid = ex.Cid 196 196 } 197 197 198 198 // re-announce by registering under same rkey 199 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 200 Collection: tangled.SpindleNSID, 201 201 Repo: user.Did, 202 202 Rkey: instance, ··· 332 332 return 333 333 } 334 334 335 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 336 336 Collection: tangled.SpindleNSID, 337 337 Repo: user.Did, 338 338 Rkey: instance, ··· 542 542 return 543 543 } 544 544 545 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 546 546 Collection: tangled.SpindleMemberNSID, 547 547 Repo: user.Did, 548 548 Rkey: rkey, ··· 683 683 } 684 684 685 685 // remove from pds 686 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 687 687 Collection: tangled.SpindleMemberNSID, 688 688 Repo: user.Did, 689 689 Rkey: members[0].Rkey,
+2 -2
appview/state/follow.go
··· 43 43 case http.MethodPost: 44 44 createdAt := time.Now().Format(time.RFC3339) 45 45 rkey := tid.TID() 46 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 46 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 47 47 Collection: tangled.GraphFollowNSID, 48 48 Repo: currentUser.Did, 49 49 Rkey: rkey, ··· 88 88 return 89 89 } 90 90 91 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 91 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 92 92 Collection: tangled.GraphFollowNSID, 93 93 Repo: currentUser.Did, 94 94 Rkey: follow.Rkey,
+63
appview/state/login.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "strings" 8 + 9 + "tangled.org/core/appview/pages" 10 + ) 11 + 12 + func (s *State) Login(w http.ResponseWriter, r *http.Request) { 13 + switch r.Method { 14 + case http.MethodGet: 15 + returnURL := r.URL.Query().Get("return_url") 16 + s.pages.Login(w, pages.LoginParams{ 17 + ReturnUrl: returnURL, 18 + }) 19 + case http.MethodPost: 20 + handle := r.FormValue("handle") 21 + 22 + // when users copy their handle from bsky.app, it tends to have these characters around it: 23 + // 24 + // @nelind.dk: 25 + // \u202a ensures that the handle is always rendered left to right and 26 + // \u202c reverts that so the rest of the page renders however it should 27 + handle = strings.TrimPrefix(handle, "\u202a") 28 + handle = strings.TrimSuffix(handle, "\u202c") 29 + 30 + // `@` is harmless 31 + handle = strings.TrimPrefix(handle, "@") 32 + 33 + // basic handle validation 34 + if !strings.Contains(handle, ".") { 35 + log.Println("invalid handle format", "raw", handle) 36 + s.pages.Notice( 37 + w, 38 + "login-msg", 39 + fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 40 + ) 41 + return 42 + } 43 + 44 + redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 45 + if err != nil { 46 + http.Error(w, err.Error(), http.StatusInternalServerError) 47 + return 48 + } 49 + 50 + s.pages.HxRedirect(w, redirectURL) 51 + } 52 + } 53 + 54 + func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 55 + err := s.oauth.DeleteSession(w, r) 56 + if err != nil { 57 + log.Println("failed to logout", "err", err) 58 + } else { 59 + log.Println("logged out successfully") 60 + } 61 + 62 + s.pages.HxRedirect(w, "/login") 63 + }
+2 -2
appview/state/profile.go
··· 634 634 vanityStats = append(vanityStats, string(v.Kind)) 635 635 } 636 636 637 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 637 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 638 638 var cid *string 639 639 if ex != nil { 640 640 cid = ex.Cid 641 641 } 642 642 643 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 643 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 644 644 Collection: tangled.ActorProfileNSID, 645 645 Repo: user.Did, 646 646 Rkey: "self",
+35 -7
appview/state/reaction.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 + 12 12 "tangled.org/core/api/tangled" 13 13 "tangled.org/core/appview/db" 14 14 "tangled.org/core/appview/models" ··· 47 47 case http.MethodPost: 48 48 createdAt := time.Now().Format(time.RFC3339) 49 49 rkey := tid.TID() 50 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 51 Collection: tangled.FeedReactionNSID, 52 52 Repo: currentUser.Did, 53 53 Rkey: rkey, ··· 67 67 68 68 69 69 70 + return 71 + } 70 72 73 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 74 + if err != nil { 75 + log.Println("failed to get reactions for ", subjectUri) 76 + } 71 77 78 + log.Println("created atproto record: ", resp.Uri) 72 79 80 + s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 + ThreadAt: subjectUri, 82 + Kind: reactionKind, 83 + Count: reactionMap[reactionKind].Count, 84 + Users: reactionMap[reactionKind].Users, 85 + IsReacted: true, 86 + }) 73 87 74 88 75 89 76 90 77 91 78 92 93 + return 94 + } 79 95 96 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 97 + Collection: tangled.FeedReactionNSID, 98 + Repo: currentUser.Did, 99 + Rkey: reaction.Rkey, 80 100 81 101 82 102 ··· 87 107 88 108 89 109 110 + // this is not an issue, the firehose event might have already done this 111 + } 90 112 91 - 113 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 114 + if err != nil { 115 + log.Println("failed to get reactions for ", subjectUri) 92 116 return 93 117 } 94 118 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 - Collection: tangled.FeedReactionNSID, 97 - Repo: currentUser.Did, 98 - Rkey: reaction.Rkey, 119 + s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 120 + ThreadAt: subjectUri, 121 + Kind: reactionKind, 122 + Count: reactionMap[reactionKind].Count, 123 + Users: reactionMap[reactionKind].Users, 124 + IsReacted: false, 125 + }) 126 +
+5 -10
appview/state/router.go
··· 5 5 "strings" 6 6 7 7 "github.com/go-chi/chi/v5" 8 - "github.com/gorilla/sessions" 9 8 "tangled.org/core/appview/issues" 10 9 "tangled.org/core/appview/knots" 11 10 "tangled.org/core/appview/labels" 12 11 "tangled.org/core/appview/middleware" 13 12 "tangled.org/core/appview/notifications" 14 - oauthhandler "tangled.org/core/appview/oauth/handler" 15 13 "tangled.org/core/appview/pipelines" 16 14 "tangled.org/core/appview/pulls" 17 15 "tangled.org/core/appview/repo" ··· 34 32 s.pages, 35 33 ) 36 34 37 - router.Use(middleware.TryRefreshSession()) 38 35 router.Get("/favicon.svg", s.Favicon) 39 36 router.Get("/favicon.ico", s.Favicon) 40 37 router.Get("/pwa-manifest.json", s.PWAManifest) ··· 123 120 // special-case handler for serving tangled.org/core 124 121 r.Get("/core", s.Core()) 125 122 123 + r.Get("/login", s.Login) 124 + r.Post("/login", s.Login) 125 + r.Post("/logout", s.Logout) 126 + 126 127 r.Route("/repo", func(r chi.Router) { 127 128 r.Route("/new", func(r chi.Router) { 128 129 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 164 165 r.Mount("/notifications", s.NotificationsRouter(mw)) 165 166 166 167 r.Mount("/signup", s.SignupRouter()) 167 - r.Mount("/", s.OAuthRouter()) 168 + r.Mount("/", s.oauth.Router()) 168 169 169 170 r.Get("/keys/{user}", s.Keys) 170 171 r.Get("/terms", s.TermsOfService) ··· 191 192 } 192 193 } 193 194 194 - func (s *State) OAuthRouter() http.Handler { 195 - store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 196 - oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog) 197 - return oauth.Router() 198 - } 199 - 200 195 func (s *State) SettingsRouter() http.Handler { 201 196 settings := &settings.Settings{ 202 197 Db: s.db,
+2 -2
appview/state/star.go
··· 40 40 case http.MethodPost: 41 41 createdAt := time.Now().Format(time.RFC3339) 42 42 rkey := tid.TID() 43 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 44 Collection: tangled.FeedStarNSID, 45 45 Repo: currentUser.Did, 46 46 Rkey: rkey, ··· 92 92 return 93 93 } 94 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 96 Collection: tangled.FeedStarNSID, 97 97 Repo: currentUser.Did, 98 98 Rkey: star.Rkey,
+9 -7
appview/strings/strings.go
··· 22 22 "github.com/bluesky-social/indigo/api/atproto" 23 23 "github.com/bluesky-social/indigo/atproto/identity" 24 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 - lexutil "github.com/bluesky-social/indigo/lex/util" 26 25 "github.com/go-chi/chi/v5" 26 + 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 27 29 ) 28 30 29 31 type Strings struct { ··· 254 256 } 255 257 256 258 // first replace the existing record in the PDS 257 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 259 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 258 260 if err != nil { 259 261 fail("Failed to updated existing record.", err) 260 262 return 261 263 } 262 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 264 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 263 265 Collection: tangled.StringNSID, 264 266 Repo: entry.Did.String(), 265 267 Rkey: entry.Rkey, ··· 284 286 s.Notifier.EditString(r.Context(), &entry) 285 287 286 288 // if that went okay, redir to the string 287 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 289 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 288 290 } 289 291 290 292 } ··· 336 338 return 337 339 } 338 340 339 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 341 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 340 342 Collection: tangled.StringNSID, 341 343 Repo: user.Did, 342 344 Rkey: string.Rkey, ··· 360 362 s.Notifier.NewString(r.Context(), &string) 361 363 362 364 // successful 363 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 365 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 364 366 } 365 367 } 366 368 ··· 403 405 404 406 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 405 407 406 - s.Pages.HxRedirect(w, "/strings/"+user.Handle) 408 + s.Pages.HxRedirect(w, "/strings/"+user.Did) 407 409 } 408 410 409 411 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
-99
appview/xrpcclient/xrpc.go
··· 1 1 package xrpcclient 2 2 3 3 import ( 4 - "bytes" 5 - "context" 6 4 "errors" 7 - "io" 8 5 "net/http" 9 6 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 7 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - oauth "tangled.org/anirudh.fi/atproto-oauth" 14 8 ) 15 9 16 10 var ( ··· 20 14 ErrXrpcInvalid = errors.New("invalid xrpc request") 21 15 ) 22 16 23 - type Client struct { 24 - *oauth.XrpcClient 25 - authArgs *oauth.XrpcAuthedRequestArgs 26 - } 27 - 28 - func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client { 29 - return &Client{ 30 - XrpcClient: client, 31 - authArgs: authArgs, 32 - } 33 - } 34 - 35 - func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) { 36 - var out atproto.RepoPutRecord_Output 37 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 38 - return nil, err 39 - } 40 - 41 - return &out, nil 42 - } 43 - 44 - func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) { 45 - var out atproto.RepoApplyWrites_Output 46 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil { 47 - return nil, err 48 - } 49 - 50 - return &out, nil 51 - } 52 - 53 - func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 54 - var out atproto.RepoGetRecord_Output 55 - 56 - params := map[string]interface{}{ 57 - "cid": cid, 58 - "collection": collection, 59 - "repo": repo, 60 - "rkey": rkey, 61 - } 62 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { 63 - return nil, err 64 - } 65 - 66 - return &out, nil 67 - } 68 - 69 - func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { 70 - var out atproto.RepoUploadBlob_Output 71 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 72 - return nil, err 73 - } 74 - 75 - return &out, nil 76 - } 77 - 78 - func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { 79 - buf := new(bytes.Buffer) 80 - 81 - params := map[string]interface{}{ 82 - "cid": cid, 83 - "did": did, 84 - } 85 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { 86 - return nil, err 87 - } 88 - 89 - return buf.Bytes(), nil 90 - } 91 - 92 - func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { 93 - var out atproto.RepoDeleteRecord_Output 94 - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { 95 - return nil, err 96 - } 97 - 98 - return &out, nil 99 - } 100 - 101 - func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 102 - var out atproto.ServerGetServiceAuth_Output 103 - 104 - params := map[string]interface{}{ 105 - "aud": aud, 106 - "exp": exp, 107 - "lxm": lxm, 108 - } 109 - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 110 - return nil, err 111 - } 112 - 113 - return &out, nil 114 - } 115 - 116 17 // produces a more manageable error 117 18 func HandleXrpcErr(err error) error { 118 19 if err == nil {
+1 -1
go.mod
··· 8 8 github.com/alecthomas/chroma/v2 v2.15.0 9 9 github.com/avast/retry-go/v4 v4.6.1 10 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/casbin/casbin/v2 v2.103.0
+2
go.sum
··· 25 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 26 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 27 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 28 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= 29 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 28 30 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 32 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0"> 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 3 {{ template "basicLabels" . }} 4 4 {{ template "kvLabels" . }} 5 5 </div>
+1 -1
appview/pages/templates/repo/fragments/participants.html
··· 1 1 {{ define "repo/fragments/participants" }} 2 2 {{ $all := . }} 3 3 {{ $ps := take $all 5 }} 4 - <div class="px-6 md:px-0"> 4 + <div class="px-2 md:px-0"> 5 5 <div class="py-1 flex items-center text-sm"> 6 6 <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 7 <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
+7 -2
appview/pages/templates/repo/issues/fragments/newComment.html
··· 138 138 </div> 139 139 </form> 140 140 {{ else }} 141 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 - <a href="/login" class="underline">login</a> to join the discussion 141 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-6 relative flex gap-2 items-center"> 142 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 143 + sign up 144 + </a> 145 + <span class="text-gray-500 dark:text-gray-400">or</span> 146 + <a href="/login" class="underline">login</a> 147 + to add to the discussion 143 148 </div> 144 149 {{ end }} 145 150 {{ end }}
+7 -3
appview/pages/templates/repo/pulls/pull.html
··· 189 189 {{ if $.LoggedInUser }} 190 190 {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 191 191 {{ else }} 192 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white"> 193 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 194 - <a href="/login" class="underline">login</a> to join the discussion 192 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm p-2 relative flex gap-2 items-center w-fit"> 193 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 194 + sign up 195 + </a> 196 + <span class="text-gray-500 dark:text-gray-400">or</span> 197 + <a href="/login" class="underline">login</a> 198 + to add to the discussion 195 199 </div> 196 200 {{ end }} 197 201 </div>
+34 -7
appview/db/reaction.go
··· 62 62 return count, nil 63 63 } 64 64 65 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { 66 - countMap := map[models.ReactionKind]int{} 65 + func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 66 + query := ` 67 + select kind, reacted_by_did, 68 + row_number() over (partition by kind order by created asc) as rn, 69 + count(*) over (partition by kind) as total 70 + from reactions 71 + where thread_at = ? 72 + order by kind, created asc` 73 + 74 + rows, err := e.Query(query, threadAt) 75 + if err != nil { 76 + return nil, err 77 + } 78 + defer rows.Close() 79 + 80 + reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} 67 81 for _, kind := range models.OrderedReactionKinds { 68 - count, err := GetReactionCount(e, threadAt, kind) 69 - if err != nil { 70 - return map[models.ReactionKind]int{}, nil 82 + reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} 83 + } 84 + 85 + for rows.Next() { 86 + var kind models.ReactionKind 87 + var did string 88 + var rn, total int 89 + if err := rows.Scan(&kind, &did, &rn, &total); err != nil { 90 + return nil, err 71 91 } 72 - countMap[kind] = count 92 + 93 + data := reactionMap[kind] 94 + data.Count = total 95 + if userLimit > 0 && rn <= userLimit { 96 + data.Users = append(data.Users, did) 97 + } 98 + reactionMap[kind] = data 73 99 } 74 - return countMap, nil 100 + 101 + return reactionMap, rows.Err() 75 102 } 76 103 77 104 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+5
appview/models/reaction.go
··· 55 55 Rkey string 56 56 Kind ReactionKind 57 57 } 58 + 59 + type ReactionDisplayData struct { 60 + Count int 61 + Users []string 62 + }
+6 -1
appview/pages/templates/repo/fragments/reaction.html
··· 2 2 <button 3 3 id="reactIndi-{{ .Kind }}" 4 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 - leading-4 px-3 gap-1 5 + leading-4 px-3 gap-1 relative group 6 6 {{ if eq .Count 0 }} 7 7 hidden 8 8 {{ end }} ··· 20 20 dark:hover:border-gray-600 21 21 {{ end }} 22 22 " 23 + {{ if gt (length .Users) 0 }} 24 + title="{{ range $i, $did := .Users }}{{ if ne $i 0 }}, {{ end }}{{ resolve $did }}{{ end }}{{ if gt .Count (length .Users) }}, and {{ sub .Count (length .Users) }} more{{ end }}" 25 + {{ else }} 26 + title="{{ .Kind }}" 27 + {{ end }} 23 28 {{ if .IsReacted }} 24 29 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 30 {{ else }}
+4 -2
appview/pages/templates/repo/issues/issue.html
··· 110 110 <div class="flex items-center gap-2"> 111 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 112 {{ range $kind := .OrderedReactionKinds }} 113 + {{ $reactionData := index $.Reactions $kind }} 113 114 {{ 114 115 template "repo/fragments/reaction" 115 116 (dict 116 117 "Kind" $kind 117 - "Count" (index $.Reactions $kind) 118 + "Count" $reactionData.Count 118 119 "IsReacted" (index $.UserReacted $kind) 119 - "ThreadAt" $.Issue.AtUri) 120 + "ThreadAt" $.Issue.AtUri 121 + "Users" $reactionData.Users) 120 122 }} 121 123 {{ end }} 122 124 </div>
+4 -2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 66 66 <div class="flex items-center gap-2 mt-2"> 67 67 {{ template "repo/fragments/reactionsPopUp" . }} 68 68 {{ range $kind := . }} 69 + {{ $reactionData := index $.Reactions $kind }} 69 70 {{ 70 71 template "repo/fragments/reaction" 71 72 (dict 72 73 "Kind" $kind 73 - "Count" (index $.Reactions $kind) 74 + "Count" $reactionData.Count 74 75 "IsReacted" (index $.UserReacted $kind) 75 - "ThreadAt" $.Pull.PullAt) 76 + "ThreadAt" $.Pull.PullAt 77 + "Users" $reactionData.Users) 76 78 }} 77 79 {{ end }} 78 80 </div>

History

5 rounds 4 comments
sign up or login to add to the discussion
6 commits
expand
d76a775d
appview: switch to indigo oauth library
4a1653c5
appview: improve logged-out CTAs
56721612
appview/{db,pages,models}: show tooltips for user handles when hovering on reactions
98084555
appview: allow timeline db queries to be filterable by users follows
e067fac7
appview: allows the user to toggle between a filtered or non filtered timeline
2812b7c6
remove the front end changes
expand 0 comments
closed without merging
5 commits
expand
d76a775d
appview: switch to indigo oauth library
4a1653c5
appview: improve logged-out CTAs
56721612
appview/{db,pages,models}: show tooltips for user handles when hovering on reactions
98084555
appview: allow timeline db queries to be filterable by users follows
e067fac7
appview: allows the user to toggle between a filtered or non filtered timeline
expand 0 comments
2 commits
expand
9366b5f4
appview: allow timeline db queries to be filterable by users follows
2bb3d5d6
appview: allows the user to toggle between a filtered or non filtered timeline
expand 1 comment

following up on discord for this patch, but it works like a charm!

2 commits
expand
af82d9d6
appview: allow timeline db queries to be filterable by users follows
bba81ead
appview: allows the user to toggle between a filtered or non filtered timeline
expand 0 comments
1 commit
expand
af82d9d6
appview: allow timeline db queries to be filterable by users follows
expand 3 comments

This paves the way for allowing users to filter their timeline by just content of users they follow. I'm not great at front end stuff so may need some help getting an actual toggle into the UI.

Note: See this issue as to why the PR has no description

https://tangled.org/@tangled.org/core/issues/241

I'd be happy to assist with the toggle. I'm out on vacation next week, but feel free to reach out on Discord after that :-)

Amazing, thank you! I was planning on giving it a go this weekend and see how I get on but will ping you if I fall into a pit of dispair.