+16
-1
backend/go.mod
+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
+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
+4
-1
backend/internal/api/annotations.go
+435
backend/internal/api/apikey.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+2
-1
docker-compose.yml
···
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
+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
+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
+3
-3
lexicons/at/margin/collection.json
-2
web/src/App.jsx
-2
web/src/App.jsx
+15
web/src/api/client.js
+15
web/src/api/client.js
···
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
+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
+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
+34
-1
web/src/css/annotations.css
···
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 {
+16
web/src/css/collections.css
+16
web/src/css/collections.css
···
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
+2
web/src/css/feed.css
+48
web/src/css/layout.css
+48
web/src/css/layout.css
···
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
+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
+2
web/src/css/notifications.css
+7
web/src/css/profile.css
+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
+19
web/src/css/utilities.css
···
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
+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
+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
+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'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'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
+
}