cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
at main 647 lines 18 kB view raw
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}