From 9b0191b1b4952bfe3132e72a52a715bdf8dd08df Mon Sep 17 00:00:00 2001 From: Sergey Grebenshchikov Date: Fri, 21 Dec 2018 16:22:50 +0100 Subject: [PATCH] Release 1.1.2 --- .gitignore | 3 + Makefile | 75 +++++++++ README.md | 78 +++++++++ contrib/cron/Dockerfile | 31 ++++ main.go | 353 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 540 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 contrib/cron/Dockerfile create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..375d526 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/binaries +/release +/emoji diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3995326 --- /dev/null +++ b/Makefile @@ -0,0 +1,75 @@ +VERSION = 1.1.2 + +APP := slack-emoji-upload +PACKAGES := $(shell go list -f {{.Dir}} ./...) +GOFILES := $(addsuffix /*.go,$(PACKAGES)) +GOFILES := $(wildcard $(GOFILES)) + +.PHONY: clean release + +clean: + rm -rf binaries/ + rm -rf release/ + +release: zip README.md + git add README.md + git add Makefile + git commit -am "Release $(VERSION)" || true + git push + hub release create $(VERSION) -m "$(VERSION)" -a release/$(APP)_$(VERSION)_osx_x86_64.zip -a release/$(APP)_$(VERSION)_windows_x86_64.zip -a release/$(APP)_$(VERSION)_linux_x86_64.zip -a release/$(APP)_$(VERSION)_osx_x86_32.zip -a release/$(APP)_$(VERSION)_windows_x86_32.zip -a release/$(APP)_$(VERSION)_linux_x86_32.zip -a release/$(APP)_$(VERSION)_linux_arm64.zip + +docker-build-cron: $(GOFILES) + docker build -t slack-emoji-upload-cron -f contrib/cron/Dockerfile . + +zip: release/$(APP)_$(VERSION)_osx_x86_64.zip release/$(APP)_$(VERSION)_windows_x86_64.zip release/$(APP)_$(VERSION)_linux_x86_64.zip release/$(APP)_$(VERSION)_osx_x86_32.zip release/$(APP)_$(VERSION)_windows_x86_32.zip release/$(APP)_$(VERSION)_linux_x86_32.zip release/$(APP)_$(VERSION)_linux_arm64.zip + +binaries: binaries/osx_x86_64/$(APP) binaries/windows_x86_64/$(APP).exe binaries/linux_x86_64/$(APP) binaries/osx_x86_32/$(APP) binaries/windows_x86_32/$(APP).exe binaries/linux_x86_32/$(APP) + +release/$(APP)_$(VERSION)_osx_x86_64.zip: binaries/osx_x86_64/$(APP) + mkdir -p release + cd ./binaries/osx_x86_64 && zip -r -D ../../release/$(APP)_$(VERSION)_osx_x86_64.zip $(APP) + +binaries/osx_x86_64/$(APP): $(GOFILES) + GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.version=$(VERSION)" -o binaries/osx_x86_64/$(APP) + +release/$(APP)_$(VERSION)_windows_x86_64.zip: binaries/windows_x86_64/$(APP).exe + mkdir -p release + cd ./binaries/windows_x86_64 && zip -r -D ../../release/$(APP)_$(VERSION)_windows_x86_64.zip $(APP).exe + +binaries/windows_x86_64/$(APP).exe: $(GOFILES) + GOOS=windows GOARCH=amd64 go build -ldflags "-X main.version=$(VERSION)" -o binaries/windows_x86_64/$(APP).exe + +release/$(APP)_$(VERSION)_linux_x86_64.zip: binaries/linux_x86_64/$(APP) + mkdir -p release + cd ./binaries/linux_x86_64 && zip -r -D ../../release/$(APP)_$(VERSION)_linux_x86_64.zip $(APP) + +binaries/linux_x86_64/$(APP): $(GOFILES) + GOOS=linux GOARCH=amd64 go build -ldflags "-X main.version=$(VERSION)" -o binaries/linux_x86_64/$(APP) + +release/$(APP)_$(VERSION)_osx_x86_32.zip: binaries/osx_x86_32/$(APP) + mkdir -p release + cd ./binaries/osx_x86_32 && zip -r -D ../../release/$(APP)_$(VERSION)_osx_x86_32.zip $(APP) + +binaries/osx_x86_32/$(APP): $(GOFILES) + GOOS=darwin GOARCH=386 go build -ldflags "-X main.version=$(VERSION)" -o binaries/osx_x86_32/$(APP) + +release/$(APP)_$(VERSION)_windows_x86_32.zip: binaries/windows_x86_32/$(APP).exe + mkdir -p release + cd ./binaries/windows_x86_32 && zip -r -D ../../release/$(APP)_$(VERSION)_windows_x86_32.zip $(APP).exe + +binaries/windows_x86_32/$(APP).exe: $(GOFILES) + GOOS=windows GOARCH=386 go build -ldflags "-X main.version=$(VERSION)" -o binaries/windows_x86_32/$(APP).exe + +release/$(APP)_$(VERSION)_linux_x86_32.zip: binaries/linux_x86_32/$(APP) + mkdir -p release + cd ./binaries/linux_x86_32 && zip -r -D ../../release/$(APP)_$(VERSION)_linux_x86_32.zip $(APP) + +binaries/linux_x86_32/$(APP): $(GOFILES) + GOOS=linux GOARCH=386 go build -ldflags "-X main.version=$(VERSION)" -o binaries/linux_x86_32/$(APP) + +release/$(APP)_$(VERSION)_linux_arm64.zip: binaries/linux_arm64/$(APP) + mkdir -p release + cd ./binaries/linux_arm64 && zip -r -D ../../release/$(APP)_$(VERSION)_linux_arm64.zip $(APP) + +binaries/linux_arm64/$(APP): $(GOFILES) + GOOS=linux GOARCH=arm64 go build -ldflags "-X main.version=$(VERSION)" -o binaries/linux_arm64/$(APP) diff --git a/README.md b/README.md new file mode 100644 index 0000000..498a76a --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# slack-emoji-upload + +Upload custom Slack emoji from the CLI. + + + +- [Get it](#get-it) +- [Use it](#use-it) +- [Authentication](#authentication) + - [Token](#token) + - [Password](#password) +- [Example](#example) + - [Token auth](#token-auth) + - [Password auth](#password-auth) + + + + +## Get it + +- Either download the statically linked binary from [the latest release](https://github.com/sgreben/slack-emoji-upload/releases/latest) + +- ...or use `go get`: + ```sh + go get github.com/sgreben/slack-emoji-upload + ``` + +## Use it + +```text +slack-emoji-upload OPTIONS [FILES] + +Options: + -token string + Slack API token + -email string + user email (required when -token not specified) + -password string + user password (required when -token not specified) + -team string + Slack team (required when -token not specified) +``` + +## Authentication + +### Token + +To authenticate with a token (`-token` option), you need to use a `xoxs-*` Slack API token, not a regular user token. It looks something like this: + +``` +xoxs-abcdefghij-klmnopqrstuv-wxyzabcdefgh-ijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrst +``` + +### Password + +Alternatively, you can provide your `-team`, `-email` and `-password` to let the app obtain a `xoxs-*` API token for you. + +## Example + +### Token auth + +```sh +$ slack-emoji-upload -token "$MY_TOKEN" emoji/*.* +2018/09/11 11:34:53 reeeeee: uploading "/tmp/emoji/reeeeee.gif" +2018/09/11 11:34:55 reeeeee: uploaded +2018/09/11 11:34:55 yeet: uploading "/tmp/emoji/yeet.png" +2018/09/11 11:34:57 yeet: uploaded +``` + +### Password auth + +```sh +$ slack-emoji-upload -team my-team -email "me@example.com" -password "hunter2" emoji/*.* +2018/09/11 11:34:53 reeeeee: uploading "/tmp/emoji/reeeeee.gif" +2018/09/11 11:34:55 reeeeee: uploaded +2018/09/11 11:34:55 yeet: uploading "/tmp/emoji/yeet.png" +2018/09/11 11:34:57 yeet: uploaded +``` diff --git a/contrib/cron/Dockerfile b/contrib/cron/Dockerfile new file mode 100644 index 0000000..33ce59b --- /dev/null +++ b/contrib/cron/Dockerfile @@ -0,0 +1,31 @@ +FROM golang:1.11-alpine AS minicron +RUN apk add --no-cache git +ENV CGO_ENABLED 0 +RUN go get -u github.com/networkteam/minicron && \ + cp "${GOPATH}/bin/minicron" /app + +FROM golang:1.11-alpine AS build +COPY . "${GOPATH}"/src/app/ +ENV CGO_ENABLED 0 +RUN go install app && \ + cp "${GOPATH}/bin/app" /app + +FROM alpine:3.7 +RUN apk add --no-cache ca-certificates + +COPY --from=minicron /app /minicron +COPY --from=build /app /slack-emoji-upload + +VOLUME [ "/emoji" ] +RUN >/task printf "%s\n" "#!/bin/sh -eu" && \ + >>/task printf "%s\n" '/slack-emoji-upload $* /emoji/*.*' && \ + chmod +x /task +RUN >/entrypoint printf "%s\n" "#!/bin/sh -eu" && \ + >>/entrypoint printf "%s\n" 'SCHEDULE=$1' && \ + >>/entrypoint printf "%s\n" 'shift' && \ + >>/entrypoint printf "%s\n" '/minicron -v -c emoji "$SCHEDULE" "/task $*"' && \ + chmod +x /entrypoint + +# Example usage: +# docker run -v ~/Emoji:/emoji slack-emoji-upload-cron "@every 12h" -team my-team -token "$SLACK_API_TOKEN" +ENTRYPOINT [ "/entrypoint" ] diff --git a/main.go b/main.go new file mode 100644 index 0000000..0cc8d3d --- /dev/null +++ b/main.go @@ -0,0 +1,353 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "io/ioutil" + "log" + "mime/multipart" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" +) + +var config struct { + TeamName string + Email string + Password string + Token string + Quiet bool + NotifyChannel string + Loop bool +} + +var ( + cookieJar, _ = cookiejar.New(nil) + client = &http.Client{Jar: cookieJar} + everyTwoSeconds <-chan time.Time + everyThreeSeconds <-chan time.Time +) + +var ( + crumbRegex = regexp.MustCompile(`name="crumb" value="([^\"]+)"`) + apiTokenRegex = regexp.MustCompile(`api_token\s*:\s*"([^\"]+)"`) +) + +var ( + domain string + baseURL string +) + +func notificationMessageJSON(emojiNames []string) string { + buf := bytes.NewBuffer(nil) + for _, emojiName := range emojiNames { + fmt.Fprintf(buf, ":%s: ", emojiName) + } + return fmt.Sprintf(`{ "channel": %q, "text": "> 🤖 *emoji-bot*\n> %s" }`, config.NotifyChannel, buf.String()) +} + +func init() { + log.SetOutput(os.Stderr) + log.SetFlags(log.Ldate | log.Ltime) + flag.StringVar(&config.TeamName, "team", "", "Slack team (only needed when using email/password auth)") + flag.StringVar(&config.NotifyChannel, "notify-channel", "", "Notify this channel on successful uploads") + flag.StringVar(&config.Token, "token", os.Getenv("SLACK_API_TOKEN"), "Slack API token") + flag.StringVar(&config.Email, "email", "", "user email") + flag.StringVar(&config.Password, "password", "", "user password") + flag.BoolVar(&config.Quiet, "quiet", false, "suppress log output") + flag.BoolVar(&config.Loop, "loop", false, "repeatedly sync") + flag.Parse() + + if config.Quiet { + log.SetOutput(ioutil.Discard) + } + + if config.Token != "" && config.Email != "" { + config.TeamName = "api" + } + + if config.TeamName == "" { + log.Fatal("required parameter: -team") + } + + domain = fmt.Sprintf("%s.slack.com", config.TeamName) + baseURL = fmt.Sprintf("https://%s", domain) + + if config.Email != "" { + apiToken, err := obtainToken() + if err != nil { + log.Fatalf("email/password auth failed: %v", err) + } + config.Token = apiToken + log.Printf("obtained token: %q", config.Token) + } + if config.Token == "" { + log.Fatal("required parameters: -token or -email/-password") + } + + everyTwoSeconds = time.Tick(2 * time.Second) + everyThreeSeconds = time.Tick(3 * time.Second) +} + +func obtainToken() (string, error) { + // Get CSRF-protection token ("crumb") + req, err := http.NewRequest(http.MethodGet, baseURL, nil) + if err != nil { + return "", err + } + + <-everyTwoSeconds + resp, err := client.Do(req) + if err != nil { + return "", err + } + respBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + resp.Body.Close() + crumbs := crumbRegex.FindSubmatch(respBytes) + if len(crumbs) < 2 { + return "", fmt.Errorf("no crumbs") + } + crumb := string(crumbs[1]) + + // Log in and scrape an API token + form := url.Values{ + "email": {config.Email}, + "password": {config.Password}, + "crumb": {crumb}, + "signin": {"1"}, + "redir": {""}, + "has_remember": {"1"}, + } + req, err = http.NewRequest(http.MethodPost, baseURL, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + <-everyTwoSeconds + resp, err = client.Do(req) + if err != nil { + return "", err + } + respBytes, err = ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + resp.Body.Close() + apiTokens := apiTokenRegex.FindSubmatch(respBytes) + if len(apiTokens) < 2 { + return "", fmt.Errorf("could not scrape API token") + } + apiToken := string(apiTokens[1]) + + return apiToken, nil +} + +func listEmoji() (map[string]string, error) { + apiURL := fmt.Sprintf("%s/api/emoji.list", baseURL) + req, _ := http.NewRequest(http.MethodGet, apiURL, nil) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.Token)) + + <-everyThreeSeconds + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var apiResponse struct { + Ok bool `json:"ok"` + Error string `json:"error"` + Emoji map[string]string `json:"emoji"` + } + err = json.NewDecoder(resp.Body).Decode(&apiResponse) + if err != nil { + return nil, err + } + + if !apiResponse.Ok { + return nil, fmt.Errorf("%s", apiResponse.Error) + } + + return apiResponse.Emoji, nil +} + +func notifyEmojiUploaded(messageJSON string) error { + if config.NotifyChannel == "" { + return nil + } + + apiURL := fmt.Sprintf("%s/api/chat.postMessage", baseURL) + req, _ := http.NewRequest(http.MethodPost, apiURL, strings.NewReader(messageJSON)) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.Token)) + req.Header.Set("Content-Type", "application/json") + + <-everyTwoSeconds + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + var apiResponse struct { + Ok bool `json:"ok"` + Error string `json:"error"` + } + + err = json.NewDecoder(resp.Body).Decode(&apiResponse) + if err != nil { + return err + } + if !apiResponse.Ok { + return fmt.Errorf("%s", apiResponse.Error) + } + return nil +} + +func uploadEmoji(fileName, emojiName string) error { + f, err := os.Open(fileName) + if err != nil { + return err + } + defer f.Close() + + apiURL := fmt.Sprintf("%s/api/emoji.add", baseURL) + + body := bytes.NewBuffer(nil) + bodyWriter := multipart.NewWriter(body) + + bodyWriter.WriteField("mode", "data") + bodyWriter.WriteField("name", emojiName) + image, _ := bodyWriter.CreateFormFile("image", filepath.Base(fileName)) + io.Copy(image, f) + bodyWriter.WriteField("token", config.Token) + bodyWriter.Close() + + req, err := http.NewRequest(http.MethodPost, apiURL, body) + if err != nil { + return err + } + + req.Header.Set("Origin", baseURL) + req.Header.Set("Accept", "*/*") + req.Header.Set("Content-Type", bodyWriter.FormDataContentType()) + + <-everyTwoSeconds + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := ioutil.ReadAll(resp.Body) + bodyString := string(bodyBytes) + return fmt.Errorf("HTTP %d: %q", resp.StatusCode, bodyString) + } + + var apiResponse struct { + Ok bool `json:"ok"` + Error string `json:"error"` + } + + err = json.NewDecoder(resp.Body).Decode(&apiResponse) + if err != nil { + return err + } + if !apiResponse.Ok { + return fmt.Errorf("%s", apiResponse.Error) + } + return nil +} + +func main() { + files := flag.Args() + for { + uploadFilesAndPrintSummary(files) + if !config.Loop { + return + } + } +} + +func uploadFilesAndPrintSummary(files []string) { + var ( + summary = map[string][]string{} + summaryMu sync.Mutex + ) + var wg sync.WaitGroup + log.Println("fetching emoji list") + currentEmoji, err := listEmoji() + if err != nil { + log.Println(err) + return + } + + const skipKey = "skip" + const successKeyRestored = "successRestored" + const successKey = "successAdded" + + for _, filePath := range files { + ext := filepath.Ext(filePath) + emojiName := strings.TrimSuffix(filepath.Base(filePath), ext) + if _, ok := currentEmoji[emojiName]; ok { + log.Printf("%s: already exists, skipping", emojiName) + summary[skipKey] = append(summary[skipKey], emojiName) + continue + } + wg.Add(1) + go func(filePath string) { + defer wg.Done() + log.Printf("%s: uploading %q", emojiName, filePath) + err := uploadEmoji(filePath, emojiName) + if err != nil { + error := err.Error() + summaryMu.Lock() + summary[error] = append(summary[error], emojiName) + summaryMu.Unlock() + return + } + + summaryMu.Lock() + summary[successKey] = append(summary[successKey], emojiName) + summaryMu.Unlock() + }(filePath) + } + wg.Wait() + + if len(summary[successKey]) > 0 { + err = notifyEmojiUploaded(notificationMessageJSON(summary[successKey])) + if err != nil { + log.Printf("notification failed: %v", err) + } + } + + var output struct { + Emoji map[string][]string `json:",omitempty"` + Counts map[string]int + } + output.Emoji = summary + output.Counts = map[string]int{} + for k, v := range summary { + sort.Strings(v) + output.Counts[k] = len(v) + } + if len(output.Emoji) == 1 { + if _, ok := output.Emoji[skipKey]; ok { + output.Emoji = nil + } + } + json.NewEncoder(os.Stdout).Encode(output) +}