diff --git a/backend/go.mod b/backend/go.mod index faa5a76..3c12a47 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,9 +3,12 @@ module margin.at go 1.24.0 require ( + github.com/fxamacker/cbor/v2 v2.9.0 github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/cors v1.2.1 github.com/go-jose/go-jose/v4 v4.0.4 + github.com/gorilla/websocket v1.5.3 + github.com/ipfs/go-cid v0.6.0 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.22 @@ -14,8 +17,20 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/minio/sha256-simd v1.0.0 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/multiformats/go-base32 v0.0.3 // indirect + github.com/multiformats/go-base36 v0.1.0 // indirect + github.com/multiformats/go-multibase v0.2.0 // indirect + github.com/multiformats/go-multihash v0.2.3 // indirect + github.com/multiformats/go-varint v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/stretchr/testify v1.10.0 // indirect - golang.org/x/crypto v0.31.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.32.0 // indirect + lukechampine.com/blake3 v1.1.6 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index b770d1a..ff92a28 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,7 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= @@ -8,21 +10,50 @@ github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= +github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/multiformats/go-base32 v0.0.3 h1:tw5+NhuwaOjJCC5Pp82QuXbrmLzWg7uxlMFp8Nq/kkI= +github.com/multiformats/go-base32 v0.0.3/go.mod h1:pLiuGC8y0QR3Ue4Zug5UzK9LjgbkL8NSQj0zQ5Nz/AA= +github.com/multiformats/go-base36 v0.1.0 h1:JR6TyF7JjGd3m6FbLU2cOxhC0Li8z8dLNGQ89tUg4F4= +github.com/multiformats/go-base36 v0.1.0/go.mod h1:kFGE83c6s80PklsHO9sRn2NCoffoRdUUOENyW/Vv6sM= +github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= +github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= +github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= +lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= diff --git a/backend/internal/api/apikey.go b/backend/internal/api/apikey.go new file mode 100644 index 0000000..8c7daae --- /dev/null +++ b/backend/internal/api/apikey.go @@ -0,0 +1,342 @@ +package api + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "margin.at/internal/db" + "margin.at/internal/xrpc" +) + +type APIKeyHandler struct { + db *db.DB + refresher *TokenRefresher +} + +func NewAPIKeyHandler(database *db.DB, refresher *TokenRefresher) *APIKeyHandler { + return &APIKeyHandler{db: database, refresher: refresher} +} + +type CreateKeyRequest struct { + Name string `json:"name"` +} + +type CreateKeyResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Key string `json:"key"` + CreatedAt time.Time `json:"createdAt"` +} + +func (h *APIKeyHandler) CreateKey(w http.ResponseWriter, r *http.Request) { + session, err := h.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req CreateKeyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Name == "" { + req.Name = "API Key" + } + + rawKey := generateAPIKey() + keyHash := hashAPIKey(rawKey) + keyID := generateKeyID() + + apiKey := &db.APIKey{ + ID: keyID, + OwnerDID: session.DID, + Name: req.Name, + KeyHash: keyHash, + CreatedAt: time.Now(), + } + + if err := h.db.CreateAPIKey(apiKey); err != nil { + http.Error(w, "Failed to create key", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(CreateKeyResponse{ + ID: keyID, + Name: req.Name, + Key: rawKey, + CreatedAt: apiKey.CreatedAt, + }) +} + +func (h *APIKeyHandler) ListKeys(w http.ResponseWriter, r *http.Request) { + session, err := h.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + keys, err := h.db.GetAPIKeysByOwner(session.DID) + if err != nil { + http.Error(w, "Failed to get keys", http.StatusInternalServerError) + return + } + + if keys == nil { + keys = []db.APIKey{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"keys": keys}) +} + +func (h *APIKeyHandler) DeleteKey(w http.ResponseWriter, r *http.Request) { + session, err := h.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + keyID := chi.URLParam(r, "id") + if keyID == "" { + http.Error(w, "Key ID required", http.StatusBadRequest) + return + } + + if err := h.db.DeleteAPIKey(keyID, session.DID); err != nil { + http.Error(w, "Failed to delete key", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"success": true}) +} + +type QuickBookmarkRequest struct { + URL string `json:"url"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` +} + +func (h *APIKeyHandler) QuickBookmark(w http.ResponseWriter, r *http.Request) { + apiKey, err := h.authenticateAPIKey(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + var req QuickBookmarkRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.URL == "" { + http.Error(w, "URL is required", http.StatusBadRequest) + return + } + + session, err := h.getSessionByDID(apiKey.OwnerDID) + if err != nil { + http.Error(w, "User session not found. Please log in to margin.at first.", http.StatusUnauthorized) + return + } + + urlHash := db.HashURL(req.URL) + record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) + + client := h.refresher.CreateClientFromSession(session) + result, err := client.CreateRecord(r.Context(), session.DID, xrpc.CollectionBookmark, record) + if err != nil { + http.Error(w, "Failed to create bookmark: "+err.Error(), http.StatusInternalServerError) + return + } + + h.db.UpdateAPIKeyLastUsed(apiKey.ID) + + var titlePtr, descPtr *string + if req.Title != "" { + titlePtr = &req.Title + } + if req.Description != "" { + descPtr = &req.Description + } + + cid := result.CID + bookmark := &db.Bookmark{ + URI: result.URI, + AuthorDID: apiKey.OwnerDID, + Source: req.URL, + SourceHash: urlHash, + Title: titlePtr, + Description: descPtr, + CreatedAt: time.Now(), + IndexedAt: time.Now(), + CID: &cid, + } + h.db.CreateBookmark(bookmark) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "uri": result.URI, + "cid": result.CID, + "message": "Bookmark created successfully", + }) +} + +type QuickAnnotationRequest struct { + URL string `json:"url"` + Text string `json:"text"` +} + +func (h *APIKeyHandler) QuickAnnotation(w http.ResponseWriter, r *http.Request) { + apiKey, err := h.authenticateAPIKey(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + var req QuickAnnotationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.URL == "" || req.Text == "" { + http.Error(w, "URL and text are required", http.StatusBadRequest) + return + } + + session, err := h.getSessionByDID(apiKey.OwnerDID) + if err != nil { + http.Error(w, "User session not found. Please log in to margin.at first.", http.StatusUnauthorized) + return + } + + urlHash := db.HashURL(req.URL) + record := xrpc.NewAnnotationRecord(req.URL, urlHash, req.Text, nil, "") + + client := h.refresher.CreateClientFromSession(session) + result, err := client.CreateRecord(r.Context(), session.DID, xrpc.CollectionAnnotation, record) + if err != nil { + http.Error(w, "Failed to create annotation: "+err.Error(), http.StatusInternalServerError) + return + } + + h.db.UpdateAPIKeyLastUsed(apiKey.ID) + + bodyValue := req.Text + annotation := &db.Annotation{ + URI: result.URI, + AuthorDID: apiKey.OwnerDID, + Motivation: "commenting", + BodyValue: &bodyValue, + TargetSource: req.URL, + TargetHash: urlHash, + CreatedAt: time.Now(), + IndexedAt: time.Now(), + CID: &result.CID, + } + h.db.CreateAnnotation(annotation) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "uri": result.URI, + "cid": result.CID, + "message": "Annotation created successfully", + }) +} + +func (h *APIKeyHandler) authenticateAPIKey(r *http.Request) (*db.APIKey, error) { + auth := r.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("missing Authorization header") + } + + if !strings.HasPrefix(auth, "Bearer ") { + return nil, fmt.Errorf("invalid Authorization format, expected 'Bearer '") + } + + rawKey := strings.TrimPrefix(auth, "Bearer ") + keyHash := hashAPIKey(rawKey) + + apiKey, err := h.db.GetAPIKeyByHash(keyHash) + if err != nil { + return nil, fmt.Errorf("invalid API key") + } + + return apiKey, nil +} + +func (h *APIKeyHandler) getSessionByDID(did string) (*SessionData, error) { + rows, err := h.db.Query(h.db.Rebind(` + SELECT id, did, handle, access_token, refresh_token, COALESCE(dpop_key, '') + FROM sessions + WHERE did = ? AND expires_at > ? + ORDER BY created_at DESC + LIMIT 1 + `), did, time.Now()) + if err != nil { + return nil, err + } + defer rows.Close() + + if !rows.Next() { + return nil, fmt.Errorf("no active session") + } + + var sessionID, sessDID, handle, accessToken, refreshToken, dpopKeyStr string + if err := rows.Scan(&sessionID, &sessDID, &handle, &accessToken, &refreshToken, &dpopKeyStr); err != nil { + return nil, err + } + + block, _ := pem.Decode([]byte(dpopKeyStr)) + if block == nil { + return nil, fmt.Errorf("invalid session DPoP key") + } + dpopKey, err := x509.ParseECPrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("invalid session DPoP key: %w", err) + } + + pds, _ := resolveDIDToPDS(sessDID) + + return &SessionData{ + DID: sessDID, + Handle: handle, + AccessToken: accessToken, + RefreshToken: refreshToken, + DPoPKey: dpopKey, + PDS: pds, + }, nil +} + +func generateAPIKey() string { + b := make([]byte, 32) + rand.Read(b) + return "mk_" + hex.EncodeToString(b) +} + +func generateKeyID() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +func hashAPIKey(key string) string { + h := sha256.New() + h.Write([]byte(key)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/backend/internal/api/avatar.go b/backend/internal/api/avatar.go new file mode 100644 index 0000000..729576f --- /dev/null +++ b/backend/internal/api/avatar.go @@ -0,0 +1,117 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/url" + "os" + "sync" + "time" + + "github.com/go-chi/chi/v5" +) + +type avatarCache struct { + url string + fetchedAt time.Time +} + +var ( + avatarCacheMu sync.RWMutex + avatarCacheMap = make(map[string]avatarCache) + avatarCacheTTL = 5 * time.Minute +) + +func (h *Handler) HandleAvatarProxy(w http.ResponseWriter, r *http.Request) { + did := chi.URLParam(r, "did") + if did == "" { + http.Error(w, "DID required", http.StatusBadRequest) + return + } + + if decoded, err := url.QueryUnescape(did); err == nil { + did = decoded + } + + avatarURL := getAvatarURL(did) + if avatarURL == "" { + http.Error(w, "Avatar not found", http.StatusNotFound) + return + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(avatarURL) + if err != nil { + http.Error(w, "Failed to fetch avatar", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + http.Error(w, "Avatar not available", http.StatusNotFound) + return + } + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "image/jpeg" + } + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("Access-Control-Allow-Origin", "*") + + io.Copy(w, resp.Body) +} + +func getAvatarURL(did string) string { + avatarCacheMu.RLock() + if cached, ok := avatarCacheMap[did]; ok && time.Since(cached.fetchedAt) < avatarCacheTTL { + avatarCacheMu.RUnlock() + return cached.url + } + avatarCacheMu.RUnlock() + + q := url.Values{} + q.Add("actor", did) + + resp, err := http.Get("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?" + q.Encode()) + if err != nil { + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "" + } + + var profile struct { + Avatar string `json:"avatar"` + } + if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { + return "" + } + + avatarCacheMu.Lock() + avatarCacheMap[did] = avatarCache{ + url: profile.Avatar, + fetchedAt: time.Now(), + } + avatarCacheMu.Unlock() + + return profile.Avatar +} + +func getProxiedAvatarURL(did, originalURL string) string { + if originalURL == "" { + return "" + } + + baseURL := os.Getenv("BASE_URL") + if baseURL == "" { + return originalURL + } + + return baseURL + "/api/avatar/" + url.PathEscape(did) +} diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go index e4332b1..bbb099e 100644 --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -19,10 +19,16 @@ type Handler struct { db *db.DB annotationService *AnnotationService refresher *TokenRefresher + apiKeys *APIKeyHandler } func NewHandler(database *db.DB, annotationService *AnnotationService, refresher *TokenRefresher) *Handler { - return &Handler{db: database, annotationService: annotationService, refresher: refresher} + return &Handler{ + db: database, + annotationService: annotationService, + refresher: refresher, + apiKeys: NewAPIKeyHandler(database, refresher), + } } func (h *Handler) RegisterRoutes(r chi.Router) { @@ -64,6 +70,14 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/notifications", h.GetNotifications) r.Get("/notifications/count", h.GetUnreadNotificationCount) r.Post("/notifications/read", h.MarkNotificationsRead) + r.Get("/avatar/{did}", h.HandleAvatarProxy) + + r.Post("/keys", h.apiKeys.CreateKey) + r.Get("/keys", h.apiKeys.ListKeys) + r.Delete("/keys/{id}", h.apiKeys.DeleteKey) + + r.Post("/quick/bookmark", h.apiKeys.QuickBookmark) + r.Post("/quick/annotation", h.apiKeys.QuickAnnotation) }) } diff --git a/backend/internal/api/hydration.go b/backend/internal/api/hydration.go index b750b7f..8f4cdcd 100644 --- a/backend/internal/api/hydration.go +++ b/backend/internal/api/hydration.go @@ -470,7 +470,7 @@ func fetchProfiles(dids []string) (map[string]Author, error) { DID: p.DID, Handle: p.Handle, DisplayName: p.DisplayName, - Avatar: p.Avatar, + Avatar: getProxiedAvatarURL(p.DID, p.Avatar), } } diff --git a/backend/internal/api/token_refresh.go b/backend/internal/api/token_refresh.go index 2db0bfc..2d6f67e 100644 --- a/backend/internal/api/token_refresh.go +++ b/backend/internal/api/token_refresh.go @@ -196,3 +196,7 @@ func (tr *TokenRefresher) ExecuteWithAutoRefresh( client = xrpc.NewClient(newSession.PDS, newSession.AccessToken, newSession.DPoPKey) return fn(client, newSession.DID) } + +func (tr *TokenRefresher) CreateClientFromSession(session *SessionData) *xrpc.Client { + return xrpc.NewClient(session.PDS, session.AccessToken, session.DPoPKey) +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index cc22c64..8c9fe11 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -120,6 +120,15 @@ type Notification struct { ReadAt *time.Time `json:"readAt,omitempty"` } +type APIKey struct { + ID string `json:"id"` + OwnerDID string `json:"ownerDid"` + Name string `json:"name"` + KeyHash string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` +} + func New(dsn string) (*DB, error) { driver := "sqlite3" if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { @@ -296,6 +305,17 @@ func (db *DB) Migrate() error { db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_did)`) db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC)`) + db.Exec(`CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + owner_did TEXT NOT NULL, + name TEXT NOT NULL, + key_hash TEXT NOT NULL, + created_at ` + dateType + ` NOT NULL, + last_used_at ` + dateType + ` + )`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_did)`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)`) + db.runMigrations() db.Exec(`CREATE TABLE IF NOT EXISTS cursors ( diff --git a/backend/internal/db/queries.go b/backend/internal/db/queries.go index ce79389..08e4dfd 100644 --- a/backend/internal/db/queries.go +++ b/backend/internal/db/queries.go @@ -910,3 +910,57 @@ func (db *DB) GetAuthorByURI(uri string) (string, error) { return "", fmt.Errorf("uri not found or no author") } + +func (db *DB) CreateAPIKey(key *APIKey) error { + _, err := db.Exec(db.Rebind(` + INSERT INTO api_keys (id, owner_did, name, key_hash, created_at) + VALUES (?, ?, ?, ?, ?) + `), key.ID, key.OwnerDID, key.Name, key.KeyHash, key.CreatedAt) + return err +} + +func (db *DB) GetAPIKeysByOwner(ownerDID string) ([]APIKey, error) { + rows, err := db.Query(db.Rebind(` + SELECT id, owner_did, name, key_hash, created_at, last_used_at + FROM api_keys + WHERE owner_did = ? + ORDER BY created_at DESC + `), ownerDID) + if err != nil { + return nil, err + } + defer rows.Close() + + var keys []APIKey + for rows.Next() { + var k APIKey + if err := rows.Scan(&k.ID, &k.OwnerDID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsedAt); err != nil { + return nil, err + } + keys = append(keys, k) + } + return keys, nil +} + +func (db *DB) GetAPIKeyByHash(keyHash string) (*APIKey, error) { + var k APIKey + err := db.QueryRow(db.Rebind(` + SELECT id, owner_did, name, key_hash, created_at, last_used_at + FROM api_keys + WHERE key_hash = ? + `), keyHash).Scan(&k.ID, &k.OwnerDID, &k.Name, &k.KeyHash, &k.CreatedAt, &k.LastUsedAt) + if err != nil { + return nil, err + } + return &k, nil +} + +func (db *DB) DeleteAPIKey(id, ownerDID string) error { + _, err := db.Exec(db.Rebind(`DELETE FROM api_keys WHERE id = ? AND owner_did = ?`), id, ownerDID) + return err +} + +func (db *DB) UpdateAPIKeyLastUsed(id string) error { + _, err := db.Exec(db.Rebind(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`), time.Now(), id) + return err +} diff --git a/backend/internal/firehose/ingester.go b/backend/internal/firehose/ingester.go index 21fd138..2af0844 100644 --- a/backend/internal/firehose/ingester.go +++ b/backend/internal/firehose/ingester.go @@ -3,14 +3,17 @@ package firehose import ( "bytes" "context" + "encoding/binary" "encoding/json" "fmt" "io" "log" - "net/http" - "strings" "time" + "github.com/fxamacker/cbor/v2" + "github.com/gorilla/websocket" + "github.com/ipfs/go-cid" + "margin.at/internal/db" ) @@ -56,15 +59,36 @@ func (i *Ingester) run(ctx context.Context) { return default: if err := i.subscribe(ctx); err != nil { + log.Printf("Firehose error: %v, reconnecting in 5s...", err) if ctx.Err() != nil { return } - time.Sleep(30 * time.Second) + time.Sleep(5 * time.Second) } } } } +type FrameHeader struct { + Op int `cbor:"op"` + T string `cbor:"t"` +} +type Commit struct { + Repo string `cbor:"repo"` + Rev string `cbor:"rev"` + Seq int64 `cbor:"seq"` + Prev *cid.Cid `cbor:"prev"` + Time string `cbor:"time"` + Blocks []byte `cbor:"blocks"` + Ops []RepoOp `cbor:"ops"` +} + +type RepoOp struct { + Action string `cbor:"action"` + Path string `cbor:"path"` + Cid *cid.Cid `cbor:"cid"` +} + func (i *Ingester) subscribe(ctx context.Context) error { cursor := i.getLastCursor() @@ -73,23 +97,16 @@ func (i *Ingester) subscribe(ctx context.Context) error { url = fmt.Sprintf("%s?cursor=%d", RelayURL, cursor) } - req, err := http.NewRequestWithContext(ctx, "GET", strings.Replace(url, "wss://", "https://", 1), nil) - if err != nil { - return err - } + log.Printf("Connecting to firehose: %s", url) - resp, err := http.DefaultClient.Do(req) + conn, _, err := websocket.DefaultDialer.DialContext(ctx, url, nil) if err != nil { - return err + return fmt.Errorf("websocket dial failed: %w", err) } - defer resp.Body.Close() + defer conn.Close() - if resp.StatusCode != 200 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("firehose returned %d: %s", resp.StatusCode, string(body)) - } + log.Printf("Connected to firehose") - decoder := json.NewDecoder(resp.Body) for { select { case <-ctx.Done(): @@ -97,91 +114,226 @@ func (i *Ingester) subscribe(ctx context.Context) error { default: } - var event FirehoseEvent - if err := decoder.Decode(&event); err != nil { - if err == io.EOF { - return nil - } - return err + _, message, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("websocket read failed: %w", err) } - i.handleEvent(&event) + i.handleMessage(message) } } -type FirehoseEvent struct { - Repo string `json:"repo"` - Collection string `json:"collection"` - Rkey string `json:"rkey"` - Record json.RawMessage `json:"record"` - Operation string `json:"operation"` - Cursor int64 `json:"cursor"` -} +func (i *Ingester) handleMessage(data []byte) { + reader := bytes.NewReader(data) -func (i *Ingester) handleEvent(event *FirehoseEvent) { - uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) + var header FrameHeader + decoder := cbor.NewDecoder(reader) + if err := decoder.Decode(&header); err != nil { + return + } - switch event.Collection { - case CollectionAnnotation: - switch event.Operation { - case "create", "update": - i.handleAnnotation(event) - case "delete": - i.db.DeleteAnnotation(uri) + if header.Op != 1 { + return + } + + if header.T != "#commit" { + return + } + + var commit Commit + if err := decoder.Decode(&commit); err != nil { + return + } + + for _, op := range commit.Ops { + collection, rkey := parseOpPath(op.Path) + if !isMarginCollection(collection) { + continue } - case CollectionHighlight: - switch event.Operation { + + uri := fmt.Sprintf("at://%s/%s/%s", commit.Repo, collection, rkey) + + switch op.Action { case "create", "update": - i.handleHighlight(event) + if op.Cid != nil && len(commit.Blocks) > 0 { + record := extractRecord(commit.Blocks, *op.Cid) + if record != nil { + i.handleRecord(commit.Repo, collection, rkey, record, commit.Seq) + } + } case "delete": - i.db.DeleteHighlight(uri) + i.handleDelete(collection, uri) } - case CollectionBookmark: - switch event.Operation { - case "create", "update": - i.handleBookmark(event) - case "delete": - i.db.DeleteBookmark(uri) + } + + if commit.Seq > 0 { + if err := i.db.SetCursor("firehose_cursor", commit.Seq); err != nil { + log.Printf("Failed to save cursor: %v", err) } - case CollectionReply: - switch event.Operation { - case "create", "update": - i.handleReply(event) - case "delete": - i.db.DeleteReply(uri) + } +} + +func parseOpPath(path string) (collection, rkey string) { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '/' { + return path[:i], path[i+1:] } - case CollectionLike: - switch event.Operation { - case "create": - i.handleLike(event) - case "delete": - i.db.DeleteLike(uri) + } + return path, "" +} + +func isMarginCollection(collection string) bool { + switch collection { + case CollectionAnnotation, CollectionHighlight, CollectionBookmark, + CollectionReply, CollectionLike, CollectionCollection, CollectionCollectionItem: + return true + } + return false +} + +func extractRecord(blocks []byte, targetCid cid.Cid) map[string]interface{} { + reader := bytes.NewReader(blocks) + + headerLen, err := binary.ReadUvarint(reader) + if err != nil { + return nil + } + reader.Seek(int64(headerLen), io.SeekCurrent) + + for reader.Len() > 0 { + blockLen, err := binary.ReadUvarint(reader) + if err != nil { + break } - case CollectionCollection: - switch event.Operation { - case "create", "update": - i.handleCollection(event) - case "delete": - i.db.DeleteCollection(uri) + + blockData := make([]byte, blockLen) + if _, err := io.ReadFull(reader, blockData); err != nil { + break } - case CollectionCollectionItem: - switch event.Operation { - case "create", "update": - i.handleCollectionItem(event) - case "delete": - i.db.RemoveFromCollection(uri) + + blockCid, cidLen, err := parseCidFromBlock(blockData) + if err != nil { + continue + } + + if blockCid.Equals(targetCid) { + var record map[string]interface{} + if err := cbor.Unmarshal(blockData[cidLen:], &record); err != nil { + return nil + } + return record } } - if event.Cursor > 0 { - if err := i.db.SetCursor("firehose_cursor", event.Cursor); err != nil { - log.Printf("Failed to save cursor: %v", err) + return nil +} + +func parseCidFromBlock(data []byte) (cid.Cid, int, error) { + if len(data) < 2 { + return cid.Cid{}, 0, fmt.Errorf("data too short") + } + version, n1 := binary.Uvarint(data) + if n1 <= 0 { + return cid.Cid{}, 0, fmt.Errorf("invalid version varint") + } + + if version == 1 { + codec, n2 := binary.Uvarint(data[n1:]) + if n2 <= 0 { + return cid.Cid{}, 0, fmt.Errorf("invalid codec varint") + } + + mhStart := n1 + n2 + hashType, n3 := binary.Uvarint(data[mhStart:]) + if n3 <= 0 { + return cid.Cid{}, 0, fmt.Errorf("invalid hash type varint") + } + + hashLen, n4 := binary.Uvarint(data[mhStart+n3:]) + if n4 <= 0 { + return cid.Cid{}, 0, fmt.Errorf("invalid hash length varint") + } + + totalCidLen := mhStart + n3 + n4 + int(hashLen) + + c, err := cid.Cast(data[:totalCidLen]) + if err != nil { + return cid.Cid{}, 0, err } + + _ = codec + _ = hashType + + return c, totalCidLen, nil } + + return cid.Cid{}, 0, fmt.Errorf("unsupported CID version") } -func (i *Ingester) handleAnnotation(event *FirehoseEvent) { +func (i *Ingester) handleDelete(collection, uri string) { + switch collection { + case CollectionAnnotation: + i.db.DeleteAnnotation(uri) + case CollectionHighlight: + i.db.DeleteHighlight(uri) + case CollectionBookmark: + i.db.DeleteBookmark(uri) + case CollectionReply: + i.db.DeleteReply(uri) + case CollectionLike: + i.db.DeleteLike(uri) + case CollectionCollection: + i.db.DeleteCollection(uri) + case CollectionCollectionItem: + i.db.RemoveFromCollection(uri) + } +} + +func (i *Ingester) handleRecord(repo, collection, rkey string, record map[string]interface{}, seq int64) { + _ = fmt.Sprintf("at://%s/%s/%s", repo, collection, rkey) + + recordJSON, err := json.Marshal(record) + if err != nil { + return + } + + event := &FirehoseEvent{ + Repo: repo, + Collection: collection, + Rkey: rkey, + Record: recordJSON, + Operation: "create", + Cursor: seq, + } + + switch collection { + case CollectionAnnotation: + i.handleAnnotation(event) + case CollectionHighlight: + i.handleHighlight(event) + case CollectionBookmark: + i.handleBookmark(event) + case CollectionReply: + i.handleReply(event) + case CollectionLike: + i.handleLike(event) + case CollectionCollection: + i.handleCollection(event) + case CollectionCollectionItem: + i.handleCollectionItem(event) + } +} + +type FirehoseEvent struct { + Repo string `json:"repo"` + Collection string `json:"collection"` + Rkey string `json:"rkey"` + Record json.RawMessage `json:"record"` + Operation string `json:"operation"` + Cursor int64 `json:"cursor"` +} +func (i *Ingester) handleAnnotation(event *FirehoseEvent) { var record struct { Motivation string `json:"motivation"` Body struct { @@ -205,7 +357,7 @@ func (i *Ingester) handleAnnotation(event *FirehoseEvent) { Title string `json:"title"` } - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { + if err := json.Unmarshal(event.Record, &record); err != nil { return } @@ -302,7 +454,7 @@ func (i *Ingester) handleReply(event *FirehoseEvent) { CreatedAt string `json:"createdAt"` } - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { + if err := json.Unmarshal(event.Record, &record); err != nil { return } @@ -334,7 +486,7 @@ func (i *Ingester) handleLike(event *FirehoseEvent) { CreatedAt string `json:"createdAt"` } - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { + if err := json.Unmarshal(event.Record, &record); err != nil { return } @@ -369,7 +521,7 @@ func (i *Ingester) handleHighlight(event *FirehoseEvent) { CreatedAt string `json:"createdAt"` } - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { + if err := json.Unmarshal(event.Record, &record); err != nil { return } @@ -432,7 +584,7 @@ func (i *Ingester) handleBookmark(event *FirehoseEvent) { CreatedAt string `json:"createdAt"` } - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { + if err := json.Unmarshal(event.Record, &record); err != nil { return } @@ -488,7 +640,7 @@ func (i *Ingester) handleCollection(event *FirehoseEvent) { CreatedAt string `json:"createdAt"` } - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { + if err := json.Unmarshal(event.Record, &record); err != nil { return } @@ -532,7 +684,7 @@ func (i *Ingester) handleCollectionItem(event *FirehoseEvent) { CreatedAt string `json:"createdAt"` } - if err := json.NewDecoder(bytes.NewReader(event.Record)).Decode(&record); err != nil { + if err := json.Unmarshal(event.Record, &record); err != nil { return } diff --git a/docker-compose.yml b/docker-compose.yml index cd94eec..32d9667 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - OAUTH_KEY_PATH=/data/oauth_private_key.pem env_file: - .env + volumes: + - margin-data:/data depends_on: db: condition: service_healthy @@ -23,7 +25,6 @@ services: volumes: - db-data:/var/lib/postgresql/data - - margin-data:/data healthcheck: test: ["CMD-SHELL", "pg_isready -U margin"] interval: 5s diff --git a/extension/content/content.js b/extension/content/content.js index b235f25..2848951 100644 --- a/extension/content/content.js +++ b/extension/content/content.js @@ -3,7 +3,6 @@ let sidebarShadow = null; let popoverEl = null; - let activeItems = []; let currentSelection = null; @@ -640,7 +639,7 @@ const firstRect = firstRange.getClientRects()[0]; const totalWidth = Math.min(uniqueAuthors.length, maxShow + (overflow > 0 ? 1 : 0)) * - 18 + + 18 + 8; const leftPos = firstRect.left - totalWidth; const topPos = firstRect.top + firstRect.height / 2 - 12; @@ -1026,4 +1025,47 @@ setTimeout(() => fetchAnnotations(), 500); } }); + + let lastUrl = window.location.href; + + function checkUrlChange() { + if (window.location.href !== lastUrl) { + lastUrl = window.location.href; + onUrlChange(); + } + } + + function onUrlChange() { + if (typeof CSS !== "undefined" && CSS.highlights) { + CSS.highlights.clear(); + } + activeItems = []; + + if (typeof chrome !== "undefined" && chrome.storage) { + chrome.storage.local.get(["showOverlay"], (result) => { + if (result.showOverlay !== false) { + fetchAnnotations(); + } + }); + } else { + fetchAnnotations(); + } + } + + window.addEventListener("popstate", onUrlChange); + + const originalPushState = history.pushState; + const originalReplaceState = history.replaceState; + + history.pushState = function (...args) { + originalPushState.apply(this, args); + checkUrlChange(); + }; + + history.replaceState = function (...args) { + originalReplaceState.apply(this, args); + checkUrlChange(); + }; + + setInterval(checkUrlChange, 1000); })(); diff --git a/web/src/App.jsx b/web/src/App.jsx index 5b6ad06..ce63fe0 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -15,9 +15,7 @@ import AnnotationDetail from "./pages/AnnotationDetail"; import Collections from "./pages/Collections"; import CollectionDetail from "./pages/CollectionDetail"; import Privacy from "./pages/Privacy"; - import Terms from "./pages/Terms"; - import ScrollToTop from "./components/ScrollToTop"; function AppContent() { diff --git a/web/src/api/client.js b/web/src/api/client.js index e97269d..8e6fc95 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -430,3 +430,18 @@ export async function startLogin(handle, inviteCode) { export async function getTrendingTags(limit = 10) { return request(`${API_BASE}/tags/trending?limit=${limit}`); } + +export async function getAPIKeys() { + return request(`${API_BASE}/keys`); +} + +export async function createAPIKey(name) { + return request(`${API_BASE}/keys`, { + method: "POST", + body: JSON.stringify({ name }), + }); +} + +export async function deleteAPIKey(id) { + return request(`${API_BASE}/keys/${id}`, { method: "DELETE" }); +} diff --git a/web/src/components/RightSidebar.jsx b/web/src/components/RightSidebar.jsx index 32d200f..03c53da 100644 --- a/web/src/components/RightSidebar.jsx +++ b/web/src/components/RightSidebar.jsx @@ -1,7 +1,13 @@ import { useState, useEffect } from "react"; import { Link } from "react-router-dom"; import { ExternalLink } from "lucide-react"; -import { SiFirefox, SiGooglechrome, SiGithub, SiBluesky } from "react-icons/si"; +import { + SiFirefox, + SiGooglechrome, + SiGithub, + SiBluesky, + SiApple, +} from "react-icons/si"; import { FaEdge } from "react-icons/fa"; import { useAuth } from "../context/AuthContext"; import { getTrendingTags } from "../api/client"; @@ -10,13 +16,27 @@ const isFirefox = typeof navigator !== "undefined" && /Firefox/i.test(navigator.userAgent); const isEdge = typeof navigator !== "undefined" && /Edg/i.test(navigator.userAgent); +const isMobileSafari = + typeof navigator !== "undefined" && + /iPhone|iPad|iPod/.test(navigator.userAgent) && + /Safari/.test(navigator.userAgent) && + !/CriOS|FxiOS|OPiOS|EdgiOS/.test(navigator.userAgent); function getExtensionInfo() { + if (isMobileSafari) { + return { + url: "https://margin.at/soon", + icon: SiApple, + name: "iOS", + label: "Get the Shortcut", + }; + } if (isFirefox) { return { url: "https://addons.mozilla.org/en-US/firefox/addon/margin/", icon: SiFirefox, name: "Firefox", + label: "Install for Firefox", }; } if (isEdge) { @@ -24,12 +44,14 @@ function getExtensionInfo() { url: "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn", icon: FaEdge, name: "Edge", + label: "Install for Edge", }; } return { url: "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa/", icon: SiGooglechrome, name: "Chrome", + label: "Install for Chrome", }; } @@ -50,9 +72,13 @@ export default function RightSidebar() { return (