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