Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).
0
fork

Configure Feed

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

appview/settings: add account management UI for tngl.sh users

Signed-off-by: Lewis <lewis@tangled.org>

authored by

Lewis and committed by tangled.org 3417b984 7108735b

+874 -7
+5
appview/config/config.go
··· 4 4 "context" 5 5 "fmt" 6 6 "net/url" 7 + "strings" 7 8 "time" 8 9 9 10 "github.com/sethvargo/go-envconfig" ··· 87 86 type PdsConfig struct { 88 87 Host string `env:"HOST, default=https://tngl.sh"` 89 88 AdminSecret string `env:"ADMIN_SECRET"` 89 + } 90 + 91 + func (p *PdsConfig) IsTnglShUser(pdsHost string) bool { 92 + return strings.TrimRight(pdsHost, "/") == strings.TrimRight(p.Host, "/") 90 93 } 91 94 92 95 type R2Config struct {
+28
appview/oauth/handler.go
··· 15 15 comatproto "github.com/bluesky-social/indigo/api/atproto" 16 16 "github.com/bluesky-social/indigo/atproto/auth/oauth" 17 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 + xrpc "github.com/bluesky-social/indigo/xrpc" 18 19 "github.com/go-chi/chi/v5" 19 20 "github.com/posthog/posthog-go" 20 21 "tangled.org/core/api/tangled" ··· 41 40 doc.JWKSURI = &o.JwksUri 42 41 doc.ClientName = &o.ClientName 43 42 doc.ClientURI = &o.ClientUri 43 + doc.Scope = doc.Scope + " identity:handle" 44 44 45 45 w.Header().Set("Content-Type", "application/json") 46 46 if err := json.NewEncoder(w).Encode(doc); err != nil { ··· 111 109 redirectURL = authReturn.ReturnURL 112 110 } 113 111 112 + if o.isAccountDeactivated(sessData) { 113 + redirectURL = "/settings/profile" 114 + } 115 + 114 116 http.Redirect(w, r, redirectURL, http.StatusFound) 117 + } 118 + 119 + func (o *OAuth) isAccountDeactivated(sessData *oauth.ClientSessionData) bool { 120 + pdsClient := &xrpc.Client{ 121 + Host: sessData.HostURL, 122 + Client: &http.Client{Timeout: 5 * time.Second}, 123 + } 124 + 125 + _, err := comatproto.RepoDescribeRepo( 126 + context.Background(), 127 + pdsClient, 128 + sessData.AccountDID.String(), 129 + ) 130 + if err == nil { 131 + return false 132 + } 133 + 134 + var xrpcErr *xrpc.Error 135 + var xrpcBody *xrpc.XRPCError 136 + return errors.As(err, &xrpcErr) && 137 + errors.As(xrpcErr.Wrapped, &xrpcBody) && 138 + xrpcBody.ErrStr == "RepoDeactivated" 115 139 } 116 140 117 141 func (o *OAuth) addToDefaultSpindle(did string) {
+58
appview/oauth/oauth.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "context" 4 5 "errors" 5 6 "fmt" 6 7 "log/slog" 7 8 "net/http" 9 + "net/url" 8 10 "sync" 9 11 "time" 10 12 ··· 364 362 Timeout: opts.timeout, 365 363 }, 366 364 }, nil 365 + } 366 + 367 + func (o *OAuth) StartElevatedAuthFlow(ctx context.Context, w http.ResponseWriter, r *http.Request, did string, extraScopes []string, returnURL string) (string, error) { 368 + parsedDid, err := syntax.ParseDID(did) 369 + if err != nil { 370 + return "", fmt.Errorf("invalid DID: %w", err) 371 + } 372 + 373 + ident, err := o.ClientApp.Dir.Lookup(ctx, parsedDid.AtIdentifier()) 374 + if err != nil { 375 + return "", fmt.Errorf("failed to resolve DID (%s): %w", did, err) 376 + } 377 + 378 + host := ident.PDSEndpoint() 379 + if host == "" { 380 + return "", fmt.Errorf("identity does not link to an atproto host (PDS)") 381 + } 382 + 383 + authserverURL, err := o.ClientApp.Resolver.ResolveAuthServerURL(ctx, host) 384 + if err != nil { 385 + return "", fmt.Errorf("resolving auth server: %w", err) 386 + } 387 + 388 + authserverMeta, err := o.ClientApp.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) 389 + if err != nil { 390 + return "", fmt.Errorf("fetching auth server metadata: %w", err) 391 + } 392 + 393 + scopes := make([]string, 0, len(TangledScopes)+len(extraScopes)) 394 + scopes = append(scopes, TangledScopes...) 395 + scopes = append(scopes, extraScopes...) 396 + 397 + loginHint := did 398 + if ident.Handle != "" && !ident.Handle.IsInvalidHandle() { 399 + loginHint = ident.Handle.String() 400 + } 401 + 402 + info, err := o.ClientApp.SendAuthRequest(ctx, authserverMeta, scopes, loginHint) 403 + if err != nil { 404 + return "", fmt.Errorf("auth request failed: %w", err) 405 + } 406 + 407 + info.AccountDID = &parsedDid 408 + o.ClientApp.Store.SaveAuthRequestInfo(ctx, *info) 409 + 410 + if err := o.SetAuthReturn(w, r, returnURL, false); err != nil { 411 + return "", fmt.Errorf("failed to set auth return: %w", err) 412 + } 413 + 414 + redirectURL := fmt.Sprintf("%s?client_id=%s&request_uri=%s", 415 + authserverMeta.AuthorizationEndpoint, 416 + url.QueryEscape(o.ClientApp.Config.ClientID), 417 + url.QueryEscape(info.RequestURI), 418 + ) 419 + 420 + return redirectURL, nil 367 421 }
+12 -2
appview/pages/htmx.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "html" 5 6 "net/http" 6 7 ) 7 8 8 9 // Notice performs a hx-oob-swap to replace the content of an element with a message. 9 10 // Pass the id of the element and the message to display. 10 11 func (s *Pages) Notice(w http.ResponseWriter, id, msg string) { 11 - html := fmt.Sprintf(`<span id="%s" hx-swap-oob="innerHTML">%s</span>`, id, msg) 12 + escaped := html.EscapeString(msg) 13 + markup := fmt.Sprintf(`<span id="%s" hx-swap-oob="innerHTML">%s</span>`, id, escaped) 12 14 13 15 w.Header().Set("Content-Type", "text/html") 14 16 w.WriteHeader(http.StatusOK) 15 - w.Write([]byte(html)) 17 + w.Write([]byte(markup)) 18 + } 19 + 20 + func (s *Pages) NoticeHTML(w http.ResponseWriter, id string, trustedHTML string) { 21 + markup := fmt.Sprintf(`<span id="%s" hx-swap-oob="innerHTML">%s</span>`, id, trustedHTML) 22 + 23 + w.Header().Set("Content-Type", "text/html") 24 + w.WriteHeader(http.StatusOK) 25 + w.Write([]byte(markup)) 16 26 } 17 27 18 28 // HxRefresh is a client-side full refresh of the page.
+16
appview/pages/pages.go
··· 364 364 LoggedInUser *oauth.MultiAccountUser 365 365 Tab string 366 366 PunchcardPreference models.PunchcardPreference 367 + IsTnglSh bool 368 + IsDeactivated bool 369 + PdsDomain string 370 + HandleOpen bool 367 371 } 368 372 369 373 func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { ··· 1575 1571 } 1576 1572 1577 1573 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 1574 + } 1575 + 1576 + func (p *Pages) DangerPasswordTokenStep(w io.Writer) error { 1577 + return p.executePlain("user/settings/fragments/dangerPasswordToken", w, nil) 1578 + } 1579 + 1580 + func (p *Pages) DangerPasswordSuccess(w io.Writer) error { 1581 + return p.executePlain("user/settings/fragments/dangerPasswordSuccess", w, nil) 1582 + } 1583 + 1584 + func (p *Pages) DangerDeleteTokenStep(w io.Writer) error { 1585 + return p.executePlain("user/settings/fragments/dangerDeleteToken", w, nil) 1578 1586 } 1579 1587 1580 1588 func (p *Pages) Error500(w io.Writer) error {
+26
appview/pages/templates/user/settings/fragments/dangerDeleteToken.html
··· 1 + {{ define "user/settings/fragments/dangerDeleteToken" }} 2 + <div id="delete-form-container" hx-swap-oob="innerHTML"> 3 + <label class="uppercase text-sm font-bold p-0 text-red-600 dark:text-red-400">Delete account</label> 4 + <p class="text-sm text-gray-500 dark:text-gray-400 pt-1">Check your email for an account deletion code.</p> 5 + <form hx-post="/settings/delete/confirm" hx-swap="none" hx-confirm="This will permanently delete your account. This cannot be undone. Continue?" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3 pt-2"> 6 + <div class="flex flex-col"> 7 + <label for="delete-token">deletion code</label> 8 + <input type="text" id="delete-token" name="token" required autocomplete="off" placeholder="xxxx-xxxx" /> 9 + </div> 10 + <div class="flex flex-col"> 11 + <label for="delete-password-confirm">password</label> 12 + <input type="password" id="delete-password-confirm" name="password" required autocomplete="current-password" /> 13 + </div> 14 + <div class="flex flex-col"> 15 + <label for="delete-confirmation">confirmation</label> 16 + <input type="text" id="delete-confirmation" name="confirmation" required autocomplete="off" placeholder="delete my account" /> 17 + <span class="text-sm text-gray-500 mt-1">Type <strong>delete my account</strong> to confirm.</span> 18 + </div> 19 + <div class="flex gap-2 pt-2"> 20 + <button type="button" popovertarget="delete-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">cancel</button> 21 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">delete account</button> 22 + </div> 23 + <div id="delete-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 24 + </form> 25 + </div> 26 + {{ end }}
+6
appview/pages/templates/user/settings/fragments/dangerPasswordSuccess.html
··· 1 + {{ define "user/settings/fragments/dangerPasswordSuccess" }} 2 + <div id="password-form-container" hx-swap-oob="innerHTML"> 3 + <label class="uppercase text-sm font-bold p-0">Change password</label> 4 + <p class="text-green-500 dark:text-green-400 pt-2">Password changed.</p> 5 + </div> 6 + {{ end }}
+25
appview/pages/templates/user/settings/fragments/dangerPasswordToken.html
··· 1 + {{ define "user/settings/fragments/dangerPasswordToken" }} 2 + <div id="password-form-container" hx-swap-oob="innerHTML"> 3 + <label class="uppercase text-sm font-bold p-0">Change password</label> 4 + <p class="text-sm text-gray-500 dark:text-gray-400 pt-1">Check your email for a password reset code.</p> 5 + <form hx-post="/settings/password/reset" hx-swap="none" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3 pt-2"> 6 + <div class="flex flex-col"> 7 + <label for="token">reset code</label> 8 + <input type="text" id="token" name="token" required autocomplete="off" placeholder="xxxx-xxxx" /> 9 + </div> 10 + <div class="flex flex-col"> 11 + <label for="new-password">new password</label> 12 + <input type="password" id="new-password" name="new_password" required autocomplete="new-password" /> 13 + </div> 14 + <div class="flex flex-col"> 15 + <label for="confirm-password">confirm new password</label> 16 + <input type="password" id="confirm-password" name="confirm_password" required autocomplete="new-password" /> 17 + </div> 18 + <div class="flex gap-2 pt-2"> 19 + <button type="button" popovertarget="change-password-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">cancel</button> 20 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2">set new password</button> 21 + </div> 22 + <div id="password-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 23 + </form> 24 + </div> 25 + {{ end }}
+242 -2
appview/pages/templates/user/settings/profile.html
··· 11 11 </div> 12 12 <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 13 {{ template "profile" . }} 14 + {{ if .IsTnglSh }} 15 + {{ template "accountActions" . }} 16 + {{ end }} 14 17 {{ template "punchcard" . }} 15 18 </div> 16 19 </section> ··· 29 26 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 30 27 <div class="flex flex-col gap-1 p-4"> 31 28 <span class="text-sm text-gray-500 dark:text-gray-400">Handle</span> 32 - <span class="font-bold">{{ resolve .LoggedInUser.Did }}</span> 29 + <div class="flex items-center gap-2"> 30 + <span class="font-bold">{{ resolve .LoggedInUser.Did }}</span> 31 + {{ if .IsTnglSh }} 32 + {{ if .HandleOpen }} 33 + <button 34 + popovertarget="change-handle-modal" 35 + popovertargetaction="toggle" 36 + class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-pointer">change</button> 37 + {{ else }} 38 + <a href="/settings/handle" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">change</a> 39 + {{ end }} 40 + {{ end }} 41 + </div> 33 42 </div> 34 43 <div class="flex flex-col gap-1 p-4"> 35 44 <span class="text-sm text-gray-500 dark:text-gray-400">Decentralized Identifier (DID)</span> ··· 52 37 <span class="font-bold">{{ .LoggedInUser.Pds }}</span> 53 38 </div> 54 39 </div> 40 + {{ if and .IsTnglSh .HandleOpen }} 41 + <div 42 + id="change-handle-modal" 43 + popover 44 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 45 + <div id="handle-subdomain" class="flex flex-col gap-3"> 46 + <label class="uppercase text-sm font-bold p-0">Change handle</label> 47 + <form hx-post="/settings/handle" hx-swap="none" class="flex flex-col gap-3"> 48 + <input type="hidden" name="type" value="subdomain"> 49 + <div class="flex items-stretch rounded border border-gray-200 dark:border-gray-600 overflow-hidden focus-within:ring-1 focus-within:ring-blue-500 dark:bg-gray-700"> 50 + <input type="text" name="handle" placeholder="username" class="flex-1 px-2 py-1.5 bg-transparent dark:text-white border-0 focus:outline-none focus:ring-0 min-w-0" required> 51 + <span class="px-2 py-1.5 bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-300 select-none whitespace-nowrap border-l border-gray-200 dark:border-gray-600 content-center">.{{ .PdsDomain }}</span> 52 + </div> 53 + <div class="flex gap-2 pt-2"> 54 + <button type="button" popovertarget="change-handle-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 55 + {{ i "x" "size-4" }} cancel 56 + </button> 57 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2"> 58 + {{ i "check" "size-4" }} save 59 + </button> 60 + </div> 61 + </form> 62 + <a href="#" id="switch-to-custom" class="text-sm text-gray-400 underline hover:text-gray-600 dark:hover:text-gray-300">I have my own domain</a> 63 + </div> 64 + <div id="handle-custom" style="display: none;" class="flex flex-col gap-3"> 65 + <label class="uppercase text-sm font-bold p-0">Change handle</label> 66 + <form hx-post="/settings/handle" hx-swap="none" class="flex flex-col gap-3"> 67 + <input type="hidden" name="type" value="custom"> 68 + <input id="custom-domain-input" type="text" name="handle" placeholder="mycoolhandle.com" class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-2 py-1.5 border border-gray-200 dark:border-gray-600 rounded outline-none focus:ring-1 focus:ring-blue-500" required> 69 + <div class="bg-gray-50 dark:bg-gray-900 rounded p-3 text-gray-500 dark:text-gray-400 flex flex-col gap-2 text-xs"> 70 + <p>Set up one of the following on your domain:</p> 71 + <div> 72 + <p class="font-medium text-gray-700 dark:text-gray-300">DNS TXT record</p> 73 + <p>Add a TXT record for <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">_atproto.<span id="dns-domain">mycoolhandle.com</span></code></p> 74 + <code class="inline-block bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded mt-1.5 break-all">did={{ .LoggedInUser.Did }}</code> 75 + </div> 76 + <div> 77 + <p class="font-medium text-gray-700 dark:text-gray-300">HTTP well-known</p> 78 + <p>Serve your DID at:</p> 79 + <code class="inline-block bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded mt-1 break-all">https://<span id="wk-domain">mycoolhandle.com</span>/.well-known/atproto-did</code> 80 + <code class="inline-block bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded mt-1.5 break-all">{{ .LoggedInUser.Did }}</code> 81 + </div> 82 + </div> 83 + <div class="flex gap-2 pt-2"> 84 + <button type="button" popovertarget="change-handle-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 85 + {{ i "x" "size-4" }} cancel 86 + </button> 87 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2"> 88 + {{ i "check" "size-4" }} verify & save 89 + </button> 90 + </div> 91 + </form> 92 + <a href="#" id="switch-to-subdomain" class="text-sm text-gray-400 underline hover:text-gray-600 dark:hover:text-gray-300">use a {{ .PdsDomain }} subdomain instead</a> 93 + </div> 94 + <div id="handle-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 95 + <div id="handle-success" class="text-green-500 dark:text-green-400 text-sm empty:hidden"></div> 96 + </div> 97 + <script> 98 + document.getElementById('switch-to-custom').addEventListener('click', function(e) { 99 + e.preventDefault(); 100 + document.getElementById('handle-subdomain').style.display = 'none'; 101 + document.getElementById('handle-custom').style.display = ''; 102 + }); 103 + document.getElementById('switch-to-subdomain').addEventListener('click', function(e) { 104 + e.preventDefault(); 105 + document.getElementById('handle-custom').style.display = 'none'; 106 + document.getElementById('handle-subdomain').style.display = ''; 107 + }); 108 + document.getElementById('custom-domain-input').addEventListener('input', function(e) { 109 + var d = e.target.value.trim() || 'mycoolhandle.com'; 110 + document.getElementById('dns-domain').textContent = d; 111 + document.getElementById('wk-domain').textContent = d; 112 + }); 113 + document.getElementById('change-handle-modal').showPopover(); 114 + </script> 115 + {{ end }} 116 + </div> 117 + {{ end }} 118 + 119 + {{ define "accountActions" }} 120 + <div> 121 + <h2 class="text-sm uppercase font-bold">Account</h2> 122 + {{ if .IsDeactivated }} 123 + <div class="mt-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded text-sm text-amber-700 dark:text-amber-300"> 124 + Your account is deactivated. Your profile and repositories are currently inaccessible. Reactivate to restore access. 125 + </div> 126 + {{ end }} 127 + <div class="flex flex-wrap gap-2 pt-2"> 128 + <button 129 + popovertarget="change-password-modal" 130 + popovertargetaction="toggle" 131 + class="btn flex items-center gap-2 text-sm cursor-pointer"> 132 + {{ i "key" "size-4" }} 133 + change password 134 + </button> 135 + {{ if .IsDeactivated }} 136 + <button 137 + popovertarget="reactivate-modal" 138 + popovertargetaction="toggle" 139 + class="btn flex items-center gap-2 text-sm text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 border-green-300 dark:border-green-600 cursor-pointer"> 140 + {{ i "play" "size-4" }} 141 + reactivate account 142 + </button> 143 + {{ else }} 144 + <button 145 + popovertarget="deactivate-modal" 146 + popovertargetaction="toggle" 147 + class="btn flex items-center gap-2 text-sm text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300 border-amber-300 dark:border-amber-600 cursor-pointer"> 148 + {{ i "pause" "size-4" }} 149 + deactivate account 150 + </button> 151 + {{ end }} 152 + <button 153 + popovertarget="delete-modal" 154 + popovertargetaction="toggle" 155 + class="btn flex items-center gap-2 text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border-red-300 dark:border-red-600 cursor-pointer"> 156 + {{ i "trash-2" "size-4" }} 157 + delete account 158 + </button> 159 + </div> 160 + </div> 161 + 162 + <div 163 + id="change-password-modal" 164 + popover 165 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 166 + <div id="password-form-container" class="flex flex-col gap-3"> 167 + <label class="uppercase text-sm font-bold p-0">Change password</label> 168 + <form hx-post="/settings/password/request" hx-swap="none" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3"> 169 + <div class="flex flex-col"> 170 + <label for="current-password">current password</label> 171 + <input type="password" id="current-password" name="current_password" required autocomplete="current-password" /> 172 + <span class="text-sm text-gray-500 mt-1">Confirm your identity to proceed.</span> 173 + </div> 174 + <div class="flex gap-2 pt-2"> 175 + <button type="button" popovertarget="change-password-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 176 + {{ i "x" "size-4" }} cancel 177 + </button> 178 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2"> 179 + {{ i "key" "size-4" }} send reset code 180 + </button> 181 + </div> 182 + </form> 183 + <div id="password-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 184 + </div> 185 + </div> 186 + 187 + {{ if .IsDeactivated }} 188 + <div 189 + id="reactivate-modal" 190 + popover 191 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-green-300 dark:border-green-600 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 192 + <div class="flex flex-col gap-3"> 193 + <label class="uppercase text-sm font-bold p-0 text-green-600 dark:text-green-400">Reactivate account</label> 194 + <p class="text-sm text-gray-500 dark:text-gray-400">This will restore your profile and repositories, making them accessible again.</p> 195 + <form hx-post="/settings/reactivate" hx-swap="none" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3"> 196 + <div class="flex flex-col"> 197 + <label for="reactivate-password">password</label> 198 + <input type="password" id="reactivate-password" name="password" required autocomplete="current-password" /> 199 + <span class="text-sm text-gray-500 mt-1">Confirm your identity to proceed.</span> 200 + </div> 201 + <div class="flex gap-2 pt-2"> 202 + <button type="button" popovertarget="reactivate-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 203 + {{ i "x" "size-4" }} cancel 204 + </button> 205 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2 text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"> 206 + {{ i "play" "size-4" }} reactivate 207 + </button> 208 + </div> 209 + </form> 210 + <div id="reactivate-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 211 + </div> 212 + </div> 213 + {{ else }} 214 + <div 215 + id="deactivate-modal" 216 + popover 217 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-amber-300 dark:border-amber-600 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 218 + <div class="flex flex-col gap-3"> 219 + <label class="uppercase text-sm font-bold p-0 text-amber-600 dark:text-amber-400">Deactivate account</label> 220 + <p class="text-sm text-gray-500 dark:text-gray-400">Your profile and repositories will become inaccessible. You can reactivate by logging in again.</p> 221 + <form hx-post="/settings/deactivate" hx-swap="none" hx-confirm="Are you sure you want to deactivate your account?" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3"> 222 + <div class="flex flex-col"> 223 + <label for="deactivate-password">password</label> 224 + <input type="password" id="deactivate-password" name="password" required autocomplete="current-password" /> 225 + <span class="text-sm text-gray-500 mt-1">Confirm your identity to proceed.</span> 226 + </div> 227 + <div class="flex gap-2 pt-2"> 228 + <button type="button" popovertarget="deactivate-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 229 + {{ i "x" "size-4" }} cancel 230 + </button> 231 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2 text-amber-600 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300"> 232 + {{ i "pause" "size-4" }} deactivate 233 + </button> 234 + </div> 235 + </form> 236 + <div id="deactivate-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 237 + </div> 238 + </div> 239 + {{ end }} 240 + 241 + <div 242 + id="delete-modal" 243 + popover 244 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-red-300 dark:border-red-600 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 245 + <div id="delete-form-container" class="flex flex-col gap-3"> 246 + <label class="uppercase text-sm font-bold p-0 text-red-600 dark:text-red-400">Delete account</label> 247 + <p class="text-sm text-gray-500 dark:text-gray-400">This permanently deletes your account and all associated data. This cannot be undone.</p> 248 + <form hx-post="/settings/delete/request" hx-swap="none" hx-disabled-elt="find button[type='submit']" class="flex flex-col gap-3"> 249 + <div class="flex flex-col"> 250 + <label for="delete-password">password</label> 251 + <input type="password" id="delete-password" name="password" required autocomplete="current-password" /> 252 + <span class="text-sm text-gray-500 mt-1">Confirm your identity to proceed.</span> 253 + </div> 254 + <div class="flex gap-2 pt-2"> 255 + <button type="button" popovertarget="delete-modal" popovertargetaction="hide" class="btn w-1/2 flex items-center gap-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"> 256 + {{ i "x" "size-4" }} cancel 257 + </button> 258 + <button type="submit" class="btn w-1/2 flex items-center justify-center gap-2 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 259 + {{ i "trash-2" "size-4" }} send deletion code 260 + </button> 261 + </div> 262 + </form> 263 + <div id="delete-error" class="text-red-500 dark:text-red-400 text-sm empty:hidden"></div> 264 + </div> 55 265 </div> 56 266 {{ end }} 57 267 ··· 286 46 <p class="text-gray-500 dark:text-gray-400 pb-2 "> 287 47 Configure punchcard visibility and preferences. 288 48 </p> 289 - <form hx-post="/profile/punchcard" hx-trigger="change" hx-swap="none" class="flex flex-col gap-2"> 49 + <form hx-post="/profile/punchcard" hx-trigger="change" hx-swap="none" class="flex flex-col gap-3"> 290 50 <div class="flex items-center gap-2"> 291 51 <input type="checkbox" id="hideMine" name="hideMine" value="on" {{ if eq true $.PunchcardPreference.HideMine }}checked{{ end }}> 292 52 <label for="hideMine" class="my-0 py-0 normal-case font-normal">Hide mine</label>
+294
appview/settings/danger.go
··· 1 + package settings 2 + 3 + import ( 4 + "context" 5 + "errors" 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 + ) 13 + 14 + type pdsSession struct { 15 + Client *xrpc.Client 16 + Did string 17 + Email string 18 + AccessJwt string 19 + } 20 + 21 + func (s *Settings) pdsClient() *xrpc.Client { 22 + return &xrpc.Client{ 23 + Host: s.Config.Pds.Host, 24 + Client: &http.Client{Timeout: 15 * time.Second}, 25 + } 26 + } 27 + 28 + func (s *Settings) verifyPdsPassword(did, password string) (*pdsSession, error) { 29 + client := s.pdsClient() 30 + resp, err := comatproto.ServerCreateSession(context.Background(), client, &comatproto.ServerCreateSession_Input{ 31 + Identifier: did, 32 + Password: password, 33 + }) 34 + if err != nil { 35 + return nil, err 36 + } 37 + 38 + client.Auth = &xrpc.AuthInfo{AccessJwt: resp.AccessJwt} 39 + 40 + var email string 41 + if resp.Email != nil { 42 + email = *resp.Email 43 + } 44 + 45 + return &pdsSession{ 46 + Client: client, 47 + Did: resp.Did, 48 + Email: email, 49 + AccessJwt: resp.AccessJwt, 50 + }, nil 51 + } 52 + 53 + func (s *Settings) revokePdsSession(session *pdsSession) { 54 + if err := comatproto.ServerDeleteSession(context.Background(), session.Client); err != nil { 55 + s.Logger.Warn("failed to revoke session", "err", err) 56 + } 57 + } 58 + 59 + func (s *Settings) requestPasswordReset(w http.ResponseWriter, r *http.Request) { 60 + user := s.OAuth.GetMultiAccountUser(r) 61 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 62 + s.Pages.Notice(w, "password-error", "Only available for tngl.sh accounts.") 63 + return 64 + } 65 + 66 + did := s.OAuth.GetDid(r) 67 + password := r.FormValue("current_password") 68 + if password == "" { 69 + s.Pages.Notice(w, "password-error", "Password is required.") 70 + return 71 + } 72 + 73 + session, err := s.verifyPdsPassword(did, password) 74 + if err != nil { 75 + s.Pages.Notice(w, "password-error", "Current password is incorrect.") 76 + return 77 + } 78 + 79 + if session.Email == "" { 80 + s.revokePdsSession(session) 81 + s.Logger.Error("requesting password reset: no email on account", "did", did) 82 + s.Pages.Notice(w, "password-error", "No email associated with your account.") 83 + return 84 + } 85 + 86 + s.revokePdsSession(session) 87 + 88 + err = comatproto.ServerRequestPasswordReset(context.Background(), s.pdsClient(), &comatproto.ServerRequestPasswordReset_Input{ 89 + Email: session.Email, 90 + }) 91 + if err != nil { 92 + s.Logger.Error("requesting password reset", "err", err) 93 + s.Pages.Notice(w, "password-error", "Failed to request password reset. Try again later.") 94 + return 95 + } 96 + 97 + s.Pages.DangerPasswordTokenStep(w) 98 + } 99 + 100 + func (s *Settings) resetPassword(w http.ResponseWriter, r *http.Request) { 101 + user := s.OAuth.GetMultiAccountUser(r) 102 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 103 + s.Pages.Notice(w, "password-error", "Only available for tngl.sh accounts.") 104 + return 105 + } 106 + 107 + token := strings.TrimSpace(r.FormValue("token")) 108 + newPassword := r.FormValue("new_password") 109 + confirmPassword := r.FormValue("confirm_password") 110 + 111 + if token == "" || newPassword == "" || confirmPassword == "" { 112 + s.Pages.Notice(w, "password-error", "All fields are required.") 113 + return 114 + } 115 + 116 + if newPassword != confirmPassword { 117 + s.Pages.Notice(w, "password-error", "Passwords do not match.") 118 + return 119 + } 120 + 121 + err := comatproto.ServerResetPassword(context.Background(), s.pdsClient(), &comatproto.ServerResetPassword_Input{ 122 + Token: token, 123 + Password: newPassword, 124 + }) 125 + if err != nil { 126 + s.Logger.Error("resetting password", "err", err) 127 + s.Pages.Notice(w, "password-error", "Failed to reset password. The token may have expired.") 128 + return 129 + } 130 + 131 + s.Pages.DangerPasswordSuccess(w) 132 + } 133 + 134 + func (s *Settings) deactivateAccount(w http.ResponseWriter, r *http.Request) { 135 + user := s.OAuth.GetMultiAccountUser(r) 136 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 137 + s.Pages.Notice(w, "deactivate-error", "Only available for tngl.sh accounts.") 138 + return 139 + } 140 + 141 + did := s.OAuth.GetDid(r) 142 + password := r.FormValue("password") 143 + 144 + if password == "" { 145 + s.Pages.Notice(w, "deactivate-error", "Password is required.") 146 + return 147 + } 148 + 149 + session, err := s.verifyPdsPassword(did, password) 150 + if err != nil { 151 + s.Pages.Notice(w, "deactivate-error", "Password is incorrect.") 152 + return 153 + } 154 + 155 + err = comatproto.ServerDeactivateAccount(context.Background(), session.Client, &comatproto.ServerDeactivateAccount_Input{}) 156 + s.revokePdsSession(session) 157 + if err != nil { 158 + s.Logger.Error("deactivating account", "err", err) 159 + s.Pages.Notice(w, "deactivate-error", "Failed to deactivate account. Try again later.") 160 + return 161 + } 162 + 163 + if err := s.OAuth.DeleteSession(w, r); err != nil { 164 + s.Logger.Error("clearing session after deactivation", "did", did, "err", err) 165 + } 166 + if err := s.OAuth.RemoveAccount(w, r, did); err != nil { 167 + s.Logger.Error("removing account after deactivation", "did", did, "err", err) 168 + } 169 + s.Pages.HxRedirect(w, "/") 170 + } 171 + 172 + func (s *Settings) requestAccountDelete(w http.ResponseWriter, r *http.Request) { 173 + user := s.OAuth.GetMultiAccountUser(r) 174 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 175 + s.Pages.Notice(w, "delete-error", "Only available for tngl.sh accounts.") 176 + return 177 + } 178 + 179 + did := s.OAuth.GetDid(r) 180 + password := r.FormValue("password") 181 + 182 + if password == "" { 183 + s.Pages.Notice(w, "delete-error", "Password is required.") 184 + return 185 + } 186 + 187 + session, err := s.verifyPdsPassword(did, password) 188 + if err != nil { 189 + s.Pages.Notice(w, "delete-error", "Password is incorrect.") 190 + return 191 + } 192 + 193 + err = comatproto.ServerRequestAccountDelete(context.Background(), session.Client) 194 + s.revokePdsSession(session) 195 + if err != nil { 196 + s.Logger.Error("requesting account deletion", "err", err) 197 + s.Pages.Notice(w, "delete-error", "Failed to request account deletion. Try again later.") 198 + return 199 + } 200 + 201 + s.Pages.DangerDeleteTokenStep(w) 202 + } 203 + 204 + func (s *Settings) deleteAccount(w http.ResponseWriter, r *http.Request) { 205 + user := s.OAuth.GetMultiAccountUser(r) 206 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 207 + s.Pages.Notice(w, "delete-error", "Only available for tngl.sh accounts.") 208 + return 209 + } 210 + 211 + did := s.OAuth.GetDid(r) 212 + password := r.FormValue("password") 213 + token := strings.TrimSpace(r.FormValue("token")) 214 + confirmation := r.FormValue("confirmation") 215 + 216 + if password == "" || token == "" { 217 + s.Pages.Notice(w, "delete-error", "All fields are required.") 218 + return 219 + } 220 + 221 + if confirmation != "delete my account" { 222 + s.Pages.Notice(w, "delete-error", "You must type \"delete my account\" to confirm.") 223 + return 224 + } 225 + 226 + err := comatproto.ServerDeleteAccount(context.Background(), s.pdsClient(), &comatproto.ServerDeleteAccount_Input{ 227 + Did: did, 228 + Password: password, 229 + Token: token, 230 + }) 231 + if err != nil { 232 + s.Logger.Error("deleting account", "err", err) 233 + s.Pages.Notice(w, "delete-error", "Failed to delete account. Try again later.") 234 + return 235 + } 236 + 237 + if err := s.OAuth.DeleteSession(w, r); err != nil { 238 + s.Logger.Error("clearing session after account deletion", "did", did, "err", err) 239 + } 240 + if err := s.OAuth.RemoveAccount(w, r, did); err != nil { 241 + s.Logger.Error("removing account after deletion", "did", did, "err", err) 242 + } 243 + s.Pages.HxRedirect(w, "/") 244 + } 245 + 246 + func (s *Settings) isAccountDeactivated(ctx context.Context, did, pdsHost string) bool { 247 + client := &xrpc.Client{ 248 + Host: pdsHost, 249 + Client: &http.Client{Timeout: 5 * time.Second}, 250 + } 251 + 252 + _, err := comatproto.RepoDescribeRepo(ctx, client, did) 253 + if err == nil { 254 + return false 255 + } 256 + 257 + var xrpcErr *xrpc.Error 258 + var xrpcBody *xrpc.XRPCError 259 + return errors.As(err, &xrpcErr) && 260 + errors.As(xrpcErr.Wrapped, &xrpcBody) && 261 + xrpcBody.ErrStr == "RepoDeactivated" 262 + } 263 + 264 + func (s *Settings) reactivateAccount(w http.ResponseWriter, r *http.Request) { 265 + user := s.OAuth.GetMultiAccountUser(r) 266 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 267 + s.Pages.Notice(w, "reactivate-error", "Only available for tngl.sh accounts.") 268 + return 269 + } 270 + 271 + did := s.OAuth.GetDid(r) 272 + password := r.FormValue("password") 273 + 274 + if password == "" { 275 + s.Pages.Notice(w, "reactivate-error", "Password is required.") 276 + return 277 + } 278 + 279 + session, err := s.verifyPdsPassword(did, password) 280 + if err != nil { 281 + s.Pages.Notice(w, "reactivate-error", "Password is incorrect.") 282 + return 283 + } 284 + 285 + err = comatproto.ServerActivateAccount(context.Background(), session.Client) 286 + s.revokePdsSession(session) 287 + if err != nil { 288 + s.Logger.Error("reactivating account", "err", err) 289 + s.Pages.Notice(w, "reactivate-error", "Failed to reactivate account. Try again later.") 290 + return 291 + } 292 + 293 + s.Pages.HxRefresh(w) 294 + }
+129 -3
appview/settings/settings.go
··· 5 5 "database/sql" 6 6 "errors" 7 7 "fmt" 8 + "html" 8 9 "log" 9 10 "log/slog" 10 11 "net/http" 11 12 "net/url" 13 + "slices" 12 14 "strings" 13 15 "time" 14 16 ··· 28 26 "tangled.org/core/tid" 29 27 30 28 comatproto "github.com/bluesky-social/indigo/api/atproto" 29 + atpclient "github.com/bluesky-social/indigo/atproto/client" 31 30 "github.com/bluesky-social/indigo/atproto/syntax" 32 31 lexutil "github.com/bluesky-social/indigo/lex/util" 33 32 "github.com/gliderlabs/ssh" ··· 78 75 r.Put("/", s.claimSitesDomain) 79 76 r.Delete("/", s.releaseSitesDomain) 80 77 }) 78 + 79 + r.Post("/password/request", s.requestPasswordReset) 80 + r.Post("/password/reset", s.resetPassword) 81 + r.Post("/deactivate", s.deactivateAccount) 82 + r.Post("/reactivate", s.reactivateAccount) 83 + r.Post("/delete/request", s.requestAccountDelete) 84 + r.Post("/delete/confirm", s.deleteAccount) 85 + 86 + r.Get("/handle", s.elevateForHandle) 87 + r.Post("/handle", s.updateHandle) 81 88 82 89 return r 83 90 } ··· 256 243 log.Printf("failed to get users punchcard preferences: %s", err) 257 244 } 258 245 246 + isDeactivated := s.Config.Pds.IsTnglShUser(user.Pds()) && s.isAccountDeactivated(r.Context(), user.Did(), user.Pds()) 247 + 259 248 s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 260 249 LoggedInUser: user, 261 250 PunchcardPreference: punchcardPreferences, 251 + IsTnglSh: s.Config.Pds.IsTnglShUser(user.Pds()), 252 + IsDeactivated: isDeactivated, 253 + PdsDomain: s.pdsDomain(), 254 + HandleOpen: r.URL.Query().Get("handle") == "1", 262 255 }) 263 256 } 264 257 ··· 624 605 _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(key)) 625 606 if err != nil { 626 607 s.Logger.Error("parsing public key", "err", err) 627 - s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 608 + s.Pages.NoticeHTML(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 628 609 return 629 610 } 630 611 ··· 708 689 709 690 // invalid record 710 691 if err != nil { 711 - s.Logger.Error("failed to delete record from PDS", "err", err) 712 - s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.") 692 + s.Logger.Error("failed to delete record", "err", err) 693 + s.Pages.Notice(w, "settings-keys", "Failed to remove key.") 713 694 return 714 695 } 715 696 } ··· 718 699 s.Pages.HxLocation(w, "/settings/keys") 719 700 return 720 701 } 702 + } 703 + 704 + func (s *Settings) pdsDomain() string { 705 + parsed, err := url.Parse(s.Config.Pds.Host) 706 + if err != nil { 707 + return s.Config.Pds.Host 708 + } 709 + return parsed.Hostname() 710 + } 711 + 712 + func (s *Settings) elevateForHandle(w http.ResponseWriter, r *http.Request) { 713 + user := s.OAuth.GetMultiAccountUser(r) 714 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 715 + http.Redirect(w, r, "/settings/profile", http.StatusSeeOther) 716 + return 717 + } 718 + 719 + sess, err := s.OAuth.ResumeSession(r) 720 + if err == nil && slices.Contains(sess.Data.Scopes, "identity:handle") { 721 + http.Redirect(w, r, "/settings/profile?handle=1", http.StatusSeeOther) 722 + return 723 + } 724 + 725 + redirectURL, err := s.OAuth.StartElevatedAuthFlow( 726 + r.Context(), w, r, 727 + user.Did(), 728 + []string{"identity:handle"}, 729 + "/settings/profile?handle=1", 730 + ) 731 + if err != nil { 732 + log.Printf("failed to start elevated auth flow: %s", err) 733 + http.Redirect(w, r, "/settings/profile", http.StatusSeeOther) 734 + return 735 + } 736 + 737 + http.Redirect(w, r, redirectURL, http.StatusFound) 738 + } 739 + 740 + func (s *Settings) updateHandle(w http.ResponseWriter, r *http.Request) { 741 + user := s.OAuth.GetMultiAccountUser(r) 742 + if !s.Config.Pds.IsTnglShUser(user.Pds()) { 743 + s.Pages.Notice(w, "handle-error", "Handle changes are only available for tngl.sh accounts.") 744 + return 745 + } 746 + 747 + handleType := r.FormValue("type") 748 + handleInput := strings.TrimSpace(r.FormValue("handle")) 749 + 750 + if handleInput == "" { 751 + s.Pages.Notice(w, "handle-error", "Handle cannot be empty.") 752 + return 753 + } 754 + 755 + var newHandle string 756 + switch handleType { 757 + case "subdomain": 758 + if !isValidSubdomain(handleInput) { 759 + s.Pages.Notice(w, "handle-error", "Invalid handle. Use only lowercase letters, digits, and hyphens.") 760 + return 761 + } 762 + newHandle = handleInput + "." + s.pdsDomain() 763 + case "custom": 764 + newHandle = handleInput 765 + default: 766 + s.Pages.Notice(w, "handle-error", "Invalid handle type.") 767 + return 768 + } 769 + 770 + client, err := s.OAuth.AuthorizedClient(r) 771 + if err != nil { 772 + log.Printf("failed to get authorized client: %s", err) 773 + s.Pages.Notice(w, "handle-error", "Failed to authorize. Try logging in again.") 774 + return 775 + } 776 + 777 + err = comatproto.IdentityUpdateHandle(r.Context(), client, &comatproto.IdentityUpdateHandle_Input{ 778 + Handle: newHandle, 779 + }) 780 + if err != nil { 781 + if strings.Contains(err.Error(), "ScopeMissing") || strings.Contains(err.Error(), "insufficient_scope") { 782 + redirectURL, elevErr := s.OAuth.StartElevatedAuthFlow( 783 + r.Context(), w, r, 784 + user.Did(), 785 + []string{"identity:handle"}, 786 + "/settings/profile?handle=1", 787 + ) 788 + if elevErr != nil { 789 + log.Printf("failed to start elevated auth flow: %s", elevErr) 790 + s.Pages.Notice(w, "handle-error", "Failed to start re-authorization. Try again later.") 791 + return 792 + } 793 + 794 + s.Pages.HxRedirect(w, redirectURL) 795 + return 796 + } 797 + 798 + log.Printf("failed to update handle: %s", err) 799 + msg := err.Error() 800 + var apiErr *atpclient.APIError 801 + if errors.As(err, &apiErr) && apiErr.Message != "" { 802 + msg = apiErr.Message 803 + } 804 + s.Pages.Notice(w, "handle-error", fmt.Sprintf("Failed to update handle: %s", msg)) 805 + return 806 + } 807 + 808 + s.Pages.NoticeHTML(w, "handle-success", fmt.Sprintf("Handle updated to <strong>%s</strong>.", html.EscapeString(newHandle))) 721 809 }
+33
appview/state/login.go
··· 1 1 package state 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 "net/http" 6 7 "strings" 8 + "time" 7 9 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/xrpc" 8 12 "tangled.org/core/appview/oauth" 9 13 "tangled.org/core/appview/pages" 10 14 ) ··· 66 62 fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 67 63 ) 68 64 return 65 + } 66 + 67 + ident, err := s.idResolver.ResolveIdent(r.Context(), handle) 68 + if err != nil { 69 + l.Warn("handle resolution failed", "handle", handle, "err", err) 70 + s.pages.Notice(w, "login-msg", fmt.Sprintf("Could not resolve handle \"%s\". The account may not exist.", handle)) 71 + return 72 + } 73 + 74 + pdsEndpoint := ident.PDSEndpoint() 75 + if pdsEndpoint == "" { 76 + s.pages.Notice(w, "login-msg", fmt.Sprintf("No PDS found for \"%s\".", handle)) 77 + return 78 + } 79 + 80 + pdsClient := &xrpc.Client{Host: pdsEndpoint, Client: &http.Client{Timeout: 5 * time.Second}} 81 + _, err = comatproto.RepoDescribeRepo(r.Context(), pdsClient, ident.DID.String()) 82 + if err != nil { 83 + var xrpcErr *xrpc.Error 84 + var xrpcBody *xrpc.XRPCError 85 + isDeactivated := errors.As(err, &xrpcErr) && 86 + errors.As(xrpcErr.Wrapped, &xrpcBody) && 87 + xrpcBody.ErrStr == "RepoDeactivated" 88 + 89 + if !isDeactivated { 90 + l.Warn("describeRepo failed", "handle", handle, "did", ident.DID, "pds", pdsEndpoint, "err", err) 91 + s.pages.Notice(w, "login-msg", fmt.Sprintf("Account \"%s\" is no longer available.", handle)) 92 + return 93 + } 69 94 } 70 95 71 96 if err := s.oauth.SetAuthReturn(w, r, returnURL, addAccount); err != nil {