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