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 6 7 8 - "log" 9 - "log/slog" 10 - "net/http" 11 - "strconv" 12 - "strings" 13 - "time" 14 15 16 ··· 249 250 251 252 - } 253 254 - func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 255 - filtered := getTimelineFilteredQuery(r) 256 - user := s.oauth.GetUser(r) 257 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 267 268 ··· 272 273 274 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 if err != nil { 331 log.Println(err) 332 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 - }
··· 5 6 7 8 9 10 ··· 243 244 245 246 247 248 249 250 ··· 254 255 256 257 + if user != nil { 258 + userDid = user.Did 259 + } 260 + timeline, err := db.MakeTimeline(s.db, 50, userDid) 261 if err != nil { 262 log.Println(err) 263 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
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 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 lexutil "github.com/bluesky-social/indigo/lex/util" 17 "github.com/go-chi/chi/v5" ··· 26 "tangled.org/core/appview/pagination" 27 "tangled.org/core/appview/reporesolver" 28 "tangled.org/core/appview/validator" 29 - "tangled.org/core/appview/xrpcclient" 30 "tangled.org/core/idresolver" 31 tlog "tangled.org/core/log" 32 "tangled.org/core/tid" ··· 80 81 82 83 84 85 86 ··· 106 107 108 109 110 111 ··· 150 151 152 153 - 154 - 155 - 156 - 157 - 158 - 159 - 160 - 161 - 162 - 163 - 164 - 165 - 166 return 167 } 168 169 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 if err != nil { 171 l.Error("failed to get record", "err", err) 172 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 173 return 174 } 175 176 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 177 Collection: tangled.RepoIssueNSID, 178 Repo: user.Did, 179 Rkey: newIssue.Rkey, ··· 241 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 return 243 } 244 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 245 Collection: tangled.RepoIssueNSID, 246 Repo: issue.Did, 247 Rkey: issue.Rkey, ··· 408 } 409 410 // create a record first 411 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 412 Collection: tangled.RepoIssueCommentNSID, 413 Repo: comment.Did, 414 Rkey: comment.Rkey, ··· 559 // rkey is optional, it was introduced later 560 if newComment.Rkey != "" { 561 // update the record on pds 562 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 563 if err != nil { 564 log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 565 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 566 return 567 } 568 569 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 570 Collection: tangled.RepoIssueCommentNSID, 571 Repo: user.Did, 572 Rkey: newComment.Rkey, ··· 733 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 734 return 735 } 736 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 737 Collection: tangled.RepoIssueCommentNSID, 738 Repo: user.Did, 739 Rkey: comment.Rkey, ··· 865 rp.pages.Notice(w, "issues", "Failed to create issue.") 866 return 867 } 868 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 869 Collection: tangled.RepoIssueNSID, 870 Repo: user.Did, 871 Rkey: issue.Rkey, ··· 923 // this is used to rollback changes made to the PDS 924 // 925 // it is a no-op if the provided ATURI is empty 926 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 927 if aturi == "" { 928 return nil 929 } ··· 934 repo := parsed.Authority().String() 935 rkey := parsed.RecordKey().String() 936 937 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 938 Collection: collection, 939 Repo: repo, 940 Rkey: rkey,
··· 12 "time" 13 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 15 + atpclient "github.com/bluesky-social/indigo/atproto/client" 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 "github.com/go-chi/chi/v5" ··· 27 "tangled.org/core/appview/pagination" 28 "tangled.org/core/appview/reporesolver" 29 "tangled.org/core/appview/validator" 30 "tangled.org/core/idresolver" 31 tlog "tangled.org/core/log" 32 "tangled.org/core/tid" ··· 80 81 82 83 + return 84 + } 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 + } 90 91 92 ··· 112 113 114 115 + Issue: issue, 116 + CommentList: issue.CommentList(), 117 + OrderedReactionKinds: models.OrderedReactionKinds, 118 + Reactions: reactionMap, 119 + UserReacted: userReactions, 120 + LabelDefs: defs, 121 + }) 122 123 124 ··· 163 164 165 166 return 167 } 168 169 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 170 if err != nil { 171 l.Error("failed to get record", "err", err) 172 rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 173 return 174 } 175 176 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 177 Collection: tangled.RepoIssueNSID, 178 Repo: user.Did, 179 Rkey: newIssue.Rkey, ··· 241 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 242 return 243 } 244 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 245 Collection: tangled.RepoIssueNSID, 246 Repo: issue.Did, 247 Rkey: issue.Rkey, ··· 408 } 409 410 // create a record first 411 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 412 Collection: tangled.RepoIssueCommentNSID, 413 Repo: comment.Did, 414 Rkey: comment.Rkey, ··· 559 // rkey is optional, it was introduced later 560 if newComment.Rkey != "" { 561 // update the record on pds 562 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 563 if err != nil { 564 log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 565 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 566 return 567 } 568 569 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 570 Collection: tangled.RepoIssueCommentNSID, 571 Repo: user.Did, 572 Rkey: newComment.Rkey, ··· 733 rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 734 return 735 } 736 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 737 Collection: tangled.RepoIssueCommentNSID, 738 Repo: user.Did, 739 Rkey: comment.Rkey, ··· 865 rp.pages.Notice(w, "issues", "Failed to create issue.") 866 return 867 } 868 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 869 Collection: tangled.RepoIssueNSID, 870 Repo: user.Did, 871 Rkey: issue.Rkey, ··· 923 // this is used to rollback changes made to the PDS 924 // 925 // it is a no-op if the provided ATURI is empty 926 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 927 if aturi == "" { 928 return nil 929 } ··· 934 repo := parsed.Authority().String() 935 rkey := parsed.RecordKey().String() 936 937 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 938 Collection: collection, 939 Repo: repo, 940 Rkey: rkey,
+6 -6
appview/knots/knots.go
··· 185 return 186 } 187 188 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 189 var exCid *string 190 if ex != nil { 191 exCid = ex.Cid 192 } 193 194 // re-announce by registering under same rkey 195 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 196 Collection: tangled.KnotNSID, 197 Repo: user.Did, 198 Rkey: domain, ··· 323 return 324 } 325 326 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 327 Collection: tangled.KnotNSID, 328 Repo: user.Did, 329 Rkey: domain, ··· 431 return 432 } 433 434 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 435 var exCid *string 436 if ex != nil { 437 exCid = ex.Cid 438 } 439 440 // ignore the error here 441 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 442 Collection: tangled.KnotNSID, 443 Repo: user.Did, 444 Rkey: domain, ··· 555 556 rkey := tid.TID() 557 558 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 559 Collection: tangled.KnotMemberNSID, 560 Repo: user.Did, 561 Rkey: rkey,
··· 185 return 186 } 187 188 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 189 var exCid *string 190 if ex != nil { 191 exCid = ex.Cid 192 } 193 194 // re-announce by registering under same rkey 195 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 196 Collection: tangled.KnotNSID, 197 Repo: user.Did, 198 Rkey: domain, ··· 323 return 324 } 325 326 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 327 Collection: tangled.KnotNSID, 328 Repo: user.Did, 329 Rkey: domain, ··· 431 return 432 } 433 434 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) 435 var exCid *string 436 if ex != nil { 437 exCid = ex.Cid 438 } 439 440 // ignore the error here 441 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 442 Collection: tangled.KnotNSID, 443 Repo: user.Did, 444 Rkey: domain, ··· 555 556 rkey := tid.TID() 557 558 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 559 Collection: tangled.KnotMemberNSID, 560 Repo: user.Did, 561 Rkey: rkey,
+9 -9
appview/labels/labels.go
··· 9 "net/http" 10 "time" 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 "tangled.org/core/api/tangled" 18 "tangled.org/core/appview/db" 19 "tangled.org/core/appview/middleware" ··· 21 "tangled.org/core/appview/oauth" 22 "tangled.org/core/appview/pages" 23 "tangled.org/core/appview/validator" 24 - "tangled.org/core/appview/xrpcclient" 25 "tangled.org/core/log" 26 "tangled.org/core/rbac" 27 "tangled.org/core/tid" 28 ) 29 30 type Labels struct { ··· 196 return 197 } 198 199 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.LabelOpNSID, 201 Repo: did, 202 Rkey: rkey, ··· 252 // this is used to rollback changes made to the PDS 253 // 254 // it is a no-op if the provided ATURI is empty 255 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 256 if aturi == "" { 257 return nil 258 } ··· 263 repo := parsed.Authority().String() 264 rkey := parsed.RecordKey().String() 265 266 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 267 Collection: collection, 268 Repo: repo, 269 Rkey: rkey,
··· 9 "net/http" 10 "time" 11 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/middleware" ··· 16 "tangled.org/core/appview/oauth" 17 "tangled.org/core/appview/pages" 18 "tangled.org/core/appview/validator" 19 "tangled.org/core/log" 20 "tangled.org/core/rbac" 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 ) 29 30 type Labels struct { ··· 196 return 197 } 198 199 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.LabelOpNSID, 201 Repo: did, 202 Rkey: rkey, ··· 252 // this is used to rollback changes made to the PDS 253 // 254 // it is a no-op if the provided ATURI is empty 255 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 256 if aturi == "" { 257 return nil 258 } ··· 263 repo := parsed.Authority().String() 264 rkey := parsed.RecordKey().String() 265 266 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 267 Collection: collection, 268 Repo: repo, 269 Rkey: rkey,
+5 -14
appview/middleware/middleware.go
··· 43 44 type middlewareFunc func(http.Handler) http.Handler 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 { 56 return func(next http.Handler) http.Handler { 57 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 58 returnURL := "/" ··· 72 } 73 } 74 75 - _, auth, err := a.GetSession(r) 76 if err != nil { 77 - log.Println("not logged in, redirecting", "err", err) 78 redirectFunc(w, r) 79 return 80 } 81 82 - if !auth { 83 - log.Printf("not logged in, redirecting") 84 redirectFunc(w, r) 85 return 86 }
··· 43 44 type middlewareFunc func(http.Handler) http.Handler 45 46 + func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 47 return func(next http.Handler) http.Handler { 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 returnURL := "/" ··· 63 } 64 } 65 66 + sess, err := o.ResumeSession(r) 67 if err != nil { 68 + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) 69 redirectFunc(w, r) 70 return 71 } 72 73 + if sess == nil { 74 + log.Printf("session is nil, redirecting...") 75 redirectFunc(w, r) 76 return 77 }
+18 -20
appview/notifications/notifications.go
··· 1 package notifications 2 3 import ( 4 - "fmt" 5 "log" 6 "net/http" 7 "strconv" ··· 31 func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 32 r := chi.NewRouter() 33 34 - r.Use(middleware.AuthMiddleware(n.oauth)) 35 - 36 - r.With(middleware.Paginate).Get("/", n.notificationsPage) 37 - 38 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) 42 43 return r 44 } 45 46 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 47 - userDid := n.oauth.GetDid(r) 48 49 page, ok := r.Context().Value("page").(pagination.Page) 50 if !ok { ··· 54 55 total, err := db.CountNotifications( 56 n.db, 57 - db.FilterEq("recipient_did", userDid), 58 ) 59 if err != nil { 60 log.Println("failed to get total notifications:", err) ··· 65 notifications, err := db.GetNotificationsWithEntities( 66 n.db, 67 page, 68 - db.FilterEq("recipient_did", userDid), 69 ) 70 if err != nil { 71 log.Println("failed to get notifications:", err) ··· 73 return 74 } 75 76 - err = n.db.MarkAllNotificationsRead(r.Context(), userDid) 77 if err != nil { 78 log.Println("failed to mark notifications as read:", err) 79 } 80 81 unreadCount := 0 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{ 90 LoggedInUser: user, 91 Notifications: notifications, 92 UnreadCount: unreadCount, 93 Page: page, 94 Total: total, 95 - })) 96 } 97 98 func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 99 user := n.oauth.GetUser(r) 100 count, err := db.CountNotifications( 101 n.db, 102 db.FilterEq("recipient_did", user.Did),
··· 1 package notifications 2 3 import ( 4 "log" 5 "net/http" 6 "strconv" ··· 30 func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { 31 r := chi.NewRouter() 32 33 r.Get("/count", n.getUnreadCount) 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 43 return r 44 } 45 46 func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { 47 + user := n.oauth.GetUser(r) 48 49 page, ok := r.Context().Value("page").(pagination.Page) 50 if !ok { ··· 54 55 total, err := db.CountNotifications( 56 n.db, 57 + db.FilterEq("recipient_did", user.Did), 58 ) 59 if err != nil { 60 log.Println("failed to get total notifications:", err) ··· 65 notifications, err := db.GetNotificationsWithEntities( 66 n.db, 67 page, 68 + db.FilterEq("recipient_did", user.Did), 69 ) 70 if err != nil { 71 log.Println("failed to get notifications:", err) ··· 73 return 74 } 75 76 + err = n.db.MarkAllNotificationsRead(r.Context(), user.Did) 77 if err != nil { 78 log.Println("failed to mark notifications as read:", err) 79 } 80 81 unreadCount := 0 82 83 + n.pages.Notifications(w, pages.NotificationsParams{ 84 LoggedInUser: user, 85 Notifications: notifications, 86 UnreadCount: unreadCount, 87 Page: page, 88 Total: total, 89 + }) 90 } 91 92 func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { 93 user := n.oauth.GetUser(r) 94 + if user == nil { 95 + return 96 + } 97 + 98 count, err := db.CountNotifications( 99 n.db, 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 package oauth 2 3 const ( 4 - SessionName = "appview-session" 5 SessionHandle = "handle" 6 SessionDid = "did" 7 SessionPds = "pds" 8 SessionAccessJwt = "accessJwt" 9 SessionRefreshJwt = "refreshJwt"
··· 1 package oauth 2 3 const ( 4 + SessionName = "appview-session-v2" 5 SessionHandle = "handle" 6 SessionDid = "did" 7 + SessionId = "id" 8 SessionPds = "pds" 9 SessionAccessJwt = "accessJwt" 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 package oauth 2 3 import ( 4 "fmt" 5 - "log" 6 "net/http" 7 - "net/url" 8 "time" 9 10 - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 11 "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 "tangled.org/core/appview/config" 16 - "tangled.org/core/appview/oauth/client" 17 - xrpc "tangled.org/core/appview/xrpcclient" 18 ) 19 20 - type OAuth struct { 21 - store *sessions.CookieStore 22 - config *config.Config 23 - sess *sessioncache.SessionStore 24 - } 25 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, 31 } 32 } 33 34 - func (o *OAuth) Stores() *sessions.CookieStore { 35 - return o.store 36 } 37 38 - func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error { 39 // first we save the did in the user session 40 - userSession, err := o.store.Get(r, SessionName) 41 if err != nil { 42 return err 43 } 44 45 - userSession.Values[SessionDid] = oreq.Did 46 - userSession.Values[SessionHandle] = oreq.Handle 47 - userSession.Values[SessionPds] = oreq.PdsUrl 48 userSession.Values[SessionAuthenticated] = true 49 - err = userSession.Save(r, w) 50 if err != nil { 51 - return fmt.Errorf("error saving user session: %w", err) 52 } 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), 65 } 66 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) 74 } 75 76 - did := userSession.Values[SessionDid].(string) 77 78 - err = o.sess.DeleteSession(r.Context(), did) 79 if err != nil { 80 - return fmt.Errorf("error deleting oauth session: %w", err) 81 } 82 83 - userSession.Options.MaxAge = -1 84 - 85 - return userSession.Save(r, w) 86 } 87 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) 92 } 93 94 - did := userSession.Values[SessionDid].(string) 95 - auth := userSession.Values[SessionAuthenticated].(bool) 96 - 97 - session, err := o.sess.GetSession(r.Context(), did) 98 if err != nil { 99 - return nil, false, fmt.Errorf("error getting oauth session: %w", err) 100 } 101 102 - expiry, err := time.Parse(time.RFC3339, session.Expiry) 103 if err != nil { 104 - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 105 } 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 140 } 141 - 142 - return session, auth, nil 143 } 144 145 type User struct { 146 - Handle string 147 - Did string 148 - Pds string 149 } 150 151 - func (a *OAuth) GetUser(r *http.Request) *User { 152 - clientSession, err := a.store.Get(r, SessionName) 153 154 - if err != nil || clientSession.IsNew { 155 return nil 156 } 157 158 return &User{ 159 - Handle: clientSession.Values[SessionHandle].(string), 160 - Did: clientSession.Values[SessionDid].(string), 161 - Pds: clientSession.Values[SessionPds].(string), 162 } 163 } 164 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 "" 170 } 171 172 - return clientSession.Values[SessionDid].(string) 173 } 174 175 - func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 176 - session, auth, err := o.GetSession(r) 177 if err != nil { 178 return nil, fmt.Errorf("error getting session: %w", err) 179 } 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 208 } 209 210 - // use this to create a client to communicate with knots or spindles 211 - // 212 // this is a higher level abstraction on ServerGetServiceAuth 213 type ServiceClientOpts struct { 214 service string ··· 259 return scheme + s.service 260 } 261 262 - func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 263 opts := ServiceClientOpts{} 264 for _, o := range os { 265 o(&opts) 266 } 267 268 - authorizedClient, err := o.AuthorizedClient(r) 269 if err != nil { 270 return nil, err 271 } ··· 276 opts.exp = sixty 277 } 278 279 - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 280 if err != nil { 281 return nil, err 282 } 283 284 - return &indigo_xrpc.Client{ 285 - Auth: &indigo_xrpc.AuthInfo{ 286 AccessJwt: resp.Token, 287 }, 288 Host: opts.Host(), ··· 291 }, 292 }, nil 293 } 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 - }
··· 1 package oauth 2 3 import ( 4 + "errors" 5 "fmt" 6 "net/http" 7 "time" 8 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" 14 "github.com/gorilla/sessions" 15 + "github.com/lestrrat-go/jwx/v2/jwk" 16 "tangled.org/core/appview/config" 17 ) 18 19 + func New(config *config.Config) (*OAuth, error) { 20 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"}) 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 50 } 51 52 + type OAuth struct { 53 + ClientApp *oauth.ClientApp 54 + SessStore *sessions.CookieStore 55 + Config *config.Config 56 + JwksUri string 57 } 58 59 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { 60 // first we save the did in the user session 61 + userSession, err := o.SessStore.Get(r, SessionName) 62 if err != nil { 63 return err 64 } 65 66 + userSession.Values[SessionDid] = sessData.AccountDID.String() 67 + userSession.Values[SessionPds] = sessData.HostURL 68 + userSession.Values[SessionId] = sessData.SessionID 69 userSession.Values[SessionAuthenticated] = true 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) 75 if err != nil { 76 + return nil, fmt.Errorf("error getting user session: %w", err) 77 } 78 + if userSession.IsNew { 79 + return nil, fmt.Errorf("no session available for user") 80 } 81 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) 86 } 87 88 + sessId := userSession.Values[SessionId].(string) 89 90 + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) 91 if err != nil { 92 + return nil, fmt.Errorf("failed to resume session: %w", err) 93 } 94 95 + return clientSess, nil 96 } 97 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") 105 } 106 107 + d := userSession.Values[SessionDid].(string) 108 + sessDid, err := syntax.ParseDID(d) 109 if err != nil { 110 + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) 111 } 112 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)) 127 if err != nil { 128 + return nil, err 129 } 130 + pubKey, err := k.PublicKey() 131 + if err != nil { 132 + return nil, err 133 } 134 + return pubKey, nil 135 } 136 137 type User struct { 138 + Did string 139 + Pds string 140 } 141 142 + func (o *OAuth) GetUser(r *http.Request) *User { 143 + sess, err := o.SessStore.Get(r, SessionName) 144 145 + if err != nil || sess.IsNew { 146 return nil 147 } 148 149 return &User{ 150 + Did: sess.Values[SessionDid].(string), 151 + Pds: sess.Values[SessionPds].(string), 152 } 153 } 154 155 + func (o *OAuth) GetDid(r *http.Request) string { 156 + if u := o.GetUser(r); u != nil { 157 + return u.Did 158 } 159 160 + return "" 161 } 162 163 + func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { 164 + session, err := o.ResumeSession(r) 165 if err != nil { 166 return nil, fmt.Errorf("error getting session: %w", err) 167 } 168 + return session.APIClient(), nil 169 } 170 171 // this is a higher level abstraction on ServerGetServiceAuth 172 type ServiceClientOpts struct { 173 service string ··· 218 return scheme + s.service 219 } 220 221 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 222 opts := ServiceClientOpts{} 223 for _, o := range os { 224 o(&opts) 225 } 226 227 + client, err := o.AuthorizedClient(r) 228 if err != nil { 229 return nil, err 230 } ··· 235 opts.exp = sixty 236 } 237 238 + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 239 if err != nil { 240 return nil, err 241 } 242 243 + return &xrpc.Client{ 244 + Auth: &xrpc.AuthInfo{ 245 AccessJwt: resp.Token, 246 }, 247 Host: opts.Host(), ··· 250 }, 251 }, nil 252 }
+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 <summary 52 class="cursor-pointer list-none flex items-center gap-1" 53 > 54 - {{ $user := didOrHandle .Did .Handle }} 55 <img 56 src="{{ tinyAvatar $user }}" 57 alt=""
··· 51 <summary 52 class="cursor-pointer list-none flex items-center gap-1" 53 > 54 + {{ $user := .Did }} 55 <img 56 src="{{ tinyAvatar $user }}" 57 alt=""
+1 -1
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 3 id="pull-comment-card-{{ .RoundNumber }}" 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 7 </div> 8 <form 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
··· 3 id="pull-comment-card-{{ .RoundNumber }}" 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 + {{ resolve .LoggedInUser.Did }} 7 </div> 8 <form 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment"
+1 -3
appview/pages/templates/user/settings/profile.html
··· 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 <span>Handle</span> 35 </div> 36 - {{ if .LoggedInUser.Handle }} 37 <span class="font-bold"> 38 - @{{ .LoggedInUser.Handle }} 39 </span> 40 - {{ end }} 41 </div> 42 </div> 43 <div class="flex items-center justify-between p-4">
··· 33 <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 <span>Handle</span> 35 </div> 36 <span class="font-bold"> 37 + {{ resolve .LoggedInUser.Did }} 38 </span> 39 </div> 40 </div> 41 <div class="flex items-center justify-between p-4">
+2 -1
appview/pipelines/pipelines.go
··· 48 ) *Pipelines { 49 logger := log.New("pipelines") 50 51 - return &Pipelines{oauth: oauth, 52 repoResolver: repoResolver, 53 pages: pages, 54 idResolver: idResolver,
··· 48 ) *Pipelines { 49 logger := log.New("pipelines") 50 51 + return &Pipelines{ 52 + oauth: oauth, 53 repoResolver: repoResolver, 54 pages: pages, 55 idResolver: idResolver,
+17 -17
appview/pulls/pulls.go
··· 186 187 188 189 190 191 192 ··· 218 219 220 221 222 223 224 225 ··· 651 652 653 654 - 655 - 656 - 657 - 658 - 659 - 660 - 661 - 662 - 663 - 664 - 665 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 666 return 667 } 668 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 669 Collection: tangled.RepoPullCommentNSID, 670 Repo: user.Did, 671 Rkey: tid.TID(), ··· 1142 return 1143 } 1144 1145 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1146 Collection: tangled.RepoPullNSID, 1147 Repo: user.Did, 1148 Rkey: rkey, ··· 1239 } 1240 writes = append(writes, &write) 1241 } 1242 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1243 Repo: user.Did, 1244 Writes: writes, 1245 }) ··· 1770 return 1771 } 1772 1773 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1774 if err != nil { 1775 // failed to get record 1776 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1793 } 1794 } 1795 1796 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1797 Collection: tangled.RepoPullNSID, 1798 Repo: user.Did, 1799 Rkey: pull.Rkey, ··· 2065 return 2066 } 2067 2068 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 2069 Repo: user.Did, 2070 Writes: writes, 2071 })
··· 186 187 188 189 + m[p.Sha] = p 190 + } 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.") 196 197 198 ··· 224 225 226 227 + Pipelines: m, 228 229 + OrderedReactionKinds: models.OrderedReactionKinds, 230 + Reactions: reactionMap, 231 + UserReacted: userReactions, 232 233 + LabelDefs: defs, 234 235 236 ··· 662 663 664 665 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 666 return 667 } 668 + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 669 Collection: tangled.RepoPullCommentNSID, 670 Repo: user.Did, 671 Rkey: tid.TID(), ··· 1142 return 1143 } 1144 1145 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1146 Collection: tangled.RepoPullNSID, 1147 Repo: user.Did, 1148 Rkey: rkey, ··· 1239 } 1240 writes = append(writes, &write) 1241 } 1242 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1243 Repo: user.Did, 1244 Writes: writes, 1245 }) ··· 1770 return 1771 } 1772 1773 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1774 if err != nil { 1775 // failed to get record 1776 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") ··· 1793 } 1794 } 1795 1796 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1797 Collection: tangled.RepoPullNSID, 1798 Repo: user.Did, 1799 Rkey: pull.Rkey, ··· 2065 return 2066 } 2067 2068 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2069 Repo: user.Did, 2070 Writes: writes, 2071 })
+11 -10
appview/repo/artifact.go
··· 10 "net/url" 11 "time" 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 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview/db" 22 "tangled.org/core/appview/models" ··· 25 "tangled.org/core/appview/xrpcclient" 26 "tangled.org/core/tid" 27 "tangled.org/core/types" 28 ) 29 30 // TODO: proper statuses here on early exit ··· 60 return 61 } 62 63 - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 64 if err != nil { 65 log.Println("failed to upload blob", err) 66 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 72 rkey := tid.TID() 73 createdAt := time.Now() 74 75 - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 76 Collection: tangled.RepoArtifactNSID, 77 Repo: user.Did, 78 Rkey: rkey, ··· 249 return 250 } 251 252 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 253 Collection: tangled.RepoArtifactNSID, 254 Repo: user.Did, 255 Rkey: artifact.Rkey,
··· 10 "net/url" 11 "time" 12 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview/db" 15 "tangled.org/core/appview/models" ··· 18 "tangled.org/core/appview/xrpcclient" 19 "tangled.org/core/tid" 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" 29 ) 30 31 // TODO: proper statuses here on early exit ··· 61 return 62 } 63 64 + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) 65 if err != nil { 66 log.Println("failed to upload blob", err) 67 rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") ··· 73 rkey := tid.TID() 74 createdAt := time.Now() 75 76 + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 77 Collection: tangled.RepoArtifactNSID, 78 Repo: user.Did, 79 Rkey: rkey, ··· 250 return 251 } 252 253 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 254 Collection: tangled.RepoArtifactNSID, 255 Repo: user.Did, 256 Rkey: artifact.Rkey,
+28 -35
appview/repo/repo.go
··· 17 "strings" 18 "time" 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 "tangled.org/core/api/tangled" 24 "tangled.org/core/appview/commitverify" 25 "tangled.org/core/appview/config" ··· 40 "tangled.org/core/types" 41 "tangled.org/core/xrpc/serviceauth" 42 43 securejoin "github.com/cyphar/filepath-securejoin" 44 "github.com/go-chi/chi/v5" 45 "github.com/go-git/go-git/v5/plumbing" 46 - 47 - "github.com/bluesky-social/indigo/atproto/syntax" 48 ) 49 50 type Repo struct { ··· 307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 308 // 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) 311 if err != nil { 312 // failed to get record 313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 314 return 315 } 316 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 317 Collection: tangled.RepoNSID, 318 Repo: newRepo.Did, 319 Rkey: newRepo.Rkey, ··· 863 user := rp.oauth.GetUser(r) 864 l := rp.logger.With("handler", "EditSpindle") 865 l = l.With("did", user.Did) 866 - l = l.With("handle", user.Handle) 867 868 errorId := "operation-error" 869 fail := func(msg string, err error) { ··· 916 return 917 } 918 919 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 920 if err != nil { 921 fail("Failed to update spindle, no record found on PDS.", err) 922 return 923 } 924 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 925 Collection: tangled.RepoNSID, 926 Repo: newRepo.Did, 927 Rkey: newRepo.Rkey, ··· 951 user := rp.oauth.GetUser(r) 952 l := rp.logger.With("handler", "AddLabel") 953 l = l.With("did", user.Did) 954 - l = l.With("handle", user.Handle) 955 956 f, err := rp.repoResolver.Resolve(r) 957 if err != nil { ··· 1020 1021 // emit a labelRecord 1022 labelRecord := label.AsRecord() 1023 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1024 Collection: tangled.LabelDefinitionNSID, 1025 Repo: label.Did, 1026 Rkey: label.Rkey, ··· 1043 newRepo.Labels = append(newRepo.Labels, aturi) 1044 repoRecord := newRepo.AsRecord() 1045 1046 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1047 if err != nil { 1048 fail("Failed to update labels, no record found on PDS.", err) 1049 return 1050 } 1051 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1052 Collection: tangled.RepoNSID, 1053 Repo: newRepo.Did, 1054 Rkey: newRepo.Rkey, ··· 1111 user := rp.oauth.GetUser(r) 1112 l := rp.logger.With("handler", "DeleteLabel") 1113 l = l.With("did", user.Did) 1114 - l = l.With("handle", user.Handle) 1115 1116 f, err := rp.repoResolver.Resolve(r) 1117 if err != nil { ··· 1141 } 1142 1143 // delete label record from PDS 1144 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1145 Collection: tangled.LabelDefinitionNSID, 1146 Repo: label.Did, 1147 Rkey: label.Rkey, ··· 1163 newRepo.Labels = updated 1164 repoRecord := newRepo.AsRecord() 1165 1166 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1167 if err != nil { 1168 fail("Failed to update labels, no record found on PDS.", err) 1169 return 1170 } 1171 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1172 Collection: tangled.RepoNSID, 1173 Repo: newRepo.Did, 1174 Rkey: newRepo.Rkey, ··· 1220 user := rp.oauth.GetUser(r) 1221 l := rp.logger.With("handler", "SubscribeLabel") 1222 l = l.With("did", user.Did) 1223 - l = l.With("handle", user.Handle) 1224 1225 f, err := rp.repoResolver.Resolve(r) 1226 if err != nil { ··· 1261 return 1262 } 1263 1264 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1265 if err != nil { 1266 fail("Failed to update labels, no record found on PDS.", err) 1267 return 1268 } 1269 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1270 Collection: tangled.RepoNSID, 1271 Repo: newRepo.Did, 1272 Rkey: newRepo.Rkey, ··· 1307 user := rp.oauth.GetUser(r) 1308 l := rp.logger.With("handler", "UnsubscribeLabel") 1309 l = l.With("did", user.Did) 1310 - l = l.With("handle", user.Handle) 1311 1312 f, err := rp.repoResolver.Resolve(r) 1313 if err != nil { ··· 1350 return 1351 } 1352 1353 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1354 if err != nil { 1355 fail("Failed to update labels, no record found on PDS.", err) 1356 return 1357 } 1358 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1359 Collection: tangled.RepoNSID, 1360 Repo: newRepo.Did, 1361 Rkey: newRepo.Rkey, ··· 1479 user := rp.oauth.GetUser(r) 1480 l := rp.logger.With("handler", "AddCollaborator") 1481 l = l.With("did", user.Did) 1482 - l = l.With("handle", user.Handle) 1483 1484 f, err := rp.repoResolver.Resolve(r) 1485 if err != nil { ··· 1526 currentUser := rp.oauth.GetUser(r) 1527 rkey := tid.TID() 1528 createdAt := time.Now() 1529 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1530 Collection: tangled.RepoCollaboratorNSID, 1531 Repo: currentUser.Did, 1532 Rkey: rkey, ··· 1617 } 1618 1619 // remove record from pds 1620 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 1621 if err != nil { 1622 log.Println("failed to get authorized client", err) 1623 return 1624 } 1625 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1626 Collection: tangled.RepoNSID, 1627 Repo: user.Did, 1628 Rkey: f.Rkey, ··· 1764 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1765 user := rp.oauth.GetUser(r) 1766 l := rp.logger.With("handler", "Secrets") 1767 - l = l.With("handle", user.Handle) 1768 l = l.With("did", user.Did) 1769 1770 f, err := rp.repoResolver.Resolve(r) ··· 2179 } 2180 record := repo.AsRecord() 2181 2182 - xrpcClient, err := rp.oauth.AuthorizedClient(r) 2183 if err != nil { 2184 l.Error("failed to create xrpcclient", "err", err) 2185 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2186 return 2187 } 2188 2189 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2190 Collection: tangled.RepoNSID, 2191 Repo: user.Did, 2192 Rkey: rkey, ··· 2218 rollback := func() { 2219 err1 := tx.Rollback() 2220 err2 := rp.enforcer.E.LoadPolicy() 2221 - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 2222 2223 // ignore txn complete errors, this is okay 2224 if errors.Is(err1, sql.ErrTxDone) { ··· 2291 aturi = "" 2292 2293 rp.notifier.NewRepo(r.Context(), repo) 2294 - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2295 } 2296 } 2297 2298 // this is used to rollback changes made to the PDS 2299 // 2300 // it is a no-op if the provided ATURI is empty 2301 - func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 2302 if aturi == "" { 2303 return nil 2304 } ··· 2309 repo := parsed.Authority().String() 2310 rkey := parsed.RecordKey().String() 2311 2312 - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 2313 Collection: collection, 2314 Repo: repo, 2315 Rkey: rkey,
··· 17 "strings" 18 "time" 19 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview/commitverify" 22 "tangled.org/core/appview/config" ··· 37 "tangled.org/core/types" 38 "tangled.org/core/xrpc/serviceauth" 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" 45 securejoin "github.com/cyphar/filepath-securejoin" 46 "github.com/go-chi/chi/v5" 47 "github.com/go-git/go-git/v5/plumbing" 48 ) 49 50 type Repo struct { ··· 307 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 308 // 309 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 310 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 311 if err != nil { 312 // failed to get record 313 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 314 return 315 } 316 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 317 Collection: tangled.RepoNSID, 318 Repo: newRepo.Did, 319 Rkey: newRepo.Rkey, ··· 863 user := rp.oauth.GetUser(r) 864 l := rp.logger.With("handler", "EditSpindle") 865 l = l.With("did", user.Did) 866 867 errorId := "operation-error" 868 fail := func(msg string, err error) { ··· 915 return 916 } 917 918 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 919 if err != nil { 920 fail("Failed to update spindle, no record found on PDS.", err) 921 return 922 } 923 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 924 Collection: tangled.RepoNSID, 925 Repo: newRepo.Did, 926 Rkey: newRepo.Rkey, ··· 950 user := rp.oauth.GetUser(r) 951 l := rp.logger.With("handler", "AddLabel") 952 l = l.With("did", user.Did) 953 954 f, err := rp.repoResolver.Resolve(r) 955 if err != nil { ··· 1018 1019 // emit a labelRecord 1020 labelRecord := label.AsRecord() 1021 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1022 Collection: tangled.LabelDefinitionNSID, 1023 Repo: label.Did, 1024 Rkey: label.Rkey, ··· 1041 newRepo.Labels = append(newRepo.Labels, aturi) 1042 repoRecord := newRepo.AsRecord() 1043 1044 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1045 if err != nil { 1046 fail("Failed to update labels, no record found on PDS.", err) 1047 return 1048 } 1049 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1050 Collection: tangled.RepoNSID, 1051 Repo: newRepo.Did, 1052 Rkey: newRepo.Rkey, ··· 1109 user := rp.oauth.GetUser(r) 1110 l := rp.logger.With("handler", "DeleteLabel") 1111 l = l.With("did", user.Did) 1112 1113 f, err := rp.repoResolver.Resolve(r) 1114 if err != nil { ··· 1138 } 1139 1140 // delete label record from PDS 1141 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 1142 Collection: tangled.LabelDefinitionNSID, 1143 Repo: label.Did, 1144 Rkey: label.Rkey, ··· 1160 newRepo.Labels = updated 1161 repoRecord := newRepo.AsRecord() 1162 1163 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) 1164 if err != nil { 1165 fail("Failed to update labels, no record found on PDS.", err) 1166 return 1167 } 1168 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1169 Collection: tangled.RepoNSID, 1170 Repo: newRepo.Did, 1171 Rkey: newRepo.Rkey, ··· 1217 user := rp.oauth.GetUser(r) 1218 l := rp.logger.With("handler", "SubscribeLabel") 1219 l = l.With("did", user.Did) 1220 1221 f, err := rp.repoResolver.Resolve(r) 1222 if err != nil { ··· 1257 return 1258 } 1259 1260 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1261 if err != nil { 1262 fail("Failed to update labels, no record found on PDS.", err) 1263 return 1264 } 1265 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1266 Collection: tangled.RepoNSID, 1267 Repo: newRepo.Did, 1268 Rkey: newRepo.Rkey, ··· 1303 user := rp.oauth.GetUser(r) 1304 l := rp.logger.With("handler", "UnsubscribeLabel") 1305 l = l.With("did", user.Did) 1306 1307 f, err := rp.repoResolver.Resolve(r) 1308 if err != nil { ··· 1345 return 1346 } 1347 1348 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) 1349 if err != nil { 1350 fail("Failed to update labels, no record found on PDS.", err) 1351 return 1352 } 1353 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1354 Collection: tangled.RepoNSID, 1355 Repo: newRepo.Did, 1356 Rkey: newRepo.Rkey, ··· 1474 user := rp.oauth.GetUser(r) 1475 l := rp.logger.With("handler", "AddCollaborator") 1476 l = l.With("did", user.Did) 1477 1478 f, err := rp.repoResolver.Resolve(r) 1479 if err != nil { ··· 1520 currentUser := rp.oauth.GetUser(r) 1521 rkey := tid.TID() 1522 createdAt := time.Now() 1523 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1524 Collection: tangled.RepoCollaboratorNSID, 1525 Repo: currentUser.Did, 1526 Rkey: rkey, ··· 1611 } 1612 1613 // remove record from pds 1614 + atpClient, err := rp.oauth.AuthorizedClient(r) 1615 if err != nil { 1616 log.Println("failed to get authorized client", err) 1617 return 1618 } 1619 + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ 1620 Collection: tangled.RepoNSID, 1621 Repo: user.Did, 1622 Rkey: f.Rkey, ··· 1758 func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1759 user := rp.oauth.GetUser(r) 1760 l := rp.logger.With("handler", "Secrets") 1761 l = l.With("did", user.Did) 1762 1763 f, err := rp.repoResolver.Resolve(r) ··· 2172 } 2173 record := repo.AsRecord() 2174 2175 + atpClient, err := rp.oauth.AuthorizedClient(r) 2176 if err != nil { 2177 l.Error("failed to create xrpcclient", "err", err) 2178 rp.pages.Notice(w, "repo", "Failed to fork repository.") 2179 return 2180 } 2181 2182 + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ 2183 Collection: tangled.RepoNSID, 2184 Repo: user.Did, 2185 Rkey: rkey, ··· 2211 rollback := func() { 2212 err1 := tx.Rollback() 2213 err2 := rp.enforcer.E.LoadPolicy() 2214 + err3 := rollbackRecord(context.Background(), aturi, atpClient) 2215 2216 // ignore txn complete errors, this is okay 2217 if errors.Is(err1, sql.ErrTxDone) { ··· 2284 aturi = "" 2285 2286 rp.notifier.NewRepo(r.Context(), repo) 2287 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) 2288 } 2289 } 2290 2291 // this is used to rollback changes made to the PDS 2292 // 2293 // it is a no-op if the provided ATURI is empty 2294 + func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { 2295 if aturi == "" { 2296 return nil 2297 } ··· 2302 repo := parsed.Authority().String() 2303 rkey := parsed.RecordKey().String() 2304 2305 + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ 2306 Collection: collection, 2307 Repo: repo, 2308 Rkey: rkey,
+2 -2
appview/settings/settings.go
··· 470 } 471 472 // store in pds too 473 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 474 Collection: tangled.PublicKeyNSID, 475 Repo: did, 476 Rkey: rkey, ··· 527 528 if rkey != "" { 529 // remove from pds too 530 - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 531 Collection: tangled.PublicKeyNSID, 532 Repo: did, 533 Rkey: rkey,
··· 470 } 471 472 // store in pds too 473 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 474 Collection: tangled.PublicKeyNSID, 475 Repo: did, 476 Rkey: rkey, ··· 527 528 if rkey != "" { 529 // remove from pds too 530 + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 531 Collection: tangled.PublicKeyNSID, 532 Repo: did, 533 Rkey: rkey,
-2
appview/signup/signup.go
··· 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/state/userutil" 23 - "tangled.org/core/appview/xrpcclient" 24 "tangled.org/core/idresolver" 25 ) 26 ··· 29 db *db.DB 30 cf *dns.Cloudflare 31 posthog posthog.Client 32 - xrpc *xrpcclient.Client 33 idResolver *idresolver.Resolver 34 pages *pages.Pages 35 l *slog.Logger
··· 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/state/userutil" 23 "tangled.org/core/idresolver" 24 ) 25 ··· 28 db *db.DB 29 cf *dns.Cloudflare 30 posthog posthog.Client 31 idResolver *idresolver.Resolver 32 pages *pages.Pages 33 l *slog.Logger
+5 -5
appview/spindles/spindles.go
··· 189 return 190 } 191 192 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) 193 var exCid *string 194 if ex != nil { 195 exCid = ex.Cid 196 } 197 198 // re-announce by registering under same rkey 199 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.SpindleNSID, 201 Repo: user.Did, 202 Rkey: instance, ··· 332 return 333 } 334 335 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 336 Collection: tangled.SpindleNSID, 337 Repo: user.Did, 338 Rkey: instance, ··· 542 return 543 } 544 545 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 546 Collection: tangled.SpindleMemberNSID, 547 Repo: user.Did, 548 Rkey: rkey, ··· 683 } 684 685 // remove from pds 686 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 687 Collection: tangled.SpindleMemberNSID, 688 Repo: user.Did, 689 Rkey: members[0].Rkey,
··· 189 return 190 } 191 192 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) 193 var exCid *string 194 if ex != nil { 195 exCid = ex.Cid 196 } 197 198 // re-announce by registering under same rkey 199 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 200 Collection: tangled.SpindleNSID, 201 Repo: user.Did, 202 Rkey: instance, ··· 332 return 333 } 334 335 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 336 Collection: tangled.SpindleNSID, 337 Repo: user.Did, 338 Rkey: instance, ··· 542 return 543 } 544 545 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 546 Collection: tangled.SpindleMemberNSID, 547 Repo: user.Did, 548 Rkey: rkey, ··· 683 } 684 685 // remove from pds 686 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 687 Collection: tangled.SpindleMemberNSID, 688 Repo: user.Did, 689 Rkey: members[0].Rkey,
+2 -2
appview/state/follow.go
··· 43 case http.MethodPost: 44 createdAt := time.Now().Format(time.RFC3339) 45 rkey := tid.TID() 46 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 47 Collection: tangled.GraphFollowNSID, 48 Repo: currentUser.Did, 49 Rkey: rkey, ··· 88 return 89 } 90 91 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 92 Collection: tangled.GraphFollowNSID, 93 Repo: currentUser.Did, 94 Rkey: follow.Rkey,
··· 43 case http.MethodPost: 44 createdAt := time.Now().Format(time.RFC3339) 45 rkey := tid.TID() 46 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 47 Collection: tangled.GraphFollowNSID, 48 Repo: currentUser.Did, 49 Rkey: rkey, ··· 88 return 89 } 90 91 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 92 Collection: tangled.GraphFollowNSID, 93 Repo: currentUser.Did, 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 vanityStats = append(vanityStats, string(v.Kind)) 635 } 636 637 - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 638 var cid *string 639 if ex != nil { 640 cid = ex.Cid 641 } 642 643 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 644 Collection: tangled.ActorProfileNSID, 645 Repo: user.Did, 646 Rkey: "self",
··· 634 vanityStats = append(vanityStats, string(v.Kind)) 635 } 636 637 + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") 638 var cid *string 639 if ex != nil { 640 cid = ex.Cid 641 } 642 643 + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 644 Collection: tangled.ActorProfileNSID, 645 Repo: user.Did, 646 Rkey: "self",
+35 -7
appview/state/reaction.go
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 - 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/models" ··· 47 case http.MethodPost: 48 createdAt := time.Now().Format(time.RFC3339) 49 rkey := tid.TID() 50 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 51 Collection: tangled.FeedReactionNSID, 52 Repo: currentUser.Did, 53 Rkey: rkey, ··· 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 ··· 87 88 89 90 91 - 92 return 93 } 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 - Collection: tangled.FeedReactionNSID, 97 - Repo: currentUser.Did, 98 - Rkey: reaction.Rkey,
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 + 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/db" 14 "tangled.org/core/appview/models" ··· 47 case http.MethodPost: 48 createdAt := time.Now().Format(time.RFC3339) 49 rkey := tid.TID() 50 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 Collection: tangled.FeedReactionNSID, 52 Repo: currentUser.Did, 53 Rkey: rkey, ··· 67 68 69 70 + return 71 + } 72 73 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 74 + if err != nil { 75 + log.Println("failed to get reactions for ", subjectUri) 76 + } 77 78 + log.Println("created atproto record: ", resp.Uri) 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 + }) 87 88 89 90 91 92 93 + return 94 + } 95 96 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 97 + Collection: tangled.FeedReactionNSID, 98 + Repo: currentUser.Did, 99 + Rkey: reaction.Rkey, 100 101 102 ··· 107 108 109 110 + // this is not an issue, the firehose event might have already done this 111 + } 112 113 + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) 114 + if err != nil { 115 + log.Println("failed to get reactions for ", subjectUri) 116 return 117 } 118 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 "strings" 6 7 "github.com/go-chi/chi/v5" 8 - "github.com/gorilla/sessions" 9 "tangled.org/core/appview/issues" 10 "tangled.org/core/appview/knots" 11 "tangled.org/core/appview/labels" 12 "tangled.org/core/appview/middleware" 13 "tangled.org/core/appview/notifications" 14 - oauthhandler "tangled.org/core/appview/oauth/handler" 15 "tangled.org/core/appview/pipelines" 16 "tangled.org/core/appview/pulls" 17 "tangled.org/core/appview/repo" ··· 34 s.pages, 35 ) 36 37 - router.Use(middleware.TryRefreshSession()) 38 router.Get("/favicon.svg", s.Favicon) 39 router.Get("/favicon.ico", s.Favicon) 40 router.Get("/pwa-manifest.json", s.PWAManifest) ··· 123 // special-case handler for serving tangled.org/core 124 r.Get("/core", s.Core()) 125 126 r.Route("/repo", func(r chi.Router) { 127 r.Route("/new", func(r chi.Router) { 128 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 164 r.Mount("/notifications", s.NotificationsRouter(mw)) 165 166 r.Mount("/signup", s.SignupRouter()) 167 - r.Mount("/", s.OAuthRouter()) 168 169 r.Get("/keys/{user}", s.Keys) 170 r.Get("/terms", s.TermsOfService) ··· 191 } 192 } 193 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 func (s *State) SettingsRouter() http.Handler { 201 settings := &settings.Settings{ 202 Db: s.db,
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 "tangled.org/core/appview/issues" 9 "tangled.org/core/appview/knots" 10 "tangled.org/core/appview/labels" 11 "tangled.org/core/appview/middleware" 12 "tangled.org/core/appview/notifications" 13 "tangled.org/core/appview/pipelines" 14 "tangled.org/core/appview/pulls" 15 "tangled.org/core/appview/repo" ··· 32 s.pages, 33 ) 34 35 router.Get("/favicon.svg", s.Favicon) 36 router.Get("/favicon.ico", s.Favicon) 37 router.Get("/pwa-manifest.json", s.PWAManifest) ··· 120 // special-case handler for serving tangled.org/core 121 r.Get("/core", s.Core()) 122 123 + r.Get("/login", s.Login) 124 + r.Post("/login", s.Login) 125 + r.Post("/logout", s.Logout) 126 + 127 r.Route("/repo", func(r chi.Router) { 128 r.Route("/new", func(r chi.Router) { 129 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 165 r.Mount("/notifications", s.NotificationsRouter(mw)) 166 167 r.Mount("/signup", s.SignupRouter()) 168 + r.Mount("/", s.oauth.Router()) 169 170 r.Get("/keys/{user}", s.Keys) 171 r.Get("/terms", s.TermsOfService) ··· 192 } 193 } 194 195 func (s *State) SettingsRouter() http.Handler { 196 settings := &settings.Settings{ 197 Db: s.db,
+2 -2
appview/state/star.go
··· 40 case http.MethodPost: 41 createdAt := time.Now().Format(time.RFC3339) 42 rkey := tid.TID() 43 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 Repo: currentUser.Did, 46 Rkey: rkey, ··· 92 return 93 } 94 95 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedStarNSID, 97 Repo: currentUser.Did, 98 Rkey: star.Rkey,
··· 40 case http.MethodPost: 41 createdAt := time.Now().Format(time.RFC3339) 42 rkey := tid.TID() 43 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 Collection: tangled.FeedStarNSID, 45 Repo: currentUser.Did, 46 Rkey: rkey, ··· 92 return 93 } 94 95 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 96 Collection: tangled.FeedStarNSID, 97 Repo: currentUser.Did, 98 Rkey: star.Rkey,
+9 -7
appview/strings/strings.go
··· 22 "github.com/bluesky-social/indigo/api/atproto" 23 "github.com/bluesky-social/indigo/atproto/identity" 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 - lexutil "github.com/bluesky-social/indigo/lex/util" 26 "github.com/go-chi/chi/v5" 27 ) 28 29 type Strings struct { ··· 254 } 255 256 // first replace the existing record in the PDS 257 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 258 if err != nil { 259 fail("Failed to updated existing record.", err) 260 return 261 } 262 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 263 Collection: tangled.StringNSID, 264 Repo: entry.Did.String(), 265 Rkey: entry.Rkey, ··· 284 s.Notifier.EditString(r.Context(), &entry) 285 286 // if that went okay, redir to the string 287 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 288 } 289 290 } ··· 336 return 337 } 338 339 - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 340 Collection: tangled.StringNSID, 341 Repo: user.Did, 342 Rkey: string.Rkey, ··· 360 s.Notifier.NewString(r.Context(), &string) 361 362 // successful 363 - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 364 } 365 } 366 ··· 403 404 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 405 406 - s.Pages.HxRedirect(w, "/strings/"+user.Handle) 407 } 408 409 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
··· 22 "github.com/bluesky-social/indigo/api/atproto" 23 "github.com/bluesky-social/indigo/atproto/identity" 24 "github.com/bluesky-social/indigo/atproto/syntax" 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" 29 ) 30 31 type Strings struct { ··· 256 } 257 258 // first replace the existing record in the PDS 259 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 260 if err != nil { 261 fail("Failed to updated existing record.", err) 262 return 263 } 264 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 265 Collection: tangled.StringNSID, 266 Repo: entry.Did.String(), 267 Rkey: entry.Rkey, ··· 286 s.Notifier.EditString(r.Context(), &entry) 287 288 // if that went okay, redir to the string 289 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) 290 } 291 292 } ··· 338 return 339 } 340 341 + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ 342 Collection: tangled.StringNSID, 343 Repo: user.Did, 344 Rkey: string.Rkey, ··· 362 s.Notifier.NewString(r.Context(), &string) 363 364 // successful 365 + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) 366 } 367 } 368 ··· 405 406 s.Notifier.DeleteString(r.Context(), user.Did, rkey) 407 408 + s.Pages.HxRedirect(w, "/strings/"+user.Did) 409 } 410 411 func (s *Strings) comment(w http.ResponseWriter, r *http.Request) {
-99
appview/xrpcclient/xrpc.go
··· 1 package xrpcclient 2 3 import ( 4 - "bytes" 5 - "context" 6 "errors" 7 - "io" 8 "net/http" 9 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/xrpc" 12 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - oauth "tangled.org/anirudh.fi/atproto-oauth" 14 ) 15 16 var ( ··· 20 ErrXrpcInvalid = errors.New("invalid xrpc request") 21 ) 22 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 // produces a more manageable error 117 func HandleXrpcErr(err error) error { 118 if err == nil {
··· 1 package xrpcclient 2 3 import ( 4 "errors" 5 "net/http" 6 7 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 8 ) 9 10 var ( ··· 14 ErrXrpcInvalid = errors.New("invalid xrpc request") 15 ) 16 17 // produces a more manageable error 18 func HandleXrpcErr(err error) error { 19 if err == nil {
+1 -1
go.mod
··· 8 github.com/alecthomas/chroma/v2 v2.15.0 9 github.com/avast/retry-go/v4 v4.6.1 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0
··· 8 github.com/alecthomas/chroma/v2 v2.15.0 9 github.com/avast/retry-go/v4 v4.6.1 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/carlmjohnson/versioninfo v0.22.5 14 github.com/casbin/casbin/v2 v2.103.0
+2
go.sum
··· 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
··· 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 26 github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 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= 30 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 32 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+1 -1
appview/pages/templates/repo/fragments/labelPanel.html
··· 1 {{ define "repo/fragments/labelPanel" }} 2 - <div id="label-panel" class="flex flex-col gap-6 px-6 md:px-0"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
··· 1 {{ define "repo/fragments/labelPanel" }} 2 + <div id="label-panel" class="flex flex-col gap-6 px-2 md:px-0"> 3 {{ template "basicLabels" . }} 4 {{ template "kvLabels" . }} 5 </div>
+1 -1
appview/pages/templates/repo/fragments/participants.html
··· 1 {{ define "repo/fragments/participants" }} 2 {{ $all := . }} 3 {{ $ps := take $all 5 }} 4 - <div class="px-6 md:px-0"> 5 <div class="py-1 flex items-center text-sm"> 6 <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 7 <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 ml-1">{{ len $all }}</span>
··· 1 {{ define "repo/fragments/participants" }} 2 {{ $all := . }} 3 {{ $ps := take $all 5 }} 4 + <div class="px-2 md:px-0"> 5 <div class="py-1 flex items-center text-sm"> 6 <span class="font-bold text-gray-500 dark:text-gray-400 capitalize">Participants</span> 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 </div> 139 </form> 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 143 </div> 144 {{ end }} 145 {{ end }}
··· 138 </div> 139 </form> 140 {{ else }} 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 148 </div> 149 {{ end }} 150 {{ end }}
+7 -3
appview/pages/templates/repo/pulls/pull.html
··· 189 {{ if $.LoggedInUser }} 190 {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 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 195 </div> 196 {{ end }} 197 </div>
··· 189 {{ if $.LoggedInUser }} 190 {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} 191 {{ else }} 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 199 </div> 200 {{ end }} 201 </div>
+34 -7
appview/db/reaction.go
··· 62 return count, nil 63 } 64 65 - func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { 66 - countMap := map[models.ReactionKind]int{} 67 for _, kind := range models.OrderedReactionKinds { 68 - count, err := GetReactionCount(e, threadAt, kind) 69 - if err != nil { 70 - return map[models.ReactionKind]int{}, nil 71 } 72 - countMap[kind] = count 73 } 74 - return countMap, nil 75 } 76 77 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
··· 62 return count, nil 63 } 64 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{} 81 for _, kind := range models.OrderedReactionKinds { 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 91 } 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 99 } 100 + 101 + return reactionMap, rows.Err() 102 } 103 104 func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool {
+5
appview/models/reaction.go
··· 55 Rkey string 56 Kind ReactionKind 57 }
··· 55 Rkey string 56 Kind ReactionKind 57 } 58 + 59 + type ReactionDisplayData struct { 60 + Count int 61 + Users []string 62 + }
+6 -1
appview/pages/templates/repo/fragments/reaction.html
··· 2 <button 3 id="reactIndi-{{ .Kind }}" 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 - leading-4 px-3 gap-1 6 {{ if eq .Count 0 }} 7 hidden 8 {{ end }} ··· 20 dark:hover:border-gray-600 21 {{ end }} 22 " 23 {{ if .IsReacted }} 24 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 {{ else }}
··· 2 <button 3 id="reactIndi-{{ .Kind }}" 4 class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 + leading-4 px-3 gap-1 relative group 6 {{ if eq .Count 0 }} 7 hidden 8 {{ end }} ··· 20 dark:hover:border-gray-600 21 {{ end }} 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 }} 28 {{ if .IsReacted }} 29 hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 30 {{ else }}
+4 -2
appview/pages/templates/repo/issues/issue.html
··· 110 <div class="flex items-center gap-2"> 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 {{ range $kind := .OrderedReactionKinds }} 113 {{ 114 template "repo/fragments/reaction" 115 (dict 116 "Kind" $kind 117 - "Count" (index $.Reactions $kind) 118 "IsReacted" (index $.UserReacted $kind) 119 - "ThreadAt" $.Issue.AtUri) 120 }} 121 {{ end }} 122 </div>
··· 110 <div class="flex items-center gap-2"> 111 {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 112 {{ range $kind := .OrderedReactionKinds }} 113 + {{ $reactionData := index $.Reactions $kind }} 114 {{ 115 template "repo/fragments/reaction" 116 (dict 117 "Kind" $kind 118 + "Count" $reactionData.Count 119 "IsReacted" (index $.UserReacted $kind) 120 + "ThreadAt" $.Issue.AtUri 121 + "Users" $reactionData.Users) 122 }} 123 {{ end }} 124 </div>
+4 -2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 66 <div class="flex items-center gap-2 mt-2"> 67 {{ template "repo/fragments/reactionsPopUp" . }} 68 {{ range $kind := . }} 69 {{ 70 template "repo/fragments/reaction" 71 (dict 72 "Kind" $kind 73 - "Count" (index $.Reactions $kind) 74 "IsReacted" (index $.UserReacted $kind) 75 - "ThreadAt" $.Pull.PullAt) 76 }} 77 {{ end }} 78 </div>
··· 66 <div class="flex items-center gap-2 mt-2"> 67 {{ template "repo/fragments/reactionsPopUp" . }} 68 {{ range $kind := . }} 69 + {{ $reactionData := index $.Reactions $kind }} 70 {{ 71 template "repo/fragments/reaction" 72 (dict 73 "Kind" $kind 74 + "Count" $reactionData.Count 75 "IsReacted" (index $.UserReacted $kind) 76 + "ThreadAt" $.Pull.PullAt 77 + "Users" $reactionData.Users) 78 }} 79 {{ end }} 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.