cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
1// Package services provides AT Protocol integration for leaflet.pub
2//
3// Document Flow:
4// - Pull: Fetch pub.leaflet.document records from AT Protocol repository
5// - Post: Create new pub.leaflet.document records in AT Protocol repository
6// - Push: Update existing pub.leaflet.document records in AT Protocol repository
7// - Delete: Remove pub.leaflet.document records from AT Protocol repository
8package services
9
10import (
11 "bytes"
12 "context"
13 "encoding/json"
14 "fmt"
15 "strings"
16 "time"
17
18 "github.com/bluesky-social/indigo/api/atproto"
19 lexutil "github.com/bluesky-social/indigo/lex/util"
20 "github.com/bluesky-social/indigo/repo"
21 "github.com/bluesky-social/indigo/xrpc"
22 "github.com/fxamacker/cbor/v2"
23 "github.com/ipfs/go-cid"
24 "github.com/stormlightlabs/noteleaf/internal/public"
25)
26
27type MutateRecordOutput struct {
28 Cid string `json:"cid"`
29 Uri string `json:"uri"`
30}
31
32// DocumentWithMeta combines a document with its repository metadata
33type DocumentWithMeta struct {
34 Document public.Document
35 Meta public.DocumentMeta
36}
37
38// PublicationWithMeta combines a publication with its metadata
39type PublicationWithMeta struct {
40 Publication public.Publication
41 RKey string
42 CID string
43 URI string
44}
45
46// Session holds authentication session information
47type Session struct {
48 DID string // Decentralized Identifier
49 Handle string // User handle (e.g., username.bsky.social)
50 AccessJWT string // Access token
51 RefreshJWT string // Refresh token
52 PDSURL string // Personal Data Server URL
53 ExpiresAt time.Time // When access token expires
54 Authenticated bool // Whether session is valid
55}
56
57// ATProtoClient defines the interface for AT Protocol operations
58type ATProtoClient interface {
59 Authenticate(ctx context.Context, handle, password string) error
60 GetSession() (*Session, error)
61 IsAuthenticated() bool
62 RestoreSession(session *Session) error
63 PullDocuments(ctx context.Context) ([]DocumentWithMeta, error)
64 PostDocument(ctx context.Context, doc public.Document, isDraft bool) (*DocumentWithMeta, error)
65 PatchDocument(ctx context.Context, rkey string, doc public.Document, isDraft bool) (*DocumentWithMeta, error)
66 DeleteDocument(ctx context.Context, rkey string, isDraft bool) error
67 UploadBlob(ctx context.Context, data []byte, mimeType string) (public.Blob, error)
68 GetDefaultPublication(ctx context.Context) (string, error)
69 Close() error
70}
71
72// ATProtoService provides AT Protocol operations for leaflet integration
73type ATProtoService struct {
74 handle string
75 password string
76 session *Session
77 pdsURL string // Personal Data Server URL
78 client *xrpc.Client
79
80 // TODO: Future enhancement - integrate OS keychain for secure password storage
81 // Consider using keyring libraries like:
82 // - github.com/zalando/go-keyring (cross-platform)
83 // - keychain access on macOS (Security.framework)
84 // - Windows Credential Manager (credman)
85 // - Linux Secret Service API (libsecret)
86 // This would allow storing app passwords securely in the system keychain
87 // instead of requiring re-authentication every time JWTs expire.
88}
89
90// NewATProtoService creates a new AT Protocol service
91func NewATProtoService() *ATProtoService {
92 pdsURL := "https://bsky.social"
93 return &ATProtoService{
94 pdsURL: pdsURL,
95 client: &xrpc.Client{
96 Host: pdsURL,
97 },
98 }
99}
100
101// Authenticate logs in with BlueSky/AT Protocol credentials
102func (s *ATProtoService) Authenticate(ctx context.Context, handle, password string) error {
103 if handle == "" || password == "" {
104 return fmt.Errorf("handle and password are required")
105 }
106
107 s.handle = handle
108 s.password = password
109
110 input := &atproto.ServerCreateSession_Input{
111 Identifier: handle,
112 Password: password,
113 }
114
115 output, err := atproto.ServerCreateSession(ctx, s.client, input)
116 if err != nil {
117 return fmt.Errorf("failed to create session: %w", err)
118 }
119
120 expiresAt := time.Now().Add(2 * time.Hour)
121
122 s.session = &Session{
123 DID: output.Did,
124 Handle: output.Handle,
125 AccessJWT: output.AccessJwt,
126 RefreshJWT: output.RefreshJwt,
127 PDSURL: s.pdsURL,
128 ExpiresAt: expiresAt,
129 Authenticated: true,
130 }
131
132 s.client.Auth = &xrpc.AuthInfo{
133 AccessJwt: output.AccessJwt,
134 RefreshJwt: output.RefreshJwt,
135 Handle: output.Handle,
136 Did: output.Did,
137 }
138
139 return nil
140}
141
142// GetSession returns the current session information
143func (s *ATProtoService) GetSession() (*Session, error) {
144 if s.session == nil || !s.session.Authenticated {
145 return nil, fmt.Errorf("not authenticated - run 'noteleaf pub auth' first")
146 }
147 return s.session, nil
148}
149
150// IsAuthenticated checks if the service has a valid session
151func (s *ATProtoService) IsAuthenticated() bool {
152 return s.session != nil && s.session.Authenticated
153}
154
155// RestoreSession restores a previously authenticated session from stored credentials
156// and automatically refreshes the token if expired
157func (s *ATProtoService) RestoreSession(session *Session) error {
158 if session == nil {
159 return fmt.Errorf("session cannot be nil")
160 }
161
162 if session.DID == "" || session.AccessJWT == "" || session.RefreshJWT == "" {
163 return fmt.Errorf("session missing required fields (DID, AccessJWT, RefreshJWT)")
164 }
165
166 s.session = session
167
168 s.client.Auth = &xrpc.AuthInfo{
169 AccessJwt: session.AccessJWT,
170 RefreshJwt: session.RefreshJWT,
171 Handle: session.Handle,
172 Did: session.DID,
173 }
174
175 if session.PDSURL != "" {
176 s.pdsURL = session.PDSURL
177 s.client.Host = session.PDSURL
178 }
179
180 // Check if token is expired or about to expire (within 5 minutes)
181 if time.Now().Add(5 * time.Minute).After(session.ExpiresAt) {
182 ctx := context.Background()
183 if err := s.RefreshToken(ctx); err != nil {
184 // Token refresh failed - session may be invalid
185 // User will need to re-authenticate
186 return fmt.Errorf("session expired and refresh failed: %w", err)
187 }
188 }
189
190 return nil
191}
192
193// RefreshToken refreshes the access token using the refresh token
194// This extends the session without requiring the user to re-authenticate
195func (s *ATProtoService) RefreshToken(ctx context.Context) error {
196 if s.session == nil || s.session.RefreshJWT == "" {
197 return fmt.Errorf("no session available to refresh")
198 }
199
200 s.client.Auth = &xrpc.AuthInfo{
201 AccessJwt: s.session.AccessJWT,
202 RefreshJwt: s.session.RefreshJWT,
203 Handle: s.session.Handle,
204 Did: s.session.DID,
205 }
206
207 output, err := atproto.ServerRefreshSession(ctx, s.client)
208 if err != nil {
209 return fmt.Errorf("failed to refresh session: %w", err)
210 }
211
212 // TODO: Consider increasing token lifetime for better UX
213 // Current: 2 hours - requires frequent re-authentication
214 // Consider: Store in OS keychain to enable longer sessions without security risk
215 expiresAt := time.Now().Add(2 * time.Hour)
216 s.session.AccessJWT = output.AccessJwt
217 s.session.RefreshJWT = output.RefreshJwt
218 s.session.ExpiresAt = expiresAt
219 s.session.Authenticated = true
220
221 s.client.Auth.AccessJwt = output.AccessJwt
222 s.client.Auth.RefreshJwt = output.RefreshJwt
223
224 return nil
225}
226
227// PullDocuments fetches all leaflet documents from the user's repository
228func (s *ATProtoService) PullDocuments(ctx context.Context) ([]DocumentWithMeta, error) {
229 if !s.IsAuthenticated() {
230 return nil, fmt.Errorf("not authenticated")
231 }
232
233 carBytes, err := atproto.SyncGetRepo(ctx, s.client, s.session.DID, "")
234 if err != nil {
235 return nil, fmt.Errorf("failed to fetch repository: %w", err)
236 }
237
238 r, err := repo.ReadRepoFromCar(ctx, bytes.NewReader(carBytes))
239 if err != nil {
240 return nil, fmt.Errorf("failed to parse CAR file: %w", err)
241 }
242
243 var documents []DocumentWithMeta
244 prefix := public.TypeDocument
245
246 documentCount := 0
247 err = r.ForEach(ctx, prefix, func(k string, v cid.Cid) error {
248 documentCount++
249
250 _, recordBytes, err := r.GetRecordBytes(ctx, k)
251 if err != nil {
252 return fmt.Errorf("failed to get record bytes for %s: %w", k, err)
253 }
254
255 var cborData any
256 if err := cbor.Unmarshal(*recordBytes, &cborData); err != nil {
257 return fmt.Errorf("failed to decode CBOR for document %s: %w", k, err)
258 }
259
260 jsonCompatible := convertCBORToJSONCompatible(cborData)
261
262 jsonBytes, err := json.MarshalIndent(jsonCompatible, "", " ")
263 if err != nil {
264 return fmt.Errorf("failed to convert CBOR to JSON for document %s: %w", k, err)
265 }
266
267 parts := strings.Split(k, "/")
268 rkey := ""
269 if len(parts) > 0 {
270 rkey = parts[len(parts)-1]
271 }
272
273 var typeCheck public.TypeCheck
274
275 if err := json.Unmarshal(jsonBytes, &typeCheck); err != nil {
276 return fmt.Errorf("failed to check $type for %s: %w", k, err)
277 }
278
279 if typeCheck.Type != public.TypeDocument {
280 return nil
281 }
282
283 var doc public.Document
284 if err := json.Unmarshal(jsonBytes, &doc); err != nil {
285 return fmt.Errorf("failed to unmarshal JSON to Document for %s: %w", k, err)
286 }
287
288 uri := fmt.Sprintf("at://%s/%s", s.session.DID, k)
289
290 meta := public.DocumentMeta{
291 RKey: rkey,
292 CID: v.String(),
293 URI: uri,
294 IsDraft: false,
295 FetchedAt: time.Now(),
296 }
297
298 documents = append(documents, DocumentWithMeta{
299 Document: doc,
300 Meta: meta,
301 })
302
303 return nil
304 })
305
306 if err != nil {
307 return nil, fmt.Errorf("failed to iterate over documents: %w", err)
308 }
309
310 return documents, nil
311}
312
313// ListPublications fetches available publications for the authenticated user
314func (s *ATProtoService) ListPublications(ctx context.Context) ([]PublicationWithMeta, error) {
315 if !s.IsAuthenticated() {
316 return nil, fmt.Errorf("not authenticated")
317 }
318
319 carBytes, err := atproto.SyncGetRepo(ctx, s.client, s.session.DID, "")
320 if err != nil {
321 return nil, fmt.Errorf("failed to fetch repository: %w", err)
322 }
323
324 r, err := repo.ReadRepoFromCar(ctx, bytes.NewReader(carBytes))
325 if err != nil {
326 return nil, fmt.Errorf("failed to parse CAR file: %w", err)
327 }
328
329 var publications []PublicationWithMeta
330 prefix := public.TypePublication
331
332 err = r.ForEach(ctx, prefix, func(k string, v cid.Cid) error {
333 _, recordBytes, err := r.GetRecordBytes(ctx, k)
334 if err != nil {
335 return fmt.Errorf("failed to get record bytes for %s: %w", k, err)
336 }
337
338 var cborData any
339 if err := cbor.Unmarshal(*recordBytes, &cborData); err != nil {
340 return fmt.Errorf("failed to decode CBOR for document %s: %w", k, err)
341 }
342
343 jsonCompatible := convertCBORToJSONCompatible(cborData)
344
345 jsonBytes, err := json.MarshalIndent(jsonCompatible, "", " ")
346 if err != nil {
347 return fmt.Errorf("failed to convert CBOR to JSON for document %s: %w", k, err)
348 }
349
350 parts := strings.Split(k, "/")
351 rkey := ""
352 if len(parts) > 0 {
353 rkey = parts[len(parts)-1]
354 }
355
356 var pub public.Publication
357 if err := json.Unmarshal(jsonBytes, &pub); err != nil {
358 return fmt.Errorf("failed to unmarshal publication %s: %w", k, err)
359 }
360
361 uri := fmt.Sprintf("at://%s/%s", s.session.DID, k)
362 publications = append(publications, PublicationWithMeta{
363 Publication: pub,
364 RKey: rkey,
365 CID: v.String(),
366 URI: uri,
367 })
368 return nil
369 })
370
371 if err != nil {
372 return nil, fmt.Errorf("failed to iterate over publications: %w", err)
373 }
374 return publications, nil
375}
376
377// GetDefaultPublication returns the URI of the first available publication for the authenticated user
378//
379// Returns an error if no publications exist
380func (s *ATProtoService) GetDefaultPublication(ctx context.Context) (string, error) {
381 publications, err := s.ListPublications(ctx)
382 if err != nil {
383 return "", err
384 }
385
386 if len(publications) == 0 {
387 return "", fmt.Errorf("no publications found - create a publication on leaflet.pub first")
388 }
389
390 return publications[0].URI, nil
391}
392
393// PostDocument creates a new document in the user's repository
394func (s *ATProtoService) PostDocument(ctx context.Context, doc public.Document, isDraft bool) (*DocumentWithMeta, error) {
395 if !s.IsAuthenticated() {
396 return nil, fmt.Errorf("not authenticated")
397 }
398
399 if doc.Title == "" {
400 return nil, fmt.Errorf("document title is required")
401 }
402
403 collection := public.TypeDocument
404 if isDraft {
405 collection = public.TypeDocumentDraft
406 }
407
408 doc.Type = collection
409 jsonBytes, err := json.Marshal(doc)
410 if err != nil {
411 return nil, fmt.Errorf("marshal: %w", err)
412 }
413
414 var m map[string]any
415 if err := json.Unmarshal(jsonBytes, &m); err != nil {
416 return nil, fmt.Errorf("unmarshal: %w", err)
417 }
418 m["$type"] = collection
419
420 output, err := repoCreateRecord(ctx, s.client, s.session.DID, collection, m)
421 if err != nil {
422 return nil, fmt.Errorf("failed to create record: %w", err)
423 }
424
425 parts := strings.Split(output.Uri, "/")
426 rkey := parts[len(parts)-1]
427 meta := public.DocumentMeta{
428 RKey: rkey,
429 CID: output.Cid,
430 URI: output.Uri,
431 IsDraft: isDraft,
432 FetchedAt: time.Now(),
433 }
434 return &DocumentWithMeta{Document: doc, Meta: meta}, nil
435}
436
437// PatchDocument updates an existing document in the user's repository
438func (s *ATProtoService) PatchDocument(ctx context.Context, rkey string, doc public.Document, isDraft bool) (*DocumentWithMeta, error) {
439 if !s.IsAuthenticated() {
440 return nil, fmt.Errorf("not authenticated")
441 }
442
443 if rkey == "" {
444 return nil, fmt.Errorf("rkey is required")
445 }
446
447 if doc.Title == "" {
448 return nil, fmt.Errorf("document title is required")
449 }
450
451 collection := public.TypeDocument
452 if isDraft {
453 collection = public.TypeDocumentDraft
454 }
455
456 doc.Type = collection
457 jsonBytes, err := json.Marshal(doc)
458 if err != nil {
459 return nil, fmt.Errorf("marshal: %w", err)
460 }
461
462 var m map[string]any
463 if err := json.Unmarshal(jsonBytes, &m); err != nil {
464 return nil, fmt.Errorf("unmarshal: %w", err)
465 }
466 m["$type"] = collection
467
468 output, err := repoPutRecord(ctx, s.client, s.session.DID, collection, rkey, m)
469 if err != nil {
470 return nil, fmt.Errorf("failed to update record: %w", err)
471 }
472
473 uri := fmt.Sprintf("at://%s/%s/%s", s.session.DID, collection, rkey)
474 meta := public.DocumentMeta{
475 RKey: rkey,
476 CID: output.Cid,
477 URI: uri,
478 IsDraft: isDraft,
479 FetchedAt: time.Now(),
480 }
481 return &DocumentWithMeta{Document: doc, Meta: meta}, nil
482}
483
484// DeleteDocument removes a document from the user's repository
485func (s *ATProtoService) DeleteDocument(ctx context.Context, rkey string, isDraft bool) error {
486 if !s.IsAuthenticated() {
487 return fmt.Errorf("not authenticated")
488 }
489
490 if rkey == "" {
491 return fmt.Errorf("rkey is required")
492 }
493
494 collection := public.TypeDocument
495 if isDraft {
496 collection = public.TypeDocumentDraft
497 }
498
499 input := &atproto.RepoDeleteRecord_Input{
500 Repo: s.session.DID,
501 Collection: collection,
502 Rkey: rkey,
503 }
504
505 _, err := atproto.RepoDeleteRecord(ctx, s.client, input)
506 if err != nil {
507 return fmt.Errorf("failed to delete record: %w", err)
508 }
509
510 return nil
511}
512
513// UploadBlob uploads binary data as a blob to AT Protocol
514func (s *ATProtoService) UploadBlob(ctx context.Context, data []byte, mimeType string) (public.Blob, error) {
515 if !s.IsAuthenticated() {
516 return public.Blob{}, fmt.Errorf("not authenticated")
517 }
518
519 if len(data) == 0 {
520 return public.Blob{}, fmt.Errorf("data cannot be empty")
521 }
522
523 if mimeType == "" {
524 return public.Blob{}, fmt.Errorf("mimeType is required")
525 }
526
527 output, err := atproto.RepoUploadBlob(ctx, s.client, bytes.NewReader(data))
528 if err != nil {
529 return public.Blob{}, fmt.Errorf("failed to upload blob: %w", err)
530 }
531
532 blob := public.Blob{
533 Type: public.TypeBlob,
534 Ref: public.CID{Link: output.Blob.Ref.String()},
535 MimeType: output.Blob.MimeType,
536 Size: int(output.Blob.Size),
537 }
538 return blob, nil
539}
540
541// Close cleans up resources
542func (s *ATProtoService) Close() error {
543 s.session = nil
544 return nil
545}
546
547func repoCreateRecord(ctx context.Context, client *xrpc.Client, repo, collection string, record map[string]any) (*MutateRecordOutput, error) {
548 body := map[string]any{
549 "repo": repo,
550 "collection": collection,
551 "record": record,
552 }
553
554 var out MutateRecordOutput
555 if err := client.LexDo(
556 ctx,
557 lexutil.Procedure,
558 "application/json",
559 "com.atproto.repo.createRecord",
560 nil,
561 body,
562 &out,
563 ); err != nil {
564 return nil, fmt.Errorf("repoCreateRecord failed: %w", err)
565 }
566 return &out, nil
567}
568
569func repoPutRecord(ctx context.Context, client *xrpc.Client, repo, collection, rkey string, record map[string]any) (*MutateRecordOutput, error) {
570 body := map[string]any{
571 "repo": repo,
572 "collection": collection,
573 "rkey": rkey,
574 "record": record,
575 }
576
577 var out MutateRecordOutput
578 if err := client.LexDo(
579 ctx,
580 lexutil.Procedure,
581 "application/json",
582 "com.atproto.repo.putRecord",
583 nil,
584 body,
585 &out,
586 ); err != nil {
587 return nil, fmt.Errorf("repoPutRecord failed: %w", err)
588 }
589 return &out, nil
590}
591
592// convertCBORToJSONCompatible recursively converts CBOR data structures to JSON-compatible types
593//
594// This converts map[any]any to map[string]any to allow usage of [json.Marshal]
595func convertCBORToJSONCompatible(data any) any {
596 switch v := data.(type) {
597 case map[any]any:
598 result := make(map[string]any, len(v))
599 for key, value := range v {
600 strKey := fmt.Sprintf("%v", key)
601 result[strKey] = convertCBORToJSONCompatible(value)
602 }
603 return result
604 case map[string]any:
605 result := make(map[string]any, len(v))
606 for key, value := range v {
607 result[key] = convertCBORToJSONCompatible(value)
608 }
609 return result
610 case []any:
611 result := make([]any, len(v))
612 for i, item := range v {
613 result[i] = convertCBORToJSONCompatible(item)
614 }
615 return result
616 default:
617 return v
618 }
619}
620
621// convertJSONToCBORCompatible recursively converts JSON-compatible data structures to CBOR types
622//
623// This converts map[string]any to map[any]any to allow proper CBOR encoding for AT Protocol
624func convertJSONToCBORCompatible(data any) any {
625 switch v := data.(type) {
626 case map[string]any:
627 result := make(map[any]any, len(v))
628 for key, value := range v {
629 result[key] = convertJSONToCBORCompatible(value)
630 }
631 return result
632 case map[any]any:
633 result := make(map[any]any, len(v))
634 for key, value := range v {
635 result[key] = convertJSONToCBORCompatible(value)
636 }
637 return result
638 case []any:
639 result := make([]any, len(v))
640 for i, item := range v {
641 result[i] = convertJSONToCBORCompatible(item)
642 }
643 return result
644 default:
645 return v
646 }
647}