Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

bug fixes

+75 -33
+31 -23
backend/internal/api/annotations.go
··· 165 165 return createErr 166 166 }) 167 167 if err != nil { 168 - http.Error(w, "Failed to create annotation: "+err.Error(), http.StatusInternalServerError) 168 + HandleAPIError(w, r, err, "Failed to create annotation: ", http.StatusInternalServerError) 169 169 return 170 170 } 171 171 ··· 251 251 return 252 252 } 253 253 254 + did := session.DID 255 + 254 256 collection := xrpc.CollectionAnnotation 255 257 if collectionType == "reply" { 256 258 collection = xrpc.CollectionReply 259 + } else { 260 + candidateCollections := []string{xrpc.CollectionAnnotation, "network.cosmik.card"} 261 + for _, col := range candidateCollections { 262 + uri := "at://" + did + "/" + col + "/" + rkey 263 + if _, dbErr := s.db.GetAnnotationByURI(uri); dbErr == nil { 264 + collection = col 265 + break 266 + } 267 + } 257 268 } 258 269 259 - err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 270 + pdsErr := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 260 271 return client.DeleteRecord(r.Context(), did, collection, rkey) 261 272 }) 262 - if err != nil { 263 - http.Error(w, "Failed to delete record: "+err.Error(), http.StatusInternalServerError) 264 - return 273 + if pdsErr != nil { 274 + log.Printf("PDS delete failed (will still clean local DB): %v", pdsErr) 265 275 } 266 276 267 - did := session.DID 277 + // Always clean up local DB regardless of PDS result 268 278 if collectionType == "reply" { 269 279 uri := "at://" + did + "/" + xrpc.CollectionReply + "/" + rkey 270 280 s.db.DeleteReply(uri) 271 281 } else { 272 - uri := "at://" + did + "/" + xrpc.CollectionAnnotation + "/" + rkey 282 + uri := "at://" + did + "/" + collection + "/" + rkey 273 283 s.db.DeleteAnnotation(uri) 274 284 } 275 285 ··· 385 395 386 396 if err != nil { 387 397 log.Printf("[UpdateAnnotation] Failed: %v", err) 388 - http.Error(w, "Failed to update record: "+err.Error(), http.StatusInternalServerError) 398 + HandleAPIError(w, r, err, "Failed to update record: ", http.StatusInternalServerError) 389 399 return 390 400 } 391 401 ··· 462 472 return createErr 463 473 }) 464 474 if err != nil { 465 - http.Error(w, "Failed to create like: "+err.Error(), http.StatusInternalServerError) 475 + HandleAPIError(w, r, err, "Failed to create like: ", http.StatusInternalServerError) 466 476 return 467 477 } 468 478 ··· 516 526 return client.DeleteRecord(r.Context(), did, xrpc.CollectionLike, rkey) 517 527 }) 518 528 if err != nil { 519 - http.Error(w, "Failed to delete like: "+err.Error(), http.StatusInternalServerError) 529 + HandleAPIError(w, r, err, "Failed to delete like: ", http.StatusInternalServerError) 520 530 return 521 531 } 522 532 ··· 574 584 return createErr 575 585 }) 576 586 if err != nil { 577 - http.Error(w, "Failed to create reply: "+err.Error(), http.StatusInternalServerError) 587 + HandleAPIError(w, r, err, "Failed to create reply: ", http.StatusInternalServerError) 578 588 return 579 589 } 580 590 ··· 700 710 return createErr 701 711 }) 702 712 if err != nil { 703 - http.Error(w, "Failed to create highlight: "+err.Error(), http.StatusInternalServerError) 713 + HandleAPIError(w, r, err, "Failed to create highlight: ", http.StatusInternalServerError) 704 714 return 705 715 } 706 716 ··· 805 815 return createErr 806 816 }) 807 817 if err != nil { 808 - http.Error(w, "Failed to create bookmark: "+err.Error(), http.StatusInternalServerError) 818 + HandleAPIError(w, r, err, "Failed to create bookmark: ", http.StatusInternalServerError) 809 819 return 810 820 } 811 821 ··· 857 867 return 858 868 } 859 869 860 - err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 870 + pdsErr := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 861 871 return client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) 862 872 }) 863 - if err != nil { 864 - http.Error(w, "Failed to delete highlight: "+err.Error(), http.StatusInternalServerError) 865 - return 873 + if pdsErr != nil { 874 + log.Printf("PDS delete highlight failed (will still clean local DB): %v", pdsErr) 866 875 } 867 876 868 877 uri := "at://" + session.DID + "/" + xrpc.CollectionHighlight + "/" + rkey ··· 885 894 return 886 895 } 887 896 888 - err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 897 + pdsErr := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 889 898 return client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) 890 899 }) 891 - if err != nil { 892 - http.Error(w, "Failed to delete bookmark: "+err.Error(), http.StatusInternalServerError) 893 - return 900 + if pdsErr != nil { 901 + log.Printf("PDS delete bookmark failed (will still clean local DB): %v", pdsErr) 894 902 } 895 903 896 904 uri := "at://" + session.DID + "/" + xrpc.CollectionBookmark + "/" + rkey ··· 978 986 }) 979 987 980 988 if err != nil { 981 - http.Error(w, "Failed to update: "+err.Error(), http.StatusInternalServerError) 989 + HandleAPIError(w, r, err, "Failed to update: ", http.StatusInternalServerError) 982 990 return 983 991 } 984 992 ··· 1086 1094 }) 1087 1095 1088 1096 if err != nil { 1089 - http.Error(w, "Failed to update: "+err.Error(), http.StatusInternalServerError) 1097 + HandleAPIError(w, r, err, "Failed to update: ", http.StatusInternalServerError) 1090 1098 return 1091 1099 } 1092 1100
+1 -1
backend/internal/api/collections.go
··· 439 439 return client.DeleteRecord(r.Context(), did, xrpc.CollectionCollection, rkey) 440 440 }) 441 441 if err != nil { 442 - http.Error(w, "Failed to delete collection: "+err.Error(), http.StatusInternalServerError) 442 + HandleAPIError(w, r, err, "Failed to delete collection: ", http.StatusInternalServerError) 443 443 return 444 444 } 445 445
+1 -1
backend/internal/api/profile.go
··· 60 60 }) 61 61 62 62 if err != nil { 63 - http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError) 63 + HandleAPIError(w, r, err, "Failed to update profile: ", http.StatusInternalServerError) 64 64 return 65 65 } 66 66
+29 -4
backend/internal/api/token_refresh.go
··· 6 6 "crypto/ecdsa" 7 7 "crypto/x509" 8 8 "encoding/pem" 9 + "errors" 9 10 "fmt" 10 11 "log" 11 12 "net/http" ··· 16 17 "margin.at/internal/oauth" 17 18 "margin.at/internal/xrpc" 18 19 ) 20 + 21 + var ErrSessionInvalid = errors.New("session invalid") 19 22 20 23 type TokenRefresher struct { 21 24 db *db.DB ··· 77 80 78 81 did, handle, accessToken, refreshToken, dpopKeyStr, err := tr.db.GetSession(sessionID) 79 82 if err != nil { 80 - return nil, fmt.Errorf("session expired") 83 + tr.db.DeleteSession(sessionID) 84 + return nil, fmt.Errorf("%w: session expired", ErrSessionInvalid) 81 85 } 82 86 83 87 block, _ := pem.Decode([]byte(dpopKeyStr)) 84 88 if block == nil { 85 - return nil, fmt.Errorf("invalid session DPoP key") 89 + tr.db.DeleteSession(sessionID) 90 + return nil, fmt.Errorf("%w: invalid DPoP key", ErrSessionInvalid) 86 91 } 87 92 dpopKey, err := x509.ParseECPrivateKey(block.Bytes) 88 93 if err != nil { 89 - return nil, fmt.Errorf("invalid session DPoP key") 94 + tr.db.DeleteSession(sessionID) 95 + return nil, fmt.Errorf("%w: invalid DPoP key", ErrSessionInvalid) 90 96 } 91 97 92 98 pds, err := xrpc.ResolveDIDToPDS(did) ··· 192 198 193 199 newSession, refreshErr := tr.RefreshSessionToken(r, session) 194 200 if refreshErr != nil { 195 - return fmt.Errorf("original error: %w; refresh failed: %v", err, refreshErr) 201 + log.Printf("Token refresh failed for user %s, invalidating session: %v", session.Handle, refreshErr) 202 + tr.db.DeleteSession(session.ID) 203 + return fmt.Errorf("%w: %v", ErrSessionInvalid, refreshErr) 196 204 } 197 205 198 206 client = xrpc.NewClient(newSession.PDS, newSession.AccessToken, newSession.DPoPKey) ··· 202 210 func (tr *TokenRefresher) CreateClientFromSession(session *SessionData) *xrpc.Client { 203 211 return xrpc.NewClient(session.PDS, session.AccessToken, session.DPoPKey) 204 212 } 213 + 214 + func HandleAPIError(w http.ResponseWriter, r *http.Request, err error, fallbackMsg string, fallbackStatus int) { 215 + if errors.Is(err, ErrSessionInvalid) { 216 + http.SetCookie(w, &http.Cookie{ 217 + Name: "margin_session", 218 + Value: "", 219 + Path: "/", 220 + HttpOnly: true, 221 + Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", 222 + SameSite: http.SameSiteLaxMode, 223 + MaxAge: -1, 224 + }) 225 + http.Error(w, "session expired", http.StatusUnauthorized) 226 + return 227 + } 228 + http.Error(w, fallbackMsg+err.Error(), fallbackStatus) 229 + }
+13 -4
web/src/api/client.ts
··· 106 106 107 107 if (response.status === 401 && !skipAuthRedirect) { 108 108 sessionAtom.set(null); 109 + try { 110 + await fetch("/auth/logout", { method: "POST" }); 111 + } catch { 112 + // Ignore 113 + } 109 114 if (window.location.pathname !== "/login") { 110 115 window.location.href = "/login"; 111 116 } ··· 166 171 return { 167 172 ...normalizedInner, 168 173 uri: normalizedInner.uri || raw.uri || "", 169 - cid: raw.cid || "", 174 + cid: normalizedInner.cid || raw.cid || "", 170 175 author: (normalizedInner.author || 171 176 raw.author || 172 177 raw.creator) as UserProfile, ··· 459 464 460 465 export async function deleteItem( 461 466 uri: string, 462 - _type: string = "annotation", 467 + type: string = "annotation", 463 468 ): Promise<boolean> { 464 469 const rkey = (uri || "").split("/").pop(); 470 + 465 471 let endpoint = "/api/annotations"; 466 - if (uri.includes("highlight")) endpoint = "/api/highlights"; 467 - if (uri.includes("bookmark")) endpoint = "/api/bookmarks"; 472 + if (type === "highlight" || uri.includes("highlight")) { 473 + endpoint = "/api/highlights"; 474 + } else if (type === "bookmark" || uri.includes("bookmark")) { 475 + endpoint = "/api/bookmarks"; 476 + } 468 477 469 478 try { 470 479 const res = await apiRequest(`${endpoint}?rkey=${rkey}`, {