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

Implement API keys for iOS shortcut, refactor Firehose ingestion to use WebSockets and CBOR, and fix mobile styles.

+1321 -99
+16 -1
backend/go.mod
··· 3 3 go 1.24.0 4 4 5 5 require ( 6 + github.com/fxamacker/cbor/v2 v2.9.0 6 7 github.com/go-chi/chi/v5 v5.1.0 7 8 github.com/go-chi/cors v1.2.1 8 9 github.com/go-jose/go-jose/v4 v4.0.4 10 + github.com/gorilla/websocket v1.5.3 11 + github.com/ipfs/go-cid v0.6.0 9 12 github.com/joho/godotenv v1.5.1 10 13 github.com/lib/pq v1.10.9 11 14 github.com/mattn/go-sqlite3 v1.14.22 ··· 14 17 15 18 require ( 16 19 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 20 + github.com/klauspost/cpuid/v2 v2.0.9 // indirect 21 + github.com/minio/sha256-simd v1.0.0 // indirect 22 + github.com/mr-tron/base58 v1.2.0 // indirect 23 + github.com/multiformats/go-base32 v0.0.3 // indirect 24 + github.com/multiformats/go-base36 v0.1.0 // indirect 25 + github.com/multiformats/go-multibase v0.2.0 // indirect 26 + github.com/multiformats/go-multihash v0.2.3 // indirect 27 + github.com/multiformats/go-varint v0.1.0 // indirect 17 28 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 29 + github.com/spaolacci/murmur3 v1.1.0 // indirect 18 30 github.com/stretchr/testify v1.10.0 // indirect 19 - golang.org/x/crypto v0.31.0 // indirect 31 + github.com/x448/float16 v0.8.4 // indirect 32 + golang.org/x/crypto v0.35.0 // indirect 33 + golang.org/x/sys v0.30.0 // indirect 20 34 golang.org/x/text v0.32.0 // indirect 35 + lukechampine.com/blake3 v1.1.6 // indirect 21 36 )
+33 -2
backend/go.sum
··· 1 1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 2 2 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 + github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= 4 + github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 3 5 github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 4 6 github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 5 7 github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= ··· 8 10 github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= 9 11 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 10 12 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 + github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 14 + github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 15 + github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= 16 + github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= 11 17 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 12 18 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 19 + github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 20 + github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= 21 + github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 13 22 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 14 23 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 15 24 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 16 25 github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 26 + github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= 27 + github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= 28 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 29 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 30 + github.com/multiformats/go-base32 v0.0.3 h1:tw5+NhuwaOjJCC5Pp82QuXbrmLzWg7uxlMFp8Nq/kkI= 31 + github.com/multiformats/go-base32 v0.0.3/go.mod h1:pLiuGC8y0QR3Ue4Zug5UzK9LjgbkL8NSQj0zQ5Nz/AA= 32 + github.com/multiformats/go-base36 v0.1.0 h1:JR6TyF7JjGd3m6FbLU2cOxhC0Li8z8dLNGQ89tUg4F4= 33 + github.com/multiformats/go-base36 v0.1.0/go.mod h1:kFGE83c6s80PklsHO9sRn2NCoffoRdUUOENyW/Vv6sM= 34 + github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 35 + github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 36 + github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 37 + github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 38 + github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= 39 + github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= 17 40 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 18 41 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 42 + github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 43 + github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 19 44 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 20 45 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 21 - golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 22 - golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 46 + github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 47 + github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 48 + golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 49 + golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 23 50 golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= 24 51 golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= 52 + golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 53 + golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 25 54 golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 26 55 golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 27 56 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 28 57 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 58 + lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= 59 + lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
+342
backend/internal/api/apikey.go
··· 1 + package api 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/sha256" 6 + "crypto/x509" 7 + "encoding/hex" 8 + "encoding/json" 9 + "encoding/pem" 10 + "fmt" 11 + "net/http" 12 + "strings" 13 + "time" 14 + 15 + "github.com/go-chi/chi/v5" 16 + 17 + "margin.at/internal/db" 18 + "margin.at/internal/xrpc" 19 + ) 20 + 21 + type APIKeyHandler struct { 22 + db *db.DB 23 + refresher *TokenRefresher 24 + } 25 + 26 + func NewAPIKeyHandler(database *db.DB, refresher *TokenRefresher) *APIKeyHandler { 27 + return &APIKeyHandler{db: database, refresher: refresher} 28 + } 29 + 30 + type CreateKeyRequest struct { 31 + Name string `json:"name"` 32 + } 33 + 34 + type CreateKeyResponse struct { 35 + ID string `json:"id"` 36 + Name string `json:"name"` 37 + Key string `json:"key"` 38 + CreatedAt time.Time `json:"createdAt"` 39 + } 40 + 41 + func (h *APIKeyHandler) CreateKey(w http.ResponseWriter, r *http.Request) { 42 + session, err := h.refresher.GetSessionWithAutoRefresh(r) 43 + if err != nil { 44 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 45 + return 46 + } 47 + 48 + var req CreateKeyRequest 49 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 50 + http.Error(w, "Invalid request body", http.StatusBadRequest) 51 + return 52 + } 53 + 54 + if req.Name == "" { 55 + req.Name = "API Key" 56 + } 57 + 58 + rawKey := generateAPIKey() 59 + keyHash := hashAPIKey(rawKey) 60 + keyID := generateKeyID() 61 + 62 + apiKey := &db.APIKey{ 63 + ID: keyID, 64 + OwnerDID: session.DID, 65 + Name: req.Name, 66 + KeyHash: keyHash, 67 + CreatedAt: time.Now(), 68 + } 69 + 70 + if err := h.db.CreateAPIKey(apiKey); err != nil { 71 + http.Error(w, "Failed to create key", http.StatusInternalServerError) 72 + return 73 + } 74 + 75 + w.Header().Set("Content-Type", "application/json") 76 + json.NewEncoder(w).Encode(CreateKeyResponse{ 77 + ID: keyID, 78 + Name: req.Name, 79 + Key: rawKey, 80 + CreatedAt: apiKey.CreatedAt, 81 + }) 82 + } 83 + 84 + func (h *APIKeyHandler) ListKeys(w http.ResponseWriter, r *http.Request) { 85 + session, err := h.refresher.GetSessionWithAutoRefresh(r) 86 + if err != nil { 87 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 88 + return 89 + } 90 + 91 + keys, err := h.db.GetAPIKeysByOwner(session.DID) 92 + if err != nil { 93 + http.Error(w, "Failed to get keys", http.StatusInternalServerError) 94 + return 95 + } 96 + 97 + if keys == nil { 98 + keys = []db.APIKey{} 99 + } 100 + 101 + w.Header().Set("Content-Type", "application/json") 102 + json.NewEncoder(w).Encode(map[string]interface{}{"keys": keys}) 103 + } 104 + 105 + func (h *APIKeyHandler) DeleteKey(w http.ResponseWriter, r *http.Request) { 106 + session, err := h.refresher.GetSessionWithAutoRefresh(r) 107 + if err != nil { 108 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 109 + return 110 + } 111 + 112 + keyID := chi.URLParam(r, "id") 113 + if keyID == "" { 114 + http.Error(w, "Key ID required", http.StatusBadRequest) 115 + return 116 + } 117 + 118 + if err := h.db.DeleteAPIKey(keyID, session.DID); err != nil { 119 + http.Error(w, "Failed to delete key", http.StatusInternalServerError) 120 + return 121 + } 122 + 123 + w.Header().Set("Content-Type", "application/json") 124 + json.NewEncoder(w).Encode(map[string]bool{"success": true}) 125 + } 126 + 127 + type QuickBookmarkRequest struct { 128 + URL string `json:"url"` 129 + Title string `json:"title,omitempty"` 130 + Description string `json:"description,omitempty"` 131 + } 132 + 133 + func (h *APIKeyHandler) QuickBookmark(w http.ResponseWriter, r *http.Request) { 134 + apiKey, err := h.authenticateAPIKey(r) 135 + if err != nil { 136 + http.Error(w, err.Error(), http.StatusUnauthorized) 137 + return 138 + } 139 + 140 + var req QuickBookmarkRequest 141 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 142 + http.Error(w, "Invalid request body", http.StatusBadRequest) 143 + return 144 + } 145 + 146 + if req.URL == "" { 147 + http.Error(w, "URL is required", http.StatusBadRequest) 148 + return 149 + } 150 + 151 + session, err := h.getSessionByDID(apiKey.OwnerDID) 152 + if err != nil { 153 + http.Error(w, "User session not found. Please log in to margin.at first.", http.StatusUnauthorized) 154 + return 155 + } 156 + 157 + urlHash := db.HashURL(req.URL) 158 + record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) 159 + 160 + client := h.refresher.CreateClientFromSession(session) 161 + result, err := client.CreateRecord(r.Context(), session.DID, xrpc.CollectionBookmark, record) 162 + if err != nil { 163 + http.Error(w, "Failed to create bookmark: "+err.Error(), http.StatusInternalServerError) 164 + return 165 + } 166 + 167 + h.db.UpdateAPIKeyLastUsed(apiKey.ID) 168 + 169 + var titlePtr, descPtr *string 170 + if req.Title != "" { 171 + titlePtr = &req.Title 172 + } 173 + if req.Description != "" { 174 + descPtr = &req.Description 175 + } 176 + 177 + cid := result.CID 178 + bookmark := &db.Bookmark{ 179 + URI: result.URI, 180 + AuthorDID: apiKey.OwnerDID, 181 + Source: req.URL, 182 + SourceHash: urlHash, 183 + Title: titlePtr, 184 + Description: descPtr, 185 + CreatedAt: time.Now(), 186 + IndexedAt: time.Now(), 187 + CID: &cid, 188 + } 189 + h.db.CreateBookmark(bookmark) 190 + 191 + w.Header().Set("Content-Type", "application/json") 192 + json.NewEncoder(w).Encode(map[string]string{ 193 + "uri": result.URI, 194 + "cid": result.CID, 195 + "message": "Bookmark created successfully", 196 + }) 197 + } 198 + 199 + type QuickAnnotationRequest struct { 200 + URL string `json:"url"` 201 + Text string `json:"text"` 202 + } 203 + 204 + func (h *APIKeyHandler) QuickAnnotation(w http.ResponseWriter, r *http.Request) { 205 + apiKey, err := h.authenticateAPIKey(r) 206 + if err != nil { 207 + http.Error(w, err.Error(), http.StatusUnauthorized) 208 + return 209 + } 210 + 211 + var req QuickAnnotationRequest 212 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 213 + http.Error(w, "Invalid request body", http.StatusBadRequest) 214 + return 215 + } 216 + 217 + if req.URL == "" || req.Text == "" { 218 + http.Error(w, "URL and text are required", http.StatusBadRequest) 219 + return 220 + } 221 + 222 + session, err := h.getSessionByDID(apiKey.OwnerDID) 223 + if err != nil { 224 + http.Error(w, "User session not found. Please log in to margin.at first.", http.StatusUnauthorized) 225 + return 226 + } 227 + 228 + urlHash := db.HashURL(req.URL) 229 + record := xrpc.NewAnnotationRecord(req.URL, urlHash, req.Text, nil, "") 230 + 231 + client := h.refresher.CreateClientFromSession(session) 232 + result, err := client.CreateRecord(r.Context(), session.DID, xrpc.CollectionAnnotation, record) 233 + if err != nil { 234 + http.Error(w, "Failed to create annotation: "+err.Error(), http.StatusInternalServerError) 235 + return 236 + } 237 + 238 + h.db.UpdateAPIKeyLastUsed(apiKey.ID) 239 + 240 + bodyValue := req.Text 241 + annotation := &db.Annotation{ 242 + URI: result.URI, 243 + AuthorDID: apiKey.OwnerDID, 244 + Motivation: "commenting", 245 + BodyValue: &bodyValue, 246 + TargetSource: req.URL, 247 + TargetHash: urlHash, 248 + CreatedAt: time.Now(), 249 + IndexedAt: time.Now(), 250 + CID: &result.CID, 251 + } 252 + h.db.CreateAnnotation(annotation) 253 + 254 + w.Header().Set("Content-Type", "application/json") 255 + json.NewEncoder(w).Encode(map[string]string{ 256 + "uri": result.URI, 257 + "cid": result.CID, 258 + "message": "Annotation created successfully", 259 + }) 260 + } 261 + 262 + func (h *APIKeyHandler) authenticateAPIKey(r *http.Request) (*db.APIKey, error) { 263 + auth := r.Header.Get("Authorization") 264 + if auth == "" { 265 + return nil, fmt.Errorf("missing Authorization header") 266 + } 267 + 268 + if !strings.HasPrefix(auth, "Bearer ") { 269 + return nil, fmt.Errorf("invalid Authorization format, expected 'Bearer <key>'") 270 + } 271 + 272 + rawKey := strings.TrimPrefix(auth, "Bearer ") 273 + keyHash := hashAPIKey(rawKey) 274 + 275 + apiKey, err := h.db.GetAPIKeyByHash(keyHash) 276 + if err != nil { 277 + return nil, fmt.Errorf("invalid API key") 278 + } 279 + 280 + return apiKey, nil 281 + } 282 + 283 + func (h *APIKeyHandler) getSessionByDID(did string) (*SessionData, error) { 284 + rows, err := h.db.Query(h.db.Rebind(` 285 + SELECT id, did, handle, access_token, refresh_token, COALESCE(dpop_key, '') 286 + FROM sessions 287 + WHERE did = ? AND expires_at > ? 288 + ORDER BY created_at DESC 289 + LIMIT 1 290 + `), did, time.Now()) 291 + if err != nil { 292 + return nil, err 293 + } 294 + defer rows.Close() 295 + 296 + if !rows.Next() { 297 + return nil, fmt.Errorf("no active session") 298 + } 299 + 300 + var sessionID, sessDID, handle, accessToken, refreshToken, dpopKeyStr string 301 + if err := rows.Scan(&sessionID, &sessDID, &handle, &accessToken, &refreshToken, &dpopKeyStr); err != nil { 302 + return nil, err 303 + } 304 + 305 + block, _ := pem.Decode([]byte(dpopKeyStr)) 306 + if block == nil { 307 + return nil, fmt.Errorf("invalid session DPoP key") 308 + } 309 + dpopKey, err := x509.ParseECPrivateKey(block.Bytes) 310 + if err != nil { 311 + return nil, fmt.Errorf("invalid session DPoP key: %w", err) 312 + } 313 + 314 + pds, _ := resolveDIDToPDS(sessDID) 315 + 316 + return &SessionData{ 317 + DID: sessDID, 318 + Handle: handle, 319 + AccessToken: accessToken, 320 + RefreshToken: refreshToken, 321 + DPoPKey: dpopKey, 322 + PDS: pds, 323 + }, nil 324 + } 325 + 326 + func generateAPIKey() string { 327 + b := make([]byte, 32) 328 + rand.Read(b) 329 + return "mk_" + hex.EncodeToString(b) 330 + } 331 + 332 + func generateKeyID() string { 333 + b := make([]byte, 16) 334 + rand.Read(b) 335 + return hex.EncodeToString(b) 336 + } 337 + 338 + func hashAPIKey(key string) string { 339 + h := sha256.New() 340 + h.Write([]byte(key)) 341 + return hex.EncodeToString(h.Sum(nil)) 342 + }
+117
backend/internal/api/avatar.go
··· 1 + package api 2 + 3 + import ( 4 + "encoding/json" 5 + "io" 6 + "net/http" 7 + "net/url" 8 + "os" 9 + "sync" 10 + "time" 11 + 12 + "github.com/go-chi/chi/v5" 13 + ) 14 + 15 + type avatarCache struct { 16 + url string 17 + fetchedAt time.Time 18 + } 19 + 20 + var ( 21 + avatarCacheMu sync.RWMutex 22 + avatarCacheMap = make(map[string]avatarCache) 23 + avatarCacheTTL = 5 * time.Minute 24 + ) 25 + 26 + func (h *Handler) HandleAvatarProxy(w http.ResponseWriter, r *http.Request) { 27 + did := chi.URLParam(r, "did") 28 + if did == "" { 29 + http.Error(w, "DID required", http.StatusBadRequest) 30 + return 31 + } 32 + 33 + if decoded, err := url.QueryUnescape(did); err == nil { 34 + did = decoded 35 + } 36 + 37 + avatarURL := getAvatarURL(did) 38 + if avatarURL == "" { 39 + http.Error(w, "Avatar not found", http.StatusNotFound) 40 + return 41 + } 42 + 43 + client := &http.Client{Timeout: 10 * time.Second} 44 + resp, err := client.Get(avatarURL) 45 + if err != nil { 46 + http.Error(w, "Failed to fetch avatar", http.StatusBadGateway) 47 + return 48 + } 49 + defer resp.Body.Close() 50 + 51 + if resp.StatusCode != http.StatusOK { 52 + http.Error(w, "Avatar not available", http.StatusNotFound) 53 + return 54 + } 55 + 56 + contentType := resp.Header.Get("Content-Type") 57 + if contentType == "" { 58 + contentType = "image/jpeg" 59 + } 60 + 61 + w.Header().Set("Content-Type", contentType) 62 + w.Header().Set("Cache-Control", "public, max-age=3600") 63 + w.Header().Set("Access-Control-Allow-Origin", "*") 64 + 65 + io.Copy(w, resp.Body) 66 + } 67 + 68 + func getAvatarURL(did string) string { 69 + avatarCacheMu.RLock() 70 + if cached, ok := avatarCacheMap[did]; ok && time.Since(cached.fetchedAt) < avatarCacheTTL { 71 + avatarCacheMu.RUnlock() 72 + return cached.url 73 + } 74 + avatarCacheMu.RUnlock() 75 + 76 + q := url.Values{} 77 + q.Add("actor", did) 78 + 79 + resp, err := http.Get("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?" + q.Encode()) 80 + if err != nil { 81 + return "" 82 + } 83 + defer resp.Body.Close() 84 + 85 + if resp.StatusCode != 200 { 86 + return "" 87 + } 88 + 89 + var profile struct { 90 + Avatar string `json:"avatar"` 91 + } 92 + if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { 93 + return "" 94 + } 95 + 96 + avatarCacheMu.Lock() 97 + avatarCacheMap[did] = avatarCache{ 98 + url: profile.Avatar, 99 + fetchedAt: time.Now(), 100 + } 101 + avatarCacheMu.Unlock() 102 + 103 + return profile.Avatar 104 + } 105 + 106 + func getProxiedAvatarURL(did, originalURL string) string { 107 + if originalURL == "" { 108 + return "" 109 + } 110 + 111 + baseURL := os.Getenv("BASE_URL") 112 + if baseURL == "" { 113 + return originalURL 114 + } 115 + 116 + return baseURL + "/api/avatar/" + url.PathEscape(did) 117 + }
+15 -1
backend/internal/api/handler.go
··· 19 19 db *db.DB 20 20 annotationService *AnnotationService 21 21 refresher *TokenRefresher 22 + apiKeys *APIKeyHandler 22 23 } 23 24 24 25 func NewHandler(database *db.DB, annotationService *AnnotationService, refresher *TokenRefresher) *Handler { 25 - return &Handler{db: database, annotationService: annotationService, refresher: refresher} 26 + return &Handler{ 27 + db: database, 28 + annotationService: annotationService, 29 + refresher: refresher, 30 + apiKeys: NewAPIKeyHandler(database, refresher), 31 + } 26 32 } 27 33 28 34 func (h *Handler) RegisterRoutes(r chi.Router) { ··· 64 70 r.Get("/notifications", h.GetNotifications) 65 71 r.Get("/notifications/count", h.GetUnreadNotificationCount) 66 72 r.Post("/notifications/read", h.MarkNotificationsRead) 73 + r.Get("/avatar/{did}", h.HandleAvatarProxy) 74 + 75 + r.Post("/keys", h.apiKeys.CreateKey) 76 + r.Get("/keys", h.apiKeys.ListKeys) 77 + r.Delete("/keys/{id}", h.apiKeys.DeleteKey) 78 + 79 + r.Post("/quick/bookmark", h.apiKeys.QuickBookmark) 80 + r.Post("/quick/annotation", h.apiKeys.QuickAnnotation) 67 81 }) 68 82 } 69 83
+1 -1
backend/internal/api/hydration.go
··· 470 470 DID: p.DID, 471 471 Handle: p.Handle, 472 472 DisplayName: p.DisplayName, 473 - Avatar: p.Avatar, 473 + Avatar: getProxiedAvatarURL(p.DID, p.Avatar), 474 474 } 475 475 } 476 476
+4
backend/internal/api/token_refresh.go
··· 196 196 client = xrpc.NewClient(newSession.PDS, newSession.AccessToken, newSession.DPoPKey) 197 197 return fn(client, newSession.DID) 198 198 } 199 + 200 + func (tr *TokenRefresher) CreateClientFromSession(session *SessionData) *xrpc.Client { 201 + return xrpc.NewClient(session.PDS, session.AccessToken, session.DPoPKey) 202 + }
+20
backend/internal/db/db.go
··· 120 120 ReadAt *time.Time `json:"readAt,omitempty"` 121 121 } 122 122 123 + type APIKey struct { 124 + ID string `json:"id"` 125 + OwnerDID string `json:"ownerDid"` 126 + Name string `json:"name"` 127 + KeyHash string `json:"-"` 128 + CreatedAt time.Time `json:"createdAt"` 129 + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` 130 + } 131 + 123 132 func New(dsn string) (*DB, error) { 124 133 driver := "sqlite3" 125 134 if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { ··· 295 304 )`) 296 305 db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_did)`) 297 306 db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC)`) 307 + 308 + db.Exec(`CREATE TABLE IF NOT EXISTS api_keys ( 309 + id TEXT PRIMARY KEY, 310 + owner_did TEXT NOT NULL, 311 + name TEXT NOT NULL, 312 + key_hash TEXT NOT NULL, 313 + created_at ` + dateType + ` NOT NULL, 314 + last_used_at ` + dateType + ` 315 + )`) 316 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_did)`) 317 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)`) 298 318 299 319 db.runMigrations() 300 320
+54
backend/internal/db/queries.go
··· 910 910 911 911 return "", fmt.Errorf("uri not found or no author") 912 912 } 913 + 914 + func (db *DB) CreateAPIKey(key *APIKey) error { 915 + _, err := db.Exec(db.Rebind(` 916 + INSERT INTO api_keys (id, owner_did, name, key_hash, created_at) 917 + VALUES (?, ?, ?, ?, ?) 918 + `), key.ID, key.OwnerDID, key.Name, key.KeyHash, key.CreatedAt) 919 + return err 920 + } 921 + 922 + func (db *DB) GetAPIKeysByOwner(ownerDID string) ([]APIKey, error) { 923 + rows, err := db.Query(db.Rebind(` 924 + SELECT id, owner_did, name, key_hash, created_at, last_used_at 925 + FROM api_keys 926 + WHERE owner_did = ? 927 + ORDER BY created_at DESC 928 + `), ownerDID) 929 + if err != nil { 930 + return nil, err 931 + } 932 + defer rows.Close() 933 + 934 + var keys []APIKey 935 + for rows.Next() { 936 + var k APIKey 937 + if err := rows.Scan(&k.ID, &k.OwnerDID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsedAt); err != nil { 938 + return nil, err 939 + } 940 + keys = append(keys, k) 941 + } 942 + return keys, nil 943 + } 944 + 945 + func (db *DB) GetAPIKeyByHash(keyHash string) (*APIKey, error) { 946 + var k APIKey 947 + err := db.QueryRow(db.Rebind(` 948 + SELECT id, owner_did, name, key_hash, created_at, last_used_at 949 + FROM api_keys 950 + WHERE key_hash = ? 951 + `), keyHash).Scan(&k.ID, &k.OwnerDID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsedAt) 952 + if err != nil { 953 + return nil, err 954 + } 955 + return &k, nil 956 + } 957 + 958 + func (db *DB) DeleteAPIKey(id, ownerDID string) error { 959 + _, err := db.Exec(db.Rebind(`DELETE FROM api_keys WHERE id = ? AND owner_did = ?`), id, ownerDID) 960 + return err 961 + } 962 + 963 + func (db *DB) UpdateAPIKeyLastUsed(id string) error { 964 + _, err := db.Exec(db.Rebind(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`), time.Now(), id) 965 + return err 966 + }
+236 -84
backend/internal/firehose/ingester.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "encoding/binary" 6 7 "encoding/json" 7 8 "fmt" 8 9 "io" 9 10 "log" 10 - "net/http" 11 - "strings" 12 11 "time" 12 + 13 + "github.com/fxamacker/cbor/v2" 14 + "github.com/gorilla/websocket" 15 + "github.com/ipfs/go-cid" 13 16 14 17 "margin.at/internal/db" 15 18 ) ··· 56 59 return 57 60 default: 58 61 if err := i.subscribe(ctx); err != nil { 62 + log.Printf("Firehose error: %v, reconnecting in 5s...", err) 59 63 if ctx.Err() != nil { 60 64 return 61 65 } 62 - time.Sleep(30 * time.Second) 66 + time.Sleep(5 * time.Second) 63 67 } 64 68 } 65 69 } 66 70 } 67 71 72 + type FrameHeader struct { 73 + Op int `cbor:"op"` 74 + T string `cbor:"t"` 75 + } 76 + type Commit struct { 77 + Repo string `cbor:"repo"` 78 + Rev string `cbor:"rev"` 79 + Seq int64 `cbor:"seq"` 80 + Prev *cid.Cid `cbor:"prev"` 81 + Time string `cbor:"time"` 82 + Blocks []byte `cbor:"blocks"` 83 + Ops []RepoOp `cbor:"ops"` 84 + } 85 + 86 + type RepoOp struct { 87 + Action string `cbor:"action"` 88 + Path string `cbor:"path"` 89 + Cid *cid.Cid `cbor:"cid"` 90 + } 91 + 68 92 func (i *Ingester) subscribe(ctx context.Context) error { 69 93 cursor := i.getLastCursor() 70 94 ··· 73 97 url = fmt.Sprintf("%s?cursor=%d", RelayURL, cursor) 74 98 } 75 99 76 - req, err := http.NewRequestWithContext(ctx, "GET", strings.Replace(url, "wss://", "https://", 1), nil) 77 - if err != nil { 78 - return err 79 - } 100 + log.Printf("Connecting to firehose: %s", url) 80 101 81 - resp, err := http.DefaultClient.Do(req) 102 + conn, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil) 82 103 if err != nil { 83 - return err 104 + return fmt.Errorf("websocket dial failed: %w", err) 84 105 } 85 - defer resp.Body.Close() 106 + defer conn.Close() 86 107 87 - if resp.StatusCode != 200 { 88 - body, _ := io.ReadAll(resp.Body) 89 - return fmt.Errorf("firehose returned %d: %s", resp.StatusCode, string(body)) 90 - } 108 + log.Printf("Connected to firehose") 91 109 92 - decoder := json.NewDecoder(resp.Body) 93 110 for { 94 111 select { 95 112 case <-ctx.Done(): ··· 97 114 default: 98 115 } 99 116 100 - var event FirehoseEvent 101 - if err := decoder.Decode(&event); err != nil { 102 - if err == io.EOF { 103 - return nil 104 - } 105 - return err 117 + _, message, err := conn.ReadMessage() 118 + if err != nil { 119 + return fmt.Errorf("websocket read failed: %w", err) 106 120 } 107 121 108 - i.handleEvent(&event) 122 + i.handleMessage(message) 109 123 } 110 124 } 111 125 112 - type FirehoseEvent struct { 113 - Repo string `json:"repo"` 114 - Collection string `json:"collection"` 115 - Rkey string `json:"rkey"` 116 - Record json.RawMessage `json:"record"` 117 - Operation string `json:"operation"` 118 - Cursor int64 `json:"cursor"` 119 - } 126 + func (i *Ingester) handleMessage(data []byte) { 127 + reader := bytes.NewReader(data) 120 128 121 - func (i *Ingester) handleEvent(event *FirehoseEvent) { 122 - uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 129 + var header FrameHeader 130 + decoder := cbor.NewDecoder(reader) 131 + if err := decoder.Decode(&header); err != nil { 132 + return 133 + } 123 134 124 - switch event.Collection { 125 - case CollectionAnnotation: 126 - switch event.Operation { 127 - case "create", "update": 128 - i.handleAnnotation(event) 129 - case "delete": 130 - i.db.DeleteAnnotation(uri) 135 + if header.Op != 1 { 136 + return 137 + } 138 + 139 + if header.T != "#commit" { 140 + return 141 + } 142 + 143 + var commit Commit 144 + if err := decoder.Decode(&commit); err != nil { 145 + return 146 + } 147 + 148 + for _, op := range commit.Ops { 149 + collection, rkey := parseOpPath(op.Path) 150 + if !isMarginCollection(collection) { 151 + continue 131 152 } 132 - case CollectionHighlight: 133 - switch event.Operation { 153 + 154 + uri := fmt.Sprintf("at://%s/%s/%s", commit.Repo, collection, rkey) 155 + 156 + switch op.Action { 134 157 case "create", "update": 135 - i.handleHighlight(event) 158 + if op.Cid != nil && len(commit.Blocks) > 0 { 159 + record := extractRecord(commit.Blocks, *op.Cid) 160 + if record != nil { 161 + i.handleRecord(commit.Repo, collection, rkey, record, commit.Seq) 162 + } 163 + } 136 164 case "delete": 137 - i.db.DeleteHighlight(uri) 165 + i.handleDelete(collection, uri) 138 166 } 139 - case CollectionBookmark: 140 - switch event.Operation { 141 - case "create", "update": 142 - i.handleBookmark(event) 143 - case "delete": 144 - i.db.DeleteBookmark(uri) 167 + } 168 + 169 + if commit.Seq > 0 { 170 + if err := i.db.SetCursor("firehose_cursor", commit.Seq); err != nil { 171 + log.Printf("Failed to save cursor: %v", err) 145 172 } 146 - case CollectionReply: 147 - switch event.Operation { 148 - case "create", "update": 149 - i.handleReply(event) 150 - case "delete": 151 - i.db.DeleteReply(uri) 173 + } 174 + } 175 + 176 + func parseOpPath(path string) (collection, rkey string) { 177 + for i := len(path) - 1; i >= 0; i-- { 178 + if path[i] == '/' { 179 + return path[:i], path[i+1:] 152 180 } 153 - case CollectionLike: 154 - switch event.Operation { 155 - case "create": 156 - i.handleLike(event) 157 - case "delete": 158 - i.db.DeleteLike(uri) 181 + } 182 + return path, "" 183 + } 184 + 185 + func isMarginCollection(collection string) bool { 186 + switch collection { 187 + case CollectionAnnotation, CollectionHighlight, CollectionBookmark, 188 + CollectionReply, CollectionLike, CollectionCollection, CollectionCollectionItem: 189 + return true 190 + } 191 + return false 192 + } 193 + 194 + func extractRecord(blocks []byte, targetCid cid.Cid) map[string]interface{} { 195 + reader := bytes.NewReader(blocks) 196 + 197 + headerLen, err := binary.ReadUvarint(reader) 198 + if err != nil { 199 + return nil 200 + } 201 + reader.Seek(int64(headerLen), io.SeekCurrent) 202 + 203 + for reader.Len() > 0 { 204 + blockLen, err := binary.ReadUvarint(reader) 205 + if err != nil { 206 + break 159 207 } 160 - case CollectionCollection: 161 - switch event.Operation { 162 - case "create", "update": 163 - i.handleCollection(event) 164 - case "delete": 165 - i.db.DeleteCollection(uri) 208 + 209 + blockData := make([]byte, blockLen) 210 + if _, err := io.ReadFull(reader, blockData); err != nil { 211 + break 166 212 } 167 - case CollectionCollectionItem: 168 - switch event.Operation { 169 - case "create", "update": 170 - i.handleCollectionItem(event) 171 - case "delete": 172 - i.db.RemoveFromCollection(uri) 213 + 214 + blockCid, cidLen, err := parseCidFromBlock(blockData) 215 + if err != nil { 216 + continue 217 + } 218 + 219 + if blockCid.Equals(targetCid) { 220 + var record map[string]interface{} 221 + if err := cbor.Unmarshal(blockData[cidLen:], &record); err != nil { 222 + return nil 223 + } 224 + return record 173 225 } 174 226 } 175 227 176 - if event.Cursor > 0 { 177 - if err := i.db.SetCursor("firehose_cursor", event.Cursor); err != nil { 178 - log.Printf("Failed to save cursor: %v", err) 228 + return nil 229 + } 230 + 231 + func parseCidFromBlock(data []byte) (cid.Cid, int, error) { 232 + if len(data) < 2 { 233 + return cid.Cid{}, 0, fmt.Errorf("data too short") 234 + } 235 + version, n1 := binary.Uvarint(data) 236 + if n1 <= 0 { 237 + return cid.Cid{}, 0, fmt.Errorf("invalid version varint") 238 + } 239 + 240 + if version == 1 { 241 + codec, n2 := binary.Uvarint(data[n1:]) 242 + if n2 <= 0 { 243 + return cid.Cid{}, 0, fmt.Errorf("invalid codec varint") 179 244 } 245 + 246 + mhStart := n1 + n2 247 + hashType, n3 := binary.Uvarint(data[mhStart:]) 248 + if n3 <= 0 { 249 + return cid.Cid{}, 0, fmt.Errorf("invalid hash type varint") 250 + } 251 + 252 + hashLen, n4 := binary.Uvarint(data[mhStart+n3:]) 253 + if n4 <= 0 { 254 + return cid.Cid{}, 0, fmt.Errorf("invalid hash length varint") 255 + } 256 + 257 + totalCidLen := mhStart + n3 + n4 + int(hashLen) 258 + 259 + c, err := cid.Cast(data[:totalCidLen]) 260 + if err != nil { 261 + return cid.Cid{}, 0, err 262 + } 263 + 264 + _ = codec 265 + _ = hashType 266 + 267 + return c, totalCidLen, nil 180 268 } 269 + 270 + return cid.Cid{}, 0, fmt.Errorf("unsupported CID version") 181 271 } 182 272 183 - func (i *Ingester) handleAnnotation(event *FirehoseEvent) { 273 + func (i *Ingester) handleDelete(collection, uri string) { 274 + switch collection { 275 + case CollectionAnnotation: 276 + i.db.DeleteAnnotation(uri) 277 + case CollectionHighlight: 278 + i.db.DeleteHighlight(uri) 279 + case CollectionBookmark: 280 + i.db.DeleteBookmark(uri) 281 + case CollectionReply: 282 + i.db.DeleteReply(uri) 283 + case CollectionLike: 284 + i.db.DeleteLike(uri) 285 + case CollectionCollection: 286 + i.db.DeleteCollection(uri) 287 + case CollectionCollectionItem: 288 + i.db.RemoveFromCollection(uri) 289 + } 290 + } 184 291 292 + func (i *Ingester) handleRecord(repo, collection, rkey string, record map[string]interface{}, seq int64) { 293 + _ = fmt.Sprintf("at://%s/%s/%s", repo, collection, rkey) 294 + 295 + recordJSON, err := json.Marshal(record) 296 + if err != nil { 297 + return 298 + } 299 + 300 + event := &FirehoseEvent{ 301 + Repo: repo, 302 + Collection: collection, 303 + Rkey: rkey, 304 + Record: recordJSON, 305 + Operation: "create", 306 + Cursor: seq, 307 + } 308 + 309 + switch collection { 310 + case CollectionAnnotation: 311 + i.handleAnnotation(event) 312 + case CollectionHighlight: 313 + i.handleHighlight(event) 314 + case CollectionBookmark: 315 + i.handleBookmark(event) 316 + case CollectionReply: 317 + i.handleReply(event) 318 + case CollectionLike: 319 + i.handleLike(event) 320 + case CollectionCollection: 321 + i.handleCollection(event) 322 + case CollectionCollectionItem: 323 + i.handleCollectionItem(event) 324 + } 325 + } 326 + 327 + type FirehoseEvent struct { 328 + Repo string `json:"repo"` 329 + Collection string `json:"collection"` 330 + Rkey string `json:"rkey"` 331 + Record json.RawMessage `json:"record"` 332 + Operation string `json:"operation"` 333 + Cursor int64 `json:"cursor"` 334 + } 335 + 336 + func (i *Ingester) handleAnnotation(event *FirehoseEvent) { 185 337 var record struct { 186 338 Motivation string `json:"motivation"` 187 339 Body struct { ··· 205 357 Title string `json:"title"` 206 358 } 207 359 208 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 360 + if err := json.Unmarshal(event.Record, &record); err != nil { 209 361 return 210 362 } 211 363 ··· 302 454 CreatedAt string `json:"createdAt"` 303 455 } 304 456 305 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 457 + if err := json.Unmarshal(event.Record, &record); err != nil { 306 458 return 307 459 } 308 460 ··· 334 486 CreatedAt string `json:"createdAt"` 335 487 } 336 488 337 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 489 + if err := json.Unmarshal(event.Record, &record); err != nil { 338 490 return 339 491 } 340 492 ··· 369 521 CreatedAt string `json:"createdAt"` 370 522 } 371 523 372 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 524 + if err := json.Unmarshal(event.Record, &record); err != nil { 373 525 return 374 526 } 375 527 ··· 432 584 CreatedAt string `json:"createdAt"` 433 585 } 434 586 435 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 587 + if err := json.Unmarshal(event.Record, &record); err != nil { 436 588 return 437 589 } 438 590 ··· 488 640 CreatedAt string `json:"createdAt"` 489 641 } 490 642 491 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 643 + if err := json.Unmarshal(event.Record, &record); err != nil { 492 644 return 493 645 } 494 646 ··· 532 684 CreatedAt string `json:"createdAt"` 533 685 } 534 686 535 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 687 + if err := json.Unmarshal(event.Record, &record); err != nil { 536 688 return 537 689 } 538 690
+2 -1
docker-compose.yml
··· 9 9 - OAUTH_KEY_PATH=/data/oauth_private_key.pem 10 10 env_file: 11 11 - .env 12 + volumes: 13 + - margin-data:/data 12 14 depends_on: 13 15 db: 14 16 condition: service_healthy ··· 23 25 24 26 volumes: 25 27 - db-data:/var/lib/postgresql/data 26 - - margin-data:/data 27 28 healthcheck: 28 29 test: ["CMD-SHELL", "pg_isready -U margin"] 29 30 interval: 5s
+44 -2
extension/content/content.js
··· 3 3 let sidebarShadow = null; 4 4 let popoverEl = null; 5 5 6 - 7 6 let activeItems = []; 8 7 let currentSelection = null; 9 8 ··· 640 639 const firstRect = firstRange.getClientRects()[0]; 641 640 const totalWidth = 642 641 Math.min(uniqueAuthors.length, maxShow + (overflow > 0 ? 1 : 0)) * 643 - 18 + 642 + 18 + 644 643 8; 645 644 const leftPos = firstRect.left - totalWidth; 646 645 const topPos = firstRect.top + firstRect.height / 2 - 12; ··· 1026 1025 setTimeout(() => fetchAnnotations(), 500); 1027 1026 } 1028 1027 }); 1028 + 1029 + let lastUrl = window.location.href; 1030 + 1031 + function checkUrlChange() { 1032 + if (window.location.href !== lastUrl) { 1033 + lastUrl = window.location.href; 1034 + onUrlChange(); 1035 + } 1036 + } 1037 + 1038 + function onUrlChange() { 1039 + if (typeof CSS !== "undefined" && CSS.highlights) { 1040 + CSS.highlights.clear(); 1041 + } 1042 + activeItems = []; 1043 + 1044 + if (typeof chrome !== "undefined" && chrome.storage) { 1045 + chrome.storage.local.get(["showOverlay"], (result) => { 1046 + if (result.showOverlay !== false) { 1047 + fetchAnnotations(); 1048 + } 1049 + }); 1050 + } else { 1051 + fetchAnnotations(); 1052 + } 1053 + } 1054 + 1055 + window.addEventListener("popstate", onUrlChange); 1056 + 1057 + const originalPushState = history.pushState; 1058 + const originalReplaceState = history.replaceState; 1059 + 1060 + history.pushState = function (...args) { 1061 + originalPushState.apply(this, args); 1062 + checkUrlChange(); 1063 + }; 1064 + 1065 + history.replaceState = function (...args) { 1066 + originalReplaceState.apply(this, args); 1067 + checkUrlChange(); 1068 + }; 1069 + 1070 + setInterval(checkUrlChange, 1000); 1029 1071 })();
-2
web/src/App.jsx
··· 15 15 import Collections from "./pages/Collections"; 16 16 import CollectionDetail from "./pages/CollectionDetail"; 17 17 import Privacy from "./pages/Privacy"; 18 - 19 18 import Terms from "./pages/Terms"; 20 - 21 19 import ScrollToTop from "./components/ScrollToTop"; 22 20 23 21 function AppContent() {
+15
web/src/api/client.js
··· 430 430 export async function getTrendingTags(limit = 10) { 431 431 return request(`${API_BASE}/tags/trending?limit=${limit}`); 432 432 } 433 + 434 + export async function getAPIKeys() { 435 + return request(`${API_BASE}/keys`); 436 + } 437 + 438 + export async function createAPIKey(name) { 439 + return request(`${API_BASE}/keys`, { 440 + method: "POST", 441 + body: JSON.stringify({ name }), 442 + }); 443 + } 444 + 445 + export async function deleteAPIKey(id) { 446 + return request(`${API_BASE}/keys/${id}`, { method: "DELETE" }); 447 + }
+30 -4
web/src/components/RightSidebar.jsx
··· 1 1 import { useState, useEffect } from "react"; 2 2 import { Link } from "react-router-dom"; 3 3 import { ExternalLink } from "lucide-react"; 4 - import { SiFirefox, SiGooglechrome, SiGithub, SiBluesky } from "react-icons/si"; 4 + import { 5 + SiFirefox, 6 + SiGooglechrome, 7 + SiGithub, 8 + SiBluesky, 9 + SiApple, 10 + } from "react-icons/si"; 5 11 import { FaEdge } from "react-icons/fa"; 6 12 import { useAuth } from "../context/AuthContext"; 7 13 import { getTrendingTags } from "../api/client"; ··· 10 16 typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); 11 17 const isEdge = 12 18 typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); 19 + const isMobileSafari = 20 + typeof navigator !== "undefined" && 21 + /iPhone|iPad|iPod/.test(navigator.userAgent) && 22 + /Safari/.test(navigator.userAgent) && 23 + !/CriOS|FxiOS|OPiOS|EdgiOS/.test(navigator.userAgent); 13 24 14 25 function getExtensionInfo() { 26 + if (isMobileSafari) { 27 + return { 28 + url: "https://margin.at/soon", 29 + icon: SiApple, 30 + name: "iOS", 31 + label: "Get the Shortcut", 32 + }; 33 + } 15 34 if (isFirefox) { 16 35 return { 17 36 url: "https://addons.mozilla.org/en-US/firefox/addon/margin/", 18 37 icon: SiFirefox, 19 38 name: "Firefox", 39 + label: "Install for Firefox", 20 40 }; 21 41 } 22 42 if (isEdge) { ··· 24 44 url: "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn", 25 45 icon: FaEdge, 26 46 name: "Edge", 47 + label: "Install for Edge", 27 48 }; 28 49 } 29 50 return { 30 51 url: "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/", 31 52 icon: SiGooglechrome, 32 53 name: "Chrome", 54 + label: "Install for Chrome", 33 55 }; 34 56 } 35 57 ··· 50 72 return ( 51 73 <aside className="right-sidebar"> 52 74 <div className="right-section"> 53 - <h3 className="right-section-title">Get the Extension</h3> 75 + <h3 className="right-section-title"> 76 + {isMobileSafari ? "Save from Safari" : "Get the Extension"} 77 + </h3> 54 78 <p className="right-section-desc"> 55 - Annotate, highlight, and bookmark any webpage 79 + {isMobileSafari 80 + ? "Bookmark pages using Safari's share sheet" 81 + : "Annotate, highlight, and bookmark any webpage"} 56 82 </p> 57 83 <a 58 84 href={ext.url} ··· 61 87 className="right-extension-btn" 62 88 > 63 89 <ExtIcon size={18} /> 64 - Install for {ext.name} 90 + {ext.label} 65 91 <ExternalLink size={14} /> 66 92 </a> 67 93 </div>
+34 -1
web/src/css/annotations.css
··· 182 182 font-weight: 400; 183 183 font-family: var(--font-serif, var(--font-sans)); 184 184 display: inline; 185 + overflow-wrap: anywhere; 186 + word-break: break-all; 187 + padding-right: 4px; 185 188 } 186 189 187 190 .annotation-text { ··· 277 280 padding-left: 46px; 278 281 } 279 282 280 - @media (max-width: 600px) { 283 + .annotation-text, 284 + .reply-text, 285 + .history-content { 286 + overflow-wrap: break-word; 287 + word-break: break-word; 288 + max-width: 100%; 289 + } 290 + 291 + .annotation-highlight mark { 292 + overflow-wrap: break-word; 293 + word-break: break-word; 294 + display: inline; 295 + } 296 + 297 + .annotation-header-left, 298 + .annotation-meta, 299 + .reply-meta { 300 + min-width: 0; 301 + max-width: 100%; 302 + } 303 + 304 + .annotation-author-row, 305 + .reply-author { 306 + max-width: 100%; 307 + } 308 + 309 + .annotation-source { 310 + max-width: 100%; 311 + } 312 + 313 + @media (max-width: 768px) { 281 314 .annotation-content, 282 315 .annotation-actions, 283 316 .inline-replies {
+1
web/src/css/buttons.css
··· 119 119 .action-buttons { 120 120 display: flex; 121 121 gap: 8px; 122 + flex-wrap: wrap; 122 123 } 123 124 124 125 .action-buttons-end {
+16
web/src/css/collections.css
··· 171 171 line-height: 1.3; 172 172 } 173 173 174 + @media (max-width: 600px) { 175 + .collection-detail-header { 176 + flex-direction: column; 177 + padding: 16px; 178 + gap: 16px; 179 + } 180 + 181 + .collection-detail-actions { 182 + position: static; 183 + margin-top: -8px; 184 + justify-content: flex-end; 185 + } 186 + } 187 + 174 188 .collection-detail-desc { 175 189 color: var(--text-secondary); 176 190 font-size: 1rem; 177 191 line-height: 1.5; 178 192 margin-bottom: 12px; 179 193 max-width: 600px; 194 + overflow-wrap: break-word; 195 + word-break: break-word; 180 196 } 181 197 182 198 .collection-detail-stats {
+2
web/src/css/feed.css
··· 24 24 background: var(--bg-tertiary); 25 25 border-radius: var(--radius-lg); 26 26 width: fit-content; 27 + max-width: 100%; 28 + flex-wrap: wrap; 27 29 } 28 30 29 31 .filter-tab {
+48
web/src/css/layout.css
··· 451 451 .main-layout { 452 452 margin-left: 0; 453 453 padding-bottom: 80px; 454 + width: 100%; 455 + min-width: 0; 454 456 } 455 457 456 458 .main-content-wrapper { 457 459 padding: 20px 16px; 458 460 max-width: 100%; 461 + width: 100%; 459 462 overflow-x: hidden; 463 + min-width: 0; 460 464 } 461 465 462 466 .mobile-nav { 463 467 display: block; 468 + max-width: 100vw; 469 + } 470 + 471 + .card, 472 + .annotation-card, 473 + .collection-card, 474 + .profile-header, 475 + .api-keys-section { 476 + overflow-x: hidden; 477 + max-width: 100%; 478 + } 479 + 480 + code { 481 + word-break: break-all; 482 + overflow-wrap: break-word; 483 + } 484 + 485 + pre { 486 + overflow-x: auto; 487 + max-width: 100%; 488 + } 489 + 490 + input, 491 + textarea { 492 + max-width: 100%; 493 + } 494 + 495 + .flex-row, 496 + [style*="display: flex"][style*="gap"] { 497 + flex-wrap: wrap; 498 + } 499 + 500 + .static-page { 501 + overflow-x: hidden; 502 + } 503 + 504 + .static-page ol, 505 + .static-page ul { 506 + padding-left: 1.25rem; 507 + } 508 + 509 + .static-page code { 510 + font-size: 0.75rem; 511 + word-break: break-all; 464 512 } 465 513 }
+20
web/src/css/login.css
··· 10 10 margin: 0 auto; 11 11 } 12 12 13 + @media (max-width: 600px) { 14 + .login-page { 15 + padding: 40px 16px; 16 + } 17 + 18 + .login-at-logo { 19 + font-size: 4rem; 20 + } 21 + 22 + .login-brand-name { 23 + font-size: 1.25rem; 24 + } 25 + 26 + .login-brand-icon { 27 + width: 40px; 28 + height: 40px; 29 + font-size: 1.5rem; 30 + } 31 + } 32 + 13 33 .login-at-logo { 14 34 font-size: 5rem; 15 35 font-weight: 800;
+2
web/src/css/notifications.css
··· 53 53 margin-bottom: 4px; 54 54 line-height: 1.4; 55 55 color: var(--text-primary); 56 + overflow-wrap: break-word; 57 + word-break: break-word; 56 58 } 57 59 58 60 .notification-text strong {
+6
web/src/css/profile.css
··· 45 45 font-weight: 700; 46 46 color: var(--text-primary); 47 47 line-height: 1.2; 48 + overflow-wrap: break-word; 49 + word-break: break-word; 48 50 } 49 51 50 52 .profile-handle-row { ··· 60 62 text-decoration: none; 61 63 font-size: 1rem; 62 64 transition: color 0.15s; 65 + overflow-wrap: break-word; 66 + word-break: break-all; 63 67 } 64 68 65 69 .profile-handle-link:hover { ··· 106 110 gap: 24px; 107 111 margin-bottom: 24px; 108 112 border-bottom: 1px solid var(--border); 113 + flex-wrap: wrap; 114 + row-gap: 8px; 109 115 } 110 116 111 117 .profile-tab {
+19
web/src/css/utilities.css
··· 261 261 margin-bottom: 16px; 262 262 font-style: italic; 263 263 color: var(--text-secondary); 264 + overflow-wrap: break-word; 265 + word-break: break-word; 266 + max-width: 100%; 264 267 } 265 268 266 269 .composer-quote-remove { ··· 728 731 .bookmark-time { 729 732 color: var(--text-tertiary); 730 733 } 734 + 735 + .bookmark-preview { 736 + max-width: 100%; 737 + width: 100%; 738 + box-sizing: border-box; 739 + } 740 + 741 + @media (max-width: 600px) { 742 + .bookmark-preview-content { 743 + padding: 12px 14px; 744 + } 745 + 746 + .legal-content { 747 + padding: 16px; 748 + } 749 + }
+244
web/src/pages/Profile.jsx
··· 7 7 getUserHighlights, 8 8 getUserBookmarks, 9 9 getCollections, 10 + getAPIKeys, 11 + createAPIKey, 12 + deleteAPIKey, 10 13 } from "../api/client"; 14 + import { useAuth } from "../context/AuthContext"; 11 15 import CollectionIcon from "../components/CollectionIcon"; 12 16 import CollectionRow from "../components/CollectionRow"; 13 17 import { ··· 17 21 BlueskyIcon, 18 22 } from "../components/Icons"; 19 23 24 + function KeyIcon({ size = 16 }) { 25 + return ( 26 + <svg 27 + width={size} 28 + height={size} 29 + viewBox="0 0 24 24" 30 + fill="none" 31 + stroke="currentColor" 32 + strokeWidth="2" 33 + strokeLinecap="round" 34 + strokeLinejoin="round" 35 + > 36 + <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" /> 37 + </svg> 38 + ); 39 + } 40 + 20 41 export default function Profile() { 21 42 const { handle } = useParams(); 43 + const { user } = useAuth(); 22 44 const [activeTab, setActiveTab] = useState("annotations"); 23 45 const [profile, setProfile] = useState(null); 24 46 const [annotations, setAnnotations] = useState([]); 25 47 const [highlights, setHighlights] = useState([]); 26 48 const [bookmarks, setBookmarks] = useState([]); 27 49 const [collections, setCollections] = useState([]); 50 + const [apiKeys, setApiKeys] = useState([]); 51 + const [newKeyName, setNewKeyName] = useState(""); 52 + const [newKey, setNewKey] = useState(null); 53 + const [keysLoading, setKeysLoading] = useState(false); 28 54 const [loading, setLoading] = useState(true); 29 55 const [error, setError] = useState(null); 56 + 57 + const isOwnProfile = user && (user.did === handle || user.handle === handle); 30 58 31 59 useEffect(() => { 32 60 async function fetchProfile() { ··· 62 90 fetchProfile(); 63 91 }, [handle]); 64 92 93 + useEffect(() => { 94 + if (isOwnProfile && activeTab === "apikeys") { 95 + loadAPIKeys(); 96 + } 97 + }, [isOwnProfile, activeTab]); 98 + 99 + const loadAPIKeys = async () => { 100 + setKeysLoading(true); 101 + try { 102 + const data = await getAPIKeys(); 103 + setApiKeys(data.keys || []); 104 + } catch { 105 + setApiKeys([]); 106 + } finally { 107 + setKeysLoading(false); 108 + } 109 + }; 110 + 111 + const handleCreateKey = async () => { 112 + if (!newKeyName.trim()) return; 113 + try { 114 + const data = await createAPIKey(newKeyName.trim()); 115 + setNewKey(data.key); 116 + setNewKeyName(""); 117 + loadAPIKeys(); 118 + } catch (err) { 119 + alert("Failed to create key: " + err.message); 120 + } 121 + }; 122 + 123 + const handleDeleteKey = async (id) => { 124 + if (!confirm("Delete this API key? This cannot be undone.")) return; 125 + try { 126 + await deleteAPIKey(id); 127 + loadAPIKeys(); 128 + } catch (err) { 129 + alert("Failed to delete key: " + err.message); 130 + } 131 + }; 132 + 65 133 const displayName = profile?.displayName || profile?.handle || handle; 66 134 const displayHandle = 67 135 profile?.handle || (handle?.startsWith("did:") ? null : handle); ··· 155 223 </div> 156 224 ); 157 225 } 226 + 227 + if (activeTab === "apikeys" && isOwnProfile) { 228 + return ( 229 + <div className="api-keys-section"> 230 + <div className="card" style={{ marginBottom: "1rem" }}> 231 + <h3 style={{ marginBottom: "0.5rem" }}>Create API Key</h3> 232 + <p 233 + style={{ 234 + color: "var(--text-muted)", 235 + marginBottom: "1rem", 236 + fontSize: "0.875rem", 237 + }} 238 + > 239 + Use API keys to create bookmarks from iOS Shortcuts or other 240 + tools. 241 + </p> 242 + <div style={{ display: "flex", gap: "0.5rem" }}> 243 + <input 244 + type="text" 245 + value={newKeyName} 246 + onChange={(e) => setNewKeyName(e.target.value)} 247 + placeholder="Key name (e.g., iOS Shortcut)" 248 + className="input" 249 + style={{ flex: 1 }} 250 + /> 251 + <button className="btn btn-primary" onClick={handleCreateKey}> 252 + Generate 253 + </button> 254 + </div> 255 + {newKey && ( 256 + <div 257 + style={{ 258 + marginTop: "1rem", 259 + padding: "1rem", 260 + background: "var(--bg-secondary)", 261 + borderRadius: "8px", 262 + }} 263 + > 264 + <p 265 + style={{ 266 + color: "var(--text-success)", 267 + fontWeight: 500, 268 + marginBottom: "0.5rem", 269 + }} 270 + > 271 + ✓ Key created! Copy it now, you won&apos;t see it again. 272 + </p> 273 + <code 274 + style={{ 275 + display: "block", 276 + padding: "0.75rem", 277 + background: "var(--bg-tertiary)", 278 + borderRadius: "4px", 279 + wordBreak: "break-all", 280 + fontSize: "0.8rem", 281 + }} 282 + > 283 + {newKey} 284 + </code> 285 + <button 286 + className="btn btn-secondary" 287 + style={{ marginTop: "0.5rem" }} 288 + onClick={() => { 289 + navigator.clipboard.writeText(newKey); 290 + alert("Copied!"); 291 + }} 292 + > 293 + Copy to clipboard 294 + </button> 295 + </div> 296 + )} 297 + </div> 298 + 299 + {keysLoading ? ( 300 + <div className="card"> 301 + <div className="skeleton skeleton-text" /> 302 + </div> 303 + ) : apiKeys.length === 0 ? ( 304 + <div className="empty-state"> 305 + <div className="empty-state-icon"> 306 + <KeyIcon size={32} /> 307 + </div> 308 + <h3 className="empty-state-title">No API keys</h3> 309 + <p className="empty-state-text"> 310 + Create a key to use with iOS Shortcuts. 311 + </p> 312 + </div> 313 + ) : ( 314 + <div className="card"> 315 + <h3 style={{ marginBottom: "1rem" }}>Your API Keys</h3> 316 + {apiKeys.map((key) => ( 317 + <div 318 + key={key.id} 319 + style={{ 320 + display: "flex", 321 + justifyContent: "space-between", 322 + alignItems: "center", 323 + padding: "0.75rem 0", 324 + borderBottom: "1px solid var(--border-color)", 325 + }} 326 + > 327 + <div> 328 + <strong>{key.name}</strong> 329 + <div 330 + style={{ 331 + fontSize: "0.75rem", 332 + color: "var(--text-muted)", 333 + }} 334 + > 335 + Created {new Date(key.createdAt).toLocaleDateString()} 336 + {key.lastUsedAt && 337 + ` • Last used ${new Date(key.lastUsedAt).toLocaleDateString()}`} 338 + </div> 339 + </div> 340 + <button 341 + className="btn btn-sm" 342 + style={{ 343 + fontSize: "0.75rem", 344 + padding: "0.25rem 0.5rem", 345 + color: "#ef4444", 346 + border: "1px solid #ef4444", 347 + }} 348 + onClick={() => handleDeleteKey(key.id)} 349 + > 350 + Revoke 351 + </button> 352 + </div> 353 + ))} 354 + </div> 355 + )} 356 + 357 + <div className="card" style={{ marginTop: "1rem" }}> 358 + <h3 style={{ marginBottom: "0.5rem" }}>iOS Shortcut</h3> 359 + <p 360 + style={{ 361 + color: "var(--text-muted)", 362 + marginBottom: "1rem", 363 + fontSize: "0.875rem", 364 + }} 365 + > 366 + Save bookmarks from Safari&apos;s share sheet. 367 + </p> 368 + <a 369 + href="https://margin.at/soon" 370 + target="_blank" 371 + rel="noopener noreferrer" 372 + className="btn btn-primary" 373 + style={{ 374 + display: "inline-flex", 375 + alignItems: "center", 376 + gap: "0.5rem", 377 + }} 378 + > 379 + <AppleIcon size={16} /> Download Shortcut 380 + </a> 381 + </div> 382 + </div> 383 + ); 384 + } 158 385 }; 159 386 160 387 const bskyProfileUrl = displayHandle ··· 230 457 > 231 458 Collections ({collections.length}) 232 459 </button> 460 + 461 + {isOwnProfile && ( 462 + <button 463 + className={`profile-tab ${activeTab === "apikeys" ? "active" : ""}`} 464 + onClick={() => setActiveTab("apikeys")} 465 + > 466 + <KeyIcon size={14} /> API Keys 467 + </button> 468 + )} 233 469 </div> 234 470 235 471 {loading && ( ··· 262 498 </div> 263 499 ); 264 500 } 501 + 502 + function AppleIcon({ size = 16 }) { 503 + return ( 504 + <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor"> 505 + <path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" /> 506 + </svg> 507 + ); 508 + }