From f5f9c585ef2624a9ecd19c548493906d98ea99de Mon Sep 17 00:00:00 2001 From: pluveto Date: Sat, 21 Jan 2023 20:50:21 +0800 Subject: [PATCH] feat: support clipboard file upload --- lib/xapp/shared.go | 3 + lib/xgithub/xgithub.go | 29 ++++++++++ lib/xhttp/xhttp.go | 84 +++++++++++++++++++++++++++- lib/xzip/xzip.go | 74 ++++++++++++++++++++++++ main.go | 124 ++++++++++++++++++++++++++++++++--------- 5 files changed, 288 insertions(+), 26 deletions(-) create mode 100644 lib/xzip/xzip.go diff --git a/lib/xapp/shared.go b/lib/xapp/shared.go index 105f29a..45297d8 100644 --- a/lib/xapp/shared.go +++ b/lib/xapp/shared.go @@ -15,7 +15,10 @@ import ( const UserAgent = "UPGIT/0.2" const DefaultBranch = "master" + +// case insensitive const ClipboardPlaceholder = ":clipboard" +const ClipboardFilePlaceholder = ":clipboard-file" var MaxUploadSize = int64(5 * 1024 * 1024) var ConfigFilePath string diff --git a/lib/xgithub/xgithub.go b/lib/xgithub/xgithub.go index d279a67..de287f5 100644 --- a/lib/xgithub/xgithub.go +++ b/lib/xgithub/xgithub.go @@ -5,6 +5,7 @@ import ( "fmt" "io/ioutil" "net/http" + "regexp" "github.com/pluveto/upgit/lib/xapp" ) @@ -95,3 +96,31 @@ func GetFile(repo string, branch string, path string) ([]byte, error) { } return bodyBuf, nil } + +func GetLatestReleaseDownloadUrl(repo string) (string, error) { + url := "https://api.github.com/repos/" + repo + "/releases/latest" + // logger.Trace("GET " + url) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", xapp.UserAgent) + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + bodyBuf, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + jsonStr := string(bodyBuf) + // find "browser_download_url": "{link}" + re := regexp.MustCompile(`"browser_download_url":\s*"([^"]+)"`) + matches := re.FindStringSubmatch(jsonStr) + if len(matches) < 2 { + return "", fmt.Errorf("cannot find browser_download_url in %s", jsonStr) + } + return matches[1], nil +} diff --git a/lib/xhttp/xhttp.go b/lib/xhttp/xhttp.go index 3884c29..6e3ef08 100644 --- a/lib/xhttp/xhttp.go +++ b/lib/xhttp/xhttp.go @@ -1 +1,83 @@ -package xhttp \ No newline at end of file +package xhttp + +// https://gist.github.com/cnu/026744b1e86c6d9e22313d06cba4c2e9 + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" +) + +// WriteCounter counts the number of bytes written to it. By implementing the Write method, +// it is of the io.Writer interface and we can pass this into io.TeeReader() +// Every write to this writer, will print the progress of the file write. +type WriteCounter struct { + Total uint64 +} + +func (wc *WriteCounter) Write(p []byte) (int, error) { + n := len(p) + wc.Total += uint64(n) + wc.PrintProgress() + return n, nil +} + +func humanizeBytes(bytes uint64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "kMGTPE"[exp]) +} + +// PrintProgress prints the progress of a file write +func (wc WriteCounter) PrintProgress() { + // Clear the line by using a character return to go back to the start and remove + // the remaining characters by filling it with spaces + fmt.Printf("\r%s", strings.Repeat(" ", 50)) + + // Return again and print current status of download + // We use the humanize package to print the bytes in a meaningful way (e.g. 10 MB) + fmt.Printf("\rDownloading... %s complete", humanizeBytes(wc.Total)) +} + +// DownloadFile will download a url and store it in local filepath. +// It writes to the destination file as it downloads it, without +// loading the entire file into memory. +// We pass an io.TeeReader into Copy() to report progress on the download. +func DownloadFile(url string, filepath string) error { + + // Create the file with .tmp extension, so that we won't overwrite a + // file until it's downloaded fully + out, err := os.Create(filepath) + if err != nil { + return err + } + defer out.Close() + + // Get the data + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + // Create our bytes counter and pass it to be used alongside our writer + counter := &WriteCounter{} + _, err = io.Copy(out, io.TeeReader(resp.Body, counter)) + if err != nil { + return err + } + + // The progress use the same line so print a new line once it's finished downloading + fmt.Println() + + return nil +} diff --git a/lib/xzip/xzip.go b/lib/xzip/xzip.go new file mode 100644 index 0000000..701fbd2 --- /dev/null +++ b/lib/xzip/xzip.go @@ -0,0 +1,74 @@ +package xzip + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) +// Unzip, from https://stackoverflow.com/questions/20357223/easy-way-to-unzip-file-with-golang +func Unzip(srcFile, destDir string) error { + r, err := zip.OpenReader(srcFile) + if err != nil { + return err + } + defer func() { + if err := r.Close(); err != nil { + panic(err) + } + }() + + os.MkdirAll(destDir, 0755) + + // Closure to address file descriptors issue with all the deferred .Close() methods + extractAndWriteFile := func(f *zip.File) error { + rc, err := f.Open() + if err != nil { + return err + } + defer func() { + if err := rc.Close(); err != nil { + panic(err) + } + }() + + path := filepath.Join(destDir, f.Name) + + // Check for ZipSlip (Directory traversal) + if !strings.HasPrefix(path, filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", path) + } + + if f.FileInfo().IsDir() { + os.MkdirAll(path, f.Mode()) + } else { + os.MkdirAll(filepath.Dir(path), f.Mode()) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + defer func() { + if err := f.Close(); err != nil { + panic(err) + } + }() + + _, err = io.Copy(f, rc) + if err != nil { + return err + } + } + return nil + } + + for _, f := range r.File { + err := extractAndWriteFile(f) + if err != nil { + return err + } + } + + return nil +} diff --git a/main.go b/main.go index e4164cc..831ed7f 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,13 @@ package main import ( + "encoding/json" "errors" "fmt" "io/fs" "io/ioutil" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -22,11 +24,14 @@ import ( "github.com/pluveto/upgit/lib/xapp" "github.com/pluveto/upgit/lib/xclipboard" "github.com/pluveto/upgit/lib/xext" + "github.com/pluveto/upgit/lib/xgithub" + "github.com/pluveto/upgit/lib/xhttp" "github.com/pluveto/upgit/lib/xio" "github.com/pluveto/upgit/lib/xlog" "github.com/pluveto/upgit/lib/xmap" "github.com/pluveto/upgit/lib/xpath" "github.com/pluveto/upgit/lib/xstrings" + "github.com/pluveto/upgit/lib/xzip" "golang.design/x/clipboard" "gopkg.in/validator.v2" ) @@ -51,7 +56,7 @@ func mainCommand() { xlog.GVerbose.TraceStruct(xapp.AppCfg) // handle clipboard if need - loadClipboard() + handleClipboard() // validating args validArgs() @@ -63,7 +68,6 @@ func mainCommand() { fmt.Scanln() } - return } // loadCliOpts load cli options into xapp.AppOpt @@ -195,10 +199,10 @@ func loadConfig(cfg *xapp.Config) { appDir := xpath.MustGetApplicationPath("") var configFiles = map[string]bool{ - filepath.Join(homeDir, ".upgit.config.toml"): false, + filepath.Join(homeDir, ".upgit.config.toml"): false, filepath.Join(homeDir, filepath.Join(".config", "upgitrc")): false, - filepath.Join(appDir, "config.toml"): false, - filepath.Join(appDir, "upgit.toml"): false, + filepath.Join(appDir, "config.toml"): false, + filepath.Join(appDir, "upgit.toml"): false, } if xapp.AppOpt.ConfigFile != "" { @@ -359,34 +363,104 @@ func dispatchUploader() { xlog.AbortErr(errors.New("unknown uploader: " + uploaderId)) } UploadAll(uploader, xapp.AppOpt.LocalPaths, xapp.AppOpt.TargetDir) - return } -func loadClipboard() { - if len(xapp.AppOpt.LocalPaths) == 1 && strings.ToLower(xapp.AppOpt.LocalPaths[0]) == xapp.ClipboardPlaceholder { - err := clipboard.Init() - if err != nil { - xlog.AbortErr(fmt.Errorf("failed to init clipboard: " + err.Error())) - } +func handleClipboard() { + if len(xapp.AppOpt.LocalPaths) == 1 { + label := strings.ToLower(xapp.AppOpt.LocalPaths[0]) + if label == xapp.ClipboardPlaceholder { + err := clipboard.Init() + if err != nil { + xlog.AbortErr(fmt.Errorf("failed to init clipboard: " + err.Error())) + } - tmpFileName := fmt.Sprint(os.TempDir(), "/upgit_tmp_", time.Now().UnixMicro(), ".png") - buf := clipboard.Read(clipboard.FmtImage) - if nil == buf { - // try second chance for Windows user. To adapt bitmap format (compatible with Snipaste) - if runtime.GOOS == "windows" { - buf, err = xclipboard.ReadClipboardImage() + tmpFileName := fmt.Sprint(os.TempDir(), "/upgit_tmp_", time.Now().UnixMicro(), ".png") + buf := clipboard.Read(clipboard.FmtImage) + if nil == buf { + // try second chance for Windows user. To adapt bitmap format (compatible with Snipaste) + if runtime.GOOS == "windows" { + buf, err = xclipboard.ReadClipboardImage() + } + if err != nil { + xlog.GVerbose.Error("failed to read clipboard image: " + err.Error()) + } } - if err != nil { - xlog.GVerbose.Error("failed to read clipboard image: " + err.Error()) + if nil == buf { + xlog.AbortErr(fmt.Errorf("failed: no image in clipboard or unsupported format")) } + os.WriteFile(tmpFileName, buf, os.FileMode(fs.ModePerm)) + xapp.AppOpt.LocalPaths[0] = tmpFileName + xapp.AppOpt.Clean = true } - if nil == buf { - xlog.AbortErr(fmt.Errorf("failed: no image in clipboard or unsupported format")) + if strings.HasPrefix(label, xapp.ClipboardFilePlaceholder) { + // Must be Windows + if runtime.GOOS != "windows" { + xlog.AbortErr(fmt.Errorf("failed: clipboard file only supported on Windows")) + } + // Download latest https://github.com/pluveto/APIProxy-Win32/releases + // and put it in same directory with upgit.exe + download := func() { + downloadUrl, err := xgithub.GetLatestReleaseDownloadUrl("pluveto/APIProxy-Win32") + xlog.AbortErr(err) + xlog.GVerbose.Trace("download url: %s", downloadUrl) + saveName := xpath.MustGetApplicationPath("/apiproxy-win32.zip") + xlog.AbortErr(xhttp.DownloadFile(downloadUrl, saveName)) + // Unzip + xlog.AbortErr(xzip.Unzip(saveName, xpath.MustGetApplicationPath("/"))) + // Clean downloaded zip + xlog.AbortErr(os.Remove(saveName)) + } + // Run + executable := xpath.MustGetApplicationPath("APIProxy.exe") + if _, err := os.Stat(executable); os.IsNotExist(err) { + println("APIProxy not found, downloading...") + download() + } + execArgs := []string{"clipboard", "GetFilePaths"} + cmd := exec.Command(executable) + cmd.Args = append(cmd.Args, execArgs...) + // Wait and fetch cmdOutput + cmdOutput, err := cmd.Output() + if err != nil { + xlog.AbortErr(fmt.Errorf("failed to run APIProxy: %s, stderr: %s", err.Error(), cmdOutput)) + } + parseOutput := func(output string) []string { + /* + Type: ApplicationError + Msg: No handler name specified + HMsg: + Data: null + */ + lines := strings.Split(output, "\n") + for i, line := range lines { + lines[i] = strings.TrimSpace(line) + if len(lines[i]) == 0 { + lines = append(lines[:i], lines[i+1:]...) + } + } + var result []string + if len(lines) != 4 { + xlog.AbortErr(errors.New("unable to parse APIProxy output, unexpected line count. output: " + output)) + return result + } + if !strings.HasPrefix(lines[0], "Type: Success") { + xlog.AbortErr(errors.New("got error from APIProxy output: " + output)) + return result + } + // Parse data + jsonStr := lines[3][len("Data: "):] + var paths []string + xlog.AbortErr(json.Unmarshal([]byte(jsonStr), &paths)) + return paths + } + xlog.GVerbose.Trace("stdout: %s", cmdOutput) + paths := parseOutput(string(cmdOutput)) + if len(paths) == 0 { + xlog.AbortErr(errors.New("no file in clipboard")) + } + xapp.AppOpt.LocalPaths = paths } - os.WriteFile(tmpFileName, buf, os.FileMode(fs.ModePerm)) - xapp.AppOpt.LocalPaths[0] = tmpFileName - xapp.AppOpt.Clean = true } }