From d76a775df3685a958b1c5796f3ad013ada3dfb83 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Sun, 5 Oct 2025 12:33:39 +0100 Subject: [PATCH] appview: switch to indigo oauth library Change-Id: mxrxyqnpypoolrxwuuzywruvnrzwrmsn Signed-off-by: oppiliappan --- appview/issues/issues.go | 22 +- appview/knots/knots.go | 12 +- appview/labels/labels.go | 18 +- appview/middleware/middleware.go | 19 +- appview/notifications/notifications.go | 38 +- appview/oauth/client/oauth_client.go | 24 - appview/oauth/consts.go | 3 +- appview/oauth/handler.go | 65 +++ appview/oauth/handler/handler.go | 538 ------------------ appview/oauth/oauth.go | 315 ++++------ appview/oauth/store.go | 147 +++++ .../templates/layouts/fragments/topbar.html | 2 +- .../repo/pulls/fragments/pullNewComment.html | 2 +- .../templates/user/settings/profile.html | 4 +- appview/pipelines/pipelines.go | 3 +- appview/pulls/pulls.go | 12 +- appview/repo/artifact.go | 21 +- appview/repo/repo.go | 63 +- appview/settings/settings.go | 4 +- appview/signup/signup.go | 2 - appview/spindles/spindles.go | 10 +- appview/state/follow.go | 4 +- appview/state/login.go | 63 ++ appview/state/profile.go | 4 +- appview/state/reaction.go | 6 +- appview/state/router.go | 15 +- appview/state/star.go | 4 +- appview/state/state.go | 43 +- appview/strings/strings.go | 16 +- appview/xrpcclient/xrpc.go | 99 ---- go.mod | 2 +- go.sum | 2 + 32 files changed, 542 insertions(+), 1040 deletions(-) delete mode 100644 appview/oauth/client/oauth_client.go create mode 100644 appview/oauth/handler.go delete mode 100644 appview/oauth/handler/handler.go create mode 100644 appview/oauth/store.go create mode 100644 appview/state/login.go diff --git a/appview/issues/issues.go b/appview/issues/issues.go index 8e4ed732..d4f654a3 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -12,6 +12,7 @@ import ( "time" comatproto "github.com/bluesky-social/indigo/api/atproto" + atpclient "github.com/bluesky-social/indigo/atproto/client" "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/go-chi/chi/v5" @@ -26,7 +27,6 @@ import ( "tangled.org/core/appview/pagination" "tangled.org/core/appview/reporesolver" "tangled.org/core/appview/validator" - "tangled.org/core/appview/xrpcclient" "tangled.org/core/idresolver" tlog "tangled.org/core/log" "tangled.org/core/tid" @@ -166,14 +166,14 @@ func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { return } - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) if err != nil { l.Error("failed to get record", "err", err) rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") return } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueNSID, Repo: user.Did, Rkey: newIssue.Rkey, @@ -241,7 +241,7 @@ func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") return } - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.RepoIssueNSID, Repo: issue.Did, Rkey: issue.Rkey, @@ -408,7 +408,7 @@ func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { } // create a record first - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueCommentNSID, Repo: comment.Did, Rkey: comment.Rkey, @@ -559,14 +559,14 @@ func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { // rkey is optional, it was introduced later if newComment.Rkey != "" { // update the record on pds - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) if err != nil { log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") return } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueCommentNSID, Repo: user.Did, Rkey: newComment.Rkey, @@ -733,7 +733,7 @@ func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") return } - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.RepoIssueCommentNSID, Repo: user.Did, Rkey: comment.Rkey, @@ -865,7 +865,7 @@ func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { rp.pages.Notice(w, "issues", "Failed to create issue.") return } - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueNSID, Repo: user.Did, Rkey: issue.Rkey, @@ -923,7 +923,7 @@ func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { // this is used to rollback changes made to the PDS // // it is a no-op if the provided ATURI is empty -func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { +func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { if aturi == "" { return nil } @@ -934,7 +934,7 @@ func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) repo := parsed.Authority().String() rkey := parsed.RecordKey().String() - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ Collection: collection, Repo: repo, Rkey: rkey, diff --git a/appview/knots/knots.go b/appview/knots/knots.go index 97c4d2e9..a5d41ed8 100644 --- a/appview/knots/knots.go +++ b/appview/knots/knots.go @@ -185,14 +185,14 @@ func (k *Knots) register(w http.ResponseWriter, r *http.Request) { return } - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) var exCid *string if ex != nil { exCid = ex.Cid } // re-announce by registering under same rkey - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.KnotNSID, Repo: user.Did, Rkey: domain, @@ -323,7 +323,7 @@ func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { return } - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.KnotNSID, Repo: user.Did, Rkey: domain, @@ -431,14 +431,14 @@ func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { return } - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.KnotNSID, user.Did, domain) var exCid *string if ex != nil { exCid = ex.Cid } // ignore the error here - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.KnotNSID, Repo: user.Did, Rkey: domain, @@ -555,7 +555,7 @@ func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { rkey := tid.TID() - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.KnotMemberNSID, Repo: user.Did, Rkey: rkey, diff --git a/appview/labels/labels.go b/appview/labels/labels.go index 94860917..f1384032 100644 --- a/appview/labels/labels.go +++ b/appview/labels/labels.go @@ -9,11 +9,6 @@ import ( "net/http" "time" - comatproto "github.com/bluesky-social/indigo/api/atproto" - "github.com/bluesky-social/indigo/atproto/syntax" - lexutil "github.com/bluesky-social/indigo/lex/util" - "github.com/go-chi/chi/v5" - "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/middleware" @@ -21,10 +16,15 @@ import ( "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/validator" - "tangled.org/core/appview/xrpcclient" "tangled.org/core/log" "tangled.org/core/rbac" "tangled.org/core/tid" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + atpclient "github.com/bluesky-social/indigo/atproto/client" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" + "github.com/go-chi/chi/v5" ) type Labels struct { @@ -196,7 +196,7 @@ func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { return } - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.LabelOpNSID, Repo: did, Rkey: rkey, @@ -252,7 +252,7 @@ func (l *Labels) PerformLabelOp(w http.ResponseWriter, r *http.Request) { // this is used to rollback changes made to the PDS // // it is a no-op if the provided ATURI is empty -func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { +func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { if aturi == "" { return nil } @@ -263,7 +263,7 @@ func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) repo := parsed.Authority().String() rkey := parsed.RecordKey().String() - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ Collection: collection, Repo: repo, Rkey: rkey, diff --git a/appview/middleware/middleware.go b/appview/middleware/middleware.go index 01d9d2ee..2d7366ab 100644 --- a/appview/middleware/middleware.go +++ b/appview/middleware/middleware.go @@ -43,16 +43,7 @@ func New(oauth *oauth.OAuth, db *db.DB, enforcer *rbac.Enforcer, repoResolver *r type middlewareFunc func(http.Handler) http.Handler -func (mw *Middleware) TryRefreshSession() middlewareFunc { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _, _ = mw.oauth.GetSession(r) - next.ServeHTTP(w, r) - }) - } -} - -func AuthMiddleware(a *oauth.OAuth) middlewareFunc { +func AuthMiddleware(o *oauth.OAuth) middlewareFunc { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { returnURL := "/" @@ -72,15 +63,15 @@ func AuthMiddleware(a *oauth.OAuth) middlewareFunc { } } - _, auth, err := a.GetSession(r) + sess, err := o.ResumeSession(r) if err != nil { - log.Println("not logged in, redirecting", "err", err) + log.Println("failed to resume session, redirecting...", "err", err, "url", r.URL.String()) redirectFunc(w, r) return } - if !auth { - log.Printf("not logged in, redirecting") + if sess == nil { + log.Printf("session is nil, redirecting...") redirectFunc(w, r) return } diff --git a/appview/notifications/notifications.go b/appview/notifications/notifications.go index bb017df4..5cda8e39 100644 --- a/appview/notifications/notifications.go +++ b/appview/notifications/notifications.go @@ -1,7 +1,6 @@ package notifications import ( - "fmt" "log" "net/http" "strconv" @@ -31,20 +30,21 @@ func New(database *db.DB, oauthHandler *oauth.OAuth, pagesHandler *pages.Pages) func (n *Notifications) Router(mw *middleware.Middleware) http.Handler { r := chi.NewRouter() - r.Use(middleware.AuthMiddleware(n.oauth)) - - r.With(middleware.Paginate).Get("/", n.notificationsPage) - r.Get("/count", n.getUnreadCount) - r.Post("/{id}/read", n.markRead) - r.Post("/read-all", n.markAllRead) - r.Delete("/{id}", n.deleteNotification) + + r.Group(func(r chi.Router) { + r.Use(middleware.AuthMiddleware(n.oauth)) + r.With(middleware.Paginate).Get("/", n.notificationsPage) + r.Post("/{id}/read", n.markRead) + r.Post("/read-all", n.markAllRead) + r.Delete("/{id}", n.deleteNotification) + }) return r } func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request) { - userDid := n.oauth.GetDid(r) + user := n.oauth.GetUser(r) page, ok := r.Context().Value("page").(pagination.Page) if !ok { @@ -54,7 +54,7 @@ func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request total, err := db.CountNotifications( n.db, - db.FilterEq("recipient_did", userDid), + db.FilterEq("recipient_did", user.Did), ) if err != nil { log.Println("failed to get total notifications:", err) @@ -65,7 +65,7 @@ func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request notifications, err := db.GetNotificationsWithEntities( n.db, page, - db.FilterEq("recipient_did", userDid), + db.FilterEq("recipient_did", user.Did), ) if err != nil { log.Println("failed to get notifications:", err) @@ -73,30 +73,28 @@ func (n *Notifications) notificationsPage(w http.ResponseWriter, r *http.Request return } - err = n.db.MarkAllNotificationsRead(r.Context(), userDid) + err = n.db.MarkAllNotificationsRead(r.Context(), user.Did) if err != nil { log.Println("failed to mark notifications as read:", err) } unreadCount := 0 - user := n.oauth.GetUser(r) - if user == nil { - http.Error(w, "Failed to get user", http.StatusInternalServerError) - return - } - - fmt.Println(n.pages.Notifications(w, pages.NotificationsParams{ + n.pages.Notifications(w, pages.NotificationsParams{ LoggedInUser: user, Notifications: notifications, UnreadCount: unreadCount, Page: page, Total: total, - })) + }) } func (n *Notifications) getUnreadCount(w http.ResponseWriter, r *http.Request) { user := n.oauth.GetUser(r) + if user == nil { + return + } + count, err := db.CountNotifications( n.db, db.FilterEq("recipient_did", user.Did), diff --git a/appview/oauth/client/oauth_client.go b/appview/oauth/client/oauth_client.go deleted file mode 100644 index 428e62e2..00000000 --- a/appview/oauth/client/oauth_client.go +++ /dev/null @@ -1,24 +0,0 @@ -package client - -import ( - oauth "tangled.org/anirudh.fi/atproto-oauth" - "tangled.org/anirudh.fi/atproto-oauth/helpers" -) - -type OAuthClient struct { - *oauth.Client -} - -func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) { - k, err := helpers.ParseJWKFromBytes([]byte(clientJwk)) - if err != nil { - return nil, err - } - - cli, err := oauth.NewClient(oauth.ClientArgs{ - ClientId: clientId, - ClientJwk: k, - RedirectUri: redirectUri, - }) - return &OAuthClient{cli}, err -} diff --git a/appview/oauth/consts.go b/appview/oauth/consts.go index 487c9a89..f86ca6a0 100644 --- a/appview/oauth/consts.go +++ b/appview/oauth/consts.go @@ -1,9 +1,10 @@ package oauth const ( - SessionName = "appview-session" + SessionName = "appview-session-v2" SessionHandle = "handle" SessionDid = "did" + SessionId = "id" SessionPds = "pds" SessionAccessJwt = "accessJwt" SessionRefreshJwt = "refreshJwt" diff --git a/appview/oauth/handler.go b/appview/oauth/handler.go new file mode 100644 index 00000000..2172ab13 --- /dev/null +++ b/appview/oauth/handler.go @@ -0,0 +1,65 @@ +package oauth + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/lestrrat-go/jwx/v2/jwk" +) + +func (o *OAuth) Router() http.Handler { + r := chi.NewRouter() + + r.Get("/oauth/client-metadata.json", o.clientMetadata) + r.Get("/oauth/jwks.json", o.jwks) + r.Get("/oauth/callback", o.callback) + return r +} + +func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { + doc := o.ClientApp.Config.ClientMetadata() + doc.JWKSURI = &o.JwksUri + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(doc); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { + jwks := o.Config.OAuth.Jwks + pubKey, err := pubKeyFromJwk(jwks) + if err != nil { + log.Printf("error parsing public key: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + response := map[string]any{ + "keys": []jwk.Key{pubKey}, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := o.SaveSession(w, r, sessData); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/", http.StatusFound) +} diff --git a/appview/oauth/handler/handler.go b/appview/oauth/handler/handler.go deleted file mode 100644 index 7c725886..00000000 --- a/appview/oauth/handler/handler.go +++ /dev/null @@ -1,538 +0,0 @@ -package oauth - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "net/url" - "slices" - "strings" - "time" - - "github.com/go-chi/chi/v5" - "github.com/gorilla/sessions" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/posthog/posthog-go" - "tangled.org/anirudh.fi/atproto-oauth/helpers" - tangled "tangled.org/core/api/tangled" - sessioncache "tangled.org/core/appview/cache/session" - "tangled.org/core/appview/config" - "tangled.org/core/appview/db" - "tangled.org/core/appview/middleware" - "tangled.org/core/appview/oauth" - "tangled.org/core/appview/oauth/client" - "tangled.org/core/appview/pages" - "tangled.org/core/consts" - "tangled.org/core/idresolver" - "tangled.org/core/rbac" - "tangled.org/core/tid" -) - -const ( - oauthScope = "atproto transition:generic" -) - -type OAuthHandler struct { - config *config.Config - pages *pages.Pages - idResolver *idresolver.Resolver - sess *sessioncache.SessionStore - db *db.DB - store *sessions.CookieStore - oauth *oauth.OAuth - enforcer *rbac.Enforcer - posthog posthog.Client -} - -func New( - config *config.Config, - pages *pages.Pages, - idResolver *idresolver.Resolver, - db *db.DB, - sess *sessioncache.SessionStore, - store *sessions.CookieStore, - oauth *oauth.OAuth, - enforcer *rbac.Enforcer, - posthog posthog.Client, -) *OAuthHandler { - return &OAuthHandler{ - config: config, - pages: pages, - idResolver: idResolver, - db: db, - sess: sess, - store: store, - oauth: oauth, - enforcer: enforcer, - posthog: posthog, - } -} - -func (o *OAuthHandler) Router() http.Handler { - r := chi.NewRouter() - - r.Get("/login", o.login) - r.Post("/login", o.login) - - r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout) - - r.Get("/oauth/client-metadata.json", o.clientMetadata) - r.Get("/oauth/jwks.json", o.jwks) - r.Get("/oauth/callback", o.callback) - return r -} - -func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(o.oauth.ClientMetadata()) -} - -func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { - jwks := o.config.OAuth.Jwks - pubKey, err := pubKeyFromJwk(jwks) - if err != nil { - log.Printf("error parsing public key: %v", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - response := helpers.CreateJwksResponseObject(pubKey) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) -} - -func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - returnURL := r.URL.Query().Get("return_url") - o.pages.Login(w, pages.LoginParams{ - ReturnUrl: returnURL, - }) - case http.MethodPost: - handle := r.FormValue("handle") - - // when users copy their handle from bsky.app, it tends to have these characters around it: - // - // @nelind.dk: - // \u202a ensures that the handle is always rendered left to right and - // \u202c reverts that so the rest of the page renders however it should - handle = strings.TrimPrefix(handle, "\u202a") - handle = strings.TrimSuffix(handle, "\u202c") - - // `@` is harmless - handle = strings.TrimPrefix(handle, "@") - - // basic handle validation - if !strings.Contains(handle, ".") { - log.Println("invalid handle format", "raw", handle) - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social?", handle, handle)) - return - } - - resolved, err := o.idResolver.ResolveIdent(r.Context(), handle) - if err != nil { - log.Println("failed to resolve handle:", err) - o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) - return - } - self := o.oauth.ClientMetadata() - oauthClient, err := client.NewClient( - self.ClientID, - o.config.OAuth.Jwks, - self.RedirectURIs[0], - ) - - if err != nil { - log.Println("failed to create oauth client:", err) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) - if err != nil { - log.Println("failed to resolve auth server:", err) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) - if err != nil { - log.Println("failed to fetch auth server metadata:", err) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - dpopKey, err := helpers.GenerateKey(nil) - if err != nil { - log.Println("failed to generate dpop key:", err) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - dpopKeyJson, err := json.Marshal(dpopKey) - if err != nil { - log.Println("failed to marshal dpop key:", err) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) - if err != nil { - log.Println("failed to send par auth request:", err) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - err = o.sess.SaveRequest(r.Context(), sessioncache.OAuthRequest{ - Did: resolved.DID.String(), - PdsUrl: resolved.PDSEndpoint(), - Handle: handle, - AuthserverIss: authMeta.Issuer, - PkceVerifier: parResp.PkceVerifier, - DpopAuthserverNonce: parResp.DpopAuthserverNonce, - DpopPrivateJwk: string(dpopKeyJson), - State: parResp.State, - ReturnUrl: r.FormValue("return_url"), - }) - if err != nil { - log.Println("failed to save oauth request:", err) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - u, _ := url.Parse(authMeta.AuthorizationEndpoint) - query := url.Values{} - query.Add("client_id", self.ClientID) - query.Add("request_uri", parResp.RequestUri) - u.RawQuery = query.Encode() - o.pages.HxRedirect(w, u.String()) - } -} - -func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { - state := r.FormValue("state") - - oauthRequest, err := o.sess.GetRequestByState(r.Context(), state) - if err != nil { - log.Println("failed to get oauth request:", err) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - defer func() { - err := o.sess.DeleteRequestByState(r.Context(), state) - if err != nil { - log.Println("failed to delete oauth request for state:", state, err) - } - }() - - error := r.FormValue("error") - errorDescription := r.FormValue("error_description") - if error != "" || errorDescription != "" { - log.Printf("error: %s, %s", error, errorDescription) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - code := r.FormValue("code") - if code == "" { - log.Println("missing code for state: ", state) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - iss := r.FormValue("iss") - if iss == "" { - log.Println("missing iss for state: ", state) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - if iss != oauthRequest.AuthserverIss { - log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - self := o.oauth.ClientMetadata() - - oauthClient, err := client.NewClient( - self.ClientID, - o.config.OAuth.Jwks, - self.RedirectURIs[0], - ) - - if err != nil { - log.Println("failed to create oauth client:", err) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) - if err != nil { - log.Println("failed to parse jwk:", err) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - tokenResp, err := oauthClient.InitialTokenRequest( - r.Context(), - code, - oauthRequest.AuthserverIss, - oauthRequest.PkceVerifier, - oauthRequest.DpopAuthserverNonce, - jwk, - ) - if err != nil { - log.Println("failed to get token:", err) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - if tokenResp.Scope != oauthScope { - log.Println("scope doesn't match:", tokenResp.Scope) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - err = o.oauth.SaveSession(w, r, *oauthRequest, tokenResp) - if err != nil { - log.Println("failed to save session:", err) - o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") - return - } - - log.Println("session saved successfully") - go o.addToDefaultKnot(oauthRequest.Did) - go o.addToDefaultSpindle(oauthRequest.Did) - - if !o.config.Core.Dev { - err = o.posthog.Enqueue(posthog.Capture{ - DistinctId: oauthRequest.Did, - Event: "signin", - }) - if err != nil { - log.Println("failed to enqueue posthog event:", err) - } - } - - returnUrl := oauthRequest.ReturnUrl - if returnUrl == "" { - returnUrl = "/" - } - - http.Redirect(w, r, returnUrl, http.StatusFound) -} - -func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { - err := o.oauth.ClearSession(r, w) - if err != nil { - log.Println("failed to clear session:", err) - http.Redirect(w, r, "/", http.StatusFound) - return - } - - log.Println("session cleared successfully") - o.pages.HxRedirect(w, "/login") -} - -func pubKeyFromJwk(jwks string) (jwk.Key, error) { - k, err := helpers.ParseJWKFromBytes([]byte(jwks)) - if err != nil { - return nil, err - } - pubKey, err := k.PublicKey() - if err != nil { - return nil, err - } - return pubKey, nil -} - -func (o *OAuthHandler) addToDefaultSpindle(did string) { - // use the tangled.sh app password to get an accessJwt - // and create an sh.tangled.spindle.member record with that - spindleMembers, err := db.GetSpindleMembers( - o.db, - db.FilterEq("instance", "spindle.tangled.sh"), - db.FilterEq("subject", did), - ) - if err != nil { - log.Printf("failed to get spindle members for did %s: %v", did, err) - return - } - - if len(spindleMembers) != 0 { - log.Printf("did %s is already a member of the default spindle", did) - return - } - - log.Printf("adding %s to default spindle", did) - session, err := o.createAppPasswordSession(o.config.Core.AppPassword, consts.TangledDid) - if err != nil { - log.Printf("failed to create session: %s", err) - return - } - - record := tangled.SpindleMember{ - LexiconTypeID: "sh.tangled.spindle.member", - Subject: did, - Instance: consts.DefaultSpindle, - CreatedAt: time.Now().Format(time.RFC3339), - } - - if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { - log.Printf("failed to add member to default spindle: %s", err) - return - } - - log.Printf("successfully added %s to default spindle", did) -} - -func (o *OAuthHandler) addToDefaultKnot(did string) { - // use the tangled.sh app password to get an accessJwt - // and create an sh.tangled.spindle.member record with that - - allKnots, err := o.enforcer.GetKnotsForUser(did) - if err != nil { - log.Printf("failed to get knot members for did %s: %v", did, err) - return - } - - if slices.Contains(allKnots, consts.DefaultKnot) { - log.Printf("did %s is already a member of the default knot", did) - return - } - - log.Printf("adding %s to default knot", did) - session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, consts.IcyDid) - if err != nil { - log.Printf("failed to create session: %s", err) - return - } - - record := tangled.KnotMember{ - LexiconTypeID: "sh.tangled.knot.member", - Subject: did, - Domain: consts.DefaultKnot, - CreatedAt: time.Now().Format(time.RFC3339), - } - - if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { - log.Printf("failed to add member to default knot: %s", err) - return - } - - if err := o.enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { - log.Printf("failed to set up enforcer rules: %s", err) - return - } - - log.Printf("successfully added %s to default Knot", did) -} - -// create a session using apppasswords -type session struct { - AccessJwt string `json:"accessJwt"` - PdsEndpoint string - Did string -} - -func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { - if appPassword == "" { - return nil, fmt.Errorf("no app password configured, skipping member addition") - } - - resolved, err := o.idResolver.ResolveIdent(context.Background(), did) - if err != nil { - return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) - } - - pdsEndpoint := resolved.PDSEndpoint() - if pdsEndpoint == "" { - return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) - } - - sessionPayload := map[string]string{ - "identifier": did, - "password": appPassword, - } - sessionBytes, err := json.Marshal(sessionPayload) - if err != nil { - return nil, fmt.Errorf("failed to marshal session payload: %v", err) - } - - sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" - sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) - if err != nil { - return nil, fmt.Errorf("failed to create session request: %v", err) - } - sessionReq.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 30 * time.Second} - sessionResp, err := client.Do(sessionReq) - if err != nil { - return nil, fmt.Errorf("failed to create session: %v", err) - } - defer sessionResp.Body.Close() - - if sessionResp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) - } - - var session session - if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { - return nil, fmt.Errorf("failed to decode session response: %v", err) - } - - session.PdsEndpoint = pdsEndpoint - session.Did = did - - return &session, nil -} - -func (s *session) putRecord(record any, collection string) error { - recordBytes, err := json.Marshal(record) - if err != nil { - return fmt.Errorf("failed to marshal knot member record: %w", err) - } - - payload := map[string]any{ - "repo": s.Did, - "collection": collection, - "rkey": tid.TID(), - "record": json.RawMessage(recordBytes), - } - - payloadBytes, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal request payload: %w", err) - } - - url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" - req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) - if err != nil { - return fmt.Errorf("failed to create HTTP request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+s.AccessJwt) - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to add user to default service: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) - } - - return nil -} diff --git a/appview/oauth/oauth.go b/appview/oauth/oauth.go index e5ab33be..19f293a2 100644 --- a/appview/oauth/oauth.go +++ b/appview/oauth/oauth.go @@ -1,214 +1,173 @@ package oauth import ( + "errors" "fmt" - "log" "net/http" - "net/url" "time" - indigo_xrpc "github.com/bluesky-social/indigo/xrpc" + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/atproto/auth/oauth" + atpclient "github.com/bluesky-social/indigo/atproto/client" + "github.com/bluesky-social/indigo/atproto/syntax" + xrpc "github.com/bluesky-social/indigo/xrpc" "github.com/gorilla/sessions" - oauth "tangled.org/anirudh.fi/atproto-oauth" - "tangled.org/anirudh.fi/atproto-oauth/helpers" - sessioncache "tangled.org/core/appview/cache/session" + "github.com/lestrrat-go/jwx/v2/jwk" "tangled.org/core/appview/config" - "tangled.org/core/appview/oauth/client" - xrpc "tangled.org/core/appview/xrpcclient" ) -type OAuth struct { - store *sessions.CookieStore - config *config.Config - sess *sessioncache.SessionStore -} +func New(config *config.Config) (*OAuth, error) { -func NewOAuth(config *config.Config, sess *sessioncache.SessionStore) *OAuth { - return &OAuth{ - store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)), - config: config, - sess: sess, + var oauthConfig oauth.ClientConfig + var clientUri string + + if config.Core.Dev { + clientUri = "http://127.0.0.1:3000" + callbackUri := clientUri + "/oauth/callback" + oauthConfig = oauth.NewLocalhostConfig(callbackUri, []string{"atproto", "transition:generic"}) + } else { + clientUri = config.Core.AppviewHost + clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri) + callbackUri := clientUri + "/oauth/callback" + oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, []string{"atproto", "transition:generic"}) } + + jwksUri := clientUri + "/oauth/jwks.json" + + authStore, err := NewRedisStore(config.Redis.ToURL()) + if err != nil { + return nil, err + } + + sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) + + return &OAuth{ + ClientApp: oauth.NewClientApp(&oauthConfig, authStore), + Config: config, + SessStore: sessStore, + JwksUri: jwksUri, + }, nil } -func (o *OAuth) Stores() *sessions.CookieStore { - return o.store +type OAuth struct { + ClientApp *oauth.ClientApp + SessStore *sessions.CookieStore + Config *config.Config + JwksUri string } -func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq sessioncache.OAuthRequest, oresp *oauth.TokenResponse) error { +func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { // first we save the did in the user session - userSession, err := o.store.Get(r, SessionName) + userSession, err := o.SessStore.Get(r, SessionName) if err != nil { return err } - userSession.Values[SessionDid] = oreq.Did - userSession.Values[SessionHandle] = oreq.Handle - userSession.Values[SessionPds] = oreq.PdsUrl + userSession.Values[SessionDid] = sessData.AccountDID.String() + userSession.Values[SessionPds] = sessData.HostURL + userSession.Values[SessionId] = sessData.SessionID userSession.Values[SessionAuthenticated] = true - err = userSession.Save(r, w) + return userSession.Save(r, w) +} + +func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) { + userSession, err := o.SessStore.Get(r, SessionName) if err != nil { - return fmt.Errorf("error saving user session: %w", err) + return nil, fmt.Errorf("error getting user session: %w", err) } - - // then save the whole thing in the db - session := sessioncache.OAuthSession{ - Did: oreq.Did, - Handle: oreq.Handle, - PdsUrl: oreq.PdsUrl, - DpopAuthserverNonce: oreq.DpopAuthserverNonce, - AuthServerIss: oreq.AuthserverIss, - DpopPrivateJwk: oreq.DpopPrivateJwk, - AccessJwt: oresp.AccessToken, - RefreshJwt: oresp.RefreshToken, - Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339), + if userSession.IsNew { + return nil, fmt.Errorf("no session available for user") } - return o.sess.SaveSession(r.Context(), session) -} - -func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error { - userSession, err := o.store.Get(r, SessionName) - if err != nil || userSession.IsNew { - return fmt.Errorf("error getting user session (or new session?): %w", err) + d := userSession.Values[SessionDid].(string) + sessDid, err := syntax.ParseDID(d) + if err != nil { + return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) } - did := userSession.Values[SessionDid].(string) + sessId := userSession.Values[SessionId].(string) - err = o.sess.DeleteSession(r.Context(), did) + clientSess, err := o.ClientApp.ResumeSession(r.Context(), sessDid, sessId) if err != nil { - return fmt.Errorf("error deleting oauth session: %w", err) + return nil, fmt.Errorf("failed to resume session: %w", err) } - userSession.Options.MaxAge = -1 - - return userSession.Save(r, w) + return clientSess, nil } -func (o *OAuth) GetSession(r *http.Request) (*sessioncache.OAuthSession, bool, error) { - userSession, err := o.store.Get(r, SessionName) - if err != nil || userSession.IsNew { - return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err) +func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { + userSession, err := o.SessStore.Get(r, SessionName) + if err != nil { + return fmt.Errorf("error getting user session: %w", err) + } + if userSession.IsNew { + return fmt.Errorf("no session available for user") } - did := userSession.Values[SessionDid].(string) - auth := userSession.Values[SessionAuthenticated].(bool) - - session, err := o.sess.GetSession(r.Context(), did) + d := userSession.Values[SessionDid].(string) + sessDid, err := syntax.ParseDID(d) if err != nil { - return nil, false, fmt.Errorf("error getting oauth session: %w", err) + return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err) } - expiry, err := time.Parse(time.RFC3339, session.Expiry) + sessId := userSession.Values[SessionId].(string) + + // delete the session + err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId) + + // remove the cookie + userSession.Options.MaxAge = -1 + err2 := o.SessStore.Save(r, w, userSession) + + return errors.Join(err1, err2) +} + +func pubKeyFromJwk(jwks string) (jwk.Key, error) { + k, err := jwk.ParseKey([]byte(jwks)) if err != nil { - return nil, false, fmt.Errorf("error parsing expiry time: %w", err) + return nil, err } - if time.Until(expiry) <= 5*time.Minute { - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) - if err != nil { - return nil, false, err - } - - self := o.ClientMetadata() - - oauthClient, err := client.NewClient( - self.ClientID, - o.config.OAuth.Jwks, - self.RedirectURIs[0], - ) - - if err != nil { - return nil, false, err - } - - resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) - if err != nil { - return nil, false, err - } - - newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339) - err = o.sess.RefreshSession(r.Context(), did, resp.AccessToken, resp.RefreshToken, newExpiry) - if err != nil { - return nil, false, fmt.Errorf("error refreshing oauth session: %w", err) - } - - // update the current session - session.AccessJwt = resp.AccessToken - session.RefreshJwt = resp.RefreshToken - session.DpopAuthserverNonce = resp.DpopAuthserverNonce - session.Expiry = newExpiry + pubKey, err := k.PublicKey() + if err != nil { + return nil, err } - - return session, auth, nil + return pubKey, nil } type User struct { - Handle string - Did string - Pds string + Did string + Pds string } -func (a *OAuth) GetUser(r *http.Request) *User { - clientSession, err := a.store.Get(r, SessionName) +func (o *OAuth) GetUser(r *http.Request) *User { + sess, err := o.SessStore.Get(r, SessionName) - if err != nil || clientSession.IsNew { + if err != nil || sess.IsNew { return nil } return &User{ - Handle: clientSession.Values[SessionHandle].(string), - Did: clientSession.Values[SessionDid].(string), - Pds: clientSession.Values[SessionPds].(string), + Did: sess.Values[SessionDid].(string), + Pds: sess.Values[SessionPds].(string), } } -func (a *OAuth) GetDid(r *http.Request) string { - clientSession, err := a.store.Get(r, SessionName) - - if err != nil || clientSession.IsNew { - return "" +func (o *OAuth) GetDid(r *http.Request) string { + if u := o.GetUser(r); u != nil { + return u.Did } - return clientSession.Values[SessionDid].(string) + return "" } -func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { - session, auth, err := o.GetSession(r) +func (o *OAuth) AuthorizedClient(r *http.Request) (*atpclient.APIClient, error) { + session, err := o.ResumeSession(r) if err != nil { return nil, fmt.Errorf("error getting session: %w", err) } - if !auth { - return nil, fmt.Errorf("not authorized") - } - - client := &oauth.XrpcClient{ - OnDpopPdsNonceChanged: func(did, newNonce string) { - err := o.sess.UpdateNonce(r.Context(), did, newNonce) - if err != nil { - log.Printf("error updating dpop pds nonce: %v", err) - } - }, - } - - privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) - if err != nil { - return nil, fmt.Errorf("error parsing private jwk: %w", err) - } - - xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{ - Did: session.Did, - PdsUrl: session.PdsUrl, - DpopPdsNonce: session.PdsUrl, - AccessToken: session.AccessJwt, - Issuer: session.AuthServerIss, - DpopPrivateJwk: privateJwk, - }) - - return xrpcClient, nil + return session.APIClient(), nil } -// use this to create a client to communicate with knots or spindles -// // this is a higher level abstraction on ServerGetServiceAuth type ServiceClientOpts struct { service string @@ -259,13 +218,13 @@ func (s *ServiceClientOpts) Host() string { return scheme + s.service } -func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { +func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { opts := ServiceClientOpts{} for _, o := range os { o(&opts) } - authorizedClient, err := o.AuthorizedClient(r) + client, err := o.AuthorizedClient(r) if err != nil { return nil, err } @@ -276,13 +235,13 @@ func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_ opts.exp = sixty } - resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) + resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) if err != nil { return nil, err } - return &indigo_xrpc.Client{ - Auth: &indigo_xrpc.AuthInfo{ + return &xrpc.Client{ + Auth: &xrpc.AuthInfo{ AccessJwt: resp.Token, }, Host: opts.Host(), @@ -291,57 +250,3 @@ func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_ }, }, nil } - -type ClientMetadata struct { - ClientID string `json:"client_id"` - ClientName string `json:"client_name"` - SubjectType string `json:"subject_type"` - ClientURI string `json:"client_uri"` - RedirectURIs []string `json:"redirect_uris"` - GrantTypes []string `json:"grant_types"` - ResponseTypes []string `json:"response_types"` - ApplicationType string `json:"application_type"` - DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` - JwksURI string `json:"jwks_uri"` - Scope string `json:"scope"` - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` - TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` -} - -func (o *OAuth) ClientMetadata() ClientMetadata { - makeRedirectURIs := func(c string) []string { - return []string{fmt.Sprintf("%s/oauth/callback", c)} - } - - clientURI := o.config.Core.AppviewHost - clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI) - redirectURIs := makeRedirectURIs(clientURI) - - if o.config.Core.Dev { - clientURI = "http://127.0.0.1:3000" - redirectURIs = makeRedirectURIs(clientURI) - - query := url.Values{} - query.Add("redirect_uri", redirectURIs[0]) - query.Add("scope", "atproto transition:generic") - clientID = fmt.Sprintf("http://localhost?%s", query.Encode()) - } - - jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI) - - return ClientMetadata{ - ClientID: clientID, - ClientName: "Tangled", - SubjectType: "public", - ClientURI: clientURI, - RedirectURIs: redirectURIs, - GrantTypes: []string{"authorization_code", "refresh_token"}, - ResponseTypes: []string{"code"}, - ApplicationType: "web", - DpopBoundAccessTokens: true, - JwksURI: jwksURI, - Scope: "atproto transition:generic", - TokenEndpointAuthMethod: "private_key_jwt", - TokenEndpointAuthSigningAlg: "ES256", - } -} diff --git a/appview/oauth/store.go b/appview/oauth/store.go new file mode 100644 index 00000000..7d7dcc7c --- /dev/null +++ b/appview/oauth/store.go @@ -0,0 +1,147 @@ +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/redis/go-redis/v9" +) + +// redis-backed implementation of ClientAuthStore. +type RedisStore struct { + client *redis.Client + SessionTTL time.Duration + AuthRequestTTL time.Duration +} + +var _ oauth.ClientAuthStore = &RedisStore{} + +func NewRedisStore(redisURL string) (*RedisStore, error) { + opts, err := redis.ParseURL(redisURL) + if err != nil { + return nil, fmt.Errorf("failed to parse redis URL: %w", err) + } + + client := redis.NewClient(opts) + + // test the connection + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := client.Ping(ctx).Err(); err != nil { + return nil, fmt.Errorf("failed to connect to redis: %w", err) + } + + return &RedisStore{ + client: client, + SessionTTL: 30 * 24 * time.Hour, // 30 days + AuthRequestTTL: 10 * time.Minute, // 10 minutes + }, nil +} + +func (r *RedisStore) Close() error { + return r.client.Close() +} + +func sessionKey(did syntax.DID, sessionID string) string { + return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) +} + +func authRequestKey(state string) string { + return fmt.Sprintf("oauth:auth_request:%s", state) +} + +func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { + key := sessionKey(did, sessionID) + data, err := r.client.Get(ctx, key).Bytes() + if err == redis.Nil { + return nil, fmt.Errorf("session not found: %s", did) + } + if err != nil { + return nil, fmt.Errorf("failed to get session: %w", err) + } + + var sess oauth.ClientSessionData + if err := json.Unmarshal(data, &sess); err != nil { + return nil, fmt.Errorf("failed to unmarshal session: %w", err) + } + + return &sess, nil +} + +func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { + key := sessionKey(sess.AccountDID, sess.SessionID) + + data, err := json.Marshal(sess) + if err != nil { + return fmt.Errorf("failed to marshal session: %w", err) + } + + if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { + return fmt.Errorf("failed to save session: %w", err) + } + + return nil +} + +func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { + key := sessionKey(did, sessionID) + if err := r.client.Del(ctx, key).Err(); err != nil { + return fmt.Errorf("failed to delete session: %w", err) + } + return nil +} + +func (r *RedisStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { + key := authRequestKey(state) + data, err := r.client.Get(ctx, key).Bytes() + if err == redis.Nil { + return nil, fmt.Errorf("request info not found: %s", state) + } + if err != nil { + return nil, fmt.Errorf("failed to get auth request: %w", err) + } + + var req oauth.AuthRequestData + if err := json.Unmarshal(data, &req); err != nil { + return nil, fmt.Errorf("failed to unmarshal auth request: %w", err) + } + + return &req, nil +} + +func (r *RedisStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { + key := authRequestKey(info.State) + + // check if already exists (to match MemStore behavior) + exists, err := r.client.Exists(ctx, key).Result() + if err != nil { + return fmt.Errorf("failed to check auth request existence: %w", err) + } + if exists > 0 { + return fmt.Errorf("auth request already saved for state %s", info.State) + } + + data, err := json.Marshal(info) + if err != nil { + return fmt.Errorf("failed to marshal auth request: %w", err) + } + + if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { + return fmt.Errorf("failed to save auth request: %w", err) + } + + return nil +} + +func (r *RedisStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { + key := authRequestKey(state) + if err := r.client.Del(ctx, key).Err(); err != nil { + return fmt.Errorf("failed to delete auth request: %w", err) + } + return nil +} diff --git a/appview/pages/templates/layouts/fragments/topbar.html b/appview/pages/templates/layouts/fragments/topbar.html index 55cb205b..74d5815f 100644 --- a/appview/pages/templates/layouts/fragments/topbar.html +++ b/appview/pages/templates/layouts/fragments/topbar.html @@ -51,7 +51,7 @@ - {{ $user := didOrHandle .Did .Handle }} + {{ $user := .Did }}
- {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} + {{ resolve .LoggedInUser.Did }}
Handle - {{ if .LoggedInUser.Handle }} - @{{ .LoggedInUser.Handle }} + {{ resolve .LoggedInUser.Did }} - {{ end }}
diff --git a/appview/pipelines/pipelines.go b/appview/pipelines/pipelines.go index 61f2607c..619087c4 100644 --- a/appview/pipelines/pipelines.go +++ b/appview/pipelines/pipelines.go @@ -48,7 +48,8 @@ func New( ) *Pipelines { logger := log.New("pipelines") - return &Pipelines{oauth: oauth, + return &Pipelines{ + oauth: oauth, repoResolver: repoResolver, pages: pages, idResolver: idResolver, diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index 0f5e70f9..1194b2da 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -665,7 +665,7 @@ func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { s.pages.Notice(w, "pull-comment", "Failed to create comment.") return } - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoPullCommentNSID, Repo: user.Did, Rkey: tid.TID(), @@ -1142,7 +1142,7 @@ func (s *Pulls) createPullRequest( return } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoPullNSID, Repo: user.Did, Rkey: rkey, @@ -1239,7 +1239,7 @@ func (s *Pulls) createStackedPullRequest( } writes = append(writes, &write) } - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ Repo: user.Did, Writes: writes, }) @@ -1770,7 +1770,7 @@ func (s *Pulls) resubmitPullHelper( return } - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) if err != nil { // failed to get record s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") @@ -1793,7 +1793,7 @@ func (s *Pulls) resubmitPullHelper( } } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoPullNSID, Repo: user.Did, Rkey: pull.Rkey, @@ -2065,7 +2065,7 @@ func (s *Pulls) resubmitStackedPullHelper( return } - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ Repo: user.Did, Writes: writes, }) diff --git a/appview/repo/artifact.go b/appview/repo/artifact.go index ec222028..469d63e1 100644 --- a/appview/repo/artifact.go +++ b/appview/repo/artifact.go @@ -10,13 +10,6 @@ import ( "net/url" "time" - comatproto "github.com/bluesky-social/indigo/api/atproto" - lexutil "github.com/bluesky-social/indigo/lex/util" - indigoxrpc "github.com/bluesky-social/indigo/xrpc" - "github.com/dustin/go-humanize" - "github.com/go-chi/chi/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/ipfs/go-cid" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/models" @@ -25,6 +18,14 @@ import ( "tangled.org/core/appview/xrpcclient" "tangled.org/core/tid" "tangled.org/core/types" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + lexutil "github.com/bluesky-social/indigo/lex/util" + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "github.com/dustin/go-humanize" + "github.com/go-chi/chi/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/ipfs/go-cid" ) // TODO: proper statuses here on early exit @@ -60,7 +61,7 @@ func (rp *Repo) AttachArtifact(w http.ResponseWriter, r *http.Request) { return } - uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) + uploadBlobResp, err := comatproto.RepoUploadBlob(r.Context(), client, file) if err != nil { log.Println("failed to upload blob", err) rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") @@ -72,7 +73,7 @@ func (rp *Repo) AttachArtifact(w http.ResponseWriter, r *http.Request) { rkey := tid.TID() createdAt := time.Now() - putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + putRecordResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoArtifactNSID, Repo: user.Did, Rkey: rkey, @@ -249,7 +250,7 @@ func (rp *Repo) DeleteArtifact(w http.ResponseWriter, r *http.Request) { return } - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.RepoArtifactNSID, Repo: user.Did, Rkey: artifact.Rkey, diff --git a/appview/repo/repo.go b/appview/repo/repo.go index a47b2e42..f6b467e7 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -17,9 +17,6 @@ import ( "strings" "time" - comatproto "github.com/bluesky-social/indigo/api/atproto" - lexutil "github.com/bluesky-social/indigo/lex/util" - indigoxrpc "github.com/bluesky-social/indigo/xrpc" "tangled.org/core/api/tangled" "tangled.org/core/appview/commitverify" "tangled.org/core/appview/config" @@ -40,11 +37,14 @@ import ( "tangled.org/core/types" "tangled.org/core/xrpc/serviceauth" + comatproto "github.com/bluesky-social/indigo/api/atproto" + atpclient "github.com/bluesky-social/indigo/atproto/client" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" + indigoxrpc "github.com/bluesky-social/indigo/xrpc" securejoin "github.com/cyphar/filepath-securejoin" "github.com/go-chi/chi/v5" "github.com/go-git/go-git/v5/plumbing" - - "github.com/bluesky-social/indigo/atproto/syntax" ) type Repo struct { @@ -307,13 +307,13 @@ func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field // // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) if err != nil { // failed to get record rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") return } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoNSID, Repo: newRepo.Did, Rkey: newRepo.Rkey, @@ -863,7 +863,6 @@ func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) l := rp.logger.With("handler", "EditSpindle") l = l.With("did", user.Did) - l = l.With("handle", user.Handle) errorId := "operation-error" fail := func(msg string, err error) { @@ -916,12 +915,12 @@ func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { return } - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) if err != nil { fail("Failed to update spindle, no record found on PDS.", err) return } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoNSID, Repo: newRepo.Did, Rkey: newRepo.Rkey, @@ -951,7 +950,6 @@ func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) l := rp.logger.With("handler", "AddLabel") l = l.With("did", user.Did) - l = l.With("handle", user.Handle) f, err := rp.repoResolver.Resolve(r) if err != nil { @@ -1020,7 +1018,7 @@ func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) { // emit a labelRecord labelRecord := label.AsRecord() - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.LabelDefinitionNSID, Repo: label.Did, Rkey: label.Rkey, @@ -1043,12 +1041,12 @@ func (rp *Repo) AddLabelDef(w http.ResponseWriter, r *http.Request) { newRepo.Labels = append(newRepo.Labels, aturi) repoRecord := newRepo.AsRecord() - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) if err != nil { fail("Failed to update labels, no record found on PDS.", err) return } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoNSID, Repo: newRepo.Did, Rkey: newRepo.Rkey, @@ -1111,7 +1109,6 @@ func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) l := rp.logger.With("handler", "DeleteLabel") l = l.With("did", user.Did) - l = l.With("handle", user.Handle) f, err := rp.repoResolver.Resolve(r) if err != nil { @@ -1141,7 +1138,7 @@ func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) { } // delete label record from PDS - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.LabelDefinitionNSID, Repo: label.Did, Rkey: label.Rkey, @@ -1163,12 +1160,12 @@ func (rp *Repo) DeleteLabelDef(w http.ResponseWriter, r *http.Request) { newRepo.Labels = updated repoRecord := newRepo.AsRecord() - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, newRepo.Did, newRepo.Rkey) if err != nil { fail("Failed to update labels, no record found on PDS.", err) return } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoNSID, Repo: newRepo.Did, Rkey: newRepo.Rkey, @@ -1220,7 +1217,6 @@ func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) l := rp.logger.With("handler", "SubscribeLabel") l = l.With("did", user.Did) - l = l.With("handle", user.Handle) f, err := rp.repoResolver.Resolve(r) if err != nil { @@ -1261,12 +1257,12 @@ func (rp *Repo) SubscribeLabel(w http.ResponseWriter, r *http.Request) { return } - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) if err != nil { fail("Failed to update labels, no record found on PDS.", err) return } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoNSID, Repo: newRepo.Did, Rkey: newRepo.Rkey, @@ -1307,7 +1303,6 @@ func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) l := rp.logger.With("handler", "UnsubscribeLabel") l = l.With("did", user.Did) - l = l.With("handle", user.Handle) f, err := rp.repoResolver.Resolve(r) if err != nil { @@ -1350,12 +1345,12 @@ func (rp *Repo) UnsubscribeLabel(w http.ResponseWriter, r *http.Request) { return } - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, f.Repo.Did, f.Repo.Rkey) if err != nil { fail("Failed to update labels, no record found on PDS.", err) return } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoNSID, Repo: newRepo.Did, Rkey: newRepo.Rkey, @@ -1479,7 +1474,6 @@ func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) l := rp.logger.With("handler", "AddCollaborator") l = l.With("did", user.Did) - l = l.With("handle", user.Handle) f, err := rp.repoResolver.Resolve(r) if err != nil { @@ -1526,7 +1520,7 @@ func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { currentUser := rp.oauth.GetUser(r) rkey := tid.TID() createdAt := time.Now() - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoCollaboratorNSID, Repo: currentUser.Did, Rkey: rkey, @@ -1617,12 +1611,12 @@ func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { } // remove record from pds - xrpcClient, err := rp.oauth.AuthorizedClient(r) + atpClient, err := rp.oauth.AuthorizedClient(r) if err != nil { log.Println("failed to get authorized client", err) return } - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + _, err = comatproto.RepoDeleteRecord(r.Context(), atpClient, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.RepoNSID, Repo: user.Did, Rkey: f.Rkey, @@ -1764,7 +1758,6 @@ func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetUser(r) l := rp.logger.With("handler", "Secrets") - l = l.With("handle", user.Handle) l = l.With("did", user.Did) f, err := rp.repoResolver.Resolve(r) @@ -2179,14 +2172,14 @@ func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { } record := repo.AsRecord() - xrpcClient, err := rp.oauth.AuthorizedClient(r) + atpClient, err := rp.oauth.AuthorizedClient(r) if err != nil { l.Error("failed to create xrpcclient", "err", err) rp.pages.Notice(w, "repo", "Failed to fork repository.") return } - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoNSID, Repo: user.Did, Rkey: rkey, @@ -2218,7 +2211,7 @@ func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { rollback := func() { err1 := tx.Rollback() err2 := rp.enforcer.E.LoadPolicy() - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) + err3 := rollbackRecord(context.Background(), aturi, atpClient) // ignore txn complete errors, this is okay if errors.Is(err1, sql.ErrTxDone) { @@ -2291,14 +2284,14 @@ func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { aturi = "" rp.notifier.NewRepo(r.Context(), repo) - rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, forkName)) } } // this is used to rollback changes made to the PDS // // it is a no-op if the provided ATURI is empty -func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { +func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { if aturi == "" { return nil } @@ -2309,7 +2302,7 @@ func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) repo := parsed.Authority().String() rkey := parsed.RecordKey().String() - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ Collection: collection, Repo: repo, Rkey: rkey, diff --git a/appview/settings/settings.go b/appview/settings/settings.go index 00b6bda4..4dca2414 100644 --- a/appview/settings/settings.go +++ b/appview/settings/settings.go @@ -470,7 +470,7 @@ func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { } // store in pds too - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.PublicKeyNSID, Repo: did, Rkey: rkey, @@ -527,7 +527,7 @@ func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { if rkey != "" { // remove from pds too - _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.PublicKeyNSID, Repo: did, Rkey: rkey, diff --git a/appview/signup/signup.go b/appview/signup/signup.go index c96b6b9a..6c79cd26 100644 --- a/appview/signup/signup.go +++ b/appview/signup/signup.go @@ -20,7 +20,6 @@ import ( "tangled.org/core/appview/models" "tangled.org/core/appview/pages" "tangled.org/core/appview/state/userutil" - "tangled.org/core/appview/xrpcclient" "tangled.org/core/idresolver" ) @@ -29,7 +28,6 @@ type Signup struct { db *db.DB cf *dns.Cloudflare posthog posthog.Client - xrpc *xrpcclient.Client idResolver *idresolver.Resolver pages *pages.Pages l *slog.Logger diff --git a/appview/spindles/spindles.go b/appview/spindles/spindles.go index 0b035882..7c97e967 100644 --- a/appview/spindles/spindles.go +++ b/appview/spindles/spindles.go @@ -189,14 +189,14 @@ func (s *Spindles) register(w http.ResponseWriter, r *http.Request) { return } - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.SpindleNSID, user.Did, instance) + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.SpindleNSID, user.Did, instance) var exCid *string if ex != nil { exCid = ex.Cid } // re-announce by registering under same rkey - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.SpindleNSID, Repo: user.Did, Rkey: instance, @@ -332,7 +332,7 @@ func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { return } - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.SpindleNSID, Repo: user.Did, Rkey: instance, @@ -542,7 +542,7 @@ func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) { return } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.SpindleMemberNSID, Repo: user.Did, Rkey: rkey, @@ -683,7 +683,7 @@ func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { } // remove from pds - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.SpindleMemberNSID, Repo: user.Did, Rkey: members[0].Rkey, diff --git a/appview/state/follow.go b/appview/state/follow.go index 96a2f42e..bd4b847f 100644 --- a/appview/state/follow.go +++ b/appview/state/follow.go @@ -43,7 +43,7 @@ func (s *State) Follow(w http.ResponseWriter, r *http.Request) { case http.MethodPost: createdAt := time.Now().Format(time.RFC3339) rkey := tid.TID() - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.GraphFollowNSID, Repo: currentUser.Did, Rkey: rkey, @@ -88,7 +88,7 @@ func (s *State) Follow(w http.ResponseWriter, r *http.Request) { return } - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.GraphFollowNSID, Repo: currentUser.Did, Rkey: follow.Rkey, diff --git a/appview/state/login.go b/appview/state/login.go new file mode 100644 index 00000000..2dd8191c --- /dev/null +++ b/appview/state/login.go @@ -0,0 +1,63 @@ +package state + +import ( + "fmt" + "log" + "net/http" + "strings" + + "tangled.org/core/appview/pages" +) + +func (s *State) Login(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + returnURL := r.URL.Query().Get("return_url") + s.pages.Login(w, pages.LoginParams{ + ReturnUrl: returnURL, + }) + case http.MethodPost: + handle := r.FormValue("handle") + + // when users copy their handle from bsky.app, it tends to have these characters around it: + // + // @nelind.dk: + // \u202a ensures that the handle is always rendered left to right and + // \u202c reverts that so the rest of the page renders however it should + handle = strings.TrimPrefix(handle, "\u202a") + handle = strings.TrimSuffix(handle, "\u202c") + + // `@` is harmless + handle = strings.TrimPrefix(handle, "@") + + // basic handle validation + if !strings.Contains(handle, ".") { + log.Println("invalid handle format", "raw", handle) + s.pages.Notice( + w, + "login-msg", + fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), + ) + return + } + + redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.pages.HxRedirect(w, redirectURL) + } +} + +func (s *State) Logout(w http.ResponseWriter, r *http.Request) { + err := s.oauth.DeleteSession(w, r) + if err != nil { + log.Println("failed to logout", "err", err) + } else { + log.Println("logged out successfully") + } + + s.pages.HxRedirect(w, "/login") +} diff --git a/appview/state/profile.go b/appview/state/profile.go index aa252f98..8d9e82fd 100644 --- a/appview/state/profile.go +++ b/appview/state/profile.go @@ -634,13 +634,13 @@ func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r vanityStats = append(vanityStats, string(v.Kind)) } - ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") var cid *string if ex != nil { cid = ex.Cid } - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.ActorProfileNSID, Repo: user.Did, Rkey: "self", diff --git a/appview/state/reaction.go b/appview/state/reaction.go index 7443b8f4..3797e504 100644 --- a/appview/state/reaction.go +++ b/appview/state/reaction.go @@ -7,8 +7,8 @@ import ( comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/syntax" - lexutil "github.com/bluesky-social/indigo/lex/util" + "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/models" @@ -47,7 +47,7 @@ func (s *State) React(w http.ResponseWriter, r *http.Request) { case http.MethodPost: createdAt := time.Now().Format(time.RFC3339) rkey := tid.TID() - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.FeedReactionNSID, Repo: currentUser.Did, Rkey: rkey, @@ -92,7 +92,7 @@ func (s *State) React(w http.ResponseWriter, r *http.Request) { return } - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.FeedReactionNSID, Repo: currentUser.Did, Rkey: reaction.Rkey, diff --git a/appview/state/router.go b/appview/state/router.go index bb2dac81..83171aad 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -5,13 +5,11 @@ import ( "strings" "github.com/go-chi/chi/v5" - "github.com/gorilla/sessions" "tangled.org/core/appview/issues" "tangled.org/core/appview/knots" "tangled.org/core/appview/labels" "tangled.org/core/appview/middleware" "tangled.org/core/appview/notifications" - oauthhandler "tangled.org/core/appview/oauth/handler" "tangled.org/core/appview/pipelines" "tangled.org/core/appview/pulls" "tangled.org/core/appview/repo" @@ -34,7 +32,6 @@ func (s *State) Router() http.Handler { s.pages, ) - router.Use(middleware.TryRefreshSession()) router.Get("/favicon.svg", s.Favicon) router.Get("/favicon.ico", s.Favicon) router.Get("/pwa-manifest.json", s.PWAManifest) @@ -123,6 +120,10 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { // special-case handler for serving tangled.org/core r.Get("/core", s.Core()) + r.Get("/login", s.Login) + r.Post("/login", s.Login) + r.Post("/logout", s.Logout) + r.Route("/repo", func(r chi.Router) { r.Route("/new", func(r chi.Router) { r.Use(middleware.AuthMiddleware(s.oauth)) @@ -164,7 +165,7 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r.Mount("/notifications", s.NotificationsRouter(mw)) r.Mount("/signup", s.SignupRouter()) - r.Mount("/", s.OAuthRouter()) + r.Mount("/", s.oauth.Router()) r.Get("/keys/{user}", s.Keys) r.Get("/terms", s.TermsOfService) @@ -191,12 +192,6 @@ func (s *State) Core() http.HandlerFunc { } } -func (s *State) OAuthRouter() http.Handler { - store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) - oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, s.sess, store, s.oauth, s.enforcer, s.posthog) - return oauth.Router() -} - func (s *State) SettingsRouter() http.Handler { settings := &settings.Settings{ Db: s.db, diff --git a/appview/state/star.go b/appview/state/star.go index 9682da54..3473f65d 100644 --- a/appview/state/star.go +++ b/appview/state/star.go @@ -40,7 +40,7 @@ func (s *State) Star(w http.ResponseWriter, r *http.Request) { case http.MethodPost: createdAt := time.Now().Format(time.RFC3339) rkey := tid.TID() - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ Collection: tangled.FeedStarNSID, Repo: currentUser.Did, Rkey: rkey, @@ -92,7 +92,7 @@ func (s *State) Star(w http.ResponseWriter, r *http.Request) { return } - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.FeedStarNSID, Repo: currentUser.Did, Rkey: star.Rkey, diff --git a/appview/state/state.go b/appview/state/state.go index 0a23b9ca..fa5624aa 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -11,12 +11,6 @@ import ( "strings" "time" - comatproto "github.com/bluesky-social/indigo/api/atproto" - "github.com/bluesky-social/indigo/atproto/syntax" - lexutil "github.com/bluesky-social/indigo/lex/util" - securejoin "github.com/cyphar/filepath-securejoin" - "github.com/go-chi/chi/v5" - "github.com/posthog/posthog-go" "tangled.org/core/api/tangled" "tangled.org/core/appview" "tangled.org/core/appview/cache" @@ -38,6 +32,14 @@ import ( tlog "tangled.org/core/log" "tangled.org/core/rbac" "tangled.org/core/tid" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + atpclient "github.com/bluesky-social/indigo/atproto/client" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/go-chi/chi/v5" + "github.com/posthog/posthog-go" ) type State struct { @@ -75,10 +77,13 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { res = idresolver.DefaultResolver() } - pgs := pages.NewPages(config, res) + pages := pages.NewPages(config, res) cache := cache.New(config.Redis.Addr) sess := session.New(cache) - oauth := oauth.NewOAuth(config, sess) + oauth2, err := oauth.New(config) + if err != nil { + return nil, fmt.Errorf("failed to start oauth handler: %w", err) + } validator := validator.New(d, res, enforcer) posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) @@ -162,9 +167,9 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { state := &State{ d, notifier, - oauth, + oauth2, enforcer, - pgs, + pages, sess, res, posthog, @@ -275,12 +280,12 @@ func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { // non-fatal } - fmt.Println(s.pages.Timeline(w, pages.TimelineParams{ + s.pages.Timeline(w, pages.TimelineParams{ LoggedInUser: user, Timeline: timeline, Repos: repos, GfiLabel: gfiLabel, - })) + }) } func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { @@ -291,7 +296,6 @@ func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { l := s.logger.With("handler", "UpgradeBanner") l = l.With("did", user.Did) - l = l.With("handle", user.Handle) regs, err := db.GetRegistrations( s.db, @@ -431,7 +435,6 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) l = l.With("did", user.Did) - l = l.With("handle", user.Handle) // form validation domain := r.FormValue("domain") @@ -495,14 +498,14 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { } record := repo.AsRecord() - xrpcClient, err := s.oauth.AuthorizedClient(r) + atpClient, err := s.oauth.AuthorizedClient(r) if err != nil { l.Info("PDS write failed", "err", err) s.pages.Notice(w, "repo", "Failed to write record to PDS.") return } - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ + atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoNSID, Repo: user.Did, Rkey: rkey, @@ -534,7 +537,7 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { rollback := func() { err1 := tx.Rollback() err2 := s.enforcer.E.LoadPolicy() - err3 := rollbackRecord(context.Background(), aturi, xrpcClient) + err3 := rollbackRecord(context.Background(), aturi, atpClient) // ignore txn complete errors, this is okay if errors.Is(err1, sql.ErrTxDone) { @@ -607,14 +610,14 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { aturi = "" s.notifier.NewRepo(r.Context(), repo) - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Did, repoName)) } } // this is used to rollback changes made to the PDS // // it is a no-op if the provided ATURI is empty -func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { +func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error { if aturi == "" { return nil } @@ -625,7 +628,7 @@ func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) repo := parsed.Authority().String() rkey := parsed.RecordKey().String() - _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ + _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ Collection: collection, Repo: repo, Rkey: rkey, diff --git a/appview/strings/strings.go b/appview/strings/strings.go index e23766b0..8a291924 100644 --- a/appview/strings/strings.go +++ b/appview/strings/strings.go @@ -22,8 +22,10 @@ import ( "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" - lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/go-chi/chi/v5" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + lexutil "github.com/bluesky-social/indigo/lex/util" ) type Strings struct { @@ -254,12 +256,12 @@ func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { } // first replace the existing record in the PDS - ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.StringNSID, entry.Did.String(), entry.Rkey) if err != nil { fail("Failed to updated existing record.", err) return } - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ Collection: tangled.StringNSID, Repo: entry.Did.String(), Rkey: entry.Rkey, @@ -284,7 +286,7 @@ func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { s.Notifier.EditString(r.Context(), &entry) // if that went okay, redir to the string - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+entry.Rkey) } } @@ -336,7 +338,7 @@ func (s *Strings) create(w http.ResponseWriter, r *http.Request) { return } - resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ + resp, err := comatproto.RepoPutRecord(r.Context(), client, &atproto.RepoPutRecord_Input{ Collection: tangled.StringNSID, Repo: user.Did, Rkey: string.Rkey, @@ -360,7 +362,7 @@ func (s *Strings) create(w http.ResponseWriter, r *http.Request) { s.Notifier.NewString(r.Context(), &string) // successful - s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) + s.Pages.HxRedirect(w, "/strings/"+user.Did+"/"+string.Rkey) } } @@ -403,7 +405,7 @@ func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { s.Notifier.DeleteString(r.Context(), user.Did, rkey) - s.Pages.HxRedirect(w, "/strings/"+user.Handle) + s.Pages.HxRedirect(w, "/strings/"+user.Did) } func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { diff --git a/appview/xrpcclient/xrpc.go b/appview/xrpcclient/xrpc.go index 36404e49..4e0ca538 100644 --- a/appview/xrpcclient/xrpc.go +++ b/appview/xrpcclient/xrpc.go @@ -1,16 +1,10 @@ package xrpcclient import ( - "bytes" - "context" "errors" - "io" "net/http" - "github.com/bluesky-social/indigo/api/atproto" - "github.com/bluesky-social/indigo/xrpc" indigoxrpc "github.com/bluesky-social/indigo/xrpc" - oauth "tangled.org/anirudh.fi/atproto-oauth" ) var ( @@ -20,99 +14,6 @@ var ( ErrXrpcInvalid = errors.New("invalid xrpc request") ) -type Client struct { - *oauth.XrpcClient - authArgs *oauth.XrpcAuthedRequestArgs -} - -func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client { - return &Client{ - XrpcClient: client, - authArgs: authArgs, - } -} - -func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) { - var out atproto.RepoPutRecord_Output - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { - return nil, err - } - - return &out, nil -} - -func (c *Client) RepoApplyWrites(ctx context.Context, input *atproto.RepoApplyWrites_Input) (*atproto.RepoApplyWrites_Output, error) { - var out atproto.RepoApplyWrites_Output - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.applyWrites", nil, input, &out); err != nil { - return nil, err - } - - return &out, nil -} - -func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { - var out atproto.RepoGetRecord_Output - - params := map[string]interface{}{ - "cid": cid, - "collection": collection, - "repo": repo, - "rkey": rkey, - } - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { - return nil, err - } - - return &out, nil -} - -func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { - var out atproto.RepoUploadBlob_Output - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { - return nil, err - } - - return &out, nil -} - -func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { - buf := new(bytes.Buffer) - - params := map[string]interface{}{ - "cid": cid, - "did": did, - } - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { - return nil, err - } - - return buf.Bytes(), nil -} - -func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { - var out atproto.RepoDeleteRecord_Output - if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { - return nil, err - } - - return &out, nil -} - -func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { - var out atproto.ServerGetServiceAuth_Output - - params := map[string]interface{}{ - "aud": aud, - "exp": exp, - "lxm": lxm, - } - if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { - return nil, err - } - - return &out, nil -} - // produces a more manageable error func HandleXrpcErr(err error) error { if err == nil { diff --git a/go.mod b/go.mod index a5e519bc..a45717f9 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/alecthomas/chroma/v2 v2.15.0 github.com/avast/retry-go/v4 v4.6.1 github.com/bluekeyes/go-gitdiff v0.8.1 - github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb + github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 github.com/carlmjohnson/versioninfo v0.22.5 github.com/casbin/casbin/v2 v2.103.0 diff --git a/go.sum b/go.sum index 79435e00..95a0eb7a 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= +github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU= +github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -- 2.43.0 From 4a1653c5e8339e25b511e7a4224d46743e110c3f Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Mon, 6 Oct 2025 17:38:00 +0100 Subject: [PATCH] appview: improve logged-out CTAs Change-Id: wmwlvmmzttolstkxoovtrsrlzxvypkox Signed-off-by: oppiliappan --- appview/pages/templates/repo/fragments/labelPanel.html | 2 +- .../pages/templates/repo/fragments/participants.html | 2 +- .../templates/repo/issues/fragments/newComment.html | 9 +++++++-- appview/pages/templates/repo/pulls/pull.html | 10 +++++++--- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/appview/pages/templates/repo/fragments/labelPanel.html b/appview/pages/templates/repo/fragments/labelPanel.html index e735d186..be10f309 100644 --- a/appview/pages/templates/repo/fragments/labelPanel.html +++ b/appview/pages/templates/repo/fragments/labelPanel.html @@ -1,5 +1,5 @@ {{ define "repo/fragments/labelPanel" }} -
+
{{ template "basicLabels" . }} {{ template "kvLabels" . }}
diff --git a/appview/pages/templates/repo/fragments/participants.html b/appview/pages/templates/repo/fragments/participants.html index 7539cca9..eeb50994 100644 --- a/appview/pages/templates/repo/fragments/participants.html +++ b/appview/pages/templates/repo/fragments/participants.html @@ -1,7 +1,7 @@ {{ define "repo/fragments/participants" }} {{ $all := . }} {{ $ps := take $all 5 }} -
+
Participants {{ len $all }} diff --git a/appview/pages/templates/repo/issues/fragments/newComment.html b/appview/pages/templates/repo/issues/fragments/newComment.html index a704749c..f2a50311 100644 --- a/appview/pages/templates/repo/issues/fragments/newComment.html +++ b/appview/pages/templates/repo/issues/fragments/newComment.html @@ -138,8 +138,13 @@
{{ else }} -
- login to join the discussion +
+ + sign up + + or + login + to add to the discussion
{{ end }} {{ end }} diff --git a/appview/pages/templates/repo/pulls/pull.html b/appview/pages/templates/repo/pulls/pull.html index 7873be1d..ac207223 100644 --- a/appview/pages/templates/repo/pulls/pull.html +++ b/appview/pages/templates/repo/pulls/pull.html @@ -189,9 +189,13 @@ {{ if $.LoggedInUser }} {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck "Stack" $.Stack) }} {{ else }} -
-
- login to join the discussion +
+ + sign up + + or + login + to add to the discussion
{{ end }}
-- 2.43.0 From 56721612fcd1562f134b0862fc34d0ac9382eb9e Mon Sep 17 00:00:00 2001 From: Cameron Smith Date: Mon, 6 Oct 2025 02:36:45 -0400 Subject: [PATCH] appview/{db,pages,models}: show tooltips for user handles when hovering on reactions Signed-off-by: Cameron Smith --- appview/db/reaction.go | 41 +++++++++++++++---- appview/issues/issues.go | 4 +- appview/models/reaction.go | 5 +++ appview/pages/pages.go | 5 ++- .../templates/repo/fragments/reaction.html | 7 +++- .../pages/templates/repo/issues/issue.html | 6 ++- .../repo/pulls/fragments/pullHeader.html | 6 ++- appview/pulls/pulls.go | 4 +- appview/state/reaction.go | 14 ++++--- 9 files changed, 68 insertions(+), 24 deletions(-) diff --git a/appview/db/reaction.go b/appview/db/reaction.go index 50831528..fda13e5c 100644 --- a/appview/db/reaction.go +++ b/appview/db/reaction.go @@ -62,16 +62,43 @@ func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) return count, nil } -func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[models.ReactionKind]int, error) { - countMap := map[models.ReactionKind]int{} +func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { + query := ` + select kind, reacted_by_did, + row_number() over (partition by kind order by created asc) as rn, + count(*) over (partition by kind) as total + from reactions + where thread_at = ? + order by kind, created asc` + + rows, err := e.Query(query, threadAt) + if err != nil { + return nil, err + } + defer rows.Close() + + reactionMap := map[models.ReactionKind]models.ReactionDisplayData{} for _, kind := range models.OrderedReactionKinds { - count, err := GetReactionCount(e, threadAt, kind) - if err != nil { - return map[models.ReactionKind]int{}, nil + reactionMap[kind] = models.ReactionDisplayData{Count: 0, Users: []string{}} + } + + for rows.Next() { + var kind models.ReactionKind + var did string + var rn, total int + if err := rows.Scan(&kind, &did, &rn, &total); err != nil { + return nil, err } - countMap[kind] = count + + data := reactionMap[kind] + data.Count = total + if userLimit > 0 && rn <= userLimit { + data.Users = append(data.Users, did) + } + reactionMap[kind] = data } - return countMap, nil + + return reactionMap, rows.Err() } func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool { diff --git a/appview/issues/issues.go b/appview/issues/issues.go index d4f654a3..d454d890 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -83,7 +83,7 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { return } - reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) + reactionMap, err := db.GetReactionMap(rp.db, 20, issue.AtUri()) if err != nil { l.Error("failed to get issue reactions", "err", err) } @@ -115,7 +115,7 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { Issue: issue, CommentList: issue.CommentList(), OrderedReactionKinds: models.OrderedReactionKinds, - Reactions: reactionCountMap, + Reactions: reactionMap, UserReacted: userReactions, LabelDefs: defs, }) diff --git a/appview/models/reaction.go b/appview/models/reaction.go index c1898076..3cb21acf 100644 --- a/appview/models/reaction.go +++ b/appview/models/reaction.go @@ -55,3 +55,8 @@ type Reaction struct { Rkey string Kind ReactionKind } + +type ReactionDisplayData struct { + Count int + Users []string +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index c0d4a036..f78f3856 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -985,7 +985,7 @@ type RepoSingleIssueParams struct { LabelDefs map[string]*models.LabelDefinition OrderedReactionKinds []models.ReactionKind - Reactions map[models.ReactionKind]int + Reactions map[models.ReactionKind]models.ReactionDisplayData UserReacted map[models.ReactionKind]bool } @@ -1010,6 +1010,7 @@ type ThreadReactionFragmentParams struct { ThreadAt syntax.ATURI Kind models.ReactionKind Count int + Users []string IsReacted bool } @@ -1138,7 +1139,7 @@ type RepoSinglePullParams struct { Pipelines map[string]models.Pipeline OrderedReactionKinds []models.ReactionKind - Reactions map[models.ReactionKind]int + Reactions map[models.ReactionKind]models.ReactionDisplayData UserReacted map[models.ReactionKind]bool LabelDefs map[string]*models.LabelDefinition diff --git a/appview/pages/templates/repo/fragments/reaction.html b/appview/pages/templates/repo/fragments/reaction.html index c9d032fd..5268b8de 100644 --- a/appview/pages/templates/repo/fragments/reaction.html +++ b/appview/pages/templates/repo/fragments/reaction.html @@ -2,7 +2,7 @@
diff --git a/appview/pages/templates/repo/pulls/fragments/pullHeader.html b/appview/pages/templates/repo/pulls/fragments/pullHeader.html index 74ddc9af..93c1c77b 100644 --- a/appview/pages/templates/repo/pulls/fragments/pullHeader.html +++ b/appview/pages/templates/repo/pulls/fragments/pullHeader.html @@ -66,13 +66,15 @@
{{ template "repo/fragments/reactionsPopUp" . }} {{ range $kind := . }} + {{ $reactionData := index $.Reactions $kind }} {{ template "repo/fragments/reaction" (dict "Kind" $kind - "Count" (index $.Reactions $kind) + "Count" $reactionData.Count "IsReacted" (index $.UserReacted $kind) - "ThreadAt" $.Pull.PullAt) + "ThreadAt" $.Pull.PullAt + "Users" $reactionData.Users) }} {{ end }}
diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index 1194b2da..126202a7 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -189,7 +189,7 @@ func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { m[p.Sha] = p } - reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) + reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) if err != nil { log.Println("failed to get pull reactions") s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") @@ -227,7 +227,7 @@ func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { Pipelines: m, OrderedReactionKinds: models.OrderedReactionKinds, - Reactions: reactionCountMap, + Reactions: reactionMap, UserReacted: userReactions, LabelDefs: defs, diff --git a/appview/state/reaction.go b/appview/state/reaction.go index 3797e504..d4385b11 100644 --- a/appview/state/reaction.go +++ b/appview/state/reaction.go @@ -70,9 +70,9 @@ func (s *State) React(w http.ResponseWriter, r *http.Request) { return } - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) if err != nil { - log.Println("failed to get reaction count for ", subjectUri) + log.Println("failed to get reactions for ", subjectUri) } log.Println("created atproto record: ", resp.Uri) @@ -80,7 +80,8 @@ func (s *State) React(w http.ResponseWriter, r *http.Request) { s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ ThreadAt: subjectUri, Kind: reactionKind, - Count: count, + Count: reactionMap[reactionKind].Count, + Users: reactionMap[reactionKind].Users, IsReacted: true, }) @@ -109,16 +110,17 @@ func (s *State) React(w http.ResponseWriter, r *http.Request) { // this is not an issue, the firehose event might have already done this } - count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) + reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) if err != nil { - log.Println("failed to get reaction count for ", subjectUri) + log.Println("failed to get reactions for ", subjectUri) return } s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ ThreadAt: subjectUri, Kind: reactionKind, - Count: count, + Count: reactionMap[reactionKind].Count, + Users: reactionMap[reactionKind].Users, IsReacted: false, }) -- 2.43.0 From 9808455584358d57b7ed2bd5b581bc717394fded Mon Sep 17 00:00:00 2001 From: Will Andrews Date: Wed, 1 Oct 2025 06:34:35 +0100 Subject: [PATCH] appview: allow timeline db queries to be filterable by users follows Signed-off-by: Will Andrews --- appview/db/timeline.go | 48 +++++++++++++++++++++++++++++++++--------- appview/state/state.go | 2 +- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/appview/db/timeline.go b/appview/db/timeline.go index c74b1e4e..46439c77 100644 --- a/appview/db/timeline.go +++ b/appview/db/timeline.go @@ -9,20 +9,33 @@ import ( // TODO: this gathers heterogenous events from different sources and aggregates // them in code; if we did this entirely in sql, we could order and limit and paginate easily -func MakeTimeline(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { +func MakeTimeline(e Execer, limit int, loggedInUserDid string, limitToUsersIsFollowing bool) ([]models.TimelineEvent, error) { var events []models.TimelineEvent - repos, err := getTimelineRepos(e, limit, loggedInUserDid) + var userIsFollowing []string + if limitToUsersIsFollowing { + following, err := GetFollowing(e, loggedInUserDid) + if err != nil { + return nil, err + } + + userIsFollowing = make([]string, 0, len(following)) + for _, follow := range following { + userIsFollowing = append(userIsFollowing, follow.SubjectDid) + } + } + + repos, err := getTimelineRepos(e, limit, loggedInUserDid, userIsFollowing) if err != nil { return nil, err } - stars, err := getTimelineStars(e, limit, loggedInUserDid) + stars, err := getTimelineStars(e, limit, loggedInUserDid, userIsFollowing) if err != nil { return nil, err } - follows, err := getTimelineFollows(e, limit, loggedInUserDid) + follows, err := getTimelineFollows(e, limit, loggedInUserDid, userIsFollowing) if err != nil { return nil, err } @@ -70,8 +83,13 @@ func getRepoStarInfo(repo *models.Repo, starStatuses map[string]bool) (bool, int return isStarred, starCount } -func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { - repos, err := GetRepos(e, limit) +func getTimelineRepos(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { + filters := make([]filter, 0) + if userIsFollowing != nil { + filters = append(filters, FilterIn("did", userIsFollowing)) + } + + repos, err := GetRepos(e, limit, filters...) if err != nil { return nil, err } @@ -125,8 +143,13 @@ func getTimelineRepos(e Execer, limit int, loggedInUserDid string) ([]models.Tim return events, nil } -func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { - stars, err := GetStars(e, limit) +func getTimelineStars(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { + filters := make([]filter, 0) + if userIsFollowing != nil { + filters = append(filters, FilterIn("starred_by_did", userIsFollowing)) + } + + stars, err := GetStars(e, limit, filters...) if err != nil { return nil, err } @@ -166,8 +189,13 @@ func getTimelineStars(e Execer, limit int, loggedInUserDid string) ([]models.Tim return events, nil } -func getTimelineFollows(e Execer, limit int, loggedInUserDid string) ([]models.TimelineEvent, error) { - follows, err := GetFollows(e, limit) +func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { + filters := make([]filter, 0) + if userIsFollowing != nil { + filters = append(filters, FilterIn("user_did", userIsFollowing)) + } + + follows, err := GetFollows(e, limit, filters...) if err != nil { return nil, err } diff --git a/appview/state/state.go b/appview/state/state.go index fa5624aa..cda17d81 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -262,7 +262,7 @@ func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { if user != nil { userDid = user.Did } - timeline, err := db.MakeTimeline(s.db, 50, userDid) + timeline, err := db.MakeTimeline(s.db, 50, userDid, false) if err != nil { log.Println(err) s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") -- 2.43.0 From e067fac7a8a9a3b5dc93bf00f9cb4a399a7e0cee Mon Sep 17 00:00:00 2001 From: Will Andrews Date: Sun, 5 Oct 2025 20:48:20 +0100 Subject: [PATCH] appview: allows the user to toggle between a filtered or non filtered timeline Signed-off-by: Will Andrews --- appview/pages/pages.go | 1 + .../timeline/fragments/timeline.html | 20 +++++++++++++++++-- appview/state/state.go | 19 ++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/appview/pages/pages.go b/appview/pages/pages.go index f78f3856..6eb98c45 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -307,6 +307,7 @@ type TimelineParams struct { Timeline []models.TimelineEvent Repos []models.Repo GfiLabel *models.LabelDefinition + Filtered bool } func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { diff --git a/appview/pages/templates/timeline/fragments/timeline.html b/appview/pages/templates/timeline/fragments/timeline.html index 081e112a..ea0bbd7c 100644 --- a/appview/pages/templates/timeline/fragments/timeline.html +++ b/appview/pages/templates/timeline/fragments/timeline.html @@ -1,7 +1,23 @@ {{ define "timeline/fragments/timeline" }}
-
-

Timeline

+ +
+
+

Timeline

+
+ {{ if .LoggedInUser }} +
+ {{ if .Filtered }} + + Show All + + {{ else }} + + Show following only + + {{ end }} +
+ {{ end }}
diff --git a/appview/state/state.go b/appview/state/state.go index cda17d81..f7f3b41b 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -8,6 +8,7 @@ import ( "log" "log/slog" "net/http" + "strconv" "strings" "time" @@ -256,13 +257,14 @@ func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { } func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { + filtered := getTimelineFilteredQuery(r) user := s.oauth.GetUser(r) var userDid string if user != nil { userDid = user.Did } - timeline, err := db.MakeTimeline(s.db, 50, userDid, false) + timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered) if err != nil { log.Println(err) s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") @@ -285,6 +287,7 @@ func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { Timeline: timeline, Repos: repos, GfiLabel: gfiLabel, + Filtered: filtered, }) } @@ -326,7 +329,8 @@ func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { } func (s *State) Home(w http.ResponseWriter, r *http.Request) { - timeline, err := db.MakeTimeline(s.db, 5, "") + filtered := getTimelineFilteredQuery(r) + timeline, err := db.MakeTimeline(s.db, 5, "", filtered) if err != nil { log.Println(err) s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") @@ -344,6 +348,7 @@ func (s *State) Home(w http.ResponseWriter, r *http.Request) { LoggedInUser: nil, Timeline: timeline, Repos: repos, + Filtered: filtered, }) } @@ -662,3 +667,13 @@ func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver) error { return nil } + +func getTimelineFilteredQuery(r *http.Request) bool { + filteredStr := r.URL.Query().Get("filtered") + if filteredStr == "" { + return false + } + + res, _ := strconv.ParseBool(filteredStr) + return res +} -- 2.43.0