Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

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

feat: error popup modal for recorc creation upon oauth grant expiration

authored by

Patrick Dewey and committed by tangled.org 69af458d 4dce0d78

+274 -46
+1 -1
go.mod
··· 5 5 require ( 6 6 github.com/a-h/templ v0.3.977 7 7 github.com/bluesky-social/indigo v0.0.0-20260106221649-6fcd9317e725 8 + github.com/go-logr/zerologr v1.2.3 8 9 github.com/google/go-querystring v1.1.0 9 10 github.com/gorilla/websocket v1.5.3 10 11 github.com/klauspost/compress v1.18.3 ··· 32 33 github.com/felixge/httpsnoop v1.0.4 // indirect 33 34 github.com/go-logr/logr v1.4.3 // indirect 34 35 github.com/go-logr/stdr v1.2.2 // indirect 35 - github.com/go-logr/zerologr v1.2.3 // indirect 36 36 github.com/gogo/protobuf v1.3.2 // indirect 37 37 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 38 38 github.com/google/uuid v1.6.0 // indirect
+23
internal/atproto/client.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "net/http" 8 + "strings" 8 9 9 10 "arabica/internal/metrics" 10 11 "arabica/internal/tracing" ··· 19 20 // indicating the user's authorization grant has expired and they need to log in again. 20 21 var ErrSessionExpired = errors.New("oauth session expired") 21 22 23 + // wrapPDSError checks whether an error from an XRPC call indicates that the 24 + // OAuth grant is no longer valid (e.g. token refresh returned invalid_grant) 25 + // and, if so, wraps it with ErrSessionExpired so that upstream handlers can 26 + // return 401 instead of 500. 27 + func wrapPDSError(err error) error { 28 + if err == nil { 29 + return nil 30 + } 31 + msg := err.Error() 32 + if strings.Contains(msg, "invalid_grant") || 33 + strings.Contains(msg, "failed to refresh OAuth tokens") || 34 + strings.Contains(msg, "token is expired") { 35 + return fmt.Errorf("%w: %w", ErrSessionExpired, err) 36 + } 37 + return err 38 + } 39 + 22 40 // Client wraps the atproto API client for making authenticated requests to a PDS 23 41 type Client struct { 24 42 oauth *OAuthManager ··· 106 124 metrics.PDSRequestsTotal.WithLabelValues("createRecord", input.Collection).Inc() 107 125 108 126 if err != nil { 127 + err = wrapPDSError(err) 109 128 tracing.EndWithError(span, err) 110 129 log.Error(). 111 130 Err(err). ··· 172 191 metrics.PDSRequestsTotal.WithLabelValues("getRecord", input.Collection).Inc() 173 192 174 193 if err != nil { 194 + err = wrapPDSError(err) 175 195 tracing.EndWithError(span, err) 176 196 log.Error(). 177 197 Err(err). ··· 258 278 metrics.PDSRequestsTotal.WithLabelValues("listRecords", input.Collection).Inc() 259 279 260 280 if err != nil { 281 + err = wrapPDSError(err) 261 282 tracing.EndWithError(span, err) 262 283 log.Error(). 263 284 Err(err). ··· 393 414 metrics.PDSRequestsTotal.WithLabelValues("putRecord", input.Collection).Inc() 394 415 395 416 if err != nil { 417 + err = wrapPDSError(err) 396 418 tracing.EndWithError(span, err) 397 419 log.Error(). 398 420 Err(err). ··· 447 469 metrics.PDSRequestsTotal.WithLabelValues("deleteRecord", input.Collection).Inc() 448 470 449 471 if err != nil { 472 + err = wrapPDSError(err) 450 473 tracing.EndWithError(span, err) 451 474 log.Error(). 452 475 Err(err).
+47
internal/atproto/client_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "testing" 7 + 8 + "github.com/stretchr/testify/assert" 9 + ) 10 + 11 + func TestWrapPDSError(t *testing.T) { 12 + t.Run("nil error passes through", func(t *testing.T) { 13 + assert.Nil(t, wrapPDSError(nil)) 14 + }) 15 + 16 + t.Run("unrelated error passes through unchanged", func(t *testing.T) { 17 + err := errors.New("network timeout") 18 + result := wrapPDSError(err) 19 + assert.False(t, errors.Is(result, ErrSessionExpired)) 20 + assert.Equal(t, err, result) 21 + }) 22 + 23 + t.Run("invalid_grant is wrapped as ErrSessionExpired", func(t *testing.T) { 24 + err := fmt.Errorf("auth server request failed (HTTP 400): invalid_grant") 25 + result := wrapPDSError(err) 26 + assert.True(t, errors.Is(result, ErrSessionExpired)) 27 + }) 28 + 29 + t.Run("token refresh failure is wrapped as ErrSessionExpired", func(t *testing.T) { 30 + err := fmt.Errorf("failed to refresh OAuth tokens: token refresh failed") 31 + result := wrapPDSError(err) 32 + assert.True(t, errors.Is(result, ErrSessionExpired)) 33 + }) 34 + 35 + t.Run("token expired is wrapped as ErrSessionExpired", func(t *testing.T) { 36 + err := fmt.Errorf("token is expired") 37 + result := wrapPDSError(err) 38 + assert.True(t, errors.Is(result, ErrSessionExpired)) 39 + }) 40 + 41 + t.Run("nested invalid_grant is detected", func(t *testing.T) { 42 + inner := fmt.Errorf("auth server request failed (HTTP 400): invalid_grant") 43 + err := fmt.Errorf("failed to refresh OAuth tokens: token refresh failed: %w", inner) 44 + result := wrapPDSError(err) 45 + assert.True(t, errors.Is(result, ErrSessionExpired)) 46 + }) 47 + }
+54 -2
internal/handlers/auth.go
··· 106 106 Str("session_id", sessData.SessionID). 107 107 Msg("User logged in successfully") 108 108 109 - // Redirect to home page 110 - http.Redirect(w, r, "/", http.StatusFound) 109 + // Check for reauth return path 110 + redirectTo := "/" 111 + if cookie, err := r.Cookie("reauth_return"); err == nil && cookie.Value != "" { 112 + redirectTo = cookie.Value 113 + // Clear the cookie 114 + http.SetCookie(w, &http.Cookie{ 115 + Name: "reauth_return", Value: "", Path: "/", 116 + HttpOnly: true, Secure: h.config.SecureCookies, 117 + SameSite: http.SameSiteLaxMode, MaxAge: -1, 118 + }) 119 + } 120 + 121 + http.Redirect(w, r, redirectTo, http.StatusFound) 122 + } 123 + 124 + // HandleReauth clears the stale session and immediately restarts the OAuth 125 + // login flow so the user doesn't have to re-enter their handle. 126 + func (h *Handler) HandleReauth(w http.ResponseWriter, r *http.Request) { 127 + // Clear the stale session 128 + didCookie, err1 := r.Cookie("account_did") 129 + sessionCookie, err2 := r.Cookie("session_id") 130 + if err1 == nil && err2 == nil { 131 + if did, err := syntax.ParseDID(didCookie.Value); err == nil { 132 + if h.oauth != nil { 133 + if err := h.oauth.DeleteSession(r.Context(), did, sessionCookie.Value); err != nil { 134 + log.Warn().Err(err).Str("user_did", did.String()).Msg("Failed to delete session during reauth") 135 + } 136 + } 137 + } 138 + } 139 + 140 + // Clear session cookies 141 + http.SetCookie(w, &http.Cookie{ 142 + Name: "account_did", Value: "", Path: "/", 143 + HttpOnly: true, Secure: h.config.SecureCookies, 144 + SameSite: http.SameSiteLaxMode, MaxAge: -1, 145 + }) 146 + http.SetCookie(w, &http.Cookie{ 147 + Name: "session_id", Value: "", Path: "/", 148 + HttpOnly: true, Secure: h.config.SecureCookies, 149 + SameSite: http.SameSiteLaxMode, MaxAge: -1, 150 + }) 151 + 152 + // Set a short-lived cookie so the OAuth callback knows where to redirect 153 + if returnTo := r.FormValue("return_to"); returnTo != "" { 154 + http.SetCookie(w, &http.Cookie{ 155 + Name: "reauth_return", Value: returnTo, Path: "/", 156 + HttpOnly: true, Secure: h.config.SecureCookies, 157 + SameSite: http.SameSiteLaxMode, MaxAge: 300, // 5 minutes 158 + }) 159 + } 160 + 161 + // Delegate to the existing login flow 162 + h.HandleLoginSubmit(w, r) 111 163 } 112 164 113 165 // HandleLogout logs out the user
+2 -2
internal/handlers/brew.go
··· 85 85 86 86 brews, err := store.ListBrews(r.Context(), 1) // User ID is not used with atproto 87 87 if err != nil { 88 - http.Error(w, "Failed to fetch brews", http.StatusInternalServerError) 89 88 log.Error().Err(err).Msg("Failed to fetch brews") 89 + handleStoreError(w, err, "Failed to fetch brews") 90 90 return 91 91 } 92 92 ··· 686 686 687 687 brews, err := store.ListBrews(r.Context(), 1) // User ID is not used with atproto 688 688 if err != nil { 689 - http.Error(w, "Failed to fetch brews", http.StatusInternalServerError) 690 689 log.Error().Err(err).Msg("Failed to list brews for export") 690 + handleStoreError(w, err, "Failed to fetch brews") 691 691 return 692 692 } 693 693
+2 -2
internal/handlers/entities.go
··· 55 55 }) 56 56 57 57 if err := g.Wait(); err != nil { 58 - http.Error(w, "Failed to fetch data", http.StatusInternalServerError) 59 58 log.Error().Err(err).Msg("Failed to fetch manage page data") 59 + handleStoreError(w, err, "Failed to fetch data") 60 60 return 61 61 } 62 62 ··· 125 125 }) 126 126 127 127 if err := g.Wait(); err != nil { 128 - http.Error(w, "Failed to fetch data", http.StatusInternalServerError) 129 128 log.Error().Err(err).Msg("Failed to fetch all data for API") 129 + handleStoreError(w, err, "Failed to fetch data") 130 130 return 131 131 } 132 132
+3 -3
internal/handlers/feed.go
··· 179 179 // Check if user already liked this record 180 180 existingLike, err := store.GetUserLikeForSubject(r.Context(), subjectURI) 181 181 if err != nil { 182 - http.Error(w, "Failed to check like status", http.StatusInternalServerError) 183 182 log.Error().Err(err).Msg("Failed to check existing like") 183 + handleStoreError(w, err, "Failed to check like status") 184 184 return 185 185 } 186 186 ··· 190 190 if existingLike != nil { 191 191 // Unlike: delete the existing like 192 192 if err := store.DeleteLikeByRKey(r.Context(), existingLike.RKey); err != nil { 193 - http.Error(w, "Failed to unlike", http.StatusInternalServerError) 194 193 log.Error().Err(err).Msg("Failed to delete like") 194 + handleStoreError(w, err, "Failed to unlike") 195 195 return 196 196 } 197 197 isLiked = false ··· 213 213 } 214 214 like, err := store.CreateLike(r.Context(), req) 215 215 if err != nil { 216 - http.Error(w, "Failed to like", http.StatusInternalServerError) 217 216 log.Error().Err(err).Msg("Failed to create like") 217 + handleStoreError(w, err, "Failed to like") 218 218 return 219 219 } 220 220 isLiked = true
+2 -2
internal/handlers/handlers.go
··· 335 335 336 336 comment, err := store.CreateComment(r.Context(), req) 337 337 if err != nil { 338 - http.Error(w, "Failed to create comment", http.StatusInternalServerError) 339 338 log.Error().Err(err).Msg("Failed to create comment") 339 + handleStoreError(w, err, "Failed to create comment") 340 340 return 341 341 } 342 342 ··· 396 396 397 397 // Delete the comment from the user's PDS 398 398 if err := store.DeleteCommentByRKey(r.Context(), rkey); err != nil { 399 - http.Error(w, "Failed to delete comment", http.StatusInternalServerError) 400 399 log.Error().Err(err).Str("rkey", rkey).Str("did", didStr).Msg("Failed to delete comment from PDS") 400 + handleStoreError(w, err, "Failed to delete comment") 401 401 return 402 402 } 403 403
+1
internal/routing/routing.go
··· 32 32 mux.Handle("POST /auth/login", cop.Handler(http.HandlerFunc(h.HandleLoginSubmit))) 33 33 mux.HandleFunc("GET /oauth/callback", h.HandleOAuthCallback) 34 34 mux.Handle("POST /logout", cop.Handler(http.HandlerFunc(h.HandleLogout))) 35 + mux.Handle("POST /reauth", cop.Handler(http.HandlerFunc(h.HandleReauth))) 35 36 mux.HandleFunc("GET /client-metadata.json", h.HandleClientMetadata) 36 37 mux.HandleFunc("GET /.well-known/oauth-client-metadata", h.HandleWellKnownOAuth) 37 38
+4 -4
internal/web/components/dialog_modals.templ
··· 27 27 } 28 28 hx-trigger="submit" 29 29 hx-swap="none" 30 - hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { document.body.dispatchEvent(new CustomEvent('auth-expired', { bubbles: true })); this.closest('dialog').close(); }" 30 + hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); }" 31 31 class="space-y-4" 32 32 > 33 33 if bean == nil { ··· 189 189 } 190 190 hx-trigger="submit" 191 191 hx-swap="none" 192 - hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { document.body.dispatchEvent(new CustomEvent('auth-expired', { bubbles: true })); this.closest('dialog').close(); }" 192 + hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); }" 193 193 class="space-y-4" 194 194 > 195 195 if grinder == nil { ··· 312 312 } 313 313 hx-trigger="submit" 314 314 hx-swap="none" 315 - hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { document.body.dispatchEvent(new CustomEvent('auth-expired', { bubbles: true })); this.closest('dialog').close(); }" 315 + hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); }" 316 316 class="space-y-4" 317 317 > 318 318 if brewer == nil { ··· 409 409 } 410 410 hx-trigger="submit" 411 411 hx-swap="none" 412 - hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { document.body.dispatchEvent(new CustomEvent('auth-expired', { bubbles: true })); this.closest('dialog').close(); }" 412 + hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { this.closest('dialog').close(); window.__showSessionExpiredModal(); }" 413 413 class="space-y-4" 414 414 > 415 415 if roaster == nil {
+133 -28
internal/web/components/layout.templ
··· 81 81 </style> 82 82 <link rel="manifest" href="/static/manifest.json"/> 83 83 <script nonce={ data.CSPNonce }> 84 + // Show the session-expired modal and populate return path 85 + window.__showSessionExpiredModal = function() { 86 + var modal = document.getElementById('session-expired-modal'); 87 + if (modal && !modal.open) { 88 + var returnInput = document.getElementById('reauth-return-to'); 89 + if (returnInput) returnInput.value = window.location.pathname; 90 + modal.showModal(); 91 + } 92 + }; 93 + 94 + // Save form data to sessionStorage before re-auth redirect 95 + window.__saveFormBeforeReauth = function() { 96 + var form = document.querySelector('main form'); 97 + if (!form) return; 98 + var data = {}; 99 + var inputs = form.querySelectorAll('input, select, textarea'); 100 + for (var i = 0; i < inputs.length; i++) { 101 + var el = inputs[i]; 102 + if (!el.name || el.type === 'hidden' || el.type === 'submit' || el.type === 'button') continue; 103 + if (el.type === 'checkbox' || el.type === 'radio') { 104 + data[el.name] = el.checked; 105 + } else { 106 + data[el.name] = el.value; 107 + } 108 + } 109 + if (Object.keys(data).length > 0) { 110 + sessionStorage.setItem('arabica_form_restore', JSON.stringify({ 111 + path: window.location.pathname, 112 + data: data 113 + })); 114 + } 115 + }; 116 + 117 + // Wire up session-expired modal buttons (no inline handlers due to CSP) 118 + document.addEventListener('DOMContentLoaded', function() { 119 + var reauthForm = document.getElementById('reauth-form'); 120 + if (reauthForm) { 121 + reauthForm.addEventListener('submit', function() { 122 + window.__saveFormBeforeReauth(); 123 + }); 124 + } 125 + var dismissBtn = document.getElementById('session-expired-dismiss'); 126 + if (dismissBtn) { 127 + dismissBtn.addEventListener('click', function() { 128 + document.getElementById('session-expired-modal').close(); 129 + }); 130 + } 131 + }); 132 + 133 + // Restore saved form data after re-auth 134 + document.addEventListener('DOMContentLoaded', function() { 135 + var saved = sessionStorage.getItem('arabica_form_restore'); 136 + if (!saved) return; 137 + try { 138 + var parsed = JSON.parse(saved); 139 + if (parsed.path !== window.location.pathname) { 140 + sessionStorage.removeItem('arabica_form_restore'); 141 + return; 142 + } 143 + // Retry until the form and dropdown options are populated 144 + var attempts = 0; 145 + var maxAttempts = 30; // 30 x 500ms = 15 seconds max 146 + var interval = setInterval(function() { 147 + attempts++; 148 + var form = document.querySelector('main form'); 149 + if (!form) { 150 + if (attempts >= maxAttempts) clearInterval(interval); 151 + return; 152 + } 153 + // Wait until selects have options (dropdowns populated by Alpine) 154 + var selects = form.querySelectorAll('select'); 155 + var ready = true; 156 + for (var i = 0; i < selects.length; i++) { 157 + if (selects[i].options.length <= 1) { ready = false; break; } 158 + } 159 + if (!ready && attempts < maxAttempts) return; 160 + clearInterval(interval); 161 + // Restore values 162 + var formData = parsed.data; 163 + for (var key in formData) { 164 + var el = form.querySelector('[name="' + key + '"]'); 165 + if (!el) continue; 166 + if (el.type === 'checkbox' || el.type === 'radio') { 167 + el.checked = formData[key]; 168 + } else { 169 + el.value = formData[key]; 170 + el.dispatchEvent(new Event('change', { bubbles: true })); 171 + } 172 + } 173 + sessionStorage.removeItem('arabica_form_restore'); 174 + }, 500); 175 + } catch(e) { 176 + sessionStorage.removeItem('arabica_form_restore'); 177 + } 178 + }); 179 + 84 180 // Configure HTMX before it loads 85 181 document.addEventListener('DOMContentLoaded', function() { 86 182 if (typeof htmx !== 'undefined') { ··· 91 187 // Increase history cache size to prevent cache misses 92 188 htmx.config.historyCacheSize = 20; 93 189 94 - // Show session-expired banner on 401 responses 190 + // Show session-expired modal on 401 responses 95 191 document.body.addEventListener('htmx:afterRequest', function(evt) { 96 192 if (evt.detail.xhr && evt.detail.xhr.status === 401) { 97 - document.body.dispatchEvent(new CustomEvent('auth-expired', { bubbles: true })); 193 + window.__showSessionExpiredModal(); 98 194 } 99 195 }); 100 196 ··· 141 237 data-user-did={ data.UserDID } 142 238 } 143 239 > 144 - <!-- Session expired banner --> 145 - <div 146 - x-data="{ show: false }" 147 - x-on:auth-expired.window="show = true" 148 - x-show="show" 149 - x-cloak 150 - x-transition:enter="transition ease-out duration-300" 151 - x-transition:enter-start="opacity-0 -translate-y-2" 152 - x-transition:enter-end="opacity-100 translate-y-0" 153 - class="fixed top-0 inset-x-0 z-50 bg-amber-100 border-b border-amber-400 text-amber-900 px-4 py-3 flex items-center justify-between gap-4 shadow-md" 154 - role="alert" 155 - > 156 - <p class="text-sm font-medium"> 157 - Your session has expired. 158 - <a href="/login" class="underline underline-offset-2 hover:text-amber-700 font-semibold ml-1">Log in again</a> 159 - </p> 160 - <button 161 - @click="show = false" 162 - class="shrink-0 text-amber-700 hover:text-amber-900 transition-colors" 163 - aria-label="Dismiss" 164 - > 165 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 166 - <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"></path> 167 - </svg> 168 - </button> 169 - </div> 240 + <!-- Session expired modal --> 241 + <dialog id="session-expired-modal" class="modal-dialog"> 242 + <div class="modal-content text-center"> 243 + <div class="mb-4"> 244 + <svg class="w-12 h-12 mx-auto text-amber-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 245 + <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"></path> 246 + </svg> 247 + </div> 248 + <h3 class="modal-title text-center">Session Expired</h3> 249 + <p class="text-brown-700 text-sm mb-6"> 250 + Your login session has expired. Log back in to continue where you left off. 251 + </p> 252 + <div class="flex flex-col gap-3"> 253 + <form id="reauth-form" method="POST" action="/reauth"> 254 + if data.UserProfile != nil && data.UserProfile.Handle != "" { 255 + <input type="hidden" name="handle" value={ data.UserProfile.Handle }/> 256 + } 257 + <input type="hidden" name="return_to" id="reauth-return-to"/> 258 + <button 259 + type="submit" 260 + class="btn-primary w-full" 261 + > 262 + Log In Again 263 + </button> 264 + </form> 265 + <button 266 + type="button" 267 + id="session-expired-dismiss" 268 + class="btn-secondary w-full" 269 + > 270 + Dismiss 271 + </button> 272 + </div> 273 + </div> 274 + </dialog> 170 275 @HeaderWithProps(HeaderProps{ 171 276 IsAuthenticated: data.IsAuthenticated, 172 277 UserProfile: data.UserProfile,
+2 -2
static/js/entity-manager.js
··· 98 98 99 99 if (!response.ok) { 100 100 if (response.status === 401) { 101 - document.body.dispatchEvent(new CustomEvent('auth-expired', { bubbles: true })); 101 + window.__showSessionExpiredModal(); 102 102 return; 103 103 } 104 104 const errorText = await response.text(); ··· 153 153 154 154 if (!response.ok) { 155 155 if (response.status === 401) { 156 - document.body.dispatchEvent(new CustomEvent('auth-expired', { bubbles: true })); 156 + window.__showSessionExpiredModal(); 157 157 return false; 158 158 } 159 159 const errorText = await response.text();