diff --git a/cli/cmd/publish.go b/cli/cmd/publish.go index b06f08c..f2c355f 100644 --- a/cli/cmd/publish.go +++ b/cli/cmd/publish.go @@ -5,16 +5,32 @@ package cmd import ( "fmt" + "os" "github.com/spf13/cobra" + "tangled.org/hectorsector.com/books/cli/internal/atproto" ) // publishCmd represents the publish command var publishCmd = &cobra.Command{ Use: "publish", - Short: "🔜 Will publish book and status records to a user's PDS", - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("publish called") + Short: "Publishes book and status records to a user's PDS", + RunE: func(cmd *cobra.Command, args []string) error { + handle, _ := cmd.Flags().GetString("handle") + pds, err := atproto.ResolveHandleToPDS(handle) + if err != nil { + return fmt.Errorf("Couldn't get pds from handle %s: %w", handle, err) + } + + appPassword, _ := cmd.Flags().GetString("app-password") + + session, err := atproto.CreateSession(pds, handle, appPassword) + if err != nil { + return fmt.Errorf("Couldn't create session from:\n handle %s\n pds: %s\n Error: %w", handle, pds, err) + } + + fmt.Printf("Session:\n%v", session) + return nil }, } @@ -30,4 +46,6 @@ func init() { // Cobra supports local flags which will only run when this command // is called directly, e.g.: // publishCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") + publishCmd.Flags().String("handle", os.Getenv("BSKY_HANDLE"), "the atproto handle to which to publish") + publishCmd.Flags().String("app-password", os.Getenv("BSKY_APP_PASSWORD"), "bsky app password") } diff --git a/cli/internal/atproto/atproto.go b/cli/internal/atproto/atproto.go new file mode 100644 index 0000000..d04c63d --- /dev/null +++ b/cli/internal/atproto/atproto.go @@ -0,0 +1,174 @@ +package atproto + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" +) + +type Session struct { + AccessJwt string `json:"accessJwt"` + DID string `json:"did"` +} + +type didService struct { + ID string `json:"id"` + Type string `json:"type"` + ServiceEndpoint string `json:"serviceEndpoint"` +} + +type didDoc struct { + Services []didService `json:"service"` +} + +type requestToPDS struct { + Identifier string `json:"identifier"` + Password string `json:"password"` +} + +func CreateSession(pdsHost, handle, appPassword string) (Session, error) { + body, err := json.Marshal(requestToPDS{ + Identifier: handle, + Password: appPassword, + }) + if err != nil { + return Session{}, fmt.Errorf("Couldn't marshal JSON to create a session: %w", err) + } + + // the actual auth call. POST to `/xrpc/com.atproto.server.createSession`, + url := pdsHost + "/xrpc/com.atproto.server.createSession" + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return Session{}, fmt.Errorf("Couldn't create request for url %s: %w", url, err) + } + + req.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(req) + if err != nil { + return Session{}, fmt.Errorf("Couldn't POST to the PDS: %w", err) + } + defer response.Body.Close() + + if response.StatusCode == 401 { + return Session{}, fmt.Errorf("Invalid creds when calling PDS ", response.Status) + } + + if response.StatusCode != 200 { + return Session{}, fmt.Errorf("Got non-200 response from calling PDS ", response.Status) + } + + var sesh Session + err = json.NewDecoder(response.Body).Decode(&sesh) + if err != nil { + return Session{}, fmt.Errorf("Unable to decode response from calling for pds: %w", err) + } + + // return a `Session` struct with `AccessJwt` and `DID`. + return sesh, nil +} + +func ResolveHandleToPDS(handle string) (string, error) { + did, err := resolveHandleToDID(handle) + if err != nil { + return "", fmt.Errorf("Problem resolving handle %s to did: %w", handle, err) + } + + pds, err := resolveDIDtoPDSHost(did) + if err != nil { + return "", fmt.Errorf("Problem resolving did %s for handle %s to pds: %w", did, handle, err) + } + + return pds, nil +} + +func resolveHandleToDID(handle string) (string, error) { + // will be called by `publishCmd` + + // attempt dns lookup first + did, _ := resolveViaDNS(handle) + + // fallback to http + if did == "" { + var err error + did, err = resolveViaHTTP(handle) + if err != nil { + return "", fmt.Errorf("Problem resolving did for handle %s via http: %w", handle, err) + } + } + + return did, nil +} + +func resolveDIDtoPDSHost(did string) (string, error) { + url := "https://plc.directory/" + did + response, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("Couldn't call %s to get did %s: %w", url, did, err) + } + defer response.Body.Close() + + if response.StatusCode != 200 { + return "", fmt.Errorf("Received non-OK response from call to %s: status <%s>", url, response.Status) + } + + var result didDoc + err = json.NewDecoder(response.Body).Decode(&result) + if err != nil { + return "", fmt.Errorf("Unable to decode response from calling %s: %w", url, err) + } + + for _, service := range result.Services { + if service.Type == "AtprotoPersonalDataServer" { + return service.ServiceEndpoint, nil + } + } + + return "", fmt.Errorf("Couldn't find PDS host for did %s", did) +} + +func resolveViaDNS(handle string) (string, error) { + // look up TXT records on `_atproto.`, + hostname := "_atproto." + handle + dnsRecords, err := net.LookupTXT(hostname) + if err != nil { + return "", fmt.Errorf("Couldn't lookup dns txt records for hostname %s: %w", hostname, err) + } + + // find the record prefixed `did=`, + for _, record := range dnsRecords { + if strings.HasPrefix(record, "did=") { + // strip the prefix, return the DID. + return strings.TrimPrefix(record, "did="), nil + } + } + + // nil error here so the caller can fallback to http + return "", nil +} + +func resolveViaHTTP(handle string) (string, error) { + // GET `https:///.well-known/atproto-did` + url := fmt.Sprintf("https://%s/.well-known/atproto-did", handle) + + response, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("Couldn't call %s to get handle %s: %w", url, handle, err) + } + defer response.Body.Close() + + if response.StatusCode != 200 { + return "", fmt.Errorf("Received non-OK response from call to %s: status <%s>", url, response.Status) + } + + // return the response body trimmed as the DID. + bytes, err := io.ReadAll(response.Body) + if err != nil { + return "", fmt.Errorf("Unable to read response body from call to %s: %w", url, err) + } + didRaw := string(bytes) + return strings.TrimSpace(didRaw), nil +} diff --git a/cli/internal/openlibrary/openlibrary.go b/cli/internal/openlibrary/openlibrary.go index a925a98..3d9a21e 100644 --- a/cli/internal/openlibrary/openlibrary.go +++ b/cli/internal/openlibrary/openlibrary.go @@ -47,7 +47,7 @@ func lookup(params url.Values) (string, error) { defer response.Body.Close() if response.StatusCode != 200 { - return "", fmt.Errorf("Received non-OK response from call to %s: status <%s> body <%s>", url, response.Status, response.Body) + return "", fmt.Errorf("Received non-OK response from call to %s: status <%s>", url, response.Status) } var result searchResponse