This repository has no description
0

Configure Feed

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

core / appview / state / login.go
5.3 kB 178 lines
1package state 2 3import ( 4 "errors" 5 "fmt" 6 "net/http" 7 "strings" 8 "time" 9 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 "github.com/bluesky-social/indigo/atproto/identity" 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "github.com/bluesky-social/indigo/xrpc" 14 "tangled.org/core/appview/oauth" 15 "tangled.org/core/appview/pages" 16) 17 18func (s *State) Login(w http.ResponseWriter, r *http.Request) { 19 l := s.logger.With("handler", "Login") 20 21 switch r.Method { 22 case http.MethodGet: 23 returnURL := r.URL.Query().Get("return_url") 24 errorCode := r.URL.Query().Get("error") 25 addAccount := r.URL.Query().Get("mode") == "add_account" 26 27 user := s.oauth.GetMultiAccountUser(r) 28 if user == nil { 29 registry := s.oauth.GetAccounts(r) 30 if len(registry.Accounts) > 0 { 31 user = &oauth.MultiAccountUser{ 32 Active: nil, 33 Accounts: registry.Accounts, 34 } 35 } 36 } 37 s.pages.Login(w, pages.LoginParams{ 38 ReturnUrl: returnURL, 39 ErrorCode: errorCode, 40 AddAccount: addAccount, 41 LoggedInUser: user, 42 }) 43 case http.MethodPost: 44 handle := r.FormValue("handle") 45 returnURL := r.FormValue("return_url") 46 addAccount := r.FormValue("add_account") == "true" 47 48 // remove spaces around the handle, handles can't have spaces around them 49 handle = strings.TrimSpace(handle) 50 51 // when users copy their handle from bsky.app, it tends to have these characters around it: 52 // 53 // @nelind.dk: 54 // \u202a ensures that the handle is always rendered left to right and 55 // \u202c reverts that so the rest of the page renders however it should 56 handle = strings.TrimPrefix(handle, "\u202a") 57 handle = strings.TrimSuffix(handle, "\u202c") 58 59 // `@` is harmless 60 handle = strings.TrimPrefix(handle, "@") 61 62 // basic handle validation 63 if !strings.Contains(handle, ".") { 64 l.Error("invalid handle format", "raw", handle) 65 s.pages.Notice( 66 w, 67 "login-msg", 68 fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 69 ) 70 return 71 } 72 73 ident, err := s.idResolver.ResolveIdent(r.Context(), handle) 74 if err != nil && errors.Is(err, identity.ErrHandleMismatch) { 75 if h, parseErr := syntax.ParseHandle(handle); parseErr == nil { 76 if did, resolveErr := s.idResolver.ResolveHandle(r.Context(), h); resolveErr == nil { 77 ident, err = s.idResolver.ResolveIdent(r.Context(), did.String()) 78 } 79 } 80 } 81 if err != nil { 82 l.Warn("handle resolution failed", "handle", handle, "err", err) 83 s.pages.Notice(w, "login-msg", fmt.Sprintf("Could not resolve handle \"%s\". The account may not exist.", handle)) 84 return 85 } 86 87 pdsEndpoint := ident.PDSEndpoint() 88 if pdsEndpoint == "" { 89 s.pages.Notice(w, "login-msg", fmt.Sprintf("No PDS found for \"%s\".", handle)) 90 return 91 } 92 93 pdsClient := &xrpc.Client{Host: pdsEndpoint, Client: &http.Client{Timeout: 5 * time.Second}} 94 _, err = comatproto.RepoDescribeRepo(r.Context(), pdsClient, ident.DID.String()) 95 if err != nil { 96 var xrpcErr *xrpc.Error 97 var xrpcBody *xrpc.XRPCError 98 isDeactivated := errors.As(err, &xrpcErr) && 99 errors.As(xrpcErr.Wrapped, &xrpcBody) && 100 xrpcBody.ErrStr == "RepoDeactivated" 101 102 if !isDeactivated { 103 l.Warn("describeRepo failed", "handle", handle, "did", ident.DID, "pds", pdsEndpoint, "err", err) 104 s.pages.Notice(w, "login-msg", fmt.Sprintf("Account \"%s\" is no longer available.", handle)) 105 return 106 } 107 } 108 109 if err := s.oauth.SetAuthReturn(w, r, sanitizeReturnURL(returnURL), addAccount); err != nil { 110 l.Error("failed to set auth return", "err", err) 111 } 112 113 redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), ident.DID.String()) 114 if err != nil { 115 l.Error("failed to start auth", "err", err) 116 s.pages.Notice( 117 w, 118 "login-msg", 119 fmt.Sprintf("Failed to start auth flow: %v", err), 120 ) 121 return 122 } 123 124 s.pages.HxRedirect(w, redirectURL) 125 } 126} 127 128// sanitizeReturnURL ensures the return URL is a relative path on the same 129// origin. Anything else — absolute URLs, protocol-relative URLs — is replaced 130// with "/" to prevent open redirect after OAuth login. 131func sanitizeReturnURL(s string) string { 132 if strings.HasPrefix(s, "/") && !strings.HasPrefix(s, "//") { 133 return s 134 } 135 return "/" 136} 137 138func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 139 l := s.logger.With("handler", "Logout") 140 141 currentUser := s.oauth.GetMultiAccountUser(r) 142 if currentUser == nil || currentUser.Active == nil { 143 s.pages.HxRedirect(w, "/login") 144 return 145 } 146 147 currentDid := currentUser.Active.Did 148 149 var remainingAccounts []string 150 for _, acc := range currentUser.Accounts { 151 if acc.Did != currentDid { 152 remainingAccounts = append(remainingAccounts, acc.Did) 153 } 154 } 155 156 if err := s.oauth.RemoveAccount(w, r, currentDid); err != nil { 157 l.Error("failed to remove account from registry", "err", err) 158 } 159 160 if err := s.oauth.DeleteSession(w, r); err != nil { 161 l.Error("failed to delete session", "err", err) 162 } 163 164 if len(remainingAccounts) > 0 { 165 nextDid := remainingAccounts[0] 166 if err := s.oauth.SwitchAccount(w, r, nextDid); err != nil { 167 l.Error("failed to switch to next account", "err", err) 168 s.pages.HxRedirect(w, "/login") 169 return 170 } 171 l.Info("switched to next account after logout", "did", nextDid) 172 s.pages.HxRefresh(w) 173 return 174 } 175 176 l.Info("logged out last account") 177 s.pages.HxRedirect(w, "/login") 178}