├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── cmd └── s3surfer │ ├── main.go │ └── s3surfer_test.go ├── go.mod ├── go.sum ├── pkg ├── c │ ├── controller.go │ └── controller_test.go ├── m │ ├── s3.go │ └── s3_test.go └── v │ ├── ui.go │ └── ui_test.go └── s3surfer.gif /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v3 17 | with: 18 | go-version: '1.20' 19 | 20 | - name: Build 21 | run: make cross 22 | 23 | - name: Create release 24 | uses: softprops/action-gh-release@v1 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | with: 28 | tag_name: ${{ github.ref }} 29 | name: Release ${{ github.ref }} 30 | generate_release_notes: true 31 | 32 | - name: Upload 33 | run: make upload 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | pull_request: {} 8 | 9 | jobs: 10 | test: 11 | name: Test ${{ matrix.go }} on ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, macos-latest, windows-latest] 15 | go: ['1.20', '1.19'] 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v3 22 | with: 23 | go-version: ${{ matrix.go }} 24 | 25 | - name: Build 26 | run: go build -v ./cmd/s3surfer 27 | 28 | - name: Test 29 | run: go test -v ./... 30 | 31 | lint: 32 | name: golint 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v3 36 | 37 | - name: Set up Go 38 | uses: actions/setup-go@v3 39 | with: 40 | go-version: '1.20' 41 | 42 | - name: Lint 43 | run: make lint 44 | 45 | security: 46 | name: gosec 47 | runs-on: ubuntu-latest 48 | env: 49 | GO111MODULE: on 50 | steps: 51 | - uses: actions/checkout@v3 52 | 53 | - name: Run Gosec Security Scanner 54 | uses: securego/gosec@master 55 | with: 56 | args: './...' 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /s3surfer 2 | /s3surfer-debug 3 | /goxz/ 4 | 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [v1.0.6](https://github.com/hirose31/s3surfer/compare/v1.0.5...v1.0.6) (2023-02-24) 2 | 3 | * Upgrade golang.org/x/text [#13](https://github.com/hirose31/s3surfer/pull/13) ([hirose31](https://github.com/hirose31)) 4 | * Pass make lint security [#12](https://github.com/hirose31/s3surfer/pull/12) ([hirose31](https://github.com/hirose31)) 5 | 6 | ## [v1.0.5](https://github.com/hirose31/s3surfer/compare/v1.0.4...v1.0.5) (2022-04-28) 7 | 8 | 9 | ## [v1.0.4](https://github.com/hirose31/s3surfer/compare/v1.0.3...v1.0.4) (2021-12-20) 10 | 11 | * Prepare to release v1.0.4 [#10](https://github.com/hirose31/s3surfer/pull/10) ([hirose31](https://github.com/hirose31)) 12 | * Support Windows [#9](https://github.com/hirose31/s3surfer/pull/9) ([mattn](https://github.com/mattn)) 13 | * Add --region and --path-style for other S3 compatible services [#8](https://github.com/hirose31/s3surfer/pull/8) ([mattn](https://github.com/mattn)) 14 | 15 | ## [v1.0.3](https://github.com/hirose31/s3surfer/compare/v1.0.2...v1.0.3) (2021-12-17) 16 | 17 | * Introduce --endpoint-url option [#7](https://github.com/hirose31/s3surfer/pull/7) ([kazeburo](https://github.com/kazeburo)) 18 | * Topic/delete for loop [#6](https://github.com/hirose31/s3surfer/pull/6) ([gongqi-zhen](https://github.com/gongqi-zhen)) 19 | 20 | ## [v1.0.2](https://github.com/hirose31/s3surfer/compare/v1.0.1...v1.0.2) (2021-10-27) 21 | 22 | * Handle odd object key [#5](https://github.com/hirose31/s3surfer/pull/5) ([hirose31](https://github.com/hirose31)) 23 | * Decide default region by LANG [#4](https://github.com/hirose31/s3surfer/pull/4) ([hirose31](https://github.com/hirose31)) 24 | * Show startup message [#3](https://github.com/hirose31/s3surfer/pull/3) ([hirose31](https://github.com/hirose31)) 25 | 26 | ## [v1.0.1](https://github.com/hirose31/s3surfer/compare/v1.0.0...v1.0.1) (2021-10-26) 27 | 28 | * Ignore gosec G307 [#2](https://github.com/hirose31/s3surfer/pull/2) ([hirose31](https://github.com/hirose31)) 29 | * Fix abort when --bucket option specified [#1](https://github.com/hirose31/s3surfer/pull/1) ([hirose31](https://github.com/hirose31)) 30 | 31 | ## [v1.0.0](https://github.com/hirose31/s3surfer/compare/b42907131c11...v1.0.0) (2021-10-25) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN := s3surfer 2 | MAIN = ./cmd/s3surfer 3 | VERSION := $$(make -s show-version) 4 | CURRENT_REVISION := $(shell git rev-parse --short HEAD) 5 | BUILD_LDFLAGS := "-s -w -X main.revision=$(CURRENT_REVISION)" 6 | GOBIN ?= $(shell go env GOPATH)/bin 7 | u := $(if $(update),-u) # make update=1 deps 8 | 9 | .PHONY: help 10 | .DEFAULT_GOAL := help 11 | 12 | help: 13 | @grep -h -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 14 | 15 | .PHONY: all 16 | all: clean build ## clean and build 17 | 18 | .PHONY: build 19 | build: deps ## build 20 | go build -ldflags=$(BUILD_LDFLAGS) -o $(BIN) $(MAIN) 21 | 22 | .PHONY: install 23 | install: deps ## install 24 | go install -ldflags=$(BUILD_LDFLAGS) $(MAIN) 25 | 26 | .PHONY: clean 27 | clean: ## clean 28 | rm -rf $(BIN) goxz 29 | go clean 30 | 31 | .PHONY: test 32 | test: deps ## test 33 | go test -v ./... 34 | 35 | .PHONY: lint 36 | lint: devel-deps ## run golint and staticcheck 37 | golint -set_exit_status ./... 38 | staticcheck -checks all ./... 39 | 40 | .PHONY: security 41 | security: devel-deps ## run gosec 42 | gosec ./... 43 | 44 | .PHONY: bump 45 | bump: devel-deps ## release new version 46 | ifneq ($(shell git status --porcelain),) 47 | $(error git workspace is dirty) 48 | endif 49 | ifneq ($(shell git rev-parse --abbrev-ref HEAD),master) 50 | $(error current branch is not master) 51 | endif 52 | @gobump up -w $(MAIN) 53 | ghch -w -N "v$(VERSION)" 54 | git commit -am "bump up version to $(VERSION)" 55 | git tag "v$(VERSION)" 56 | git push origin master 57 | git push origin "refs/tags/v$(VERSION)" 58 | 59 | .PHONY: cross 60 | cross: devel-deps ## build for cross platforms 61 | goxz -arch amd64,arm64 -os linux,darwin -n $(BIN) -pv=v$(VERSION) -build-ldflags=$(BUILD_LDFLAGS) -trimpath $(MAIN) 62 | goxz -arch amd64 -os windows -n $(BIN) -pv=v$(VERSION) -build-ldflags=$(BUILD_LDFLAGS) -trimpath $(MAIN) 63 | 64 | .PHONY: upload 65 | upload: devel-deps ## upload 66 | ghr "v$(VERSION)" goxz/ 67 | 68 | 69 | .PHONY: show-version 70 | show-version: devel-deps ## show-version 71 | @gobump show -r $(MAIN) 72 | 73 | .PHONY: deps 74 | deps: 75 | go get ${u} -d -v $(MAIN) 76 | go mod tidy 77 | 78 | .PHONY: devel-deps 79 | devel-deps: $(GOBIN)/golint $(GOBIN)/staticcheck $(GOBIN)/gosec $(GOBIN)/gobump $(GOBIN)/ghch $(GOBIN)/ghr $(GOBIN)/goxz 80 | 81 | $(GOBIN)/golint: 82 | go install golang.org/x/lint/golint@latest 83 | 84 | $(GOBIN)/staticcheck: 85 | go install honnef.co/go/tools/cmd/staticcheck@latest 86 | 87 | $(GOBIN)/gosec: 88 | go install github.com/securego/gosec/v2/cmd/gosec@latest 89 | 90 | $(GOBIN)/gobump: 91 | go install github.com/x-motemen/gobump/cmd/gobump@latest 92 | 93 | $(GOBIN)/ghch: 94 | go install github.com/Songmu/ghch/cmd/ghch@latest 95 | 96 | $(GOBIN)/ghr: 97 | go install github.com/tcnksm/ghr@latest 98 | 99 | $(GOBIN)/goxz: 100 | go install github.com/Songmu/goxz/cmd/goxz@latest 101 | 102 | 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![GitHub](https://img.shields.io/github/license/hirose31/s3surfer) 2 | [![test](https://github.com/hirose31/s3surfer/actions/workflows/test.yml/badge.svg)](https://github.com/hirose31/s3surfer/actions/workflows/test.yml) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/hirose31/s3surfer?style=flat-square)](https://goreportcard.com/report/github.com/hirose31/s3surfer) 4 | [![Go Doc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat-square)](http://godoc.org/github.com/hirose31/s3surfer) 5 | 6 | # s3surfer 7 | 8 | `s3surfer` is CLI tool for browsing S3 bucket and download objects interactively. 9 | 10 | ![Screencast](s3surfer.gif) 11 | 12 | # Installation 13 | 14 | It's just a single binary file, **no external dependencies**. 15 | Just download the appropriate version of [executable from latest release](https://github.com/hirose31/s3surfer/releases) for your OS. 16 | 17 | # Usage 18 | 19 | ## Options 20 | 21 | | Option | Description | 22 | | --- | --- | 23 | | `-b STRING` | S3 bucket name (optional) | 24 | | `--endpoint-url STRING` | Endpoint URL to request | 25 | | `-d STRING` | Write debug log into file | 26 | | `--version` | Print version information and exit | 27 | | `-h` | Show help messages | 28 | 29 | ## Keyboard Commands 30 | 31 | | Key | Description | 32 | | --- | --- | 33 | | `↓`, `j` | Select next item | 34 | | `↑`, `k` | Select previous item | 35 | | `Enter`, `l` | Move into directory | 36 | | `u`, `h` | Move parent directory | 37 | | `d` | Download the selected file or directory into the current working directory | 38 | | `q` | Quit | 39 | 40 | ## Environmental Variables 41 | 42 | | Variable | Description | 43 | | --- | --- | 44 | | `AWS_PROFILE` | Use a specific profile from your credential file. | 45 | 46 | ## Examples 47 | 48 | Using default profile, specify bucket name. 49 | 50 | ``` 51 | $ s3surfer -b my-bucket 52 | ``` 53 | 54 | Using `my-profile` profile, choose bucket in `s3surfer`. 55 | 56 | ``` 57 | $ env AWS_PROFILE='my-profile' s3surfer 58 | ``` 59 | 60 | # Note 61 | 62 | - Set ambiguous characters to single-width in your terminal setting. 63 | - If the total download size is greater than 80% of the available size of the destination partition, the download will not start. 64 | - "Overwrite protection" protect the data in the same file name. 65 | -------------------------------------------------------------------------------- /cmd/s3surfer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | "syscall" 8 | 9 | "github.com/alecthomas/kong" 10 | "github.com/cli/safeexec" 11 | 12 | "github.com/hirose31/s3surfer/pkg/c" 13 | ) 14 | 15 | const version = "1.0.6" 16 | 17 | var revision = "HEAD" 18 | 19 | type buildInfo struct { 20 | Version string 21 | Revision string 22 | } 23 | 24 | func (b buildInfo) String() string { 25 | return fmt.Sprintf( 26 | "s3surfer %s (rev: %s/%s)", 27 | b.Version, 28 | b.Revision, 29 | runtime.Version(), 30 | ) 31 | } 32 | 33 | // CLI ... 34 | type CLI struct { 35 | Debug string `help:"write debug log info file" short:"d" type:"path"` 36 | Version kong.VersionFlag `help:"print version information and exit"` 37 | 38 | Bucket string `help:"S3 bucket name" short:"b" optional env:"S3SURFER_BUCKET"` 39 | EndpointURL string `help:"endpoint url request to" optional env:"S3SURFER_ENDPOINT_URL"` 40 | Region string `help:"region request to" optional env:"S3SURFER_REGION"` 41 | PathStyle bool `help:"path-style of endpoint" optional env:"S3SURFER_PATHSTYLE"` 42 | } 43 | 44 | func init() { 45 | // https://github.com/rivo/tview/wiki/FAQ#why-do-my-borders-look-weird 46 | if os.Getenv("LC_CTYPE") != "en_US.UTF-8" && runtime.GOOS != "windows" { 47 | err := os.Setenv("LC_CTYPE", "en_US.UTF-8") 48 | if err != nil { 49 | panic(err) 50 | } 51 | env := os.Environ() 52 | argv0, err := safeexec.LookPath(os.Args[0]) 53 | if err != nil { 54 | panic(err) 55 | } 56 | os.Args[0] = argv0 57 | /* #nosec G204 */ 58 | if err := syscall.Exec(argv0, os.Args, env); err != nil { 59 | panic(err) 60 | } 61 | } 62 | } 63 | 64 | func main() { 65 | buildInfo := buildInfo{ 66 | Version: version, 67 | Revision: revision, 68 | } 69 | 70 | cli := CLI{} 71 | 72 | ctx := kong.Parse(&cli, 73 | kong.Name("s3surfer"), 74 | kong.Description("s3surfer is CLI tool for browsing S3 bucket and download objects.\nhttps://github.com/hirose31/s3surfer"), 75 | kong.UsageOnError(), 76 | kong.Vars{ 77 | "version": buildInfo.String(), 78 | }, 79 | ) 80 | 81 | err := c.NewController( 82 | cli.Bucket, 83 | cli.EndpointURL, 84 | cli.Region, 85 | cli.PathStyle, 86 | cli.Debug, 87 | buildInfo.String(), 88 | ).Run() 89 | 90 | ctx.FatalIfErrorf(err) 91 | } 92 | -------------------------------------------------------------------------------- /cmd/s3surfer/s3surfer_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestPinger(t *testing.T) { 6 | // TODO 7 | } 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hirose31/s3surfer 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/alecthomas/kong v0.2.17 7 | github.com/aws/aws-sdk-go-v2 v1.7.1 8 | github.com/aws/aws-sdk-go-v2/config v1.5.0 9 | github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.3.2 10 | github.com/aws/aws-sdk-go-v2/service/s3 v1.11.1 11 | github.com/cli/safeexec v1.0.0 12 | github.com/dustin/go-humanize v1.0.0 13 | github.com/gdamore/tcell/v2 v2.4.0 14 | github.com/mattn/go-runewidth v0.0.10 15 | github.com/rivo/tview v0.0.0-20210624165335-29d673af0ce2 16 | github.com/shirou/gopsutil/v3 v3.21.9 17 | ) 18 | 19 | require ( 20 | github.com/StackExchange/wmi v1.2.1 // indirect 21 | github.com/aws/aws-sdk-go-v2/credentials v1.3.1 // indirect 22 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.3.0 // indirect 23 | github.com/aws/aws-sdk-go-v2/internal/ini v1.1.1 // indirect 24 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.2.1 // indirect 25 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.2.1 // indirect 26 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.5.1 // indirect 27 | github.com/aws/aws-sdk-go-v2/service/sso v1.3.1 // indirect 28 | github.com/aws/aws-sdk-go-v2/service/sts v1.6.0 // indirect 29 | github.com/aws/smithy-go v1.6.0 // indirect 30 | github.com/gdamore/encoding v1.0.0 // indirect 31 | github.com/go-ole/go-ole v1.2.5 // indirect 32 | github.com/jmespath/go-jmespath v0.4.0 // indirect 33 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 34 | github.com/pkg/errors v0.9.1 // indirect 35 | github.com/rivo/uniseg v0.2.0 // indirect 36 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect 37 | golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d // indirect 38 | golang.org/x/text v0.7.0 // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= 2 | github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= 3 | github.com/alecthomas/kong v0.2.17 h1:URDISCI96MIgcIlQyoCAlhOmrSw6pZScBNkctg8r0W0= 4 | github.com/alecthomas/kong v0.2.17/go.mod h1:ka3VZ8GZNPXv9Ov+j4YNLkI8mTuhXyr/0ktSlqIydQQ= 5 | github.com/aws/aws-sdk-go-v2 v1.7.1 h1:TswSc7KNqZ/K1Ijt3IkpXk/2+62vi3Q82Yrr5wSbRBQ= 6 | github.com/aws/aws-sdk-go-v2 v1.7.1/go.mod h1:L5LuPC1ZgDr2xQS7AmIec/Jlc7O/Y1u2KxJyNVab250= 7 | github.com/aws/aws-sdk-go-v2/config v1.5.0 h1:tRQcWXVmO7wC+ApwYc2LiYKfIBoIrdzcJ+7HIh6AlR0= 8 | github.com/aws/aws-sdk-go-v2/config v1.5.0/go.mod h1:RWlPOAW3E3tbtNAqTwvSW54Of/yP3oiZXMI0xfUdjyA= 9 | github.com/aws/aws-sdk-go-v2/credentials v1.3.1 h1:fFeqL5+9kwFKsCb2oci5yAIDsWYqn/Nga8oQ5bIasI8= 10 | github.com/aws/aws-sdk-go-v2/credentials v1.3.1/go.mod h1:r0n73xwsIVagq8RsxmZbGSRQFj9As3je72C2WzUIToc= 11 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.3.0 h1:s4vtv3Mv1CisI3qm2HGHi1Ls9ZtbCOEqeQn6oz7fTyU= 12 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.3.0/go.mod h1:2LAuqPx1I6jNfaGDucWfA2zqQCYCOMCDHiCOciALyNw= 13 | github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.3.2 h1:fzEMxnHQWh+bUV0ZzfhMbgUG8zjIPnAgApjtdHtC9Yg= 14 | github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.3.2/go.mod h1:qaqQiHSrOUVOfKe6fhgQ6UzhxjwqVW8aHNegd6Ws4w4= 15 | github.com/aws/aws-sdk-go-v2/internal/ini v1.1.1 h1:SDLwr1NKyowP7uqxuLNdvFZhjnoVWxNv456zAp+ZFjU= 16 | github.com/aws/aws-sdk-go-v2/internal/ini v1.1.1/go.mod h1:Zy8smImhTdOETZqfyn01iNOe0CNggVbPjCajyaz6Gvg= 17 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.2.1 h1:s/uV8UyMB4UcO0ERHxG9BJhYJAD9MiY0QeYvJmlC7PE= 18 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.2.1/go.mod h1:v33JQ57i2nekYTA70Mb+O18KeH4KqhdqxTJZNK1zdRE= 19 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.2.1 h1:VJe/XEhrfyfBLupcGg1BfUSK2VMZNdbDcZQ49jnp+h0= 20 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.2.1/go.mod h1:zceowr5Z1Nh2WVP8bf/3ikB41IZW59E4yIYbg+pC6mw= 21 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.5.1 h1:1ds3HkMQEBx9XvOkqsPuqBmNFn0w8XEDuB4LOi6KepU= 22 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.5.1/go.mod h1:6EQZIwNNvHpq/2/QSJnp4+ECvqIy55w95Ofs0ze+nGQ= 23 | github.com/aws/aws-sdk-go-v2/service/s3 v1.11.1 h1:HiXhafnqG0AkVJIZA/BHhFvuc/8xFdUO1uaeqF2Artc= 24 | github.com/aws/aws-sdk-go-v2/service/s3 v1.11.1/go.mod h1:XLAGFrEjbvMCLvAtWLLP32yTv8GpBquCApZEycDLunI= 25 | github.com/aws/aws-sdk-go-v2/service/sso v1.3.1 h1:H2ZLWHUbbeYtghuqCY5s/7tbBM99PAwCioRJF8QvV/U= 26 | github.com/aws/aws-sdk-go-v2/service/sso v1.3.1/go.mod h1:J3A3RGUvuCZjvSuZEcOpHDnzZP/sKbhDWV2T1EOzFIM= 27 | github.com/aws/aws-sdk-go-v2/service/sts v1.6.0 h1:Y9r6mrzOyAYz4qKaluSH19zqH1236il/nGbsPKOUT0s= 28 | github.com/aws/aws-sdk-go-v2/service/sts v1.6.0/go.mod h1:q7o0j7d7HrJk/vr9uUt3BVRASvcU7gYZB9PUgPiByXg= 29 | github.com/aws/smithy-go v1.6.0 h1:T6puApfBcYiTIsaI+SYWqanjMt5pc3aoyyDrI+0YH54= 30 | github.com/aws/smithy-go v1.6.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= 31 | github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= 32 | github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= 33 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 34 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 35 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 36 | github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= 37 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 38 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 39 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 40 | github.com/gdamore/tcell/v2 v2.3.3/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU= 41 | github.com/gdamore/tcell/v2 v2.4.0 h1:W6dxJEmaxYvhICFoTY3WrLLEXsQ11SaFnKGVEXW57KM= 42 | github.com/gdamore/tcell/v2 v2.4.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU= 43 | github.com/go-ole/go-ole v1.2.5 h1:t4MGB5xEDZvXI+0rMjjsfBsD7yAgp/s9ZDkL1JndXwY= 44 | github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 45 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 46 | github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= 47 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 48 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 49 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 50 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 51 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 52 | github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 53 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 54 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 55 | github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg= 56 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 57 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 58 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 59 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 60 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 61 | github.com/rivo/tview v0.0.0-20210624165335-29d673af0ce2 h1:I5N0WNMgPSq5NKUFspB4jMJ6n2P0ipz5FlOlB4BXviQ= 62 | github.com/rivo/tview v0.0.0-20210624165335-29d673af0ce2/go.mod h1:IxQujbYMAh4trWr0Dwa8jfciForjVmxyHpskZX6aydQ= 63 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 64 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 65 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 66 | github.com/shirou/gopsutil/v3 v3.21.9 h1:Vn4MUz2uXhqLSiCbGFRc0DILbMVLAY92DSkT8bsYrHg= 67 | github.com/shirou/gopsutil/v3 v3.21.9/go.mod h1:YWp/H8Qs5fVmf17v7JNZzA0mPJ+mS2e9JdiUF9LlKzQ= 68 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 69 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 70 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 71 | github.com/tklauser/go-sysconf v0.3.9/go.mod h1:11DU/5sG7UexIrp/O6g35hrWzu0JxlwQ3LSFUzyeuhs= 72 | github.com/tklauser/numcpus v0.3.0/go.mod h1:yFGUr7TUHQRAhyqBcEg0Ge34zDBAsIvJJcyE6boqnA8= 73 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 74 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 75 | golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 76 | golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 77 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= 78 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 79 | golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 80 | golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE= 81 | golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 82 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 83 | golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 84 | golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= 85 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 86 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 87 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 88 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 89 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 90 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 91 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 92 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 93 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 94 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 95 | -------------------------------------------------------------------------------- /pkg/c/controller.go: -------------------------------------------------------------------------------- 1 | // Package c provides Controller. 2 | package c 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/aws/aws-sdk-go-v2/aws" 11 | "github.com/dustin/go-humanize" 12 | "github.com/gdamore/tcell/v2" 13 | "github.com/hirose31/s3surfer/pkg/m" 14 | "github.com/hirose31/s3surfer/pkg/v" 15 | "github.com/rivo/tview" 16 | "github.com/shirou/gopsutil/v3/disk" 17 | ) 18 | 19 | // Controller ... 20 | type Controller struct { 21 | dfp *os.File 22 | v v.View 23 | m *m.S3Model 24 | } 25 | 26 | // NewController ... 27 | func NewController( 28 | bucket string, 29 | endpoint string, 30 | region string, 31 | pathStyle bool, 32 | debug string, 33 | version string, 34 | ) Controller { 35 | 36 | var dfp *os.File 37 | if debug != "" { 38 | var err error 39 | if dfp, err = os.OpenFile(filepath.Clean(debug), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600); err != nil { 40 | panic(err) 41 | } 42 | } 43 | 44 | fmt.Printf("fetch available buckets...\n") 45 | m := m.NewS3Model(endpoint, region, pathStyle) 46 | if bucket != "" { 47 | err := m.SetBucket(bucket) 48 | if err != nil { 49 | panic(err) 50 | } 51 | } 52 | 53 | v := v.NewView() 54 | v.Frame.AddText(version, true, tview.AlignCenter, tcell.ColorWhite) 55 | 56 | c := Controller{ 57 | dfp, 58 | v, 59 | m, 60 | } 61 | 62 | return c 63 | } 64 | 65 | // Debugf ... 66 | func (c Controller) Debugf(format string, args ...interface{}) { 67 | fmt.Fprintf(c.dfp, format, args...) 68 | } 69 | 70 | // Run ... 71 | func (c Controller) Run() error { 72 | c.Debugf(">> Run\n") 73 | c.Debugf(" bucket=%s\n", c.m.Bucket()) 74 | 75 | c.updateList() 76 | 77 | c.setInputCapture() 78 | 79 | return c.v.App.Run() 80 | } 81 | 82 | // Stop ... 83 | func (c Controller) Stop() { 84 | c.v.App.Stop() 85 | } 86 | 87 | func (c Controller) setInputCapture() { 88 | c.v.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 89 | switch event.Key() { 90 | case tcell.KeyRune: 91 | switch event.Rune() { 92 | case 'q': 93 | c.Stop() 94 | return nil 95 | } 96 | 97 | } 98 | return event 99 | }) 100 | 101 | c.v.List.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 102 | switch event.Key() { 103 | case tcell.KeyRune: 104 | switch event.Rune() { 105 | case 'u', 'h': 106 | c.moveUp() 107 | return nil 108 | case 'j': 109 | return tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone) 110 | case 'k': 111 | return tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone) 112 | case 'l': 113 | return tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone) 114 | case 'd': 115 | i := c.v.List.GetCurrentItem() 116 | _, cur := c.v.List.GetItemText(i) 117 | cur = strings.TrimSpace(cur) 118 | c.Debugf("[%d] %s\n", i, cur) 119 | c.Debugf("download by d %s/%s%s\n", c.m.Bucket(), c.m.Prefix(), cur) 120 | c.Download(cur) 121 | return nil 122 | } 123 | 124 | } 125 | return event 126 | }) 127 | } 128 | 129 | func (c Controller) updateList() { 130 | c.v.List.Clear() 131 | 132 | c.Debugf(">> updateList bucket=%s\n", c.m.Bucket()) 133 | if c.m.Bucket() == "" { 134 | c.Debugf("select bucket\n") 135 | buckets := c.m.AvailableBuckets() 136 | c.Debugf("available buckets=%s\n", buckets) 137 | 138 | c.v.List.SetTitle("[ [::b]s3://[::-] ]") 139 | 140 | for _, _bucket := range buckets { 141 | bucket := _bucket.Name 142 | c.v.List.AddItem("[::b]s3://"+bucket+"[::-]", bucket, 0, func() { 143 | c.Debugf("select bucket=%s\n", bucket) 144 | 145 | err := c.m.SetBucket(bucket) 146 | if err != nil { 147 | panic(err) 148 | } 149 | c.updateList() 150 | }) 151 | } 152 | } else { 153 | c.Debugf("select prefix or object\n") 154 | 155 | c.v.List.SetTitle("[ [::b]s3://" + c.m.Bucket() + "/" + c.m.Prefix() + "[::-] ]") 156 | 157 | prefixes, keys, err := c.m.List() 158 | if err != nil { 159 | panic(err) 160 | } 161 | c.Debugf("prefixes=%s keys=%s\n", prefixes, keys) 162 | 163 | for _, _prefix := range prefixes { 164 | prefix := _prefix 165 | c.v.List.AddItem("[::b]"+prefix+"[::-]", prefix, 0, func() { 166 | c.Debugf("select prefix=%s\n", prefix) 167 | c.moveDown(prefix) 168 | }) 169 | } 170 | 171 | for _, _key := range keys { 172 | key := _key 173 | c.v.List.AddItem(key, key, 0, func() { 174 | c.Debugf("select key=%s\n", key) 175 | c.Debugf("download key %s/%s%s\n", c.m.Bucket(), c.m.Prefix(), key) 176 | c.Download(key) 177 | }) 178 | } 179 | 180 | } 181 | } 182 | 183 | func (c Controller) moveUp() { 184 | c.Debugf("u1 %s/%s\n", c.m.Bucket(), c.m.Prefix()) 185 | err := c.m.MoveUp() 186 | if err != nil { 187 | panic(err) 188 | } 189 | c.Debugf("u2 %s/%s\n", c.m.Bucket(), c.m.Prefix()) 190 | c.updateList() 191 | } 192 | 193 | func (c Controller) moveDown(prefix string) { 194 | err := c.m.MoveDown(prefix) 195 | if err != nil { 196 | panic(err) 197 | } 198 | c.updateList() 199 | 200 | } 201 | 202 | // Download ... 203 | func (c Controller) Download(key string) { 204 | c.Debugf("bucket=%s prefix=%s key=%s\n", c.m.Bucket(), c.m.Prefix(), key) 205 | 206 | cwd, err := os.Getwd() 207 | if err != nil { 208 | panic(err) 209 | } 210 | cwd = cwd + fmt.Sprintf("%c", filepath.Separator) 211 | 212 | totalSize := int64(0) 213 | existFilePath := []string{} 214 | objects := c.m.ListObjects(key) 215 | destPathMap := map[string]string{} 216 | for _, object := range objects { 217 | key := aws.ToString(object.Key) 218 | // download into under current directory 219 | destPath, err := filepath.Abs("./" + key) 220 | if err != nil { 221 | panic(err) 222 | } 223 | destPath = filepath.Clean(destPath) 224 | 225 | // just to be safe 226 | if !strings.HasPrefix(destPath, cwd) { 227 | panic(fmt.Sprintf("destPath is not under current directory: destPath=%s cwd=%s", destPath, cwd)) 228 | } 229 | 230 | destPathMap[key] = destPath 231 | 232 | c.Debugf("- %s %s\n", key, destPath) 233 | if _, err := os.Stat(destPath); err == nil { 234 | existFilePath = append(existFilePath, destPath) 235 | } 236 | totalSize += object.Size 237 | } 238 | 239 | // don't overwrite local files 240 | if len(existFilePath) > 0 { 241 | panic(fmt.Sprintf("\n[ABORT] following files are exists:\n%s\n", strings.Join(existFilePath, "\n"))) 242 | } 243 | 244 | // check disk available 245 | usage, err := disk.Usage(cwd) 246 | if err != nil { 247 | panic(err) 248 | } 249 | freeThreshold := int64(float64(usage.Free) * 0.8) 250 | c.Debugf("check disk free: totalSize=%d usage.Free=%d threshold=%d\n", totalSize, usage.Free, freeThreshold) 251 | if totalSize > freeThreshold { 252 | panic(fmt.Sprintf("[ABORT] there is not enough free space: download size=%d free=%d free threshold=%d", totalSize, usage.Free, freeThreshold)) 253 | } 254 | 255 | nobjects := len(objects) 256 | 257 | progress := tview.NewModal(). 258 | SetText("Downloading\n\n"). 259 | AddButtons([]string{"Done"}) 260 | 261 | confirm := tview.NewModal(). 262 | SetText(fmt.Sprintf("Do you want to download?\n%d object(s)\ntotal size %s", 263 | nobjects, 264 | humanize.IBytes(uint64(totalSize)), 265 | )). 266 | AddButtons([]string{"OK", "Cancel"}). 267 | SetDoneFunc(func(buttonIndex int, buttonLabel string) { 268 | c.v.Pages.RemovePage("confirm").SwitchToPage("main") 269 | if buttonLabel == "OK" { 270 | c.v.Pages.AddAndSwitchToPage("progress", progress, true) 271 | 272 | go func() { 273 | downloadedSize := int64(0) 274 | title := "Downloading" 275 | 276 | for i, object := range objects { 277 | key := aws.ToString(object.Key) 278 | c.Debugf("download %s\n", key) 279 | n, err := c.m.Download(object, destPathMap[key]) 280 | 281 | if err != nil { 282 | panic(err) 283 | } 284 | 285 | downloadedSize += n 286 | 287 | if i+1 == nobjects { 288 | title = "Downloaded" 289 | } 290 | 291 | c.v.App.QueueUpdateDraw(func() { 292 | progress.SetText(fmt.Sprintf("%s\n%d/%d objects\n%s/%s", 293 | title, 294 | i+1, 295 | nobjects, 296 | humanize.IBytes(uint64(downloadedSize)), 297 | humanize.IBytes(uint64(totalSize)), 298 | )) 299 | }) 300 | } 301 | 302 | progress.SetDoneFunc(func(buttonIndex int, buttonLabel string) { 303 | c.v.Pages.RemovePage("progress").SwitchToPage("main") 304 | }) 305 | }() 306 | } 307 | }) 308 | 309 | c.v.Pages.AddAndSwitchToPage("confirm", confirm, true) 310 | } 311 | -------------------------------------------------------------------------------- /pkg/c/controller_test.go: -------------------------------------------------------------------------------- 1 | package c 2 | 3 | import "testing" 4 | 5 | func TestPinger(t *testing.T) { 6 | // TODO 7 | } 8 | -------------------------------------------------------------------------------- /pkg/m/s3.go: -------------------------------------------------------------------------------- 1 | // Package m provides S3 models. 2 | package m 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/aws/aws-sdk-go-v2/aws" 12 | "github.com/aws/aws-sdk-go-v2/config" 13 | s3manager "github.com/aws/aws-sdk-go-v2/feature/s3/manager" 14 | "github.com/aws/aws-sdk-go-v2/service/s3" 15 | s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" 16 | ) 17 | 18 | // S3Bucket ... 19 | type S3Bucket struct { 20 | Name string 21 | Region string 22 | } 23 | 24 | type optsFunc = func(*config.LoadOptions) error 25 | 26 | // S3Model ... 27 | type S3Model struct { 28 | bucket string 29 | pathStyle bool 30 | availableBuckets []S3Bucket 31 | prefix string 32 | client *s3.Client 33 | downloader *s3manager.Downloader 34 | cache map[string]*ObjectCache 35 | endpointURL string 36 | } 37 | 38 | // ObjectCache ... 39 | type ObjectCache struct { 40 | prefixes []string 41 | keys []string 42 | } 43 | 44 | // NewS3Model ... 45 | func NewS3Model(endpointURL string, region string, pathStyle bool) *S3Model { 46 | s3m := S3Model{} 47 | 48 | // client 49 | if region == "" { 50 | region = "us-east-1" 51 | if strings.HasPrefix(os.Getenv("LANG"), "ja") { 52 | region = "ap-northeast-1" 53 | } 54 | } 55 | opts := []optsFunc{ 56 | config.WithRegion(region), 57 | } 58 | 59 | if endpointURL != "" { 60 | endpoint := aws.EndpointResolverFunc(func(service, r string) (aws.Endpoint, error) { 61 | return aws.Endpoint{ 62 | URL: endpointURL, 63 | SigningRegion: r, 64 | HostnameImmutable: pathStyle, 65 | }, nil 66 | }) 67 | s3m.endpointURL = endpointURL 68 | opts = append(opts, config.WithEndpointResolver(endpoint)) 69 | } 70 | 71 | cfg, err := config.LoadDefaultConfig(context.TODO(), opts...) 72 | if err != nil { 73 | panic(err) 74 | } 75 | 76 | s3m.client = s3.NewFromConfig(cfg) 77 | 78 | // avaiable buckets 79 | output, err := s3m.client.ListBuckets(context.TODO(), &s3.ListBucketsInput{}) 80 | if err != nil { 81 | panic(err) 82 | } 83 | 84 | for _, bucket := range output.Buckets { 85 | bl, err := s3m.client.GetBucketLocation( 86 | context.TODO(), 87 | &s3.GetBucketLocationInput{ 88 | Bucket: bucket.Name, 89 | }, 90 | ) 91 | if err != nil { 92 | panic(err) 93 | } 94 | 95 | // NormalizeBucketLocation in aws-sd-go v1 96 | // Replaces empty string with "us-east-1", and "EU" with "eu-west-1". 97 | // 98 | // See http://docs.aws.amazon.com/AmazonS3/latest/API/RESTBucketGETlocation.html 99 | // for more information on the values that can be returned. 100 | region := string(bl.LocationConstraint) 101 | switch region { 102 | case "": 103 | region = "us-east-1" 104 | case "EU": 105 | region = "eu-west-1" 106 | } 107 | s3m.availableBuckets = append(s3m.availableBuckets, 108 | S3Bucket{ 109 | Name: aws.ToString(bucket.Name), 110 | Region: region, 111 | }, 112 | ) 113 | } 114 | 115 | if len(s3m.AvailableBuckets()) == 0 { 116 | panic("no available S3 buckets") 117 | } 118 | 119 | // cache 120 | s3m.cache = map[string]*ObjectCache{} 121 | 122 | s3m.pathStyle = pathStyle 123 | 124 | return &s3m 125 | } 126 | 127 | // Bucket ... 128 | func (s3m S3Model) Bucket() string { 129 | return s3m.bucket 130 | } 131 | 132 | // SetBucket ... 133 | func (s3m *S3Model) SetBucket(bucket string) error { 134 | if s3m.bucket != "" { 135 | return fmt.Errorf("bucket is already set: %s", s3m.bucket) 136 | } 137 | 138 | for _, ab := range s3m.AvailableBuckets() { 139 | if ab.Name != bucket { 140 | continue 141 | } 142 | 143 | // found 144 | s3m.bucket = bucket 145 | 146 | opts := []optsFunc{ 147 | config.WithRegion(ab.Region), 148 | } 149 | if s3m.endpointURL != "" { 150 | endpoint := aws.EndpointResolverFunc(func(service, r string) (aws.Endpoint, error) { 151 | return aws.Endpoint{ 152 | URL: s3m.endpointURL, 153 | SigningRegion: r, 154 | HostnameImmutable: s3m.pathStyle, 155 | }, nil 156 | }) 157 | opts = append(opts, config.WithEndpointResolver(endpoint)) 158 | } 159 | 160 | // re-create client with region 161 | cfg, err := config.LoadDefaultConfig(context.TODO(), opts...) 162 | if err != nil { 163 | panic(err) 164 | } 165 | 166 | s3m.client = s3.NewFromConfig(cfg) 167 | s3m.downloader = s3manager.NewDownloader(s3m.client, func(d *s3manager.Downloader) { 168 | d.BufferProvider = s3manager.NewPooledBufferedWriterReadFromProvider(8 * 1024 * 1024) // 8MB 169 | }) 170 | 171 | return nil 172 | } 173 | 174 | return fmt.Errorf("not found in available buckets: %s", bucket) 175 | } 176 | 177 | // AvailableBuckets ... 178 | func (s3m S3Model) AvailableBuckets() []S3Bucket { 179 | return s3m.availableBuckets 180 | } 181 | 182 | // Prefix ... 183 | func (s3m S3Model) Prefix() string { 184 | return s3m.prefix 185 | } 186 | 187 | func (s3m *S3Model) setPrefix(prefix string) error { 188 | if prefix != "" && !strings.HasSuffix(prefix, "/") { 189 | return fmt.Errorf("prefix must be end with slash: %s", prefix) 190 | } 191 | 192 | s3m.prefix = prefix 193 | return nil 194 | } 195 | 196 | // MoveUp ... 197 | func (s3m *S3Model) MoveUp() error { 198 | return s3m.setPrefix(upperPrefix((s3m.prefix))) 199 | } 200 | 201 | // MoveDown ... 202 | func (s3m *S3Model) MoveDown(prefix string) error { 203 | return s3m.setPrefix(s3m.prefix + prefix) 204 | } 205 | 206 | // List ... 207 | func (s3m S3Model) List() ( 208 | prefixes []string, 209 | keys []string, 210 | err error, 211 | ) { 212 | if s3m.bucket == "" { 213 | return nil, nil, fmt.Errorf("bucket not set") 214 | } 215 | 216 | if cache, ok := s3m.cache[s3m.prefix]; ok { 217 | return cache.prefixes, cache.keys, nil 218 | } 219 | 220 | input := &s3.ListObjectsV2Input{ 221 | Bucket: aws.String(s3m.bucket), 222 | Delimiter: aws.String("/"), 223 | Prefix: aws.String(s3m.prefix), 224 | } 225 | 226 | paginator := s3.NewListObjectsV2Paginator(s3m.client, input) 227 | for paginator.HasMorePages() { 228 | output, err := paginator.NextPage(context.TODO()) 229 | if err != nil { 230 | panic(err) 231 | } 232 | 233 | for _, prefix := range output.CommonPrefixes { 234 | prefixes = append(prefixes, lastPartPrefix(aws.ToString(prefix.Prefix))) 235 | } 236 | for _, object := range output.Contents { 237 | keys = append(keys, lastPartPrefix(aws.ToString(object.Key))) 238 | } 239 | } 240 | 241 | s3m.cache[s3m.prefix] = &ObjectCache{ 242 | prefixes: prefixes, 243 | keys: keys, 244 | } 245 | 246 | return prefixes, keys, nil 247 | } 248 | 249 | // ListObjects ... 250 | func (s3m S3Model) ListObjects(key string) []s3types.Object { 251 | var objects []s3types.Object 252 | 253 | targetPrefix := s3m.Prefix() 254 | if key != "" { 255 | targetPrefix += key 256 | } 257 | 258 | input := &s3.ListObjectsV2Input{ 259 | Bucket: aws.String(s3m.Bucket()), 260 | Prefix: aws.String(targetPrefix), 261 | } 262 | 263 | paginator := s3.NewListObjectsV2Paginator(s3m.client, input) 264 | for paginator.HasMorePages() { 265 | output, err := paginator.NextPage(context.TODO()) 266 | if err != nil { 267 | panic(err) 268 | } 269 | 270 | objects = append(objects, output.Contents...) 271 | } 272 | 273 | return objects 274 | } 275 | 276 | // Download ... 277 | func (s3m S3Model) Download(object s3types.Object, destPath string) (n int64, err error) { 278 | if err = os.MkdirAll(filepath.Dir(destPath), 0700); err != nil { 279 | return 0, err 280 | } 281 | 282 | _, err = os.Stat(destPath) 283 | if err == nil { 284 | return 0, fmt.Errorf("exists") 285 | } 286 | 287 | fp, err := os.Create(filepath.Clean(destPath)) 288 | if err != nil { 289 | return 0, err 290 | } 291 | /* #nosec G307 */ 292 | defer func() { 293 | if err := fp.Close(); err != nil { 294 | panic(err) 295 | } 296 | }() 297 | 298 | return s3m.downloader.Download( 299 | context.TODO(), 300 | fp, 301 | &s3.GetObjectInput{ 302 | Bucket: aws.String(s3m.Bucket()), 303 | Key: object.Key, 304 | }, 305 | ) 306 | } 307 | 308 | func upperPrefix(prefix string) string { 309 | if prefix == "" { 310 | return "" 311 | } 312 | 313 | prefixNoslash := prefix[:len(prefix)-1] 314 | i := strings.LastIndex(prefixNoslash, "/") 315 | 316 | if i == -1 { 317 | // "foo/" => "" 318 | return "" 319 | } 320 | 321 | // "foo/bar/baz/" => "foo/bar/" 322 | return prefixNoslash[:i+1] 323 | } 324 | 325 | func lastPartPrefix(prefix string) string { 326 | if prefix == "" { 327 | return "" 328 | } 329 | 330 | prefixNoslash := prefix[:len(prefix)-1] 331 | i := strings.LastIndex(prefixNoslash, "/") 332 | 333 | if i == -1 { 334 | // "foo/" => "foo/" 335 | return prefix 336 | } 337 | 338 | // "foo/bar/baz/" => "baz/" 339 | return prefix[i+1:] 340 | } 341 | -------------------------------------------------------------------------------- /pkg/m/s3_test.go: -------------------------------------------------------------------------------- 1 | package m 2 | 3 | import "testing" 4 | 5 | func TestPinger(t *testing.T) { 6 | // TODO 7 | } 8 | -------------------------------------------------------------------------------- /pkg/v/ui.go: -------------------------------------------------------------------------------- 1 | // Package v provides UI View 2 | package v 3 | 4 | import ( 5 | "runtime" 6 | 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/mattn/go-runewidth" 9 | "github.com/rivo/tview" 10 | ) 11 | 12 | // View ... 13 | type View struct { 14 | App *tview.Application 15 | Frame *tview.Frame 16 | Pages *tview.Pages 17 | List *tview.List 18 | } 19 | 20 | func init() { 21 | if runtime.GOOS == "windows" && runewidth.IsEastAsian() { 22 | tview.Borders.Horizontal = '-' 23 | tview.Borders.Vertical = '|' 24 | tview.Borders.TopLeft = '+' 25 | tview.Borders.TopRight = '+' 26 | tview.Borders.BottomLeft = '+' 27 | tview.Borders.BottomRight = '+' 28 | tview.Borders.LeftT = '|' 29 | tview.Borders.RightT = '|' 30 | tview.Borders.TopT = '-' 31 | tview.Borders.BottomT = '-' 32 | tview.Borders.Cross = '+' 33 | tview.Borders.HorizontalFocus = '=' 34 | tview.Borders.VerticalFocus = '|' 35 | tview.Borders.TopLeftFocus = '+' 36 | tview.Borders.TopRightFocus = '+' 37 | tview.Borders.BottomLeftFocus = '+' 38 | tview.Borders.BottomRightFocus = '+' 39 | } 40 | } 41 | 42 | // NewView ... 43 | func NewView() View { 44 | app := tview.NewApplication() 45 | 46 | list := tview.NewList(). 47 | ShowSecondaryText(false) 48 | list.SetBorder(true). 49 | SetTitleAlign(tview.AlignLeft) 50 | 51 | main := tview.NewFlex(). 52 | AddItem(list, 0, 1, true) 53 | 54 | pages := tview.NewPages(). 55 | AddPage("main", main, true, true) 56 | 57 | frame := tview.NewFrame(pages) 58 | frame.AddText("[::b][↓,j/↑,k][::-] Down/Up [::b][Enter,l/u,h][::-] Lower/Upper [::b][d[][::-] Download [::b][q[][::-] Quit", false, tview.AlignCenter, tcell.ColorWhite) 59 | 60 | app.SetRoot(frame, true) 61 | 62 | v := View{ 63 | app, 64 | frame, 65 | pages, 66 | list, 67 | } 68 | 69 | return v 70 | } 71 | -------------------------------------------------------------------------------- /pkg/v/ui_test.go: -------------------------------------------------------------------------------- 1 | package v 2 | 3 | import "testing" 4 | 5 | func TestPinger(t *testing.T) { 6 | // TODO 7 | } 8 | -------------------------------------------------------------------------------- /s3surfer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hirose31/s3surfer/644cc23d6ab642d3b8b58f5e6eb2868081ef5ef0/s3surfer.gif --------------------------------------------------------------------------------