tangled
alpha
login
or
join now
margin.at
/
margin
86
fork
atom
Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
86
fork
atom
overview
issues
4
pulls
1
pipelines
bug fixes
scanash.com
3 weeks ago
b926e092
9f615d44
+75
-33
5 changed files
expand all
collapse all
unified
split
backend
internal
api
annotations.go
collections.go
profile.go
token_refresh.go
web
src
api
client.ts
+31
-23
backend/internal/api/annotations.go
···
165
165
return createErr
166
166
})
167
167
if err != nil {
168
168
-
http.Error(w, "Failed to create annotation: "+err.Error(), http.StatusInternalServerError)
168
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
254
+
did := session.DID
255
255
+
254
256
collection := xrpc.CollectionAnnotation
255
257
if collectionType == "reply" {
256
258
collection = xrpc.CollectionReply
259
259
+
} else {
260
260
+
candidateCollections := []string{xrpc.CollectionAnnotation, "network.cosmik.card"}
261
261
+
for _, col := range candidateCollections {
262
262
+
uri := "at://" + did + "/" + col + "/" + rkey
263
263
+
if _, dbErr := s.db.GetAnnotationByURI(uri); dbErr == nil {
264
264
+
collection = col
265
265
+
break
266
266
+
}
267
267
+
}
257
268
}
258
269
259
259
-
err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
270
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
262
-
if err != nil {
263
263
-
http.Error(w, "Failed to delete record: "+err.Error(), http.StatusInternalServerError)
264
264
-
return
273
273
+
if pdsErr != nil {
274
274
+
log.Printf("PDS delete failed (will still clean local DB): %v", pdsErr)
265
275
}
266
276
267
267
-
did := session.DID
277
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
272
-
uri := "at://" + did + "/" + xrpc.CollectionAnnotation + "/" + rkey
282
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
388
-
http.Error(w, "Failed to update record: "+err.Error(), http.StatusInternalServerError)
398
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
465
-
http.Error(w, "Failed to create like: "+err.Error(), http.StatusInternalServerError)
475
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
519
-
http.Error(w, "Failed to delete like: "+err.Error(), http.StatusInternalServerError)
529
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
577
-
http.Error(w, "Failed to create reply: "+err.Error(), http.StatusInternalServerError)
587
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
703
-
http.Error(w, "Failed to create highlight: "+err.Error(), http.StatusInternalServerError)
713
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
808
-
http.Error(w, "Failed to create bookmark: "+err.Error(), http.StatusInternalServerError)
818
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
860
-
err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
870
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
863
-
if err != nil {
864
864
-
http.Error(w, "Failed to delete highlight: "+err.Error(), http.StatusInternalServerError)
865
865
-
return
873
873
+
if pdsErr != nil {
874
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
888
-
err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
897
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
891
-
if err != nil {
892
892
-
http.Error(w, "Failed to delete bookmark: "+err.Error(), http.StatusInternalServerError)
893
893
-
return
900
900
+
if pdsErr != nil {
901
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
981
-
http.Error(w, "Failed to update: "+err.Error(), http.StatusInternalServerError)
989
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
1089
-
http.Error(w, "Failed to update: "+err.Error(), http.StatusInternalServerError)
1097
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
442
-
http.Error(w, "Failed to delete collection: "+err.Error(), http.StatusInternalServerError)
442
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
63
-
http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError)
63
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
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
20
+
21
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
80
-
return nil, fmt.Errorf("session expired")
83
83
+
tr.db.DeleteSession(sessionID)
84
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
85
-
return nil, fmt.Errorf("invalid session DPoP key")
89
89
+
tr.db.DeleteSession(sessionID)
90
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
89
-
return nil, fmt.Errorf("invalid session DPoP key")
94
94
+
tr.db.DeleteSession(sessionID)
95
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
195
-
return fmt.Errorf("original error: %w; refresh failed: %v", err, refreshErr)
201
201
+
log.Printf("Token refresh failed for user %s, invalidating session: %v", session.Handle, refreshErr)
202
202
+
tr.db.DeleteSession(session.ID)
203
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
213
+
214
214
+
func HandleAPIError(w http.ResponseWriter, r *http.Request, err error, fallbackMsg string, fallbackStatus int) {
215
215
+
if errors.Is(err, ErrSessionInvalid) {
216
216
+
http.SetCookie(w, &http.Cookie{
217
217
+
Name: "margin_session",
218
218
+
Value: "",
219
219
+
Path: "/",
220
220
+
HttpOnly: true,
221
221
+
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
222
222
+
SameSite: http.SameSiteLaxMode,
223
223
+
MaxAge: -1,
224
224
+
})
225
225
+
http.Error(w, "session expired", http.StatusUnauthorized)
226
226
+
return
227
227
+
}
228
228
+
http.Error(w, fallbackMsg+err.Error(), fallbackStatus)
229
229
+
}
+13
-4
web/src/api/client.ts
···
106
106
107
107
if (response.status === 401 && !skipAuthRedirect) {
108
108
sessionAtom.set(null);
109
109
+
try {
110
110
+
await fetch("/auth/logout", { method: "POST" });
111
111
+
} catch {
112
112
+
// Ignore
113
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
169
-
cid: raw.cid || "",
174
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
462
-
_type: string = "annotation",
467
467
+
type: string = "annotation",
463
468
): Promise<boolean> {
464
469
const rkey = (uri || "").split("/").pop();
470
470
+
465
471
let endpoint = "/api/annotations";
466
466
-
if (uri.includes("highlight")) endpoint = "/api/highlights";
467
467
-
if (uri.includes("bookmark")) endpoint = "/api/bookmarks";
472
472
+
if (type === "highlight" || uri.includes("highlight")) {
473
473
+
endpoint = "/api/highlights";
474
474
+
} else if (type === "bookmark" || uri.includes("bookmark")) {
475
475
+
endpoint = "/api/bookmarks";
476
476
+
}
468
477
469
478
try {
470
479
const res = await apiRequest(`${endpoint}?rkey=${rkey}`, {