├── .github └── workflows │ ├── changelog.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── config.sample.toml ├── config.sample.zh-CN.toml ├── docs └── README.zh-CN.md ├── ext_cmd.go ├── extensions ├── catbox.jsonc ├── chevereto.jsonc ├── cloudinary.jsonc ├── dalexni.jsonc ├── easyimage.jsonc ├── gitee.jsonc ├── hello.jsonc ├── imgbb.jsonc ├── imgtg.jsonc ├── imgur.jsonc ├── imgurlorg.jsonc ├── juejin.jsonc ├── lskypro.jsonc ├── lskypro2.jsonc ├── moetu.jsonc ├── netease.jsonc ├── niupic.jsonc ├── qiniu.jsonc ├── smms.jsonc ├── sougou.jsonc └── upload_cc.jsonc ├── go.mod ├── go.sum ├── lib ├── aliyunoss │ └── uploader.go ├── model │ ├── task.go │ └── uploader.go ├── qcloudcos │ ├── signature.go │ ├── transport.go │ └── uploader.go ├── result │ └── result.go ├── s3 │ └── s3_uploader.go ├── uploaders │ ├── github_upload.go │ └── simple_http_uploader.go ├── upyun │ ├── sdk.go │ └── uploader.go ├── xapp │ ├── cfg.go │ ├── cli.go │ ├── shared.go │ └── upload.go ├── xclipboard │ ├── clipboard_darwin.go │ ├── clipboard_linux.go │ ├── clipboard_windows.go │ └── clipboard_windows_test.go ├── xext │ └── xext.go ├── xgithub │ └── xgithub.go ├── xhttp │ └── xhttp.go ├── xio │ └── xio.go ├── xlog │ ├── verbose.go │ └── xlog.go ├── xmap │ └── xmap.go ├── xnetwork │ └── xnetwork.go ├── xpath │ └── xpath.go ├── xstrings │ ├── xstrings.go │ └── xstrings_test.go └── xzip │ └── xzip.go ├── logo.png └── main.go /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: Changelog 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | # Fetch depth 0 is required for Changelog generation 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: Create changelog text 17 | id: changelog 18 | uses: loopwerk/tag-changelog@v1 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | 22 | - name: Print changelog 23 | run: | 24 | cat </dev/null 2>&1; then \ 31 | echo "Using musl compiler"; \ 32 | export CC=x86_64-linux-musl-gcc; \ 33 | export CXX=x86_64-linux-musl-g++; \ 34 | else \ 35 | echo "musl compiler not found. Using gnu compiler"; \ 36 | export CC=gcc; \ 37 | export CXX=g++; \ 38 | fi && \ 39 | GOOS=linux GOARCH=amd64 CGO_ENABLED=1 CC=$$CC CXX=$$CXX go build -o $(BINARY)_cgo_linux_amd64 $(LDFLAGS) $(SRC) 40 | 41 | GOOS=linux GOARCH=386 go build -o $(BINARY)_linux_386 $(LDFLAGS) $(SRC) 42 | GOOS=linux GOARCH=amd64 go build -o $(BINARY)_linux_amd64 $(LDFLAGS) $(SRC) 43 | GOOS=linux GOARCH=arm go build -o $(BINARY)_linux_arm $(LDFLAGS) $(SRC) 44 | GOOS=linux GOARCH=arm64 go build -o $(BINARY)_linux_arm64 $(LDFLAGS) $(SRC) 45 | 46 | upx: all 47 | for i in $(DIST_DIR)/* 48 | do 49 | upx -9 $i 50 | done 51 | 52 | all: windows macos linux 53 | @echo "done." 54 | 55 | clean: 56 | rm -rfd $(DIST_DIR) 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![upgit](https://cdn.jsdelivr.net/gh/pluveto/upgit/logo.png) 2 | 3 | 4 | 5 | 6 | 7 | **Languages**: English / [简体中文](docs/README.zh-CN.md) 8 | 9 | *Upgit* is a native & lightweight tool to helps you upload any file to your Github repository and then get a raw URL for it. 10 | 11 | This is also useful with [Typora](https://support.typora.io/Upload-Image/#image-uploaders) as an image uploader. 12 | 13 | ## Feature 14 | 15 | + Integrate with VSCode via [extension](https://github.com/pluveto/upgit-vscode-extension) 16 | + Support for Linux, Windows and macOS 17 | + Upload any file to given remote github repo folder 18 | + Upload from **clipboard** 19 | + Custom auto **renaming** rules 20 | + **CDN** via replacing rules 21 | + Config via **Environment Variable** 22 | + Output URL to stdout/clipboard, supports markdown image format 23 | 24 | ### Supported Upload Extensions 25 | 26 | + Github 27 | + S3 Compatible Storages 28 | 29 | + AWS S3 30 | + MinIO 31 | + Cloudflare R2 32 | + Ceph 33 | + Backblaze 34 | + Flexify.IO 35 | + IBM Cloud Object Storage 36 | + DigitalOcean Spaces 37 | + Wasabi 38 | + Gitee 39 | + Tencent QcloudCOS 40 | + Qiniu Kodo 41 | + Upyun 42 | + Hello 43 | + Niupic 44 | + SM.MS 45 | + Imgur 46 | + ImgUrl.org 47 | + CatBox 48 | + LSkyPro 49 | + Chevereto 50 | + ImgBB 51 | + Cloudinary 52 | + EasyImage 53 | + DALEXNI 54 | + AliyunOSS 55 | 56 | More: `./upgit ext ls` 57 | 58 | ## Get started 59 | 60 | ### Download 61 | 62 | Download it from [Release](https://github.com/pluveto/upgit/releases). 63 | 64 | > If you have no idea which to download: 65 | > 66 | > + For most Windows users, choose `upgit_win_amd64.exe` 67 | > + For most macOS users, choose `upgit_macos_arm64` 68 | > + Execute `chmod +x upgit` if permission is needed 69 | 70 | Rename it to `upgit` (For Windows users, `upgit.exe`), save it to somewhere you like. To access it from anywhere, add its directory to the `PATH` environment variable. 71 | 72 | **Warning:** this program doesn't contain an auto-updater. If you need to keep updated, just give *upgit* a ⭐star. 73 | 74 | ### Config 75 | 76 | Create `config.toml` in the same directory of *upgit*, and fill it in following [this sample config file](https://github.com/pluveto/upgit/blob/main/config.sample.toml). 77 | 78 | ### Use it 79 | 80 | To upload file `logo.png` with rename rules, execute: 81 | 82 | ```shell 83 | ./upgit logo.png 84 | # for windows: .\upgit.exe logo.png 85 | ``` 86 | 87 | Then you'll see a link to `logo.png`. 88 | 89 | To upload file `logo.png` to remote folder `/my_images/demo`, execute: 90 | 91 | ```shell 92 | ./upgit logo.png -t /my_images/demo 93 | # for Windows: .\upgit.exe logo.png -t /my_images/demo 94 | ``` 95 | 96 | --- 97 | 98 | For more help, type `-h` argument 99 | 100 | ``` 101 | 102 | Upload anything to github repo or other remote storages and then get its link. 103 | For more information: https://github.com/pluveto/upgit 104 | 105 | Usage: upgit [--target-dir TARGET-DIR] [--verbose] [--size-limit SIZE-LIMIT] [--wait] [--config-file CONFIG-FILE] [--clean] [--raw] [--no-log] [--uploader UPLOADER] [--output-type OUTPUT-TYPE] [--output-format OUTPUT-FORMAT] [--application-path APPLICATION-PATH] FILE [FILE ...] 106 | 107 | Positional arguments: 108 | FILE local file path to upload. :clipboard for uploading clipboard image 109 | 110 | Options: 111 | --target-dir TARGET-DIR, -t TARGET-DIR 112 | upload file with original name to given directory. if not set, will use renaming rules 113 | --verbose, -V when set, output more details to help developers 114 | --size-limit SIZE-LIMIT, -s SIZE-LIMIT 115 | in bytes. overwrite default size limit (5MiB). 0 means no limit 116 | --wait, -w when set, not exit after upload, util user press any key 117 | --config-file CONFIG-FILE, -c CONFIG-FILE 118 | when set, will use specific config file 119 | --clean, -C when set, remove local file after upload 120 | --raw, -r when set, output non-replaced raw url 121 | --no-log, -n when set, disable logging 122 | --uploader UPLOADER, -u UPLOADER 123 | uploader to use. if not set, will follow config 124 | --output-type OUTPUT-TYPE, -o OUTPUT-TYPE 125 | output type, supports stdout, clipboard [default: stdout] 126 | --output-format OUTPUT-FORMAT, -f OUTPUT-FORMAT 127 | output format, supports url, markdown and your customs [default: url] 128 | --application-path APPLICATION-PATH 129 | custom application path, which determines config file path and extensions dir path. current binary dir by default 130 | --help, -h display this help and exit 131 | 132 | Manage extensions: 133 | upgit ext ACTION 134 | 135 | Actions: 136 | ls list all downloadable extensions 137 | my list all local extensions 138 | add smms.jsonc install SMMS uploader 139 | remove smms.jsonc remove SMMS uploader 140 | ``` 141 | 142 | ### Use it for Typora 143 | 144 | > Assuming your *upgit* program is saved at `"C:\repo\upgit\upgit.exe"`. 145 | 146 | Select *File > Preferences...* 147 | 148 | ![image-20220128204217802](https://cdn.jsdelivr.net/gh/pluveto/0images@master/2022/01/upgit_20220128_1643373863.png) 149 | 150 | Move to *Image*. Choose *Custom Command* as your *Image Uploader*. 151 | 152 | Input *upgit* program location into *Command* textbox. 153 | 154 | > You can click *Test Uploader* button to make sure it works. 155 | 156 | ![image-20220128204418723](https://cdn.jsdelivr.net/gh/pluveto/0images@master/2022/01/upgit_20220128_1643373868.png) 157 | 158 | Now enjoy it! 159 | 160 | ### Upload Clipboard Image 161 | 162 | Use `:clipboard` place holder for clipboard image. (Only supports **png** format) 163 | 164 | ```shell 165 | ./upgit :clipboard 166 | ``` 167 | 168 | Shortcuts for screenshot: 169 | 170 | + On macOS, use `Ctrl+Shift+Cmd+4` 171 | + On Linux/Ubuntu, use `Ctrl+Shift+PrintScreen` 172 | + On Windows, use `Shift+Win+s` 173 | 174 | ### Upload Clipboard Files 175 | 176 | **Note:** This feature is only supported on Windows. 177 | 178 | Use `:clipboard-files` or `:clipboard-file` place holder for clipboard files. Both will upload all files in clipboard. 179 | 180 | ```shell 181 | ./upgit :clipboard-files 182 | ``` 183 | 184 | Because golang doesn't support clipboard file list, so *upgit* will use [APIProxy-Win32](https://github.com/pluveto/APIProxy-Win32) to get clipboard file list. It will be downloaded automatically when you first use this feature. 185 | 186 | ### Save URL to Clipboard 187 | 188 | Use `--output-type clipboard`: 189 | 190 | ```shell 191 | ./upgit logo.png --output-type clipboard 192 | # or .\upgit.exe :clipboard -o clipboard 193 | ``` 194 | 195 | #### Copy as Markdown format 196 | 197 | Add argument `--output-format markdown`: 198 | 199 | ```shell 200 | ./upgit logo.png --output-type clipboard --output-format markdown 201 | # or .\upgit.exe :clipboard -o clipboard -f markdown 202 | ``` 203 | 204 | Then you'll get a markdown image link in your clipboard like: 205 | 206 | ``` 207 | ![logo.png](!https://cdn.jsdelivr.net/gh/pluveto/upgit/logo.png) 208 | ``` 209 | 210 | ### Best practice with AHK 211 | 212 | For Windows user: 213 | 214 | 1. Install AHK 215 | 216 | 2. Create this script `upload_clipboard.ahk` and run: 217 | 218 | ```ahk 219 | ; Press Ctrl + F9 to upload clipboard image 220 | ^F9:: 221 | RunWait, "upgit.exe" :clipboard --output-type clipboard --output-format markdown 222 | return 223 | ``` 224 | 225 | 3. Then press WinShiftS to take screenshot. CtrlF9 to upload it and get its link to your clipboard! 226 | 227 | **Compatible with Snipaste** 228 | 229 | (Windows Only, from v0.1.5) We recently added support for Snipaste bitmap format. Just copy screenshot and upload! 230 | 231 | ## Config Instructions 232 | 233 | | Key | Desc | 234 | | --------------------- | ------------------------------------------------------------ | 235 | | username | Your Github username, like `pluveto` | 236 | | repo | Your Github repository name, like `upgit` | 237 | | branch | The branch for saving files, like `master` or `main` | 238 | | pat | Personal Access Token. Visit [GitHub Docs](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) for more info | 239 | | rename | Renaming rule. Path separator `/` will create directories if not exists. Supporting: | 240 | | -- `{year}` | -- Year like `2006` | 241 | | -- `{month}` | -- Month like `01` | 242 | | -- `{day}` | -- Day like `02` | 243 | | -- `{hour}` | -- Hours of current time | 244 | | -- `{minute}` | -- Minutes of current time | 245 | | -- `{second}` | -- Seconds of current time | 246 | | -- `{unix_ts}` | -- Unix timestamp in second. Like `1643373370`. | 247 | | -- `{unix_tsms}` | -- Unix timestamp in microsecond. Like `1644212979622`. | 248 | | --- `{ext}` | -- Extension like `.png`, and empty when the original file has no extension | 249 | | -- `{fname}` | -- Original file base name like `demo` (without extension) | 250 | | -- `{fname_hash}` | -- MD5 Hash in hex of `{fname}` | 251 | | -- `{fname_hash4}` | -- MD5 Hash in hex of `{fname}`, first 4 digits | 252 | | -- `{fname_hash8}` | -- MD5 Hash in hex of `{fname}`, first 8 digits | 253 | 254 | Here is a simplist sample config file: 255 | 256 | ```toml 257 | rename = "{year}/{month}/upgit_{year}{month}{day}_{unix_ts}{ext}" 258 | [uploaders.github] 259 | pat = "ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" 260 | repo = "repo-name" 261 | username = "username" 262 | ``` 263 | 264 | ### Config via Environment Variables 265 | 266 | + `UPGIT_TOKEN` 267 | + `UPGIT_RENAME` 268 | + `UPGIT_USERNAME` 269 | + `UPGIT_REPO` 270 | + `UPGIT_BRANCH` 271 | 272 | ### Custome output format 273 | 274 | In follwing way: 275 | 276 | ```toml 277 | [output_formats] 278 | "bbcode" = "[img]{url}[/img]" 279 | "html" = '' 280 | "markdown-simple" = "![]({url})" 281 | ``` 282 | 283 | Placeholder: 284 | 285 | + `{url}`: URL to image 286 | + `{fname}`: Original file basename 287 | + `{url_fname}`: File basename from url 288 | 289 | Example usage: 290 | 291 | ``` 292 | # Upload clipboard and save link to clipboard as bbcode format 293 | upgit :clipboard -o clipboard -f bbcode 294 | ``` 295 | 296 | ## Todo 297 | 298 | + [x] Upload to specific folder 299 | + [x] Upload and get raw URL that is not replaced. 300 | + [x] Upload clipboard image 301 | + [x] Save uploaded image link to clipboard 302 | + [ ] Upload from link 303 | + [x] Ignore uploaded file (link input) 304 | + [x] Upload history 305 | -------------------------------------------------------------------------------- /config.sample.toml: -------------------------------------------------------------------------------- 1 | # ============================================================================= 2 | # UPGIT Config 3 | # ============================================================================= 4 | 5 | # default uploader id 6 | default_uploader = "github" 7 | 8 | # The file name formatting template is applied when uploading 9 | # / is directory separator used to distinguish directories 10 | # {year} Year, for example: 2022 11 | # {month} Month, for example: 02 12 | # {day} Day, for example: 01 13 | # {unix_ts} Time stamp, for example: 1643617626. If you're uploading frequently, try {unix_tsms} to escape name repeating 14 | # {fname} Original file name, such as logo (without suffix) 15 | # {fname_hash} MD5 hash value of {fname} 16 | # {ext} File name suffix, for example: .png 17 | # The following example generates a file name like: 2022/01/upgit_20220131_1643617626.png 18 | rename = "{year}/{month}/upgit_{year}{month}{day}_{unix_ts}{ext}" 19 | 20 | # ----------------------------------------------------------------------------- 21 | # Custom extra output formats 22 | # ----------------------------------------------------------------------------- 23 | # {url} direct URL of the file 24 | [output_formats] 25 | "bbcode" = "[img]{url}[/img]" 26 | "html" = '' 27 | "markdown-simple" = "![]({url})" 28 | 29 | # ----------------------------------------------------------------------------- 30 | # URL replacing rules. Process: RawUrl -[replace]-> Url 31 | # ----------------------------------------------------------------------------- 32 | 33 | # If your network access to Github is abnormal or sluggish, you can try the following CDN acceleration. 34 | # [replacements] 35 | # "raw.githubusercontent.com" = "cdn.jsdelivr.net/gh" 36 | # "/master" = "@master" 37 | 38 | # ============================================================================= 39 | # Configurations examples for some uploaders, leave them blank if not used 40 | # ============================================================================= 41 | 42 | # Github uploader 43 | [uploaders.github] 44 | # Branch to save files, for example master or main 45 | branch = "master" 46 | 47 | # "pat" enter the Github token that has the "repo" permission 48 | # Get token from https://github.com/settings/tokens 49 | pat = "ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" 50 | 51 | # The name of your public Github repository 52 | # Attention: In order for you and others to access image resources, your Github repository must be public. 53 | # In private repositories Github blocks unauthorized requests and you will get a 404. 54 | repo = "repo-name" 55 | 56 | # your Github username 57 | username = "username" 58 | 59 | # SMMS Uploader 60 | [uploaders.smms] 61 | # Get token from https://sm.ms/home/apitoken 62 | token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 63 | 64 | # Imgur Uploader 65 | [uploaders.imgur] 66 | # Get token from https://api.imgur.com/oauth2/addclient 67 | # See your apps in https://imgur.com/account/settings/apps 68 | client_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 69 | 70 | # Chevereto Uploader 71 | [uploaders.chevereto] 72 | key = "c8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8" 73 | upload_url = "https://chevereto.com/api/v1/upload" 74 | 75 | # Qcloudcos Uploader 76 | [uploaders.qcloudcos] 77 | host = "xxx.cos.ap-chengdu.myqcloud.com" 78 | secret_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 79 | secret_key= "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 80 | 81 | # Qiniu cloud 82 | [uploaders.qiniu] 83 | # Generate Token: http://jsfiddle.net/gh/get/extjs/4.2/icattlecoder/jsfiddle/tree/master/uptoken 84 | token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==" 85 | prefix = "https://cdn.mydomain.com/" 86 | 87 | # Gitee 88 | [uploaders.gitee] 89 | username = "username" 90 | repo = "repo-name" 91 | # https://gitee.com/profile/personal_access_tokens/new 92 | access_token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 93 | 94 | [uploaders.cloudinary] 95 | cloud_name = "my_cloud" 96 | upload_preset = "preset_name" 97 | 98 | [uploaders.easyimage] 99 | request_url = "https://img.545141.com/api/index.php" 100 | token = "1c17b11693cb5ec63859b0ccccccccccc" 101 | 102 | # S3 Uploader 103 | [uploaders.s3] 104 | region = "us-west-2" 105 | bucket_name = "my-bucket" 106 | access_key = "your-access-key" 107 | secret_key = "your-secret-key" 108 | endpoint = "https://s3.us-west-2.amazonaws.com" 109 | url_format = "{endpoint}/{bucket}/{path}" 110 | 111 | # AliyunOSS Uploader 112 | [uploaders.aliyunoss] 113 | endpoint = "https://oss-cn-shanghai.aliyuncs.com" 114 | access_key_id = "your-access-key-id" 115 | access_key_secret = "your-access-key-secret" 116 | bucket_name = "your-bucket-name" 117 | host = "https://cdn.example.com" 118 | -------------------------------------------------------------------------------- /config.sample.zh-CN.toml: -------------------------------------------------------------------------------- 1 | # ============================================================================= 2 | # UPGIT 配置 3 | # ============================================================================= 4 | 5 | # 默认上传器 6 | default_uploader = "github" 7 | 8 | # 上传文件名的格式模板(仅特定上传器适配) 9 | # / 目录分隔符, 作用: 是区分目录 10 | # {year} 年份, 例如: 2022 11 | # {month} 月份, 例如: 02 12 | # {day} 天, 例如: 01 13 | # {unix_ts} 时间戳, 例如: 1643617626 14 | # {fname} 原始文件名,如 logo (不含后缀名) 15 | # {fname_hash} {fname}的 MD5 散列值 16 | # {ext} 文件后缀名, 例如.png 17 | # 下面的例子生成的文件名预览: 2022/01/upgit_20220131_1643617626.png 18 | # 如果目录不存在将会被程序自动创建 19 | rename = "{year}/{month}/upgit_{year}{month}{day}_{unix_ts}{ext}" 20 | 21 | 22 | # ----------------------------------------------------------------------------- 23 | # 自定义输出格式 24 | # ----------------------------------------------------------------------------- 25 | # {url} 图片文件的网络URL地址 26 | [output_formats] 27 | "bbcode" = "[img]{url}[/img]" 28 | "html" = '' 29 | "markdown-simple" = "![]({url})" 30 | 31 | # ----------------------------------------------------------------------------- 32 | # 直链替换规则 RawUrl -[replace]-> Url 33 | # ----------------------------------------------------------------------------- 34 | 35 | # 如果您的网络访问Github异常或者缓慢,您可以尝试下面的配置以开启CDN加速 36 | # [replacements] 37 | # "raw.githubusercontent.com" = "cdn.jsdelivr.net/gh" 38 | # "/master" = "@master" 39 | 40 | # ============================================================================= 41 | # 以下为各个上传器的配置示例. 用不到的留空即可 42 | # ============================================================================= 43 | 44 | # Github 上传器 45 | [uploaders.github] 46 | # 保存文件的分支,例如 master 或 main 47 | branch = "master" 48 | 49 | # 您的拥有"repo"权限的 Github 令牌 50 | # 获取Github Token连接: https://github.com/settings/tokens 51 | pat = "ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" 52 | 53 | # 您的公共Github存储库的名称 54 | # 注意: 为了让您和他人可以访问到图片资源, 您的Github仓库一定要是公开的, 55 | # 在私有仓库中Github会拦截未授权的请求,你将会得到一个404. 56 | repo = "repo-name" 57 | 58 | # 您的 Gtihub 用户名 59 | username = "username" 60 | 61 | # SMMS 上传器 62 | [uploaders.smms] 63 | # Get token from https://sm.ms/home/apitoken 64 | token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 65 | 66 | # Imgur 上传器 67 | [uploaders.imgur] 68 | # Get token from https://api.imgur.com/oauth2/addclient 69 | # See your apps in https://imgur.com/account/settings/apps 70 | client_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 71 | 72 | # Chevereto Uploader 73 | [uploaders.chevereto] 74 | upload_url = "https://chevereto.com/api/v1/upload" 75 | key = "c8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8" 76 | 77 | # 腾讯云 COS 78 | [uploaders.qcloudcos] 79 | host = "xxx.cos.ap-chengdu.myqcloud.com" 80 | secret_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 81 | secret_key= "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 82 | 83 | # 七牛云存储 84 | [uploaders.qiniu] 85 | # Generate Token: http://jsfiddle.net/gh/get/extjs/4.2/icattlecoder/jsfiddle/tree/master/uptoken 86 | token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==" 87 | prefix = "https://cdn.mydomain.com/" 88 | 89 | # Gitee 90 | [uploaders.gitee] 91 | username = "username" 92 | repo = "repo-name" 93 | # https://gitee.com/profile/personal_access_tokens/new 94 | access_token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 95 | 96 | [uploaders.cloudinary] 97 | cloud_name = "my_cloud" 98 | upload_preset = "preset_name" 99 | 100 | [uploaders.easyimage] 101 | request_url = "https://img.545141.com/api/index.php" 102 | token = "1c17b11693cb5ec63859b0ccccccccccc" 103 | 104 | [uploaders.lskypro2] 105 | host = "my_images_host" 106 | token = "images_host_token" 107 | 108 | [uploaders.s3] 109 | region = "us-west-2" 110 | bucket_name = "my-bucket" 111 | access_key = "your-access-key" 112 | secret_key = "your-secret-key" 113 | endpoint = "https://s3.us-west-2.amazonaws.com" 114 | url_format = "{endpoint}/{bucket}/{path}" 115 | 116 | # 阿里云 OSS 117 | [uploaders.aliyunoss] 118 | endpoint = "https://oss-cn-shanghai.aliyuncs.com" 119 | access_key_id = "your-access-key-id" 120 | access_key_secret = "your-access-key-secret" 121 | bucket_name = "your-bucket-name" 122 | host = "https://cdn.example.com" 123 | -------------------------------------------------------------------------------- /docs/README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # ![upgit](https://cdn.jsdelivr.net/gh/pluveto/upgit/logo.png) 2 | 3 | *Upgit* 可以快捷地将文件上传到 Github 仓库并得到其直链。简洁跨平台,不常驻内存。 4 | 5 | 可作为 [Typora](https://support.typora.io/Upload-Image/#image-uploaders) 的自定义上传器使用。 6 | 7 | **太长不看**:本程序用于快速上传。配合 AHK 可以帮助你一键完成截图、上传、复制链接的操作。 8 | 9 | ## 特点 10 | 11 | + 支持多平台,包括 Linux、Windows 和 macOS 12 | + 支持**多种上传器**,目前包括 Github 和 SMMS 13 | + 不限制文件类型 14 | + 支持从**剪贴板上传** 15 | + 自定义**自动重命名**规则(包括路径) 16 | + 可通过替换规则实现**CDN**加速 17 | + 可通过**环境变量**配置 18 | + 将 URL 输出到标准输出/**剪贴板**,支持 Markdown 格式 19 | 20 | ### 上传扩展 21 | 22 | + Github 23 | + Gitee 24 | + 腾讯云 COS 25 | + 七牛云 Kodo 26 | + 又拍云 27 | + Hello 28 | + Niupic 29 | + SM.MS 30 | + Imgur 31 | + ImgUrl.org 32 | + CatBox 33 | + LSkyPro 34 | + Chevereto 35 | + ImgBB 36 | + Cloudinary 37 | + EasyImage 38 | + DALEXNI 39 | 40 | 查看更多: `./upgit ext ls` 41 | 42 | ## 开始使用 43 | 44 | ### 下载 45 | 46 | 从[Release](https://github.com/pluveto/upgit/releases) 下载. 47 | 48 | >如果不知道下载哪一个: 49 | > 50 | > + 对于大多数 Windows用户,请选择 `upgit_win_amd64.exe` 51 | > + 对于大多数 macOS用户,请选择 `upgit_macOS_arm64` 52 | 53 | 下载后将其重命名为`upgit`(对于Windows用户,`upgit.exe`),保存到某处。若要从任何地方访问它,请将其目录添加到 `PATH` 环境变量中。 54 | 55 | **提醒:** 此程序不会自动检查更新。如果你关心本程序的新功能,可以点右上角的 ⭐star 收藏。 56 | 57 | ### 配置 58 | 59 | 在程序的同一目录创建 `config.toml` 文件,内容按照[此示例配置文件](https://github.com/pluveto/upgit/blob/main/config.sample.zh-CN.toml) 填写即可. 60 | 61 | ### 使用 62 | 63 | 比如上传 `logo.png` 并自动使用重命名规则,执行: 64 | 65 | ```shell 66 | ./upgit logo.png 67 | # for windows: .\upgit.exe logo.png 68 | ``` 69 | 70 | 然后会看到一个指向 `logo.png` 的直链。 71 | 72 | 比如上传 `logo.png` 到远程文件夹 `/my_images/demo`,执行: 73 | 74 | ```shell 75 | ./upgit logo.png -t /my_images/demo 76 | # 对于 Windows: .\upgit.exe logo.png -t /my_images/demo 77 | ``` 78 | 79 | 有关更多帮助,请键入“-h”参数 80 | 81 | ```shell 82 | 83 | Upload anything to github repo or other remote storages and then get its link. 84 | For more information: https://github.com/pluveto/upgit 85 | 86 | Usage: upgit [--target-dir TARGET-DIR] [--verbose] [--size-limit SIZE-LIMIT] [--wait] [--config-file CONFIG-FILE] [--clean] [--raw] [--no-log] [--uploader UPLOADER] [--output-type OUTPUT-TYPE] [--output-format OUTPUT-FORMAT] [--application-path APPLICATION-PATH] FILE [FILE ...] 87 | 88 | Positional arguments: 89 | FILE local file path to upload. :clipboard for uploading clipboard image 90 | 91 | Options: 92 | --target-dir TARGET-DIR, -t TARGET-DIR 93 | upload file with original name to given directory. if not set, will use renaming rules 94 | --verbose, -V when set, output more details to help developers 95 | --size-limit SIZE-LIMIT, -s SIZE-LIMIT 96 | in bytes. overwrite default size limit (5MiB). 0 means no limit 97 | --wait, -w when set, not exit after upload, util user press any key 98 | --config-file CONFIG-FILE, -c CONFIG-FILE 99 | when set, will use specific config file 100 | --clean, -C when set, remove local file after upload 101 | --raw, -r when set, output non-replaced raw url 102 | --no-log, -n when set, disable logging 103 | --uploader UPLOADER, -u UPLOADER 104 | uploader to use. if not set, will follow config 105 | --output-type OUTPUT-TYPE, -o OUTPUT-TYPE 106 | output type, supports stdout, clipboard [default: stdout] 107 | --output-format OUTPUT-FORMAT, -f OUTPUT-FORMAT 108 | output format, supports url, markdown and your customs [default: url] 109 | --application-path APPLICATION-PATH 110 | custom application path, which determines config file path and extensions dir path. current binary dir by default 111 | --help, -h display this help and exit 112 | 113 | Manage extensions: 114 | upgit ext ACTION 115 | 116 | Actions: 117 | ls list all downloadable extensions 118 | my list all local extensions 119 | add smms.jsonc install SMMS uploader 120 | remove smms.jsonc remove SMMS uploader 121 | ``` 122 | 123 | ### 配合 Typora 使用 124 | 125 | > 假设 *upgit* 程序保存在`“C:\repo\upgit\upgit.exe`。 126 | 127 | 选择 *文件 > 首选项* 128 | 129 | ![image-20220128204217802](https://cdn.jsdelivr.net/gh/pluveto/0images@master/2022/01/upgit_20220128_1643373863.png) 130 | 131 | 转到 *Image*。选择*自定义命令*作为*图像上传器*。 132 | 133 | 在*命令*文本框中输入*upgit* 程序位置。 134 | 135 | > 你可以点击*测试上传*按钮来确保它工作正常。 136 | 137 | ![image-20220128204418723](https://cdn.jsdelivr.net/gh/pluveto/0images@master/2022/01/upgit_20220128_1643373868.png) 138 | 139 | 然后就可以使用了。 140 | 141 | ### 上传剪贴板图像 142 | 143 | 使用 `:clipboard` 占位符放置剪贴板图像。(仅支持**png**格式) 144 | 145 | ```shell 146 | ./upgit :clipboard 147 | ``` 148 | 149 | 截图快捷键: 150 | 151 | + 在 macOS 上,使用 `Ctrl+Shift+Cmd+4` 152 | + 在 Linux/Ubuntu 上,使用 `Ctrl+Shift+PrintScreen` 153 | + 在 Windows 上,使用 `Shift+Win+s` 154 | 155 | ### 上传剪贴板文件 156 | 157 | **注意:**此功能仅在 Windows 上支持。 158 | 159 | 使用 `:clipboard-files` 或 `:clipboard-file` 的位置标识来表示剪贴板文件。两者都将上传剪贴板中的所有文件。 160 | 161 | ```shell 162 | ./upgit :clipboard-files 163 | ``` 164 | 165 | 因为 golang 不支持直接获取剪贴板文件列表,所以 *upgit* 将使用 [APIProxy-Win32](https://github.com/pluveto/APIProxy-Win32) 来获取剪贴板文件列表。当你第一次使用这个功能时,它将自动下载。 166 | 167 | ### 将 URL 保存到剪贴板 168 | 169 | 使用参数 `--output-type clipboard`: 170 | 171 | ```shell 172 | ./upgit logo.png --output-type clipboard 173 | # or .\upgit.exe :clipboard -o clipboard 174 | ``` 175 | 176 | #### 复制为 Markdown 格式 177 | 178 | 增加参数 `--output-format markdown`: 179 | 180 | ```shell 181 | ./upgit logo.png --output-type clipboard --output-format markdown 182 | # or .\upgit.exe :clipboard -o clipboard -f markdown 183 | ``` 184 | 185 | 然后会在剪贴板上得到一个 Markdown 图片链接,比如: 186 | 187 | ```md 188 | ![logo.png](!https://cdn.jsdelivr.net/gh/pluveto/upgit/logo.png) 189 | ``` 190 | 191 | ### AHK 的最佳实践 192 | 193 | 对于 Windows 用户: 194 | 195 | 1. 安装AHK 196 | 2. 创建这个脚本 `upload_clipboard.ahk` 并运行: 197 | 198 | ```ahk 199 | ; Press Ctrl + F9 to upload clipboard image 200 | ^F9:: 201 | RunWait, "upgit.exe" :clipboard --output-type clipboard --output-format markdown 202 | return 203 | ``` 204 | 205 | 3. 然后按 WinShiftS 截图,按 CtrlF9上传并将其链接复制到剪贴板 206 | 207 | ## 配置文件说明 208 | 209 | | 键 | 说明 | 210 | | --------------------- | ------------------------------------------------------------ | 211 | | username | 您的 Github 用户名,例如 `pluveto` | 212 | | repo | 您的 Github 存储库名称,例如 `upgit` | 213 | | branch | 保存文件的分支,例如 `master` 或 `main` | 214 | | pat | 个人访问令牌。 访问 [GitHub 文档](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) 了解更多信息 | 215 | | rename | 重命名规则。不存在的路径目录将被创建。 支持下列占位符: | 216 | | -- `{year}` | -- 年份,如 `2006` | 217 | | -- `{month}` | -- 月,如 `01` | 218 | | -- `{day}` | -- 日,如 `02` | 219 | | -- `{hour}` | -- 时 | 220 | | -- `{minute}` | -- 分 | 221 | | -- `{second}` | -- 秒 | 222 | | -- `{unix_ts}` | -- 以秒计的 Unix 时间戳,如 `1643373370`. | 223 | | -- `{unix_tsms}` | -- 以毫秒计的 Unix 时间戳,如 `1644212979622`. | 224 | | --- `{ext}` | -- 扩展名,如 `.png`,若文件无扩展名,则为空串 | 225 | | -- `{fname}` | -- 原始文件名,如 `logo` (不含扩展名) | 226 | | -- `{fname_hash}` | -- `{fname}`的 MD5 散列值 | 227 | | -- `{fname_hash_4}` | -- `{fname}`的 MD5 散列值,取前 4 位 | 228 | | -- `{fname_hash_8}` | -- `{fname}`的 MD5 散列值,取前 8 位 | 229 | 230 | 这是一个简单的示例配置文件: 231 | 232 | ```toml 233 | rename = "{year}/{month}/upgit_{year}{month}{day}_{unix_ts}{ext}" 234 | [uploaders.github] 235 | pat = "ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" 236 | repo = "repo-name" 237 | username = "username" 238 | ``` 239 | 240 | ### 自定义输出格式 241 | 242 | 可以通过如下方式自定义输出格式: 243 | 244 | ```toml 245 | [output_formats] 246 | "bbcode" = "[img]{url}[/img]" 247 | "html" = '' 248 | "markdown-simple" = "![]({url})" 249 | ``` 250 | 251 | 使用方法示例: 252 | 253 | ``` 254 | upgit :clipboard -o clipboard -f bbcode 255 | ``` 256 | -------------------------------------------------------------------------------- /ext_cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "path" 9 | "strings" 10 | 11 | "github.com/alexflint/go-arg" 12 | "github.com/pluveto/upgit/lib/xext" 13 | "github.com/pluveto/upgit/lib/xgithub" 14 | "github.com/pluveto/upgit/lib/xlog" 15 | "github.com/pluveto/upgit/lib/xpath" 16 | ) 17 | 18 | type ExtListCmd struct { 19 | } 20 | 21 | type ExtListLocalCmd struct { 22 | } 23 | 24 | type ExtAddCmd struct { 25 | Name string `arg:"positional"` 26 | } 27 | 28 | type ExtRemoveCmd struct { 29 | Name string `arg:"positional"` 30 | } 31 | 32 | type ExtCmd struct { 33 | ListLocal *ExtListCmd `arg:"subcommand:listlocal,subcommand:lsmy,subcommand:my"` 34 | List *ExtListCmd `arg:"subcommand:list,subcommand:ls"` 35 | Add *ExtAddCmd `arg:"subcommand:add,subcommand:install"` 36 | Remove *ExtRemoveCmd `arg:"subcommand:remove,subcommand:rm"` 37 | } 38 | 39 | type ExtArgs struct { 40 | Ext *ExtCmd `arg:"subcommand:ext"` 41 | } 42 | 43 | var extArgs ExtArgs 44 | 45 | func autoFixExtName(extName string) string { 46 | if !strings.Contains(extName, ".") { 47 | extName += ".jsonc" 48 | } 49 | return extName 50 | } 51 | 52 | func extSubcommand() { 53 | err := arg.Parse(&extArgs) 54 | if err != nil || extArgs.Ext == nil { 55 | os.Stderr.WriteString("Error: " + err.Error() + "\n") 56 | printExtHelp() 57 | return 58 | } 59 | extDir := xpath.MustGetApplicationPath("extensions") 60 | 61 | switch { 62 | case extArgs.Ext.List != nil: 63 | ls, err := xgithub.ListFolder("pluveto/upgit", "/extensions") 64 | if err != nil { 65 | xlog.AbortErr(err) 66 | } 67 | fmt.Println("All Extensions:") 68 | for i, v := range ls { 69 | fmt.Printf("%d. %s\n", i, v.Name) 70 | } 71 | os.Exit(0) 72 | 73 | case extArgs.Ext.Add != nil: 74 | extName := extArgs.Ext.Add.Name 75 | if len(extName) == 0 { 76 | xlog.AbortErr(errors.New("extension name is required")) 77 | } 78 | extName = autoFixExtName(extName) 79 | buf, err := xgithub.GetFile("pluveto/upgit", "master", "/extensions/"+extName) 80 | if err != nil { 81 | xlog.AbortErr(errors.New("extension " + extName + " not found or network error: " + err.Error())) 82 | } 83 | // save buf 84 | file, err := os.Create(path.Join(extDir, extName)) 85 | defer file.Close() 86 | xlog.AbortErr(err) 87 | _, err = file.Write(buf) 88 | xlog.AbortErr(err) 89 | fmt.Println("Extension installed:", extName) 90 | os.Exit(0) 91 | 92 | case extArgs.Ext.Remove != nil: 93 | extName := extArgs.Ext.Remove.Name 94 | if len(extName) == 0 { 95 | xlog.AbortErr(errors.New("extension name is required")) 96 | } 97 | extName = autoFixExtName(extName) 98 | extPath := path.Join(extDir, extName) 99 | err := os.Remove(extPath) 100 | xlog.AbortErr(err) 101 | fmt.Println("Extension removed:", extName) 102 | os.Exit(0) 103 | 104 | case extArgs.Ext.ListLocal != nil: 105 | files, err := ioutil.ReadDir(extDir) 106 | xlog.AbortErr(err) 107 | fmt.Println("Installed Extensions:") 108 | for i, v := range files { 109 | uploaderDef, err := xext.GetExtDefinition(extDir, v.Name()) 110 | xlog.AbortErr(err) 111 | uploaderDef.DisplaySimple(fmt.Sprintf("%2d ", i), "\n") 112 | } 113 | os.Exit(0) 114 | 115 | } 116 | 117 | os.Stderr.WriteString("Unknown subcommand\n") 118 | printExtHelp() 119 | os.Exit(0) 120 | } 121 | 122 | func printExtHelp() { 123 | os.Stdout.WriteString("Usage: upgit ext [list|my|add|remove]\n") 124 | } 125 | -------------------------------------------------------------------------------- /extensions/catbox.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "catbox", 4 | "name": "Catbox Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "0.0.1", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | "url": "https://catbox.moe/user/api.php", 12 | "method": "POST", 13 | "headers": { 14 | "Content-Type": "multipart/form-data", 15 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 16 | }, 17 | "body": { 18 | "fileToUpload": { 19 | "type": "file", 20 | "value": "$(task.local_path)" 21 | }, 22 | "reqtype": { 23 | "type": "string", 24 | "value": "fileupload" 25 | }, 26 | "userhash": { 27 | "type": "string", 28 | "value": "$(ext_config.userhash)" 29 | } 30 | } 31 | } 32 | }, 33 | "upload": { 34 | "rawUrl": { 35 | "from": "text_response" 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /extensions/chevereto.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "chevereto", 4 | "name": "Chevereto Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "0.0.1", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | // See https://apidocs.imgur.com/#c85c9dfc-7487-4de2-9ecd-66f727cf3139 12 | "url": "$(ext_config.upload_url)", 13 | "method": "POST", 14 | "params": { 15 | "key": "$(ext_config.key)" 16 | }, 17 | "headers": { 18 | "Content-Type": "multipart/form-data", 19 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 20 | }, 21 | "body": { 22 | "key": { 23 | "type": "string", 24 | "value": "$(ext_config.key)" 25 | }, 26 | "source": { 27 | "type": "file_base64", 28 | "value": "$(task.local_path)" 29 | }, 30 | "format": { 31 | "type": "string", 32 | "value": "json" 33 | } 34 | } 35 | } 36 | }, 37 | "upload": { 38 | "rawUrl": { 39 | "from": "json_response", 40 | "path": "image.url" 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /extensions/cloudinary.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "cloudinary", 4 | "name": "Cloudinary Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "0.0.1", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | // See https://cloudinary.com/console/settings/upload 12 | "url": "https://api.cloudinary.com/v1_1/$(ext_config.cloud_name)/upload", 13 | "method": "POST", 14 | "headers": { 15 | "Content-Type": "multipart/form-data", 16 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 17 | }, 18 | "body": { 19 | "upload_preset": { 20 | "type": "string", 21 | "value": "$(ext_config.upload_preset)" 22 | }, 23 | "file": { 24 | "type": "file", 25 | "value": "$(task.local_path)" 26 | } 27 | } 28 | } 29 | }, 30 | "upload": { 31 | "rawUrl": { 32 | "from": "json_response", 33 | "path": "secure_url" 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /extensions/dalexni.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "dalexni", 4 | "name": "DALEXNI Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "0.0.1", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | // Get your api key from https://dalexni.com/settings/api 12 | "url": "https://dalexni.com/api/1/upload", 13 | "method": "POST", 14 | "params": { 15 | "key": "$(ext_config.key)" 16 | }, 17 | "headers": { 18 | "Content-Type": "multipart/form-data", 19 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 20 | }, 21 | "body": { 22 | "key": { 23 | "type": "string", 24 | "value": "$(ext_config.key)" 25 | }, 26 | "image": { 27 | "type": "file_base64", 28 | "value": "$(task.local_path)" 29 | }, 30 | "format": { 31 | "type": "string", 32 | "value": "json" 33 | } 34 | } 35 | } 36 | }, 37 | "upload": { 38 | "rawUrl": { 39 | "from": "json_response", 40 | "path": "data.url" 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /extensions/easyimage.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "easyimage", 4 | "name": "EasyImage Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "0.0.1", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | // See https://www.kancloud.cn/easyimage/easyimage/2625228 12 | "url": "$(ext_config.request_url)", 13 | "method": "POST", 14 | "headers": { 15 | "Content-Type": "multipart/form-data", 16 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 17 | }, 18 | "body": { 19 | "token": { 20 | "type": "string", 21 | "value": "$(ext_config.token)" 22 | }, 23 | "image": { 24 | "type": "file", 25 | "value": "$(task.local_path)" 26 | } 27 | } 28 | } 29 | }, 30 | "upload": { 31 | "rawUrl": { 32 | "from": "json_response", 33 | "path": "url" 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /extensions/gitee.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "gitee", 4 | "name": "Gitee Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "0.0.1", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | "url": "https://gitee.com/api/v5/repos/$(ext_config.username)/$(ext_config.repo)/contents/$(task.target_path)", 12 | "method": "POST", 13 | "headers": { 14 | "Content-Type": "multipart/form-data", 15 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 16 | }, 17 | "body": { 18 | "message": { 19 | "type": "string", 20 | "value": "upload via upgit" 21 | }, 22 | "access_token": { 23 | "type": "string", 24 | "value": "$(ext_config.access_token)" 25 | }, 26 | "content": { 27 | "type": "file_base64", 28 | "value": "$(task.local_path)" 29 | } 30 | } 31 | } 32 | }, 33 | "upload": { 34 | "rawUrl": { 35 | "from": "json_response", 36 | "path": "content.download_url" 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /extensions/hello.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "hello", 4 | "name": "Hello Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "0.0.1", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | "url": "https://www.helloimg.com/newapi/2/upload/?format=json", 12 | "method": "POST", 13 | "headers": { 14 | "Content-Type": "multipart/form-data", 15 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 16 | }, 17 | "body": { 18 | "login-subject": { 19 | "type": "string", 20 | "value": "$(ext_config.username)" 21 | }, 22 | "password": { 23 | "type": "string", 24 | "value": "$(ext_config.password)" 25 | }, 26 | "source": { 27 | "type": "file", 28 | "value": "$(task.local_path)" 29 | } 30 | } 31 | } 32 | }, 33 | "upload": { 34 | "rawUrl": { 35 | "from": "json_response", 36 | "path": "image" 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /extensions/imgbb.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "imgbb", 4 | "name": "Imgbb Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "0.0.1", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | // Get key https://api.imgbb.com/ 12 | "url": "https://api.imgbb.com/1/upload", 13 | "method": "POST", 14 | "params": { 15 | "key": "$(ext_config.key)" 16 | }, 17 | "headers": { 18 | "Content-Type": "multipart/form-data", 19 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 20 | }, 21 | "body": { 22 | "key": { 23 | "type": "string", 24 | "value": "$(ext_config.key)" 25 | }, 26 | "image": { 27 | "type": "file_base64", 28 | "value": "$(task.local_path)" 29 | }, 30 | "format": { 31 | "type": "string", 32 | "value": "json" 33 | } 34 | } 35 | } 36 | }, 37 | "upload": { 38 | "rawUrl": { 39 | "from": "json_response", 40 | "path": "data.url" 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /extensions/imgtg.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "imgtg", 4 | "description": "此插件尚在调试中,请勿使用", 5 | "name": "Img.tg Uploader", 6 | "type": "simple-http-uploader", 7 | "version": "0.0.1", 8 | "repository": "" 9 | }, 10 | "http": { 11 | "request": { 12 | "url": "https://img.tg/json", 13 | "method": "POST", 14 | "headers": { 15 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36", 16 | "Content-Type": "multipart/form-data" 17 | }, 18 | "body": { 19 | "auth_token": { 20 | "type": "string", 21 | "value": "$(func.match)" 22 | }, 23 | "type": { 24 | "type": "string", 25 | "value": "file" 26 | }, 27 | "action": { 28 | "type": "string", 29 | "value": "upload" 30 | }, 31 | "timestamp": { 32 | "type": "string", 33 | "value": "$(func.timestamp)" 34 | }, 35 | "nsfw": { 36 | "type": "string", 37 | "value": "0" 38 | }, 39 | "source": { 40 | "type": "file", 41 | "value": "$(task.local_path)" 42 | } 43 | } 44 | } 45 | }, 46 | "upload": { 47 | "rawUrl": { 48 | "from": "json_response", 49 | "path": "success_image[0].url" 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /extensions/imgur.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "imgur", 4 | "name": "Imgur Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "0.0.1", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | // See https://apidocs.imgur.com/#c85c9dfc-7487-4de2-9ecd-66f727cf3139 12 | "url": "https://api.imgur.com/3/upload", 13 | "method": "POST", 14 | "headers": { 15 | "Authorization": "Client-ID $(ext_config.client_id)", 16 | "Content-Type": "multipart/form-data", 17 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 18 | }, 19 | "body": { 20 | "image": { 21 | "type": "file", 22 | "value": "$(task.local_path)" 23 | } 24 | } 25 | } 26 | }, 27 | "upload": { 28 | "rawUrl": { 29 | "from": "json_response", 30 | "path": "data.link" 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /extensions/imgurlorg.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "imgurlorg", 4 | "name": "Imgurl.org Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "0.0.1", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | "url": "https://imgurl.org/upload/ftp", 12 | "method": "POST", 13 | "headers": { 14 | "Content-Type": "multipart/form-data", 15 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 16 | }, 17 | "body": { 18 | "file": { 19 | "type": "file", 20 | "value": "$(task.local_path)" 21 | } 22 | } 23 | } 24 | }, 25 | "upload": { 26 | "rawUrl": { 27 | "from": "json_response", 28 | "path": "url" 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /extensions/juejin.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "juejin", 4 | "description": "此插件尚在调试中,请勿使用", 5 | "name": "Juejin Uploader", 6 | "type": "simple-http-uploader", 7 | "version": "0.0.1", 8 | "repository": "" 9 | }, 10 | "http": { 11 | "request": { 12 | "url": "https://cdn-ms.juejin.im/v1/upload?bucket=gold-user-assets", 13 | "method": "POST", 14 | "headers": { 15 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36", 16 | "Content-Type": "multipart/form-data" 17 | }, 18 | "body": { 19 | "file": { 20 | "type": "file", 21 | "value": "$(task.local_path)" 22 | } 23 | } 24 | } 25 | }, 26 | "upload": { 27 | "rawUrl": { 28 | "from": "json_response", 29 | "path": "success_image[0].url" 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /extensions/lskypro.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "lskypro", 4 | "name": "LskyPro Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "0.0.1", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | "url": "$(ext_config.host)/api/upload", 12 | "method": "POST", 13 | "headers": { 14 | "Content-Type": "multipart/form-data", 15 | "Authorization": "$(ext_config.token)", 16 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 17 | }, 18 | "body": { 19 | "image": { 20 | "type": "file", 21 | "value": "$(task.local_path)" 22 | }, 23 | "token": { 24 | "type": "string", 25 | "value": "$(ext_config.token)" 26 | } 27 | } 28 | } 29 | }, 30 | "upload": { 31 | "rawUrl": { 32 | "from": "json_response", 33 | "path": "data.url" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /extensions/lskypro2.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "lskypro2", 4 | "name": "LskyPro2 Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "2.0.0", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | "url": "$(ext_config.host)/api/v1/upload", 12 | "method": "POST", 13 | "headers": { 14 | "Content-Type": "multipart/form-data", 15 | "Authorization": "$(ext_config.token)", 16 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 17 | }, 18 | "body": { 19 | "file": { 20 | "type": "file", 21 | "value": "$(task.local_path)" 22 | }, 23 | "token": { 24 | "type": "string", 25 | "value": "$(ext_config.token)" 26 | } 27 | } 28 | } 29 | }, 30 | "upload": { 31 | "rawUrl": { 32 | "from": "json_response", 33 | "path": "data.links.url" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /extensions/moetu.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "moetu", 4 | "name": "Moetu Uploader", 5 | "description": "此插件尚在调试中,请勿使用", 6 | "type": "simple-http-uploader", 7 | "version": "0.0.1", 8 | "repository": "" 9 | }, 10 | "http": { 11 | "request": { 12 | "url": "https://moetu.org/upload", 13 | "method": "POST", 14 | "headers": { 15 | "Content-Type": "multipart/form-data", 16 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 17 | }, 18 | "body": { 19 | "file": { 20 | "type": "file", 21 | "value": "#task.local_path#" 22 | } 23 | } 24 | } 25 | }, 26 | "upload": { 27 | "rawUrl": { 28 | "from": "json_response", 29 | "path": "url.https" 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /extensions/netease.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "netease", 4 | "name": "netease Uploader", 5 | "description": "此插件尚在调试中,请勿使用", 6 | "type": "simple-http-uploader", 7 | "version": "0.0.1", 8 | "repository": "" 9 | }, 10 | "http": { 11 | "request": { 12 | "url": "http://you.163.com/xhr/file/upload.json", 13 | "method": "POST", 14 | "headers": { 15 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36", 16 | "Content-Type": "multipart/form-data" 17 | }, 18 | "body": { 19 | "file": { 20 | "type": "file", 21 | "value": "$(task.local_path)" 22 | } 23 | } 24 | } 25 | }, 26 | "upload": { 27 | "rawUrl": { 28 | "from": "json_response", 29 | "path": "success_image[0].url" 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /extensions/niupic.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "niupic", 4 | "name": "Niupic Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "0.0.1", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | "url": "https://niupic.com/api/upload", 12 | "method": "POST", 13 | "headers": { 14 | "Content-Type": "multipart/form-data", 15 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 16 | }, 17 | "body": { 18 | "file": { 19 | "type": "file", 20 | "value": "$(task.local_path)" 21 | } 22 | } 23 | } 24 | }, 25 | "upload": { 26 | "rawUrl": { 27 | "from": "json_response", 28 | "path": "data" 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /extensions/qiniu.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "qiniu", 4 | "name": "Qiniu Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "0.0.1", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | "url": "http://up.qiniu.com", 12 | "method": "POST", 13 | "headers": { 14 | "Content-Type": "multipart/form-data", 15 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 16 | }, 17 | "body": { 18 | "token": { 19 | "type": "string", 20 | "value": "$(ext_config.token)" 21 | }, 22 | "key": { 23 | "type": "string", 24 | "value": "$(task.target_path)" 25 | }, 26 | "file": { 27 | "type": "file", 28 | "value": "$(task.local_path)" 29 | } 30 | } 31 | } 32 | }, 33 | "upload": { 34 | "rawUrl": { 35 | "from": "template", 36 | "template": "$(ext_config.prefix)$(task.target_path)" 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /extensions/smms.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "smms", 4 | "name": "SMMS Uploader", 5 | "type": "simple-http-uploader", 6 | "version": "0.0.1", 7 | "repository": "" 8 | }, 9 | "http": { 10 | "request": { 11 | "url": "https://sm.ms/api/v2/upload", 12 | "method": "POST", 13 | "headers": { 14 | "Authorization": "$(ext_config.token)", 15 | "Content-Type": "multipart/form-data", 16 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36" 17 | }, 18 | "body": { 19 | "format": { 20 | "type": "string", 21 | "value": "json" 22 | }, 23 | "smfile": { 24 | "type": "file", 25 | "value": "$(task.local_path)" 26 | } 27 | } 28 | } 29 | }, 30 | "upload": { 31 | "rawUrl": { 32 | "from": "json_response", 33 | "path": "data.url" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /extensions/sougou.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "sougou", 4 | "name": "sougou Uploader", 5 | "description": "此插件尚在调试中,请勿使用", 6 | "type": "simple-http-uploader", 7 | "version": "0.0.1", 8 | "repository": "" 9 | }, 10 | "http": { 11 | "request": { 12 | "url": "http://pic.sogou.com/pic/upload_pic.jsp", 13 | "method": "POST", 14 | "headers": { 15 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36", 16 | "Content-Type": "multipart/form-data" 17 | }, 18 | "body": { 19 | "pic_path": { 20 | "type": "file", 21 | "value": "$(task.local_path)" 22 | } 23 | } 24 | } 25 | }, 26 | "upload": { 27 | "rawUrl": { 28 | "from": "json_response", 29 | "path": "success_image[0].url" 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /extensions/upload_cc.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "id": "uploadcc", 4 | "name": "Upload.cc Uploader", 5 | "description": "此插件尚在调试中,请勿使用", 6 | "type": "simple-http-uploader", 7 | "version": "0.0.1", 8 | "repository": "" 9 | }, 10 | "http": { 11 | "request": { 12 | "url": "https://upload.cc/image_upload", 13 | "method": "POST", 14 | "headers": { 15 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36", 16 | "Content-Type": "multipart/form-data" 17 | }, 18 | "body": { 19 | "uploaded_file[]": { 20 | "type": "file", 21 | "value": "$(task.local_path)" 22 | } 23 | } 24 | } 25 | }, 26 | "upload": { 27 | "rawUrl": { 28 | "from": "json_response", 29 | "path": "success_image[0].url" 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pluveto/upgit 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/alexflint/go-arg v1.4.3 7 | github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible 8 | github.com/aws/aws-sdk-go v1.54.6 9 | github.com/fatih/color v1.13.0 10 | github.com/mitchellh/mapstructure v1.4.3 11 | github.com/pelletier/go-toml/v2 v2.0.6 12 | golang.design/x/clipboard v0.6.0 13 | golang.org/x/image v0.0.0-20220302094943-723b81ca9867 14 | gopkg.in/validator.v2 v2.0.0-20210331031555-b37d688a7fb0 15 | ) 16 | 17 | require ( 18 | github.com/alexflint/go-scalar v1.1.0 // indirect 19 | github.com/jmespath/go-jmespath v0.4.0 // indirect 20 | github.com/mattn/go-colorable v0.1.12 // indirect 21 | github.com/mattn/go-isatty v0.0.14 // indirect 22 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect 23 | golang.org/x/mobile v0.0.0-20220224134551-8a0a1e50732f // indirect 24 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 // indirect 25 | golang.org/x/time v0.11.0 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 2 | github.com/alexflint/go-arg v1.4.3 h1:9rwwEBpMXfKQKceuZfYcwuc/7YY7tWJbFsgG5cAU/uo= 3 | github.com/alexflint/go-arg v1.4.3/go.mod h1:3PZ/wp/8HuqRZMUUgu7I+e1qcpUbvmS258mRXkFH4IA= 4 | github.com/alexflint/go-scalar v1.1.0 h1:aaAouLLzI9TChcPXotr6gUhq+Scr8rl0P9P4PnltbhM= 5 | github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= 6 | github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g= 7 | github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= 8 | github.com/aws/aws-sdk-go v1.54.6 h1:HEYUib3yTt8E6vxjMWM3yAq5b+qjj/6aKA62mkgux9g= 9 | github.com/aws/aws-sdk-go v1.54.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= 14 | github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= 15 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 16 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 17 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 18 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 19 | github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 20 | github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= 21 | github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 22 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 23 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 24 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 25 | github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= 26 | github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 27 | github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= 28 | github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= 29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 32 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 33 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 34 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 35 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 36 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 37 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 38 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 39 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 40 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 41 | github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 42 | golang.design/x/clipboard v0.6.0 h1:+U/e2KDBdpIjkRdxO8GwlD6dKD3Jx5zlNNzQjxte4A0= 43 | golang.design/x/clipboard v0.6.0/go.mod h1:ep0pB+/4DGJK3ayLxweWJFHhHGGv3npJJHMXAjtLTUM= 44 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 45 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 46 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 47 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU= 48 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= 49 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 50 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 51 | golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= 52 | golang.org/x/image v0.0.0-20220302094943-723b81ca9867 h1:TcHcE0vrmgzNH1v3ppjcMGbhG5+9fMuvOmUYwNEF4q4= 53 | golang.org/x/image v0.0.0-20220302094943-723b81ca9867/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= 54 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= 55 | golang.org/x/mobile v0.0.0-20210716004757-34ab1303b554/go.mod h1:jFTmtFYCV0MFtXBU+J5V/+5AUeVS0ON/0WkE/KSrl6E= 56 | golang.org/x/mobile v0.0.0-20220224134551-8a0a1e50732f h1:G/wQ/Mbs60nXhRM80J4DOzy7FEIZjNprzOneArSgOl0= 57 | golang.org/x/mobile v0.0.0-20220224134551-8a0a1e50732f/go.mod h1:pe2sM7Uk+2Su1y7u/6Z8KJ24D7lepUjFZbhFOrmDfuQ= 58 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= 59 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 60 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 61 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 62 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 63 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 64 | golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 65 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 66 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 67 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 68 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 69 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 70 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 71 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 72 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 73 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 74 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 75 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 76 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 77 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 78 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 h1:nhht2DYV/Sn3qOayu8lM+cU1ii9sTLUeBQwQQfUHtrs= 79 | golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 80 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 81 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 82 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 83 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 84 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 85 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 86 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 87 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 88 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 89 | golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 90 | golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= 91 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 92 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 93 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 94 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 95 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 96 | gopkg.in/validator.v2 v2.0.0-20210331031555-b37d688a7fb0 h1:EFLtLCwd8tGN+r/ePz3cvRtdsfYNhDEdt/vp6qsT+0A= 97 | gopkg.in/validator.v2 v2.0.0-20210331031555-b37d688a7fb0/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc= 98 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 99 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 100 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 101 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 102 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 103 | -------------------------------------------------------------------------------- /lib/aliyunoss/uploader.go: -------------------------------------------------------------------------------- 1 | package aliyunoss 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/aliyun/aliyun-oss-go-sdk/oss" 10 | "github.com/pluveto/upgit/lib/model" 11 | "github.com/pluveto/upgit/lib/xapp" 12 | "github.com/pluveto/upgit/lib/xlog" 13 | ) 14 | 15 | type OSSConfig struct { 16 | Endpoint string `toml:"endpoint" mapstructure:"endpoint" validate:"nonzero"` 17 | AccessKeyId string `toml:"access_key_id" mapstructure:"access_key_id" validate:"nonzero"` 18 | AccessKeySecret string `toml:"access_key_secret" mapstructure:"access_key_secret" validate:"nonzero"` 19 | BucketName string `toml:"bucket_name" mapstructure:"bucket_name" validate:"nonzero"` 20 | Host string `toml:"host" mapstructure:"host" validate:"nonzero"` 21 | } 22 | 23 | type OSSUploader struct { 24 | Config OSSConfig 25 | } 26 | 27 | func (u OSSUploader) Upload(t *model.Task) error { 28 | now := time.Now() 29 | name := filepath.Base(t.LocalPath) 30 | var targetPath string 31 | if len(t.TargetDir) > 0 { 32 | targetPath = t.TargetDir + "/" + name 33 | } else { 34 | targetPath = xapp.Rename(name, now) 35 | } 36 | rawUrl := u.buildUrl(targetPath) 37 | url := xapp.ReplaceUrl(rawUrl) 38 | xlog.GVerbose.Info("uploading #TASK_%d %s\n", t.TaskId, t.LocalPath) 39 | err := u.PutFile(t.LocalPath, targetPath) 40 | if err == nil { 41 | xlog.GVerbose.Info("successfully uploaded #TASK_%d %s => %s\n", t.TaskId, t.LocalPath, url) 42 | t.Status = model.TASK_FINISHED 43 | t.Url = url 44 | t.FinishTime = time.Now() 45 | t.RawUrl = rawUrl 46 | } else { 47 | xlog.GVerbose.Info("failed to upload #TASK_%d %s : %s\n", t.TaskId, t.LocalPath, err.Error()) 48 | t.Status = model.TASK_FAILED 49 | t.FinishTime = time.Now() 50 | } 51 | return err 52 | } 53 | 54 | func (u *OSSUploader) buildUrl(path string) string { 55 | return fmt.Sprintf("%s/%s", u.Config.Host, path) 56 | } 57 | 58 | func (u *OSSUploader) PutFile(localPath, targetPath string) (err error) { 59 | cli, err := oss.New(u.Config.Endpoint, u.Config.AccessKeyId, u.Config.AccessKeySecret) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | bucket, err := cli.Bucket(u.Config.BucketName) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | file, err := os.OpenFile(localPath, os.O_RDONLY, 0644) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | err = bucket.PutObject(targetPath, file) 75 | return 76 | } 77 | -------------------------------------------------------------------------------- /lib/model/task.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | type UploadStatus string 6 | 7 | const ( 8 | TASK_CREATED UploadStatus = "created" 9 | TASK_FINISHED = "ok" 10 | TASK_PAUSED = "paused" 11 | TASK_FAILED = "failed" 12 | ) 13 | 14 | type Task struct { 15 | Status UploadStatus `toml:"status" mapstructure:"status"` 16 | TaskId int `toml:"task_id" mapstructure:"task_id"` 17 | LocalPath string `toml:"local_path" mapstructure:"local_path"` 18 | TargetDir string `toml:"target_dir" mapstructure:"target_dir"` 19 | TargetPath string `toml:"target_path" mapstructure:"target_path"` 20 | Ignored bool `toml:"ignored" mapstructure:"ignored"` 21 | RawUrl string `toml:"raw_url" mapstructure:"raw_url"` 22 | Url string `toml:"url" mapstructure:"url"` 23 | CreateTime time.Time `toml:"create_time" mapstructure:"create_time"` 24 | FinishTime time.Time `toml:"finish_time" mapstructure:"finish_time"` 25 | } 26 | -------------------------------------------------------------------------------- /lib/model/uploader.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Uploader interface { 4 | Upload(task *Task) error 5 | } 6 | -------------------------------------------------------------------------------- /lib/qcloudcos/signature.go: -------------------------------------------------------------------------------- 1 | // source: https://github.com/tencentyun/cos-go-sdk-v5 2 | package qcloudcos 3 | 4 | import ( 5 | "crypto/hmac" 6 | "crypto/sha1" 7 | "fmt" 8 | "hash" 9 | "net/http" 10 | "net/url" 11 | "sort" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | const ( 17 | sha1SignAlgorithm = "sha1" 18 | privateHeaderPrefix = "x-cos-" 19 | defaultAuthExpire = time.Hour 20 | ) 21 | 22 | var ( 23 | defaultCVMAuthExpire = int64(600) 24 | defaultCVMSchema = "http" 25 | defaultCVMMetaHost = "metadata.tencentyun.com" 26 | defaultCVMCredURI = "latest/meta-data/cam/security-credentials" 27 | ) 28 | 29 | // 需要校验的 Headers 列表 30 | var NeedSignHeaders = map[string]bool{ 31 | "host": true, 32 | "range": true, 33 | "x-cos-acl": true, 34 | "x-cos-grant-read": true, 35 | "x-cos-grant-write": true, 36 | "x-cos-grant-full-control": true, 37 | "response-content-type": true, 38 | "response-content-language": true, 39 | "response-expires": true, 40 | "response-cache-control": true, 41 | "response-content-disposition": true, 42 | "response-content-encoding": true, 43 | "cache-control": true, 44 | "content-disposition": true, 45 | "content-encoding": true, 46 | "content-type": true, 47 | "content-length": true, 48 | "content-md5": true, 49 | "transfer-encoding": true, 50 | "versionid": true, 51 | "expect": true, 52 | "expires": true, 53 | "x-cos-content-sha1": true, 54 | "x-cos-storage-class": true, 55 | "if-match": true, 56 | "if-modified-since": true, 57 | "if-none-match": true, 58 | "if-unmodified-since": true, 59 | "origin": true, 60 | "access-control-request-method": true, 61 | "access-control-request-headers": true, 62 | "x-cos-object-type": true, 63 | } 64 | 65 | // 非线程安全,只能在进程初始化(而不是Client初始化)时做设置 66 | func SetNeedSignHeaders(key string, val bool) { 67 | NeedSignHeaders[key] = val 68 | } 69 | 70 | func encodeURIComponent(str string) string { 71 | r := url.QueryEscape(str) 72 | r = strings.Replace(r, "+", "%20", -1) 73 | return r 74 | } 75 | 76 | func safeURLEncode(s string) string { 77 | s = encodeURIComponent(s) 78 | s = strings.Replace(s, "!", "%21", -1) 79 | s = strings.Replace(s, "'", "%27", -1) 80 | s = strings.Replace(s, "(", "%28", -1) 81 | s = strings.Replace(s, ")", "%29", -1) 82 | s = strings.Replace(s, "*", "%2A", -1) 83 | return s 84 | } 85 | 86 | type valuesSignMap map[string][]string 87 | 88 | func (vs valuesSignMap) Add(key, value string) { 89 | key = strings.ToLower(safeURLEncode(key)) 90 | vs[key] = append(vs[key], value) 91 | } 92 | 93 | func (vs valuesSignMap) Encode() string { 94 | var keys []string 95 | for k := range vs { 96 | keys = append(keys, k) 97 | } 98 | sort.Strings(keys) 99 | 100 | var pairs []string 101 | for _, k := range keys { 102 | items := vs[k] 103 | sort.Strings(items) 104 | for _, val := range items { 105 | pairs = append( 106 | pairs, 107 | fmt.Sprintf("%s=%s", k, safeURLEncode(val))) 108 | } 109 | } 110 | return strings.Join(pairs, "&") 111 | } 112 | 113 | // AuthTime 用于生成签名所需的 q-sign-time 和 q-key-time 相关参数 114 | type AuthTime struct { 115 | SignStartTime time.Time 116 | SignEndTime time.Time 117 | KeyStartTime time.Time 118 | KeyEndTime time.Time 119 | } 120 | 121 | // NewAuthTime 生成 AuthTime 的便捷函数 122 | // 123 | // expire: 从现在开始多久过期. 124 | func NewAuthTime(expire time.Duration) *AuthTime { 125 | signStartTime := time.Now() 126 | keyStartTime := signStartTime 127 | signEndTime := signStartTime.Add(expire) 128 | keyEndTime := signEndTime 129 | return &AuthTime{ 130 | SignStartTime: signStartTime, 131 | SignEndTime: signEndTime, 132 | KeyStartTime: keyStartTime, 133 | KeyEndTime: keyEndTime, 134 | } 135 | } 136 | 137 | // signString return q-sign-time string 138 | func (a *AuthTime) signString() string { 139 | return fmt.Sprintf("%d;%d", a.SignStartTime.Unix(), a.SignEndTime.Unix()) 140 | } 141 | 142 | // keyString return q-key-time string 143 | func (a *AuthTime) keyString() string { 144 | return fmt.Sprintf("%d;%d", a.KeyStartTime.Unix(), a.KeyEndTime.Unix()) 145 | } 146 | 147 | // newAuthorization 通过一系列步骤生成最终需要的 Authorization 字符串 148 | func newAuthorization(secretID, secretKey string, req *http.Request, authTime *AuthTime, signHost bool) string { 149 | signTime := authTime.signString() 150 | keyTime := authTime.keyString() 151 | signKey := calSignKey(secretKey, keyTime) 152 | 153 | if signHost { 154 | req.Header.Set("Host", req.Host) 155 | } 156 | formatHeaders := *new(string) 157 | signedHeaderList := *new([]string) 158 | formatHeaders, signedHeaderList = genFormatHeaders(req.Header) 159 | formatParameters, signedParameterList := genFormatParameters(req.URL.Query()) 160 | formatString := genFormatString(req.Method, *req.URL, formatParameters, formatHeaders) 161 | 162 | stringToSign := calStringToSign(sha1SignAlgorithm, keyTime, formatString) 163 | signature := calSignature(signKey, stringToSign) 164 | 165 | return genAuthorization( 166 | secretID, signTime, keyTime, signature, signedHeaderList, 167 | signedParameterList, 168 | ) 169 | } 170 | 171 | // AddAuthorizationHeader 给 req 增加签名信息 172 | func AddAuthorizationHeader(secretID, secretKey string, sessionToken string, req *http.Request, authTime *AuthTime) { 173 | if secretID == "" { 174 | return 175 | } 176 | 177 | auth := newAuthorization(secretID, secretKey, req, 178 | authTime, true, 179 | ) 180 | if len(sessionToken) > 0 { 181 | req.Header.Set("x-cos-security-token", sessionToken) 182 | } 183 | req.Header.Set("Authorization", auth) 184 | } 185 | 186 | // calSignKey 计算 SignKey 187 | func calSignKey(secretKey, keyTime string) string { 188 | digest := calHMACDigest(secretKey, keyTime, sha1SignAlgorithm) 189 | return fmt.Sprintf("%x", digest) 190 | } 191 | 192 | // calStringToSign 计算 StringToSign 193 | func calStringToSign(signAlgorithm, signTime, formatString string) string { 194 | h := sha1.New() 195 | h.Write([]byte(formatString)) 196 | return fmt.Sprintf("%s\n%s\n%x\n", signAlgorithm, signTime, h.Sum(nil)) 197 | } 198 | 199 | // calSignature 计算 Signature 200 | func calSignature(signKey, stringToSign string) string { 201 | digest := calHMACDigest(signKey, stringToSign, sha1SignAlgorithm) 202 | return fmt.Sprintf("%x", digest) 203 | } 204 | 205 | // genAuthorization 生成 Authorization 206 | func genAuthorization(secretID, signTime, keyTime, signature string, signedHeaderList, signedParameterList []string) string { 207 | return strings.Join([]string{ 208 | "q-sign-algorithm=" + sha1SignAlgorithm, 209 | "q-ak=" + secretID, 210 | "q-sign-time=" + signTime, 211 | "q-key-time=" + keyTime, 212 | "q-header-list=" + strings.Join(signedHeaderList, ";"), 213 | "q-url-param-list=" + strings.Join(signedParameterList, ";"), 214 | "q-signature=" + signature, 215 | }, "&") 216 | } 217 | 218 | // genFormatString 生成 FormatString 219 | func genFormatString(method string, uri url.URL, formatParameters, formatHeaders string) string { 220 | formatMethod := strings.ToLower(method) 221 | formatURI := uri.Path 222 | 223 | return fmt.Sprintf("%s\n%s\n%s\n%s\n", formatMethod, formatURI, 224 | formatParameters, formatHeaders, 225 | ) 226 | } 227 | 228 | // genFormatParameters 生成 FormatParameters 和 SignedParameterList 229 | // instead of the url.Values{} 230 | func genFormatParameters(parameters url.Values) (formatParameters string, signedParameterList []string) { 231 | ps := valuesSignMap{} 232 | for key, values := range parameters { 233 | for _, value := range values { 234 | ps.Add(key, value) 235 | signedParameterList = append(signedParameterList, strings.ToLower(safeURLEncode(key))) 236 | } 237 | } 238 | //formatParameters = strings.ToLower(ps.Encode()) 239 | formatParameters = ps.Encode() 240 | sort.Strings(signedParameterList) 241 | return 242 | } 243 | 244 | // genFormatHeaders 生成 FormatHeaders 和 SignedHeaderList 245 | func genFormatHeaders(headers http.Header) (formatHeaders string, signedHeaderList []string) { 246 | hs := valuesSignMap{} 247 | for key, values := range headers { 248 | if isSignHeader(strings.ToLower(key)) { 249 | for _, value := range values { 250 | hs.Add(key, value) 251 | signedHeaderList = append(signedHeaderList, strings.ToLower(safeURLEncode(key))) 252 | } 253 | } 254 | } 255 | formatHeaders = hs.Encode() 256 | sort.Strings(signedHeaderList) 257 | return 258 | } 259 | 260 | // HMAC 签名 261 | func calHMACDigest(key, msg, signMethod string) []byte { 262 | var hashFunc func() hash.Hash 263 | switch signMethod { 264 | case "sha1": 265 | hashFunc = sha1.New 266 | default: 267 | hashFunc = sha1.New 268 | } 269 | h := hmac.New(hashFunc, []byte(key)) 270 | h.Write([]byte(msg)) 271 | return h.Sum(nil) 272 | } 273 | 274 | func isSignHeader(key string) bool { 275 | for k, v := range NeedSignHeaders { 276 | if key == k && v { 277 | return true 278 | } 279 | } 280 | return strings.HasPrefix(key, privateHeaderPrefix) 281 | } 282 | -------------------------------------------------------------------------------- /lib/qcloudcos/transport.go: -------------------------------------------------------------------------------- 1 | package qcloudcos 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | // cloneRequest returns a clone of the provided *http.Request. The clone is a 12 | // shallow copy of the struct and its Header map. 13 | func cloneRequest(r *http.Request) *http.Request { 14 | // shallow copy of the struct 15 | r2 := new(http.Request) 16 | *r2 = *r 17 | // deep copy of the Header 18 | r2.Header = make(http.Header, len(r.Header)) 19 | for k, s := range r.Header { 20 | r2.Header[k] = append([]string(nil), s...) 21 | } 22 | return r2 23 | } 24 | 25 | // AuthorizationTransport 给请求增加 Authorization header 26 | type AuthorizationTransport struct { 27 | SecretID string 28 | SecretKey string 29 | SessionToken string 30 | rwLocker sync.RWMutex 31 | // 签名多久过期 32 | Expire time.Duration 33 | Transport http.RoundTripper 34 | } 35 | 36 | // SetCredential update the SecretID(ak), SercretKey(sk), sessiontoken 37 | func (t *AuthorizationTransport) SetCredential(ak, sk, token string) { 38 | t.rwLocker.Lock() 39 | defer t.rwLocker.Unlock() 40 | t.SecretID = ak 41 | t.SecretKey = sk 42 | t.SessionToken = token 43 | } 44 | 45 | // GetCredential get the ak, sk, token 46 | func (t *AuthorizationTransport) GetCredential() (string, string, string) { 47 | t.rwLocker.RLock() 48 | defer t.rwLocker.RUnlock() 49 | return t.SecretID, t.SecretKey, t.SessionToken 50 | } 51 | 52 | // RoundTrip implements the RoundTripper interface. 53 | func (t *AuthorizationTransport) RoundTrip(req *http.Request) (*http.Response, error) { 54 | // req = cloneRequest(req) // per RoundTrip contract 55 | 56 | ak, sk, token := t.GetCredential() 57 | if strings.HasPrefix(ak, " ") || strings.HasSuffix(ak, " ") { 58 | return nil, fmt.Errorf("SecretID is invalid") 59 | } 60 | if strings.HasPrefix(sk, " ") || strings.HasSuffix(sk, " ") { 61 | return nil, fmt.Errorf("SecretKey is invalid") 62 | } 63 | 64 | // 增加 Authorization header 65 | authTime := NewAuthTime(defaultAuthExpire) 66 | AddAuthorizationHeader(ak, sk, token, req, authTime) 67 | 68 | resp, err := t.transport().RoundTrip(req) 69 | return resp, err 70 | } 71 | 72 | func (t *AuthorizationTransport) transport() http.RoundTripper { 73 | if t.Transport != nil { 74 | return t.Transport 75 | } 76 | return http.DefaultTransport 77 | } 78 | -------------------------------------------------------------------------------- /lib/qcloudcos/uploader.go: -------------------------------------------------------------------------------- 1 | package qcloudcos 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "encoding/base64" 7 | "fmt" 8 | "io/ioutil" 9 | "mime" 10 | "net/http" 11 | "path/filepath" 12 | "strings" 13 | "time" 14 | 15 | "github.com/pluveto/upgit/lib/model" 16 | "github.com/pluveto/upgit/lib/xapp" 17 | "github.com/pluveto/upgit/lib/xlog" 18 | "github.com/pluveto/upgit/lib/xstrings" 19 | ) 20 | 21 | type COSConfig struct { 22 | Host string `toml:"host" mapstructure:"host" validate:"nonzero"` 23 | SecretID string `toml:"secret_id" mapstructure:"secret_id" validate:"nonzero"` 24 | SecretKey string `toml:"secret_key" mapstructure:"secret_key" validate:"nonzero"` 25 | } 26 | 27 | type COSUploader struct { 28 | Config COSConfig 29 | } 30 | 31 | var urlfmt = "https://{host}/{path}" 32 | 33 | func (u COSUploader) Upload(t *model.Task) error { 34 | now := time.Now() 35 | name := filepath.Base(t.LocalPath) 36 | var targetPath string 37 | if len(t.TargetDir) > 0 { 38 | targetPath = t.TargetDir + "/" + name 39 | } else { 40 | targetPath = xapp.Rename(name, now) 41 | } 42 | rawUrl := u.buildUrl(urlfmt, targetPath) 43 | url := xapp.ReplaceUrl(rawUrl) 44 | xlog.GVerbose.Info("uploading #TASK_%d %s\n", t.TaskId, t.LocalPath) 45 | // var err error 46 | err := u.PutFile(t.LocalPath, targetPath) 47 | if err == nil { 48 | xlog.GVerbose.Info("sucessfully uploaded #TASK_%d %s => %s\n", t.TaskId, t.LocalPath, url) 49 | t.Status = model.TASK_FINISHED 50 | t.Url = url 51 | t.FinishTime = time.Now() 52 | t.RawUrl = rawUrl 53 | } else { 54 | xlog.GVerbose.Info("failed to upload #TASK_%d %s : %s\n", t.TaskId, t.LocalPath, err.Error()) 55 | t.Status = model.TASK_FAILED 56 | t.FinishTime = time.Now() 57 | } 58 | return err 59 | } 60 | 61 | func (u *COSUploader) buildUrl(urlfmt, path string) string { 62 | r := strings.NewReplacer( 63 | // .cos..myqcloud.com 64 | "{host}", u.Config.Host, 65 | "{path}", path, 66 | ) 67 | return r.Replace(urlfmt) 68 | } 69 | 70 | func (u *COSUploader) PutFile(localPath, targetPath string) (err error) { 71 | // prepare body 72 | 73 | // create request 74 | url := u.buildUrl(urlfmt, targetPath) 75 | xlog.GVerbose.Trace("PUT %s", url) 76 | req, err := http.NewRequest("PUT", url, nil) 77 | if err != nil { 78 | return err 79 | } 80 | // set header 81 | data, err := ioutil.ReadFile(localPath) 82 | if err != nil { 83 | return err 84 | } 85 | mimeType := mime.TypeByExtension(filepath.Ext(localPath)) 86 | req.Host = u.Config.Host 87 | req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat)) 88 | req.Header.Set("Content-MD5", base64.StdEncoding.EncodeToString(calMD5Digest(data))) 89 | req.Header.Set("Content-Type", xstrings.ValueOrDefault(mimeType, "application/octet-stream")) 90 | req.Header.Set("User-Agent", xapp.UserAgent) 91 | // set body 92 | req.Body = ioutil.NopCloser(bytes.NewBuffer(data)) 93 | // send request 94 | resp, err := (&http.Client{Transport: &AuthorizationTransport{SecretID: u.Config.SecretID, SecretKey: u.Config.SecretKey}}).Do(req) 95 | 96 | xlog.GVerbose.Trace("request header:") 97 | xlog.GVerbose.TraceStruct(req.Header) 98 | 99 | if err != nil { 100 | return err 101 | } 102 | body, err := ioutil.ReadAll(resp.Body) 103 | if err != nil { 104 | return err 105 | } 106 | // check status code 107 | if resp.StatusCode != 200 { 108 | return fmt.Errorf("status code: %d, resp body: %s", resp.StatusCode, string(body)) 109 | } 110 | return 111 | } 112 | func calMD5Digest(msg []byte) []byte { 113 | m := md5.New() 114 | m.Write(msg) 115 | return m.Sum(nil) 116 | } 117 | -------------------------------------------------------------------------------- /lib/result/result.go: -------------------------------------------------------------------------------- 1 | package result 2 | 3 | type Result[T any] struct { 4 | Value T 5 | Err error 6 | } 7 | 8 | func (r Result[T]) Ok() bool { 9 | return r.Err == nil 10 | } 11 | 12 | // From 13 | // In Golang we usually return by the format of `data ,err`. 14 | // This function convert it to a Result[T], so that you can handle error in a tidy way 15 | func From[T any](in ...interface{}) Result[T] { 16 | if len(in) != 2 { 17 | panic("unexpected number of return values") 18 | } 19 | if nil != in[1] { 20 | return Result[T]{ 21 | Err: in[1].(error), 22 | } 23 | } 24 | return Result[T]{ 25 | Value: in[0].(T), 26 | } 27 | } 28 | 29 | func (r Result[T]) ValueOrDefault(default_ T) T { 30 | if r.Err == nil { 31 | return r.Value 32 | } 33 | return default_ 34 | } 35 | 36 | func (r Result[T]) ValueOrPanic() T { 37 | if r.Err == nil { 38 | return r.Value 39 | } 40 | panic(r.Err) 41 | } 42 | 43 | type AbortHandler func(err error) 44 | 45 | var AbortErr AbortHandler 46 | 47 | func (r Result[T]) ValueOrExit() T { 48 | if r.Err == nil { 49 | return r.Value 50 | } 51 | if nil != AbortErr { 52 | AbortErr(r.Err) 53 | } 54 | panic(r.Err) 55 | } 56 | -------------------------------------------------------------------------------- /lib/s3/s3_uploader.go: -------------------------------------------------------------------------------- 1 | package s3 2 | 3 | import ( 4 | "mime" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "time" 9 | 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/aws/aws-sdk-go/aws/credentials" 12 | "github.com/aws/aws-sdk-go/aws/session" 13 | "github.com/aws/aws-sdk-go/service/s3" 14 | "github.com/pluveto/upgit/lib/model" 15 | "github.com/pluveto/upgit/lib/xapp" 16 | "github.com/pluveto/upgit/lib/xlog" 17 | ) 18 | 19 | type S3Config struct { 20 | Region string `toml:"region" mapstructure:"region" validate:"nonzero"` 21 | BucketName string `toml:"bucket_name" mapstructure:"bucket_name" validate:"nonzero"` 22 | AccessKey string `toml:"access_key" mapstructure:"access_key" validate:"nonzero"` 23 | SecretKey string `toml:"secret_key" mapstructure:"secret_key" validate:"nonzero"` 24 | Endpoint string `toml:"endpoint" mapstructure:"endpoint" validate:"nonzero"` 25 | UrlFormat string `toml:"url_format" mapstructure:"url_format" validate:"nonzero" default:"{endpoint}/{bucket}/{path}"` 26 | } 27 | 28 | type S3Uploader struct { 29 | Config S3Config 30 | s3Client *s3.S3 31 | } 32 | 33 | func (u S3Uploader) Upload(t *model.Task) error { 34 | now := time.Now() 35 | name := filepath.Base(t.LocalPath) 36 | var targetPath string 37 | if len(t.TargetDir) > 0 { 38 | targetPath = t.TargetDir + "/" + name 39 | } else { 40 | targetPath = xapp.Rename(name, now) 41 | } 42 | rawUrl := u.buildUrl(u.Config.UrlFormat, targetPath) 43 | url := xapp.ReplaceUrl(rawUrl) 44 | xlog.GVerbose.Info("uploading #TASK_%d %s\n", t.TaskId, t.LocalPath) 45 | 46 | err := u.PutFile(t.LocalPath, targetPath) 47 | if err == nil { 48 | xlog.GVerbose.Info("successfully uploaded #TASK_%d %s => %s\n", t.TaskId, t.LocalPath, url) 49 | t.Status = model.TASK_FINISHED 50 | t.Url = url 51 | t.FinishTime = time.Now() 52 | t.RawUrl = rawUrl 53 | } else { 54 | xlog.GVerbose.Info("failed to upload #TASK_%d %s : %s\n", t.TaskId, t.LocalPath, err.Error()) 55 | t.Status = model.TASK_FAILED 56 | t.FinishTime = time.Now() 57 | } 58 | return err 59 | } 60 | 61 | func (u *S3Uploader) buildUrl(urlfmt, path string) string { 62 | r := strings.NewReplacer( 63 | "{bucket}", u.Config.BucketName, 64 | "{endpoint}", u.Config.Endpoint, 65 | "{path}", path, 66 | ) 67 | return r.Replace(urlfmt) 68 | } 69 | 70 | func (u *S3Uploader) PutFile(localPath, targetPath string) error { 71 | file, err := os.Open(localPath) 72 | if err != nil { 73 | return err 74 | } 75 | defer file.Close() 76 | 77 | // Detect the MIME type based on the file extension 78 | ext := filepath.Ext(localPath) 79 | mimeType := mime.TypeByExtension(ext) 80 | if mimeType == "" { 81 | mimeType = "application/octet-stream" // Default to binary/octet-stream if detection fails 82 | } 83 | 84 | _, err = u.s3Client.PutObject(&s3.PutObjectInput{ 85 | Bucket: aws.String(u.Config.BucketName), 86 | Key: aws.String(targetPath), 87 | Body: file, 88 | ContentType: aws.String(mimeType), 89 | }) 90 | return err 91 | } 92 | 93 | func NewS3Uploader(config S3Config) (*S3Uploader, error) { 94 | sess, err := session.NewSession(&aws.Config{ 95 | Region: aws.String(config.Region), 96 | Endpoint: aws.String(config.Endpoint), 97 | Credentials: credentials.NewStaticCredentials(config.AccessKey, config.SecretKey, ""), 98 | S3ForcePathStyle: aws.Bool(true), // Required for some S3-compatible services 99 | }) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | return &S3Uploader{ 105 | Config: config, 106 | s3Client: s3.New(sess), 107 | }, nil 108 | } 109 | -------------------------------------------------------------------------------- /lib/uploaders/github_upload.go: -------------------------------------------------------------------------------- 1 | package uploaders 2 | 3 | import ( 4 | "bytes" 5 | 6 | "encoding/base64" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | "github.com/pluveto/upgit/lib/model" 15 | "github.com/pluveto/upgit/lib/xapp" 16 | "github.com/pluveto/upgit/lib/xlog" 17 | ) 18 | 19 | type UploadOptions struct { 20 | LocalPath string 21 | } 22 | 23 | type GithubUploaderConfig struct { 24 | PAT string `toml:"pat" validate:"nonzero"` 25 | Username string `toml:"username" validate:"nonzero"` 26 | Repo string `toml:"repo" validate:"nonzero"` 27 | Branch string `toml:"branch,omitempty"` 28 | } 29 | type GithubUploader struct { 30 | Config GithubUploaderConfig 31 | } 32 | 33 | const kRawUrlFmt = "https://raw.githubusercontent.com/{username}/{repo}/{branch}/{path}" 34 | const kApiFmt = "https://api.github.com/repos/{username}/{repo}/contents/{path}" 35 | 36 | func (u GithubUploader) PutFile(message, path, name string) (err error) { 37 | dat, err := ioutil.ReadFile(path) 38 | if err != nil { 39 | return err 40 | } 41 | encoded := base64.StdEncoding.EncodeToString(dat) 42 | url := u.buildUrl(kApiFmt, name) 43 | xlog.GVerbose.Trace("PUT " + url) 44 | req, err := http.NewRequest(http.MethodPut, url, bytes.NewBufferString( 45 | `{ 46 | "branch": "`+u.Config.Branch+`", 47 | "message": "`+message+`", 48 | "content": "`+encoded+`" 49 | }`)) 50 | if err != nil { 51 | return err 52 | } 53 | req.Header.Set("User-Agent", xapp.UserAgent) 54 | req.Header.Set("Accept", "application/vnd.github.v3+json") 55 | req.Header.Set("Content-Type", "application/json") 56 | req.Header.Set("Authorization", "token "+u.Config.PAT) 57 | resp, err := http.DefaultClient.Do(req) 58 | if err != nil { 59 | return err 60 | } 61 | body, err := ioutil.ReadAll(resp.Body) 62 | if err != nil { 63 | return err 64 | } 65 | xlog.GVerbose.Trace("response body: " + string(body)) 66 | if strings.Contains(string(body), "\\\"sha\\\" wasn't supplied.") { 67 | return nil 68 | } 69 | if !(200 <= resp.StatusCode && resp.StatusCode < 300) { 70 | return fmt.Errorf("unexpected status code %d. response: %s", resp.StatusCode, string(body)) 71 | } 72 | return nil 73 | } 74 | 75 | func (u GithubUploader) Upload(t *model.Task) error { 76 | now := time.Now() 77 | base := filepath.Base(t.LocalPath) 78 | // TODO: USE reference 79 | var targetPath string 80 | if len(t.TargetDir) > 0 { 81 | targetPath = t.TargetDir + "/" + base 82 | } else { 83 | targetPath = xapp.Rename(base, now) 84 | } 85 | rawUrl := u.buildUrl(kRawUrlFmt, targetPath) 86 | url := xapp.ReplaceUrl(rawUrl) 87 | xlog.GVerbose.Info("uploading #TASK_%d %s\n", t.TaskId, t.LocalPath) 88 | // var err error 89 | err := u.PutFile("upload "+base+" via upgit client", t.LocalPath, targetPath) 90 | if err == nil { 91 | xlog.GVerbose.Info("sucessfully uploaded #TASK_%d %s => %s\n", t.TaskId, t.LocalPath, url) 92 | } else { 93 | xlog.GVerbose.Info("failed to upload #TASK_%d %s : %s\n", t.TaskId, t.LocalPath, err.Error()) 94 | } 95 | t.Status = model.TASK_FINISHED 96 | t.Url = url 97 | t.FinishTime = time.Now() 98 | t.RawUrl = rawUrl 99 | return err 100 | } 101 | 102 | func (u GithubUploader) buildUrl(urlfmt, path string) string { 103 | r := strings.NewReplacer( 104 | "{username}", u.Config.Username, 105 | "{repo}", u.Config.Repo, 106 | "{branch}", u.Config.Branch, 107 | "{path}", path, 108 | ) 109 | return r.Replace(urlfmt) 110 | } 111 | -------------------------------------------------------------------------------- /lib/uploaders/simple_http_uploader.go: -------------------------------------------------------------------------------- 1 | package uploaders 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "mime/multipart" 12 | "net/http" 13 | "net/url" 14 | "path/filepath" 15 | "reflect" 16 | "strings" 17 | "time" 18 | 19 | "github.com/pluveto/upgit/lib/model" 20 | "github.com/pluveto/upgit/lib/result" 21 | "github.com/pluveto/upgit/lib/xapp" 22 | "github.com/pluveto/upgit/lib/xlog" 23 | "github.com/pluveto/upgit/lib/xmap" 24 | "github.com/pluveto/upgit/lib/xstrings" 25 | ) 26 | 27 | type SimpleHttpUploader struct { 28 | Config map[string]interface{} 29 | Definition map[string]interface{} 30 | } 31 | 32 | // func (u SimpleHttpUploader) UploadAll(localPaths []string, targetDir string) { 33 | // for taskId, localPath := range localPaths { 34 | 35 | // var ret Result[*model.Task] 36 | // task := model.Task{ 37 | // Status: TASK_CREATED, 38 | // TaskId: taskId, 39 | // LocalPath: localPath, 40 | // TargetDir: targetDir, 41 | // RawUrl: localPath, 42 | // Url: localPath, 43 | // FinishTime: time.Now(), 44 | // } 45 | // // ignore non-local path 46 | // if strings.HasPrefix(localPath, "http") { 47 | // task.Ignored = true 48 | // task.Status = TASK_FINISHED 49 | // ret = Result[*model.Task]{ 50 | // value: &task, 51 | // } 52 | // } else { 53 | // ret = u.Upload(&task) 54 | // } 55 | 56 | // if ret.Err == nil { 57 | // xlog.GVerbose.TraceStruct(ret.Value) 58 | // } 59 | // if nil != u.OnTaskStatusChanged { 60 | // u.OnTaskStatusChanged(ret) 61 | // } 62 | // } 63 | // } 64 | 65 | func MapGetOrDefault(m map[string]string, key, def string) string { 66 | if v, ok := m[key]; ok { 67 | return v 68 | } 69 | return def 70 | } 71 | 72 | func panicOnNilOrValue[T any](i interface{}, msg string) T { 73 | if nil == i { 74 | panic("value is nil: " + msg) 75 | } 76 | return i.(T) 77 | } 78 | 79 | var ConfigDelimiters = []string{"$(", ")"} 80 | 81 | func (u SimpleHttpUploader) replaceStringPlaceholder(s string, task model.Task) string { 82 | dict := make(map[string]interface{}, 1) 83 | dict["_"] = s 84 | u.replaceDictPlaceholder(dict, task) 85 | xlog.GVerbose.Trace("replaceStringPlaceholder: %s => %s", s, dict["_"]) 86 | return dict["_"].(string) 87 | } 88 | 89 | func (u SimpleHttpUploader) replaceDictPlaceholder(data map[string]interface{}, task model.Task) { 90 | for k, v_ := range data { 91 | if reflect.TypeOf(v_).Kind() != reflect.String { 92 | // xlog.GVerbose.Trace("skip non-string value: " + k) 93 | continue 94 | } 95 | v := v_.(string) 96 | 97 | replacer := func(key string) *string { 98 | var ret string 99 | parentKey, subKey, found := strings.Cut(key, ".") 100 | if !found { 101 | return nil 102 | } 103 | if parentKey == "ext_config" { 104 | if v, ok := u.Config[subKey]; ok { 105 | ret = v.(string) 106 | return &ret 107 | } 108 | } else if parentKey == "config" { 109 | ret = GetValueByConfigTag(&xapp.AppCfg, subKey).(string) 110 | return &ret 111 | 112 | } else if parentKey == "option" { 113 | ret = GetValueByConfigTag(&xapp.AppOpt, subKey).(string) 114 | return &ret 115 | 116 | } else if parentKey == "task" { 117 | ret = GetValueByConfigTag(task, subKey).(string) 118 | return &ret 119 | } 120 | return nil 121 | } 122 | 123 | ret := xstrings.VariableReplaceFunc(v, ConfigDelimiters[0], ConfigDelimiters[1], replacer) 124 | if nil != ret { 125 | data[k] = *ret 126 | } 127 | } 128 | } 129 | 130 | func GetValueByConfigTag(data interface{}, key string) (ret interface{}) { 131 | t := reflect.TypeOf(data) 132 | n := t.NumField() 133 | for i := 0; i < n; i++ { 134 | f := t.Field(i) 135 | if f.Tag.Get("json") == key || f.Tag.Get("yaml") == key || f.Tag.Get("toml") == key { 136 | return reflect.ValueOf(data).Field(i).Interface() 137 | } 138 | } 139 | return nil 140 | } 141 | 142 | func (u SimpleHttpUploader) UploadFile(task *model.Task) (rawUrl string, err error) { 143 | // == prepare method and url == 144 | method := result.From[string](xmap.GetDeep[string](u.Definition, "http.request.method")).ValueOrExit() 145 | urlRaw := result.From[string](xmap.GetDeep[string](u.Definition, "http.request.url")).ValueOrExit() 146 | params := result.From[map[string]interface{}](xmap.GetDeep[map[string]interface{}](u.Definition, "http.request.params")).ValueOrDefault(map[string]interface{}{}) 147 | u.replaceDictPlaceholder(params, *task) 148 | url := result.From[*url.URL](url.Parse(u.replaceStringPlaceholder(urlRaw, *task))).ValueOrExit() 149 | query := url.Query() 150 | for paramName, paramValue := range params { 151 | query.Add(paramName, paramValue.(string)) 152 | } 153 | url.RawQuery = query.Encode() 154 | xlog.GVerbose.Info("Method: %s, URL: %s", method, url.String()) 155 | 156 | // == Prepare header == 157 | defHeaders := result.From[map[string]interface{}](xmap.GetDeep[map[string]interface{}](u.Definition, "http.request.headers")).ValueOrExit() 158 | u.replaceDictPlaceholder(defHeaders, *task) 159 | 160 | xlog.GVerbose.Trace("unformatted headers:") 161 | xlog.GVerbose.TraceStruct(defHeaders) 162 | header := make(http.Header) 163 | for k, v := range defHeaders { 164 | header.Set(k, u.replaceStringPlaceholder(v.(string), *task)) 165 | } 166 | if header.Get("Content-Type") == "" { 167 | header.Set("Content-Type", "application/octet-stream") 168 | } 169 | xlog.GVerbose.Trace("formatted headers:") 170 | xlog.GVerbose.TraceStruct(map[string][]string(header)) 171 | // upload file according to content-type 172 | 173 | // == Prepare body == 174 | var body io.ReadCloser 175 | switch header.Get("Content-Type") { 176 | case "application/octet-stream": 177 | body = ioutil.NopCloser(bytes.NewReader(result.From[[]byte](ioutil.ReadFile(task.LocalPath)).ValueOrExit())) 178 | 179 | case "multipart/form-data": 180 | body = u.buildMultipartFormData(task, &header) 181 | } 182 | 183 | // == Create Request == 184 | req := result.From[*http.Request](http.NewRequest(method, u.replaceStringPlaceholder(url.String(), *task), body)).ValueOrExit() 185 | req.Header = header 186 | xlog.GVerbose.Trace("do headers:") 187 | xlog.GVerbose.TraceStruct(map[string][]string(req.Header)) 188 | 189 | // == Do Request == 190 | resp := result.From[*http.Response](http.DefaultClient.Do(req)).ValueOrExit() 191 | bodyBytes := result.From[[]byte](ioutil.ReadAll(resp.Body)).ValueOrExit() 192 | xlog.GVerbose.Info("response body:" + string(bodyBytes)) 193 | // check statuscode 194 | if !(200 <= resp.StatusCode && resp.StatusCode < 300) { 195 | return "", fmt.Errorf("unexpected status code %d. response: %s", resp.StatusCode, string(bodyBytes)) 196 | } 197 | // == Construct rawUrl from Response == 198 | urlFrom := result.From[string](xmap.GetDeep[string](u.Definition, "upload.rawUrl.from")).ValueOrExit() 199 | switch urlFrom { 200 | case "json_response": 201 | var respJson map[string]interface{} 202 | err := json.Unmarshal(bodyBytes, &respJson) 203 | if err != nil { 204 | return "", errors.New("json response is not valid") 205 | } 206 | if !(200 <= resp.StatusCode && resp.StatusCode < 300) { 207 | return "", fmt.Errorf("response status code %d is not expected. resp: %s", resp.StatusCode, string(bodyBytes)) 208 | } 209 | rawUrlPath := result.From[string](xmap.GetDeep[string](u.Definition, "upload.rawUrl.path")).ValueOrExit() 210 | rawUrl, err = xmap.GetDeep[string](respJson, rawUrlPath) 211 | if err != nil { 212 | return "", errors.New("rawUrl path is not valid: " + err.Error()) 213 | } 214 | if len(rawUrl) == 0 { 215 | return "", fmt.Errorf("unable to get url. resp: %s", string(bodyBytes)) 216 | } 217 | xlog.GVerbose.Trace("got rawUrl from resp: " + rawUrl) 218 | 219 | case "text_response": 220 | rawUrl = string(bodyBytes) 221 | 222 | case "template": 223 | template := result.From[string](xmap.GetDeep[string](u.Definition, "upload.rawUrl.template")).ValueOrExit() 224 | rawUrl = u.replaceStringPlaceholder(template, *task) 225 | 226 | case "response_header": 227 | 228 | // read response header 229 | key := result.From[string](xmap.GetDeep[string](u.Definition, "upload.rawUrl.header")).ValueOrExit() 230 | rawUrl = resp.Header.Get(key) 231 | 232 | default: 233 | return "", errors.New("unsupported rawUrl source" + urlFrom) 234 | } 235 | return 236 | } 237 | 238 | func (u SimpleHttpUploader) buildMultipartFormData(task *model.Task, headerCache *http.Header) (body io.ReadCloser) { 239 | var bodyBuff bytes.Buffer 240 | mulWriter := multipart.NewWriter(&bodyBuff) 241 | bodyTpl := result.From[map[string]interface{}](xmap.GetDeep[map[string]interface{}](u.Definition, "http.request.body")).ValueOrExit() 242 | for fieldName, fieldMeta_ := range bodyTpl { 243 | xlog.GVerbose.Trace("processing field: " + fieldName) 244 | fieldMeta := fieldMeta_.(map[string]interface{}) 245 | fieldType := fieldMeta["type"] 246 | 247 | if fieldType == "string" { 248 | fieldValue := u.replaceStringPlaceholder(fieldMeta["value"].(string), *task) 249 | fieldValue = u.replaceStringPlaceholder(fieldValue, *task) 250 | mulWriter.WriteField(fieldName, fieldValue) 251 | xlog.GVerbose.Trace("field(string) value: " + fieldValue) 252 | 253 | } else if fieldType == "file" { 254 | fileName := filepath.Base(task.LocalPath) 255 | part := result.From[io.Writer](mulWriter.CreateFormFile(fieldName, fileName)).ValueOrExit() 256 | n, err := part.Write(result.From[[]byte](ioutil.ReadFile(task.LocalPath)).ValueOrExit()) 257 | xlog.AbortErr(err) 258 | xlog.GVerbose.Trace("field(file) value: "+"[file (len=%d, name=%s)]", n, fileName) 259 | 260 | } else if fieldType == "file_base64" { 261 | dat, err := ioutil.ReadFile(task.LocalPath) 262 | xlog.AbortErr(err) 263 | encoded := base64.StdEncoding.EncodeToString(dat) 264 | mulWriter.WriteField(fieldName, encoded) 265 | } 266 | } 267 | headerCache.Set("Content-Type", mulWriter.FormDataContentType()) 268 | mulWriter.Close() 269 | body = ioutil.NopCloser(bytes.NewReader(bodyBuff.Bytes())) 270 | return 271 | } 272 | 273 | func (u SimpleHttpUploader) Upload(t *model.Task) (err error) { 274 | now := time.Now() 275 | base := filepath.Base(t.LocalPath) 276 | 277 | if len(t.TargetDir) > 0 { 278 | t.TargetPath = t.TargetDir + "/" + base 279 | } else { 280 | t.TargetPath = xapp.Rename(base, now) 281 | t.TargetDir = filepath.Dir(t.TargetPath) 282 | } 283 | xlog.GVerbose.Trace("uploading #TASK_%d %s\n", t.TaskId, t.LocalPath) 284 | // var err error 285 | rawUrl, err := u.UploadFile(t) 286 | var url string 287 | if err == nil { 288 | url := xapp.ReplaceUrl(rawUrl) 289 | xlog.GVerbose.Trace("sucessfully uploaded #TASK_%d %s => %s\n", t.TaskId, t.LocalPath, url) 290 | t.Status = model.TASK_FINISHED 291 | } else { 292 | xlog.GVerbose.Trace("failed to upload #TASK_%d %s : %s\n", t.TaskId, t.LocalPath, err.Error()) 293 | t.Status = model.TASK_FAILED 294 | } 295 | t.RawUrl = rawUrl 296 | t.Url = url 297 | t.FinishTime = now 298 | return 299 | } 300 | -------------------------------------------------------------------------------- /lib/upyun/sdk.go: -------------------------------------------------------------------------------- 1 | package upyun 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "math" 10 | "net" 11 | "net/http" 12 | "os" 13 | "strconv" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | type UpYun struct { 19 | httpClient *http.Client 20 | trans *http.Transport 21 | bucketName string 22 | userName string 23 | passWord string 24 | apiDomain string 25 | contentMd5 string 26 | fileSecret string 27 | tmpHeaders map[string]string 28 | 29 | TimeOut int 30 | Debug bool 31 | } 32 | 33 | /** 34 | * 初始化 UpYun 存储接口 35 | * @param bucketName 空间名称 36 | * @param userName 操作员名称 37 | * @param passWord 密码 38 | * return UpYun object 39 | */ 40 | func NewUpYun(bucketName, userName, passWord string) *UpYun { 41 | u := new(UpYun) 42 | u.TimeOut = 300 43 | u.httpClient = &http.Client{} 44 | u.httpClient.Transport = &http.Transport{Dial: timeoutDialer(u.TimeOut)} 45 | u.bucketName = bucketName 46 | u.userName = userName 47 | u.passWord = StringMd5(passWord) 48 | u.apiDomain = "v0.api.upyun.com" 49 | u.Debug = false 50 | return u 51 | } 52 | 53 | func (u *UpYun) Version() string { 54 | return "1.0.1" 55 | } 56 | 57 | /** 58 | * 切换 API 接口的域名 59 | * @param domain { 60 | 默认 v0.api.upyun.com 自动识别, 61 | v1.api.upyun.com 电信, 62 | v2.api.upyun.com 联通, 63 | v3.api.upyun.com 移动 64 | } 65 | * return 无 66 | */ 67 | func (u *UpYun) SetApiDomain(domain string) { 68 | u.apiDomain = domain 69 | } 70 | 71 | /** 72 | * 设置连接超时时间 73 | * @param time 秒 74 | * return 无 75 | */ 76 | func (u *UpYun) SetTimeout(time int) { 77 | u.TimeOut = time 78 | u.httpClient.Transport = &http.Transport{Dial: timeoutDialer(u.TimeOut)} 79 | } 80 | 81 | /** 82 | * 设置待上传文件的 Content-MD5 值(如又拍云服务端收到的文件MD5值与用户设置的不一致, 83 | * 将回报 406 Not Acceptable 错误) 84 | * @param str (文件 MD5 校验码) 85 | * return 无 86 | */ 87 | func (u *UpYun) SetContentMD5(str string) { 88 | u.contentMd5 = str 89 | } 90 | 91 | /** 92 | * 连接签名方法 93 | * @param method 请求方式 {GET, POST, PUT, DELETE} 94 | * return 签名字符串 95 | */ 96 | func (u *UpYun) sign(method, uri, date string, length int64) string { 97 | var bufSign bytes.Buffer 98 | bufSign.WriteString(method) 99 | bufSign.WriteString("&") 100 | bufSign.WriteString(uri) 101 | bufSign.WriteString("&") 102 | bufSign.WriteString(date) 103 | bufSign.WriteString("&") 104 | bufSign.WriteString(strconv.FormatInt(length, 10)) 105 | bufSign.WriteString("&") 106 | bufSign.WriteString(u.passWord) 107 | 108 | var buf bytes.Buffer 109 | buf.WriteString("UpYun ") 110 | buf.WriteString(u.userName) 111 | buf.WriteString(":") 112 | buf.WriteString(StringMd5(bufSign.String())) 113 | return buf.String() 114 | } 115 | 116 | /** 117 | * 连接处理逻辑 118 | * @param method 请求方式 {GET, POST, PUT, DELETE} 119 | * @param uri 请求地址 120 | * @param inFile 如果是POST上传文件,传递文件IO数据流 121 | * @param outFile 如果是GET下载文件,可传递文件IO数据流,这种情况函数也返回"" 122 | * return 请求返回字符串,失败返回""(打开debug状态下遇到错误将中止程序执行) 123 | */ 124 | func (u *UpYun) httpAction(method, uri string, headers map[string]string, 125 | inFile, outFile *os.File) (string, error) { 126 | uri = "/" + u.bucketName + uri 127 | url := "http://" + u.apiDomain + uri 128 | req, err := http.NewRequest(method, url, nil) 129 | if err != nil { 130 | if u.Debug { 131 | fmt.Println(err) 132 | panic("http.NewRequest failed: " + err.Error()) 133 | } 134 | return "", err 135 | } 136 | 137 | for k, v := range headers { 138 | req.Header.Add(k, v) 139 | } 140 | 141 | length := FileSize(inFile) 142 | if u.Debug { 143 | fmt.Println("inFileSize: ", length) 144 | } 145 | 146 | if method == "PUT" || method == "POST" { 147 | method = "POST" 148 | if inFile != nil { 149 | if u.contentMd5 != "" { 150 | req.Header.Add("Content-MD5", u.contentMd5) 151 | u.contentMd5 = "" 152 | } 153 | if u.fileSecret != "" { 154 | req.Header.Add("Content-Secret", u.fileSecret) 155 | u.fileSecret = "" 156 | } 157 | req.Header.Add("Content-Length", strconv.FormatInt(length, 10)) 158 | req.Body = inFile 159 | req.ContentLength = length 160 | } 161 | } 162 | req.Method = method 163 | 164 | date := time.Now().UTC().Format(time.RFC1123) 165 | req.Header.Add("Date", date) 166 | req.Header.Add("Authorization", u.sign(method, uri, date, length)) 167 | 168 | if method == "HEAD" { 169 | req.Body = nil 170 | } 171 | 172 | if u.Debug { 173 | fmt.Println(req) 174 | } 175 | resp, err := u.httpClient.Do(req) 176 | if err != nil { 177 | if u.Debug { 178 | fmt.Println(resp.Status, err) 179 | panic("httpClient.Do failed: " + resp.Status + err.Error()) 180 | } 181 | return "", err 182 | } 183 | 184 | rc := resp.StatusCode 185 | if rc == 200 { 186 | u.tmpHeaders = make(map[string]string) 187 | for k, v := range resp.Header { 188 | if strings.Contains(k, "x-upyun") { 189 | u.tmpHeaders[k] = v[0] 190 | } 191 | } 192 | 193 | if method == "GET" && outFile != nil { 194 | _, err := io.Copy(outFile, resp.Body) 195 | if err != nil { 196 | if u.Debug { 197 | fmt.Printf("%v %v\n", rc, err) 198 | panic("write output file failed: ") 199 | } 200 | return "", err 201 | } 202 | return "", nil 203 | } 204 | 205 | buf := bytes.NewBuffer(make([]byte, 0, 8192)) 206 | buf.ReadFrom(resp.Body) 207 | return buf.String(), nil 208 | } 209 | 210 | return "", errors.New(resp.Status) 211 | } 212 | 213 | /** 214 | * 获取总体空间的占用信息 215 | * return 空间占用量,失败返回0.0 216 | */ 217 | func (u *UpYun) GetBucketUsage() (float64, error) { 218 | return u.GetFolderUsage("/") 219 | } 220 | 221 | /** 222 | * 获取某个子目录的占用信息 223 | * @param $path 目标路径 224 | * return 空间占用量和error,失败空间占用量返回0.0 225 | */ 226 | func (u *UpYun) GetFolderUsage(path string) (float64, error) { 227 | r, err := u.httpAction("GET", path+"?usage", nil, nil, nil) 228 | if err != nil { 229 | return 0.0, err 230 | } 231 | v, _ := strconv.ParseFloat(r, 64) 232 | return v, nil 233 | } 234 | 235 | /** 236 | * 设置待上传文件的 访问密钥(注意:仅支持图片空!,设置密钥后,无法根据原文件URL直接访问,需带 URL 后面加上 (缩略图间隔标志符+密钥) 进行访问) 237 | * 如缩略图间隔标志符为 ! ,密钥为 bac,上传文件路径为 /folder/test.jpg ,那么该图片的对外访问地址为: http://空间域名/folder/test.jpg!bac 238 | * @param $str (文件 MD5 校验码) 239 | * return null; 240 | */ 241 | func (u *UpYun) SetFileSecret(str string) { 242 | u.fileSecret = str 243 | } 244 | 245 | /** 246 | * 上传文件 247 | * @param filePath 文件路径(包含文件名) 248 | * @param inFile 文件IO数据流 249 | * @param autoMkdir 是否自动创建父级目录(最深10级目录) 250 | * return error 251 | */ 252 | func (u *UpYun) WriteFile(filePath string, inFile *os.File, autoMkdir bool) error { 253 | var headers map[string]string 254 | if autoMkdir { 255 | headers = make(map[string]string) 256 | headers["Mkdir"] = "true" 257 | //如果文件超过100kb,等比例压缩质量 258 | sta, err := inFile.Stat() 259 | if err != nil { 260 | return err 261 | } 262 | if sta.Size() > 102400 { //大于 100kb 263 | headers["x-gmkerl-type"] = "fix_scale" 264 | cof := math.Sqrt(102400) / math.Sqrt(float64(sta.Size())) * 100 265 | headers["x-gmkerl-value"] = strconv.FormatFloat(cof, 'f', 0, 64) //缩放 266 | headers["x-gmkerl-quality"] = "50" 267 | } else if sta.Size() > 102400/2 { //大于50kb 268 | headers["x-gmkerl-quality"] = "75" 269 | } 270 | } 271 | _, err := u.httpAction("PUT", filePath, headers, inFile, nil) 272 | return err 273 | } 274 | 275 | /** 276 | * 获取上传文件后的信息(仅图片空间有返回数据) 277 | * @param key 信息字段名(x-upyun-width、x-upyun-height、x-upyun-frames、x-upyun-file-type) 278 | * return string or "" 279 | */ 280 | func (u *UpYun) GetWritedFileInfo(key string) string { 281 | if u.tmpHeaders == nil { 282 | return "" 283 | } 284 | return u.tmpHeaders[strings.ToLower(key)] 285 | } 286 | 287 | /** 288 | * 读取文件 289 | * @param file 文件路径(包含文件名) 290 | * @param outFile 可传递文件IO数据流(结果返回true or false) 291 | * return error 292 | */ 293 | func (u *UpYun) ReadFile(file string, outFile *os.File) error { 294 | _, err := u.httpAction("GET", file, nil, nil, outFile) 295 | return err 296 | } 297 | 298 | /** 299 | * 获取文件信息 300 | * @param file 文件路径(包含文件名) 301 | * return array('type': file | folder, 'size': file size, 'date': unix time) 或 nil 302 | */ 303 | func (u *UpYun) GetFileInfo(file string) map[string]string { 304 | _, err := u.httpAction("HEAD", file, nil, nil, nil) 305 | if err != nil { 306 | return nil 307 | } 308 | if u.tmpHeaders == nil { 309 | return nil 310 | } 311 | m := make(map[string]string) 312 | if v, ok := u.tmpHeaders["x-upyun-file-type"]; ok { 313 | m["type"] = v 314 | } 315 | if v, ok := u.tmpHeaders["x-upyun-file-size"]; ok { 316 | m["size"] = v 317 | } 318 | if v, ok := u.tmpHeaders["x-upyun-file-date"]; ok { 319 | m["date"] = v 320 | } 321 | return m 322 | } 323 | 324 | type DirInfo struct { 325 | Name string 326 | Type string 327 | Size int64 328 | Time int64 329 | } 330 | 331 | /** 332 | * 读取目录列表 333 | * @param path 目录路径 334 | * return DirInfo数组 或 nil 335 | */ 336 | func (u *UpYun) ReadDir(path string) ([]*DirInfo, error) { 337 | r, err := u.httpAction("GET", path, nil, nil, nil) 338 | if err != nil { 339 | return nil, err 340 | } 341 | 342 | dirs := make([]*DirInfo, 0, 8) 343 | rs := strings.Split(r, "\n") 344 | for i := 0; i < len(rs); i++ { 345 | ri := strings.TrimSpace(rs[i]) 346 | rid := strings.Split(ri, "\t") 347 | d := new(DirInfo) 348 | d.Name = rid[0] 349 | if len(rid) > 3 && rid[3] != "" { 350 | if rid[1] == "N" { 351 | d.Type = "file" 352 | } else { 353 | d.Type = "folder" 354 | } 355 | d.Time, _ = strconv.ParseInt(rid[3], 10, 64) 356 | } 357 | if len(rid) > 2 { 358 | d.Size, _ = strconv.ParseInt(rid[2], 10, 64) 359 | } 360 | dirs = append(dirs, d) 361 | } 362 | return dirs, nil 363 | } 364 | 365 | /** 366 | * 删除文件 367 | * @param file 文件路径(包含文件名) 368 | * return error 369 | */ 370 | func (u *UpYun) DeleteFile(file string) error { 371 | _, err := u.httpAction("DELETE", file, nil, nil, nil) 372 | return err 373 | } 374 | 375 | /** 376 | * 创建目录 377 | * @param path 目录路径 378 | * @param auto_mkdir=false 是否自动创建父级目录 379 | * return error 380 | */ 381 | func (u *UpYun) MkDir(path string, autoMkdir bool) error { 382 | var headers map[string]string 383 | headers = make(map[string]string) 384 | headers["Folder"] = "true" 385 | if autoMkdir { 386 | headers["Mkdir"] = "true" 387 | } 388 | _, err := u.httpAction("PUT", path, headers, nil, nil) 389 | return err 390 | } 391 | 392 | /** 393 | * 删除目录 394 | * @param path 目录路径 395 | * return error 396 | */ 397 | func (u *UpYun) RmDir(dir string) error { 398 | _, err := u.httpAction("DELETE", dir, nil, nil, nil) 399 | return err 400 | } 401 | 402 | func FileSize(f *os.File) int64 { 403 | if f == nil { 404 | return 0 405 | } 406 | if fi, err := f.Stat(); err == nil { 407 | return fi.Size() 408 | } 409 | return 0 410 | } 411 | 412 | func StringMd5(s string) string { 413 | h := md5.New() 414 | io.WriteString(h, s) 415 | return fmt.Sprintf("%x", h.Sum(nil)) 416 | } 417 | 418 | 419 | func timeoutDialer(timeout int) func(string, string) (net.Conn, error) { 420 | return func(netw, addr string) (c net.Conn, err error) { 421 | delta := time.Duration(timeout) * time.Second 422 | c, err = net.DialTimeout(netw, addr, delta) 423 | if err != nil { 424 | return nil, err 425 | } 426 | c.SetDeadline(time.Now().Add(delta)) 427 | return c, nil 428 | } 429 | } 430 | -------------------------------------------------------------------------------- /lib/upyun/uploader.go: -------------------------------------------------------------------------------- 1 | package upyun 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "time" 8 | 9 | "github.com/pluveto/upgit/lib/model" 10 | "github.com/pluveto/upgit/lib/xapp" 11 | "github.com/pluveto/upgit/lib/xlog" 12 | ) 13 | 14 | type UpyunConfig struct { 15 | Host string `toml:"host" mapstructure:"host" validate:"nonzero"` 16 | BucketName string `toml:"bucket_name" mapstructure:"bucket_name" validate:"nonzero"` 17 | UserName string `toml:"user_name" mapstructure:"user_name" validate:"nonzero"` 18 | PassWord string `toml:"pass_word" mapstructure:"pass_word" validate:"nonzero"` 19 | } 20 | 21 | type UpyunUploader struct { 22 | Config UpyunConfig 23 | } 24 | 25 | var urlfmt = "https://{host}/{path}" 26 | 27 | func (u UpyunUploader) Upload(t *model.Task) error { 28 | now := time.Now() 29 | name := filepath.Base(t.LocalPath) 30 | var targetPath string 31 | if len(t.TargetDir) > 0 { 32 | targetPath = t.TargetDir + "/" + name 33 | } else { 34 | targetPath = xapp.Rename(name, now) 35 | } 36 | rawUrl := u.buildUrl(urlfmt, targetPath) 37 | url := xapp.ReplaceUrl(rawUrl) 38 | xlog.GVerbose.Info("uploading #TASK_%d %s\n", t.TaskId, t.LocalPath) 39 | // var err error 40 | err := u.PutFile(t.LocalPath, targetPath) 41 | if err == nil { 42 | xlog.GVerbose.Info("sucessfully uploaded #TASK_%d %s => %s\n", t.TaskId, t.LocalPath, url) 43 | t.Status = model.TASK_FINISHED 44 | t.Url = url 45 | t.FinishTime = time.Now() 46 | t.RawUrl = rawUrl 47 | } else { 48 | xlog.GVerbose.Info("failed to upload #TASK_%d %s : %s\n", t.TaskId, t.LocalPath, err.Error()) 49 | t.Status = model.TASK_FAILED 50 | t.FinishTime = time.Now() 51 | } 52 | return err 53 | } 54 | 55 | func (u *UpyunUploader) buildUrl(urlfmt, path string) string { 56 | r := strings.NewReplacer( 57 | "{host}", u.Config.Host, 58 | "{path}", path, 59 | ) 60 | return r.Replace(urlfmt) 61 | } 62 | 63 | func (u *UpyunUploader) PutFile(localPath, targetPath string) (err error) { 64 | upyun := NewUpYun(u.Config.BucketName, u.Config.UserName, u.Config.PassWord) 65 | file, err := os.OpenFile(localPath, os.O_RDONLY, 0644) 66 | if err != nil { 67 | return err 68 | } 69 | err = upyun.WriteFile(targetPath, file, true) 70 | return 71 | } 72 | -------------------------------------------------------------------------------- /lib/xapp/cfg.go: -------------------------------------------------------------------------------- 1 | package xapp 2 | 3 | type Config struct { 4 | DefaultUploader string `toml:"default_uploader,omitempty"` 5 | Rename string `toml:"rename,omitempty"` 6 | Replacements map[string]string `toml:"replacements,omitempty"` 7 | OutputFormats map[string]string `toml:"output_formats,omitempty"` 8 | } 9 | 10 | var AppCfg Config 11 | -------------------------------------------------------------------------------- /lib/xapp/cli.go: -------------------------------------------------------------------------------- 1 | package xapp 2 | 3 | type OutputType string 4 | 5 | const ( 6 | O_Stdout OutputType = "stdout" 7 | O_Clipboard = "clipboard" 8 | ) 9 | 10 | const kRepoURL = "https://github.com/pluveto/upgit" 11 | 12 | type CLIOptions struct { 13 | LocalPaths []string `arg:"positional, required" placeholder:"FILE" help:"local file path to upload. :clipboard for uploading clipboard image"` 14 | TargetDir string `arg:"-t,--target-dir" help:"upload file with original name to given directory. if not set, will use renaming rules"` 15 | Verbose bool `arg:"-V,--verbose" help:"when set, output more details to help developers"` 16 | SizeLimit *int64 `arg:"-s,--size-limit" help:"in bytes. overwrite default size limit (5MiB). 0 means no limit"` 17 | Wait bool `arg:"-w,--wait" help:"when set, not exit after upload, util user press any key"` 18 | ConfigFile string `arg:"-c,--config-file" help:"when set, will use specific config file"` 19 | Clean bool `arg:"-C,--clean" help:"when set, remove local file after upload"` 20 | Raw bool `arg:"-r,--raw" help:"when set, output non-replaced raw url"` 21 | NoLog bool `arg:"-n,--no-log" help:"when set, disable logging"` 22 | Uploader string `arg:"-u,--uploader" help:"uploader to use. if not set, will follow config"` 23 | OutputType OutputType `arg:"-o,--output-type" help:"output type, supports stdout, clipboard" default:"stdout"` 24 | OutputFormat string `arg:"-f,--output-format" help:"output format, supports url, markdown and your customs" default:"url"` 25 | 26 | ApplicationPath string `arg:"--application-path" help:"custom application path, which determines config file path and extensions dir path. current binary dir by default"` 27 | } 28 | 29 | func (CLIOptions) Description() string { 30 | return "\n" + 31 | "Upload anything to github repo or other remote storages and then get its link.\n" + 32 | "For more information: " + kRepoURL + "\n" 33 | } 34 | 35 | var AppOpt CLIOptions 36 | -------------------------------------------------------------------------------- /lib/xapp/shared.go: -------------------------------------------------------------------------------- 1 | package xapp 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | "io/ioutil" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/mitchellh/mapstructure" 12 | "github.com/pelletier/go-toml/v2" 13 | "github.com/pluveto/upgit/lib/xpath" 14 | ) 15 | 16 | const UserAgent = "UPGIT/0.2" 17 | const DefaultBranch = "master" 18 | 19 | // case insensitive 20 | const ClipboardPlaceholder = ":clipboard" 21 | const ClipboardFilePlaceholder = ":clipboard-file" 22 | 23 | var MaxUploadSize = int64(5 * 1024 * 1024) 24 | var ConfigFilePath string 25 | 26 | func Rename(path string, time time.Time) (ret string) { 27 | 28 | base := xpath.Basename(path) 29 | ext := filepath.Ext(path) 30 | md5HashStr := fmt.Sprintf("%x", md5.Sum([]byte(base))) 31 | r := strings.NewReplacer( 32 | "{year}", time.Format("2006"), 33 | "{month}", time.Format("01"), 34 | "{day}", time.Format("02"), 35 | "{hour}", time.Format("15"), 36 | "{minute}", time.Format("04"), 37 | "{second}", time.Format("05"), 38 | "{unixts}", fmt.Sprint(time.Unix()), 39 | "{unixtsms}", fmt.Sprint(time.UnixMicro()), 40 | "{ext}", ext, 41 | "{fullname}", base+ext, 42 | "{filename}", base, 43 | "{fname}", base, 44 | "{filenamehash}", md5HashStr, 45 | "{fnamehash}", md5HashStr, 46 | "{fnamehash4}", md5HashStr[:4], 47 | "{fnamehash8}", md5HashStr[:8], 48 | ) 49 | ret = r.Replace(AppCfg.Rename) 50 | return 51 | } 52 | func ReplaceUrl(path string) (ret string) { 53 | var rules []string 54 | for k, v := range AppCfg.Replacements { 55 | rules = append(rules, k, v) 56 | } 57 | r := strings.NewReplacer(rules...) 58 | ret = r.Replace(path) 59 | return 60 | } 61 | 62 | func LoadUploaderConfig[T any](uploaderId string) (ret T, err error) { 63 | var mCfg map[string]interface{} 64 | bytes, err := ioutil.ReadFile(ConfigFilePath) 65 | if err != nil { 66 | return 67 | } 68 | err = toml.Unmarshal(bytes, &mCfg) 69 | if err != nil { 70 | return 71 | } 72 | cfgMap := mCfg["uploaders"].(map[string]interface{})[uploaderId] 73 | var cfg_ T 74 | mapstructure.Decode(cfgMap, &cfg_) 75 | ret = cfg_ 76 | return 77 | } 78 | -------------------------------------------------------------------------------- /lib/xapp/upload.go: -------------------------------------------------------------------------------- 1 | package xapp 2 | 3 | -------------------------------------------------------------------------------- /lib/xclipboard/clipboard_darwin.go: -------------------------------------------------------------------------------- 1 | package xclipboard 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | func ReadClipboardImage() (buf []byte, err error) { 8 | return nil, errors.New("unsupported for your operation system") 9 | } 10 | -------------------------------------------------------------------------------- /lib/xclipboard/clipboard_linux.go: -------------------------------------------------------------------------------- 1 | package xclipboard 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | func ReadClipboardImage() (buf []byte, err error) { 8 | return nil, errors.New("unsupported for your operation system") 9 | } 10 | -------------------------------------------------------------------------------- /lib/xclipboard/clipboard_windows.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Modified based on 3 | * https://github.com/robinchenyu/clipboard_go 4 | */ 5 | package xclipboard 6 | 7 | import ( 8 | "bytes" 9 | "encoding/binary" 10 | "fmt" 11 | "image/png" 12 | "syscall" 13 | "unsafe" 14 | 15 | "golang.org/x/image/bmp" 16 | ) 17 | 18 | // see https://docs.microsoft.com/en-us/windows/win32/dataxchg/standard-clipboard-formats 19 | const ( 20 | CF_BITMAP = 2 21 | CF_DIB = 8 22 | CF_UNICODETEXT = 13 23 | CF_DIBV5 = 17 24 | ) 25 | 26 | // see https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapfileheader 27 | type fileHeader struct { 28 | bfType uint16 29 | bfSize uint32 30 | bfReserved1 uint16 31 | bfReserved2 uint16 32 | bfOffBits uint32 33 | } 34 | 35 | type infoHeader struct { 36 | iSize uint32 37 | iWidth uint32 38 | iHeight uint32 39 | iPLanes uint16 40 | iBitCount uint16 41 | iCompression uint32 42 | iSizeImage uint32 43 | iXPelsPerMeter uint32 44 | iYPelsPerMeter uint32 45 | iClrUsed uint32 46 | iClrImportant uint32 47 | } 48 | 49 | var ( 50 | user32 = syscall.MustLoadDLL("user32") 51 | openClipboard = user32.MustFindProc("OpenClipboard") 52 | closeClipboard = user32.MustFindProc("CloseClipboard") 53 | getClipboardData = user32.MustFindProc("GetClipboardData") 54 | isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable") 55 | 56 | kernel32 = syscall.NewLazyDLL("kernel32") 57 | globalLock = kernel32.NewProc("GlobalLock") 58 | globalUnlock = kernel32.NewProc("GlobalUnlock") 59 | ) 60 | 61 | func copyInfoHeader(dst *byte, pSrc *infoHeader) { 62 | pdst := (*infoHeader)(unsafe.Pointer(dst)) 63 | pdst.iSize = pSrc.iSize 64 | pdst.iWidth = pSrc.iWidth 65 | pdst.iHeight = pSrc.iHeight 66 | pdst.iPLanes = pSrc.iPLanes 67 | pdst.iBitCount = pSrc.iBitCount 68 | pdst.iCompression = pSrc.iCompression 69 | pdst.iSizeImage = pSrc.iSizeImage 70 | pdst.iXPelsPerMeter = pSrc.iXPelsPerMeter 71 | pdst.iYPelsPerMeter = pSrc.iYPelsPerMeter 72 | pdst.iClrUsed = pSrc.iClrUsed 73 | pdst.iClrImportant = pSrc.iClrImportant 74 | } 75 | 76 | func readUint16(b []byte) uint16 { 77 | return uint16(b[0]) | uint16(b[1])<<8 78 | } 79 | 80 | func readUint32(b []byte) uint32 { 81 | return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24 82 | } 83 | 84 | func ReadClipboardImage() (buf []byte, err error) { 85 | const ( 86 | fileHeaderLen = 14 87 | infoHeaderLen = 40 88 | ) 89 | 90 | succ, _, err := openClipboard.Call(0) 91 | if succ == 0 { 92 | return nil, fmt.Errorf("failed to open clipboard: " + err.Error()) 93 | } 94 | defer closeClipboard.Call() 95 | 96 | succ, _, err = isClipboardFormatAvailable.Call(CF_DIB) 97 | if succ == 0 { 98 | return nil, fmt.Errorf("false on IsClipboardFormatAvailable: " + err.Error()) 99 | } 100 | 101 | hClipObj, _, err := getClipboardData.Call(CF_DIB) 102 | if succ == 0 { 103 | err = syscall.GetLastError() 104 | return nil, fmt.Errorf("failed to get clipboard data: " + err.Error()) 105 | } 106 | 107 | pMemBlk, _, err := globalLock.Call(hClipObj) 108 | if pMemBlk == 0 { 109 | return nil, fmt.Errorf("failed to call global lock: " + err.Error()) 110 | } 111 | defer globalUnlock.Call(hClipObj) 112 | 113 | clipObjHeader := (*infoHeader)(unsafe.Pointer(pMemBlk)) 114 | dataSize := clipObjHeader.iSizeImage + fileHeaderLen + infoHeaderLen 115 | 116 | if clipObjHeader.iSizeImage == 0 && clipObjHeader.iCompression == 0 { 117 | iSizeImage := clipObjHeader.iHeight * ((clipObjHeader.iWidth*uint32(clipObjHeader.iBitCount)/8 + 3) &^ 3) 118 | dataSize += iSizeImage 119 | } 120 | bmpBuf := new(bytes.Buffer) 121 | binary.Write(bmpBuf, binary.LittleEndian, uint16('B')|(uint16('M')<<8)) 122 | binary.Write(bmpBuf, binary.LittleEndian, uint32(dataSize)) 123 | binary.Write(bmpBuf, binary.LittleEndian, uint32(0)) 124 | const sizeof_colorbar = 0 125 | binary.Write(bmpBuf, binary.LittleEndian, uint32(fileHeaderLen+infoHeaderLen+sizeof_colorbar)) 126 | j := 0 127 | for i := fileHeaderLen; i < int(dataSize); i++ { 128 | binary.Write(bmpBuf, binary.BigEndian, *(*byte)(unsafe.Pointer(pMemBlk + uintptr(j)))) 129 | j++ 130 | } 131 | return bmpToPng(bmpBuf) 132 | } 133 | 134 | func bmpToPng(bmpBuf *bytes.Buffer) (buf []byte, err error) { 135 | var f bytes.Buffer 136 | original_image, err := bmp.Decode(bmpBuf) 137 | if err != nil { 138 | return nil, err 139 | } 140 | err = png.Encode(&f, original_image) 141 | if err != nil { 142 | return nil, err 143 | } 144 | return f.Bytes(), nil 145 | } 146 | -------------------------------------------------------------------------------- /lib/xclipboard/clipboard_windows_test.go: -------------------------------------------------------------------------------- 1 | /** 2 | * Modified based on 3 | * https://github.com/robinchenyu/clipboard_go 4 | */ 5 | 6 | package xclipboard 7 | 8 | import ( 9 | "io/fs" 10 | "os" 11 | "strings" 12 | "testing" 13 | ) 14 | 15 | func TestReadClipboard(t *testing.T) { 16 | buff, err := ReadClipboardImage() 17 | if err != nil { 18 | if strings.Contains(err.Error(), "IsClipboardFormatAvailable") { 19 | t.Skipf("Skipped because clipboard has no image") 20 | return 21 | } 22 | t.Errorf(err.Error()) 23 | } 24 | os.WriteFile("cliboard_test.png", buff, os.FileMode(fs.ModePerm)) 25 | } 26 | -------------------------------------------------------------------------------- /lib/xext/xext.go: -------------------------------------------------------------------------------- 1 | package xext 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "path/filepath" 8 | 9 | "github.com/fatih/color" 10 | "github.com/pluveto/upgit/lib/xstrings" 11 | ) 12 | 13 | type ExtDef struct { 14 | Meta struct { 15 | Id string `mapstructure:"id"` 16 | Name string `mapstructure:"name"` 17 | Author string `mapstructure:"author"` 18 | Description string `mapstructure:"description"` 19 | Type string `mapstructure:"type"` 20 | Version string `mapstructure:"version"` 21 | Repository string `mapstructure:"repository"` 22 | } `mapstructure:"meta"` 23 | } 24 | 25 | func (e ExtDef) GetId() string { 26 | return e.Meta.Id 27 | } 28 | 29 | func (e ExtDef) DisplaySimple(prefix, suffix string) { 30 | // Gitub Uploader (id: github) v1.0.0 - Description 31 | fmt.Print(prefix) 32 | fmt.Printf("%-32s", color.CyanString(e.Meta.Name)) 33 | fmt.Printf("id: %-20s", color.YellowString(e.Meta.Id)) 34 | if len(e.Meta.Version) > 0 { 35 | fmt.Printf(" v%-8s", e.Meta.Version) 36 | } 37 | if len(e.Meta.Description) > 0 { 38 | fmt.Print("- " + e.Meta.Description) 39 | } 40 | fmt.Print(suffix) 41 | } 42 | 43 | func GetExtDefinitionInterface(extDir, fname string) (map[string]interface{}, error) { 44 | jsonBytes, err := ioutil.ReadFile(filepath.Join(extDir, fname)) 45 | if err != nil { 46 | return nil, err 47 | } 48 | jsonBytes = xstrings.RemoveJsoncComments(jsonBytes) 49 | var uploaderDef map[string]interface{} 50 | err = json.Unmarshal(jsonBytes, &uploaderDef) 51 | return uploaderDef, err 52 | } 53 | 54 | func GetExtDefinition(extDir, fname string) (ExtDef, error) { 55 | jsonBytes, err := ioutil.ReadFile(filepath.Join(extDir, fname)) 56 | if err != nil { 57 | return ExtDef{}, err 58 | } 59 | jsonBytes = xstrings.RemoveJsoncComments(jsonBytes) 60 | var uploaderDef ExtDef 61 | err = json.Unmarshal(jsonBytes, &uploaderDef) 62 | return uploaderDef, err 63 | } 64 | -------------------------------------------------------------------------------- /lib/xgithub/xgithub.go: -------------------------------------------------------------------------------- 1 | package xgithub 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "regexp" 9 | 10 | "github.com/pluveto/upgit/lib/xapp" 11 | ) 12 | 13 | type List []struct { 14 | Name string `json:"name"` 15 | Path string `json:"path"` 16 | Sha string `json:"sha"` 17 | Size int `json:"size"` 18 | URL string `json:"url"` 19 | HTMLURL string `json:"html_url"` 20 | GitURL string `json:"git_url"` 21 | DownloadURL string `json:"download_url"` 22 | Type string `json:"type"` 23 | Links Links `json:"_links"` 24 | } 25 | type Links struct { 26 | Self string `json:"self"` 27 | Git string `json:"git"` 28 | HTML string `json:"html"` 29 | } 30 | 31 | func trimSlash(path string) string { 32 | if path[0] == '/' { 33 | path = path[1:] 34 | } 35 | if path[len(path)-1] == '/' { 36 | path = path[:len(path)-1] 37 | } 38 | return path 39 | } 40 | 41 | // ListFolder 42 | // repo: pluveto/upgit 43 | func ListFolder(repo string, path string) (List, error) { 44 | url := "https://api.github.com/repos/" + repo + "/contents/" + trimSlash(path) 45 | // logger.Trace("GET " + url) 46 | req, err := http.NewRequest(http.MethodGet, url, nil) 47 | if err != nil { 48 | return nil, err 49 | } 50 | req.Header.Set("User-Agent", xapp.UserAgent) 51 | req.Header.Set("Accept", "application/vnd.github.v3+json") 52 | req.Header.Set("Content-Type", "application/json") 53 | // req.Header.Set("Authorization", "token "+PAT) 54 | resp, err := http.DefaultClient.Do(req) 55 | if err != nil { 56 | return nil, err 57 | } 58 | body, err := ioutil.ReadAll(resp.Body) 59 | if err != nil { 60 | return nil, err 61 | } 62 | // logger.Trace("response body: " + string(body)) 63 | if !(200 <= resp.StatusCode && resp.StatusCode < 300) { 64 | return nil, fmt.Errorf("%d %s", resp.StatusCode, body) 65 | } 66 | var list List 67 | err = json.Unmarshal(body, &list) 68 | if err != nil { 69 | return nil, err 70 | } 71 | return list, nil 72 | } 73 | 74 | func GetFile(repo string, branch string, path string) ([]byte, error) { 75 | url := "https://raw.githubusercontent.com/" + repo + "/" + branch + "/" + trimSlash(path) 76 | // logger.Trace("GET " + url) 77 | req, err := http.NewRequest(http.MethodGet, url, nil) 78 | if err != nil { 79 | return nil, err 80 | } 81 | req.Header.Set("User-Agent", xapp.UserAgent) 82 | req.Header.Set("Accept", "application/vnd.github.v3+json") 83 | req.Header.Set("Content-Type", "application/json") 84 | // req.Header.Set("Authorization", "token "+PAT) 85 | resp, err := http.DefaultClient.Do(req) 86 | if err != nil { 87 | return nil, err 88 | } 89 | bodyBuf, err := ioutil.ReadAll(resp.Body) 90 | if err != nil { 91 | return nil, err 92 | } 93 | // logger.Trace("response body: " + string(body)) 94 | if !(200 <= resp.StatusCode && resp.StatusCode < 300) { 95 | return nil, fmt.Errorf("%d %s", resp.StatusCode, bodyBuf) 96 | } 97 | return bodyBuf, nil 98 | } 99 | 100 | func GetLatestReleaseDownloadUrl(repo string) (string, error) { 101 | url := "https://api.github.com/repos/" + repo + "/releases/latest" 102 | // logger.Trace("GET " + url) 103 | req, err := http.NewRequest(http.MethodGet, url, nil) 104 | if err != nil { 105 | return "", err 106 | } 107 | req.Header.Set("User-Agent", xapp.UserAgent) 108 | req.Header.Set("Accept", "application/vnd.github.v3+json") 109 | req.Header.Set("Content-Type", "application/json") 110 | resp, err := http.DefaultClient.Do(req) 111 | if err != nil { 112 | return "", err 113 | } 114 | bodyBuf, err := ioutil.ReadAll(resp.Body) 115 | if err != nil { 116 | return "", err 117 | } 118 | jsonStr := string(bodyBuf) 119 | // find "browser_download_url": "{link}" 120 | re := regexp.MustCompile(`"browser_download_url":\s*"([^"]+)"`) 121 | matches := re.FindStringSubmatch(jsonStr) 122 | if len(matches) < 2 { 123 | return "", fmt.Errorf("cannot find browser_download_url in %s", jsonStr) 124 | } 125 | return matches[1], nil 126 | } 127 | -------------------------------------------------------------------------------- /lib/xhttp/xhttp.go: -------------------------------------------------------------------------------- 1 | package xhttp 2 | 3 | // https://gist.github.com/cnu/026744b1e86c6d9e22313d06cba4c2e9 4 | 5 | import ( 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "strings" 11 | ) 12 | 13 | // WriteCounter counts the number of bytes written to it. By implementing the Write method, 14 | // it is of the io.Writer interface and we can pass this into io.TeeReader() 15 | // Every write to this writer, will print the progress of the file write. 16 | type WriteCounter struct { 17 | Total uint64 18 | } 19 | 20 | func (wc *WriteCounter) Write(p []byte) (int, error) { 21 | n := len(p) 22 | wc.Total += uint64(n) 23 | wc.PrintProgress() 24 | return n, nil 25 | } 26 | 27 | func humanizeBytes(bytes uint64) string { 28 | const unit = 1024 29 | if bytes < unit { 30 | return fmt.Sprintf("%d B", bytes) 31 | } 32 | div, exp := int64(unit), 0 33 | for n := bytes / unit; n >= unit; n /= unit { 34 | div *= unit 35 | exp++ 36 | } 37 | return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "kMGTPE"[exp]) 38 | } 39 | 40 | // PrintProgress prints the progress of a file write 41 | func (wc WriteCounter) PrintProgress() { 42 | // Clear the line by using a character return to go back to the start and remove 43 | // the remaining characters by filling it with spaces 44 | fmt.Printf("\r%s", strings.Repeat(" ", 50)) 45 | 46 | // Return again and print current status of download 47 | // We use the humanize package to print the bytes in a meaningful way (e.g. 10 MB) 48 | fmt.Printf("\rDownloading... %s complete", humanizeBytes(wc.Total)) 49 | } 50 | 51 | // DownloadFile will download a url and store it in local filepath. 52 | // It writes to the destination file as it downloads it, without 53 | // loading the entire file into memory. 54 | // We pass an io.TeeReader into Copy() to report progress on the download. 55 | func DownloadFile(url string, filepath string) error { 56 | 57 | // Create the file with .tmp extension, so that we won't overwrite a 58 | // file until it's downloaded fully 59 | out, err := os.Create(filepath) 60 | if err != nil { 61 | return err 62 | } 63 | defer out.Close() 64 | 65 | // Get the data 66 | resp, err := http.Get(url) 67 | if err != nil { 68 | return err 69 | } 70 | defer resp.Body.Close() 71 | 72 | // Create our bytes counter and pass it to be used alongside our writer 73 | counter := &WriteCounter{} 74 | _, err = io.Copy(out, io.TeeReader(resp.Body, counter)) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | // The progress use the same line so print a new line once it's finished downloading 80 | fmt.Println() 81 | 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /lib/xio/xio.go: -------------------------------------------------------------------------------- 1 | package xio 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | func AppendToFile(filePath string, data []byte) { 9 | err := os.MkdirAll(filepath.Dir(filePath), 0755) 10 | panicErrWithoutLog(err) 11 | file, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0755) 12 | defer file.Close() 13 | panicErrWithoutLog(err) 14 | file.Write(data) 15 | } 16 | func panicErrWithoutLog(err error) { 17 | if err != nil { 18 | panic(err) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/xlog/verbose.go: -------------------------------------------------------------------------------- 1 | package xlog 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime/debug" 7 | "strings" 8 | "time" 9 | 10 | "github.com/pelletier/go-toml/v2" 11 | "github.com/pluveto/upgit/lib/xio" 12 | ) 13 | 14 | // GVerbose is a global verbose 15 | var GVerbose Verbose 16 | 17 | type Verbose struct { 18 | VerboseEnabled bool 19 | LogEnabled bool 20 | LogFile string 21 | LogFileMaxSize int64 22 | } 23 | 24 | func (v Verbose) Trace(fmt_ string, args ...interface{}) { 25 | _, message := toMessage("[TRACE] ", fmt_, args...) 26 | if v.VerboseEnabled { 27 | fmt.Printf(message) 28 | } 29 | } 30 | 31 | func toMessage(level, fmt_ string, args ...interface{}) (string, string) { 32 | // better format multiple lines output 33 | fmtMulLine_ := strings.TrimRight(strings.ReplaceAll(fmt_, "\n", "\n "), " \n") 34 | messageNoTime := fmt.Sprintf(level+fmtMulLine_+"\n", args...) 35 | message := time.Now().String() + messageNoTime 36 | return message, messageNoTime 37 | } 38 | 39 | func (v Verbose) Info(fmt_ string, args ...interface{}) { 40 | v.Log("[INFO ] ", fmt_, args...) 41 | } 42 | 43 | func (v Verbose) Error(fmt_ string, args ...interface{}) { 44 | v.Log("[ERROR] ", fmt_, args...) 45 | } 46 | 47 | func (v Verbose) Log(level, fmt_ string, args ...interface{}) { 48 | log, message := toMessage(level, fmt_, args...) 49 | if v.VerboseEnabled { 50 | fmt.Printf(message) 51 | } 52 | if v.LogEnabled && len(v.LogFile) > 0 { 53 | xio.AppendToFile(v.LogFile, []byte(log)) 54 | if strings.Contains(level, "[ERROR]") { 55 | xio.AppendToFile(v.LogFile, []byte(debug.Stack())) 56 | } 57 | } 58 | } 59 | 60 | func (v Verbose) TruncatLog() { 61 | doTrunc := false 62 | info, err := os.Stat(v.LogFile) 63 | if err == nil && v.LogFileMaxSize != 0 { 64 | if info.Size() >= v.LogFileMaxSize { 65 | doTrunc = true 66 | } 67 | } 68 | if !doTrunc { 69 | return 70 | } 71 | var truncSize = v.LogFileMaxSize / 2 72 | file, err := os.OpenFile(v.LogFile, os.O_RDWR, 0755) 73 | panicErrWithoutLog(err) 74 | defer file.Close() 75 | file.Seek(truncSize, 0) 76 | file.Truncate(truncSize) 77 | } 78 | 79 | func (v Verbose) TraceStruct(s interface{}) { 80 | if !v.VerboseEnabled { 81 | return 82 | } 83 | b, err := toml.Marshal(s) 84 | if err == nil { 85 | GVerbose.Trace(string(b)) 86 | } else { 87 | GVerbose.Trace(err.Error()) 88 | } 89 | } 90 | 91 | func AbortErr(err error) { 92 | if err != nil { 93 | GVerbose.Error("abort: " + err.Error()) 94 | os.Stderr.WriteString(err.Error() + "\n") 95 | os.Exit(1) 96 | } 97 | } 98 | 99 | func panicErr(err error) { 100 | if err != nil { 101 | GVerbose.Error("panic: " + err.Error()) 102 | panic(err) 103 | } 104 | } 105 | 106 | func panicErrWithoutLog(err error) { 107 | if err != nil { 108 | panic(err) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /lib/xlog/xlog.go: -------------------------------------------------------------------------------- 1 | package xlog -------------------------------------------------------------------------------- /lib/xmap/xmap.go: -------------------------------------------------------------------------------- 1 | package xmap 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | func GetDeep[T any](m map[string]interface{}, path string) (ret T, err error) { 11 | if m == nil { 12 | err = errors.New("map is nil") 13 | return 14 | } 15 | 16 | if path == "" { 17 | ret = interface{}(m).(T) 18 | return 19 | } 20 | 21 | keys := strings.Split(path, ".") 22 | for i := 0; i < len(keys); i++ { 23 | key := keys[i] 24 | if key == "" { 25 | continue 26 | } 27 | // match xxx[i] (array form) 28 | // $1 $2 29 | r := regexp.MustCompile(`^(.*)\[(\d+)\]$`) 30 | arrIndex := 0 31 | matches := r.FindAllString(key, -1) 32 | if len(matches) > 0 { 33 | key = string(matches[0][1]) 34 | arrIndex, _ = strconv.Atoi(string(matches[0][2])) 35 | } 36 | 37 | if m == nil { 38 | err = errors.New("map is nil") 39 | return 40 | } 41 | 42 | if v, ok := m[key]; ok { 43 | switch v.(type) { 44 | case []interface{}: 45 | return v.([]interface{})[arrIndex].(T), nil 46 | case map[string]interface{}: 47 | m = v.(map[string]interface{}) 48 | default: 49 | return v.(T), nil 50 | } 51 | } else { 52 | err = errors.New("for path " + path + ", key " + key + " not found") 53 | return 54 | } 55 | } 56 | return interface{}(m).(T), nil 57 | } 58 | -------------------------------------------------------------------------------- /lib/xnetwork/xnetwork.go: -------------------------------------------------------------------------------- 1 | package xnetwork 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | func DownloadFileToFolder(url string, dir string) (err error) { 12 | os.MkdirAll(dir, 0755) 13 | out, err := os.Create(filepath.Join(dir, filepath.Base(url))) 14 | if err != nil { 15 | return 16 | } 17 | defer out.Close() 18 | err = DownloadFile(url, out) 19 | return 20 | } 21 | 22 | func DownloadFile(url string, out *os.File) (err error) { 23 | resp, err := http.Get(url) 24 | if err != nil { 25 | return 26 | } 27 | // check statuscode 28 | if resp.StatusCode != http.StatusOK { 29 | err = errors.New("unexpected statuscode: " + resp.Status) 30 | return 31 | } 32 | defer resp.Body.Close() 33 | _, err = io.Copy(out, resp.Body) 34 | return 35 | } 36 | -------------------------------------------------------------------------------- /lib/xpath/xpath.go: -------------------------------------------------------------------------------- 1 | package xpath 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/pluveto/upgit/lib/xlog" 9 | ) 10 | 11 | func Basename(path string) string { 12 | if pos := strings.LastIndexByte(path, '.'); pos != -1 { 13 | return filepath.Base(path[:pos]) 14 | } 15 | return filepath.Base(path) 16 | 17 | } 18 | 19 | var ApplicationPath string 20 | 21 | func GetApplicationPath() (path string, err error) { 22 | if ApplicationPath != "" { 23 | return ApplicationPath, nil 24 | } 25 | exec, err := os.Executable() 26 | if err != nil { 27 | return 28 | } 29 | path = filepath.Dir(exec) 30 | return 31 | } 32 | 33 | func MustGetApplicationPath(append string) string { 34 | path, err := GetApplicationPath() 35 | if err != nil { 36 | xlog.AbortErr(err) 37 | } 38 | return filepath.Join(path, append) 39 | } 40 | -------------------------------------------------------------------------------- /lib/xstrings/xstrings.go: -------------------------------------------------------------------------------- 1 | package xstrings 2 | 3 | import ( 4 | "bytes" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // VariableReplace Replace vairable placeholder like $(a.b.c) to value using map 10 | func VariableReplace(s, delimiterLeft, delimiterRight string, dict map[string]string) string { 11 | ret := s 12 | for k, v := range dict { 13 | ret = strings.Replace(ret, delimiterLeft+k+delimiterRight, v, -1) 14 | } 15 | return ret 16 | } 17 | 18 | // VariableReplaceFunc Replace vairable placeholder like $(a.b.c) to value using map function 19 | func VariableReplaceFunc(s, delimiterLeft, delimiterRight string, dictFunc func(string) *string) *string { 20 | ret := s 21 | regex := regexp.QuoteMeta(delimiterLeft) + "(.*?)" + regexp.QuoteMeta(delimiterRight) 22 | r := regexp.MustCompile(regex) 23 | for _, v := range r.FindAllStringSubmatch(ret, -1) { 24 | val := dictFunc(v[1]) 25 | if nil == val { 26 | return nil 27 | } 28 | ret = strings.Replace(ret, v[0], *val, -1) 29 | } 30 | return &ret 31 | } 32 | 33 | // ValueOrDefault returns the value if it is not empty, otherwise the default value 34 | func ValueOrDefault(try, default_ string) string { 35 | if try == "" { 36 | return default_ 37 | } 38 | return try 39 | } 40 | 41 | // RemoveFmtUnderscore remove underscore in format placeholder {abc_def_} => {abcdef} 42 | func RemoveFmtUnderscore(in string) (out string) { 43 | out = "" 44 | offset := 0 45 | n := len(in) 46 | replacing := false 47 | for offset < n { 48 | r := in[offset] 49 | switch { 50 | case r == '{': 51 | replacing = true 52 | case r == '}': 53 | replacing = false 54 | } 55 | if !(replacing && r == '_') { 56 | out += string(r) 57 | } 58 | offset++ 59 | } 60 | return 61 | } 62 | 63 | // RemoveJsoncComments remove json comments 64 | func RemoveJsoncComments(data []byte) []byte { 65 | var buf bytes.Buffer 66 | var inQuote bool 67 | var inComment bool 68 | for _, b := range data { 69 | if b == '"' { 70 | inQuote = !inQuote 71 | } 72 | if inQuote { 73 | buf.WriteByte(b) 74 | continue 75 | } 76 | if b == '/' { 77 | inComment = true 78 | } 79 | if b == '\n' { 80 | inComment = false 81 | } 82 | if inComment { 83 | continue 84 | } 85 | buf.WriteByte(b) 86 | } 87 | return buf.Bytes() 88 | } 89 | -------------------------------------------------------------------------------- /lib/xstrings/xstrings_test.go: -------------------------------------------------------------------------------- 1 | package xstrings 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestVariableReplace(t *testing.T) { 9 | type args struct { 10 | s string 11 | delimiterLeft string 12 | delimiterRight string 13 | dict map[string]string 14 | } 15 | tests := []struct { 16 | name string 17 | args args 18 | want string 19 | }{ 20 | {"1", args{"aba${a}aba", "${", "}", map[string]string{"a": "b"}}, "abababa"}, 21 | {"2", args{"$(a)", "$(", ")", map[string]string{"a": "b"}}, "b"}, 22 | {"3", args{"${a.b.c}", "${", "}", map[string]string{"a.b.c": "d"}}, "d"}, 23 | } 24 | for _, tt := range tests { 25 | t.Run(tt.name, func(t *testing.T) { 26 | if got := VariableReplace(tt.args.s, tt.args.delimiterLeft, tt.args.delimiterRight, tt.args.dict); got != tt.want { 27 | t.Errorf("VariableReplace() = %v, want %v", got, tt.want) 28 | } 29 | }) 30 | } 31 | } 32 | 33 | func TestVariableReplaceFunc(t *testing.T) { 34 | type args struct { 35 | s string 36 | delimiterLeft string 37 | delimiterRight string 38 | dictFunc func(string) *string 39 | } 40 | getVal := func(k string) *string { 41 | m := map[string]string{"a": "b", "a.b.c": "d"} 42 | v := m[k] 43 | return &v 44 | } 45 | 46 | tests := []struct { 47 | name string 48 | args args 49 | want string 50 | }{ 51 | {"1", args{"aba${a}aba", "${", "}", getVal}, "abababa"}, 52 | {"2", args{"$(a)", "$(", ")", getVal}, "b"}, 53 | {"3", args{"${a.b.c}", "${", "}", getVal}, "d"}, 54 | } 55 | for _, tt := range tests { 56 | t.Run(tt.name, func(t *testing.T) { 57 | if got := VariableReplaceFunc(tt.args.s, tt.args.delimiterLeft, tt.args.delimiterRight, tt.args.dictFunc); *got != tt.want { 58 | t.Errorf("VariableReplaceFunc() = %v, want %v", got, tt.want) 59 | } 60 | }) 61 | } 62 | } 63 | 64 | func TestRemoveFmtUnderscore(t *testing.T) { 65 | type args struct { 66 | in string 67 | } 68 | tests := []struct { 69 | name string 70 | args args 71 | wantOut string 72 | }{ 73 | {"1", args{"a{b}c"}, "a{b}c"}, 74 | {"2", args{"_a_{_b_}_c_"}, "_a_{b}_c_"}, 75 | {"3", args{"{_b_/_c_}_c_{d}{_e}{f_}"}, "{b/c}_c_{d}{e}{f}"}, 76 | {"4", args{"{{a_b}}{{{{"}, "{{ab}}{{{{"}, 77 | {"5", args{"upgit_20220130_{fname_hash8}.jpg"}, "upgit_20220130_{fnamehash8}.jpg"}, 78 | } 79 | for _, tt := range tests { 80 | t.Run(tt.name, func(t *testing.T) { 81 | if gotOut := RemoveFmtUnderscore(tt.args.in); gotOut != tt.wantOut { 82 | t.Errorf("RemoveFmtUnderscore() = %v, want %v", gotOut, tt.wantOut) 83 | } 84 | }) 85 | } 86 | } 87 | 88 | func TestRemoveJsoncComments(t *testing.T) { 89 | type args struct { 90 | data []byte 91 | } 92 | tests := []struct { 93 | name string 94 | args args 95 | want []byte 96 | }{ 97 | {"1", args{ 98 | []byte( 99 | ` 100 | a//bcde// 101 | //c 102 | // 103 | a 104 | `, 105 | ), 106 | }, 107 | []byte( 108 | ` 109 | a 110 | 111 | 112 | a 113 | `, 114 | ), 115 | }, 116 | {"1", args{ 117 | []byte( 118 | ` 119 | { 120 | url: "http://www.example.com" // url 121 | } 122 | `, 123 | ), 124 | }, 125 | []byte( 126 | ` 127 | { 128 | url: "http://www.example.com" 129 | } 130 | `, 131 | ), 132 | }, 133 | } 134 | for _, tt := range tests { 135 | t.Run(tt.name, func(t *testing.T) { 136 | if got := RemoveJsoncComments(tt.args.data); !reflect.DeepEqual(got, tt.want) { 137 | t.Errorf("RemoveJsoncComments() = %v, want %v", string(got), string(tt.want)) 138 | } 139 | }) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /lib/xzip/xzip.go: -------------------------------------------------------------------------------- 1 | package xzip 2 | 3 | import ( 4 | "archive/zip" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | // Unzip, from https://stackoverflow.com/questions/20357223/easy-way-to-unzip-file-with-golang 12 | func Unzip(srcFile, destDir string) error { 13 | r, err := zip.OpenReader(srcFile) 14 | if err != nil { 15 | return err 16 | } 17 | defer func() { 18 | if err := r.Close(); err != nil { 19 | panic(err) 20 | } 21 | }() 22 | 23 | os.MkdirAll(destDir, 0755) 24 | 25 | // Closure to address file descriptors issue with all the deferred .Close() methods 26 | extractAndWriteFile := func(f *zip.File) error { 27 | rc, err := f.Open() 28 | if err != nil { 29 | return err 30 | } 31 | defer func() { 32 | if err := rc.Close(); err != nil { 33 | panic(err) 34 | } 35 | }() 36 | 37 | path := filepath.Join(destDir, f.Name) 38 | 39 | // Check for ZipSlip (Directory traversal) 40 | if !strings.HasPrefix(path, filepath.Clean(destDir)+string(os.PathSeparator)) { 41 | return fmt.Errorf("illegal file path: %s", path) 42 | } 43 | 44 | if f.FileInfo().IsDir() { 45 | os.MkdirAll(path, f.Mode()) 46 | } else { 47 | os.MkdirAll(filepath.Dir(path), f.Mode()) 48 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 49 | if err != nil { 50 | return err 51 | } 52 | defer func() { 53 | if err := f.Close(); err != nil { 54 | panic(err) 55 | } 56 | }() 57 | 58 | _, err = io.Copy(f, rc) 59 | if err != nil { 60 | return err 61 | } 62 | } 63 | return nil 64 | } 65 | 66 | for _, f := range r.File { 67 | err := extractAndWriteFile(f) 68 | if err != nil { 69 | return err 70 | } 71 | } 72 | 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluveto/upgit/3400304963564e1d5c7e0d436aea0b4f3a05f9f9/logo.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io/fs" 8 | "io/ioutil" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "runtime" 13 | "strings" 14 | "syscall" 15 | "time" 16 | 17 | "github.com/alexflint/go-arg" 18 | "github.com/pelletier/go-toml/v2" 19 | "github.com/pluveto/upgit/lib/aliyunoss" 20 | "github.com/pluveto/upgit/lib/model" 21 | "github.com/pluveto/upgit/lib/qcloudcos" 22 | "github.com/pluveto/upgit/lib/result" 23 | "github.com/pluveto/upgit/lib/s3" 24 | "github.com/pluveto/upgit/lib/uploaders" 25 | "github.com/pluveto/upgit/lib/upyun" 26 | "github.com/pluveto/upgit/lib/xapp" 27 | "github.com/pluveto/upgit/lib/xclipboard" 28 | "github.com/pluveto/upgit/lib/xext" 29 | "github.com/pluveto/upgit/lib/xgithub" 30 | "github.com/pluveto/upgit/lib/xhttp" 31 | "github.com/pluveto/upgit/lib/xio" 32 | "github.com/pluveto/upgit/lib/xlog" 33 | "github.com/pluveto/upgit/lib/xmap" 34 | "github.com/pluveto/upgit/lib/xpath" 35 | "github.com/pluveto/upgit/lib/xstrings" 36 | "github.com/pluveto/upgit/lib/xzip" 37 | "golang.design/x/clipboard" 38 | "gopkg.in/validator.v2" 39 | ) 40 | 41 | func main() { 42 | result.AbortErr = xlog.AbortErr 43 | if len(os.Args) >= 2 && os.Args[1] == "ext" { 44 | extSubcommand() 45 | return 46 | } 47 | mainCommand() 48 | } 49 | 50 | func mainCommand() { 51 | // parse cli args 52 | loadCliOpts() 53 | 54 | // load config 55 | loadEnvConfig(&xapp.AppCfg) 56 | loadConfig(&xapp.AppCfg) 57 | 58 | xlog.GVerbose.TraceStruct(xapp.AppCfg) 59 | 60 | // handle clipboard if need 61 | handleClipboard() 62 | 63 | // validating args 64 | validArgs() 65 | 66 | // executing uploading 67 | dispatchUploader() 68 | 69 | if xapp.AppOpt.Wait { 70 | fmt.Scanln() 71 | } 72 | 73 | } 74 | 75 | // loadCliOpts load cli options into xapp.AppOpt 76 | func loadCliOpts() { 77 | arg.MustParse(&xapp.AppOpt) 78 | xapp.AppOpt.TargetDir = strings.Trim(xapp.AppOpt.TargetDir, "/") 79 | xapp.AppOpt.ApplicationPath = strings.Trim(xapp.AppOpt.ApplicationPath, "/") 80 | if len(xapp.AppOpt.ApplicationPath) > 0 { 81 | xpath.ApplicationPath = xapp.AppOpt.ApplicationPath 82 | } 83 | if xapp.AppOpt.SizeLimit != nil && *xapp.AppOpt.SizeLimit >= 0 { 84 | xapp.MaxUploadSize = *xapp.AppOpt.SizeLimit 85 | } 86 | if false == xapp.AppOpt.NoLog { 87 | xlog.GVerbose.LogEnabled = true 88 | xlog.GVerbose.LogFile = xpath.MustGetApplicationPath("upgit.log") 89 | xlog.GVerbose.LogFileMaxSize = 2 * 1024 * 1024 // 2MiB 90 | xlog.GVerbose.Info("Started") 91 | xlog.GVerbose.TruncatLog() 92 | } 93 | xlog.GVerbose.VerboseEnabled = xapp.AppOpt.Verbose 94 | xlog.GVerbose.TraceStruct(xapp.AppOpt) 95 | } 96 | 97 | func onUploaded(r result.Result[*model.Task]) { 98 | if !r.Ok() && xapp.AppOpt.OutputType == xapp.O_Stdout { 99 | fmt.Println("Failed: " + r.Err.Error()) 100 | return 101 | } 102 | if xapp.AppOpt.Clean && !r.Value.Ignored { 103 | err := os.Remove(r.Value.LocalPath) 104 | if err != nil { 105 | xlog.GVerbose.Info("Failed to remove %s: %s", r.Value.LocalPath, err.Error()) 106 | } else { 107 | xlog.GVerbose.Info("Removed %s", r.Value.LocalPath) 108 | } 109 | 110 | } 111 | outputLink(*r.Value) 112 | recordHistory(*r.Value) 113 | } 114 | 115 | func mustMarshall(s interface{}) string { 116 | b, err := toml.Marshal(s) 117 | if err != nil { 118 | return "" 119 | } 120 | return string(b) 121 | } 122 | 123 | func recordHistory(r model.Task) { 124 | xio.AppendToFile(xpath.MustGetApplicationPath("history.log"), []byte( 125 | `{"time":"`+time.Now().Local().String()+`","rawUrl":"`+r.RawUrl+`","url":"`+r.Url+`"}`+"\n"), 126 | ) 127 | 128 | xlog.GVerbose.Info(mustMarshall(r)) 129 | } 130 | 131 | func outputLink(r model.Task) { 132 | outContent, err := outputFormat(r) 133 | xlog.AbortErr(err) 134 | switch xapp.AppOpt.OutputType { 135 | case xapp.O_Stdout: 136 | fmt.Println(outContent) 137 | case xapp.O_Clipboard: 138 | clipboard.Write(clipboard.FmtText, []byte(outContent)) 139 | default: 140 | xlog.AbortErr(errors.New("unknown output type: " + string(xapp.AppOpt.OutputType))) 141 | } 142 | } 143 | 144 | func outputFormat(r model.Task) (content string, err error) { 145 | var outUrl string 146 | if xapp.AppOpt.Raw || r.Url == "" { 147 | outUrl = r.RawUrl 148 | } else { 149 | outUrl = r.Url 150 | } 151 | fmt := xapp.AppOpt.OutputFormat 152 | if fmt == "" { 153 | return outUrl, nil 154 | } 155 | val, ok := xapp.AppCfg.OutputFormats[fmt] 156 | if !ok { 157 | return "", errors.New("unknown output format: " + fmt) 158 | } 159 | content = strings.NewReplacer( 160 | "{url}", outUrl, 161 | "{urlfname}", filepath.Base(outUrl), 162 | "{fname}", filepath.Base(r.LocalPath), 163 | ).Replace(xstrings.RemoveFmtUnderscore(val)) 164 | 165 | return 166 | } 167 | 168 | func validArgs() { 169 | if errs := validator.Validate(xapp.AppCfg); errs != nil { 170 | xlog.AbortErr(fmt.Errorf("incorrect config: " + errs.Error())) 171 | } 172 | 173 | for _, path := range xapp.AppOpt.LocalPaths { 174 | if strings.HasPrefix(path, "http") { 175 | continue 176 | } 177 | fs, err := os.Stat(path) 178 | if errors.Is(err, os.ErrNotExist) { 179 | xlog.AbortErr(fmt.Errorf("invalid file to upload %s: no such file", path)) 180 | } 181 | if err != nil { 182 | xlog.AbortErr(fmt.Errorf("invalid file to upload %s: %s", path, err.Error())) 183 | } 184 | if fs.Size() == 0 { 185 | xlog.AbortErr(fmt.Errorf("invalid file to upload %s: file size is zero", path)) 186 | } 187 | if xapp.MaxUploadSize != 0 && fs.Size() > xapp.MaxUploadSize { 188 | xlog.AbortErr(fmt.Errorf("invalid file to upload %s: file size is larger than %d bytes", path, xapp.MaxUploadSize)) 189 | } 190 | } 191 | } 192 | 193 | // loadConfig loads config from config file to xapp.AppCfg 194 | func loadConfig(cfg *xapp.Config) { 195 | 196 | homeDir, err := os.UserHomeDir() 197 | if err != nil { 198 | homeDir = "" 199 | } 200 | 201 | appDir := xpath.MustGetApplicationPath("") 202 | 203 | var configFiles = map[string]bool{ 204 | filepath.Join(homeDir, ".upgit.config.toml"): false, 205 | filepath.Join(homeDir, filepath.Join(".config", "upgitrc")): false, 206 | filepath.Join(appDir, "config.toml"): false, 207 | filepath.Join(appDir, "upgit.toml"): false, 208 | } 209 | 210 | if xapp.AppOpt.ConfigFile != "" { 211 | configFiles[xapp.AppOpt.ConfigFile] = true 212 | } 213 | 214 | for configFile, required := range configFiles { 215 | if _, err := os.Stat(configFile); err != nil { 216 | if required { 217 | xlog.AbortErr(fmt.Errorf("config file %s not found", configFile)) 218 | } 219 | continue 220 | } 221 | optRawBytes, err := ioutil.ReadFile(configFile) 222 | if err == nil { 223 | err = toml.Unmarshal(optRawBytes, &cfg) 224 | } 225 | if err != nil { 226 | xlog.AbortErr(fmt.Errorf("invalid config: " + err.Error())) 227 | } 228 | xapp.ConfigFilePath = configFile 229 | break 230 | } 231 | 232 | if xapp.ConfigFilePath == "" { 233 | xlog.AbortErr(fmt.Errorf("no config file found")) 234 | } 235 | 236 | // fill config 237 | xapp.AppCfg.Rename = strings.Trim(xapp.AppCfg.Rename, "/") 238 | xapp.AppCfg.Rename = xstrings.RemoveFmtUnderscore(xapp.AppCfg.Rename) 239 | 240 | // -- integrated formats 241 | if nil == xapp.AppCfg.OutputFormats { 242 | xapp.AppCfg.OutputFormats = make(map[string]string) 243 | } 244 | xapp.AppCfg.OutputFormats["markdown"] = `![{url_fname}]({url})` 245 | xapp.AppCfg.OutputFormats["url"] = `{url}` 246 | 247 | } 248 | 249 | // UploadAll will upload all given file to targetDir. 250 | // If targetDir is not set, it will upload using rename rules. 251 | func UploadAll(uploader model.Uploader, localPaths []string, targetDir string, callback func(result.Result[*model.Task])) { 252 | for taskId, localPath := range localPaths { 253 | 254 | var ret result.Result[*model.Task] 255 | task := model.Task{ 256 | Status: model.TASK_CREATED, 257 | TaskId: taskId, 258 | LocalPath: localPath, 259 | TargetDir: targetDir, 260 | RawUrl: "", 261 | Url: "", 262 | CreateTime: time.Now(), 263 | } 264 | var err error 265 | // ignore non-local path 266 | if strings.HasPrefix(localPath, "http") { 267 | task.Ignored = true 268 | task.Status = model.TASK_FINISHED 269 | } else { 270 | err = uploader.Upload(&task) 271 | } 272 | if err != nil { 273 | task.Status = model.TASK_FAILED 274 | ret = result.Result[*model.Task]{ 275 | Err: err, 276 | } 277 | } else { 278 | ret = result.Result[*model.Task]{ 279 | Value: &task, 280 | } 281 | } 282 | 283 | if err == nil { 284 | xlog.GVerbose.TraceStruct(ret.Value) 285 | } 286 | if nil != callback { 287 | callback(ret) 288 | } 289 | } 290 | } 291 | 292 | func dispatchUploader() { 293 | uploaderId := xstrings.ValueOrDefault(xapp.AppOpt.Uploader, xapp.AppCfg.DefaultUploader) 294 | xlog.GVerbose.Info("uploader: " + uploaderId) 295 | if uploaderId == "github" { 296 | gCfg, err := xapp.LoadUploaderConfig[uploaders.GithubUploaderConfig](uploaderId) 297 | xlog.AbortErr(err) 298 | err = validator.Validate(&gCfg) 299 | xlog.AbortErr(err) 300 | if len(gCfg.Branch) == 0 { 301 | gCfg.Branch = xapp.DefaultBranch 302 | } 303 | 304 | uploader := uploaders.GithubUploader{Config: gCfg} 305 | UploadAll(uploader, xapp.AppOpt.LocalPaths, xapp.AppOpt.TargetDir, onUploaded) 306 | return 307 | } 308 | if uploaderId == "qcloudcos" { 309 | qCfg, err := xapp.LoadUploaderConfig[qcloudcos.COSConfig](uploaderId) 310 | xlog.AbortErr(err) 311 | err = validator.Validate(&qCfg) 312 | xlog.AbortErr(err) 313 | xlog.GVerbose.Trace("qcloudcos config: ") 314 | xlog.GVerbose.TraceStruct(&qCfg) 315 | uploader := qcloudcos.COSUploader{Config: qCfg} 316 | UploadAll(uploader, xapp.AppOpt.LocalPaths, xapp.AppOpt.TargetDir, onUploaded) 317 | return 318 | } 319 | if uploaderId == "upyun" { 320 | ucfg, err := xapp.LoadUploaderConfig[upyun.UpyunConfig](uploaderId) 321 | xlog.AbortErr(err) 322 | err = validator.Validate(&ucfg) 323 | xlog.AbortErr(err) 324 | xlog.GVerbose.Trace("upyun config: ") 325 | xlog.GVerbose.TraceStruct(&ucfg) 326 | uploader := upyun.UpyunUploader{Config: ucfg} 327 | UploadAll(uploader, xapp.AppOpt.LocalPaths, xapp.AppOpt.TargetDir, onUploaded) 328 | return 329 | } 330 | if uploaderId == "s3" { 331 | ucfg, err := xapp.LoadUploaderConfig[s3.S3Config](uploaderId) 332 | xlog.AbortErr(err) 333 | err = validator.Validate(&ucfg) 334 | xlog.AbortErr(err) 335 | xlog.GVerbose.Trace("s3 config: ") 336 | xlog.GVerbose.TraceStruct(&ucfg) 337 | uploader, err := s3.NewS3Uploader(ucfg) 338 | xlog.AbortErr(err) 339 | UploadAll(uploader, xapp.AppOpt.LocalPaths, xapp.AppOpt.TargetDir, onUploaded) 340 | return 341 | } 342 | if uploaderId == "aliyunoss" { 343 | aCfg, err := xapp.LoadUploaderConfig[aliyunoss.OSSConfig](uploaderId) 344 | xlog.AbortErr(err) 345 | err = validator.Validate(&aCfg) 346 | xlog.AbortErr(err) 347 | xlog.GVerbose.Trace("aliyunoss config: ") 348 | xlog.GVerbose.TraceStruct(&aCfg) 349 | uploader := aliyunoss.OSSUploader{Config: aCfg} 350 | UploadAll(uploader, xapp.AppOpt.LocalPaths, xapp.AppOpt.TargetDir, onUploaded) 351 | return 352 | } 353 | // try http simple uploader 354 | // list file in ./extensions 355 | extDir := xpath.MustGetApplicationPath("extensions") 356 | info, err := ioutil.ReadDir(extDir) 357 | xlog.AbortErr(err) 358 | var uploader *uploaders.SimpleHttpUploader 359 | for _, f := range info { 360 | fname := f.Name() 361 | xlog.GVerbose.Trace("found file %s", fname) 362 | if !strings.HasSuffix(fname, ".json") && !strings.HasSuffix(fname, ".jsonc") { 363 | xlog.GVerbose.Trace("ignored file %s", fname) 364 | continue 365 | } 366 | // load file to json 367 | uploaderDef, err := xext.GetExtDefinitionInterface(extDir, fname) 368 | xlog.AbortErr(err) 369 | if result.From[string](xmap.GetDeep[string](uploaderDef, `meta.id`)).ValueOrExit() != uploaderId { 370 | continue 371 | } 372 | if result.From[string](xmap.GetDeep[string](uploaderDef, "meta.type")).ValueOrExit() != "simple-http-uploader" { 373 | continue 374 | } 375 | uploader = &uploaders.SimpleHttpUploader{Definition: uploaderDef} 376 | extConfig, err := xapp.LoadUploaderConfig[map[string]interface{}](uploaderId) 377 | if err == nil { 378 | uploader.Config = extConfig 379 | xlog.GVerbose.Trace("uploader config:") 380 | xlog.GVerbose.TraceStruct(uploader.Config) 381 | } else { 382 | xlog.GVerbose.Trace("no uploader config found") 383 | } 384 | break 385 | } 386 | if nil == uploader { 387 | xlog.AbortErr(errors.New("unknown uploader: " + uploaderId)) 388 | } 389 | UploadAll(uploader, xapp.AppOpt.LocalPaths, xapp.AppOpt.TargetDir, onUploaded) 390 | 391 | } 392 | 393 | func handleClipboard() { 394 | if len(xapp.AppOpt.LocalPaths) == 1 { 395 | label := strings.ToLower(xapp.AppOpt.LocalPaths[0]) 396 | if label == xapp.ClipboardPlaceholder { 397 | err := clipboard.Init() 398 | if err != nil { 399 | xlog.AbortErr(fmt.Errorf("failed to init clipboard: " + err.Error())) 400 | } 401 | 402 | tmpFileName := fmt.Sprint(os.TempDir(), "/upgit_tmp_", time.Now().UnixMicro(), ".png") 403 | buf := clipboard.Read(clipboard.FmtImage) 404 | if nil == buf { 405 | // try second chance for Windows user. To adapt bitmap format (compatible with Snipaste) 406 | if runtime.GOOS == "windows" { 407 | buf, err = xclipboard.ReadClipboardImage() 408 | } 409 | if err != nil { 410 | xlog.GVerbose.Error("failed to read clipboard image: " + err.Error()) 411 | } 412 | } 413 | if nil == buf { 414 | xlog.AbortErr(fmt.Errorf("failed: no image in clipboard or unsupported format")) 415 | } 416 | os.WriteFile(tmpFileName, buf, os.FileMode(fs.ModePerm)) 417 | xapp.AppOpt.LocalPaths[0] = tmpFileName 418 | xapp.AppOpt.Clean = true 419 | } 420 | if strings.HasPrefix(label, xapp.ClipboardFilePlaceholder) { 421 | // Must be Windows 422 | if runtime.GOOS != "windows" { 423 | xlog.AbortErr(fmt.Errorf("failed: clipboard file only supported on Windows")) 424 | } 425 | // Download latest https://github.com/pluveto/APIProxy-Win32/releases 426 | // and put it in same directory with upgit.exe 427 | download := func() { 428 | downloadUrl, err := xgithub.GetLatestReleaseDownloadUrl("pluveto/APIProxy-Win32") 429 | xlog.AbortErr(err) 430 | xlog.GVerbose.Trace("download url: %s", downloadUrl) 431 | saveName := xpath.MustGetApplicationPath("/apiproxy-win32.zip") 432 | xlog.AbortErr(xhttp.DownloadFile(downloadUrl, saveName)) 433 | // Unzip 434 | xlog.AbortErr(xzip.Unzip(saveName, xpath.MustGetApplicationPath("/"))) 435 | // Clean downloaded zip 436 | xlog.AbortErr(os.Remove(saveName)) 437 | } 438 | // Run 439 | executable := xpath.MustGetApplicationPath("APIProxy.exe") 440 | if _, err := os.Stat(executable); os.IsNotExist(err) { 441 | println("APIProxy not found, downloading...") 442 | download() 443 | } 444 | execArgs := []string{"clipboard", "GetFilePaths"} 445 | cmd := exec.Command(executable) 446 | cmd.Args = append(cmd.Args, execArgs...) 447 | // Wait and fetch cmdOutput 448 | cmdOutput, err := cmd.Output() 449 | if err != nil { 450 | xlog.AbortErr(fmt.Errorf("failed to run APIProxy: %s, stderr: %s", err.Error(), cmdOutput)) 451 | } 452 | parseOutput := func(output string) []string { 453 | /* 454 | Type: ApplicationError 455 | Msg: No handler name specified 456 | HMsg: 457 | Data: null 458 | */ 459 | lines := strings.Split(output, "\n") 460 | for i, line := range lines { 461 | lines[i] = strings.TrimSpace(line) 462 | if len(lines[i]) == 0 { 463 | lines = append(lines[:i], lines[i+1:]...) 464 | } 465 | } 466 | var result []string 467 | if len(lines) != 4 { 468 | xlog.AbortErr(errors.New("unable to parse APIProxy output, unexpected line count. output: " + output)) 469 | return result 470 | } 471 | if !strings.HasPrefix(lines[0], "Type: Success") { 472 | xlog.AbortErr(errors.New("got error from APIProxy output: " + output)) 473 | return result 474 | } 475 | // Parse data 476 | jsonStr := lines[3][len("Data: "):] 477 | var paths []string 478 | xlog.AbortErr(json.Unmarshal([]byte(jsonStr), &paths)) 479 | return paths 480 | } 481 | xlog.GVerbose.Trace("stdout: %s", cmdOutput) 482 | paths := parseOutput(string(cmdOutput)) 483 | if len(paths) == 0 { 484 | xlog.AbortErr(errors.New("no file in clipboard")) 485 | } 486 | xapp.AppOpt.LocalPaths = paths 487 | } 488 | } 489 | } 490 | 491 | func loadEnvConfig(cfg *xapp.Config) { 492 | if nil == cfg { 493 | xlog.AbortErr(fmt.Errorf("unable to load env config: nil config")) 494 | } 495 | 496 | if rename, found := syscall.Getenv("UPGIT_RENAME"); found { 497 | cfg.Rename = rename 498 | } 499 | } 500 | 501 | func loadGithubUploaderEnvConfig(gCfg *uploaders.GithubUploaderConfig) { 502 | // TODO: Auto generate env key name and adapt for all uploaders 503 | if pat, found := syscall.Getenv("GITHUB_TOKEN"); found { 504 | gCfg.PAT = pat 505 | } 506 | if pat, found := syscall.Getenv("UPGIT_TOKEN"); found { 507 | gCfg.PAT = pat 508 | } 509 | if username, found := syscall.Getenv("UPGIT_USERNAME"); found { 510 | gCfg.Username = username 511 | } 512 | if repo, found := syscall.Getenv("UPGIT_REPO"); found { 513 | gCfg.Repo = repo 514 | } 515 | if branch, found := syscall.Getenv("UPGIT_BRANCH"); found { 516 | gCfg.Branch = branch 517 | } 518 | } 519 | --------------------------------------------------------------------------------