├── .github
└── workflows
│ └── release.yml
├── .gitignore
├── .goreleaser.yaml
├── ARCHITECTURE.md
├── CHANGELOG.md
├── INSTALL.md
├── LICENSE
├── README.md
├── USAGE.md
├── assets
└── bucket.svg
├── cmd
├── README.md
├── configure.go
├── cp.go
├── ls.go
├── mb.go
├── mv.go
├── presign.go
├── rb.go
├── rm.go
├── root.go
└── sync.go
├── go.mod
├── go.sum
├── install.sh
├── main.go
├── pkg
├── README.md
├── bucket.go
├── client.go
└── helpers.go
├── scripts
└── make-install.sh
└── uninstall.sh
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 |
8 | permissions:
9 | contents: write
10 |
11 | jobs:
12 | goreleaser:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v3
16 | with:
17 | fetch-depth: 0
18 | - run: git fetch --force --tags
19 | - uses: actions/setup-go@v3
20 | with:
21 | go-version: '>=1.19.5'
22 | cache: true
23 | - uses: anchore/sbom-action@v0
24 | - uses: goreleaser/goreleaser-action@v4
25 | with:
26 | distribution: goreleaser
27 | version: latest
28 | args: release --rm-dist
29 | env:
30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31 | install-script:
32 | runs-on: ubuntu-latest
33 | steps:
34 | - uses: actions/checkout@v3
35 | - name: Make install script
36 | run: bash scripts/make-install.sh
37 | - name: Commit install script
38 | run: |
39 | git config user.name github-actions
40 | git config user.email github-action@github.com
41 | git add install.sh
42 | git commit -m "$(git describe --tags --abbrev=0) install script update"
43 | - name: Push install script
44 | run: git push origin HEAD:main
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | dist/
3 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | project_name: r2
2 | before:
3 | hooks:
4 | - go mod tidy
5 | builds:
6 | - ldflags:
7 | - -s -w -X github.com/erdos-one/r2/cmd.version={{.Version}}
8 | env:
9 | - CGO_ENABLED=0
10 | goos:
11 | - linux
12 | - darwin
13 |
14 | archives:
15 | - format: tar.gz
16 | name_template: >-
17 | {{ .ProjectName }}-
18 | {{- title .Os }}-
19 | {{- if eq .Arch "amd64" }}x86_64
20 | {{- else if eq .Arch "386" }}i386
21 | {{- else }}{{ .Arch }}{{ end }}
22 | {{- if .Arm }}v{{ .Arm }}{{ end }}
23 | checksum:
24 | name_template: 'checksums.txt'
25 | snapshot:
26 | name_template: "{{ incpatch .Version }}-next"
27 | changelog:
28 | sort: asc
29 | filters:
30 | exclude:
31 | - '^docs:'
32 | - '^test:'
33 | sboms:
34 | - artifacts: archive
35 |
--------------------------------------------------------------------------------
/ARCHITECTURE.md:
--------------------------------------------------------------------------------
1 | # Architecture
2 |
3 | Knowing where to find things in the repo can be difficult. This document aims to help you find your
4 | way.
5 |
6 | ## [cmd](cmd)
7 |
8 | The cmd directory contains the source code for the CLI. The CLI is built using the [cobra
9 | ](https://cobra.dev) framework.
10 |
11 | The CLI is split into several files:
12 |
13 | - [cmd/root.go](cmd/root.go) contains the root command that is executed when `r2` is run
14 | - [cmd/configure.go](cmd/configure.go) contains the `configure` command
15 | - [cmd/cp.go](cmd/cp.go) contains the `cp` command
16 | - [cmd/ls.go](cmd/ls.go) contains the `ls` command
17 | - [cmd/mb.go](cmd/mb.go) contains the `mb` command
18 | - [cmd/mv.go](cmd/mv.go) contains the `mv` command
19 | - [cmd/presign.go](cmd/presign.go) contains the `presign` command
20 | - [cmd/rb.go](cmd/rb.go) contains the `rb` command
21 | - [cmd/rm.go](cmd/rm.go) contains the `rm` command
22 | - [cmd/sync.go](cmd/sync.go) contains the `sync` command
23 |
24 | ## [pkg](pkg)
25 |
26 | The pkg directory contains the source code for the R2 package — the library enabling the CLI to
27 | communicate with R2.
28 |
29 | - [pkg/client.go](pkg/client.go) contains all R2 client-level operations (e.g. configuration, bucket
30 | creation, etc.)
31 | - [pkg/bucket.go](pkg/bucket.go) contains all bucket-level operations (e.g. listing objects, fetching
32 | objects, etc.)
33 | - [pkg/helpers.go](pkg/helpers.go) contains miscellaneous helper functions used throughout the CLI
34 |
35 | ## [workflows](.github/workflows)
36 |
37 | The workflows directory contains the GitHub Actions workflows used for this repo.
38 |
39 | - [.github/workflow/assembly.yml](.github/workflows/assembly.yml)
40 | - Bumps the version of the CLI in the [install script](install.sh) to fetch the latest release
41 | - [.github/workflow/release.yml](.github/workflows/release.yml)
42 | - Builds and deploys the CLI to GitHub Releases
43 |
44 | ## [assets](assets)
45 |
46 | The assets directory contains the assets used for the repo.
47 |
48 | - [assets/bucket.svg](assets/bucket.svg) is the R2 bucket icon used in the [README](README.md)
49 |
50 | ## [install.sh](install.sh)
51 |
52 | The install script is used to install the latest release of the CLI.
53 |
54 | ## Thanks
55 |
56 | Thanks to [Alex Kladov](https://matklad.github.io/) for his [blog post
57 | ](https://matklad.github.io//2021/02/06/ARCHITECTURE.md.html) on the importance of having an
58 | ARCHITECTURE.md file.
59 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | This changelog goes through all the changes that have been made in each release.
4 |
5 | ## v0.1.0-alpha
6 |
7 | First release of `r2`! This release includes all the commands of the AWS CLI's `s3` subcommand, but
8 | not all the options.
9 |
10 | - ADDED
11 | - [`configure` command](cmd/configure.go) — configure `r2` to use your R2 credentials
12 | - [`cp` command](cmd/cp.go) — copy objects and directories
13 | - [`ls` command](cmd/ls.go) — list objects and directories
14 | - [`mb` command](cmd/mb.go) — make buckets
15 | - [`mv` command](cmd/mv.go) — move objects and directories
16 | - [`presign` command](cmd/presign.go) — generate pre-signed URLs
17 | - [`rb` command](cmd/rb.go) — remove buckets
18 | - [`rm` command](cmd/rm.go) — remove objects and directories
19 | - [`sync` command](cmd/sync.go) — synchronize objects and directories
20 |
--------------------------------------------------------------------------------
/INSTALL.md:
--------------------------------------------------------------------------------
1 | # Installing r2
2 |
3 | ## CLI
4 |
5 | The zero-dependency CLI is available for Linux and macOS and can be installed with the following
6 | command:
7 |
8 | ```bash
9 | sh <(curl https://install.erdos.one/r2)
10 | ```
11 |
12 | ## Library
13 |
14 | The library is available as a Go module and can be installed with the following command:
15 |
16 | ```bash
17 | go get github.com/erdos-one/r2/pkg
18 | ```
19 |
--------------------------------------------------------------------------------
/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 2023 Erdos
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Cloudflare R2 object storage made easy
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ## Purpose
21 |
22 | `r2` is a library and command line interface for working with Cloudflare's
23 | [R2 Storage](https://www.cloudflare.com/products/r2/).
24 |
25 | Cloudflare's R2 implements the [S3
26 | API](https://docs.aws.amazon.com/AmazonS3/latest/API/Welcome.html), attempting to allow users and
27 | their applications to migrate easily, but importantly lacks the key, simple-to-use features provided
28 | by the AWS CLI's [s3 subcommand](https://docs.aws.amazon.com/cli/latest/reference/s3/), as opposed
29 | to the more complex and verbose API calls of the [s3api
30 | subcommand](https://docs.aws.amazon.com/cli/latest/reference/s3api/index.html). This CLI fills that
31 | gap.
32 |
33 | ## Installation
34 |
35 | To install the `r2` CLI, simply run the following command:
36 |
37 | ```bash
38 | go install github.com/erdos-one/r2@latest
39 | ```
40 |
41 | For more installation options, see [INSTALL.md](INSTALL.md).
42 |
43 | ## Usage
44 |
45 | To view the CLI's help message, run:
46 |
47 | ```bash
48 | r2 help
49 | ```
50 |
51 | ### Available Commands
52 |
53 | - `r2 configure` — Configure R2 access
54 | - `r2 cp` — Copy an object from one R2 path to another
55 | - `r2 help` — Help about any command
56 | - `r2 ls` — List either all buckets or all objects in a bucket
57 | - `r2 mb` — Create an R2 bucket
58 | - `r2 mv` — Moves a local file or R2 object to another location locally or in R2.
59 | - `r2 presign` — Generate a pre-signed URL for a Cloudflare R2 object
60 | - `r2 rb` — Remove an R2 bucket
61 | - `r2 rm` — Remove an object from an R2 bucket
62 | - `r2 sync` — Syncs directories and R2 prefixes.
63 |
64 | To view the help message for a specific command, run:
65 |
66 | ```bash
67 | r2 help
68 | ```
69 |
70 | For more usage information — including library usage — see [USAGE.md](USAGE.md).
71 |
72 | ## Progress
73 |
74 | We're working to implement all the functionality of the AWS CLI's s3 subcommand. As of
75 | [v0.1.0-alpha](https://github.com/erdos-one/r2/tree/v0.1.0-alpha), we have all the commands
76 | implemented, but not all the options. We're working on it, but if you'd like to lend a helping hand,
77 | we'd much appreciate your help!
78 |
79 | To view the latest changes, see [CHANGELOG.md](CHANGELOG.md).
80 |
81 | ## Contributing
82 |
83 | Our expected workflow is: Fork → Patch → Push → Pull Request.
84 |
85 | Another helpful way to contribute is to report bugs or request features by opening an issue. We
86 | appreciate contributions of all kinds!
87 |
88 | To understand the codebase, we recommend reading the [ARCHITECTURE.md](ARCHITECTURE.md) file.
89 |
--------------------------------------------------------------------------------
/USAGE.md:
--------------------------------------------------------------------------------
1 | # Usage
2 |
3 | `r2` is a library and command line interface for working with Cloudflare's R2 Storage.
4 |
5 | ## CLI
6 |
7 | ```bash
8 | r2 [command] [flags]
9 | ```
10 |
11 | ### Available Commands
12 |
13 | - `configure` — Configure R2 access
14 | - `cp` — Copy an object from one R2 path to another
15 | - `help` — Help about any command
16 | - `ls` — List either all buckets or all objects in a bucket
17 | - `mb` — Create an R2 bucket
18 | - `mv` — Moves a local file or R2 object to another location locally or in R2.
19 | - `presign` — Generate a pre-signed URL for a Cloudflare R2 object
20 | - `rb` — Remove an R2 bucket
21 | - `rm` — Remove an object from an R2 bucket
22 | - `sync` — Syncs directories and R2 prefixes.
23 |
24 | ### Global Flags
25 |
26 | - `-p, --profile` — R2 profile to use (default "default")
27 | - `-h, --help` — Help for any command
28 |
29 | ### Help
30 |
31 | Help for any command can be obtained by running `r2 help [command]`. For example:
32 |
33 | ```bash
34 | # Help for the configure command
35 | r2 help configure
36 | ```
37 |
38 | ## Library
39 |
40 | The `r2` library can be used to interact with R2 from within your Go application. All library code
41 | exists in the [pkg](pkg) directory and is well documented.
42 |
43 | Documentation may be found [here](https://pkg.go.dev/github.com/erdos-one/r2/pkg).
44 |
45 | ### Example
46 |
47 | Uploading a file to a bucket:
48 |
49 | ```go
50 | package main
51 |
52 | import (
53 | r2 "github.com/erdos-one/r2/pkg"
54 | )
55 |
56 | func main() {
57 | // Create client
58 | config := r2.Config{
59 | Profile: "default",
60 | AccountID: "",
61 | AccessKeyID: "",
62 | SecretAccessKey: ""
63 | }
64 | client := r2.Client(config)
65 |
66 | // Connect to bucket
67 | bucket := client.Bucket("my-bucket")
68 |
69 | // Upload a file to the bucket
70 | bucket.Upload("my-local-file.txt", "my-remote-file.txt")
71 | }
72 | ```
73 |
--------------------------------------------------------------------------------
/assets/bucket.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/cmd/README.md:
--------------------------------------------------------------------------------
1 | # cmd
2 |
3 | This directory contains the source code for the `r2` CLI, which leverages the [Cobra
4 | ](https://cobra.dev) CLI framework.
5 |
6 | ## Architecture
7 |
8 | - [root.go](root.go) contains the root command that is executed when `r2` is run
9 | - [configure.go](configure.go) contains the `configure` command
10 | - [cp.go](cp.go) contains the `cp` command
11 | - [ls.go](ls.go) contains the `ls` command
12 | - [mb.go](mb.go) contains the `mb` command
13 | - [mv.go](mv.go) contains the `mv` command
14 | - [presign.go](presign.go) contains the `presign` command
15 | - [rb.go](rb.go) contains the `rb` command
16 | - [rm.go](rm.go) contains the `rm` command
17 | - [sync.go](sync.go) contains the `sync` command
18 |
--------------------------------------------------------------------------------
/cmd/configure.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 | "path/filepath"
8 | "regexp"
9 | "sort"
10 | "strings"
11 |
12 | "github.com/erdos-one/r2/pkg"
13 |
14 | "github.com/spf13/cobra"
15 | )
16 |
17 | // configString formats a set of Cloudflare R2 credentials into a string that can be written to the
18 | // ~/.r2 configuration file. Allowing for multiple profiles, each profile is formatted as a section
19 | // with the profile name in square brackets. The profile name is followed by the account ID, access
20 | // key ID, and secret access key.
21 | func configString(c pkg.Config) string {
22 | configTemplate := "[%s]\naccount_id=%s\naccess_key_id=%s\nsecret_access_key=%s"
23 | return fmt.Sprintf(configTemplate, c.Profile, c.AccountID, c.AccessKeyID, c.SecretAccessKey)
24 | }
25 |
26 | // getConfigPath returns the path to the ~/.r2 configuration file, accounting for different
27 | // operating systems' conventions for naming the home directory.
28 | func getConfigPath() string {
29 | homeDir, err := os.UserHomeDir()
30 | if err != nil {
31 | log.Fatal(err)
32 | }
33 | return filepath.Join(homeDir, ".r2")
34 | }
35 |
36 | // R2ConfigFile globally defines the path to the ~/.r2 configuration file.
37 | var R2ConfigFile = getConfigPath()
38 |
39 | // getProfile returns the Cloudflare R2 credentials for the specified profile. If the profile does
40 | // not exist, it is created interactively and saved to the ~/.r2 configuration file.
41 | func getProfile(profileName string) pkg.Config {
42 | // Get profiles
43 | profiles := getConfig(false)
44 |
45 | // If profile exists, return it
46 | for _, profile := range profiles {
47 | if profile.Profile == profileName {
48 | return profile
49 | }
50 | }
51 |
52 | // Profile doesn't exist, create new one and save to ~/.r2 config file
53 | profile := getCredentials(profileName)
54 | writeConfig(profile)
55 |
56 | return profile
57 | }
58 |
59 | // getCredentials prompts the user to enter the Cloudflare R2 credentials for a specified profile.
60 | // If no profile is specified, the user is prompted to enter a profile name.
61 | func getCredentials(profile string) pkg.Config {
62 | var c pkg.Config
63 |
64 | // Get profile
65 | if profile == "" {
66 | // Get profile name
67 | fmt.Print("Profile [default]: ")
68 | fmt.Scanln(&profile)
69 | if profile == "" {
70 | profile = "default"
71 | }
72 | }
73 | c.Profile = profile
74 |
75 | // Get account ID
76 | fmt.Print("Account ID: ")
77 | fmt.Scanln(&c.AccountID)
78 |
79 | // Get access key ID
80 | fmt.Print("Access Key ID: ")
81 | fmt.Scanln(&c.AccessKeyID)
82 |
83 | // Get secret access key
84 | fmt.Print("Secret Access Key: ")
85 | fmt.Scanln(&c.SecretAccessKey)
86 |
87 | return c
88 | }
89 |
90 | // Parse configuration file and return profiles
91 | func getConfig(createIfNotPresent bool) map[string]pkg.Config {
92 | // Create configuration file if it doesn't exist
93 | if _, err := os.Stat(R2ConfigFile); os.IsNotExist(err) {
94 | // If not creating configuration file, return empty map
95 | if !createIfNotPresent {
96 | return make(map[string]pkg.Config)
97 | }
98 |
99 | f, err := os.Create(R2ConfigFile)
100 | if err != nil {
101 | log.Fatal(err)
102 | }
103 | defer f.Close()
104 |
105 | // Get credentials interactively and write to configuration file
106 | writeConfig(getCredentials(""))
107 | }
108 |
109 | // Read configuration file
110 | c, err := os.ReadFile(R2ConfigFile)
111 | if err != nil {
112 | log.Fatal(err)
113 | }
114 |
115 | // Remove empty lines
116 | configString := regexp.MustCompile(`^\n$`).ReplaceAllString(string(c), "")
117 |
118 | // Parse configuration file into profiles
119 | var profiles = make(map[string]pkg.Config)
120 |
121 | profilesRe := regexp.MustCompile(`\[[\w\s\]=]+`)
122 | for _, p := range profilesRe.FindAllString(configString, -1) {
123 | // Parse profiles
124 | var profile pkg.Config
125 |
126 | // Get profile name
127 | if regexp.MustCompile(`\[\w+\]`).MatchString(p) {
128 | profile.Profile = regexp.MustCompile(`\[(\w+)\]`).FindAllStringSubmatch(p, -1)[0][1]
129 | }
130 |
131 | // Get account ID
132 | accountIDRe := regexp.MustCompile(`account_id\s*=\s*(\w+)`)
133 | if accountIDRe.MatchString(p) {
134 | profile.AccountID = accountIDRe.FindAllStringSubmatch(p, -1)[0][1]
135 | }
136 |
137 | // Get access key ID
138 | akidRe := regexp.MustCompile(`access_key_id\s*=\s*(\w+)`)
139 | if akidRe.MatchString(p) {
140 | profile.AccessKeyID = akidRe.FindAllStringSubmatch(p, -1)[0][1]
141 | }
142 |
143 | // Get secret access key
144 | sakRe := regexp.MustCompile(`secret_access_key\s*=\s*(\w+)`)
145 | if sakRe.MatchString(p) {
146 | profile.SecretAccessKey = sakRe.FindAllStringSubmatch(p, -1)[0][1]
147 | }
148 |
149 | profiles[profile.Profile] = profile
150 | }
151 |
152 | return profiles
153 | }
154 |
155 | // listProfiles returns a list of all profiles in the ~/.r2 configuration file. Profile names are
156 | // sorted alphabetically, irrespective of case, with the default profile always first.
157 | func listProfiles() []string {
158 | // Get profiles
159 | profiles := getConfig(false)
160 |
161 | // Get profile names and sort alphabetically (default profile is always first)
162 | var profileNames []string
163 | for _, p := range profiles {
164 | if p.Profile != "default" {
165 | profileNames = append(profileNames, p.Profile)
166 | }
167 | }
168 |
169 | sort.Slice(profileNames, func(i, j int) bool {
170 | return strings.ToLower(profileNames[i]) < strings.ToLower(profileNames[j])
171 | })
172 |
173 | if _, ok := profiles["default"]; ok {
174 | profileNames = append([]string{"default"}, profileNames...)
175 | }
176 |
177 | return profileNames
178 | }
179 |
180 | // writeConfig writes the provided profiles to the ~/.r2 configuration file. If a profile already
181 | // exists, it is overwritten. If all credentials are not provided, the function fails. Profiles are
182 | // sorted alphabetically, irrespective of case, with the default profile always first.
183 | func writeConfig(c pkg.Config) {
184 | // Read configuration file
185 | profiles := getConfig(false)
186 |
187 | // If not all credentials are provided, fail
188 | if c.AccountID == "" || c.AccessKeyID == "" || c.SecretAccessKey == "" {
189 | log.Fatal("All credentials must be provided")
190 | }
191 |
192 | // Add profile to configuration
193 | profiles[c.Profile] = c
194 |
195 | // Format profile strings and sort alphabetically (default profile is always first)
196 | var configStrings []string
197 | for _, p := range profiles {
198 | if p.Profile != "default" {
199 | configStrings = append(configStrings, configString(p))
200 | }
201 | }
202 |
203 | sort.Slice(configStrings, func(i, j int) bool {
204 | return strings.ToLower(configStrings[i]) < strings.ToLower(configStrings[j])
205 | })
206 |
207 | if _, ok := profiles["default"]; ok {
208 | configStrings = append([]string{configString(profiles["default"])}, configStrings...)
209 | }
210 |
211 | // Write configuration to file
212 | f, err := os.Create(R2ConfigFile)
213 | if err != nil {
214 | log.Fatal(err)
215 | }
216 | defer f.Close()
217 | _, err = f.WriteString(strings.Join(configStrings, "\n\n") + "\n")
218 | if err != nil {
219 | log.Fatal(err)
220 | }
221 | }
222 |
223 | // configureCmd represents the configure command
224 | var configureCmd = &cobra.Command{
225 | Use: "configure",
226 | Short: "Configure R2 access",
227 | Long: `Configure R2 access by providing Cloudflare R2 API Token credentials.
228 |
229 | Configuration can be done interactively or by passing flags. If you pass flags,
230 | you must provide both the access key ID and secret access key, otherwise the
231 | command will fail.
232 |
233 | To configure interactively, run:
234 | r2 configure
235 |
236 | To configure with flags, run:
237 | r2 configure --access-key-id \
238 | --secret-access-key
239 |
240 | If you have multiple R2 tokens, you can configure a named profile by passing
241 | the --profile flag.
242 |
243 | Interactively:
244 | r2 configure --profile my-profile
245 |
246 | With flags:
247 | r2 configure --profile my-profile --access-key-id \
248 | --secret-access-key
249 |
250 | Profiles are stored in ~/.r2 and can be used by passing the --profile flag to
251 | any command.
252 |
253 | To list available profiles, run:
254 | r2 configure --list
255 |
256 | To generate an API Token, follow Cloudflare's guide at:
257 | https://developers.cloudflare.com/r2/data-access/s3-api/tokens/
258 |
259 | Be careful not to share your API Token credentials with anyone.`,
260 | Run: func(cmd *cobra.Command, args []string) {
261 | // Handle list flag
262 | list, err := cmd.Flags().GetBool("list")
263 | if err != nil {
264 | log.Fatal(err)
265 | }
266 | if list {
267 | // List profiles
268 | fmt.Println(strings.Join(listProfiles(), "\n"))
269 | } else {
270 | // Parse configuration
271 | var c pkg.Config
272 | var err error
273 |
274 | // Get profile name
275 | c.Profile, err = cmd.Flags().GetString("profile")
276 | if err != nil {
277 | log.Fatal(err)
278 | }
279 |
280 | // Get account ID
281 | c.AccountID, err = cmd.Flags().GetString("account-id")
282 | if err != nil {
283 | log.Fatal(err)
284 | }
285 |
286 | // Get access key ID
287 | c.AccessKeyID, err = cmd.Flags().GetString("access-key-id")
288 | if err != nil {
289 | log.Fatal(err)
290 | }
291 |
292 | // Get secret access key
293 | c.SecretAccessKey, err = cmd.Flags().GetString("secret-access-key")
294 | if err != nil {
295 | log.Fatal(err)
296 | }
297 |
298 | // Either access key ID or secret access key not passed but not both
299 | if (c.AccessKeyID == "" && c.SecretAccessKey != "") || (c.AccessKeyID != "" && c.SecretAccessKey == "") {
300 | log.Fatal(`Error: You must either provide both the access key ID and secret access key or
301 | neither to configure interactively.
302 |
303 | For more information, run:
304 | r2 help configure`)
305 | } else {
306 | // Check if configuration provided
307 | if c.AccountID != "" && c.AccessKeyID != "" && c.SecretAccessKey != "" {
308 | writeConfig(c)
309 | } else {
310 | // If no configuration provided, get configuration interactively
311 | writeConfig(getCredentials(""))
312 | }
313 | }
314 | }
315 | },
316 | }
317 |
318 | // init adds the configure command to the root command and adds flags to the configure command
319 | func init() {
320 | // Add the configure command to the root command
321 | rootCmd.AddCommand(configureCmd)
322 |
323 | // Add flags to the configure command
324 | configureCmd.Flags().BoolP("list", "l", false, "List all named profiles")
325 | configureCmd.Flags().String("profile", "", "Configure a named profile")
326 | configureCmd.Flags().String("account-id", "", "R2 Account ID")
327 | configureCmd.Flags().String("access-key-id", "", "R2 Access Key ID")
328 | configureCmd.Flags().String("secret-access-key", "", "R2 Secret Access Key")
329 | }
330 |
--------------------------------------------------------------------------------
/cmd/cp.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/erdos-one/r2/pkg"
7 |
8 | "github.com/spf13/cobra"
9 | )
10 |
11 | // cpCmd represents the cp command
12 | var cpCmd = &cobra.Command{
13 | Use: "cp",
14 | Short: "Copy an object from one R2 path to another",
15 | Long: ``,
16 | Run: func(cmd *cobra.Command, args []string) {
17 | // Get profile client
18 | profileName, err := cmd.Flags().GetString("profile")
19 | if err != nil {
20 | log.Fatal(err)
21 | }
22 | c := pkg.Client(getProfile(profileName))
23 |
24 | // If a bucket name is provided, create the bucket
25 | if len(args) == 2 {
26 | sourcePath := args[0]
27 | destinationPath := args[1]
28 | if !pkg.IsR2URI(sourcePath) && pkg.IsR2URI(destinationPath) {
29 | // Copy local file to R2
30 | destURI := pkg.ParseR2URI(destinationPath)
31 | b := c.Bucket(destURI.Bucket)
32 | b.Upload(sourcePath, destURI.Path)
33 | } else if pkg.IsR2URI(sourcePath) && !pkg.IsR2URI(destinationPath) {
34 | // Copy R2 object to local file
35 | sourceURI := pkg.ParseR2URI(sourcePath)
36 | b := c.Bucket(sourceURI.Bucket)
37 | b.Download(sourceURI.Path, destinationPath)
38 | } else if pkg.IsR2URI(sourcePath) && pkg.IsR2URI(destinationPath) {
39 | // Copy R2 object to R2 object
40 | sourceURI := pkg.ParseR2URI(sourcePath)
41 | destURI := pkg.ParseR2URI(destinationPath)
42 | b := c.Bucket(sourceURI.Bucket)
43 | b.Copy(sourceURI.Path, destURI)
44 | }
45 | } else {
46 | log.Fatal("Please provide both a source and destination path.")
47 | }
48 | },
49 | }
50 |
51 | func init() {
52 | // Add the cp command to the root command
53 | rootCmd.AddCommand(cpCmd)
54 | }
55 |
--------------------------------------------------------------------------------
/cmd/ls.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/erdos-one/r2/pkg"
7 |
8 | "github.com/spf13/cobra"
9 | )
10 |
11 | // lsCmd represents the ls command
12 | var lsCmd = &cobra.Command{
13 | Use: "ls",
14 | Short: "List either all buckets or all objects in a bucket",
15 | Run: func(cmd *cobra.Command, args []string) {
16 | // Get profile client
17 | profileName, err := cmd.Flags().GetString("profile")
18 | if err != nil {
19 | log.Fatal(err)
20 | }
21 | c := pkg.Client(getProfile(profileName))
22 |
23 | if len(args) > 0 {
24 | // If args passed to ls, list objects in each bucket passed
25 | for _, bucketName := range args {
26 | // Remove URI scheme if present
27 | bucketName = pkg.RemoveR2URIPrefix(bucketName)
28 |
29 | b := c.Bucket(bucketName)
30 | b.PrintObjects()
31 | }
32 | } else {
33 | // If no args passed to ls, list all buckets
34 | c.PrintBuckets()
35 | }
36 | },
37 | }
38 |
39 | func init() {
40 | // Add the ls command to the root command
41 | rootCmd.AddCommand(lsCmd)
42 | }
43 |
--------------------------------------------------------------------------------
/cmd/mb.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 | "log"
6 |
7 | "github.com/erdos-one/r2/pkg"
8 |
9 | "github.com/spf13/cobra"
10 | )
11 |
12 | // mbCmd represents the mb command
13 | var mbCmd = &cobra.Command{
14 | Use: "mb",
15 | Short: "Create an R2 bucket",
16 | Long: ``,
17 | Run: func(cmd *cobra.Command, args []string) {
18 | // Get profile client
19 | profileName, err := cmd.Flags().GetString("profile")
20 | if err != nil {
21 | log.Fatal(err)
22 | }
23 | c := pkg.Client(getProfile(profileName))
24 |
25 | // If a bucket name is provided, create the bucket
26 | if len(args) > 0 {
27 | bucketName := args[0]
28 | c.MakeBucket(bucketName)
29 | } else {
30 | fmt.Println("Please provide a bucket name")
31 | }
32 | },
33 | }
34 |
35 | func init() {
36 | // Add the mb command to the root command
37 | rootCmd.AddCommand(mbCmd)
38 | }
39 |
--------------------------------------------------------------------------------
/cmd/mv.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "log"
5 | "os"
6 |
7 | "github.com/erdos-one/r2/pkg"
8 |
9 | "github.com/spf13/cobra"
10 | )
11 |
12 | // mvCmd represents the mv command
13 | var mvCmd = &cobra.Command{
14 | Use: "mv",
15 | Short: "Moves a local file or R2 object to another location locally or in R2.",
16 | Long: ``,
17 | Run: func(cmd *cobra.Command, args []string) {
18 | // Get profile client
19 | profileName, err := cmd.Flags().GetString("profile")
20 | if err != nil {
21 | log.Fatal(err)
22 | }
23 | c := pkg.Client(getProfile(profileName))
24 |
25 | // If a bucket name is provided, create the bucket
26 | if len(args) == 2 {
27 | sourcePath := args[0]
28 | destinationPath := args[1]
29 | if !pkg.IsR2URI(sourcePath) && pkg.IsR2URI(destinationPath) {
30 | // Move local file to R2
31 | destURI := pkg.ParseR2URI(destinationPath)
32 | b := c.Bucket(destURI.Bucket)
33 | b.Upload(sourcePath, destURI.Path)
34 | os.Remove(sourcePath)
35 | } else if pkg.IsR2URI(sourcePath) && !pkg.IsR2URI(destinationPath) {
36 | // Move R2 object to local file
37 | sourceURI := pkg.ParseR2URI(sourcePath)
38 | b := c.Bucket(sourceURI.Bucket)
39 | b.Download(sourceURI.Path, destinationPath)
40 | b.Delete(sourceURI.Path)
41 | } else if pkg.IsR2URI(sourcePath) && pkg.IsR2URI(destinationPath) {
42 | // Move R2 object to R2 object
43 | sourceURI := pkg.ParseR2URI(sourcePath)
44 | destURI := pkg.ParseR2URI(destinationPath)
45 | b := c.Bucket(sourceURI.Bucket)
46 | b.Copy(sourceURI.Path, destURI)
47 | b.Delete(sourceURI.Path)
48 | }
49 | } else {
50 | log.Fatal("Please provide both a source and destination path.")
51 | }
52 | },
53 | }
54 |
55 | func init() {
56 | // Add the mv command to the root command
57 | rootCmd.AddCommand(mvCmd)
58 | }
59 |
--------------------------------------------------------------------------------
/cmd/presign.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 | "log"
6 |
7 | "github.com/erdos-one/r2/pkg"
8 |
9 | "github.com/spf13/cobra"
10 | )
11 |
12 | // presignCmd represents the presign command
13 | var presignCmd = &cobra.Command{
14 | Use: "presign",
15 | Short: "Generate a pre-signed URL for a Cloudflare R2 object",
16 | Long: ``,
17 | Run: func(cmd *cobra.Command, args []string) {
18 | // Get profile client
19 | profileName, err := cmd.Flags().GetString("profile")
20 | if err != nil {
21 | log.Fatal(err)
22 | }
23 | c := pkg.Client(getProfile(profileName))
24 | pc := pkg.PresignClient(getProfile(profileName))
25 |
26 | for _, arg := range args {
27 | // Get R2 URI components from argument
28 | uri := pkg.ParseR2URI(arg)
29 |
30 | // If object exists in bucket, print presigned URL to get object from bucket, otherwise print
31 | // presigned URL to put object in bucket
32 | b := c.Bucket(uri.Bucket)
33 | if pkg.Contains(b.GetObjectPaths(), uri.Path) {
34 | fmt.Println(pc.GetURL(uri))
35 | } else {
36 | fmt.Println(pc.PutURL(uri))
37 | }
38 | }
39 | },
40 | }
41 |
42 | func init() {
43 | // Add the presign command to the root command
44 | rootCmd.AddCommand(presignCmd)
45 | }
46 |
--------------------------------------------------------------------------------
/cmd/rb.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 | "log"
6 |
7 | "github.com/erdos-one/r2/pkg"
8 |
9 | "github.com/spf13/cobra"
10 | )
11 |
12 | // mbCmd represents the mb command
13 | var rbCmd = &cobra.Command{
14 | Use: "rb",
15 | Short: "Remove an R2 bucket",
16 | Long: ``,
17 | Run: func(cmd *cobra.Command, args []string) {
18 | // Get profile client
19 | profileName, err := cmd.Flags().GetString("profile")
20 | if err != nil {
21 | log.Fatal(err)
22 | }
23 | c := pkg.Client(getProfile(profileName))
24 |
25 | // If a bucket name is provided, create the bucket
26 | if len(args) > 0 {
27 | c.RemoveBucket(args[0])
28 | } else {
29 | fmt.Println("Please provide a bucket name")
30 | }
31 | },
32 | }
33 |
34 | func init() {
35 | // Add the rb command to the root command
36 | rootCmd.AddCommand(rbCmd)
37 | }
38 |
--------------------------------------------------------------------------------
/cmd/rm.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/erdos-one/r2/pkg"
7 |
8 | "github.com/spf13/cobra"
9 | )
10 |
11 | // rmCmd represents the rm command
12 | var rmCmd = &cobra.Command{
13 | Use: "rm",
14 | Short: "Remove an object from an R2 bucket",
15 | Long: ``,
16 | Run: func(cmd *cobra.Command, args []string) {
17 | // Get profile client
18 | profile, err := cmd.Flags().GetString("profile")
19 | if err != nil {
20 | log.Fatal(err)
21 | }
22 | c := pkg.Client(getProfile(profile))
23 |
24 | // If a bucket name is provided, create the bucket
25 | for _, arg := range args {
26 | if pkg.IsR2URI(arg) {
27 | uri := pkg.ParseR2URI(arg)
28 | b := c.Bucket(uri.Bucket)
29 | b.Delete(uri.Path)
30 | } else {
31 | log.Fatalf("Path %s is not a valid R2 URI", arg)
32 | }
33 | }
34 | },
35 | }
36 |
37 | func init() {
38 | // Add the rm command to the root command
39 | rootCmd.AddCommand(rmCmd)
40 | }
41 |
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/spf13/cobra"
7 | )
8 |
9 | // Store version information
10 | var version string = "unset"
11 |
12 | // rootCmd represents the base command when called without any commands
13 | var rootCmd = &cobra.Command{
14 | Use: "r2",
15 | Short: "Command Line Interface for Cloudflare R2 Storage",
16 | Long: `r2 is a command line interface for working with Cloudflare's R2 Storage.
17 |
18 | Cloudflare's R2 implements the S3 API, attempting to allow users and their
19 | applications to migrate easily, but importantly lacks the key, simple-to-use
20 | features provided by the AWS CLI's s3 subcommand, as opposed to the more complex
21 | and verbose API calls of the s3api subcommand. This CLI fills that gap.`,
22 | Run: func(cmd *cobra.Command, args []string) {
23 | // If the version flag is set, print version information and quit
24 | if v, _ := cmd.Flags().GetBool("version"); v {
25 | cmd.Println(version)
26 | return
27 | }
28 |
29 | // If no subcommand is provided, print help and quit
30 | cmd.Help()
31 | },
32 | }
33 |
34 | // Execute adds all child commands to the root command and sets flags appropriately.
35 | // This is called by main.main(). It only needs to happen once to the rootCmd.
36 | func Execute() {
37 | err := rootCmd.Execute()
38 | if err != nil {
39 | os.Exit(1)
40 | }
41 | }
42 |
43 | func init() {
44 | // Enable profile flag for all commands
45 | rootCmd.PersistentFlags().StringP("profile", "p", "default", "R2 profile to use")
46 |
47 | // Add version flag
48 | rootCmd.Flags().BoolP("version", "v", false, "Print version information and quit")
49 | }
50 |
--------------------------------------------------------------------------------
/cmd/sync.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/erdos-one/r2/pkg"
7 |
8 | "github.com/spf13/cobra"
9 | )
10 |
11 | // syncCmd represents the sync command
12 | var syncCmd = &cobra.Command{
13 | Use: "sync",
14 | Short: "Syncs directories and R2 prefixes.",
15 | Long: ``,
16 | Run: func(cmd *cobra.Command, args []string) {
17 | // Get profile client
18 | profileName, err := cmd.Flags().GetString("profile")
19 | if err != nil {
20 | log.Fatal(err)
21 | }
22 | c := pkg.Client(getProfile(profileName))
23 |
24 | // If a bucket name is provided, create the bucket
25 | if len(args) == 2 {
26 | sourcePath := args[0]
27 | destinationPath := args[1]
28 | if !pkg.IsR2URI(sourcePath) && pkg.IsR2URI(destinationPath) {
29 | // Sync local directory to R2 bucket
30 | b := c.Bucket(pkg.RemoveR2URIPrefix(destinationPath))
31 | b.SyncLocalToR2(sourcePath)
32 | } else if pkg.IsR2URI(sourcePath) && !pkg.IsR2URI(destinationPath) {
33 | // Sync R2 bucket to local directory
34 | b := c.Bucket(pkg.RemoveR2URIPrefix(sourcePath))
35 | b.SyncR2ToLocal(destinationPath)
36 | } else if pkg.IsR2URI(sourcePath) && pkg.IsR2URI(destinationPath) {
37 | // Sync R2 bucket to R2 bucket
38 | b := c.Bucket(pkg.RemoveR2URIPrefix(sourcePath))
39 | destBucket := c.Bucket(pkg.RemoveR2URIPrefix(destinationPath))
40 | b.SyncR2ToR2(destBucket)
41 | }
42 | } else {
43 | log.Fatal("Please provide both a source and destination path.")
44 | }
45 | },
46 | }
47 |
48 | func init() {
49 | // Add the sync command to the root command
50 | rootCmd.AddCommand(syncCmd)
51 | }
52 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/erdos-one/r2
2 |
3 | go 1.19
4 |
5 | require (
6 | github.com/aws/aws-sdk-go-v2/config v1.18.8
7 | github.com/aws/aws-sdk-go-v2/credentials v1.13.8
8 | github.com/aws/aws-sdk-go-v2/service/s3 v1.30.0
9 | github.com/spf13/cobra v1.6.1
10 | )
11 |
12 | require (
13 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
14 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21 // indirect
15 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27 // indirect
16 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21 // indirect
17 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28 // indirect
18 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.18 // indirect
19 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 // indirect
20 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.22 // indirect
21 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21 // indirect
22 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.21 // indirect
23 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.0 // indirect
24 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.0 // indirect
25 | github.com/aws/aws-sdk-go-v2/service/sts v1.18.0 // indirect
26 | github.com/aws/smithy-go v1.13.5 // indirect
27 | )
28 |
29 | require (
30 | github.com/aws/aws-sdk-go-v2 v1.17.3
31 | github.com/inconshreveable/mousetrap v1.0.1 // indirect
32 | github.com/spf13/pflag v1.0.5 // indirect
33 | )
34 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/aws/aws-sdk-go-v2 v1.17.3 h1:shN7NlnVzvDUgPQ+1rLMSxY8OWRNDRYtiqe0p/PgrhY=
2 | github.com/aws/aws-sdk-go-v2 v1.17.3/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw=
3 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs=
4 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10/go.mod h1:VeTZetY5KRJLuD/7fkQXMU6Mw7H5m/KP2J5Iy9osMno=
5 | github.com/aws/aws-sdk-go-v2/config v1.18.8 h1:lDpy0WM8AHsywOnVrOHaSMfpaiV2igOw8D7svkFkXVA=
6 | github.com/aws/aws-sdk-go-v2/config v1.18.8/go.mod h1:5XCmmyutmzzgkpk/6NYTjeWb6lgo9N170m1j6pQkIBs=
7 | github.com/aws/aws-sdk-go-v2/credentials v1.13.8 h1:vTrwTvv5qAwjWIGhZDSBH/oQHuIQjGmD232k01FUh6A=
8 | github.com/aws/aws-sdk-go-v2/credentials v1.13.8/go.mod h1:lVa4OHbvgjVot4gmh1uouF1ubgexSCN92P6CJQpT0t8=
9 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21 h1:j9wi1kQ8b+e0FBVHxCqCGo4kxDU175hoDHcWAi0sauU=
10 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.21/go.mod h1:ugwW57Z5Z48bpvUyZuaPy4Kv+vEfJWnIrky7RmkBvJg=
11 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27 h1:I3cakv2Uy1vNmmhRQmFptYDxOvBnwCdNwyw63N0RaRU=
12 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.27/go.mod h1:a1/UpzeyBBerajpnP5nGZa9mGzsBn5cOKxm6NWQsvoI=
13 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21 h1:5NbbMrIzmUn/TXFqAle6mgrH5m9cOvMLRGL7pnG8tRE=
14 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.21/go.mod h1:+Gxn8jYn5k9ebfHEqlhrMirFjSW0v0C9fI+KN5vk2kE=
15 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28 h1:KeTxcGdNnQudb46oOl4d90f2I33DF/c6q3RnZAmvQdQ=
16 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.28/go.mod h1:yRZVr/iT0AqyHeep00SZ4YfBAKojXz08w3XMBscdi0c=
17 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.18 h1:H/mF2LNWwX00lD6FlYfKpLLZgUW7oIzCBkig78x4Xok=
18 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.18/go.mod h1:T2Ku+STrYQ1zIkL1wMvj8P3wWQaaCMKNdz70MT2FLfE=
19 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11 h1:y2+VQzC6Zh2ojtV2LoC0MNwHWc6qXv/j2vrQtlftkdA=
20 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.11/go.mod h1:iV4q2hsqtNECrfmlXyord9u4zyuFEJX9eLgLpSPzWA8=
21 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.22 h1:kv5vRAl00tozRxSnI0IszPWGXsJOyA7hmEUHFYqsyvw=
22 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.22/go.mod h1:Od+GU5+Yx41gryN/ZGZzAJMZ9R1yn6lgA0fD5Lo5SkQ=
23 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21 h1:5C6XgTViSb0bunmU57b3CT+MhxULqHH2721FVA+/kDM=
24 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.21/go.mod h1:lRToEJsn+DRA9lW4O9L9+/3hjTkUzlzyzHqn8MTds5k=
25 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.21 h1:vY5siRXvW5TrOKm2qKEf9tliBfdLxdfy0i02LOcmqUo=
26 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.21/go.mod h1:WZvNXT1XuH8dnJM0HvOlvk+RNn7NbAPvA/ACO0QarSc=
27 | github.com/aws/aws-sdk-go-v2/service/s3 v1.30.0 h1:wddsyuESfviaiXk3w9N6/4iRwTg/a3gktjODY6jYQBo=
28 | github.com/aws/aws-sdk-go-v2/service/s3 v1.30.0/go.mod h1:L2l2/q76teehcW7YEsgsDjqdsDTERJeX3nOMIFlgGUE=
29 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.0 h1:/2gzjhQowRLarkkBOGPXSRnb8sQ2RVsjdG1C/UliK/c=
30 | github.com/aws/aws-sdk-go-v2/service/sso v1.12.0/go.mod h1:wo/B7uUm/7zw/dWhBJ4FXuw1sySU5lyIhVg1Bu2yL9A=
31 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.0 h1:Jfly6mRxk2ZOSlbCvZfKNS7TukSx1mIzhSsqZ/IGSZI=
32 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.0/go.mod h1:TZSH7xLO7+phDtViY/KUp9WGCJMQkLJ/VpgkTFd5gh8=
33 | github.com/aws/aws-sdk-go-v2/service/sts v1.18.0 h1:kOO++CYo50RcTFISESluhWEi5Prhg+gaSs4whWabiZU=
34 | github.com/aws/aws-sdk-go-v2/service/sts v1.18.0/go.mod h1:+lGbb3+1ugwKrNTWcf2RT05Xmp543B06zDFTwiTLp7I=
35 | github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8=
36 | github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
37 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
38 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
39 | github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
40 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
41 | github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
42 | github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
43 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
44 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
45 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
46 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
47 | github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
48 | github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
49 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
50 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
51 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
52 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
53 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
54 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
55 |
--------------------------------------------------------------------------------
/install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | OS=`uname -s`
3 | ARCH=`uname -m`
4 |
5 | curl -fsSLo $HOME/r2-${OS}-${ARCH}.tar.gz \
6 | https://github.com/erdos-one/r2/releases/download/v0.1.0-alpha/r2-${OS}-${ARCH}.tar.gz
7 |
8 | mkdir -p $HOME/r2-v0.1.0-alpha
9 | tar -xzf $HOME/r2-${OS}-${ARCH}.tar.gz -C $HOME/r2-v0.1.0-alpha
10 | chmod +x $HOME/r2-v0.1.0-alpha/r2
11 |
12 | if [ "$EUID" -eq 0 ]; then
13 | mv $HOME/r2-v0.1.0-alpha/r2 /usr/bin/r2
14 | rm -r $HOME/r2-v0.1.0-alpha
15 | else
16 | echo "Couldn't add r2 to /usr/bin because script is not running at admin"
17 | echo "Please run the following commands:"
18 | echo " mv $HOME/r2-v0.1.0-alpha/r2-${OS}-${ARCH} /usr/bin/r2"
19 | echo " rm -r $HOME/r2-v0.1.0-alpha"
20 | fi
21 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "github.com/erdos-one/r2/cmd"
4 |
5 | func main() {
6 | cmd.Execute()
7 | }
8 |
--------------------------------------------------------------------------------
/pkg/README.md:
--------------------------------------------------------------------------------
1 | # pkg
2 |
3 | This directory contains the source code for `r2`'s main functionality. Functions defined here are
4 | to be used by the [CLI](../cmd) and are publicly exported for API use.
5 |
6 | ## Architecture
7 |
8 | - [client.go](client.go) contains all R2 client-level operations (e.g. configuration, bucket
9 | creation, etc.)
10 | - [bucket.go](bucket.go) contains all bucket-level operations (e.g. listing objects, fetching
11 | objects, etc.)
12 | - [helpers.go](helpers.go) contains miscellaneous helper functions used throughout the CLI
13 |
--------------------------------------------------------------------------------
/pkg/bucket.go:
--------------------------------------------------------------------------------
1 | // Bucket-level operations
2 |
3 | package pkg
4 |
5 | import (
6 | "context"
7 | "fmt"
8 | "io"
9 | "log"
10 | "os"
11 | "path/filepath"
12 | "strings"
13 |
14 | "github.com/aws/aws-sdk-go-v2/aws"
15 | "github.com/aws/aws-sdk-go-v2/service/s3"
16 | "github.com/aws/aws-sdk-go-v2/service/s3/types"
17 | )
18 |
19 | // R2Bucket represents a Cloudflare R2 bucket, storing the bucket's name and R2 client used to
20 | // access the bucket.
21 | type R2Bucket struct {
22 | Client *R2Client
23 | Name string
24 | }
25 |
26 | // Bucket receives an R2 client and takes a bucket name as an argument, returning a configured
27 | // R2Bucket struct. This allows for simple bucket-level operations. For example, you can create
28 | // a bucket struct as so:
29 | //
30 | // client := Client(Config{...})
31 | // bucket := client.Bucket("my-bucket")
32 | //
33 | // // Then, you can perform bucket-level operations easily
34 | // bucket.Put("my-local-file.txt", "my-remote-file.txt")
35 | func (c *R2Client) Bucket(bucketName string) R2Bucket {
36 | return R2Bucket{
37 | Client: c,
38 | Name: bucketName,
39 | }
40 | }
41 |
42 | // GetObjects returns a list of all objects in a bucket. This method leverages S3's ListObjectsV2
43 | // API call. The returned list of objects is of type types.Object, which is a struct containing all
44 | // available information about the object, such as its name, size, and last modified date.
45 | func (b *R2Bucket) GetObjects() []types.Object {
46 | listObjectsOutput, err := b.Client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
47 | Bucket: &b.Name,
48 | })
49 | if err != nil {
50 | log.Fatal(err)
51 | }
52 | return listObjectsOutput.Contents
53 | }
54 |
55 | // GetObjectPaths returns a list of all object paths in a bucket, represented as strings. This
56 | // method is a wrapper around GetObjects, which returns a list of types.Object structs.
57 | func (b *R2Bucket) GetObjectPaths() []string {
58 | var objectPaths []string
59 | for _, object := range b.GetObjects() {
60 | objectPaths = append(objectPaths, *object.Key)
61 | }
62 | return objectPaths
63 | }
64 |
65 | // PrintObjects prints a list of all objects in a bucket. This method is a wrapper around GetObjects,
66 | // which returns a list of types.Object structs. The returned list of objects is formatted as a table
67 | // with the following columns: last modified date, file size, file name. The file size column is
68 | // formatted as a string with the file size and its unit (e.g. 1.2 MB).
69 | func (b *R2Bucket) PrintObjects() {
70 | // Get creation date, file size, and name of each object
71 | var objectData [][]string
72 | for _, object := range b.GetObjects() {
73 | // Get file size
74 | fs := fileSizeFmt(object.Size)
75 |
76 | // Append last modified, file size, and file name to objectData
77 | objectData = append(objectData, []string{
78 | object.LastModified.Format("2006-01-02 15:04:05"),
79 | fs[0],
80 | fs[1],
81 | *object.Key,
82 | })
83 | }
84 |
85 | // Get length of longest file size string
86 | var longestFileSizeString int
87 | var longestFileSizeUnitString int
88 | for _, object := range objectData {
89 | if len(object[1]) > longestFileSizeString {
90 | longestFileSizeString = len(object[1])
91 | }
92 | if len(object[2]) > longestFileSizeUnitString {
93 | longestFileSizeUnitString = len(object[2])
94 | }
95 | }
96 |
97 | // Print objects
98 | for _, object := range objectData {
99 | fmt.Println(
100 | object[0],
101 | strings.Repeat(" ", longestFileSizeString-len(object[1])),
102 | object[1],
103 | object[2],
104 | strings.Repeat(" ", longestFileSizeUnitString-len(object[2])),
105 | object[3],
106 | )
107 | }
108 | }
109 |
110 | // Put puts an object into a bucket. The inputted object is represented as an io.Reader, which can
111 | // be created from a file, a string, or any other type that implements the io.Reader interface. The
112 | // bucketPath argument takes the path for the object to be put in the bucket.
113 | func (b *R2Bucket) Put(file io.Reader, bucketPath string) error {
114 | _, err := b.Client.PutObject(context.TODO(), &s3.PutObjectInput{
115 | Bucket: aws.String(b.Name),
116 | Key: aws.String(bucketPath),
117 | Body: file,
118 | })
119 | return err
120 | }
121 |
122 | // Upload uploads a local file to a bucket. The localPath argument takes the path to the local file
123 | // to be uploaded. The bucketPath argument takes the path for the object to be put in the bucket.
124 | // This method is a wrapper around Put, which takes an io.Reader as an argument.
125 | func (b *R2Bucket) Upload(localPath, bucketPath string) {
126 | file, err := os.Open(localPath)
127 | if err != nil {
128 | log.Fatalf("Couldn't open file %s to upload: %v\n", localPath, err)
129 | }
130 |
131 | defer file.Close()
132 |
133 | err = b.Put(file, bucketPath)
134 | if err != nil {
135 | log.Fatalf("Couldn't upload file %s to r2://%s/%s: %v\n", localPath, b.Name, bucketPath, err)
136 | }
137 | }
138 |
139 | // Get gets an object from a bucket. The bucketPath argument takes the path to the object in the
140 | // bucket. This method returns an io.ReadCloser, which can be used to read the object's contents.
141 | // This method is a wrapper around the S3 GetObject API call.
142 | func (b *R2Bucket) Get(bucketPath string) io.ReadCloser {
143 | obj, err := b.Client.GetObject(context.TODO(), &s3.GetObjectInput{
144 | Bucket: aws.String(b.Name),
145 | Key: aws.String(bucketPath),
146 | })
147 | if err != nil {
148 | log.Fatalf("Couldn't get file r2://%s/%s: %v\n", b.Name, bucketPath, err)
149 | }
150 |
151 | return obj.Body
152 | }
153 |
154 | // Download downloads an object from a bucket to a local file. The bucketPath argument takes the
155 | // path to the object in the bucket. The localPath argument takes the path to the local file to
156 | // download to. This method is a wrapper around Get, which returns an io.ReadCloser.
157 | func (b *R2Bucket) Download(bucketPath, localPath string) {
158 | objBody := b.Get(bucketPath)
159 |
160 | file, err := os.Create(localPath)
161 | if err != nil {
162 | log.Fatalf("Couldn't create file %s to download to: %v\n", localPath, err)
163 | }
164 |
165 | defer file.Close()
166 | _, err = io.Copy(file, objBody)
167 | if err != nil {
168 | log.Fatalf("Couldn't download file r2://%s/%s to %s: %v\n", b.Name, bucketPath, localPath, err)
169 | }
170 | }
171 |
172 | // Copy copies an object from a bucket to another bucket. The bucketPath argument takes the path to
173 | // the object in the bucket. The copyToURI argument takes the URI of the bucket to copy the object
174 | // to. This method is a wrapper around the S3 CopyObject API call.
175 | func (b *R2Bucket) Copy(bucketPath string, copyToURI R2URI) {
176 | _, err := b.Client.CopyObject(context.TODO(), &s3.CopyObjectInput{
177 | Bucket: aws.String(copyToURI.Bucket),
178 | CopySource: aws.String(b.Name + "/" + bucketPath),
179 | Key: aws.String(copyToURI.Path),
180 | })
181 | if err != nil {
182 | log.Fatalf("Couldn't copy file r2://%s/%s to r2://%s/%s: %v\n", b.Name, bucketPath, copyToURI.Bucket, copyToURI.Path, err)
183 | }
184 | }
185 |
186 | // Delete deletes an object from a bucket. The bucketPath argument takes the path to the object in
187 | // the bucket. This method is a wrapper around the S3 DeleteObject API call.
188 | func (b *R2Bucket) Delete(bucketPath string) {
189 | _, err := b.Client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{
190 | Bucket: aws.String(b.Name),
191 | Key: aws.String(bucketPath),
192 | })
193 | if err != nil {
194 | log.Fatalf("Couldn't delete file r2://%s/%s: %v\n", b.Name, bucketPath, err)
195 | }
196 | }
197 |
198 | // SyncLocalToR2 syncs a local directory to an R2 bucket. The sourcePath argument takes the path to
199 | // the local directory to sync. This method iterates through the local directory and uploads any new
200 | // or changed files to the bucket.
201 | func (b *R2Bucket) SyncLocalToR2(sourcePath string) {
202 | // Check if source path exists and is a directory
203 | if !isDir(sourcePath) {
204 | log.Fatal("Source path must be a directory.")
205 | }
206 |
207 | // Get extant paths and their MD5 checksums in bucket
208 | bucketObjects := make(map[string]string)
209 | for _, object := range b.GetObjects() {
210 | bucketObjects[*object.Key] = strings.Trim(*object.ETag, `"`)
211 | }
212 |
213 | // Iterate through paths in source directory
214 | err := filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
215 | if err != nil {
216 | return err
217 | }
218 |
219 | // If path is a file, upload it
220 | if !info.IsDir() {
221 | bucketPath := strings.TrimPrefix(path, sourcePath+"/")
222 | objectMD5, objectInBucket := bucketObjects[bucketPath]
223 | if !objectInBucket || (md5sum(path) != objectMD5) {
224 | b.Upload(path, bucketPath)
225 | }
226 | }
227 |
228 | return nil
229 | })
230 |
231 | if err != nil {
232 | log.Fatal(err)
233 | }
234 | }
235 |
236 | // SyncR2ToLocal syncs an R2 bucket to a local directory. The destinationPath argument takes the
237 | // path to the local directory to sync. This method iterates through the bucket and downloads any
238 | // new or changed files to the local directory.
239 | func (b *R2Bucket) SyncR2ToLocal(destinationPath string) {
240 | // Check if destination path exists and is a directory
241 | if !isDir(destinationPath) {
242 | log.Fatal("Destination path must be a directory.")
243 | }
244 |
245 | // Iterate through objects and download necessary ones
246 | for _, object := range b.GetObjects() {
247 | path := *object.Key
248 | hash := strings.Trim(*object.ETag, `"`)
249 |
250 | // If file either doesn't exist locally or it's changed, download it
251 | if !fileExists(path) || (fileExists(path) && (md5sum(path) != hash)) {
252 | outPath := destinationPath + "/" + path
253 | ensureDirExists(outPath)
254 | b.Download(path, outPath)
255 | }
256 | }
257 | }
258 |
259 | // SyncR2ToR2 syncs an R2 bucket to another R2 bucket. The destBucket argument takes the bucket to
260 | // sync to. This method iterates through the bucket and copies any new or changed files to the
261 | // destination bucket.
262 | func (b *R2Bucket) SyncR2ToR2(destBucket R2Bucket) {
263 | // Get extant paths and their MD5 checksums in source bucket
264 | sourceBucketObjects := make(map[string]string)
265 | for _, object := range b.GetObjects() {
266 | sourceBucketObjects[*object.Key] = strings.Trim(*object.ETag, `"`)
267 | }
268 |
269 | // Get extant paths and their MD5 checksums in destination bucket
270 | destBucketObjects := make(map[string]string)
271 | for _, object := range destBucket.GetObjects() {
272 | destBucketObjects[*object.Key] = strings.Trim(*object.ETag, `"`)
273 | }
274 |
275 | // Iterate through paths in source bucket and copy necessary ones
276 | for sourcePath, sourceHash := range sourceBucketObjects {
277 | destHash, sourceObjectInDestBucket := destBucketObjects[sourcePath]
278 | if !sourceObjectInDestBucket || (sourceHash != destHash) {
279 | b.Copy(sourcePath, R2URI{Bucket: destBucket.Name, Path: sourcePath})
280 | }
281 | }
282 | }
283 |
284 | // GetURL returns a presigned URL for an object to get from a bucket. The uri argument takes the
285 | // URI of the object in the bucket. This method is a wrapper around the S3 PresignGetObject API
286 | // call.
287 | func (pc *R2PresignClient) GetURL(uri R2URI) string {
288 | presignResult, err := pc.PresignGetObject(context.TODO(), &s3.GetObjectInput{
289 | Bucket: aws.String(uri.Bucket),
290 | Key: aws.String(uri.Path),
291 | })
292 | if err != nil {
293 | log.Fatal("Couldn't get presigned URL for GetObject")
294 | }
295 | return presignResult.URL
296 | }
297 |
298 | // PutURL returns a presigned URL for an object to put in a bucket. The uri argument takes the URI
299 | // of the object in the bucket. This method is a wrapper around the S3 PresignPutObject API call.
300 | func (pc *R2PresignClient) PutURL(uri R2URI) string {
301 | presignResult, err := pc.PresignPutObject(context.TODO(), &s3.PutObjectInput{
302 | Bucket: aws.String(uri.Bucket),
303 | Key: aws.String(uri.Path),
304 | })
305 | if err != nil {
306 | log.Fatal("Couldn't get presigned URL for PutObject")
307 | }
308 | return presignResult.URL
309 | }
310 |
--------------------------------------------------------------------------------
/pkg/client.go:
--------------------------------------------------------------------------------
1 | // Client-level operations
2 |
3 | package pkg
4 |
5 | import (
6 | "context"
7 | "fmt"
8 | "log"
9 |
10 | "github.com/aws/aws-sdk-go-v2/aws"
11 | awsConfig "github.com/aws/aws-sdk-go-v2/config"
12 | "github.com/aws/aws-sdk-go-v2/credentials"
13 | "github.com/aws/aws-sdk-go-v2/service/s3"
14 | "github.com/aws/aws-sdk-go-v2/service/s3/types"
15 | )
16 |
17 | // Config holds the configuration for the R2 client. This is used to authenticate and connect to the
18 | // R2 API. The profile is the name of the profile in the ~/.r2 configuration file. The account ID is
19 | // the ID of the R2 account. The access key ID and secret access key are the credentials for the
20 | // account.
21 | type Config struct {
22 | Profile string
23 | AccountID string
24 | AccessKeyID string
25 | SecretAccessKey string
26 | }
27 |
28 | // R2Client is a wrapper around the S3 client that provides methods for interacting with R2. This
29 | // allows us to add methods to the client. The S3 client is embedded in the R2Client struct so that
30 | // we can use the existing methods of the S3 client without having to re-implement them.
31 | type R2Client struct {
32 | s3.Client
33 | }
34 |
35 | // s3Client returns a new S3 client for the given profile. The client is configured with the R2
36 | // endpoint and credentials for the given profile. This is used to create the R2Client and
37 | // R2PresignClient structs, which are used for all R2 operations.
38 | func s3Client(c Config) *s3.Client {
39 | // Get R2 account endpoint
40 | r2Resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
41 | return aws.Endpoint{
42 | URL: fmt.Sprintf("https://%s.r2.cloudflarestorage.com", c.AccountID),
43 | }, nil
44 | })
45 |
46 | // Set credentials
47 | cfg, err := awsConfig.LoadDefaultConfig(context.TODO(),
48 | awsConfig.WithEndpointResolverWithOptions(r2Resolver),
49 | awsConfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(c.AccessKeyID, c.SecretAccessKey, "")),
50 | )
51 | if err != nil {
52 | log.Fatal(err)
53 | }
54 |
55 | return s3.NewFromConfig(cfg)
56 | }
57 |
58 | // Client returns a new R2 client struct so we can add methods to it. The client is configured with
59 | // the R2 endpoint and credentials for the given profile.
60 | func Client(c Config) R2Client {
61 | return R2Client{*s3Client(c)}
62 | }
63 |
64 | // R2PresignClient is a wrapper around the S3 presign client that provides methods for interacting
65 | // with R2. This allows us to add methods to the client. The S3 presign client is embedded in the
66 | // R2PresignClient struct so that we can use the existing methods of the S3 presign client without
67 | // having to re-implement them.
68 | type R2PresignClient struct {
69 | s3.PresignClient
70 | }
71 |
72 | // PresignClient returns a new R2 presign client struct so we can add methods to it. The client is
73 | // configured with the R2 endpoint and credentials for the given profile. The presign client is
74 | // used for generating presigned URLs.
75 | func PresignClient(c Config) R2PresignClient {
76 | s3c := s3Client(c)
77 | return R2PresignClient{*s3.NewPresignClient(s3c)}
78 | }
79 |
80 | // PrintBuckets prints the creation date and name of each bucket in the R2 account.
81 | func (c *R2Client) PrintBuckets() {
82 | // Get buckets
83 | listBucketsOutput, err := c.ListBuckets(context.TODO(), &s3.ListBucketsInput{})
84 | if err != nil {
85 | log.Fatal(err)
86 | }
87 |
88 | // Print creation date and name of each bucket
89 | for _, object := range listBucketsOutput.Buckets {
90 | fmt.Println(object.CreationDate.Format("2006-01-02 15:04:05"), *object.Name)
91 | }
92 | }
93 |
94 | // MakeBucket creates a new R2 bucket with the given name. The bucket is created in the account
95 | // associated with the R2 client. The bucket name must be unique across all existing bucket names in
96 | // the account.
97 | func (c *R2Client) MakeBucket(name string) {
98 | _, err := c.CreateBucket(context.TODO(), &s3.CreateBucketInput{
99 | Bucket: aws.String(name),
100 | CreateBucketConfiguration: &types.CreateBucketConfiguration{},
101 | })
102 | if err != nil {
103 | log.Fatalf("Error creating bucket %s: %v\n", name, err)
104 | }
105 | }
106 |
107 | // RemoveBucket removes the bucket with the given name from the R2 account. The bucket must be empty
108 | // before it can be removed.
109 | func (c *R2Client) RemoveBucket(bucket string) {
110 | _, err := c.DeleteBucket(context.TODO(), &s3.DeleteBucketInput{
111 | Bucket: aws.String(bucket)})
112 | if err != nil {
113 | log.Fatalf("Couldn't create bucket %s: %v\n", bucket, err)
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/pkg/helpers.go:
--------------------------------------------------------------------------------
1 | package pkg
2 |
3 | import (
4 | "crypto/md5"
5 | "encoding/hex"
6 | "fmt"
7 | "io"
8 | "log"
9 | "os"
10 | "path/filepath"
11 | "regexp"
12 | "strings"
13 | )
14 |
15 | // Contains checks if a string is in a slice of strings.
16 | func Contains(slice []string, str string) bool {
17 | for _, v := range slice {
18 | if v == str {
19 | return true
20 | }
21 | }
22 | return false
23 | }
24 |
25 | // fileSizeFmt converts a file size in bytes to a human-readable format.
26 | func fileSizeFmt(b int64) []string {
27 | if b < 1024 {
28 | // If size is less than 1 KB, return size in bytes
29 | return []string{fmt.Sprintf("%d", b), "B"}
30 | } else if b < 1048576 {
31 | // If size is less than 1 MB, return size in KB
32 | return []string{fmt.Sprintf("%d", b/1024), "KB"}
33 | } else if b < 1073741824 {
34 | // If size is less than 1 GB, return size in MB
35 | return []string{fmt.Sprintf("%.2f", float64(b)/1048576), "MB"}
36 | } else {
37 | // If size is greater than or equal to 1 GB, return size in GB
38 | return []string{fmt.Sprintf("%.2f", float64(b)/1073741824), "GB"}
39 | }
40 | }
41 |
42 | // fileExists checks if a file exists.
43 | func fileExists(path string) bool {
44 | _, err := os.Stat(path)
45 | return err == nil
46 | }
47 |
48 | // isDir checks if a path exists and is a directory.
49 | func isDir(path string) bool {
50 | fileInfo, err := os.Stat(path)
51 | return err == nil && fileInfo.IsDir()
52 | }
53 |
54 | // ensureDirExists creates a directory if it does not exist.
55 | func ensureDirExists(path string) {
56 | dir := filepath.Dir(path)
57 | if _, err := os.Stat(dir); os.IsNotExist(err) {
58 | os.MkdirAll(dir, 0755)
59 | }
60 | }
61 |
62 | // RemoveR2URIPrefix removes the r2:// prefix from an R2 URI.
63 | func RemoveR2URIPrefix(uri string) string {
64 | return strings.TrimPrefix(uri, "r2://")
65 | }
66 |
67 | // R2URI represents an R2 URI. It contains the bucket name and the path to the file.
68 | type R2URI struct {
69 | Bucket string
70 | Path string
71 | }
72 |
73 | // IsR2URI checks if a string is an R2 URI. R2 URI's start with r2://
74 | func IsR2URI(uri string) bool {
75 | return strings.HasPrefix(uri, "r2://")
76 | }
77 |
78 | // ParseR2URI parses an R2 URI and returns a R2URI struct. It assumes that the URI is valid
79 | // and does not check if the bucket or file exists.
80 | func ParseR2URI(uri string) R2URI {
81 | return R2URI{
82 | Bucket: regexp.MustCompile(`r2://([\w-]+)/.+`).FindStringSubmatch(uri)[1],
83 | Path: regexp.MustCompile(`r2://[\w-]+/(.+)`).FindStringSubmatch(uri)[1],
84 | }
85 | }
86 |
87 | // md5sum returns the MD5 hash of a file given its path.
88 | func md5sum(path string) string {
89 | // Get file
90 | file, err := os.Open(path)
91 | if err != nil {
92 | log.Fatal(err)
93 | }
94 | defer file.Close()
95 |
96 | // Get file hash
97 | hash := md5.New()
98 | if _, err := io.Copy(hash, file); err != nil {
99 | log.Fatal(err)
100 | }
101 |
102 | hashBytes := hash.Sum(nil)[:16]
103 | return hex.EncodeToString(hashBytes)
104 | }
105 |
--------------------------------------------------------------------------------
/scripts/make-install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | REPO_USER=$(git config --get remote.origin.url | cut -d'/' -f4)
3 | REPO_NAME=$(basename `git rev-parse --show-toplevel`)
4 | TAG_NAME=$(git describe --tags --abbrev=0)
5 | BINARY_NAME="r2"
6 |
7 | cat > install.sh << EOF
8 | #!/bin/sh
9 | OS=\`uname -s\`
10 | ARCH=\`uname -m\`
11 |
12 | curl -fsSLo \$HOME/${BINARY_NAME}-\${OS}-\${ARCH}.tar.gz \\
13 | https://github.com/${REPO_USER}/${REPO_NAME}/releases/download/${TAG_NAME}/${BINARY_NAME}-\${OS}-\${ARCH}.tar.gz
14 |
15 | mkdir -p \$HOME/${BINARY_NAME}-${TAG_NAME}
16 | tar -xzf \$HOME/${BINARY_NAME}-\${OS}-\${ARCH}.tar.gz -C \$HOME/${BINARY_NAME}-${TAG_NAME}
17 | chmod +x \$HOME/${BINARY_NAME}-${TAG_NAME}/${BINARY_NAME}
18 |
19 | if [ "\$EUID" -eq 0 ]; then
20 | mv \$HOME/${BINARY_NAME}-${TAG_NAME}/${BINARY_NAME} /usr/bin/${BINARY_NAME}
21 | rm -r \$HOME/${BINARY_NAME}-${TAG_NAME}
22 | else
23 | echo "Couldn't add ${BINARY_NAME} to /usr/bin because script is not running at admin"
24 | echo "Please run the following commands:"
25 | echo " mv \$HOME/${BINARY_NAME}-${TAG_NAME}/${BINARY_NAME}-\${OS}-\${ARCH} /usr/bin/${BINARY_NAME}"
26 | echo " rm -r \$HOME/${BINARY_NAME}-${TAG_NAME}"
27 | fi
28 | EOF
--------------------------------------------------------------------------------
/uninstall.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | rm /usr/bin/r2
--------------------------------------------------------------------------------