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

Compare changes

Choose any two refs to compare.

+16 -1
backend/go.mod
··· 3 go 1.24.0 4 5 require ( 6 github.com/go-chi/chi/v5 v5.1.0 7 github.com/go-chi/cors v1.2.1 8 github.com/go-jose/go-jose/v4 v4.0.4 9 github.com/joho/godotenv v1.5.1 10 github.com/lib/pq v1.10.9 11 github.com/mattn/go-sqlite3 v1.14.22 ··· 14 15 require ( 16 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 17 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 18 github.com/stretchr/testify v1.10.0 // indirect 19 - golang.org/x/crypto v0.31.0 // indirect 20 golang.org/x/text v0.32.0 // indirect 21 )
··· 3 go 1.24.0 4 5 require ( 6 + github.com/fxamacker/cbor/v2 v2.9.0 7 github.com/go-chi/chi/v5 v5.1.0 8 github.com/go-chi/cors v1.2.1 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 12 github.com/joho/godotenv v1.5.1 13 github.com/lib/pq v1.10.9 14 github.com/mattn/go-sqlite3 v1.14.22 ··· 17 18 require ( 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 28 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 29 + github.com/spaolacci/murmur3 v1.1.0 // indirect 30 github.com/stretchr/testify v1.10.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 34 golang.org/x/text v0.32.0 // indirect 35 + lukechampine.com/blake3 v1.1.6 // indirect 36 )
+33 -2
backend/go.sum
··· 1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 2 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 4 github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 5 github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= ··· 8 github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= 9 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 10 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 11 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 12 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 13 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 14 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 15 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 16 github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 17 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 18 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 20 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= 23 golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= 24 golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= 25 golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 26 golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 27 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 28 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
··· 1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 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= 5 github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 6 github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 7 github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= ··· 10 github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= 11 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 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= 17 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 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= 22 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 23 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 24 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 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= 40 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 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= 44 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 45 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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= 50 golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= 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= 54 golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 55 golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 56 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 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=
+4 -1
backend/internal/api/annotations.go
··· 480 481 func resolveDIDToPDS(did string) (string, error) { 482 if strings.HasPrefix(did, "did:plc:") { 483 - resp, err := http.Get("https://plc.directory/" + did) 484 if err != nil { 485 return "", err 486 }
··· 480 481 func resolveDIDToPDS(did string) (string, error) { 482 if strings.HasPrefix(did, "did:plc:") { 483 + client := &http.Client{ 484 + Timeout: 10 * time.Second, 485 + } 486 + resp, err := client.Get("https://plc.directory/" + did) 487 if err != nil { 488 return "", err 489 }
+435
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 + var result *xrpc.CreateRecordOutput 161 + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 162 + var createErr error 163 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionBookmark, record) 164 + return createErr 165 + }) 166 + if err != nil { 167 + http.Error(w, "Failed to create bookmark: "+err.Error(), http.StatusInternalServerError) 168 + return 169 + } 170 + 171 + h.db.UpdateAPIKeyLastUsed(apiKey.ID) 172 + 173 + var titlePtr, descPtr *string 174 + if req.Title != "" { 175 + titlePtr = &req.Title 176 + } 177 + if req.Description != "" { 178 + descPtr = &req.Description 179 + } 180 + 181 + cid := result.CID 182 + bookmark := &db.Bookmark{ 183 + URI: result.URI, 184 + AuthorDID: apiKey.OwnerDID, 185 + Source: req.URL, 186 + SourceHash: urlHash, 187 + Title: titlePtr, 188 + Description: descPtr, 189 + CreatedAt: time.Now(), 190 + IndexedAt: time.Now(), 191 + CID: &cid, 192 + } 193 + h.db.CreateBookmark(bookmark) 194 + 195 + w.Header().Set("Content-Type", "application/json") 196 + json.NewEncoder(w).Encode(map[string]string{ 197 + "uri": result.URI, 198 + "cid": result.CID, 199 + "message": "Bookmark created successfully", 200 + }) 201 + } 202 + 203 + type QuickAnnotationRequest struct { 204 + URL string `json:"url"` 205 + Text string `json:"text"` 206 + } 207 + 208 + func (h *APIKeyHandler) QuickAnnotation(w http.ResponseWriter, r *http.Request) { 209 + apiKey, err := h.authenticateAPIKey(r) 210 + if err != nil { 211 + http.Error(w, err.Error(), http.StatusUnauthorized) 212 + return 213 + } 214 + 215 + var req QuickAnnotationRequest 216 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 217 + http.Error(w, "Invalid request body", http.StatusBadRequest) 218 + return 219 + } 220 + 221 + if req.URL == "" || req.Text == "" { 222 + http.Error(w, "URL and text are required", http.StatusBadRequest) 223 + return 224 + } 225 + 226 + session, err := h.getSessionByDID(apiKey.OwnerDID) 227 + if err != nil { 228 + http.Error(w, "User session not found. Please log in to margin.at first.", http.StatusUnauthorized) 229 + return 230 + } 231 + 232 + urlHash := db.HashURL(req.URL) 233 + record := xrpc.NewAnnotationRecord(req.URL, urlHash, req.Text, nil, "") 234 + 235 + var result *xrpc.CreateRecordOutput 236 + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 237 + var createErr error 238 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionAnnotation, record) 239 + return createErr 240 + }) 241 + if err != nil { 242 + http.Error(w, "Failed to create annotation: "+err.Error(), http.StatusInternalServerError) 243 + return 244 + } 245 + 246 + h.db.UpdateAPIKeyLastUsed(apiKey.ID) 247 + 248 + bodyValue := req.Text 249 + annotation := &db.Annotation{ 250 + URI: result.URI, 251 + AuthorDID: apiKey.OwnerDID, 252 + Motivation: "commenting", 253 + BodyValue: &bodyValue, 254 + TargetSource: req.URL, 255 + TargetHash: urlHash, 256 + CreatedAt: time.Now(), 257 + IndexedAt: time.Now(), 258 + CID: &result.CID, 259 + } 260 + h.db.CreateAnnotation(annotation) 261 + 262 + w.Header().Set("Content-Type", "application/json") 263 + json.NewEncoder(w).Encode(map[string]string{ 264 + "uri": result.URI, 265 + "cid": result.CID, 266 + "message": "Annotation created successfully", 267 + }) 268 + } 269 + 270 + type QuickHighlightRequest struct { 271 + URL string `json:"url"` 272 + Selector interface{} `json:"selector"` 273 + Color string `json:"color,omitempty"` 274 + } 275 + 276 + func (h *APIKeyHandler) QuickHighlight(w http.ResponseWriter, r *http.Request) { 277 + apiKey, err := h.authenticateAPIKey(r) 278 + if err != nil { 279 + http.Error(w, err.Error(), http.StatusUnauthorized) 280 + return 281 + } 282 + 283 + var req QuickHighlightRequest 284 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 285 + http.Error(w, "Invalid request body", http.StatusBadRequest) 286 + return 287 + } 288 + 289 + if req.URL == "" || req.Selector == nil { 290 + http.Error(w, "URL and selector are required", http.StatusBadRequest) 291 + return 292 + } 293 + 294 + session, err := h.getSessionByDID(apiKey.OwnerDID) 295 + if err != nil { 296 + http.Error(w, "User session not found. Please log in to margin.at first.", http.StatusUnauthorized) 297 + return 298 + } 299 + 300 + urlHash := db.HashURL(req.URL) 301 + color := req.Color 302 + if color == "" { 303 + color = "yellow" 304 + } 305 + 306 + record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, color, nil) 307 + 308 + var result *xrpc.CreateRecordOutput 309 + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 310 + var createErr error 311 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record) 312 + return createErr 313 + }) 314 + if err != nil { 315 + http.Error(w, "Failed to create highlight: "+err.Error(), http.StatusInternalServerError) 316 + return 317 + } 318 + 319 + h.db.UpdateAPIKeyLastUsed(apiKey.ID) 320 + 321 + selectorJSON, _ := json.Marshal(req.Selector) 322 + selectorStr := string(selectorJSON) 323 + colorPtr := &color 324 + 325 + highlight := &db.Highlight{ 326 + URI: result.URI, 327 + AuthorDID: apiKey.OwnerDID, 328 + TargetSource: req.URL, 329 + TargetHash: urlHash, 330 + SelectorJSON: &selectorStr, 331 + Color: colorPtr, 332 + CreatedAt: time.Now(), 333 + IndexedAt: time.Now(), 334 + CID: &result.CID, 335 + } 336 + if err := h.db.CreateHighlight(highlight); err != nil { 337 + fmt.Printf("Warning: failed to index highlight in local DB: %v\n", err) 338 + } 339 + 340 + w.Header().Set("Content-Type", "application/json") 341 + json.NewEncoder(w).Encode(map[string]string{ 342 + "uri": result.URI, 343 + "cid": result.CID, 344 + "message": "Highlight created successfully", 345 + }) 346 + } 347 + 348 + func (h *APIKeyHandler) authenticateAPIKey(r *http.Request) (*db.APIKey, error) { 349 + auth := r.Header.Get("Authorization") 350 + if auth == "" { 351 + return nil, fmt.Errorf("missing Authorization header") 352 + } 353 + 354 + if !strings.HasPrefix(auth, "Bearer ") { 355 + return nil, fmt.Errorf("invalid Authorization format, expected 'Bearer <key>'") 356 + } 357 + 358 + rawKey := strings.TrimPrefix(auth, "Bearer ") 359 + keyHash := hashAPIKey(rawKey) 360 + 361 + apiKey, err := h.db.GetAPIKeyByHash(keyHash) 362 + if err != nil { 363 + return nil, fmt.Errorf("invalid API key") 364 + } 365 + 366 + return apiKey, nil 367 + } 368 + 369 + func (h *APIKeyHandler) getSessionByDID(did string) (*SessionData, error) { 370 + rows, err := h.db.Query(h.db.Rebind(` 371 + SELECT id, did, handle, access_token, refresh_token, COALESCE(dpop_key, '') 372 + FROM sessions 373 + WHERE did = ? AND expires_at > ? 374 + ORDER BY created_at DESC 375 + LIMIT 1 376 + `), did, time.Now()) 377 + if err != nil { 378 + return nil, err 379 + } 380 + defer rows.Close() 381 + 382 + if !rows.Next() { 383 + return nil, fmt.Errorf("no active session") 384 + } 385 + 386 + var sessionID, sessDID, handle, accessToken, refreshToken, dpopKeyStr string 387 + if err := rows.Scan(&sessionID, &sessDID, &handle, &accessToken, &refreshToken, &dpopKeyStr); err != nil { 388 + return nil, err 389 + } 390 + 391 + block, _ := pem.Decode([]byte(dpopKeyStr)) 392 + if block == nil { 393 + return nil, fmt.Errorf("invalid session DPoP key") 394 + } 395 + dpopKey, err := x509.ParseECPrivateKey(block.Bytes) 396 + if err != nil { 397 + return nil, fmt.Errorf("invalid session DPoP key: %w", err) 398 + } 399 + 400 + pds, err := resolveDIDToPDS(sessDID) 401 + if err != nil { 402 + return nil, fmt.Errorf("failed to resolve PDS: %w", err) 403 + } 404 + if pds == "" { 405 + return nil, fmt.Errorf("PDS not found for DID: %s", sessDID) 406 + } 407 + 408 + return &SessionData{ 409 + ID: sessionID, 410 + DID: sessDID, 411 + Handle: handle, 412 + AccessToken: accessToken, 413 + RefreshToken: refreshToken, 414 + DPoPKey: dpopKey, 415 + PDS: pds, 416 + }, nil 417 + } 418 + 419 + func generateAPIKey() string { 420 + b := make([]byte, 32) 421 + rand.Read(b) 422 + return "mk_" + hex.EncodeToString(b) 423 + } 424 + 425 + func generateKeyID() string { 426 + b := make([]byte, 16) 427 + rand.Read(b) 428 + return hex.EncodeToString(b) 429 + } 430 + 431 + func hashAPIKey(key string) string { 432 + h := sha256.New() 433 + h.Write([]byte(key)) 434 + return hex.EncodeToString(h.Sum(nil)) 435 + }
+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 + }
+48
backend/internal/api/cache.go
···
··· 1 + package api 2 + 3 + import ( 4 + "sync" 5 + "time" 6 + ) 7 + 8 + type ProfileCache interface { 9 + Get(did string) (Author, bool) 10 + Set(did string, profile Author) 11 + } 12 + type InMemoryCache struct { 13 + cache sync.Map 14 + ttl time.Duration 15 + } 16 + 17 + type cachedProfile struct { 18 + Author Author 19 + ExpiresAt time.Time 20 + } 21 + 22 + func NewInMemoryCache(ttl time.Duration) *InMemoryCache { 23 + return &InMemoryCache{ 24 + ttl: ttl, 25 + } 26 + } 27 + 28 + func (c *InMemoryCache) Get(did string) (Author, bool) { 29 + val, ok := c.cache.Load(did) 30 + if !ok { 31 + return Author{}, false 32 + } 33 + 34 + entry := val.(cachedProfile) 35 + if time.Now().After(entry.ExpiresAt) { 36 + c.cache.Delete(did) 37 + return Author{}, false 38 + } 39 + 40 + return entry.Author, true 41 + } 42 + 43 + func (c *InMemoryCache) Set(did string, profile Author) { 44 + c.cache.Store(did, cachedProfile{ 45 + Author: profile, 46 + ExpiresAt: time.Now().Add(c.ttl), 47 + }) 48 + }
+16 -1
backend/internal/api/handler.go
··· 19 db *db.DB 20 annotationService *AnnotationService 21 refresher *TokenRefresher 22 } 23 24 func NewHandler(database *db.DB, annotationService *AnnotationService, refresher *TokenRefresher) *Handler { 25 - return &Handler{db: database, annotationService: annotationService, refresher: refresher} 26 } 27 28 func (h *Handler) RegisterRoutes(r chi.Router) { ··· 64 r.Get("/notifications", h.GetNotifications) 65 r.Get("/notifications/count", h.GetUnreadNotificationCount) 66 r.Post("/notifications/read", h.MarkNotificationsRead) 67 }) 68 } 69
··· 19 db *db.DB 20 annotationService *AnnotationService 21 refresher *TokenRefresher 22 + apiKeys *APIKeyHandler 23 } 24 25 func NewHandler(database *db.DB, annotationService *AnnotationService, refresher *TokenRefresher) *Handler { 26 + return &Handler{ 27 + db: database, 28 + annotationService: annotationService, 29 + refresher: refresher, 30 + apiKeys: NewAPIKeyHandler(database, refresher), 31 + } 32 } 33 34 func (h *Handler) RegisterRoutes(r chi.Router) { ··· 70 r.Get("/notifications", h.GetNotifications) 71 r.Get("/notifications/count", h.GetUnreadNotificationCount) 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) 81 + r.Post("/quick/highlight", h.apiKeys.QuickHighlight) 82 }) 83 } 84
+169 -82
backend/internal/api/hydration.go
··· 13 "margin.at/internal/db" 14 ) 15 16 type Author struct { 17 DID string `json:"did"` 18 Handle string `json:"handle"` ··· 148 149 profiles := fetchProfilesForDIDs(collectDIDs(annotations, func(a db.Annotation) string { return a.AuthorDID })) 150 151 result := make([]APIAnnotation, len(annotations)) 152 for i, a := range annotations { 153 var body *APIBody ··· 208 } 209 210 if database != nil { 211 - result[i].LikeCount, _ = database.GetLikeCount(a.URI) 212 - result[i].ReplyCount, _ = database.GetReplyCount(a.URI) 213 - if viewerDID != "" { 214 - if _, err := database.GetLikeByUserAndSubject(viewerDID, a.URI); err == nil { 215 - result[i].ViewerHasLiked = true 216 - } 217 } 218 } 219 } ··· 228 229 profiles := fetchProfilesForDIDs(collectDIDs(highlights, func(h db.Highlight) string { return h.AuthorDID })) 230 231 result := make([]APIHighlight, len(highlights)) 232 for i, h := range highlights { 233 var selector *APISelector ··· 272 } 273 274 if database != nil { 275 - result[i].LikeCount, _ = database.GetLikeCount(h.URI) 276 - result[i].ReplyCount, _ = database.GetReplyCount(h.URI) 277 - if viewerDID != "" { 278 - if _, err := database.GetLikeByUserAndSubject(viewerDID, h.URI); err == nil { 279 - result[i].ViewerHasLiked = true 280 - } 281 } 282 } 283 } ··· 292 293 profiles := fetchProfilesForDIDs(collectDIDs(bookmarks, func(b db.Bookmark) string { return b.AuthorDID })) 294 295 result := make([]APIBookmark, len(bookmarks)) 296 for i, b := range bookmarks { 297 var tags []string ··· 326 CID: cid, 327 } 328 if database != nil { 329 - result[i].LikeCount, _ = database.GetLikeCount(b.URI) 330 - result[i].ReplyCount, _ = database.GetReplyCount(b.URI) 331 - if viewerDID != "" { 332 - if _, err := database.GetLikeByUserAndSubject(viewerDID, b.URI); err == nil { 333 - result[i].ViewerHasLiked = true 334 - } 335 } 336 } 337 } ··· 388 389 func fetchProfilesForDIDs(dids []string) map[string]Author { 390 profiles := make(map[string]Author) 391 392 for _, did := range dids { 393 - profiles[did] = Author{ 394 - DID: did, 395 - Handle: "unknown", 396 } 397 } 398 399 - if len(dids) == 0 { 400 return profiles 401 } 402 ··· 404 var wg sync.WaitGroup 405 var mu sync.Mutex 406 407 - for i := 0; i < len(dids); i += batchSize { 408 end := i + batchSize 409 - if end > len(dids) { 410 - end = len(dids) 411 } 412 - batch := dids[i:end] 413 414 wg.Add(1) 415 go func(actors []string) { ··· 417 fetched, err := fetchProfiles(actors) 418 if err == nil { 419 mu.Lock() 420 for k, v := range fetched { 421 profiles[k] = v 422 } 423 - mu.Unlock() 424 } 425 }(batch) 426 } ··· 470 DID: p.DID, 471 Handle: p.Handle, 472 DisplayName: p.DisplayName, 473 - Avatar: p.Avatar, 474 } 475 } 476 ··· 484 485 profiles := fetchProfilesForDIDs(collectDIDs(items, func(i db.CollectionItem) string { return i.AuthorDID })) 486 487 result := make([]APICollectionItem, len(items)) 488 for i, item := range items { 489 apiItem := APICollectionItem{ ··· 495 Position: item.Position, 496 } 497 498 - if coll, err := database.GetCollectionByURI(item.CollectionURI); err == nil { 499 - icon := "" 500 - if coll.Icon != nil { 501 - icon = *coll.Icon 502 - } 503 - desc := "" 504 - if coll.Description != nil { 505 - desc = *coll.Description 506 - } 507 - apiItem.Collection = &APICollection{ 508 - URI: coll.URI, 509 - Name: coll.Name, 510 - Description: desc, 511 - Icon: icon, 512 - Creator: profiles[coll.AuthorDID], 513 - CreatedAt: coll.CreatedAt, 514 - IndexedAt: coll.IndexedAt, 515 - } 516 } 517 518 - if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 519 - if a, err := database.GetAnnotationByURI(item.AnnotationURI); err == nil { 520 - hydrated, _ := hydrateAnnotations(database, []db.Annotation{*a}, viewerDID) 521 - if len(hydrated) > 0 { 522 - apiItem.Annotation = &hydrated[0] 523 - } 524 - } 525 - } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 526 - if h, err := database.GetHighlightByURI(item.AnnotationURI); err == nil { 527 - hydrated, _ := hydrateHighlights(database, []db.Highlight{*h}, viewerDID) 528 - if len(hydrated) > 0 { 529 - apiItem.Highlight = &hydrated[0] 530 - } 531 - } 532 - } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 533 - if b, err := database.GetBookmarkByURI(item.AnnotationURI); err == nil { 534 - hydrated, _ := hydrateBookmarks(database, []db.Bookmark{*b}, viewerDID) 535 - if len(hydrated) > 0 { 536 - apiItem.Bookmark = &hydrated[0] 537 - } else { 538 - log.Printf("Failed to hydrate bookmark %s: empty hydration result\n", item.AnnotationURI) 539 - } 540 - } else { 541 - } 542 - } else { 543 - log.Printf("Unknown item type for URI: %s\n", item.AnnotationURI) 544 } 545 546 result[i] = apiItem ··· 577 578 replyMap := make(map[string]APIReply) 579 if len(replyURIs) > 0 { 580 - var replies []db.Reply 581 - for _, uri := range replyURIs { 582 - r, err := database.GetReplyByURI(uri) 583 - if err == nil { 584 - replies = append(replies, *r) 585 } 586 - } 587 - 588 - hydratedReplies, _ := hydrateReplies(replies) 589 - for _, r := range hydratedReplies { 590 - replyMap[r.ID] = r 591 } 592 } 593
··· 13 "margin.at/internal/db" 14 ) 15 16 + var ( 17 + Cache ProfileCache = NewInMemoryCache(5 * time.Minute) 18 + ) 19 + 20 type Author struct { 21 DID string `json:"did"` 22 Handle string `json:"handle"` ··· 152 153 profiles := fetchProfilesForDIDs(collectDIDs(annotations, func(a db.Annotation) string { return a.AuthorDID })) 154 155 + var likeCounts map[string]int 156 + var replyCounts map[string]int 157 + var viewerLikes map[string]bool 158 + 159 + if database != nil { 160 + uris := make([]string, len(annotations)) 161 + for i, a := range annotations { 162 + uris[i] = a.URI 163 + } 164 + 165 + likeCounts, _ = database.GetLikeCounts(uris) 166 + replyCounts, _ = database.GetReplyCounts(uris) 167 + if viewerDID != "" { 168 + viewerLikes, _ = database.GetViewerLikes(viewerDID, uris) 169 + } 170 + } 171 + 172 result := make([]APIAnnotation, len(annotations)) 173 for i, a := range annotations { 174 var body *APIBody ··· 229 } 230 231 if database != nil { 232 + result[i].LikeCount = likeCounts[a.URI] 233 + result[i].ReplyCount = replyCounts[a.URI] 234 + if viewerLikes != nil && viewerLikes[a.URI] { 235 + result[i].ViewerHasLiked = true 236 } 237 } 238 } ··· 247 248 profiles := fetchProfilesForDIDs(collectDIDs(highlights, func(h db.Highlight) string { return h.AuthorDID })) 249 250 + var likeCounts map[string]int 251 + var replyCounts map[string]int 252 + var viewerLikes map[string]bool 253 + 254 + if database != nil { 255 + uris := make([]string, len(highlights)) 256 + for i, h := range highlights { 257 + uris[i] = h.URI 258 + } 259 + 260 + likeCounts, _ = database.GetLikeCounts(uris) 261 + replyCounts, _ = database.GetReplyCounts(uris) 262 + if viewerDID != "" { 263 + viewerLikes, _ = database.GetViewerLikes(viewerDID, uris) 264 + } 265 + } 266 + 267 result := make([]APIHighlight, len(highlights)) 268 for i, h := range highlights { 269 var selector *APISelector ··· 308 } 309 310 if database != nil { 311 + result[i].LikeCount = likeCounts[h.URI] 312 + result[i].ReplyCount = replyCounts[h.URI] 313 + if viewerLikes != nil && viewerLikes[h.URI] { 314 + result[i].ViewerHasLiked = true 315 } 316 } 317 } ··· 326 327 profiles := fetchProfilesForDIDs(collectDIDs(bookmarks, func(b db.Bookmark) string { return b.AuthorDID })) 328 329 + var likeCounts map[string]int 330 + var replyCounts map[string]int 331 + var viewerLikes map[string]bool 332 + 333 + if database != nil { 334 + uris := make([]string, len(bookmarks)) 335 + for i, b := range bookmarks { 336 + uris[i] = b.URI 337 + } 338 + 339 + likeCounts, _ = database.GetLikeCounts(uris) 340 + replyCounts, _ = database.GetReplyCounts(uris) 341 + if viewerDID != "" { 342 + viewerLikes, _ = database.GetViewerLikes(viewerDID, uris) 343 + } 344 + } 345 + 346 result := make([]APIBookmark, len(bookmarks)) 347 for i, b := range bookmarks { 348 var tags []string ··· 377 CID: cid, 378 } 379 if database != nil { 380 + result[i].LikeCount = likeCounts[b.URI] 381 + result[i].ReplyCount = replyCounts[b.URI] 382 + if viewerLikes != nil && viewerLikes[b.URI] { 383 + result[i].ViewerHasLiked = true 384 } 385 } 386 } ··· 437 438 func fetchProfilesForDIDs(dids []string) map[string]Author { 439 profiles := make(map[string]Author) 440 + missingDIDs := make([]string, 0) 441 442 for _, did := range dids { 443 + if author, ok := Cache.Get(did); ok { 444 + profiles[did] = author 445 + } else { 446 + missingDIDs = append(missingDIDs, did) 447 } 448 } 449 450 + if len(missingDIDs) == 0 { 451 return profiles 452 } 453 ··· 455 var wg sync.WaitGroup 456 var mu sync.Mutex 457 458 + for i := 0; i < len(missingDIDs); i += batchSize { 459 end := i + batchSize 460 + if end > len(missingDIDs) { 461 + end = len(missingDIDs) 462 } 463 + batch := missingDIDs[i:end] 464 465 wg.Add(1) 466 go func(actors []string) { ··· 468 fetched, err := fetchProfiles(actors) 469 if err == nil { 470 mu.Lock() 471 + defer mu.Unlock() 472 for k, v := range fetched { 473 profiles[k] = v 474 + Cache.Set(k, v) 475 } 476 } 477 }(batch) 478 } ··· 522 DID: p.DID, 523 Handle: p.Handle, 524 DisplayName: p.DisplayName, 525 + Avatar: getProxiedAvatarURL(p.DID, p.Avatar), 526 } 527 } 528 ··· 536 537 profiles := fetchProfilesForDIDs(collectDIDs(items, func(i db.CollectionItem) string { return i.AuthorDID })) 538 539 + var collectionURIs []string 540 + var annotationURIs []string 541 + var highlightURIs []string 542 + var bookmarkURIs []string 543 + 544 + for _, item := range items { 545 + collectionURIs = append(collectionURIs, item.CollectionURI) 546 + if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 547 + annotationURIs = append(annotationURIs, item.AnnotationURI) 548 + } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 549 + highlightURIs = append(highlightURIs, item.AnnotationURI) 550 + } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 551 + bookmarkURIs = append(bookmarkURIs, item.AnnotationURI) 552 + } 553 + } 554 + 555 + collectionsMap := make(map[string]APICollection) 556 + if len(collectionURIs) > 0 { 557 + colls, err := database.GetCollectionsByURIs(collectionURIs) 558 + if err == nil { 559 + collProfiles := fetchProfilesForDIDs(collectDIDs(colls, func(c db.Collection) string { return c.AuthorDID })) 560 + for _, coll := range colls { 561 + icon := "" 562 + if coll.Icon != nil { 563 + icon = *coll.Icon 564 + } 565 + desc := "" 566 + if coll.Description != nil { 567 + desc = *coll.Description 568 + } 569 + collectionsMap[coll.URI] = APICollection{ 570 + URI: coll.URI, 571 + Name: coll.Name, 572 + Description: desc, 573 + Icon: icon, 574 + Creator: collProfiles[coll.AuthorDID], 575 + CreatedAt: coll.CreatedAt, 576 + IndexedAt: coll.IndexedAt, 577 + } 578 + } 579 + } 580 + } 581 + 582 + annotationsMap := make(map[string]APIAnnotation) 583 + if len(annotationURIs) > 0 { 584 + rawAnnos, err := database.GetAnnotationsByURIs(annotationURIs) 585 + if err == nil { 586 + hydrated, _ := hydrateAnnotations(database, rawAnnos, viewerDID) 587 + for _, a := range hydrated { 588 + annotationsMap[a.ID] = a 589 + } 590 + } 591 + } 592 + 593 + highlightsMap := make(map[string]APIHighlight) 594 + if len(highlightURIs) > 0 { 595 + rawHighlights, err := database.GetHighlightsByURIs(highlightURIs) 596 + if err == nil { 597 + hydrated, _ := hydrateHighlights(database, rawHighlights, viewerDID) 598 + for _, h := range hydrated { 599 + highlightsMap[h.ID] = h 600 + } 601 + } 602 + } 603 + 604 + bookmarksMap := make(map[string]APIBookmark) 605 + if len(bookmarkURIs) > 0 { 606 + rawBookmarks, err := database.GetBookmarksByURIs(bookmarkURIs) 607 + if err == nil { 608 + hydrated, _ := hydrateBookmarks(database, rawBookmarks, viewerDID) 609 + for _, b := range hydrated { 610 + bookmarksMap[b.ID] = b 611 + } 612 + } 613 + } 614 + 615 result := make([]APICollectionItem, len(items)) 616 for i, item := range items { 617 apiItem := APICollectionItem{ ··· 623 Position: item.Position, 624 } 625 626 + if coll, ok := collectionsMap[item.CollectionURI]; ok { 627 + apiItem.Collection = &coll 628 } 629 630 + if val, ok := annotationsMap[item.AnnotationURI]; ok { 631 + apiItem.Annotation = &val 632 + } else if val, ok := highlightsMap[item.AnnotationURI]; ok { 633 + apiItem.Highlight = &val 634 + } else if val, ok := bookmarksMap[item.AnnotationURI]; ok { 635 + apiItem.Bookmark = &val 636 } 637 638 result[i] = apiItem ··· 669 670 replyMap := make(map[string]APIReply) 671 if len(replyURIs) > 0 { 672 + replies, err := database.GetRepliesByURIs(replyURIs) 673 + if err == nil { 674 + hydratedReplies, _ := hydrateReplies(replies) 675 + for _, r := range hydratedReplies { 676 + replyMap[r.ID] = r 677 } 678 } 679 } 680
+10 -4
backend/internal/api/token_refresh.go
··· 52 } 53 54 type SessionData struct { 55 DID string 56 Handle string 57 AccessToken string ··· 94 } 95 96 return &SessionData{ 97 DID: did, 98 Handle: handle, 99 AccessToken: accessToken, ··· 104 } 105 106 func (tr *TokenRefresher) RefreshSessionToken(r *http.Request, session *SessionData) (*SessionData, error) { 107 - cookie, err := r.Cookie("margin_session") 108 - if err != nil { 109 - return nil, fmt.Errorf("not authenticated") 110 } 111 112 oauthClient := tr.getOAuthClient(r) ··· 138 139 expiresAt := time.Now().Add(7 * 24 * time.Hour) 140 if err := tr.db.SaveSession( 141 - cookie.Value, 142 session.DID, 143 session.Handle, 144 tokenResp.AccessToken, ··· 152 log.Printf("Successfully refreshed token for user %s", session.Handle) 153 154 return &SessionData{ 155 DID: session.DID, 156 Handle: session.Handle, 157 AccessToken: tokenResp.AccessToken, ··· 196 client = xrpc.NewClient(newSession.PDS, newSession.AccessToken, newSession.DPoPKey) 197 return fn(client, newSession.DID) 198 }
··· 52 } 53 54 type SessionData struct { 55 + ID string 56 DID string 57 Handle string 58 AccessToken string ··· 95 } 96 97 return &SessionData{ 98 + ID: sessionID, 99 DID: did, 100 Handle: handle, 101 AccessToken: accessToken, ··· 106 } 107 108 func (tr *TokenRefresher) RefreshSessionToken(r *http.Request, session *SessionData) (*SessionData, error) { 109 + if session.ID == "" { 110 + return nil, fmt.Errorf("invalid session ID") 111 } 112 113 oauthClient := tr.getOAuthClient(r) ··· 139 140 expiresAt := time.Now().Add(7 * 24 * time.Hour) 141 if err := tr.db.SaveSession( 142 + session.ID, 143 session.DID, 144 session.Handle, 145 tokenResp.AccessToken, ··· 153 log.Printf("Successfully refreshed token for user %s", session.Handle) 154 155 return &SessionData{ 156 + ID: session.ID, 157 DID: session.DID, 158 Handle: session.Handle, 159 AccessToken: tokenResp.AccessToken, ··· 198 client = xrpc.NewClient(newSession.PDS, newSession.AccessToken, newSession.DPoPKey) 199 return fn(client, newSession.DID) 200 } 201 + 202 + func (tr *TokenRefresher) CreateClientFromSession(session *SessionData) *xrpc.Client { 203 + return xrpc.NewClient(session.PDS, session.AccessToken, session.DPoPKey) 204 + }
+26 -1
backend/internal/db/db.go
··· 120 ReadAt *time.Time `json:"readAt,omitempty"` 121 } 122 123 func New(dsn string) (*DB, error) { 124 driver := "sqlite3" 125 if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { ··· 232 )`) 233 db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_subject_uri ON likes(subject_uri)`) 234 db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_author_did ON likes(author_did)`) 235 236 db.Exec(`CREATE TABLE IF NOT EXISTS collections ( 237 uri TEXT PRIMARY KEY, ··· 296 db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_did)`) 297 db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC)`) 298 299 db.runMigrations() 300 301 db.Exec(`CREATE TABLE IF NOT EXISTS cursors ( 302 id TEXT PRIMARY KEY, 303 - last_cursor INTEGER NOT NULL, 304 updated_at ` + dateType + ` NOT NULL 305 )`) 306 ··· 353 db.Exec(`UPDATE annotations SET body_value = text WHERE body_value IS NULL AND text IS NOT NULL`) 354 db.Exec(`UPDATE annotations SET target_title = title WHERE target_title IS NULL AND title IS NOT NULL`) 355 db.Exec(`UPDATE annotations SET motivation = 'commenting' WHERE motivation IS NULL`) 356 } 357 358 func (db *DB) Close() error {
··· 120 ReadAt *time.Time `json:"readAt,omitempty"` 121 } 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 + 132 func New(dsn string) (*DB, error) { 133 driver := "sqlite3" 134 if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { ··· 241 )`) 242 db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_subject_uri ON likes(subject_uri)`) 243 db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_author_did ON likes(author_did)`) 244 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_author_subject ON likes(author_did, subject_uri)`) 245 246 db.Exec(`CREATE TABLE IF NOT EXISTS collections ( 247 uri TEXT PRIMARY KEY, ··· 306 db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_did)`) 307 db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC)`) 308 309 + db.Exec(`CREATE TABLE IF NOT EXISTS api_keys ( 310 + id TEXT PRIMARY KEY, 311 + owner_did TEXT NOT NULL, 312 + name TEXT NOT NULL, 313 + key_hash TEXT NOT NULL, 314 + created_at ` + dateType + ` NOT NULL, 315 + last_used_at ` + dateType + ` 316 + )`) 317 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_did)`) 318 + db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)`) 319 + 320 db.runMigrations() 321 322 db.Exec(`CREATE TABLE IF NOT EXISTS cursors ( 323 id TEXT PRIMARY KEY, 324 + last_cursor BIGINT NOT NULL, 325 updated_at ` + dateType + ` NOT NULL 326 )`) 327 ··· 374 db.Exec(`UPDATE annotations SET body_value = text WHERE body_value IS NULL AND text IS NOT NULL`) 375 db.Exec(`UPDATE annotations SET target_title = title WHERE target_title IS NULL AND title IS NOT NULL`) 376 db.Exec(`UPDATE annotations SET motivation = 'commenting' WHERE motivation IS NULL`) 377 + 378 + if db.driver == "postgres" { 379 + db.Exec(`ALTER TABLE cursors ALTER COLUMN last_cursor TYPE BIGINT`) 380 + } 381 } 382 383 func (db *DB) Close() error {
+11 -825
backend/internal/db/queries.go
··· 10 "time" 11 ) 12 13 - func (db *DB) CreateAnnotation(a *Annotation) error { 14 - _, err := db.Exec(db.Rebind(` 15 - INSERT INTO annotations (uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid) 16 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 17 - ON CONFLICT(uri) DO UPDATE SET 18 - motivation = excluded.motivation, 19 - body_value = excluded.body_value, 20 - body_format = excluded.body_format, 21 - body_uri = excluded.body_uri, 22 - target_title = excluded.target_title, 23 - selector_json = excluded.selector_json, 24 - tags_json = excluded.tags_json, 25 - indexed_at = excluded.indexed_at, 26 - cid = excluded.cid 27 - `), a.URI, a.AuthorDID, a.Motivation, a.BodyValue, a.BodyFormat, a.BodyURI, a.TargetSource, a.TargetHash, a.TargetTitle, a.SelectorJSON, a.TagsJSON, a.CreatedAt, a.IndexedAt, a.CID) 28 - return err 29 - } 30 - 31 - func (db *DB) GetAnnotationByURI(uri string) (*Annotation, error) { 32 - var a Annotation 33 - err := db.QueryRow(db.Rebind(` 34 - SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 35 - FROM annotations 36 - WHERE uri = ? 37 - `), uri).Scan(&a.URI, &a.AuthorDID, &a.Motivation, &a.BodyValue, &a.BodyFormat, &a.BodyURI, &a.TargetSource, &a.TargetHash, &a.TargetTitle, &a.SelectorJSON, &a.TagsJSON, &a.CreatedAt, &a.IndexedAt, &a.CID) 38 - if err != nil { 39 - return nil, err 40 - } 41 - return &a, nil 42 - } 43 - 44 - func (db *DB) GetAnnotationsByTargetHash(targetHash string, limit, offset int) ([]Annotation, error) { 45 - rows, err := db.Query(db.Rebind(` 46 - SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 47 - FROM annotations 48 - WHERE target_hash = ? 49 - ORDER BY created_at DESC 50 - LIMIT ? OFFSET ? 51 - `), targetHash, limit, offset) 52 - if err != nil { 53 - return nil, err 54 - } 55 - defer rows.Close() 56 - 57 - return scanAnnotations(rows) 58 - } 59 - 60 - func (db *DB) GetAnnotationsByAuthor(authorDID string, limit, offset int) ([]Annotation, error) { 61 - rows, err := db.Query(db.Rebind(` 62 - SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 63 - FROM annotations 64 - WHERE author_did = ? 65 - ORDER BY created_at DESC 66 - LIMIT ? OFFSET ? 67 - `), authorDID, limit, offset) 68 - if err != nil { 69 - return nil, err 70 - } 71 - defer rows.Close() 72 - 73 - return scanAnnotations(rows) 74 - } 75 - 76 - func (db *DB) GetAnnotationsByMotivation(motivation string, limit, offset int) ([]Annotation, error) { 77 - rows, err := db.Query(db.Rebind(` 78 - SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 79 - FROM annotations 80 - WHERE motivation = ? 81 - ORDER BY created_at DESC 82 - LIMIT ? OFFSET ? 83 - `), motivation, limit, offset) 84 - if err != nil { 85 - return nil, err 86 - } 87 - defer rows.Close() 88 - 89 - return scanAnnotations(rows) 90 - } 91 - 92 - func (db *DB) GetRecentAnnotations(limit, offset int) ([]Annotation, error) { 93 - rows, err := db.Query(db.Rebind(` 94 - SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 95 - FROM annotations 96 - ORDER BY created_at DESC 97 - LIMIT ? OFFSET ? 98 - `), limit, offset) 99 - if err != nil { 100 - return nil, err 101 - } 102 - defer rows.Close() 103 - 104 - return scanAnnotations(rows) 105 - } 106 - 107 - func (db *DB) GetAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) { 108 - pattern := "%\"" + tag + "\"%" 109 - rows, err := db.Query(db.Rebind(` 110 - SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 111 - FROM annotations 112 - WHERE tags_json LIKE ? 113 - ORDER BY created_at DESC 114 - LIMIT ? OFFSET ? 115 - `), pattern, limit, offset) 116 - if err != nil { 117 - return nil, err 118 - } 119 - defer rows.Close() 120 - 121 - return scanAnnotations(rows) 122 - } 123 - 124 - func (db *DB) DeleteAnnotation(uri string) error { 125 - _, err := db.Exec(db.Rebind(`DELETE FROM annotations WHERE uri = ?`), uri) 126 - return err 127 - } 128 - 129 - func (db *DB) UpdateAnnotation(uri, bodyValue, tagsJSON, cid string) error { 130 - _, err := db.Exec(db.Rebind(` 131 - UPDATE annotations 132 - SET body_value = ?, tags_json = ?, cid = ?, indexed_at = ? 133 - WHERE uri = ? 134 - `), bodyValue, tagsJSON, cid, time.Now(), uri) 135 - return err 136 - } 137 - 138 - func (db *DB) UpdateHighlight(uri, color, tagsJSON, cid string) error { 139 - _, err := db.Exec(db.Rebind(` 140 - UPDATE highlights 141 - SET color = ?, tags_json = ?, cid = ?, indexed_at = ? 142 - WHERE uri = ? 143 - `), color, tagsJSON, cid, time.Now(), uri) 144 - return err 145 - } 146 - 147 - func (db *DB) UpdateBookmark(uri, title, description, tagsJSON, cid string) error { 148 - _, err := db.Exec(db.Rebind(` 149 - UPDATE bookmarks 150 - SET title = ?, description = ?, tags_json = ?, cid = ?, indexed_at = ? 151 - WHERE uri = ? 152 - `), title, description, tagsJSON, cid, time.Now(), uri) 153 - return err 154 - } 155 - 156 type EditHistory struct { 157 ID int `json:"id"` 158 URI string `json:"uri"` ··· 162 EditedAt time.Time `json:"editedAt"` 163 } 164 165 - func (db *DB) SaveEditHistory(uri, recordType, previousContent string, previousCID *string) error { 166 - _, err := db.Exec(db.Rebind(` 167 - INSERT INTO edit_history (uri, record_type, previous_content, previous_cid, edited_at) 168 - VALUES (?, ?, ?, ?, ?) 169 - `), uri, recordType, previousContent, previousCID, time.Now()) 170 - return err 171 - } 172 - 173 - func (db *DB) GetEditHistory(uri string) ([]EditHistory, error) { 174 - rows, err := db.Query(db.Rebind(` 175 - SELECT id, uri, record_type, previous_content, previous_cid, edited_at 176 - FROM edit_history 177 - WHERE uri = ? 178 - ORDER BY edited_at DESC 179 - `), uri) 180 - if err != nil { 181 - return nil, err 182 - } 183 - defer rows.Close() 184 - 185 - var history []EditHistory 186 - for rows.Next() { 187 - var h EditHistory 188 - if err := rows.Scan(&h.ID, &h.URI, &h.RecordType, &h.PreviousContent, &h.PreviousCID, &h.EditedAt); err != nil { 189 - return nil, err 190 - } 191 - history = append(history, h) 192 - } 193 - return history, nil 194 - } 195 - 196 func scanAnnotations(rows interface { 197 Next() bool 198 Scan(...interface{}) error ··· 208 return annotations, nil 209 } 210 211 - func (db *DB) CreateHighlight(h *Highlight) error { 212 - _, err := db.Exec(db.Rebind(` 213 - INSERT INTO highlights (uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid) 214 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 215 - ON CONFLICT(uri) DO UPDATE SET 216 - target_title = excluded.target_title, 217 - selector_json = excluded.selector_json, 218 - color = excluded.color, 219 - tags_json = excluded.tags_json, 220 - indexed_at = excluded.indexed_at, 221 - cid = excluded.cid 222 - `), h.URI, h.AuthorDID, h.TargetSource, h.TargetHash, h.TargetTitle, h.SelectorJSON, h.Color, h.TagsJSON, h.CreatedAt, h.IndexedAt, h.CID) 223 - return err 224 - } 225 - 226 - func (db *DB) GetHighlightByURI(uri string) (*Highlight, error) { 227 - var h Highlight 228 - err := db.QueryRow(db.Rebind(` 229 - SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 230 - FROM highlights 231 - WHERE uri = ? 232 - `), uri).Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID) 233 - if err != nil { 234 - return nil, err 235 - } 236 - return &h, nil 237 - } 238 - 239 - func (db *DB) GetRecentHighlights(limit, offset int) ([]Highlight, error) { 240 - rows, err := db.Query(db.Rebind(` 241 - SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 242 - FROM highlights 243 - ORDER BY created_at DESC 244 - LIMIT ? OFFSET ? 245 - `), limit, offset) 246 - if err != nil { 247 - return nil, err 248 - } 249 - defer rows.Close() 250 - 251 - var highlights []Highlight 252 - for rows.Next() { 253 - var h Highlight 254 - if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 255 - return nil, err 256 - } 257 - highlights = append(highlights, h) 258 - } 259 - return highlights, nil 260 - } 261 - 262 - func (db *DB) GetHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) { 263 - pattern := "%\"" + tag + "\"%" 264 - rows, err := db.Query(db.Rebind(` 265 - SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 266 - FROM highlights 267 - WHERE tags_json LIKE ? 268 - ORDER BY created_at DESC 269 - LIMIT ? OFFSET ? 270 - `), pattern, limit, offset) 271 - if err != nil { 272 - return nil, err 273 - } 274 - defer rows.Close() 275 - 276 - var highlights []Highlight 277 - for rows.Next() { 278 - var h Highlight 279 - if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 280 - return nil, err 281 - } 282 - highlights = append(highlights, h) 283 - } 284 - return highlights, nil 285 - } 286 - 287 - func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) { 288 - rows, err := db.Query(db.Rebind(` 289 - SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 290 - FROM bookmarks 291 - ORDER BY created_at DESC 292 - LIMIT ? OFFSET ? 293 - `), limit, offset) 294 - if err != nil { 295 - return nil, err 296 - } 297 - defer rows.Close() 298 - 299 - var bookmarks []Bookmark 300 - for rows.Next() { 301 - var b Bookmark 302 - if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 303 - return nil, err 304 - } 305 - bookmarks = append(bookmarks, b) 306 - } 307 - return bookmarks, nil 308 - } 309 - 310 - func (db *DB) GetBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) { 311 - pattern := "%\"" + tag + "\"%" 312 - rows, err := db.Query(db.Rebind(` 313 - SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 314 - FROM bookmarks 315 - WHERE tags_json LIKE ? 316 - ORDER BY created_at DESC 317 - LIMIT ? OFFSET ? 318 - `), pattern, limit, offset) 319 - if err != nil { 320 - return nil, err 321 - } 322 - defer rows.Close() 323 - 324 - var bookmarks []Bookmark 325 - for rows.Next() { 326 - var b Bookmark 327 - if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 328 - return nil, err 329 - } 330 - bookmarks = append(bookmarks, b) 331 - } 332 - return bookmarks, nil 333 - } 334 - 335 - func (db *DB) GetAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) { 336 - pattern := "%\"" + tag + "\"%" 337 - rows, err := db.Query(db.Rebind(` 338 - SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 339 - FROM annotations 340 - WHERE author_did = ? AND tags_json LIKE ? 341 - ORDER BY created_at DESC 342 - LIMIT ? OFFSET ? 343 - `), authorDID, pattern, limit, offset) 344 - if err != nil { 345 - return nil, err 346 - } 347 - defer rows.Close() 348 - 349 - return scanAnnotations(rows) 350 - } 351 - 352 - func (db *DB) GetHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) { 353 - pattern := "%\"" + tag + "\"%" 354 - rows, err := db.Query(db.Rebind(` 355 - SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 356 - FROM highlights 357 - WHERE author_did = ? AND tags_json LIKE ? 358 - ORDER BY created_at DESC 359 - LIMIT ? OFFSET ? 360 - `), authorDID, pattern, limit, offset) 361 - if err != nil { 362 - return nil, err 363 - } 364 - defer rows.Close() 365 - 366 - var highlights []Highlight 367 - for rows.Next() { 368 - var h Highlight 369 - if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 370 - return nil, err 371 - } 372 - highlights = append(highlights, h) 373 - } 374 - return highlights, nil 375 - } 376 - 377 - func (db *DB) GetBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) { 378 - pattern := "%\"" + tag + "\"%" 379 - rows, err := db.Query(db.Rebind(` 380 - SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 381 - FROM bookmarks 382 - WHERE author_did = ? AND tags_json LIKE ? 383 - ORDER BY created_at DESC 384 - LIMIT ? OFFSET ? 385 - `), authorDID, pattern, limit, offset) 386 - if err != nil { 387 - return nil, err 388 - } 389 - defer rows.Close() 390 - 391 - var bookmarks []Bookmark 392 - for rows.Next() { 393 - var b Bookmark 394 - if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 395 - return nil, err 396 - } 397 - bookmarks = append(bookmarks, b) 398 - } 399 - return bookmarks, nil 400 - } 401 - 402 - func (db *DB) GetHighlightsByTargetHash(targetHash string, limit, offset int) ([]Highlight, error) { 403 - rows, err := db.Query(db.Rebind(` 404 - SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 405 - FROM highlights 406 - WHERE target_hash = ? 407 - ORDER BY created_at DESC 408 - LIMIT ? OFFSET ? 409 - `), targetHash, limit, offset) 410 - if err != nil { 411 - return nil, err 412 - } 413 - defer rows.Close() 414 - 415 - var highlights []Highlight 416 - for rows.Next() { 417 - var h Highlight 418 - if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 419 - return nil, err 420 - } 421 - highlights = append(highlights, h) 422 - } 423 - return highlights, nil 424 - } 425 - 426 - func (db *DB) GetHighlightsByAuthor(authorDID string, limit, offset int) ([]Highlight, error) { 427 - rows, err := db.Query(db.Rebind(` 428 - SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 429 - FROM highlights 430 - WHERE author_did = ? 431 - ORDER BY created_at DESC 432 - LIMIT ? OFFSET ? 433 - `), authorDID, limit, offset) 434 - if err != nil { 435 - return nil, err 436 - } 437 - defer rows.Close() 438 - 439 - var highlights []Highlight 440 - for rows.Next() { 441 - var h Highlight 442 - if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 443 - return nil, err 444 - } 445 - highlights = append(highlights, h) 446 - } 447 - return highlights, nil 448 - } 449 - 450 - func (db *DB) DeleteHighlight(uri string) error { 451 - _, err := db.Exec(db.Rebind(`DELETE FROM highlights WHERE uri = ?`), uri) 452 - return err 453 - } 454 - 455 - func (db *DB) CreateBookmark(b *Bookmark) error { 456 - _, err := db.Exec(db.Rebind(` 457 - INSERT INTO bookmarks (uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid) 458 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 459 - ON CONFLICT(uri) DO UPDATE SET 460 - title = excluded.title, 461 - description = excluded.description, 462 - tags_json = excluded.tags_json, 463 - indexed_at = excluded.indexed_at, 464 - cid = excluded.cid 465 - `), b.URI, b.AuthorDID, b.Source, b.SourceHash, b.Title, b.Description, b.TagsJSON, b.CreatedAt, b.IndexedAt, b.CID) 466 - return err 467 - } 468 - 469 - func (db *DB) GetBookmarkByURI(uri string) (*Bookmark, error) { 470 - var b Bookmark 471 - err := db.QueryRow(db.Rebind(` 472 - SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 473 - FROM bookmarks 474 - WHERE uri = ? 475 - `), uri).Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID) 476 - if err != nil { 477 - return nil, err 478 - } 479 - return &b, nil 480 - } 481 - 482 - func (db *DB) GetBookmarksByAuthor(authorDID string, limit, offset int) ([]Bookmark, error) { 483 - rows, err := db.Query(db.Rebind(` 484 - SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 485 - FROM bookmarks 486 - WHERE author_did = ? 487 - ORDER BY created_at DESC 488 - LIMIT ? OFFSET ? 489 - `), authorDID, limit, offset) 490 - if err != nil { 491 - return nil, err 492 - } 493 - defer rows.Close() 494 - 495 - var bookmarks []Bookmark 496 - for rows.Next() { 497 - var b Bookmark 498 - if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 499 - return nil, err 500 - } 501 - bookmarks = append(bookmarks, b) 502 - } 503 - return bookmarks, nil 504 - } 505 - 506 - func (db *DB) DeleteBookmark(uri string) error { 507 - _, err := db.Exec(db.Rebind(`DELETE FROM bookmarks WHERE uri = ?`), uri) 508 - return err 509 - } 510 - 511 - func (db *DB) CreateReply(r *Reply) error { 512 - _, err := db.Exec(db.Rebind(` 513 - INSERT INTO replies (uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid) 514 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 515 - ON CONFLICT(uri) DO UPDATE SET 516 - text = excluded.text, 517 - format = excluded.format, 518 - indexed_at = excluded.indexed_at, 519 - cid = excluded.cid 520 - `), r.URI, r.AuthorDID, r.ParentURI, r.RootURI, r.Text, r.Format, r.CreatedAt, r.IndexedAt, r.CID) 521 - return err 522 - } 523 - 524 - func (db *DB) GetRepliesByRoot(rootURI string) ([]Reply, error) { 525 - rows, err := db.Query(db.Rebind(` 526 - SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid 527 - FROM replies 528 - WHERE root_uri = ? 529 - ORDER BY created_at ASC 530 - `), rootURI) 531 - if err != nil { 532 - return nil, err 533 - } 534 - defer rows.Close() 535 - 536 - var replies []Reply 537 - for rows.Next() { 538 - var r Reply 539 - if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { 540 - return nil, err 541 - } 542 - replies = append(replies, r) 543 - } 544 - return replies, nil 545 - } 546 - 547 - func (db *DB) GetReplyByURI(uri string) (*Reply, error) { 548 - var r Reply 549 - err := db.QueryRow(db.Rebind(` 550 - SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid 551 - FROM replies 552 - WHERE uri = ? 553 - `), uri).Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID) 554 - if err != nil { 555 - return nil, err 556 - } 557 - return &r, nil 558 - } 559 - 560 - func (db *DB) DeleteReply(uri string) error { 561 - _, err := db.Exec(db.Rebind(`DELETE FROM replies WHERE uri = ?`), uri) 562 - return err 563 - } 564 - 565 - func (db *DB) GetRepliesByAuthor(authorDID string) ([]Reply, error) { 566 - rows, err := db.Query(db.Rebind(` 567 - SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid 568 - FROM replies 569 - WHERE author_did = ? 570 - ORDER BY created_at DESC 571 - `), authorDID) 572 - if err != nil { 573 - return nil, err 574 - } 575 - defer rows.Close() 576 - 577 - var replies []Reply 578 - for rows.Next() { 579 - var r Reply 580 - if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { 581 - return nil, err 582 - } 583 - replies = append(replies, r) 584 - } 585 - return replies, nil 586 - } 587 - 588 func (db *DB) AnnotationExists(uri string) bool { 589 var count int 590 db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM annotations WHERE uri = ?`), uri).Scan(&count) 591 return count > 0 592 } 593 594 - func (db *DB) GetOrphanedRepliesByAuthor(authorDID string) ([]Reply, error) { 595 - rows, err := db.Query(db.Rebind(` 596 - SELECT r.uri, r.author_did, r.parent_uri, r.root_uri, r.text, r.format, r.created_at, r.indexed_at, r.cid 597 - FROM replies r 598 - LEFT JOIN annotations a ON r.root_uri = a.uri 599 - WHERE r.author_did = ? AND a.uri IS NULL 600 - `), authorDID) 601 - if err != nil { 602 - return nil, err 603 - } 604 - defer rows.Close() 605 - 606 - var replies []Reply 607 - for rows.Next() { 608 - var r Reply 609 - if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { 610 - return nil, err 611 - } 612 - replies = append(replies, r) 613 - } 614 - return replies, nil 615 - } 616 - 617 - func (db *DB) CreateLike(l *Like) error { 618 - _, err := db.Exec(db.Rebind(` 619 - INSERT INTO likes (uri, author_did, subject_uri, created_at, indexed_at) 620 - VALUES (?, ?, ?, ?, ?) 621 - ON CONFLICT(uri) DO NOTHING 622 - `), l.URI, l.AuthorDID, l.SubjectURI, l.CreatedAt, l.IndexedAt) 623 - return err 624 - } 625 - 626 - func (db *DB) DeleteLike(uri string) error { 627 - _, err := db.Exec(db.Rebind(`DELETE FROM likes WHERE uri = ?`), uri) 628 - return err 629 - } 630 - 631 - func (db *DB) GetLikeCount(subjectURI string) (int, error) { 632 - var count int 633 - err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM likes WHERE subject_uri = ?`), subjectURI).Scan(&count) 634 - return count, err 635 - } 636 - 637 - func (db *DB) GetReplyCount(rootURI string) (int, error) { 638 - var count int 639 - err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM replies WHERE root_uri = ?`), rootURI).Scan(&count) 640 - return count, err 641 - } 642 - 643 - func (db *DB) GetLikeByUserAndSubject(userDID, subjectURI string) (*Like, error) { 644 - var like Like 645 - err := db.QueryRow(db.Rebind(` 646 - SELECT uri, author_did, subject_uri, created_at, indexed_at 647 - FROM likes 648 - WHERE author_did = ? AND subject_uri = ? 649 - `), userDID, subjectURI).Scan(&like.URI, &like.AuthorDID, &like.SubjectURI, &like.CreatedAt, &like.IndexedAt) 650 - if err != nil { 651 - return nil, err 652 - } 653 - return &like, nil 654 - } 655 - 656 - func (db *DB) CreateCollection(c *Collection) error { 657 - _, err := db.Exec(db.Rebind(` 658 - INSERT INTO collections (uri, author_did, name, description, icon, created_at, indexed_at) 659 - VALUES (?, ?, ?, ?, ?, ?, ?) 660 - ON CONFLICT(uri) DO UPDATE SET 661 - name = excluded.name, 662 - description = excluded.description, 663 - icon = excluded.icon, 664 - indexed_at = excluded.indexed_at 665 - `), c.URI, c.AuthorDID, c.Name, c.Description, c.Icon, c.CreatedAt, c.IndexedAt) 666 - return err 667 - } 668 - 669 - func (db *DB) GetCollectionsByAuthor(authorDID string) ([]Collection, error) { 670 - rows, err := db.Query(db.Rebind(` 671 - SELECT uri, author_did, name, description, icon, created_at, indexed_at 672 - FROM collections 673 - WHERE author_did = ? 674 - ORDER BY created_at DESC 675 - `), authorDID) 676 - if err != nil { 677 - return nil, err 678 - } 679 - defer rows.Close() 680 - 681 - var collections []Collection 682 - for rows.Next() { 683 - var c Collection 684 - if err := rows.Scan(&c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &c.CreatedAt, &c.IndexedAt); err != nil { 685 - return nil, err 686 - } 687 - collections = append(collections, c) 688 - } 689 - return collections, nil 690 - } 691 - 692 - func (db *DB) GetCollectionByURI(uri string) (*Collection, error) { 693 - var c Collection 694 - err := db.QueryRow(db.Rebind(` 695 - SELECT uri, author_did, name, description, icon, created_at, indexed_at 696 - FROM collections 697 - WHERE uri = ? 698 - `), uri).Scan(&c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &c.CreatedAt, &c.IndexedAt) 699 - if err != nil { 700 - return nil, err 701 - } 702 - return &c, nil 703 - } 704 - 705 - func (db *DB) DeleteCollection(uri string) error { 706 - 707 - db.Exec(db.Rebind(`DELETE FROM collection_items WHERE collection_uri = ?`), uri) 708 - _, err := db.Exec(db.Rebind(`DELETE FROM collections WHERE uri = ?`), uri) 709 - return err 710 - } 711 - 712 - func (db *DB) AddToCollection(item *CollectionItem) error { 713 - _, err := db.Exec(db.Rebind(` 714 - INSERT INTO collection_items (uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at) 715 - VALUES (?, ?, ?, ?, ?, ?, ?) 716 - ON CONFLICT(uri) DO UPDATE SET 717 - position = excluded.position, 718 - indexed_at = excluded.indexed_at 719 - `), item.URI, item.AuthorDID, item.CollectionURI, item.AnnotationURI, item.Position, item.CreatedAt, item.IndexedAt) 720 - return err 721 - } 722 - 723 - func (db *DB) GetCollectionItems(collectionURI string) ([]CollectionItem, error) { 724 - rows, err := db.Query(db.Rebind(` 725 - SELECT uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at 726 - FROM collection_items 727 - WHERE collection_uri = ? 728 - ORDER BY position ASC, created_at DESC 729 - `), collectionURI) 730 - if err != nil { 731 - return nil, err 732 - } 733 - defer rows.Close() 734 - 735 - var items []CollectionItem 736 - for rows.Next() { 737 - var item CollectionItem 738 - if err := rows.Scan(&item.URI, &item.AuthorDID, &item.CollectionURI, &item.AnnotationURI, &item.Position, &item.CreatedAt, &item.IndexedAt); err != nil { 739 - return nil, err 740 - } 741 - items = append(items, item) 742 - } 743 - return items, nil 744 - } 745 - 746 - func (db *DB) RemoveFromCollection(uri string) error { 747 - _, err := db.Exec(db.Rebind(`DELETE FROM collection_items WHERE uri = ?`), uri) 748 - return err 749 - } 750 - 751 - func (db *DB) GetRecentCollectionItems(limit, offset int) ([]CollectionItem, error) { 752 - rows, err := db.Query(db.Rebind(` 753 - SELECT uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at 754 - FROM collection_items 755 - ORDER BY created_at DESC 756 - LIMIT ? OFFSET ? 757 - `), limit, offset) 758 - if err != nil { 759 - return nil, err 760 - } 761 - defer rows.Close() 762 - 763 - var items []CollectionItem 764 - for rows.Next() { 765 - var item CollectionItem 766 - if err := rows.Scan(&item.URI, &item.AuthorDID, &item.CollectionURI, &item.AnnotationURI, &item.Position, &item.CreatedAt, &item.IndexedAt); err != nil { 767 - return nil, err 768 - } 769 - items = append(items, item) 770 - } 771 - return items, nil 772 - } 773 - 774 - func (db *DB) GetCollectionURIsForAnnotation(annotationURI string) ([]string, error) { 775 - rows, err := db.Query(db.Rebind(` 776 - SELECT collection_uri FROM collection_items WHERE annotation_uri = ? 777 - `), annotationURI) 778 - if err != nil { 779 - return nil, err 780 - } 781 - defer rows.Close() 782 - 783 - var uris []string 784 - for rows.Next() { 785 - var uri string 786 - if err := rows.Scan(&uri); err != nil { 787 - return nil, err 788 - } 789 - uris = append(uris, uri) 790 - } 791 - return uris, nil 792 - } 793 - 794 - func (db *DB) SaveSession(id, did, handle, accessToken, refreshToken, dpopKey string, expiresAt time.Time) error { 795 - _, err := db.Exec(db.Rebind(` 796 - INSERT INTO sessions (id, did, handle, access_token, refresh_token, dpop_key, created_at, expires_at) 797 - VALUES (?, ?, ?, ?, ?, ?, ?, ?) 798 - ON CONFLICT(id) DO UPDATE SET 799 - access_token = excluded.access_token, 800 - refresh_token = excluded.refresh_token, 801 - dpop_key = excluded.dpop_key, 802 - expires_at = excluded.expires_at 803 - `), id, did, handle, accessToken, refreshToken, dpopKey, time.Now(), expiresAt) 804 - return err 805 - } 806 - 807 - func (db *DB) GetSession(id string) (did, handle, accessToken, refreshToken, dpopKey string, err error) { 808 - err = db.QueryRow(db.Rebind(` 809 - SELECT did, handle, access_token, refresh_token, COALESCE(dpop_key, '') 810 - FROM sessions 811 - WHERE id = ? AND expires_at > ? 812 - `), id, time.Now()).Scan(&did, &handle, &accessToken, &refreshToken, &dpopKey) 813 - return 814 - } 815 - 816 - func (db *DB) DeleteSession(id string) error { 817 - _, err := db.Exec(db.Rebind(`DELETE FROM sessions WHERE id = ?`), id) 818 - return err 819 - } 820 - 821 func HashURL(rawURL string) string { 822 parsed, err := url.Parse(rawURL) 823 if err != nil { ··· 844 return string(b) 845 } 846 847 - func (db *DB) CreateNotification(n *Notification) error { 848 - _, err := db.Exec(db.Rebind(` 849 - INSERT INTO notifications (recipient_did, actor_did, type, subject_uri, created_at) 850 - VALUES (?, ?, ?, ?, ?) 851 - `), n.RecipientDID, n.ActorDID, n.Type, n.SubjectURI, n.CreatedAt) 852 - return err 853 - } 854 - 855 - func (db *DB) GetNotifications(recipientDID string, limit, offset int) ([]Notification, error) { 856 - rows, err := db.Query(db.Rebind(` 857 - SELECT id, recipient_did, actor_did, type, subject_uri, created_at, read_at 858 - FROM notifications 859 - WHERE recipient_did = ? 860 - ORDER BY created_at DESC 861 - LIMIT ? OFFSET ? 862 - `), recipientDID, limit, offset) 863 - if err != nil { 864 - return nil, err 865 - } 866 - defer rows.Close() 867 - 868 - var notifications []Notification 869 - for rows.Next() { 870 - var n Notification 871 - if err := rows.Scan(&n.ID, &n.RecipientDID, &n.ActorDID, &n.Type, &n.SubjectURI, &n.CreatedAt, &n.ReadAt); err != nil { 872 - continue 873 - } 874 - notifications = append(notifications, n) 875 - } 876 - return notifications, nil 877 - } 878 - 879 - func (db *DB) GetUnreadNotificationCount(recipientDID string) (int, error) { 880 - var count int 881 - err := db.QueryRow(db.Rebind(` 882 - SELECT COUNT(*) FROM notifications WHERE recipient_did = ? AND read_at IS NULL 883 - `), recipientDID).Scan(&count) 884 - return count, err 885 - } 886 - 887 - func (db *DB) MarkNotificationsRead(recipientDID string) error { 888 - _, err := db.Exec(db.Rebind(` 889 - UPDATE notifications SET read_at = ? WHERE recipient_did = ? AND read_at IS NULL 890 - `), time.Now(), recipientDID) 891 - return err 892 - } 893 - 894 func (db *DB) GetAuthorByURI(uri string) (string, error) { 895 var authorDID string 896 err := db.QueryRow(db.Rebind(`SELECT author_did FROM annotations WHERE uri = ?`), uri).Scan(&authorDID) ··· 910 911 return "", fmt.Errorf("uri not found or no author") 912 }
··· 10 "time" 11 ) 12 13 type EditHistory struct { 14 ID int `json:"id"` 15 URI string `json:"uri"` ··· 19 EditedAt time.Time `json:"editedAt"` 20 } 21 22 func scanAnnotations(rows interface { 23 Next() bool 24 Scan(...interface{}) error ··· 34 return annotations, nil 35 } 36 37 func (db *DB) AnnotationExists(uri string) bool { 38 var count int 39 db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM annotations WHERE uri = ?`), uri).Scan(&count) 40 return count > 0 41 } 42 43 func HashURL(rawURL string) string { 44 parsed, err := url.Parse(rawURL) 45 if err != nil { ··· 66 return string(b) 67 } 68 69 func (db *DB) GetAuthorByURI(uri string) (string, error) { 70 var authorDID string 71 err := db.QueryRow(db.Rebind(`SELECT author_did FROM annotations WHERE uri = ?`), uri).Scan(&authorDID) ··· 85 86 return "", fmt.Errorf("uri not found or no author") 87 } 88 + 89 + func buildPlaceholders(n int) string { 90 + if n == 0 { 91 + return "" 92 + } 93 + placeholders := make([]string, n) 94 + for i := range placeholders { 95 + placeholders[i] = "?" 96 + } 97 + return strings.Join(placeholders, ", ") 98 + }
+172
backend/internal/db/queries_annotations.go
···
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + func (db *DB) CreateAnnotation(a *Annotation) error { 8 + _, err := db.Exec(db.Rebind(` 9 + INSERT INTO annotations (uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid) 10 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 11 + ON CONFLICT(uri) DO UPDATE SET 12 + motivation = excluded.motivation, 13 + body_value = excluded.body_value, 14 + body_format = excluded.body_format, 15 + body_uri = excluded.body_uri, 16 + target_title = excluded.target_title, 17 + selector_json = excluded.selector_json, 18 + tags_json = excluded.tags_json, 19 + indexed_at = excluded.indexed_at, 20 + cid = excluded.cid 21 + `), a.URI, a.AuthorDID, a.Motivation, a.BodyValue, a.BodyFormat, a.BodyURI, a.TargetSource, a.TargetHash, a.TargetTitle, a.SelectorJSON, a.TagsJSON, a.CreatedAt, a.IndexedAt, a.CID) 22 + return err 23 + } 24 + 25 + func (db *DB) GetAnnotationByURI(uri string) (*Annotation, error) { 26 + var a Annotation 27 + err := db.QueryRow(db.Rebind(` 28 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 29 + FROM annotations 30 + WHERE uri = ? 31 + `), uri).Scan(&a.URI, &a.AuthorDID, &a.Motivation, &a.BodyValue, &a.BodyFormat, &a.BodyURI, &a.TargetSource, &a.TargetHash, &a.TargetTitle, &a.SelectorJSON, &a.TagsJSON, &a.CreatedAt, &a.IndexedAt, &a.CID) 32 + if err != nil { 33 + return nil, err 34 + } 35 + return &a, nil 36 + } 37 + 38 + func (db *DB) GetAnnotationsByTargetHash(targetHash string, limit, offset int) ([]Annotation, error) { 39 + rows, err := db.Query(db.Rebind(` 40 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 41 + FROM annotations 42 + WHERE target_hash = ? 43 + ORDER BY created_at DESC 44 + LIMIT ? OFFSET ? 45 + `), targetHash, limit, offset) 46 + if err != nil { 47 + return nil, err 48 + } 49 + defer rows.Close() 50 + 51 + return scanAnnotations(rows) 52 + } 53 + 54 + func (db *DB) GetAnnotationsByAuthor(authorDID string, limit, offset int) ([]Annotation, error) { 55 + rows, err := db.Query(db.Rebind(` 56 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 57 + FROM annotations 58 + WHERE author_did = ? 59 + ORDER BY created_at DESC 60 + LIMIT ? OFFSET ? 61 + `), authorDID, limit, offset) 62 + if err != nil { 63 + return nil, err 64 + } 65 + defer rows.Close() 66 + 67 + return scanAnnotations(rows) 68 + } 69 + 70 + func (db *DB) GetAnnotationsByMotivation(motivation string, limit, offset int) ([]Annotation, error) { 71 + rows, err := db.Query(db.Rebind(` 72 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 73 + FROM annotations 74 + WHERE motivation = ? 75 + ORDER BY created_at DESC 76 + LIMIT ? OFFSET ? 77 + `), motivation, limit, offset) 78 + if err != nil { 79 + return nil, err 80 + } 81 + defer rows.Close() 82 + 83 + return scanAnnotations(rows) 84 + } 85 + 86 + func (db *DB) GetRecentAnnotations(limit, offset int) ([]Annotation, error) { 87 + rows, err := db.Query(db.Rebind(` 88 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 89 + FROM annotations 90 + ORDER BY created_at DESC 91 + LIMIT ? OFFSET ? 92 + `), limit, offset) 93 + if err != nil { 94 + return nil, err 95 + } 96 + defer rows.Close() 97 + 98 + return scanAnnotations(rows) 99 + } 100 + 101 + func (db *DB) GetAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) { 102 + pattern := "%\"" + tag + "\"%" 103 + rows, err := db.Query(db.Rebind(` 104 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 105 + FROM annotations 106 + WHERE tags_json LIKE ? 107 + ORDER BY created_at DESC 108 + LIMIT ? OFFSET ? 109 + `), pattern, limit, offset) 110 + if err != nil { 111 + return nil, err 112 + } 113 + defer rows.Close() 114 + 115 + return scanAnnotations(rows) 116 + } 117 + 118 + func (db *DB) DeleteAnnotation(uri string) error { 119 + _, err := db.Exec(db.Rebind(`DELETE FROM annotations WHERE uri = ?`), uri) 120 + return err 121 + } 122 + 123 + func (db *DB) UpdateAnnotation(uri, bodyValue, tagsJSON, cid string) error { 124 + _, err := db.Exec(db.Rebind(` 125 + UPDATE annotations 126 + SET body_value = ?, tags_json = ?, cid = ?, indexed_at = ? 127 + WHERE uri = ? 128 + `), bodyValue, tagsJSON, cid, time.Now(), uri) 129 + return err 130 + } 131 + 132 + func (db *DB) GetAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) { 133 + pattern := "%\"" + tag + "\"%" 134 + rows, err := db.Query(db.Rebind(` 135 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 136 + FROM annotations 137 + WHERE author_did = ? AND tags_json LIKE ? 138 + ORDER BY created_at DESC 139 + LIMIT ? OFFSET ? 140 + `), authorDID, pattern, limit, offset) 141 + if err != nil { 142 + return nil, err 143 + } 144 + defer rows.Close() 145 + 146 + return scanAnnotations(rows) 147 + } 148 + 149 + func (db *DB) GetAnnotationsByURIs(uris []string) ([]Annotation, error) { 150 + if len(uris) == 0 { 151 + return []Annotation{}, nil 152 + } 153 + 154 + query := db.Rebind(` 155 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 156 + FROM annotations 157 + WHERE uri IN (` + buildPlaceholders(len(uris)) + `) 158 + `) 159 + 160 + args := make([]interface{}, len(uris)) 161 + for i, uri := range uris { 162 + args[i] = uri 163 + } 164 + 165 + rows, err := db.Query(query, args...) 166 + if err != nil { 167 + return nil, err 168 + } 169 + defer rows.Close() 170 + 171 + return scanAnnotations(rows) 172 + }
+176
backend/internal/db/queries_bookmarks.go
···
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + func (db *DB) CreateBookmark(b *Bookmark) error { 8 + _, err := db.Exec(db.Rebind(` 9 + INSERT INTO bookmarks (uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid) 10 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 11 + ON CONFLICT(uri) DO UPDATE SET 12 + title = excluded.title, 13 + description = excluded.description, 14 + tags_json = excluded.tags_json, 15 + indexed_at = excluded.indexed_at, 16 + cid = excluded.cid 17 + `), b.URI, b.AuthorDID, b.Source, b.SourceHash, b.Title, b.Description, b.TagsJSON, b.CreatedAt, b.IndexedAt, b.CID) 18 + return err 19 + } 20 + 21 + func (db *DB) GetBookmarkByURI(uri string) (*Bookmark, error) { 22 + var b Bookmark 23 + err := db.QueryRow(db.Rebind(` 24 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 25 + FROM bookmarks 26 + WHERE uri = ? 27 + `), uri).Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID) 28 + if err != nil { 29 + return nil, err 30 + } 31 + return &b, nil 32 + } 33 + 34 + func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) { 35 + rows, err := db.Query(db.Rebind(` 36 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 37 + FROM bookmarks 38 + ORDER BY created_at DESC 39 + LIMIT ? OFFSET ? 40 + `), limit, offset) 41 + if err != nil { 42 + return nil, err 43 + } 44 + defer rows.Close() 45 + 46 + var bookmarks []Bookmark 47 + for rows.Next() { 48 + var b Bookmark 49 + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 50 + return nil, err 51 + } 52 + bookmarks = append(bookmarks, b) 53 + } 54 + return bookmarks, nil 55 + } 56 + 57 + func (db *DB) GetBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) { 58 + pattern := "%\"" + tag + "\"%" 59 + rows, err := db.Query(db.Rebind(` 60 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 61 + FROM bookmarks 62 + WHERE tags_json LIKE ? 63 + ORDER BY created_at DESC 64 + LIMIT ? OFFSET ? 65 + `), pattern, limit, offset) 66 + if err != nil { 67 + return nil, err 68 + } 69 + defer rows.Close() 70 + 71 + var bookmarks []Bookmark 72 + for rows.Next() { 73 + var b Bookmark 74 + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 75 + return nil, err 76 + } 77 + bookmarks = append(bookmarks, b) 78 + } 79 + return bookmarks, nil 80 + } 81 + 82 + func (db *DB) GetBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) { 83 + pattern := "%\"" + tag + "\"%" 84 + rows, err := db.Query(db.Rebind(` 85 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 86 + FROM bookmarks 87 + WHERE author_did = ? AND tags_json LIKE ? 88 + ORDER BY created_at DESC 89 + LIMIT ? OFFSET ? 90 + `), authorDID, pattern, limit, offset) 91 + if err != nil { 92 + return nil, err 93 + } 94 + defer rows.Close() 95 + 96 + var bookmarks []Bookmark 97 + for rows.Next() { 98 + var b Bookmark 99 + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 100 + return nil, err 101 + } 102 + bookmarks = append(bookmarks, b) 103 + } 104 + return bookmarks, nil 105 + } 106 + 107 + func (db *DB) GetBookmarksByAuthor(authorDID string, limit, offset int) ([]Bookmark, error) { 108 + rows, err := db.Query(db.Rebind(` 109 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 110 + FROM bookmarks 111 + WHERE author_did = ? 112 + ORDER BY created_at DESC 113 + LIMIT ? OFFSET ? 114 + `), authorDID, limit, offset) 115 + if err != nil { 116 + return nil, err 117 + } 118 + defer rows.Close() 119 + 120 + var bookmarks []Bookmark 121 + for rows.Next() { 122 + var b Bookmark 123 + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 124 + return nil, err 125 + } 126 + bookmarks = append(bookmarks, b) 127 + } 128 + return bookmarks, nil 129 + } 130 + 131 + func (db *DB) DeleteBookmark(uri string) error { 132 + _, err := db.Exec(db.Rebind(`DELETE FROM bookmarks WHERE uri = ?`), uri) 133 + return err 134 + } 135 + 136 + func (db *DB) UpdateBookmark(uri, title, description, tagsJSON, cid string) error { 137 + _, err := db.Exec(db.Rebind(` 138 + UPDATE bookmarks 139 + SET title = ?, description = ?, tags_json = ?, cid = ?, indexed_at = ? 140 + WHERE uri = ? 141 + `), title, description, tagsJSON, cid, time.Now(), uri) 142 + return err 143 + } 144 + 145 + func (db *DB) GetBookmarksByURIs(uris []string) ([]Bookmark, error) { 146 + if len(uris) == 0 { 147 + return []Bookmark{}, nil 148 + } 149 + 150 + query := db.Rebind(` 151 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 152 + FROM bookmarks 153 + WHERE uri IN (` + buildPlaceholders(len(uris)) + `) 154 + `) 155 + 156 + args := make([]interface{}, len(uris)) 157 + for i, uri := range uris { 158 + args[i] = uri 159 + } 160 + 161 + rows, err := db.Query(query, args...) 162 + if err != nil { 163 + return nil, err 164 + } 165 + defer rows.Close() 166 + 167 + var bookmarks []Bookmark 168 + for rows.Next() { 169 + var b Bookmark 170 + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 171 + return nil, err 172 + } 173 + bookmarks = append(bookmarks, b) 174 + } 175 + return bookmarks, nil 176 + }
+172
backend/internal/db/queries_collections.go
···
··· 1 + package db 2 + 3 + func (db *DB) CreateCollection(c *Collection) error { 4 + _, err := db.Exec(db.Rebind(` 5 + INSERT INTO collections (uri, author_did, name, description, icon, created_at, indexed_at) 6 + VALUES (?, ?, ?, ?, ?, ?, ?) 7 + ON CONFLICT(uri) DO UPDATE SET 8 + name = excluded.name, 9 + description = excluded.description, 10 + icon = excluded.icon, 11 + indexed_at = excluded.indexed_at 12 + `), c.URI, c.AuthorDID, c.Name, c.Description, c.Icon, c.CreatedAt, c.IndexedAt) 13 + return err 14 + } 15 + 16 + func (db *DB) GetCollectionsByAuthor(authorDID string) ([]Collection, error) { 17 + rows, err := db.Query(db.Rebind(` 18 + SELECT uri, author_did, name, description, icon, created_at, indexed_at 19 + FROM collections 20 + WHERE author_did = ? 21 + ORDER BY created_at DESC 22 + `), authorDID) 23 + if err != nil { 24 + return nil, err 25 + } 26 + defer rows.Close() 27 + 28 + var collections []Collection 29 + for rows.Next() { 30 + var c Collection 31 + if err := rows.Scan(&c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &c.CreatedAt, &c.IndexedAt); err != nil { 32 + return nil, err 33 + } 34 + collections = append(collections, c) 35 + } 36 + return collections, nil 37 + } 38 + 39 + func (db *DB) GetCollectionByURI(uri string) (*Collection, error) { 40 + var c Collection 41 + err := db.QueryRow(db.Rebind(` 42 + SELECT uri, author_did, name, description, icon, created_at, indexed_at 43 + FROM collections 44 + WHERE uri = ? 45 + `), uri).Scan(&c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &c.CreatedAt, &c.IndexedAt) 46 + if err != nil { 47 + return nil, err 48 + } 49 + return &c, nil 50 + } 51 + 52 + func (db *DB) DeleteCollection(uri string) error { 53 + 54 + db.Exec(db.Rebind(`DELETE FROM collection_items WHERE collection_uri = ?`), uri) 55 + _, err := db.Exec(db.Rebind(`DELETE FROM collections WHERE uri = ?`), uri) 56 + return err 57 + } 58 + 59 + func (db *DB) AddToCollection(item *CollectionItem) error { 60 + _, err := db.Exec(db.Rebind(` 61 + INSERT INTO collection_items (uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at) 62 + VALUES (?, ?, ?, ?, ?, ?, ?) 63 + ON CONFLICT(uri) DO UPDATE SET 64 + position = excluded.position, 65 + indexed_at = excluded.indexed_at 66 + `), item.URI, item.AuthorDID, item.CollectionURI, item.AnnotationURI, item.Position, item.CreatedAt, item.IndexedAt) 67 + return err 68 + } 69 + 70 + func (db *DB) GetCollectionItems(collectionURI string) ([]CollectionItem, error) { 71 + rows, err := db.Query(db.Rebind(` 72 + SELECT uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at 73 + FROM collection_items 74 + WHERE collection_uri = ? 75 + ORDER BY position ASC, created_at DESC 76 + `), collectionURI) 77 + if err != nil { 78 + return nil, err 79 + } 80 + defer rows.Close() 81 + 82 + var items []CollectionItem 83 + for rows.Next() { 84 + var item CollectionItem 85 + if err := rows.Scan(&item.URI, &item.AuthorDID, &item.CollectionURI, &item.AnnotationURI, &item.Position, &item.CreatedAt, &item.IndexedAt); err != nil { 86 + return nil, err 87 + } 88 + items = append(items, item) 89 + } 90 + return items, nil 91 + } 92 + 93 + func (db *DB) RemoveFromCollection(uri string) error { 94 + _, err := db.Exec(db.Rebind(`DELETE FROM collection_items WHERE uri = ?`), uri) 95 + return err 96 + } 97 + 98 + func (db *DB) GetRecentCollectionItems(limit, offset int) ([]CollectionItem, error) { 99 + rows, err := db.Query(db.Rebind(` 100 + SELECT uri, author_did, collection_uri, annotation_uri, position, created_at, indexed_at 101 + FROM collection_items 102 + ORDER BY created_at DESC 103 + LIMIT ? OFFSET ? 104 + `), limit, offset) 105 + if err != nil { 106 + return nil, err 107 + } 108 + defer rows.Close() 109 + 110 + var items []CollectionItem 111 + for rows.Next() { 112 + var item CollectionItem 113 + if err := rows.Scan(&item.URI, &item.AuthorDID, &item.CollectionURI, &item.AnnotationURI, &item.Position, &item.CreatedAt, &item.IndexedAt); err != nil { 114 + return nil, err 115 + } 116 + items = append(items, item) 117 + } 118 + return items, nil 119 + } 120 + 121 + func (db *DB) GetCollectionURIsForAnnotation(annotationURI string) ([]string, error) { 122 + rows, err := db.Query(db.Rebind(` 123 + SELECT collection_uri FROM collection_items WHERE annotation_uri = ? 124 + `), annotationURI) 125 + if err != nil { 126 + return nil, err 127 + } 128 + defer rows.Close() 129 + 130 + var uris []string 131 + for rows.Next() { 132 + var uri string 133 + if err := rows.Scan(&uri); err != nil { 134 + return nil, err 135 + } 136 + uris = append(uris, uri) 137 + } 138 + return uris, nil 139 + } 140 + 141 + func (db *DB) GetCollectionsByURIs(uris []string) ([]Collection, error) { 142 + if len(uris) == 0 { 143 + return []Collection{}, nil 144 + } 145 + 146 + query := db.Rebind(` 147 + SELECT uri, author_did, name, description, icon, created_at, indexed_at 148 + FROM collections 149 + WHERE uri IN (` + buildPlaceholders(len(uris)) + `) 150 + `) 151 + 152 + args := make([]interface{}, len(uris)) 153 + for i, uri := range uris { 154 + args[i] = uri 155 + } 156 + 157 + rows, err := db.Query(query, args...) 158 + if err != nil { 159 + return nil, err 160 + } 161 + defer rows.Close() 162 + 163 + var collections []Collection 164 + for rows.Next() { 165 + var c Collection 166 + if err := rows.Scan(&c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &c.CreatedAt, &c.IndexedAt); err != nil { 167 + return nil, err 168 + } 169 + collections = append(collections, c) 170 + } 171 + return collections, nil 172 + }
+201
backend/internal/db/queries_highlights.go
···
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + func (db *DB) CreateHighlight(h *Highlight) error { 8 + _, err := db.Exec(db.Rebind(` 9 + INSERT INTO highlights (uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid) 10 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 11 + ON CONFLICT(uri) DO UPDATE SET 12 + target_title = excluded.target_title, 13 + selector_json = excluded.selector_json, 14 + color = excluded.color, 15 + tags_json = excluded.tags_json, 16 + indexed_at = excluded.indexed_at, 17 + cid = excluded.cid 18 + `), h.URI, h.AuthorDID, h.TargetSource, h.TargetHash, h.TargetTitle, h.SelectorJSON, h.Color, h.TagsJSON, h.CreatedAt, h.IndexedAt, h.CID) 19 + return err 20 + } 21 + 22 + func (db *DB) GetHighlightByURI(uri string) (*Highlight, error) { 23 + var h Highlight 24 + err := db.QueryRow(db.Rebind(` 25 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 26 + FROM highlights 27 + WHERE uri = ? 28 + `), uri).Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID) 29 + if err != nil { 30 + return nil, err 31 + } 32 + return &h, nil 33 + } 34 + 35 + func (db *DB) GetRecentHighlights(limit, offset int) ([]Highlight, error) { 36 + rows, err := db.Query(db.Rebind(` 37 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 38 + FROM highlights 39 + ORDER BY created_at DESC 40 + LIMIT ? OFFSET ? 41 + `), limit, offset) 42 + if err != nil { 43 + return nil, err 44 + } 45 + defer rows.Close() 46 + 47 + var highlights []Highlight 48 + for rows.Next() { 49 + var h Highlight 50 + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 51 + return nil, err 52 + } 53 + highlights = append(highlights, h) 54 + } 55 + return highlights, nil 56 + } 57 + 58 + func (db *DB) GetHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) { 59 + pattern := "%\"" + tag + "\"%" 60 + rows, err := db.Query(db.Rebind(` 61 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 62 + FROM highlights 63 + WHERE tags_json LIKE ? 64 + ORDER BY created_at DESC 65 + LIMIT ? OFFSET ? 66 + `), pattern, limit, offset) 67 + if err != nil { 68 + return nil, err 69 + } 70 + defer rows.Close() 71 + 72 + var highlights []Highlight 73 + for rows.Next() { 74 + var h Highlight 75 + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 76 + return nil, err 77 + } 78 + highlights = append(highlights, h) 79 + } 80 + return highlights, nil 81 + } 82 + 83 + func (db *DB) GetHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) { 84 + pattern := "%\"" + tag + "\"%" 85 + rows, err := db.Query(db.Rebind(` 86 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 87 + FROM highlights 88 + WHERE author_did = ? AND tags_json LIKE ? 89 + ORDER BY created_at DESC 90 + LIMIT ? OFFSET ? 91 + `), authorDID, pattern, limit, offset) 92 + if err != nil { 93 + return nil, err 94 + } 95 + defer rows.Close() 96 + 97 + var highlights []Highlight 98 + for rows.Next() { 99 + var h Highlight 100 + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 101 + return nil, err 102 + } 103 + highlights = append(highlights, h) 104 + } 105 + return highlights, nil 106 + } 107 + 108 + func (db *DB) GetHighlightsByTargetHash(targetHash string, limit, offset int) ([]Highlight, error) { 109 + rows, err := db.Query(db.Rebind(` 110 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 111 + FROM highlights 112 + WHERE target_hash = ? 113 + ORDER BY created_at DESC 114 + LIMIT ? OFFSET ? 115 + `), targetHash, limit, offset) 116 + if err != nil { 117 + return nil, err 118 + } 119 + defer rows.Close() 120 + 121 + var highlights []Highlight 122 + for rows.Next() { 123 + var h Highlight 124 + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 125 + return nil, err 126 + } 127 + highlights = append(highlights, h) 128 + } 129 + return highlights, nil 130 + } 131 + 132 + func (db *DB) GetHighlightsByAuthor(authorDID string, limit, offset int) ([]Highlight, error) { 133 + rows, err := db.Query(db.Rebind(` 134 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 135 + FROM highlights 136 + WHERE author_did = ? 137 + ORDER BY created_at DESC 138 + LIMIT ? OFFSET ? 139 + `), authorDID, limit, offset) 140 + if err != nil { 141 + return nil, err 142 + } 143 + defer rows.Close() 144 + 145 + var highlights []Highlight 146 + for rows.Next() { 147 + var h Highlight 148 + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 149 + return nil, err 150 + } 151 + highlights = append(highlights, h) 152 + } 153 + return highlights, nil 154 + } 155 + 156 + func (db *DB) DeleteHighlight(uri string) error { 157 + _, err := db.Exec(db.Rebind(`DELETE FROM highlights WHERE uri = ?`), uri) 158 + return err 159 + } 160 + 161 + func (db *DB) UpdateHighlight(uri, color, tagsJSON, cid string) error { 162 + _, err := db.Exec(db.Rebind(` 163 + UPDATE highlights 164 + SET color = ?, tags_json = ?, cid = ?, indexed_at = ? 165 + WHERE uri = ? 166 + `), color, tagsJSON, cid, time.Now(), uri) 167 + return err 168 + } 169 + 170 + func (db *DB) GetHighlightsByURIs(uris []string) ([]Highlight, error) { 171 + if len(uris) == 0 { 172 + return []Highlight{}, nil 173 + } 174 + 175 + query := db.Rebind(` 176 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 177 + FROM highlights 178 + WHERE uri IN (` + buildPlaceholders(len(uris)) + `) 179 + `) 180 + 181 + args := make([]interface{}, len(uris)) 182 + for i, uri := range uris { 183 + args[i] = uri 184 + } 185 + 186 + rows, err := db.Query(query, args...) 187 + if err != nil { 188 + return nil, err 189 + } 190 + defer rows.Close() 191 + 192 + var highlights []Highlight 193 + for rows.Next() { 194 + var h Highlight 195 + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 196 + return nil, err 197 + } 198 + highlights = append(highlights, h) 199 + } 200 + return highlights, nil 201 + }
+36
backend/internal/db/queries_history.go
···
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + func (db *DB) SaveEditHistory(uri, recordType, previousContent string, previousCID *string) error { 8 + _, err := db.Exec(db.Rebind(` 9 + INSERT INTO edit_history (uri, record_type, previous_content, previous_cid, edited_at) 10 + VALUES (?, ?, ?, ?, ?) 11 + `), uri, recordType, previousContent, previousCID, time.Now()) 12 + return err 13 + } 14 + 15 + func (db *DB) GetEditHistory(uri string) ([]EditHistory, error) { 16 + rows, err := db.Query(db.Rebind(` 17 + SELECT id, uri, record_type, previous_content, previous_cid, edited_at 18 + FROM edit_history 19 + WHERE uri = ? 20 + ORDER BY edited_at DESC 21 + `), uri) 22 + if err != nil { 23 + return nil, err 24 + } 25 + defer rows.Close() 26 + 27 + var history []EditHistory 28 + for rows.Next() { 29 + var h EditHistory 30 + if err := rows.Scan(&h.ID, &h.URI, &h.RecordType, &h.PreviousContent, &h.PreviousCID, &h.EditedAt); err != nil { 31 + return nil, err 32 + } 33 + history = append(history, h) 34 + } 35 + return history, nil 36 + }
+59
backend/internal/db/queries_keys.go
···
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + func (db *DB) CreateAPIKey(key *APIKey) error { 8 + _, err := db.Exec(db.Rebind(` 9 + INSERT INTO api_keys (id, owner_did, name, key_hash, created_at) 10 + VALUES (?, ?, ?, ?, ?) 11 + `), key.ID, key.OwnerDID, key.Name, key.KeyHash, key.CreatedAt) 12 + return err 13 + } 14 + 15 + func (db *DB) GetAPIKeysByOwner(ownerDID string) ([]APIKey, error) { 16 + rows, err := db.Query(db.Rebind(` 17 + SELECT id, owner_did, name, key_hash, created_at, last_used_at 18 + FROM api_keys 19 + WHERE owner_did = ? 20 + ORDER BY created_at DESC 21 + `), ownerDID) 22 + if err != nil { 23 + return nil, err 24 + } 25 + defer rows.Close() 26 + 27 + var keys []APIKey 28 + for rows.Next() { 29 + var k APIKey 30 + if err := rows.Scan(&k.ID, &k.OwnerDID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsedAt); err != nil { 31 + return nil, err 32 + } 33 + keys = append(keys, k) 34 + } 35 + return keys, nil 36 + } 37 + 38 + func (db *DB) GetAPIKeyByHash(keyHash string) (*APIKey, error) { 39 + var k APIKey 40 + err := db.QueryRow(db.Rebind(` 41 + SELECT id, owner_did, name, key_hash, created_at, last_used_at 42 + FROM api_keys 43 + WHERE key_hash = ? 44 + `), keyHash).Scan(&k.ID, &k.OwnerDID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsedAt) 45 + if err != nil { 46 + return nil, err 47 + } 48 + return &k, nil 49 + } 50 + 51 + func (db *DB) DeleteAPIKey(id, ownerDID string) error { 52 + _, err := db.Exec(db.Rebind(`DELETE FROM api_keys WHERE id = ? AND owner_did = ?`), id, ownerDID) 53 + return err 54 + } 55 + 56 + func (db *DB) UpdateAPIKeyLastUsed(id string) error { 57 + _, err := db.Exec(db.Rebind(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`), time.Now(), id) 58 + return err 59 + }
+105
backend/internal/db/queries_likes.go
···
··· 1 + package db 2 + 3 + func (db *DB) CreateLike(l *Like) error { 4 + _, err := db.Exec(db.Rebind(` 5 + INSERT INTO likes (uri, author_did, subject_uri, created_at, indexed_at) 6 + VALUES (?, ?, ?, ?, ?) 7 + ON CONFLICT(uri) DO NOTHING 8 + `), l.URI, l.AuthorDID, l.SubjectURI, l.CreatedAt, l.IndexedAt) 9 + return err 10 + } 11 + 12 + func (db *DB) DeleteLike(uri string) error { 13 + _, err := db.Exec(db.Rebind(`DELETE FROM likes WHERE uri = ?`), uri) 14 + return err 15 + } 16 + 17 + func (db *DB) GetLikeCount(subjectURI string) (int, error) { 18 + var count int 19 + err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM likes WHERE subject_uri = ?`), subjectURI).Scan(&count) 20 + return count, err 21 + } 22 + 23 + func (db *DB) GetLikeByUserAndSubject(userDID, subjectURI string) (*Like, error) { 24 + var like Like 25 + err := db.QueryRow(db.Rebind(` 26 + SELECT uri, author_did, subject_uri, created_at, indexed_at 27 + FROM likes 28 + WHERE author_did = ? AND subject_uri = ? 29 + `), userDID, subjectURI).Scan(&like.URI, &like.AuthorDID, &like.SubjectURI, &like.CreatedAt, &like.IndexedAt) 30 + if err != nil { 31 + return nil, err 32 + } 33 + return &like, nil 34 + } 35 + 36 + func (db *DB) GetLikeCounts(subjectURIs []string) (map[string]int, error) { 37 + if len(subjectURIs) == 0 { 38 + return map[string]int{}, nil 39 + } 40 + 41 + query := db.Rebind(` 42 + SELECT subject_uri, COUNT(*) 43 + FROM likes 44 + WHERE subject_uri IN (` + buildPlaceholders(len(subjectURIs)) + `) 45 + GROUP BY subject_uri 46 + `) 47 + 48 + args := make([]interface{}, len(subjectURIs)) 49 + for i, uri := range subjectURIs { 50 + args[i] = uri 51 + } 52 + 53 + rows, err := db.Query(query, args...) 54 + if err != nil { 55 + return nil, err 56 + } 57 + defer rows.Close() 58 + 59 + counts := make(map[string]int) 60 + for rows.Next() { 61 + var uri string 62 + var count int 63 + if err := rows.Scan(&uri, &count); err != nil { 64 + return nil, err 65 + } 66 + counts[uri] = count 67 + } 68 + 69 + return counts, nil 70 + } 71 + 72 + func (db *DB) GetViewerLikes(viewerDID string, subjectURIs []string) (map[string]bool, error) { 73 + if len(subjectURIs) == 0 { 74 + return map[string]bool{}, nil 75 + } 76 + 77 + query := db.Rebind(` 78 + SELECT subject_uri 79 + FROM likes 80 + WHERE author_did = ? AND subject_uri IN (` + buildPlaceholders(len(subjectURIs)) + `) 81 + `) 82 + 83 + args := make([]interface{}, len(subjectURIs)+1) 84 + args[0] = viewerDID 85 + for i, uri := range subjectURIs { 86 + args[i+1] = uri 87 + } 88 + 89 + rows, err := db.Query(query, args...) 90 + if err != nil { 91 + return nil, err 92 + } 93 + defer rows.Close() 94 + 95 + likes := make(map[string]bool) 96 + for rows.Next() { 97 + var uri string 98 + if err := rows.Scan(&uri); err != nil { 99 + return nil, err 100 + } 101 + likes[uri] = true 102 + } 103 + 104 + return likes, nil 105 + }
+52
backend/internal/db/queries_notifications.go
···
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + func (db *DB) CreateNotification(n *Notification) error { 8 + _, err := db.Exec(db.Rebind(` 9 + INSERT INTO notifications (recipient_did, actor_did, type, subject_uri, created_at) 10 + VALUES (?, ?, ?, ?, ?) 11 + `), n.RecipientDID, n.ActorDID, n.Type, n.SubjectURI, n.CreatedAt) 12 + return err 13 + } 14 + 15 + func (db *DB) GetNotifications(recipientDID string, limit, offset int) ([]Notification, error) { 16 + rows, err := db.Query(db.Rebind(` 17 + SELECT id, recipient_did, actor_did, type, subject_uri, created_at, read_at 18 + FROM notifications 19 + WHERE recipient_did = ? 20 + ORDER BY created_at DESC 21 + LIMIT ? OFFSET ? 22 + `), recipientDID, limit, offset) 23 + if err != nil { 24 + return nil, err 25 + } 26 + defer rows.Close() 27 + 28 + var notifications []Notification 29 + for rows.Next() { 30 + var n Notification 31 + if err := rows.Scan(&n.ID, &n.RecipientDID, &n.ActorDID, &n.Type, &n.SubjectURI, &n.CreatedAt, &n.ReadAt); err != nil { 32 + continue 33 + } 34 + notifications = append(notifications, n) 35 + } 36 + return notifications, nil 37 + } 38 + 39 + func (db *DB) GetUnreadNotificationCount(recipientDID string) (int, error) { 40 + var count int 41 + err := db.QueryRow(db.Rebind(` 42 + SELECT COUNT(*) FROM notifications WHERE recipient_did = ? AND read_at IS NULL 43 + `), recipientDID).Scan(&count) 44 + return count, err 45 + } 46 + 47 + func (db *DB) MarkNotificationsRead(recipientDID string) error { 48 + _, err := db.Exec(db.Rebind(` 49 + UPDATE notifications SET read_at = ? WHERE recipient_did = ? AND read_at IS NULL 50 + `), time.Now(), recipientDID) 51 + return err 52 + }
+176
backend/internal/db/queries_replies.go
···
··· 1 + package db 2 + 3 + func (db *DB) CreateReply(r *Reply) error { 4 + _, err := db.Exec(db.Rebind(` 5 + INSERT INTO replies (uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid) 6 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 7 + ON CONFLICT(uri) DO UPDATE SET 8 + text = excluded.text, 9 + format = excluded.format, 10 + indexed_at = excluded.indexed_at, 11 + cid = excluded.cid 12 + `), r.URI, r.AuthorDID, r.ParentURI, r.RootURI, r.Text, r.Format, r.CreatedAt, r.IndexedAt, r.CID) 13 + return err 14 + } 15 + 16 + func (db *DB) GetRepliesByRoot(rootURI string) ([]Reply, error) { 17 + rows, err := db.Query(db.Rebind(` 18 + SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid 19 + FROM replies 20 + WHERE root_uri = ? 21 + ORDER BY created_at ASC 22 + `), rootURI) 23 + if err != nil { 24 + return nil, err 25 + } 26 + defer rows.Close() 27 + 28 + var replies []Reply 29 + for rows.Next() { 30 + var r Reply 31 + if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { 32 + return nil, err 33 + } 34 + replies = append(replies, r) 35 + } 36 + return replies, nil 37 + } 38 + 39 + func (db *DB) GetReplyByURI(uri string) (*Reply, error) { 40 + var r Reply 41 + err := db.QueryRow(db.Rebind(` 42 + SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid 43 + FROM replies 44 + WHERE uri = ? 45 + `), uri).Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID) 46 + if err != nil { 47 + return nil, err 48 + } 49 + return &r, nil 50 + } 51 + 52 + func (db *DB) DeleteReply(uri string) error { 53 + _, err := db.Exec(db.Rebind(`DELETE FROM replies WHERE uri = ?`), uri) 54 + return err 55 + } 56 + 57 + func (db *DB) GetRepliesByAuthor(authorDID string) ([]Reply, error) { 58 + rows, err := db.Query(db.Rebind(` 59 + SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid 60 + FROM replies 61 + WHERE author_did = ? 62 + ORDER BY created_at DESC 63 + `), authorDID) 64 + if err != nil { 65 + return nil, err 66 + } 67 + defer rows.Close() 68 + 69 + var replies []Reply 70 + for rows.Next() { 71 + var r Reply 72 + if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { 73 + return nil, err 74 + } 75 + replies = append(replies, r) 76 + } 77 + return replies, nil 78 + } 79 + 80 + func (db *DB) GetOrphanedRepliesByAuthor(authorDID string) ([]Reply, error) { 81 + rows, err := db.Query(db.Rebind(` 82 + SELECT r.uri, r.author_did, r.parent_uri, r.root_uri, r.text, r.format, r.created_at, r.indexed_at, r.cid 83 + FROM replies r 84 + LEFT JOIN annotations a ON r.root_uri = a.uri 85 + WHERE r.author_did = ? AND a.uri IS NULL 86 + `), authorDID) 87 + if err != nil { 88 + return nil, err 89 + } 90 + defer rows.Close() 91 + 92 + var replies []Reply 93 + for rows.Next() { 94 + var r Reply 95 + if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { 96 + return nil, err 97 + } 98 + replies = append(replies, r) 99 + } 100 + return replies, nil 101 + } 102 + 103 + func (db *DB) GetReplyCount(rootURI string) (int, error) { 104 + var count int 105 + err := db.QueryRow(db.Rebind(`SELECT COUNT(*) FROM replies WHERE root_uri = ?`), rootURI).Scan(&count) 106 + return count, err 107 + } 108 + 109 + func (db *DB) GetReplyCounts(rootURIs []string) (map[string]int, error) { 110 + if len(rootURIs) == 0 { 111 + return map[string]int{}, nil 112 + } 113 + 114 + query := db.Rebind(` 115 + SELECT root_uri, COUNT(*) 116 + FROM replies 117 + WHERE root_uri IN (` + buildPlaceholders(len(rootURIs)) + `) 118 + GROUP BY root_uri 119 + `) 120 + 121 + args := make([]interface{}, len(rootURIs)) 122 + for i, uri := range rootURIs { 123 + args[i] = uri 124 + } 125 + 126 + rows, err := db.Query(query, args...) 127 + if err != nil { 128 + return nil, err 129 + } 130 + defer rows.Close() 131 + 132 + counts := make(map[string]int) 133 + for rows.Next() { 134 + var uri string 135 + var count int 136 + if err := rows.Scan(&uri, &count); err != nil { 137 + return nil, err 138 + } 139 + counts[uri] = count 140 + } 141 + 142 + return counts, nil 143 + } 144 + 145 + func (db *DB) GetRepliesByURIs(uris []string) ([]Reply, error) { 146 + if len(uris) == 0 { 147 + return []Reply{}, nil 148 + } 149 + 150 + query := db.Rebind(` 151 + SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid 152 + FROM replies 153 + WHERE uri IN (` + buildPlaceholders(len(uris)) + `) 154 + `) 155 + 156 + args := make([]interface{}, len(uris)) 157 + for i, uri := range uris { 158 + args[i] = uri 159 + } 160 + 161 + rows, err := db.Query(query, args...) 162 + if err != nil { 163 + return nil, err 164 + } 165 + defer rows.Close() 166 + 167 + var replies []Reply 168 + for rows.Next() { 169 + var r Reply 170 + if err := rows.Scan(&r.URI, &r.AuthorDID, &r.ParentURI, &r.RootURI, &r.Text, &r.Format, &r.CreatedAt, &r.IndexedAt, &r.CID); err != nil { 171 + return nil, err 172 + } 173 + replies = append(replies, r) 174 + } 175 + return replies, nil 176 + }
+32
backend/internal/db/queries_sessions.go
···
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + func (db *DB) SaveSession(id, did, handle, accessToken, refreshToken, dpopKey string, expiresAt time.Time) error { 8 + _, err := db.Exec(db.Rebind(` 9 + INSERT INTO sessions (id, did, handle, access_token, refresh_token, dpop_key, created_at, expires_at) 10 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 11 + ON CONFLICT(id) DO UPDATE SET 12 + access_token = excluded.access_token, 13 + refresh_token = excluded.refresh_token, 14 + dpop_key = excluded.dpop_key, 15 + expires_at = excluded.expires_at 16 + `), id, did, handle, accessToken, refreshToken, dpopKey, time.Now(), expiresAt) 17 + return err 18 + } 19 + 20 + func (db *DB) GetSession(id string) (did, handle, accessToken, refreshToken, dpopKey string, err error) { 21 + err = db.QueryRow(db.Rebind(` 22 + SELECT did, handle, access_token, refresh_token, COALESCE(dpop_key, '') 23 + FROM sessions 24 + WHERE id = ? AND expires_at > ? 25 + `), id, time.Now()).Scan(&did, &handle, &accessToken, &refreshToken, &dpopKey) 26 + return 27 + } 28 + 29 + func (db *DB) DeleteSession(id string) error { 30 + _, err := db.Exec(db.Rebind(`DELETE FROM sessions WHERE id = ?`), id) 31 + return err 32 + }
+236 -84
backend/internal/firehose/ingester.go
··· 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "fmt" 8 "io" 9 "log" 10 - "net/http" 11 - "strings" 12 "time" 13 14 "margin.at/internal/db" 15 ) ··· 56 return 57 default: 58 if err := i.subscribe(ctx); err != nil { 59 if ctx.Err() != nil { 60 return 61 } 62 - time.Sleep(30 * time.Second) 63 } 64 } 65 } 66 } 67 68 func (i *Ingester) subscribe(ctx context.Context) error { 69 cursor := i.getLastCursor() 70 ··· 73 url = fmt.Sprintf("%s?cursor=%d", RelayURL, cursor) 74 } 75 76 - req, err := http.NewRequestWithContext(ctx, "GET", strings.Replace(url, "wss://", "https://", 1), nil) 77 - if err != nil { 78 - return err 79 - } 80 81 - resp, err := http.DefaultClient.Do(req) 82 if err != nil { 83 - return err 84 } 85 - defer resp.Body.Close() 86 87 - if resp.StatusCode != 200 { 88 - body, _ := io.ReadAll(resp.Body) 89 - return fmt.Errorf("firehose returned %d: %s", resp.StatusCode, string(body)) 90 - } 91 92 - decoder := json.NewDecoder(resp.Body) 93 for { 94 select { 95 case <-ctx.Done(): ··· 97 default: 98 } 99 100 - var event FirehoseEvent 101 - if err := decoder.Decode(&event); err != nil { 102 - if err == io.EOF { 103 - return nil 104 - } 105 - return err 106 } 107 108 - i.handleEvent(&event) 109 } 110 } 111 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 - } 120 121 - func (i *Ingester) handleEvent(event *FirehoseEvent) { 122 - uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 123 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) 131 } 132 - case CollectionHighlight: 133 - switch event.Operation { 134 case "create", "update": 135 - i.handleHighlight(event) 136 case "delete": 137 - i.db.DeleteHighlight(uri) 138 } 139 - case CollectionBookmark: 140 - switch event.Operation { 141 - case "create", "update": 142 - i.handleBookmark(event) 143 - case "delete": 144 - i.db.DeleteBookmark(uri) 145 } 146 - case CollectionReply: 147 - switch event.Operation { 148 - case "create", "update": 149 - i.handleReply(event) 150 - case "delete": 151 - i.db.DeleteReply(uri) 152 } 153 - case CollectionLike: 154 - switch event.Operation { 155 - case "create": 156 - i.handleLike(event) 157 - case "delete": 158 - i.db.DeleteLike(uri) 159 } 160 - case CollectionCollection: 161 - switch event.Operation { 162 - case "create", "update": 163 - i.handleCollection(event) 164 - case "delete": 165 - i.db.DeleteCollection(uri) 166 } 167 - case CollectionCollectionItem: 168 - switch event.Operation { 169 - case "create", "update": 170 - i.handleCollectionItem(event) 171 - case "delete": 172 - i.db.RemoveFromCollection(uri) 173 } 174 } 175 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) 179 } 180 } 181 } 182 183 - func (i *Ingester) handleAnnotation(event *FirehoseEvent) { 184 185 var record struct { 186 Motivation string `json:"motivation"` 187 Body struct { ··· 205 Title string `json:"title"` 206 } 207 208 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 209 return 210 } 211 ··· 302 CreatedAt string `json:"createdAt"` 303 } 304 305 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 306 return 307 } 308 ··· 334 CreatedAt string `json:"createdAt"` 335 } 336 337 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 338 return 339 } 340 ··· 369 CreatedAt string `json:"createdAt"` 370 } 371 372 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 373 return 374 } 375 ··· 432 CreatedAt string `json:"createdAt"` 433 } 434 435 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 436 return 437 } 438 ··· 488 CreatedAt string `json:"createdAt"` 489 } 490 491 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 492 return 493 } 494 ··· 532 CreatedAt string `json:"createdAt"` 533 } 534 535 - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { 536 return 537 } 538
··· 3 import ( 4 "bytes" 5 "context" 6 + "encoding/binary" 7 "encoding/json" 8 "fmt" 9 "io" 10 "log" 11 "time" 12 + 13 + "github.com/fxamacker/cbor/v2" 14 + "github.com/gorilla/websocket" 15 + "github.com/ipfs/go-cid" 16 17 "margin.at/internal/db" 18 ) ··· 59 return 60 default: 61 if err := i.subscribe(ctx); err != nil { 62 + log.Printf("Firehose error: %v, reconnecting in 5s...", err) 63 if ctx.Err() != nil { 64 return 65 } 66 + time.Sleep(5 * time.Second) 67 } 68 } 69 } 70 } 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 + 92 func (i *Ingester) subscribe(ctx context.Context) error { 93 cursor := i.getLastCursor() 94 ··· 97 url = fmt.Sprintf("%s?cursor=%d", RelayURL, cursor) 98 } 99 100 + log.Printf("Connecting to firehose: %s", url) 101 102 + conn, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil) 103 if err != nil { 104 + return fmt.Errorf("websocket dial failed: %w", err) 105 } 106 + defer conn.Close() 107 108 + log.Printf("Connected to firehose") 109 110 for { 111 select { 112 case <-ctx.Done(): ··· 114 default: 115 } 116 117 + _, message, err := conn.ReadMessage() 118 + if err != nil { 119 + return fmt.Errorf("websocket read failed: %w", err) 120 } 121 122 + i.handleMessage(message) 123 } 124 } 125 126 + func (i *Ingester) handleMessage(data []byte) { 127 + reader := bytes.NewReader(data) 128 129 + var header FrameHeader 130 + decoder := cbor.NewDecoder(reader) 131 + if err := decoder.Decode(&header); err != nil { 132 + return 133 + } 134 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 152 } 153 + 154 + uri := fmt.Sprintf("at://%s/%s/%s", commit.Repo, collection, rkey) 155 + 156 + switch op.Action { 157 case "create", "update": 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 + } 164 case "delete": 165 + i.handleDelete(collection, uri) 166 } 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) 172 } 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:] 180 } 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 207 } 208 + 209 + blockData := make([]byte, blockLen) 210 + if _, err := io.ReadFull(reader, blockData); err != nil { 211 + break 212 } 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 225 } 226 } 227 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") 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 268 } 269 + 270 + return cid.Cid{}, 0, fmt.Errorf("unsupported CID version") 271 } 272 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 + } 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) { 337 var record struct { 338 Motivation string `json:"motivation"` 339 Body struct { ··· 357 Title string `json:"title"` 358 } 359 360 + if err := json.Unmarshal(event.Record, &record); err != nil { 361 return 362 } 363 ··· 454 CreatedAt string `json:"createdAt"` 455 } 456 457 + if err := json.Unmarshal(event.Record, &record); err != nil { 458 return 459 } 460 ··· 486 CreatedAt string `json:"createdAt"` 487 } 488 489 + if err := json.Unmarshal(event.Record, &record); err != nil { 490 return 491 } 492 ··· 521 CreatedAt string `json:"createdAt"` 522 } 523 524 + if err := json.Unmarshal(event.Record, &record); err != nil { 525 return 526 } 527 ··· 584 CreatedAt string `json:"createdAt"` 585 } 586 587 + if err := json.Unmarshal(event.Record, &record); err != nil { 588 return 589 } 590 ··· 640 CreatedAt string `json:"createdAt"` 641 } 642 643 + if err := json.Unmarshal(event.Record, &record); err != nil { 644 return 645 } 646 ··· 684 CreatedAt string `json:"createdAt"` 685 } 686 687 + if err := json.Unmarshal(event.Record, &record); err != nil { 688 return 689 } 690
+2 -1
docker-compose.yml
··· 9 - OAUTH_KEY_PATH=/data/oauth_private_key.pem 10 env_file: 11 - .env 12 depends_on: 13 db: 14 condition: service_healthy ··· 23 24 volumes: 25 - db-data:/var/lib/postgresql/data 26 - - margin-data:/data 27 healthcheck: 28 test: ["CMD-SHELL", "pg_isready -U margin"] 29 interval: 5s
··· 9 - OAUTH_KEY_PATH=/data/oauth_private_key.pem 10 env_file: 11 - .env 12 + volumes: 13 + - margin-data:/data 14 depends_on: 15 db: 16 condition: service_healthy ··· 25 26 volumes: 27 - db-data:/var/lib/postgresql/data 28 healthcheck: 29 test: ["CMD-SHELL", "pg_isready -U margin"] 30 interval: 5s
+12 -11
extension/background/service-worker.js
··· 39 } 40 } 41 42 - async function openAnnotationUI(tabId) { 43 if (hasSidePanel) { 44 try { 45 - const tab = await chrome.tabs.get(tabId); 46 - await chrome.sidePanel.setOptions({ 47 - tabId: tabId, 48 - path: "sidepanel/sidepanel.html", 49 - enabled: true, 50 - }); 51 - await chrome.sidePanel.open({ windowId: tab.windowId }); 52 return true; 53 } catch (err) { 54 console.error("Could not open Chrome side panel:", err); ··· 117 118 chrome.contextMenus.onClicked.addListener(async (info, tab) => { 119 if (info.menuItemId === "margin-open-sidebar") { 120 - await openAnnotationUI(tab.id); 121 return; 122 } 123 ··· 186 }, 187 }); 188 return; 189 - } catch { 190 - /* ignore */ 191 } 192 } 193
··· 39 } 40 } 41 42 + async function openAnnotationUI(tabId, windowId) { 43 if (hasSidePanel) { 44 try { 45 + let targetWindowId = windowId; 46 + 47 + if (!targetWindowId) { 48 + const tab = await chrome.tabs.get(tabId); 49 + targetWindowId = tab.windowId; 50 + } 51 + 52 + await chrome.sidePanel.open({ windowId: targetWindowId }); 53 return true; 54 } catch (err) { 55 console.error("Could not open Chrome side panel:", err); ··· 118 119 chrome.contextMenus.onClicked.addListener(async (info, tab) => { 120 if (info.menuItemId === "margin-open-sidebar") { 121 + await openAnnotationUI(tab.id, tab.windowId); 122 return; 123 } 124 ··· 187 }, 188 }); 189 return; 190 + } catch (e) { 191 + console.debug("Inline annotate failed, falling back to new tab:", e); 192 } 193 } 194
+44 -2
extension/content/content.js
··· 3 let sidebarShadow = null; 4 let popoverEl = null; 5 6 - 7 let activeItems = []; 8 let currentSelection = null; 9 ··· 640 const firstRect = firstRange.getClientRects()[0]; 641 const totalWidth = 642 Math.min(uniqueAuthors.length, maxShow + (overflow > 0 ? 1 : 0)) * 643 - 18 + 644 8; 645 const leftPos = firstRect.left - totalWidth; 646 const topPos = firstRect.top + firstRect.height / 2 - 12; ··· 1026 setTimeout(() => fetchAnnotations(), 500); 1027 } 1028 }); 1029 })();
··· 3 let sidebarShadow = null; 4 let popoverEl = null; 5 6 let activeItems = []; 7 let currentSelection = null; 8 ··· 639 const firstRect = firstRange.getClientRects()[0]; 640 const totalWidth = 641 Math.min(uniqueAuthors.length, maxShow + (overflow > 0 ? 1 : 0)) * 642 + 18 + 643 8; 644 const leftPos = firstRect.left - totalWidth; 645 const topPos = firstRect.top + firstRect.height / 2 - 12; ··· 1025 setTimeout(() => fetchAnnotations(), 500); 1026 } 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); 1071 })();
+3 -3
lexicons/at/margin/collection.json
··· 25 }, 26 "icon": { 27 "type": "string", 28 - "maxLength": 10, 29 - "maxGraphemes": 2, 30 - "description": "Emoji icon for the collection" 31 }, 32 "createdAt": { 33 "type": "string",
··· 25 }, 26 "icon": { 27 "type": "string", 28 + "maxLength": 100, 29 + "maxGraphemes": 100, 30 + "description": "Emoji icon or icon identifier for the collection" 31 }, 32 "createdAt": { 33 "type": "string",
-2
web/src/App.jsx
··· 15 import Collections from "./pages/Collections"; 16 import CollectionDetail from "./pages/CollectionDetail"; 17 import Privacy from "./pages/Privacy"; 18 - 19 import Terms from "./pages/Terms"; 20 - 21 import ScrollToTop from "./components/ScrollToTop"; 22 23 function AppContent() {
··· 15 import Collections from "./pages/Collections"; 16 import CollectionDetail from "./pages/CollectionDetail"; 17 import Privacy from "./pages/Privacy"; 18 import Terms from "./pages/Terms"; 19 import ScrollToTop from "./components/ScrollToTop"; 20 21 function AppContent() {
+15
web/src/api/client.js
··· 430 export async function getTrendingTags(limit = 10) { 431 return request(`${API_BASE}/tags/trending?limit=${limit}`); 432 }
··· 430 export async function getTrendingTags(limit = 10) { 431 return request(`${API_BASE}/tags/trending?limit=${limit}`); 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 + }
+11 -11
web/src/components/AddToCollectionModal.jsx
··· 21 const [createModalOpen, setCreateModalOpen] = useState(false); 22 const [error, setError] = useState(null); 23 24 - useEffect(() => { 25 - if (isOpen && user) { 26 - if (!annotationUri) { 27 - setLoading(false); 28 - return; 29 - } 30 - loadCollections(); 31 - setError(null); 32 - } 33 - }, [isOpen, user, annotationUri, loadCollections]); 34 - 35 const loadCollections = useCallback(async () => { 36 try { 37 setLoading(true); ··· 50 setLoading(false); 51 } 52 }, [user?.did, annotationUri]); 53 54 const handleAdd = async (collectionUri) => { 55 if (addedTo.has(collectionUri)) return;
··· 21 const [createModalOpen, setCreateModalOpen] = useState(false); 22 const [error, setError] = useState(null); 23 24 const loadCollections = useCallback(async () => { 25 try { 26 setLoading(true); ··· 39 setLoading(false); 40 } 41 }, [user?.did, annotationUri]); 42 + 43 + useEffect(() => { 44 + if (isOpen && user) { 45 + if (!annotationUri) { 46 + setLoading(false); 47 + return; 48 + } 49 + loadCollections(); 50 + setError(null); 51 + } 52 + }, [isOpen, user, annotationUri, loadCollections]); 53 54 const handleAdd = async (collectionUri) => { 55 if (addedTo.has(collectionUri)) return;
+43 -7
web/src/components/RightSidebar.jsx
··· 1 import { useState, useEffect } from "react"; 2 import { Link } from "react-router-dom"; 3 import { ExternalLink } from "lucide-react"; 4 - import { SiFirefox, SiGooglechrome, SiGithub, SiBluesky } from "react-icons/si"; 5 import { FaEdge } from "react-icons/fa"; 6 import { useAuth } from "../context/AuthContext"; 7 import { getTrendingTags } from "../api/client"; ··· 10 typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); 11 const isEdge = 12 typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); 13 14 function getExtensionInfo() { 15 if (isFirefox) { 16 return { 17 url: "https://addons.mozilla.org/en-US/firefox/addon/margin/", 18 icon: SiFirefox, 19 name: "Firefox", 20 }; 21 } 22 if (isEdge) { ··· 24 url: "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn", 25 icon: FaEdge, 26 name: "Edge", 27 }; 28 } 29 return { 30 url: "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/", 31 icon: SiGooglechrome, 32 name: "Chrome", 33 }; 34 } 35 ··· 50 return ( 51 <aside className="right-sidebar"> 52 <div className="right-section"> 53 - <h3 className="right-section-title">Get the Extension</h3> 54 <p className="right-section-desc"> 55 - Annotate, highlight, and bookmark any webpage 56 </p> 57 <a 58 href={ext.url} ··· 61 className="right-extension-btn" 62 > 63 <ExtIcon size={18} /> 64 - Install for {ext.name} 65 <ExternalLink size={14} /> 66 </a> 67 </div> ··· 96 <nav className="right-links"> 97 <Link to="/url" className="right-link"> 98 Browse by URL 99 - </Link> 100 - <Link to="/highlights" className="right-link"> 101 - Public Highlights 102 </Link> 103 </nav> 104 </div> ··· 140 <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 141 <SiBluesky size={16} /> 142 Bluesky 143 </div> 144 <ExternalLink size={12} /> 145 </a>
··· 1 import { useState, useEffect } from "react"; 2 import { Link } from "react-router-dom"; 3 import { ExternalLink } from "lucide-react"; 4 + import { 5 + SiFirefox, 6 + SiGooglechrome, 7 + SiGithub, 8 + SiBluesky, 9 + SiApple, 10 + SiKofi, 11 + } from "react-icons/si"; 12 import { FaEdge } from "react-icons/fa"; 13 import { useAuth } from "../context/AuthContext"; 14 import { getTrendingTags } from "../api/client"; ··· 17 typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); 18 const isEdge = 19 typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); 20 + const isMobileSafari = 21 + typeof navigator !== "undefined" && 22 + /iPhone|iPad|iPod/.test(navigator.userAgent) && 23 + /Safari/.test(navigator.userAgent) && 24 + !/CriOS|FxiOS|OPiOS|EdgiOS/.test(navigator.userAgent); 25 26 function getExtensionInfo() { 27 + if (isMobileSafari) { 28 + return { 29 + url: "https://margin.at/soon", 30 + icon: SiApple, 31 + name: "iOS", 32 + label: "Coming Soon", 33 + }; 34 + } 35 if (isFirefox) { 36 return { 37 url: "https://addons.mozilla.org/en-US/firefox/addon/margin/", 38 icon: SiFirefox, 39 name: "Firefox", 40 + label: "Install for Firefox", 41 }; 42 } 43 if (isEdge) { ··· 45 url: "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn", 46 icon: FaEdge, 47 name: "Edge", 48 + label: "Install for Edge", 49 }; 50 } 51 return { 52 url: "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/", 53 icon: SiGooglechrome, 54 name: "Chrome", 55 + label: "Install for Chrome", 56 }; 57 } 58 ··· 73 return ( 74 <aside className="right-sidebar"> 75 <div className="right-section"> 76 + <h3 className="right-section-title"> 77 + {isMobileSafari ? "Save from Safari" : "Get the Extension"} 78 + </h3> 79 <p className="right-section-desc"> 80 + {isMobileSafari 81 + ? "Bookmark pages using Safari's share sheet" 82 + : "Annotate, highlight, and bookmark any webpage"} 83 </p> 84 <a 85 href={ext.url} ··· 88 className="right-extension-btn" 89 > 90 <ExtIcon size={18} /> 91 + {ext.label} 92 <ExternalLink size={14} /> 93 </a> 94 </div> ··· 123 <nav className="right-links"> 124 <Link to="/url" className="right-link"> 125 Browse by URL 126 </Link> 127 </nav> 128 </div> ··· 164 <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 165 <SiBluesky size={16} /> 166 Bluesky 167 + </div> 168 + <ExternalLink size={12} /> 169 + </a> 170 + <a 171 + href="https://ko-fi.com/scan" 172 + target="_blank" 173 + rel="noopener noreferrer" 174 + className="right-link" 175 + > 176 + <div style={{ display: "flex", alignItems: "center", gap: "8px" }}> 177 + <SiKofi size={16} /> 178 + Donate 179 </div> 180 <ExternalLink size={12} /> 181 </a>
+34 -1
web/src/css/annotations.css
··· 182 font-weight: 400; 183 font-family: var(--font-serif, var(--font-sans)); 184 display: inline; 185 } 186 187 .annotation-text { ··· 277 padding-left: 46px; 278 } 279 280 - @media (max-width: 600px) { 281 .annotation-content, 282 .annotation-actions, 283 .inline-replies {
··· 182 font-weight: 400; 183 font-family: var(--font-serif, var(--font-sans)); 184 display: inline; 185 + overflow-wrap: anywhere; 186 + word-break: break-all; 187 + padding-right: 4px; 188 } 189 190 .annotation-text { ··· 280 padding-left: 46px; 281 } 282 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) { 314 .annotation-content, 315 .annotation-actions, 316 .inline-replies {
+2
web/src/css/buttons.css
··· 8 font-weight: 500; 9 border-radius: var(--radius-md); 10 transition: all 0.15s ease; 11 } 12 13 .btn-primary { ··· 118 .action-buttons { 119 display: flex; 120 gap: 8px; 121 } 122 123 .action-buttons-end {
··· 8 font-weight: 500; 9 border-radius: var(--radius-md); 10 transition: all 0.15s ease; 11 + white-space: pre; 12 } 13 14 .btn-primary { ··· 119 .action-buttons { 120 display: flex; 121 gap: 8px; 122 + flex-wrap: wrap; 123 } 124 125 .action-buttons-end {
+16
web/src/css/collections.css
··· 171 line-height: 1.3; 172 } 173 174 .collection-detail-desc { 175 color: var(--text-secondary); 176 font-size: 1rem; 177 line-height: 1.5; 178 margin-bottom: 12px; 179 max-width: 600px; 180 } 181 182 .collection-detail-stats {
··· 171 line-height: 1.3; 172 } 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 + 188 .collection-detail-desc { 189 color: var(--text-secondary); 190 font-size: 1rem; 191 line-height: 1.5; 192 margin-bottom: 12px; 193 max-width: 600px; 194 + overflow-wrap: break-word; 195 + word-break: break-word; 196 } 197 198 .collection-detail-stats {
+2
web/src/css/feed.css
··· 24 background: var(--bg-tertiary); 25 border-radius: var(--radius-lg); 26 width: fit-content; 27 } 28 29 .filter-tab {
··· 24 background: var(--bg-tertiary); 25 border-radius: var(--radius-lg); 26 width: fit-content; 27 + max-width: 100%; 28 + flex-wrap: wrap; 29 } 30 31 .filter-tab {
+48
web/src/css/layout.css
··· 451 .main-layout { 452 margin-left: 0; 453 padding-bottom: 80px; 454 } 455 456 .main-content-wrapper { 457 padding: 20px 16px; 458 max-width: 100%; 459 overflow-x: hidden; 460 } 461 462 .mobile-nav { 463 display: block; 464 } 465 }
··· 451 .main-layout { 452 margin-left: 0; 453 padding-bottom: 80px; 454 + width: 100%; 455 + min-width: 0; 456 } 457 458 .main-content-wrapper { 459 padding: 20px 16px; 460 max-width: 100%; 461 + width: 100%; 462 overflow-x: hidden; 463 + min-width: 0; 464 } 465 466 .mobile-nav { 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; 512 } 513 }
+118 -107
web/src/css/login.css
··· 3 flex-direction: column; 4 align-items: center; 5 justify-content: center; 6 - min-height: 70vh; 7 - padding: 60px 20px; 8 width: 100%; 9 - max-width: 500px; 10 - margin: 0 auto; 11 } 12 13 - .login-at-logo { 14 - font-size: 5rem; 15 - font-weight: 800; 16 - color: var(--accent); 17 - margin-bottom: 24px; 18 - line-height: 1; 19 } 20 21 .login-logo-img { 22 - width: 80px; 23 - height: 80px; 24 - margin-bottom: 24px; 25 object-fit: contain; 26 } 27 28 .login-heading { 29 font-size: 1.5rem; 30 - font-weight: 600; 31 margin-bottom: 32px; 32 display: flex; 33 align-items: center; 34 - gap: 10px; 35 text-align: center; 36 - line-height: 1.4; 37 } 38 39 .login-help-btn { ··· 53 } 54 55 .login-help-text { 56 - background: var(--bg-elevated); 57 border: 1px solid var(--border); 58 border-radius: var(--radius-md); 59 - padding: 16px 20px; 60 margin-bottom: 24px; 61 - font-size: 0.95rem; 62 color: var(--text-secondary); 63 - line-height: 1.6; 64 text-align: center; 65 } 66 67 .login-help-text code { 68 - background: var(--bg-tertiary); 69 - padding: 2px 8px; 70 border-radius: var(--radius-sm); 71 - font-size: 0.9rem; 72 } 73 74 .login-form { 75 display: flex; 76 flex-direction: column; 77 - gap: 16px; 78 width: 100%; 79 } 80 ··· 85 .login-input { 86 width: 100%; 87 padding: 14px 16px; 88 - background: var(--bg-elevated); 89 border: 1px solid var(--border); 90 border-radius: var(--radius-md); 91 color: var(--text-primary); 92 font-size: 1rem; 93 - transition: 94 - border-color 0.15s, 95 - box-shadow 0.15s; 96 } 97 98 .login-input:focus { 99 outline: none; 100 border-color: var(--accent); 101 - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); 102 } 103 104 .login-input::placeholder { ··· 107 108 .login-suggestions { 109 position: absolute; 110 - top: calc(100% + 4px); 111 left: 0; 112 right: 0; 113 - background: var(--bg-card); 114 border: 1px solid var(--border); 115 border-radius: var(--radius-md); 116 box-shadow: var(--shadow-lg); 117 overflow: hidden; 118 z-index: 100; 119 } 120 121 .login-suggestion { ··· 129 cursor: pointer; 130 text-align: left; 131 transition: background 0.1s; 132 } 133 134 .login-suggestion:hover, 135 .login-suggestion.selected { 136 - background: var(--bg-elevated); 137 } 138 139 .login-suggestion-avatar { 140 - width: 40px; 141 - height: 40px; 142 border-radius: var(--radius-full); 143 background: linear-gradient(135deg, var(--accent), #a855f7); 144 display: flex; ··· 146 justify-content: center; 147 flex-shrink: 0; 148 overflow: hidden; 149 - font-size: 0.875rem; 150 font-weight: 600; 151 color: white; 152 } ··· 161 display: flex; 162 flex-direction: column; 163 min-width: 0; 164 } 165 166 .login-suggestion-name { 167 font-weight: 600; 168 color: var(--text-primary); 169 white-space: nowrap; 170 overflow: hidden; ··· 172 } 173 174 .login-suggestion-handle { 175 - font-size: 0.875rem; 176 color: var(--text-secondary); 177 white-space: nowrap; 178 overflow: hidden; ··· 182 .login-error { 183 padding: 12px 16px; 184 background: rgba(239, 68, 68, 0.1); 185 - border: 1px solid rgba(239, 68, 68, 0.3); 186 border-radius: var(--radius-md); 187 - color: #ef4444; 188 font-size: 0.875rem; 189 } 190 191 - .login-legal { 192 - font-size: 0.75rem; 193 - color: var(--text-tertiary); 194 - line-height: 1.5; 195 - margin-top: 16px; 196 - } 197 - 198 - .login-brand { 199 - display: flex; 200 - align-items: center; 201 justify-content: center; 202 - gap: 12px; 203 - margin-bottom: 24px; 204 - } 205 - 206 - .login-brand-icon { 207 - width: 48px; 208 - height: 48px; 209 - background: linear-gradient(135deg, var(--accent), #a855f7); 210 - border-radius: var(--radius-lg); 211 - display: flex; 212 - align-items: center; 213 - justify-content: center; 214 - font-size: 1.75rem; 215 - font-weight: 800; 216 - color: white; 217 - } 218 - 219 - .login-brand-name { 220 - font-size: 1.75rem; 221 - font-weight: 700; 222 - } 223 - 224 - .login-avatar { 225 - width: 72px; 226 - height: 72px; 227 - border-radius: var(--radius-full); 228 - background: linear-gradient(135deg, var(--accent), #a855f7); 229 - display: flex; 230 - align-items: center; 231 - justify-content: center; 232 - margin: 0 auto 16px; 233 - font-weight: 700; 234 - font-size: 1.5rem; 235 - color: white; 236 - overflow: hidden; 237 - } 238 - 239 - .login-avatar img { 240 - width: 100%; 241 - height: 100%; 242 - object-fit: cover; 243 } 244 245 .login-avatar-large { 246 - width: 100px; 247 - height: 100px; 248 border-radius: var(--radius-full); 249 background: linear-gradient(135deg, var(--accent), #a855f7); 250 display: flex; ··· 255 font-size: 2rem; 256 color: white; 257 overflow: hidden; 258 } 259 260 .login-avatar-large img { ··· 264 } 265 266 .login-welcome { 267 - font-size: 1.5rem; 268 font-weight: 600; 269 margin-bottom: 32px; 270 text-align: center; 271 - } 272 - 273 - .login-welcome-name { 274 - font-size: 1.25rem; 275 - font-weight: 600; 276 - margin-bottom: 24px; 277 } 278 279 .login-actions { ··· 283 width: 100%; 284 } 285 286 - .login-btn { 287 - width: 100%; 288 - padding: 14px 24px; 289 - font-size: 1rem; 290 - font-weight: 600; 291 } 292 293 - .login-submit { 294 - padding: 18px 32px; 295 - font-size: 1.1rem; 296 - font-weight: 600; 297 }
··· 3 flex-direction: column; 4 align-items: center; 5 justify-content: center; 6 + min-height: 80vh; 7 + padding: 40px 20px; 8 width: 100%; 9 } 10 11 + .login-header-group { 12 + display: flex; 13 + flex-direction: row; 14 + align-items: center; 15 + justify-content: center; 16 + gap: 24px; 17 + margin-bottom: 48px; 18 + width: auto; 19 } 20 21 .login-logo-img { 22 + width: 60px; 23 + height: 60px; 24 object-fit: contain; 25 + display: block; 26 + } 27 + 28 + .login-x { 29 + font-size: 2rem; 30 + color: var(--text-tertiary); 31 + font-weight: 300; 32 + line-height: 1; 33 + padding-bottom: 4px; 34 + } 35 + 36 + .login-atproto-icon { 37 + color: #3b83f6 !important; 38 + display: flex; 39 + align-items: center; 40 + justify-content: center; 41 } 42 43 .login-heading { 44 font-size: 1.5rem; 45 + font-weight: 700; 46 margin-bottom: 32px; 47 display: flex; 48 align-items: center; 49 + justify-content: center; 50 + gap: 8px; 51 text-align: center; 52 + line-height: 1.3; 53 + color: var(--text-primary); 54 } 55 56 .login-help-btn { ··· 70 } 71 72 .login-help-text { 73 + background: var(--bg-tertiary); 74 border: 1px solid var(--border); 75 border-radius: var(--radius-md); 76 + padding: 16px; 77 margin-bottom: 24px; 78 + font-size: 0.9rem; 79 color: var(--text-secondary); 80 + line-height: 1.5; 81 text-align: center; 82 + width: 100%; 83 } 84 85 .login-help-text code { 86 + background: rgba(255, 255, 255, 0.05); 87 + padding: 2px 6px; 88 border-radius: var(--radius-sm); 89 + font-size: 0.85rem; 90 + font-family: var(--font-mono); 91 } 92 93 .login-form { 94 display: flex; 95 flex-direction: column; 96 + gap: 20px; 97 width: 100%; 98 } 99 ··· 104 .login-input { 105 width: 100%; 106 padding: 14px 16px; 107 + background: var(--bg-secondary); 108 border: 1px solid var(--border); 109 border-radius: var(--radius-md); 110 color: var(--text-primary); 111 font-size: 1rem; 112 + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 113 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); 114 } 115 116 .login-input:focus { 117 outline: none; 118 border-color: var(--accent); 119 + box-shadow: 0 0 0 4px var(--accent-subtle); 120 + background: var(--bg-primary); 121 } 122 123 .login-input::placeholder { ··· 126 127 .login-suggestions { 128 position: absolute; 129 + top: calc(100% + 8px); 130 left: 0; 131 right: 0; 132 + background: var(--bg-elevated); 133 border: 1px solid var(--border); 134 border-radius: var(--radius-md); 135 box-shadow: var(--shadow-lg); 136 overflow: hidden; 137 z-index: 100; 138 + max-height: 300px; 139 + overflow-y: auto; 140 } 141 142 .login-suggestion { ··· 150 cursor: pointer; 151 text-align: left; 152 transition: background 0.1s; 153 + border-bottom: 1px solid var(--border); 154 + } 155 + 156 + .login-suggestion:last-child { 157 + border-bottom: none; 158 } 159 160 .login-suggestion:hover, 161 .login-suggestion.selected { 162 + background: var(--bg-tertiary); 163 } 164 165 .login-suggestion-avatar { 166 + width: 36px; 167 + height: 36px; 168 border-radius: var(--radius-full); 169 background: linear-gradient(135deg, var(--accent), #a855f7); 170 display: flex; ··· 172 justify-content: center; 173 flex-shrink: 0; 174 overflow: hidden; 175 + font-size: 0.8rem; 176 font-weight: 600; 177 color: white; 178 } ··· 187 display: flex; 188 flex-direction: column; 189 min-width: 0; 190 + gap: 2px; 191 } 192 193 .login-suggestion-name { 194 font-weight: 600; 195 + font-size: 0.95rem; 196 color: var(--text-primary); 197 white-space: nowrap; 198 overflow: hidden; ··· 200 } 201 202 .login-suggestion-handle { 203 + font-size: 0.85rem; 204 color: var(--text-secondary); 205 white-space: nowrap; 206 overflow: hidden; ··· 210 .login-error { 211 padding: 12px 16px; 212 background: rgba(239, 68, 68, 0.1); 213 + border: 1px solid rgba(239, 68, 68, 0.2); 214 border-radius: var(--radius-md); 215 + color: var(--error); 216 font-size: 0.875rem; 217 + text-align: center; 218 } 219 220 + .login-submit { 221 + padding: 14px 24px; 222 + font-size: 1rem; 223 + font-weight: 600; 224 + width: 100%; 225 justify-content: center; 226 } 227 228 .login-avatar-large { 229 + width: 80px; 230 + height: 80px; 231 border-radius: var(--radius-full); 232 background: linear-gradient(135deg, var(--accent), #a855f7); 233 display: flex; ··· 238 font-size: 2rem; 239 color: white; 240 overflow: hidden; 241 + box-shadow: var(--shadow-md); 242 } 243 244 .login-avatar-large img { ··· 248 } 249 250 .login-welcome { 251 + font-size: 1.25rem; 252 font-weight: 600; 253 margin-bottom: 32px; 254 text-align: center; 255 + color: var(--text-primary); 256 } 257 258 .login-actions { ··· 262 width: 100%; 263 } 264 265 + .morph-container { 266 + display: inline-block; 267 + color: var(--text-primary); 268 + font-weight: 700; 269 + transition: 270 + opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1), 271 + transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), 272 + filter 0.4s cubic-bezier(0.4, 0, 0.2, 1); 273 + white-space: nowrap; 274 + vertical-align: bottom; 275 } 276 277 + .morph-out { 278 + opacity: 0; 279 + transform: translateY(8px) scale(0.95); 280 + filter: blur(4px); 281 + } 282 + 283 + .morph-in { 284 + opacity: 1; 285 + transform: translateY(0) scale(1); 286 + filter: blur(0); 287 + } 288 + 289 + .login-legal { 290 + margin-top: 24px; 291 + font-size: 0.85rem; 292 + color: var(--text-tertiary); 293 + text-align: center; 294 + line-height: 1.5; 295 + } 296 + 297 + .login-legal a { 298 + color: var(--accent); 299 + text-decoration: underline; 300 + text-decoration-color: var(--accent); 301 + text-underline-offset: 4px; 302 + font-weight: 500; 303 + } 304 + 305 + .login-legal a:hover { 306 + text-decoration-thickness: 2px; 307 + opacity: 0.8; 308 }
+2
web/src/css/notifications.css
··· 53 margin-bottom: 4px; 54 line-height: 1.4; 55 color: var(--text-primary); 56 } 57 58 .notification-text strong {
··· 53 margin-bottom: 4px; 54 line-height: 1.4; 55 color: var(--text-primary); 56 + overflow-wrap: break-word; 57 + word-break: break-word; 58 } 59 60 .notification-text strong {
+7
web/src/css/profile.css
··· 45 font-weight: 700; 46 color: var(--text-primary); 47 line-height: 1.2; 48 } 49 50 .profile-handle-row { ··· 60 text-decoration: none; 61 font-size: 1rem; 62 transition: color 0.15s; 63 } 64 65 .profile-handle-link:hover { ··· 78 border-radius: var(--radius-sm); 79 background: rgba(59, 130, 246, 0.1); 80 transition: all 0.15s ease; 81 } 82 83 .profile-bluesky-link:hover { ··· 105 gap: 24px; 106 margin-bottom: 24px; 107 border-bottom: 1px solid var(--border); 108 } 109 110 .profile-tab {
··· 45 font-weight: 700; 46 color: var(--text-primary); 47 line-height: 1.2; 48 + overflow-wrap: break-word; 49 + word-break: break-word; 50 } 51 52 .profile-handle-row { ··· 62 text-decoration: none; 63 font-size: 1rem; 64 transition: color 0.15s; 65 + overflow-wrap: break-word; 66 + word-break: break-all; 67 } 68 69 .profile-handle-link:hover { ··· 82 border-radius: var(--radius-sm); 83 background: rgba(59, 130, 246, 0.1); 84 transition: all 0.15s ease; 85 + width: fit-content; 86 } 87 88 .profile-bluesky-link:hover { ··· 110 gap: 24px; 111 margin-bottom: 24px; 112 border-bottom: 1px solid var(--border); 113 + flex-wrap: wrap; 114 + row-gap: 8px; 115 } 116 117 .profile-tab {
+19
web/src/css/utilities.css
··· 261 margin-bottom: 16px; 262 font-style: italic; 263 color: var(--text-secondary); 264 } 265 266 .composer-quote-remove { ··· 728 .bookmark-time { 729 color: var(--text-tertiary); 730 }
··· 261 margin-bottom: 16px; 262 font-style: italic; 263 color: var(--text-secondary); 264 + overflow-wrap: break-word; 265 + word-break: break-word; 266 + max-width: 100%; 267 } 268 269 .composer-quote-remove { ··· 731 .bookmark-time { 732 color: var(--text-tertiary); 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 + }
+18
web/src/pages/Bookmarks.jsx
··· 10 } from "../api/client"; 11 import { BookmarkIcon } from "../components/Icons"; 12 import BookmarkCard from "../components/BookmarkCard"; 13 14 export default function Bookmarks() { 15 const { user, isAuthenticated, loading } = useAuth(); ··· 21 const [newTitle, setNewTitle] = useState(""); 22 const [submitting, setSubmitting] = useState(false); 23 const [fetchingTitle, setFetchingTitle] = useState(false); 24 25 const loadBookmarks = useCallback(async () => { 26 if (!user?.did) return; ··· 285 key={bookmark.id} 286 bookmark={bookmark} 287 onDelete={handleDelete} 288 /> 289 ))} 290 </div> 291 )} 292 </div> 293 );
··· 10 } from "../api/client"; 11 import { BookmarkIcon } from "../components/Icons"; 12 import BookmarkCard from "../components/BookmarkCard"; 13 + import AddToCollectionModal from "../components/AddToCollectionModal"; 14 15 export default function Bookmarks() { 16 const { user, isAuthenticated, loading } = useAuth(); ··· 22 const [newTitle, setNewTitle] = useState(""); 23 const [submitting, setSubmitting] = useState(false); 24 const [fetchingTitle, setFetchingTitle] = useState(false); 25 + const [collectionModalState, setCollectionModalState] = useState({ 26 + isOpen: false, 27 + uri: null, 28 + }); 29 30 const loadBookmarks = useCallback(async () => { 31 if (!user?.did) return; ··· 290 key={bookmark.id} 291 bookmark={bookmark} 292 onDelete={handleDelete} 293 + onAddToCollection={() => 294 + setCollectionModalState({ 295 + isOpen: true, 296 + uri: bookmark.uri || bookmark.id, 297 + }) 298 + } 299 /> 300 ))} 301 </div> 302 + )} 303 + {collectionModalState.isOpen && ( 304 + <AddToCollectionModal 305 + isOpen={collectionModalState.isOpen} 306 + onClose={() => setCollectionModalState({ isOpen: false, uri: null })} 307 + annotationUri={collectionModalState.uri} 308 + /> 309 )} 310 </div> 311 );
+76 -48
web/src/pages/Login.jsx
··· 2 import { Link } from "react-router-dom"; 3 import { useAuth } from "../context/AuthContext"; 4 import { searchActors, startLogin } from "../api/client"; 5 - import { HelpCircle } from "lucide-react"; 6 import logo from "../assets/logo.svg"; 7 8 export default function Login() { ··· 12 const [showInviteInput, setShowInviteInput] = useState(false); 13 const [suggestions, setSuggestions] = useState([]); 14 const [showSuggestions, setShowSuggestions] = useState(false); 15 - const [showHelp, setShowHelp] = useState(false); 16 const [loading, setLoading] = useState(false); 17 const [error, setError] = useState(null); 18 const [selectedIndex, setSelectedIndex] = useState(-1); 19 const inputRef = useRef(null); 20 const inviteRef = useRef(null); 21 const suggestionsRef = useRef(null); 22 23 const isSelectionRef = useRef(false); 24 ··· 58 return () => document.removeEventListener("mousedown", handleClickOutside); 59 }, []); 60 61 const handleKeyDown = (e) => { 62 if (!showSuggestions || suggestions.length === 0) return; 63 ··· 113 } 114 }; 115 116 - if (isAuthenticated) { 117 - return ( 118 - <div className="login-page"> 119 - <div className="login-avatar-large"> 120 - {user?.avatar ? ( 121 - <img src={user.avatar} alt={user.displayName || user.handle} /> 122 - ) : ( 123 - <span> 124 - {(user?.displayName || user?.handle || "??") 125 - .substring(0, 2) 126 - .toUpperCase()} 127 - </span> 128 - )} 129 - </div> 130 - <h1 className="login-welcome"> 131 - Welcome back, {user?.displayName || user?.handle} 132 - </h1> 133 - <div className="login-actions"> 134 - <Link to={`/profile/${user?.did}`} className="btn btn-primary"> 135 - View Profile 136 - </Link> 137 - <button onClick={logout} className="btn btn-ghost"> 138 - Sign out 139 - </button> 140 - </div> 141 - </div> 142 - ); 143 - } 144 - 145 return ( 146 <div className="login-page"> 147 - <img src={logo} alt="Margin Logo" className="login-logo-img" /> 148 149 <h1 className="login-heading"> 150 - Use the AT Protocol to login to Margin 151 - <button 152 - className="login-help-btn" 153 - onClick={() => setShowHelp(!showHelp)} 154 - type="button" 155 - > 156 - <HelpCircle size={20} /> 157 - </button> 158 </h1> 159 - 160 - {showHelp && ( 161 - <p className="login-help-text"> 162 - The AT Protocol is an open, decentralized network for social apps. 163 - Your handle looks like <code>name.bsky.social</code> or your own 164 - domain. 165 - </p> 166 - )} 167 168 <form onSubmit={handleSubmit} className="login-form"> 169 <div className="login-input-wrapper"> ··· 263 ? "Submit Code" 264 : "Continue"} 265 </button> 266 </form> 267 </div> 268 );
··· 2 import { Link } from "react-router-dom"; 3 import { useAuth } from "../context/AuthContext"; 4 import { searchActors, startLogin } from "../api/client"; 5 + import { AtSign } from "lucide-react"; 6 import logo from "../assets/logo.svg"; 7 8 export default function Login() { ··· 12 const [showInviteInput, setShowInviteInput] = useState(false); 13 const [suggestions, setSuggestions] = useState([]); 14 const [showSuggestions, setShowSuggestions] = useState(false); 15 const [loading, setLoading] = useState(false); 16 const [error, setError] = useState(null); 17 const [selectedIndex, setSelectedIndex] = useState(-1); 18 const inputRef = useRef(null); 19 const inviteRef = useRef(null); 20 const suggestionsRef = useRef(null); 21 + 22 + const [providerIndex, setProviderIndex] = useState(0); 23 + const [morphClass, setMorphClass] = useState("morph-in"); 24 + const providers = [ 25 + "AT Protocol", 26 + "Bluesky", 27 + "Blacksky", 28 + "Tangled", 29 + "selfhosted.social", 30 + "Northsky", 31 + "witchcraft.systems", 32 + "topphie.social", 33 + "altq.net", 34 + ]; 35 + 36 + useEffect(() => { 37 + const cycleText = () => { 38 + setMorphClass("morph-out"); 39 + 40 + setTimeout(() => { 41 + setProviderIndex((prev) => (prev + 1) % providers.length); 42 + setMorphClass("morph-in"); 43 + }, 400); 44 + }; 45 + 46 + const interval = setInterval(cycleText, 3000); 47 + return () => clearInterval(interval); 48 + }, [providers.length]); 49 50 const isSelectionRef = useRef(false); 51 ··· 85 return () => document.removeEventListener("mousedown", handleClickOutside); 86 }, []); 87 88 + if (isAuthenticated) { 89 + return ( 90 + <div className="login-page"> 91 + <div className="login-avatar-large"> 92 + {user?.avatar ? ( 93 + <img src={user.avatar} alt={user.displayName || user.handle} /> 94 + ) : ( 95 + <span> 96 + {(user?.displayName || user?.handle || "??") 97 + .substring(0, 2) 98 + .toUpperCase()} 99 + </span> 100 + )} 101 + </div> 102 + <h1 className="login-welcome"> 103 + Welcome back, {user?.displayName || user?.handle} 104 + </h1> 105 + <div className="login-actions"> 106 + <Link to={`/profile/${user?.did}`} className="btn btn-primary"> 107 + View Profile 108 + </Link> 109 + <button onClick={logout} className="btn btn-ghost"> 110 + Sign out 111 + </button> 112 + </div> 113 + </div> 114 + ); 115 + } 116 + 117 const handleKeyDown = (e) => { 118 if (!showSuggestions || suggestions.length === 0) return; 119 ··· 169 } 170 }; 171 172 return ( 173 <div className="login-page"> 174 + <div className="login-header-group"> 175 + <img src={logo} alt="Margin Logo" className="login-logo-img" /> 176 + <span className="login-x">X</span> 177 + <div className="login-atproto-icon"> 178 + <AtSign size={64} strokeWidth={2.4} /> 179 + </div> 180 + </div> 181 182 <h1 className="login-heading"> 183 + Sign in with your{" "} 184 + <span className={`morph-container ${morphClass}`}> 185 + {providers[providerIndex]} 186 + </span>{" "} 187 + handle 188 </h1> 189 190 <form onSubmit={handleSubmit} className="login-form"> 191 <div className="login-input-wrapper"> ··· 285 ? "Submit Code" 286 : "Continue"} 287 </button> 288 + 289 + <p className="login-legal"> 290 + By signing in, you agree to our{" "} 291 + <Link to="/terms">Terms of Service</Link> and{" "} 292 + <Link to="/privacy">Privacy Policy</Link>. 293 + </p> 294 </form> 295 </div> 296 );
+246
web/src/pages/Profile.jsx
··· 7 getUserHighlights, 8 getUserBookmarks, 9 getCollections, 10 } from "../api/client"; 11 import CollectionIcon from "../components/CollectionIcon"; 12 import CollectionRow from "../components/CollectionRow"; 13 import { ··· 17 BlueskyIcon, 18 } from "../components/Icons"; 19 20 export default function Profile() { 21 const { handle } = useParams(); 22 const [activeTab, setActiveTab] = useState("annotations"); 23 const [profile, setProfile] = useState(null); 24 const [annotations, setAnnotations] = useState([]); 25 const [highlights, setHighlights] = useState([]); 26 const [bookmarks, setBookmarks] = useState([]); 27 const [collections, setCollections] = useState([]); 28 const [loading, setLoading] = useState(true); 29 const [error, setError] = useState(null); 30 31 useEffect(() => { 32 async function fetchProfile() { ··· 62 fetchProfile(); 63 }, [handle]); 64 65 const displayName = profile?.displayName || profile?.handle || handle; 66 const displayHandle = 67 profile?.handle || (handle?.startsWith("did:") ? null : handle); ··· 155 </div> 156 ); 157 } 158 }; 159 160 const bskyProfileUrl = displayHandle ··· 230 > 231 Collections ({collections.length}) 232 </button> 233 </div> 234 235 {loading && ( ··· 262 </div> 263 ); 264 }
··· 7 getUserHighlights, 8 getUserBookmarks, 9 getCollections, 10 + getAPIKeys, 11 + createAPIKey, 12 + deleteAPIKey, 13 } from "../api/client"; 14 + import { useAuth } from "../context/AuthContext"; 15 import CollectionIcon from "../components/CollectionIcon"; 16 import CollectionRow from "../components/CollectionRow"; 17 import { ··· 21 BlueskyIcon, 22 } from "../components/Icons"; 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 + 41 export default function Profile() { 42 const { handle } = useParams(); 43 + const { user } = useAuth(); 44 const [activeTab, setActiveTab] = useState("annotations"); 45 const [profile, setProfile] = useState(null); 46 const [annotations, setAnnotations] = useState([]); 47 const [highlights, setHighlights] = useState([]); 48 const [bookmarks, setBookmarks] = useState([]); 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); 54 const [loading, setLoading] = useState(true); 55 const [error, setError] = useState(null); 56 + 57 + const isOwnProfile = user && (user.did === handle || user.handle === handle); 58 59 useEffect(() => { 60 async function fetchProfile() { ··· 90 fetchProfile(); 91 }, [handle]); 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 + 133 const displayName = profile?.displayName || profile?.handle || handle; 134 const displayHandle = 135 profile?.handle || (handle?.startsWith("did:") ? null : handle); ··· 223 </div> 224 ); 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="#" 370 + className="btn btn-primary" 371 + style={{ 372 + display: "inline-flex", 373 + alignItems: "center", 374 + gap: "0.5rem", 375 + opacity: 0.5, 376 + pointerEvents: "none", 377 + cursor: "default", 378 + }} 379 + onClick={(e) => e.preventDefault()} 380 + > 381 + <AppleIcon size={16} /> Coming Soon 382 + </a> 383 + </div> 384 + </div> 385 + ); 386 + } 387 }; 388 389 const bskyProfileUrl = displayHandle ··· 459 > 460 Collections ({collections.length}) 461 </button> 462 + 463 + {isOwnProfile && ( 464 + <button 465 + className={`profile-tab ${activeTab === "apikeys" ? "active" : ""}`} 466 + onClick={() => setActiveTab("apikeys")} 467 + > 468 + <KeyIcon size={14} /> API Keys 469 + </button> 470 + )} 471 </div> 472 473 {loading && ( ··· 500 </div> 501 ); 502 } 503 + 504 + function AppleIcon({ size = 16 }) { 505 + return ( 506 + <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor"> 507 + <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" /> 508 + </svg> 509 + ); 510 + }