diff --git a/README.md b/README.md index 91fee2c..1ee20fe 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,34 @@ writeas-cli =========== Command line interface for [Write.as](https://write.as) and [Write.as on Tor](http://writeas7pm7rcdqg.onion/). Works on Windows, OS X, and Linux. -Like the [Android app](https://play.google.com/store/apps/details?id=com.abunchtell.writeas), the command line client keeps track of the posts you make, so future editing / deleting is easier than [doing it with cURL](http://cmd.write.as/). It is currently **ALPHA**, so only basic functionality is available. But the goal is for this to hold the logic behind any future GUI app we build for the desktop. +Like the [Android app](https://play.google.com/store/apps/details?id=com.abunchtell.writeas), the command line client keeps track of the posts you make, so future editing / deleting is easier than [doing it with cURL](http://cmd.write.as/). The goal is for this to serve as the backend for any future GUI app we build for the desktop. + +It is currently **alpha**, so a) functionality is basic and b) everything is subject to change — i.e., watch the [changelog](https://write.as/changelog-cli.html). ## Usage ``` writeas [global options] command [command options] [arguments...] COMMANDS: post Alias for default action: create post from stdin delete Delete a post get Read a raw post + add Add a post locally + list List local posts help, h Shows a list of commands or help for one command GLOBAL OPTIONS: --tor, -t Perform action on Tor hidden service --tor-port "9150" Use a different port to connect to Tor --help, -h show help --version, -v print the version ``` ## Download Get it on the [web](https://write.as/cli.html) or [hidden service](http://writeas7pm7rcdqg.onion/cli.html). ## Go get it `go get github.com/writeas/writeas-cli` diff --git a/utils/fileutils.go b/utils/fileutils.go index 5bc14f0..4686606 100644 --- a/utils/fileutils.go +++ b/utils/fileutils.go @@ -1,80 +1,101 @@ package fileutils import ( "bufio" "fmt" "os" "strings" ) // Exists returns whether or not the given file exists func Exists(p string) bool { if _, err := os.Stat(p); err == nil { return true } return false } func WriteData(path string, data []byte) { f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { fmt.Println(err) } // TODO: check for Close() errors // https://github.com/ncw/swift/blob/master/swift.go#L170 defer f.Close() _, err = f.Write(data) if err != nil { fmt.Println(err) } } +func ReadData(p string) *[]string { + f, err := os.Open(p) + if err != nil { + return nil + } + defer f.Close() + + lines := []string{} + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + return nil + } + + return &lines +} + func RemoveLine(p, startsWith string) { f, err := os.Open(p) if err != nil { return } defer f.Close() var outText string found := false scanner := bufio.NewScanner(f) for scanner.Scan() { if strings.HasPrefix(scanner.Text(), startsWith) { found = true } else { outText += scanner.Text() + string('\n') } } if err := scanner.Err(); err != nil { return } if found { WriteData(p, []byte(outText)) } } func FindLine(p, startsWith string) string { f, err := os.Open(p) if err != nil { return "" } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { if strings.HasPrefix(scanner.Text(), startsWith) { return scanner.Text() } } if err := scanner.Err(); err != nil { return "" } return "" } diff --git a/writeas/cli.go b/writeas/cli.go index 8970d68..0d645db 100644 --- a/writeas/cli.go +++ b/writeas/cli.go @@ -1,317 +1,366 @@ package main import ( "bufio" "bytes" "fmt" "github.com/codegangsta/cli" "io" "io/ioutil" "log" "net/http" "net/url" "os" "strconv" "strings" ) const ( apiUrl = "http://i.write.as" hiddenApiUrl = "http://writeas7pm7rcdqg.onion" readApiUrl = "http://i.write.as" VERSION = "0.1.1" ) func main() { initialize() // Run the app app := cli.NewApp() app.Name = "writeas" app.Version = VERSION app.Usage = "Simple text pasting and publishing" app.Authors = []cli.Author{ { Name: "Write.as", Email: "hello@write.as", }, } app.Action = cmdPost app.Flags = []cli.Flag{ cli.BoolFlag{ Name: "tor, t", Usage: "Perform action on Tor hidden service", }, cli.IntFlag{ Name: "tor-port", Usage: "Use a different port to connect to Tor", Value: 9150, }, } app.Commands = []cli.Command{ { Name: "post", Usage: "Alias for default action: create post from stdin", Action: cmdPost, Flags: []cli.Flag{ cli.BoolFlag{ Name: "tor, t", Usage: "Post to Tor hidden service", }, cli.IntFlag{ Name: "tor-port", Usage: "Use a different port to connect to Tor", Value: 9150, }, }, }, { Name: "delete", Usage: "Delete a post", Action: cmdDelete, Flags: []cli.Flag{ cli.BoolFlag{ Name: "tor, t", Usage: "Delete from Tor hidden service", }, cli.IntFlag{ Name: "tor-port", Usage: "Use a different port to connect to Tor", Value: 9150, }, }, }, { Name: "get", Usage: "Read a raw post", Action: cmdGet, Flags: []cli.Flag{ cli.BoolFlag{ Name: "tor, t", Usage: "Get from Tor hidden service", }, cli.IntFlag{ Name: "tor-port", Usage: "Use a different port to connect to Tor", Value: 9150, }, }, }, + { + Name: "add", + Usage: "Add a post locally", + Action: cmdAdd, + }, + { + Name: "list", + Usage: "List local posts", + Action: cmdList, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "id", + Usage: "Show list with post IDs (default)", + }, + cli.BoolFlag{ + Name: "url", + Usage: "Show list with URLs", + }, + }, + }, } app.Run(os.Args) } func initialize() { // Ensure we have a data directory to use if !dataDirExists() { createDataDir() } } 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.Fatal(err) } numChunks++ numBytes += int64(len(buf)) fullPost = append(fullPost, buf...) if err != nil && err != io.EOF { log.Fatal(err) } } return fullPost } func check(err error) { if err != nil { fmt.Printf("%s\n", err) os.Exit(1) } } func cmdPost(c *cli.Context) { fullPost := readStdIn() tor := c.Bool("tor") || c.Bool("t") if c.Int("tor-port") != 0 { torPort = c.Int("tor-port") } if tor { fmt.Println("Posting to hidden service...") } else { fmt.Println("Posting...") } DoPost(fullPost, false, tor) } func cmdDelete(c *cli.Context) { friendlyId := c.Args().Get(0) token := c.Args().Get(1) if friendlyId == "" { fmt.Println("usage: writeas delete []") os.Exit(1) } if token == "" { // Search for the token locally token = tokenFromID(friendlyId) if token == "" { fmt.Println("Couldn't find an edit token locally. Did you create this post here?") fmt.Printf("If you have an edit token, use: writeas delete %s \n", friendlyId) os.Exit(1) } } tor := c.Bool("tor") || c.Bool("t") if c.Int("tor-port") != 0 { torPort = c.Int("tor-port") } DoDelete(friendlyId, token, tor) } func cmdGet(c *cli.Context) { friendlyId := c.Args().Get(0) if friendlyId == "" { fmt.Println("usage: writeas get ") os.Exit(1) } tor := c.Bool("tor") || c.Bool("t") if c.Int("tor-port") != 0 { torPort = c.Int("tor-port") } DoFetch(friendlyId, tor) } +func cmdAdd(c *cli.Context) { + friendlyId := c.Args().Get(0) + token := c.Args().Get(1) + if friendlyId == "" || token == "" { + fmt.Println("usage: writeas add ") + os.Exit(1) + } + + addPost(friendlyId, token) +} + +func cmdList(c *cli.Context) { + urls := c.Bool("url") + ids := c.Bool("id") + + var p Post + posts := getPosts() + for i := range *posts { + p = (*posts)[len(*posts)-1-i] + if ids || !urls { + fmt.Printf("%s ", p.ID) + } + if urls { + fmt.Printf("https://write.as/%s ", p.ID) + } + fmt.Print("\n") + } +} + func client(read, tor bool, path, query string) (string, *http.Client) { var u *url.URL var client *http.Client if tor { u, _ = url.ParseRequestURI(hiddenApiUrl) if len(path) != 12 { // Handle alpha phase HTML-based URLs path += ".txt" } if read { u.Path = "/" + path } else { u.Path = "/api" } client = torClient() } else { u, _ = url.ParseRequestURI(apiUrl) u.Path = "/" + path client = &http.Client{} } if query != "" { u.RawQuery = query } urlStr := fmt.Sprintf("%v", u) return urlStr, client } func DoFetch(friendlyId string, tor bool) { path := friendlyId urlStr, client := client(true, tor, path, "") r, _ := http.NewRequest("GET", urlStr, nil) r.Header.Add("User-Agent", "writeas-cli v"+VERSION) resp, err := client.Do(r) check(err) defer resp.Body.Close() if resp.StatusCode == http.StatusOK { content, err := ioutil.ReadAll(resp.Body) check(err) fmt.Printf("%s\n", string(content)) } else if resp.StatusCode == http.StatusNotFound { fmt.Printf("Post not found.\n") } else { fmt.Printf("Problem getting post: %s\n", resp.Status) } } func DoPost(post []byte, encrypt, tor bool) { data := url.Values{} data.Set("w", string(post)) if encrypt { data.Add("e", "") } urlStr, client := client(false, tor, "", "") r, _ := http.NewRequest("POST", urlStr, bytes.NewBufferString(data.Encode())) r.Header.Add("Content-Type", "application/x-www-form-urlencoded") r.Header.Add("Content-Length", strconv.Itoa(len(data.Encode()))) resp, err := client.Do(r) check(err) defer resp.Body.Close() if resp.StatusCode == http.StatusOK { content, err := ioutil.ReadAll(resp.Body) check(err) nlPos := strings.Index(string(content), "\n") url := content[:nlPos] idPos := strings.LastIndex(string(url), "/") + 1 id := string(url[idPos:]) token := string(content[nlPos+1 : len(content)-1]) addPost(id, token) fmt.Printf("%s\n", url) } else { fmt.Printf("Unable to post: %s\n", resp.Status) } } func DoDelete(friendlyId, token string, tor bool) { urlStr, client := client(false, tor, "", fmt.Sprintf("id=%s&t=%s", friendlyId, token)) r, _ := http.NewRequest("DELETE", urlStr, nil) r.Header.Add("Content-Type", "application/x-www-form-urlencoded") resp, err := client.Do(r) check(err) defer resp.Body.Close() if resp.StatusCode == http.StatusOK { if tor { fmt.Println("Post deleted from hidden service.") } else { fmt.Println("Post deleted.") } removePost(friendlyId) } else { if DEBUG { fmt.Printf("Problem deleting: %s\n", resp.Status) } else { fmt.Printf("Post doesn't exist, or bad edit token given.\n") } } } diff --git a/writeas/posts.go b/writeas/posts.go index 668c633..bf0606f 100644 --- a/writeas/posts.go +++ b/writeas/posts.go @@ -1,65 +1,88 @@ package main import ( "fmt" "github.com/writeas/writeas-cli/utils" "os" "path/filepath" "strings" ) const ( POSTS_FILE = "posts.psv" SEPARATOR = `|` ) +type Post struct { + ID string + EditToken string +} + func userDataDir() string { return filepath.Join(parentDataDir(), DATA_DIR_NAME) } func dataDirExists() bool { return fileutils.Exists(userDataDir()) } func createDataDir() { err := os.Mkdir(userDataDir(), 0700) if err != nil && DEBUG { panic(err) } } func addPost(id, token string) { f, err := os.OpenFile(filepath.Join(userDataDir(), POSTS_FILE), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600) if err != nil { if DEBUG { panic(err) } else { return } } defer f.Close() l := fmt.Sprintf("%s%s%s\n", id, SEPARATOR, token) if _, err = f.WriteString(l); err != nil && DEBUG { panic(err) } } func tokenFromID(id string) string { post := fileutils.FindLine(filepath.Join(userDataDir(), POSTS_FILE), id) if post == "" { return "" } parts := strings.Split(post, SEPARATOR) if len(parts) < 2 { return "" } return parts[1] } func removePost(id string) { fileutils.RemoveLine(filepath.Join(userDataDir(), POSTS_FILE), id) } + +func getPosts() *[]Post { + lines := fileutils.ReadData(filepath.Join(userDataDir(), POSTS_FILE)) + + posts := []Post{} + 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 + +}