Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 518 lines 13 kB view raw
1package api 2 3import ( 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 21type APIKeyHandler struct { 22 db *db.DB 23 refresher *TokenRefresher 24} 25 26func NewAPIKeyHandler(database *db.DB, refresher *TokenRefresher) *APIKeyHandler { 27 return &APIKeyHandler{db: database, refresher: refresher} 28} 29 30type CreateKeyRequest struct { 31 Name string `json:"name"` 32} 33 34type 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 41func (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 84func (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 105func (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 127type QuickBookmarkRequest struct { 128 URL string `json:"url"` 129 Title string `json:"title,omitempty"` 130 Description string `json:"description,omitempty"` 131} 132 133func (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 if err := record.Validate(); err != nil { 161 http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 162 return 163 } 164 165 var result *xrpc.CreateRecordOutput 166 err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 167 var createErr error 168 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionBookmark, record) 169 return createErr 170 }) 171 if err != nil { 172 http.Error(w, "Failed to create bookmark: "+err.Error(), http.StatusInternalServerError) 173 return 174 } 175 176 h.db.UpdateAPIKeyLastUsed(apiKey.ID) 177 178 var titlePtr, descPtr *string 179 if req.Title != "" { 180 titlePtr = &req.Title 181 } 182 if req.Description != "" { 183 descPtr = &req.Description 184 } 185 186 cid := result.CID 187 bookmark := &db.Bookmark{ 188 URI: result.URI, 189 AuthorDID: apiKey.OwnerDID, 190 Source: req.URL, 191 SourceHash: urlHash, 192 Title: titlePtr, 193 Description: descPtr, 194 CreatedAt: time.Now(), 195 IndexedAt: time.Now(), 196 CID: &cid, 197 } 198 h.db.CreateBookmark(bookmark) 199 200 w.Header().Set("Content-Type", "application/json") 201 json.NewEncoder(w).Encode(map[string]string{ 202 "uri": result.URI, 203 "cid": result.CID, 204 "message": "Bookmark created successfully", 205 }) 206} 207 208type QuickSaveRequest struct { 209 URL string `json:"url"` 210 Text string `json:"text,omitempty"` 211 Selector json.RawMessage `json:"selector,omitempty"` 212 Color string `json:"color,omitempty"` 213} 214 215func (h *APIKeyHandler) QuickSave(w http.ResponseWriter, r *http.Request) { 216 apiKey, err := h.authenticateAPIKey(r) 217 if err != nil { 218 http.Error(w, err.Error(), http.StatusUnauthorized) 219 return 220 } 221 222 var req QuickSaveRequest 223 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 224 http.Error(w, "Invalid request body", http.StatusBadRequest) 225 return 226 } 227 228 if req.URL == "" { 229 http.Error(w, "URL is required", http.StatusBadRequest) 230 return 231 } 232 233 session, err := h.getSessionByDID(apiKey.OwnerDID) 234 if err != nil { 235 http.Error(w, "User session not found. Please log in to margin.at first.", http.StatusUnauthorized) 236 return 237 } 238 239 urlHash := db.HashURL(req.URL) 240 241 var isHighlight bool 242 if req.Selector != nil && req.Text == "" { 243 isHighlight = true 244 } 245 246 var result *xrpc.CreateRecordOutput 247 var createErr error 248 249 if isHighlight { 250 color := req.Color 251 if color == "" { 252 color = "yellow" 253 } 254 record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, color, nil) 255 256 if err := record.Validate(); err != nil { 257 http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 258 return 259 } 260 261 err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 262 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record) 263 return createErr 264 }) 265 if err == nil { 266 h.db.UpdateAPIKeyLastUsed(apiKey.ID) 267 selectorJSON, _ := json.Marshal(req.Selector) 268 selectorStr := string(selectorJSON) 269 colorPtr := &color 270 271 highlight := &db.Highlight{ 272 URI: result.URI, 273 AuthorDID: apiKey.OwnerDID, 274 TargetSource: req.URL, 275 TargetHash: urlHash, 276 SelectorJSON: &selectorStr, 277 Color: colorPtr, 278 CreatedAt: time.Now(), 279 IndexedAt: time.Now(), 280 CID: &result.CID, 281 } 282 go func() { 283 if err := h.db.CreateHighlight(highlight); err != nil { 284 fmt.Printf("Warning: failed to index highlight in local DB: %v\n", err) 285 } 286 }() 287 } 288 289 } else { 290 record := xrpc.NewAnnotationRecord(req.URL, urlHash, req.Text, req.Selector, "") 291 292 if err := record.Validate(); err != nil { 293 http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 294 return 295 } 296 297 err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 298 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionAnnotation, record) 299 return createErr 300 }) 301 if err == nil { 302 h.db.UpdateAPIKeyLastUsed(apiKey.ID) 303 304 var selectorStrPtr *string 305 if req.Selector != nil { 306 b, _ := json.Marshal(req.Selector) 307 s := string(b) 308 selectorStrPtr = &s 309 } 310 311 bodyValue := req.Text 312 var bodyValuePtr *string 313 if bodyValue != "" { 314 bodyValuePtr = &bodyValue 315 } 316 317 annotation := &db.Annotation{ 318 URI: result.URI, 319 AuthorDID: apiKey.OwnerDID, 320 Motivation: "commenting", 321 BodyValue: bodyValuePtr, 322 TargetSource: req.URL, 323 TargetHash: urlHash, 324 SelectorJSON: selectorStrPtr, 325 CreatedAt: time.Now(), 326 IndexedAt: time.Now(), 327 CID: &result.CID, 328 } 329 go func() { 330 h.db.CreateAnnotation(annotation) 331 }() 332 } 333 } 334 335 if err != nil { 336 http.Error(w, "Failed to create record: "+err.Error(), http.StatusInternalServerError) 337 return 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": "Saved successfully", 345 }) 346} 347 348type QuickHighlightRequest struct { 349 URL string `json:"url"` 350 Selector interface{} `json:"selector"` 351 Color string `json:"color,omitempty"` 352} 353 354func (h *APIKeyHandler) QuickHighlight(w http.ResponseWriter, r *http.Request) { 355 apiKey, err := h.authenticateAPIKey(r) 356 if err != nil { 357 http.Error(w, err.Error(), http.StatusUnauthorized) 358 return 359 } 360 361 var req QuickHighlightRequest 362 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 363 http.Error(w, "Invalid request body", http.StatusBadRequest) 364 return 365 } 366 367 if req.URL == "" || req.Selector == nil { 368 http.Error(w, "URL and selector are required", http.StatusBadRequest) 369 return 370 } 371 372 session, err := h.getSessionByDID(apiKey.OwnerDID) 373 if err != nil { 374 http.Error(w, "User session not found. Please log in to margin.at first.", http.StatusUnauthorized) 375 return 376 } 377 378 urlHash := db.HashURL(req.URL) 379 color := req.Color 380 if color == "" { 381 color = "yellow" 382 } 383 384 record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, color, nil) 385 386 if err := record.Validate(); err != nil { 387 http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest) 388 return 389 } 390 391 var result *xrpc.CreateRecordOutput 392 err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 393 var createErr error 394 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record) 395 return createErr 396 }) 397 if err != nil { 398 http.Error(w, "Failed to create highlight: "+err.Error(), http.StatusInternalServerError) 399 return 400 } 401 402 h.db.UpdateAPIKeyLastUsed(apiKey.ID) 403 404 selectorJSON, _ := json.Marshal(req.Selector) 405 selectorStr := string(selectorJSON) 406 colorPtr := &color 407 408 highlight := &db.Highlight{ 409 URI: result.URI, 410 AuthorDID: apiKey.OwnerDID, 411 TargetSource: req.URL, 412 TargetHash: urlHash, 413 SelectorJSON: &selectorStr, 414 Color: colorPtr, 415 CreatedAt: time.Now(), 416 IndexedAt: time.Now(), 417 CID: &result.CID, 418 } 419 if err := h.db.CreateHighlight(highlight); err != nil { 420 fmt.Printf("Warning: failed to index highlight in local DB: %v\n", err) 421 } 422 423 w.Header().Set("Content-Type", "application/json") 424 json.NewEncoder(w).Encode(map[string]string{ 425 "uri": result.URI, 426 "cid": result.CID, 427 "message": "Highlight created successfully", 428 }) 429} 430 431func (h *APIKeyHandler) authenticateAPIKey(r *http.Request) (*db.APIKey, error) { 432 auth := r.Header.Get("Authorization") 433 if auth == "" { 434 return nil, fmt.Errorf("missing Authorization header") 435 } 436 437 if !strings.HasPrefix(auth, "Bearer ") { 438 return nil, fmt.Errorf("invalid Authorization format, expected 'Bearer <key>'") 439 } 440 441 rawKey := strings.TrimPrefix(auth, "Bearer ") 442 keyHash := hashAPIKey(rawKey) 443 444 apiKey, err := h.db.GetAPIKeyByHash(keyHash) 445 if err != nil { 446 return nil, fmt.Errorf("invalid API key") 447 } 448 449 return apiKey, nil 450} 451 452func (h *APIKeyHandler) getSessionByDID(did string) (*SessionData, error) { 453 rows, err := h.db.Query(h.db.Rebind(` 454 SELECT id, did, handle, access_token, refresh_token, COALESCE(dpop_key, '') 455 FROM sessions 456 WHERE did = ? AND expires_at > ? 457 ORDER BY created_at DESC 458 LIMIT 1 459 `), did, time.Now()) 460 if err != nil { 461 return nil, err 462 } 463 defer rows.Close() 464 465 if !rows.Next() { 466 return nil, fmt.Errorf("no active session") 467 } 468 469 var sessionID, sessDID, handle, accessToken, refreshToken, dpopKeyStr string 470 if err := rows.Scan(&sessionID, &sessDID, &handle, &accessToken, &refreshToken, &dpopKeyStr); err != nil { 471 return nil, err 472 } 473 474 block, _ := pem.Decode([]byte(dpopKeyStr)) 475 if block == nil { 476 return nil, fmt.Errorf("invalid session DPoP key") 477 } 478 dpopKey, err := x509.ParseECPrivateKey(block.Bytes) 479 if err != nil { 480 return nil, fmt.Errorf("invalid session DPoP key: %w", err) 481 } 482 483 pds, err := xrpc.ResolveDIDToPDS(sessDID) 484 if err != nil { 485 return nil, fmt.Errorf("failed to resolve PDS: %w", err) 486 } 487 if pds == "" { 488 return nil, fmt.Errorf("PDS not found for DID: %s", sessDID) 489 } 490 491 return &SessionData{ 492 ID: sessionID, 493 DID: sessDID, 494 Handle: handle, 495 AccessToken: accessToken, 496 RefreshToken: refreshToken, 497 DPoPKey: dpopKey, 498 PDS: pds, 499 }, nil 500} 501 502func generateAPIKey() string { 503 b := make([]byte, 32) 504 rand.Read(b) 505 return "mk_" + hex.EncodeToString(b) 506} 507 508func generateKeyID() string { 509 b := make([]byte, 16) 510 rand.Read(b) 511 return hex.EncodeToString(b) 512} 513 514func hashAPIKey(key string) string { 515 h := sha256.New() 516 h.Write([]byte(key)) 517 return hex.EncodeToString(h.Sum(nil)) 518}