Skip to content

Commit

Permalink
feat: support clipboard file upload
Browse files Browse the repository at this point in the history
  • Loading branch information
pluveto committed Jan 21, 2023
1 parent 30597b2 commit f5f9c58
Show file tree
Hide file tree
Showing 5 changed files with 288 additions and 26 deletions.
3 changes: 3 additions & 0 deletions lib/xapp/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions lib/xgithub/xgithub.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io/ioutil"
"net/http"
"regexp"

"github.com/pluveto/upgit/lib/xapp"
)
Expand Down Expand Up @@ -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
}
84 changes: 83 additions & 1 deletion lib/xhttp/xhttp.go
Original file line number Diff line number Diff line change
@@ -1 +1,83 @@
package xhttp
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
}
74 changes: 74 additions & 0 deletions lib/xzip/xzip.go
Original file line number Diff line number Diff line change
@@ -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
}
124 changes: 99 additions & 25 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package main

import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
Expand All @@ -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"
)
Expand All @@ -51,7 +56,7 @@ func mainCommand() {
xlog.GVerbose.TraceStruct(xapp.AppCfg)

// handle clipboard if need
loadClipboard()
handleClipboard()

// validating args
validArgs()
Expand All @@ -63,7 +68,6 @@ func mainCommand() {
fmt.Scanln()
}

return
}

// loadCliOpts load cli options into xapp.AppOpt
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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
}
}

Expand Down

0 comments on commit f5f9c58

Please sign in to comment.