Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}