diff --git a/README.md b/README.md index d378c13..2e1a789 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,8 @@ # typoraUploader Typora 图片上传插件,适配 NoaHandler 对象存储网关组件(后端:Minio) + +## usage + +```powershell +./typoraUploader.exe upload $filePath +``` diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..c724e01 --- /dev/null +++ b/config.ini @@ -0,0 +1,9 @@ +# typoraUploader Settings +# Powered By Luckykeeper + +[typoraUploader] +noaHandlerAddr = "" ;API 服务端口 +token = "" ;API 服务 Token +bucket= "" ;存储桶的名称 +workflow = "" ;API 服务的任务流 +storageType = "" ;存储桶类型,如"s3" \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..48898ab --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module typoraUploader + +go 1.20 + +require ( + github.com/h2non/filetype v1.1.3 + github.com/urfave/cli/v2 v2.25.5 + gopkg.in/ini.v1 v1.67.0 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1edb652 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/urfave/cli/v2 v2.25.5 h1:d0NIAyhh5shGscroL7ek/Ya9QYQE0KNabJgiUinIQkc= +github.com/urfave/cli/v2 v2.25.5/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/typoraUploader.go b/typoraUploader.go new file mode 100644 index 0000000..22d1e18 --- /dev/null +++ b/typoraUploader.go @@ -0,0 +1,261 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "mime/multipart" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/h2non/filetype" + "github.com/urfave/cli/v2" + "gopkg.in/ini.v1" +) + +var ( + noaHandlerAddr, NoaHandlerToken, Bucket, Workflow, StorageType string +) + +func typoraUploaderCLI() { + typoraUploader := &cli.App{ + Name: "typoraUploader", + Usage: "typora 图片上传插件,上传图片到 NoaHandler 平台" + + "\nPowered By Luckykeeper " + + "\n————————————————————————————————————————" + + "\n注意:使用前需要先填写同目录下 config.ini !", + Version: "1.0.0_build20230606", + Commands: []*cli.Command{ + { + Name: "upload", + Aliases: []string{"u"}, + Usage: "上传文件到指定路径", + Action: func(cCtx *cli.Context) error { + + exeFilePath, _ := os.Executable() + workDir := exeFilePath[:strings.LastIndex(exeFilePath, "\\")] + workDir = strings.ReplaceAll(workDir, "\\", "/") + + readConfig(workDir) + // 判断 webp 依赖库 + if bool, _ := pathExists(workDir + "/libwebp/bin/cwebp.exe"); !bool { + log.Fatalln("未找到\"" + workDir + "/libwebp/bin/cwebp.exe\"\n请先下载 webp 依赖库,并放置在指定位置!") + } + // 转换上传任务流 + uploadPathList := cCtx.Args().Slice() + filePathList, deleteList := picWebpWorkflow(uploadPathList, workDir) + fileUrls := uploadToNoaHandler(filePathList) + // 删除缓存文件 + for _, file := range deleteList { + os.Remove(file) + } + // 输出结果 + for _, file := range fileUrls { + fmt.Println(file) + } + return nil + }, + }, + }, + } + + if err := typoraUploader.Run(os.Args); err != nil { + log.Fatal(err) + } +} + +// read config.ini +func readConfig(workDir string) { + configFile, err := ini.Load(workDir + "/config.ini") + if err != nil { + log.Panicln("读取配置文件 config.ini 失败,失败原因为: ", err) + os.Exit(1) + } + noaHandlerAddr = configFile.Section("typoraUploader").Key("noaHandlerAddr").String() + NoaHandlerToken = configFile.Section("typoraUploader").Key("token").String() + Bucket = configFile.Section("typoraUploader").Key("bucket").String() + Workflow = configFile.Section("typoraUploader").Key("workflow").String() + StorageType = configFile.Section("typoraUploader").Key("storageType").String() +} + +func main() { + typoraUploaderCLI() +} + +// 图片判断与转换任务流,返回图片路径及要删除的文件路径,若转换下一环节上传完应当删除转换过的文件 +func picWebpWorkflow(uploadPathList []string, workDir string) (filePathList, deleteList []string) { + for i, filePath := range uploadPathList { + // 判断文件类型,若为 webp 可直接上传 + needConvert := checkFileHeader(filePath) + if needConvert { + // 对图像进行 webp 转换 + // .\libwebp\bin\cwebp.exe -q 75 -m 6 -mt .\E2E912F7717BF4FD0BBFF615207F6CFC.jpg -o .\E2E912F7717BF4FD0BBFF615207F6CFC.webp + newFileName := workDir + "/" + strconv.Itoa(i+1) + ".webp" + // 注意参数不能用空格分开,如:-q 75 是错误的 + command := exec.Command(workDir+"/libwebp/bin/cwebp.exe", "-q", "75", "-m", "6", "-mt", filePath, "-o", newFileName) + command.Run() + + deleteList = append(deleteList, newFileName) + filePathList = append(filePathList, newFileName) + } else { + // 直接上传,且不进入删除列表 + filePathList = append(filePathList, filePath) + } + } + return +} + +// 上传任务流 +func uploadToNoaHandler(uploadList []string) (picUrls []string) { + for _, file := range uploadList { + for { + serverReturn := uploadFileToUshioNoa(file) + if serverReturn.StatusCode != 200 { + continue + } else { + picUrls = append(picUrls, serverReturn.FileUrl) + break + } + } + } + return +} + +// 检查文件类型 +func checkFileHeader(filePath string) (needConvert bool) { + // buf, _ := ioutil.ReadFile(filePath) + buf, _ := os.ReadFile(filePath) + kind, _ := filetype.Match(buf) + fileType := kind.Extension + + // 白名单 + // 图片:jpg png webp + if fileType == "jpg" || fileType == "png" { + needConvert = true + return + } else if fileType == "webp" { + needConvert = false + return + } else { + log.Fatalln("UnSupported FlieType! Or File No Exists!") + return + } +} + +// 判断文件是否存在 +func pathExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +// post 方式上传文件(单文件) +// 根据现成的封装再封一层 +func uploadFileToUshioNoa(filePath string) (serverReturn UshioNoaUploadFileResult) { + + file := []UploadFile{ + {Name: "file", Filepath: filePath}, + } + + gatewayRequestUrl := noaHandlerAddr + "uploadFile" + + reqParams := map[string]string{"token": NoaHandlerToken, + "storageType": "s3", + "bucket": Bucket, + "workFlow": Workflow} + response := PostFile(gatewayRequestUrl, reqParams, file, map[string]string{"User-Agent": "typoraUploader"}) + json.Unmarshal(response, &serverReturn) + return serverReturn +} + +type UploadFile struct { + // 表单名称 + Name string + // 文件全路径 + Filepath string +} + +// 请求客户端 +var httpClient = &http.Client{} + +func PostFile(reqUrl string, reqParams map[string]string, files []UploadFile, headers map[string]string) []byte { + return post(reqUrl, reqParams, "multipart/form-data", files, headers) +} + +func post(reqUrl string, reqParams map[string]string, contentType string, files []UploadFile, headers map[string]string) []byte { + requestBody, realContentType := getReader(reqParams, contentType, files) + httpRequest, _ := http.NewRequest("POST", reqUrl, requestBody) + // 添加请求头 + httpRequest.Header.Add("Content-Type", realContentType) + for k, v := range headers { + httpRequest.Header.Add(k, v) + } + // 发送请求 + resp, err := httpClient.Do(httpRequest) + if err != nil { + panic(err) + } + defer resp.Body.Close() + response, _ := io.ReadAll(resp.Body) + return response +} + +func getReader(reqParams map[string]string, contentType string, files []UploadFile) (io.Reader, string) { + if strings.Contains(contentType, "json") { + bytesData, _ := json.Marshal(reqParams) + return bytes.NewReader(bytesData), contentType + } else if files != nil { + body := &bytes.Buffer{} + // 文件写入 body + writer := multipart.NewWriter(body) + for _, uploadFile := range files { + file, err := os.Open(uploadFile.Filepath) + if err != nil { + panic(err) + } + part, err := writer.CreateFormFile(uploadFile.Name, filepath.Base(uploadFile.Filepath)) + if err != nil { + panic(err) + } + io.Copy(part, file) + file.Close() + } + // 其他参数列表写入 body + for k, v := range reqParams { + if err := writer.WriteField(k, v); err != nil { + panic(err) + } + } + if err := writer.Close(); err != nil { + panic(err) + } + // 上传文件需要自己专用的contentType + return body, writer.FormDataContentType() + } else { + urlValues := url.Values{} + for key, val := range reqParams { + urlValues.Set(key, val) + } + reqBody := urlValues.Encode() + return strings.NewReader(reqBody), contentType + } +} + +// 文件上传接口 - 返回 +type UshioNoaUploadFileResult struct { + StatusCode int `json:"statusCode"` // 结果码 (操作成功200,Token错误401) + StatusString string `json:"StatusString"` + FileUrl string `json:"fileUrl"` +}