diff --git a/api/api.go b/api/api.go index 06985d2..35c10fc 100644 --- a/api/api.go +++ b/api/api.go @@ -1,283 +1,297 @@ package api import ( "fmt" "github.com/atotto/clipboard" writeas "github.com/writeas/go-writeas/v2" "github.com/writeas/web-core/posts" "github.com/writeas/writeas-cli/config" "github.com/writeas/writeas-cli/executable" "github.com/writeas/writeas-cli/log" cli "gopkg.in/urfave/cli.v1" ) func HostURL(c *cli.Context) string { host := c.GlobalString("host") if host == "" { return "" } insecure := c.Bool("insecure") scheme := "https://" if insecure { scheme = "http://" } return scheme + host } -func newClient(c *cli.Context, authRequired bool) (*writeas.Client, error) { +func newClient(c *cli.Context) (*writeas.Client, error) { var client *writeas.Client var clientConfig writeas.Config cfg, err := config.LoadConfig(config.UserDataDir(c.App.ExtraInfo()["configDir"])) if err != nil { return nil, fmt.Errorf("Failed to load configuration file: %v", err) } if host := HostURL(c); host != "" { clientConfig.URL = host + "/api" } else if cfg.Default.Host != "" { clientConfig.URL = cfg.Default.Host + "/api" } else if config.IsDev() { clientConfig.URL = config.DevBaseURL + "/api" } else if c.App.Name == "writeas" { clientConfig.URL = config.WriteasBaseURL + "/api" } else { return nil, fmt.Errorf("Must supply a host. Example: %s --host example.com %s", executable.Name(), c.Command.Name) } if config.IsTor(c) { clientConfig.URL = config.TorURL(c) clientConfig.TorPort = config.TorPort(c) } client = writeas.NewClientWith(clientConfig) client.UserAgent = config.UserAgent(c) - // TODO: load user into var shared across the app - u, _ := config.LoadUser(c) - if u != nil { - client.SetToken(u.AccessToken) - } else if authRequired { - return nil, fmt.Errorf("Not currently logged in. Authenticate with: " + executable.Name() + " auth ") - } return client, nil } // DoFetch retrieves the Write.as post with the given friendlyID, // optionally via the Tor hidden service. func DoFetch(c *cli.Context, friendlyID string) error { - cl, err := newClient(c, false) + cl, err := newClient(c) if err != nil { return err } p, err := cl.GetPost(friendlyID) if err != nil { return err } if p.Title != "" { fmt.Printf("# %s\n\n", string(p.Title)) } fmt.Printf("%s\n", string(p.Content)) return nil } // DoFetchPosts retrieves all remote posts for the // authenticated user func DoFetchPosts(c *cli.Context) ([]writeas.Post, error) { - cl, err := newClient(c, true) + cl, err := newClient(c) if err != nil { return nil, fmt.Errorf("%v", err) } + u, _ := config.LoadUser(c) + if u != nil { + cl.SetToken(u.AccessToken) + } else { + return nil, fmt.Errorf("Not currently logged in. Authenticate with: " + executable.Name() + " auth ") + } + posts, err := cl.GetUserPosts() if err != nil { return nil, err } return *posts, nil } // DoPost creates a Write.as post, returning an error if it was // unsuccessful. func DoPost(c *cli.Context, post []byte, font string, encrypt, code bool) (*writeas.Post, error) { - cl, err := newClient(c, false) + cl, err := newClient(c) if err != nil { return nil, fmt.Errorf("%v", err) } pp := &writeas.PostParams{ Font: config.GetFont(code, font), Collection: config.Collection(c), } pp.Title, pp.Content = posts.ExtractTitle(string(post)) if lang := config.Language(c, true); lang != "" { pp.Language = &lang } p, err := cl.CreatePost(pp) if err != nil { return nil, fmt.Errorf("Unable to post: %v", err) } cfg, err := config.LoadConfig(config.UserDataDir(c.App.ExtraInfo()["configDir"])) if err != nil { return nil, fmt.Errorf("Couldn't check for config file: %v", err) } var url string if p.Collection != nil { url = p.Collection.URL + p.Slug } else { if host := HostURL(c); host != "" { url = host } else if cfg.Default.Host != "" { url = cfg.Default.Host } else if config.IsDev() { url = config.DevBaseURL } else if config.IsTor(c) { url = config.TorBaseURL } else { url = config.WriteasBaseURL } url += "/" + p.ID // Output URL in requested format if c.Bool("md") { url += ".md" } } if cl.Token() == "" { // Store post locally, since we're not authenticated AddPost(c, p.ID, p.Token) } // Copy URL to clipboard err = clipboard.WriteAll(string(url)) if err != nil { log.Errorln(executable.Name()+": Didn't copy to clipboard: %s", err) } else { log.Info(c, "Copied to clipboard.") } // Output URL fmt.Printf("%s\n", url) return p, nil } // DoFetchCollections retrieves a list of the currently logged in users // collections. func DoFetchCollections(c *cli.Context) ([]RemoteColl, error) { - cl, err := newClient(c, true) + cl, err := newClient(c) if err != nil { if config.Debug() { log.ErrorlnQuit("could not create client: %v", err) } return nil, fmt.Errorf("Couldn't create new client") } + u, _ := config.LoadUser(c) + if u != nil { + cl.SetToken(u.AccessToken) + } else { + return nil, fmt.Errorf("Not currently logged in. Authenticate with: " + executable.Name() + " auth ") + } + colls, err := cl.GetUserCollections() if err != nil { if config.Debug() { log.ErrorlnQuit("failed fetching user collections: %v", err) } return nil, fmt.Errorf("Couldn't get user blogs") } out := make([]RemoteColl, len(*colls)) for i, c := range *colls { coll := RemoteColl{ Alias: c.Alias, Title: c.Title, URL: c.URL, } out[i] = coll } return out, nil } // DoUpdate updates the given post on Write.as. func DoUpdate(c *cli.Context, post []byte, friendlyID, token, font string, code bool) error { - cl, err := newClient(c, false) + cl, err := newClient(c) if err != nil { return fmt.Errorf("%v", err) } params := writeas.PostParams{} params.Title, params.Content = posts.ExtractTitle(string(post)) if lang := config.Language(c, false); lang != "" { params.Language = &lang } if code || font != "" { params.Font = config.GetFont(code, font) } _, err = cl.UpdatePost(friendlyID, token, ¶ms) if err != nil { if config.Debug() { log.ErrorlnQuit("Problem updating: %v", err) } return fmt.Errorf("Post doesn't exist, or bad edit token given.") } return nil } // DoDelete deletes the given post on Write.as, and removes any local references func DoDelete(c *cli.Context, friendlyID, token string) error { - cl, err := newClient(c, false) + cl, err := newClient(c) if err != nil { return fmt.Errorf("%v", err) } err = cl.DeletePost(friendlyID, token) if err != nil { if config.Debug() { log.ErrorlnQuit("Problem deleting: %v", err) } return fmt.Errorf("Post doesn't exist, or bad edit token given.") } RemovePost(c, friendlyID) return nil } func DoLogIn(c *cli.Context, username, password string) error { - cl, err := newClient(c, false) + cl, err := newClient(c) if err != nil { return fmt.Errorf("%v", err) } u, err := cl.LogIn(username, password) if err != nil { if config.Debug() { log.ErrorlnQuit("Problem logging in: %v", err) } return err } err = config.SaveUser(c, u) if err != nil { return err } log.Info(c, "Logged in as %s.\n", u.User.Username) return nil } func DoLogOut(c *cli.Context) error { - cl, err := newClient(c, true) + cl, err := newClient(c) if err != nil { return fmt.Errorf("%v", err) } + u, _ := config.LoadUser(c) + if u != nil { + cl.SetToken(u.AccessToken) + } else if c.App.Name == "writeas" { + return fmt.Errorf("Not currently logged in. Authenticate with: " + executable.Name() + " auth ") + } + err = cl.LogOut() if err != nil { if config.Debug() { log.ErrorlnQuit("Problem logging out: %v", err) } return err } // delete local user file return config.DeleteUser(c) } diff --git a/api/posts.go b/api/posts.go index 3c1e2bf..033cac3 100644 --- a/api/posts.go +++ b/api/posts.go @@ -1,301 +1,310 @@ package api import ( "bufio" "fmt" "io" "io/ioutil" "os" "path/filepath" "strings" "time" writeas "github.com/writeas/go-writeas/v2" "github.com/writeas/writeas-cli/config" + "github.com/writeas/writeas-cli/executable" "github.com/writeas/writeas-cli/fileutils" "github.com/writeas/writeas-cli/log" cli "gopkg.in/urfave/cli.v1" ) const ( postsFile = "posts.psv" separator = `|` ) // Post holds the basic authentication information for a Write.as post. type Post struct { ID string EditToken string } // RemotePost holds addition information about published // posts type RemotePost struct { Post Title, Excerpt, Slug, Collection, EditToken string Synced bool Updated time.Time } func AddPost(c *cli.Context, id, token string) error { hostDir, err := config.HostDirectory(c) if err != nil { return fmt.Errorf("Error checking for host directory: %v", err) } f, err := os.OpenFile(filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), hostDir, postsFile), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600) if err != nil { return fmt.Errorf("Error creating local posts list: %s", err) } defer f.Close() l := fmt.Sprintf("%s%s%s\n", id, separator, token) if _, err = f.WriteString(l); err != nil { return fmt.Errorf("Error writing to local posts list: %s", err) } return nil } // ClaimPost adds a local post to the authenticated user's account and deletes // the local reference func ClaimPosts(c *cli.Context, localPosts *[]Post) (*[]writeas.ClaimPostResult, error) { - cl, err := newClient(c, true) + cl, err := newClient(c) if err != nil { return nil, err } + + u, _ := config.LoadUser(c) + if u != nil { + cl.SetToken(u.AccessToken) + } else { + return nil, fmt.Errorf("Not currently logged in. Authenticate with: " + executable.Name() + " auth ") + } + postsToClaim := make([]writeas.OwnedPostParams, len(*localPosts)) for i, post := range *localPosts { postsToClaim[i] = writeas.OwnedPostParams{ ID: post.ID, Token: post.EditToken, } } return cl.ClaimPosts(&postsToClaim) } func TokenFromID(c *cli.Context, id string) string { hostDir, _ := config.HostDirectory(c) post := fileutils.FindLine(filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), hostDir, postsFile), id) if post == "" { return "" } parts := strings.Split(post, separator) if len(parts) < 2 { return "" } return parts[1] } func RemovePost(c *cli.Context, id string) { hostDir, _ := config.HostDirectory(c) fullPath := filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), hostDir, postsFile) fileutils.RemoveLine(fullPath, id) } func GetPosts(c *cli.Context) *[]Post { hostDir, _ := config.HostDirectory(c) lines := fileutils.ReadData(filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), hostDir, postsFile)) posts := []Post{} if lines != nil && len(*lines) > 0 { parts := make([]string, 2) for _, l := range *lines { parts = strings.Split(l, separator) if len(parts) < 2 { continue } posts = append(posts, Post{ID: parts[0], EditToken: parts[1]}) } } return &posts } func GetUserPosts(c *cli.Context, draftsOnly bool) ([]RemotePost, error) { waposts, err := DoFetchPosts(c) if err != nil { return nil, err } if len(waposts) == 0 { return nil, nil } posts := []RemotePost{} for _, p := range waposts { if draftsOnly && p.Collection != nil { continue } post := RemotePost{ Post: Post{ ID: p.ID, EditToken: p.Token, }, Title: p.Title, Excerpt: getExcerpt(p.Content), Slug: p.Slug, Synced: p.Slug != "", Updated: p.Updated, } if p.Collection != nil { post.Collection = p.Collection.Alias } posts = append(posts, post) } return posts, nil } // getExcerpt takes in a content string and returns // a concatenated version. limited to no more than // two lines of 80 chars each. delimited by '...' func getExcerpt(input string) string { length := len(input) if length <= 80 { return input } else if length < 160 { ln1, idx := trimToLength(input, 80) if idx == -1 { idx = 80 } ln2, _ := trimToLength(input[idx:], 80) return ln1 + "\n" + ln2 } else { excerpt := input[:158] ln1, idx := trimToLength(excerpt, 80) if idx == -1 { idx = 80 } ln2, _ := trimToLength(excerpt[idx:], 80) return ln1 + "\n" + ln2 + "..." } } func trimToLength(in string, l int) (string, int) { c := []rune(in) spaceIdx := -1 length := len(c) if length <= l { return in, spaceIdx } for i := l; i > 0; i-- { if c[i] == ' ' { spaceIdx = i break } } if spaceIdx > -1 { c = c[:spaceIdx] } return string(c), spaceIdx } func ComposeNewPost() (string, *[]byte) { f, err := fileutils.TempFile(os.TempDir(), "WApost", "txt") if err != nil { if config.Debug() { panic(err) } else { log.Errorln("Error creating temp file: %s", err) return "", nil } } f.Close() cmd := config.EditPostCmd(f.Name()) if cmd == nil { os.Remove(f.Name()) fmt.Println(config.NoEditorErr) return "", nil } cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr if err := cmd.Start(); err != nil { os.Remove(f.Name()) if config.Debug() { panic(err) } else { log.Errorln("Error starting editor: %s", err) return "", nil } } // If something fails past this point, the temporary post file won't be // removed automatically. Calling function should handle this. if err := cmd.Wait(); err != nil { if config.Debug() { panic(err) } else { log.Errorln("Editor finished with error: %s", err) return "", nil } } post, err := ioutil.ReadFile(f.Name()) if err != nil { if config.Debug() { panic(err) } else { log.Errorln("Error reading post: %s", err) return "", nil } } return f.Name(), &post } func WritePost(postsDir string, p *writeas.Post) error { postFilename := p.ID collDir := "" if p.Collection != nil { postFilename = p.Slug collDir = p.Collection.Alias } postFilename += PostFileExt txtFile := p.Content if p.Title != "" { txtFile = "# " + p.Title + "\n\n" + txtFile } return ioutil.WriteFile(filepath.Join(postsDir, collDir, postFilename), []byte(txtFile), 0644) } func ReadStdIn() []byte { numBytes, numChunks := int64(0), int64(0) r := bufio.NewReader(os.Stdin) fullPost := []byte{} buf := make([]byte, 0, 1024) for { n, err := r.Read(buf[:cap(buf)]) buf = buf[:n] if n == 0 { if err == nil { continue } if err == io.EOF { break } log.ErrorlnQuit("Error reading from stdin: %v", err) } numChunks++ numBytes += int64(len(buf)) fullPost = append(fullPost, buf...) if err != nil && err != io.EOF { log.ErrorlnQuit("Error appending to end of post: %v", err) } } return fullPost } diff --git a/api/sync.go b/api/sync.go index b025608..e57a31b 100644 --- a/api/sync.go +++ b/api/sync.go @@ -1,131 +1,139 @@ package api import ( //"github.com/writeas/writeas-cli/sync" "fmt" "io/ioutil" "os" "path/filepath" "github.com/writeas/writeas-cli/config" + "github.com/writeas/writeas-cli/executable" "github.com/writeas/writeas-cli/fileutils" "github.com/writeas/writeas-cli/log" cli "gopkg.in/urfave/cli.v1" ) const ( PostFileExt = ".txt" userFilename = "writeas_user" ) func CmdPull(c *cli.Context) error { cfg, err := config.LoadConfig(config.UserDataDir(c.App.ExtraInfo()["configDir"])) if err != nil { return err } // Create posts directory if needed if cfg.Posts.Directory == "" { syncSetUp(c, cfg) } - cl, err := newClient(c, true) + cl, err := newClient(c) if err != nil { return err } + u, _ := config.LoadUser(c) + if u != nil { + cl.SetToken(u.AccessToken) + } else { + return fmt.Errorf("Not currently logged in. Authenticate with: " + executable.Name() + " auth ") + } + // Fetch posts posts, err := cl.GetUserPosts() if err != nil { return err } for _, p := range *posts { postFilename := p.ID collDir := "" if p.Collection != nil { postFilename = p.Slug // Create directory for collection collDir = p.Collection.Alias if !fileutils.Exists(filepath.Join(cfg.Posts.Directory, collDir)) { log.Info(c, "Creating folder "+collDir) err = os.Mkdir(filepath.Join(cfg.Posts.Directory, collDir), 0755) if err != nil { log.Errorln("Error creating blog directory %s: %s. Skipping post %s.", collDir, err, postFilename) continue } } } postFilename += PostFileExt // Write file txtFile := p.Content if p.Title != "" { txtFile = "# " + p.Title + "\n\n" + txtFile } err = ioutil.WriteFile(filepath.Join(cfg.Posts.Directory, collDir, postFilename), []byte(txtFile), 0644) if err != nil { log.Errorln("Error creating file %s: %s", postFilename, err) } log.Info(c, "Saved post "+postFilename) // Update mtime and atime on files modTime := p.Updated.Local() err = os.Chtimes(filepath.Join(cfg.Posts.Directory, collDir, postFilename), modTime, modTime) if err != nil { log.Errorln("Error setting time on %s: %s", postFilename, err) } } return nil } func syncSetUp(c *cli.Context, cfg *config.Config) error { // Get user information and fail early (before we make the user do // anything), if we're going to u, err := config.LoadUser(c) if err != nil { return err } // Prompt for posts directory defaultDir, err := os.Getwd() if err != nil { return err } var dir string fmt.Printf("Posts directory? [%s]: ", defaultDir) fmt.Scanln(&dir) if dir == "" { dir = defaultDir } // FIXME: This only works on non-Windows OSes (fix: https://www.reddit.com/r/golang/comments/5t3ezd/hidden_files_directories/) userFilepath := filepath.Join(dir, "."+userFilename) // Create directory if needed if !fileutils.Exists(dir) { err = os.MkdirAll(dir, 0700) if err != nil { if config.Debug() { log.Errorln("Error creating data directory: %s", err) } return err } // Create username file in directory err = ioutil.WriteFile(userFilepath, []byte(u.User.Username), 0644) fmt.Println("Created posts directory.") } // Save preference cfg.Posts.Directory = dir err = config.SaveConfig(config.UserDataDir(c.App.ExtraInfo()["configDir"]), cfg) if err != nil { if config.Debug() { log.Errorln("Unable to save config: %s", err) } return err } fmt.Println("Saved config.") return nil }