├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── apitest ├── README.md └── apitest.go ├── apitypes.go ├── b2 ├── b2.go ├── createbucket.go ├── delete.go ├── deletebucket.go ├── get.go ├── list.go ├── listbuckets.go └── put.go ├── backblaze.go ├── backblaze_test.go ├── buckets.go ├── buckets_test.go ├── examples_test.go ├── files.go └── files_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | b2/b2 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | sudo: false 4 | 5 | os: 6 | - linux 7 | - osx 8 | 9 | go: 10 | - 1.9.x 11 | - 1.10.x 12 | - 1.11.x 13 | 14 | before_install: 15 | # our 'canonical import path' is gopkg.in-based, not github.com 16 | - export CANONICAL_IMPORT=${GOPATH}/src/gopkg.in/kothar/go-backblaze.v0 17 | - mkdir -p ${CANONICAL_IMPORT} 18 | - rsync -az ${TRAVIS_BUILD_DIR}/ ${CANONICAL_IMPORT}/ 19 | - cd ${CANONICAL_IMPORT} 20 | - rm -rf ${TRAVIS_BUILD_DIR} 21 | - export TRAVIS_BUILD_DIR=${CANONICAL_IMPORT} 22 | 23 | install: 24 | - go get ./... 25 | - go get -u github.com/golang/lint/golint 26 | - go get -u golang.org/x/tools/cmd/goimports 27 | 28 | script: 29 | - go vet ./... 30 | # this is to work around differences in gofmt between 1.9/1.10/1.11 31 | - "go version | egrep 'go1.9.|go1.10.' > /dev/null && sed -i -e 's/valid: true/valid: true/' backblaze.go || true" 32 | - diff <(goimports -l .) <(printf "") 33 | - diff <(golint ./...) <(printf "") 34 | - go test -v -cpu=2 ./... 35 | - go test -v -cpu=1,2,4 -short -race ./... 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 pH14 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-backblaze 2 | [![GoDoc](https://godoc.org/gopkg.in/kothar/go-backblaze.v0?status.svg)](https://godoc.org/gopkg.in/kothar/go-backblaze.v0) 3 | [![Build Status](https://travis-ci.org/kothar/go-backblaze.svg)](https://travis-ci.org/kothar/go-backblaze) 4 | 5 | A golang client for Backblaze's B2 storage 6 | 7 | ## Usage 8 | 9 | Some simple examples to get you started. Errors are ommitted for brevity 10 | 11 | Import the API package 12 | ~~~ 13 | import "gopkg.in/kothar/go-backblaze.v0" 14 | ~~~ 15 | 16 | Create an API client 17 | ~~~ 18 | b2, _ := backblaze.NewB2(backblaze.Credentials{ 19 | AccountID: accountID, 20 | ApplicationKey: applicationKey, 21 | }) 22 | ~~~ 23 | 24 | Create a bucket 25 | ~~~ 26 | bucket, _ := b2.CreateBucket("test_bucket", backblaze.AllPrivate) 27 | ~~~ 28 | 29 | Uploading a file 30 | ~~~ 31 | reader, _ := os.Open(path) 32 | name := filepath.Base(path) 33 | metadata := make(map[string]string) 34 | 35 | file, _ := bucket.UploadFile(name, metadata, reader) 36 | ~~~ 37 | 38 | All API methods except `B2.AuthorizeAccount` and `Bucket.UploadHashedFile` will 39 | retry once if authorization fails, which allows the operation to proceed if the current 40 | authorization token has expired. 41 | 42 | To disable this behaviour, set `B2.NoRetry` to `true` 43 | 44 | ## b2 command line client 45 | 46 | A test applicaiton has been implemented using this package, and can be found in the /b2 directory. 47 | It should provide you with more examples of how to use the API in your own applications. 48 | 49 | To install the b2 command, use: 50 | 51 | `go get -u gopkg.in/kothar/go-backblaze.v0/b2` 52 | 53 | ~~~ 54 | $ b2 --help 55 | Usage: 56 | b2 [OPTIONS] 57 | 58 | Application Options: 59 | --account= The account ID to use [$B2_ACCOUNT_ID] 60 | --appKey= The application key to use [$B2_APP_KEY] 61 | -b, --bucket= The bucket to access [$B2_BUCKET] 62 | -d, --debug Debug API requests 63 | -v, --verbose Display verbose output 64 | 65 | Help Options: 66 | -h, --help Show this help message 67 | 68 | Available commands: 69 | createbucket Create a new bucket 70 | delete Delete a file 71 | deletebucket Delete a bucket 72 | get Download a file 73 | list List files in a bucket 74 | listbuckets List buckets in an account 75 | put Store a file 76 | ~~~ 77 | 78 | ## Links 79 | 80 | * GoDoc: [https://godoc.org/gopkg.in/kothar/go-backblaze.v0](https://godoc.org/gopkg.in/kothar/go-backblaze.v0) 81 | * Originally based on pH14's work on the API: [https://github.com/pH14/go-backblaze](https://github.com/pH14/go-backblaze) 82 | -------------------------------------------------------------------------------- /apitest/README.md: -------------------------------------------------------------------------------- 1 | ## apitest 2 | 3 | `apitest` will create a bucket, upload some test files, download, delete, 4 | and otherwise verify that the API methods supported by go-backblaze all 5 | function as expected. 6 | 7 | Note that you use this program at your own risk. Don't use it with account 8 | credentials with access to sensitive files. 9 | -------------------------------------------------------------------------------- /apitest/apitest.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "log" 7 | "math/rand" 8 | "os" 9 | "reflect" 10 | "strconv" 11 | "sync" 12 | "time" 13 | 14 | "github.com/jessevdk/go-flags" 15 | 16 | "gopkg.in/kothar/go-backblaze.v0" 17 | ) 18 | 19 | // Options defines command line flags used by this application 20 | type Options struct { 21 | // Credentials 22 | AccountID string `long:"account" env:"B2_ACCOUNT_ID" description:"The account ID to use"` 23 | ApplicationKey string `long:"appKey" env:"B2_APP_KEY" description:"The application key to use"` 24 | 25 | // Bucket 26 | Bucket string `short:"b" long:"bucket" description:"The bucket name to use for testing (a random bucket name will be chosen if not specified)"` 27 | Debug bool `short:"d" long:"debug" description:"Show debug information during test"` 28 | } 29 | 30 | var opts = &Options{} 31 | 32 | var parser = flags.NewParser(opts, flags.Default) 33 | 34 | func init() { 35 | rand.Seed(time.Now().UnixNano()) 36 | } 37 | 38 | func main() { 39 | 40 | _, err := parser.Parse() 41 | if err != nil { 42 | os.Exit(1) 43 | } 44 | 45 | // Create client 46 | b2, err := backblaze.NewB2(backblaze.Credentials{ 47 | AccountID: opts.AccountID, 48 | ApplicationKey: opts.ApplicationKey, 49 | }) 50 | b2.Debug = opts.Debug 51 | check(err) 52 | 53 | b := testBucketCreate(b2) 54 | 55 | // Test basic file operations 56 | f, data := testFileUpload(b) 57 | testFileDownload(b, f, data) 58 | testFileRangeDownload(b, f, data) 59 | testFileDelete(b, f) 60 | 61 | // Test file listing calls 62 | files := uploadFiles(b) 63 | testListFiles(b, files) 64 | testListDirectories(b, files) 65 | deleteFiles(b, files) 66 | 67 | testBucketDelete(b) 68 | } 69 | 70 | func testBucketCreate(b2 *backblaze.B2) *backblaze.Bucket { 71 | // Get Test bucket 72 | if opts.Bucket == "" { 73 | opts.Bucket = "test-bucket-" + randSeq(10) 74 | } 75 | log.Printf("Testing with bucket %s", opts.Bucket) 76 | 77 | b, err := b2.Bucket(opts.Bucket) 78 | check(err) 79 | if b != nil { 80 | log.Fatal("Testing bucket already exists") 81 | } 82 | 83 | b, err = b2.CreateBucket(opts.Bucket, backblaze.AllPrivate) 84 | check(err) 85 | log.Print("Bucket created") 86 | 87 | return b 88 | } 89 | 90 | func testBucketDelete(b *backblaze.Bucket) { 91 | check(b.Delete()) 92 | log.Print("Bucket deleted") 93 | } 94 | 95 | func testFileUpload(b *backblaze.Bucket) (*backblaze.File, []byte) { 96 | fileData := randBytes(1024 * 1024) 97 | 98 | f, err := b.UploadFile("test_file", nil, bytes.NewBuffer(fileData)) 99 | check(err) 100 | 101 | log.Print("File uploaded") 102 | 103 | return f, fileData 104 | } 105 | 106 | func testFileDownload(b *backblaze.Bucket, f *backblaze.File, data []byte) { 107 | f, reader, err := b.DownloadFileByName(f.Name) 108 | check(err) 109 | 110 | body, err := ioutil.ReadAll(reader) 111 | check(err) 112 | 113 | if !bytes.Equal(body, data) { 114 | log.Fatal("Downloaded file content does not match upload") 115 | } 116 | 117 | log.Print("File downloaded") 118 | } 119 | 120 | func testFileRangeDownload(b *backblaze.Bucket, f *backblaze.File, data []byte) { 121 | f, reader, err := b.DownloadFileRangeByName(f.Name, &backblaze.FileRange{Start: 100, End: 2000}) 122 | check(err) 123 | 124 | body, err := ioutil.ReadAll(reader) 125 | check(err) 126 | 127 | if !bytes.Equal(body, data[100:2000+1]) { 128 | log.Fatal("Downloaded file range does not match upload") 129 | } 130 | 131 | log.Print("File range downloaded") 132 | } 133 | 134 | func testFileDelete(b *backblaze.Bucket, f *backblaze.File) { 135 | _, err := b.DeleteFileVersion(f.Name, f.ID) 136 | check(err) 137 | 138 | log.Print("File deleted") 139 | } 140 | 141 | func uploadFiles(b *backblaze.Bucket) []*backblaze.File { 142 | fileData := randBytes(1024) 143 | 144 | files := []*backblaze.File{} 145 | 146 | queue := make(chan int64) 147 | var m sync.Mutex 148 | var wg sync.WaitGroup 149 | 150 | for i := 0; i < 10; i++ { 151 | wg.Add(1) 152 | go func() { 153 | for n := range queue { 154 | f, err := b.UploadFile("test/file_"+strconv.FormatInt(n, 10), nil, bytes.NewBuffer(fileData)) 155 | check(err) 156 | 157 | m.Lock() 158 | files = append(files, f) 159 | m.Unlock() 160 | } 161 | 162 | wg.Done() 163 | }() 164 | } 165 | 166 | // Upload files 167 | count := 40 168 | for i := 1; i <= count; i++ { 169 | log.Printf("Uploading file %d/%d...", i, count) 170 | queue <- int64(i) 171 | } 172 | 173 | close(queue) 174 | wg.Wait() 175 | log.Println("Done.") 176 | 177 | return files 178 | } 179 | 180 | func testListFiles(b *backblaze.Bucket, files []*backblaze.File) { 181 | 182 | // List bucket content 183 | log.Println("Listing bucket contents") 184 | bulkResponse, err := b.ListFileNames("", 500) 185 | check(err) 186 | if len(bulkResponse.Files) != len(files) { 187 | log.Fatalf("Expected listing to return %d files but found %d", len(files), len(bulkResponse.Files)) 188 | } 189 | 190 | // Test paging 191 | log.Println("Paging bucket contents") 192 | pagedFiles := []backblaze.FileStatus{} 193 | cursor := "" 194 | for { 195 | r, err := b.ListFileNames(cursor, 10) 196 | check(err) 197 | 198 | pagedFiles = append(pagedFiles, r.Files...) 199 | 200 | if r.NextFileName == "" { 201 | break 202 | } 203 | 204 | cursor = r.NextFileName 205 | } 206 | 207 | if !reflect.DeepEqual(bulkResponse.Files, pagedFiles) { 208 | log.Fatalf("Result of paged directory listing does not match bulk listing") 209 | } 210 | } 211 | 212 | func testListDirectories(b *backblaze.Bucket, files []*backblaze.File) { 213 | // List root directory 214 | log.Println("Listing root directory contents") 215 | bulkResponse, err := b.ListFileNamesWithPrefix("", 500, "", "/") 216 | check(err) 217 | if len(bulkResponse.Files) != 1 { 218 | log.Fatalf("Expected listing to return 1 directory but found %d", len(bulkResponse.Files)) 219 | } 220 | 221 | // List subdirectory 222 | log.Println("Listing subdirectory contents") 223 | bulkResponse, err = b.ListFileNamesWithPrefix("", 500, "test/", "/") 224 | check(err) 225 | if len(bulkResponse.Files) != len(files) { 226 | log.Fatalf("Expected listing to return %d files but found %d", len(files), len(bulkResponse.Files)) 227 | } 228 | } 229 | 230 | func deleteFiles(b *backblaze.Bucket, files []*backblaze.File) { 231 | // Delete files 232 | log.Printf("Deleting %d files...", len(files)) 233 | for _, f := range files { 234 | _, err := b.DeleteFileVersion(f.Name, f.ID) 235 | check(err) 236 | } 237 | log.Println("Done.") 238 | } 239 | 240 | func check(err error) { 241 | if err == nil { 242 | return 243 | } 244 | 245 | log.Fatal(err) 246 | } 247 | 248 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 249 | 250 | // see http://stackoverflow.com/a/22892986/37416 251 | func randSeq(n int) string { 252 | b := make([]rune, n) 253 | for i := range b { 254 | b[i] = letters[rand.Intn(len(letters))] 255 | } 256 | return string(b) 257 | } 258 | 259 | func randBytes(n int) []byte { 260 | b := make([]byte, n) 261 | for i := range b { 262 | b[i] = byte(rand.Int()) 263 | } 264 | return b 265 | } 266 | -------------------------------------------------------------------------------- /apitypes.go: -------------------------------------------------------------------------------- 1 | //go:generate ffjson $GOFILE 2 | 3 | package backblaze 4 | 5 | // B2Error encapsulates an error message returned by the B2 API. 6 | // 7 | // Failures to connect to the B2 servers, and networking problems in general can cause errors 8 | type B2Error struct { 9 | Code string `json:"code"` 10 | Message string `json:"message"` 11 | Status int `json:"status"` 12 | } 13 | 14 | func (e B2Error) Error() string { 15 | return e.Code + ": " + e.Message 16 | } 17 | 18 | // IsFatal returns true if this error represents 19 | // an error which can't be recovered from by retrying 20 | func (e *B2Error) IsFatal() bool { 21 | switch { 22 | case e.Status == 401: // Unauthorized 23 | switch e.Code { 24 | case "expired_auth_token": 25 | return false 26 | case "missing_auth_token", "bad_auth_token": 27 | return true 28 | default: 29 | return true 30 | } 31 | case e.Status == 408: // Timeout 32 | return false 33 | case e.Status >= 500 && e.Status < 600: // Server error 34 | return false 35 | default: 36 | return true 37 | } 38 | } 39 | 40 | type authorizeAccountResponse struct { 41 | AccountID string `json:"accountId"` 42 | APIEndpoint string `json:"apiUrl"` 43 | AuthorizationToken string `json:"authorizationToken"` 44 | DownloadURL string `json:"downloadUrl"` 45 | } 46 | 47 | type accountRequest struct { 48 | ID string `json:"accountId"` 49 | } 50 | 51 | // BucketType defines the security setting for a bucket 52 | type BucketType string 53 | 54 | // Buckets can be either public, private, or snapshot 55 | const ( 56 | AllPublic BucketType = "allPublic" 57 | AllPrivate BucketType = "allPrivate" 58 | Snapshot BucketType = "snapshot" 59 | ) 60 | 61 | // LifecycleRule instructs the B2 service to automatically hide and/or delete old files. 62 | // You can set up rules to do things like delete old versions of files 30 days after a newer version was uploaded. 63 | type LifecycleRule struct { 64 | DaysFromUploadingToHiding int `json:"daysFromUploadingToHiding"` 65 | DaysFromHidingToDeleting int `json:"daysFromHidingToDeleting"` 66 | FileNamePrefix string `json:"fileNamePrefix"` 67 | } 68 | 69 | // BucketInfo describes a bucket 70 | type BucketInfo struct { 71 | // The account that the bucket is in. 72 | AccountID string `json:"accountId"` 73 | 74 | // The unique ID of the bucket. 75 | ID string `json:"bucketId"` 76 | 77 | // User-defined information to be stored with the bucket. 78 | Info map[string]string `json:"bucketInfo"` 79 | 80 | // The name to give the new bucket. 81 | // Bucket names must be a minimum of 6 and a maximum of 50 characters long, and must be globally unique; 82 | // two different B2 accounts cannot have buckets with the name name. Bucket names can consist of: letters, 83 | // digits, and "-". Bucket names cannot start with "b2-"; these are reserved for internal Backblaze use. 84 | Name string `json:"bucketName"` 85 | 86 | // Either "allPublic", meaning that files in this bucket can be downloaded by anybody, or "allPrivate", 87 | // meaning that you need a bucket authorization token to download the files. 88 | BucketType BucketType `json:"bucketType"` 89 | 90 | // The initial list of lifecycle rules for this bucket. 91 | LifecycleRules []LifecycleRule `json:"lifecycleRules"` 92 | 93 | // A counter that is updated every time the bucket is modified. 94 | Revision int `json:"revision"` 95 | } 96 | 97 | type bucketRequest struct { 98 | ID string `json:"bucketId"` 99 | } 100 | 101 | type createBucketRequest struct { 102 | AccountID string `json:"accountId"` 103 | BucketName string `json:"bucketName"` 104 | BucketType BucketType `json:"bucketType"` 105 | BucketInfo map[string]string `json:"bucketInfo,omitempty"` 106 | LifecycleRules []LifecycleRule `json:"lifecycleRules,omitempty"` 107 | } 108 | 109 | type deleteBucketRequest struct { 110 | AccountID string `json:"accountId"` 111 | BucketID string `json:"bucketId"` 112 | } 113 | 114 | // updateBucketRequest describes the request parameters that may be provided to the b2_update_bucket API endpoint 115 | type updateBucketRequest struct { 116 | AccountID string `json:"accountId"` // The account that the bucket is in 117 | BucketID string `json:"bucketId"` // The unique ID of the bucket 118 | BucketType BucketType `json:"bucketType,omitempty"` // If not specified, setting will remain unchanged 119 | BucketInfo map[string]string `json:"bucketInfo,omitempty"` // If not specified, setting will remain unchanged 120 | LifecycleRules []LifecycleRule `json:"lifecycleRules,omitempty"` // If not specified, setting will remain unchanged 121 | IfRevisionIs int `json:"ifRevisionIs,omitempty"` // When set, the update will only happen if the revision number stored in the B2 service matches the one passed in 122 | } 123 | 124 | type getUploadURLResponse struct { 125 | BucketID string `json:"bucketId"` 126 | UploadURL string `json:"uploadUrl"` 127 | AuthorizationToken string `json:"authorizationToken"` 128 | } 129 | 130 | type listBucketsResponse struct { 131 | Buckets []*BucketInfo `json:"buckets"` 132 | } 133 | 134 | type fileRequest struct { 135 | ID string `json:"fileId"` 136 | } 137 | 138 | type fileVersionRequest struct { 139 | Name string `json:"fileName"` 140 | ID string `json:"fileId"` 141 | } 142 | 143 | // File descibes a file stored in a B2 bucket 144 | type File struct { 145 | ID string `json:"fileId"` 146 | Name string `json:"fileName"` 147 | AccountID string `json:"accountId"` 148 | BucketID string `json:"bucketId"` 149 | ContentLength int64 `json:"contentLength"` 150 | ContentSha1 string `json:"contentSha1"` 151 | ContentType string `json:"contentType"` 152 | FileInfo map[string]string `json:"fileInfo"` 153 | Action FileAction `json:"action"` 154 | Size int `json:"size"` // Deprecated - same as ContentSha1 155 | UploadTimestamp int64 `json:"uploadTimestamp"` 156 | } 157 | 158 | // FileRange describes a range of bytes in a file by its 0-based start and end position (inclusive) 159 | type FileRange struct { 160 | Start int64 161 | End int64 162 | } 163 | 164 | type listFilesRequest struct { 165 | BucketID string `json:"bucketId"` 166 | StartFileName string `json:"startFileName"` 167 | MaxFileCount int `json:"maxFileCount"` 168 | Prefix string `json:"prefix,omitempty"` 169 | Delimiter string `json:"delimiter,omitempty"` 170 | } 171 | 172 | // ListFilesResponse lists a page of files stored in a B2 bucket 173 | type ListFilesResponse struct { 174 | Files []FileStatus `json:"files"` 175 | NextFileName string `json:"nextFileName"` 176 | } 177 | 178 | type listFileVersionsRequest struct { 179 | BucketID string `json:"bucketId"` 180 | StartFileName string `json:"startFileName,omitempty"` 181 | StartFileID string `json:"startFileId,omitempty"` 182 | MaxFileCount int `json:"maxFileCount,omitempty"` 183 | } 184 | 185 | // ListFileVersionsResponse lists a page of file versions stored in a B2 bucket 186 | type ListFileVersionsResponse struct { 187 | Files []FileStatus `json:"files"` 188 | NextFileName string `json:"nextFileName"` 189 | NextFileID string `json:"nextFileId"` 190 | } 191 | 192 | type fileCopyRequest struct { 193 | ID string `json:"sourceFileId"` 194 | Name string `json:"fileName"` 195 | MetadataDirective FileMetadataDirective `json:"metadataDirective"` 196 | DestinationBucketID string `json:"destinationBucketId"` 197 | } 198 | 199 | type hideFileRequest struct { 200 | BucketID string `json:"bucketId"` 201 | FileName string `json:"fileName"` 202 | } 203 | 204 | type FileMetadataDirective string 205 | 206 | const ( 207 | FileMetaDirectiveCopy FileMetadataDirective = "COPY" 208 | FileMetaDirectiveReplace FileMetadataDirective = "REPLACE" 209 | ) 210 | 211 | // FileAction indicates the current status of a file in a B2 bucket 212 | type FileAction string 213 | 214 | // Files can be either uploads (visible) or hidden. 215 | // 216 | // Hiding a file makes it look like the file has been deleted, without 217 | // removing any of the history. It adds a new version of the file that is a 218 | // marker saying the file is no longer there. 219 | const ( 220 | Upload FileAction = "upload" 221 | Hide FileAction = "hide" 222 | ) 223 | 224 | // FileStatus is now identical to File in repsonses from ListFileNames and ListFileVersions 225 | type FileStatus struct { 226 | File 227 | } 228 | -------------------------------------------------------------------------------- /b2/b2.go: -------------------------------------------------------------------------------- 1 | // A minimal client for accessing B2 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "os" 7 | 8 | "github.com/jessevdk/go-flags" 9 | 10 | "gopkg.in/kothar/go-backblaze.v0" 11 | ) 12 | 13 | // Options defines command line flags used by this application 14 | type Options struct { 15 | // Credentials 16 | AccountID string `long:"account" env:"B2_ACCOUNT_ID" description:"The Master Application Key ID"` 17 | ApplicationID string `long:"application" env:"B2_APPLICATION_ID" description:"The Application Key ID (if specified, will be used instead of Master Application Key)"` 18 | ApplicationKey string `long:"appKey" env:"B2_APP_KEY" description:"The application key to use"` 19 | 20 | // Bucket 21 | Bucket string `short:"b" long:"bucket" env:"B2_BUCKET" description:"The bucket to access"` 22 | 23 | Debug bool `short:"d" long:"debug" description:"Debug API requests"` 24 | Verbose bool `short:"v" long:"verbose" description:"Display verbose output"` 25 | } 26 | 27 | var opts = &Options{} 28 | 29 | var parser = flags.NewParser(opts, flags.Default) 30 | 31 | func main() { 32 | flag.Parse() 33 | _, err := parser.Parse() 34 | if err != nil { 35 | os.Exit(1) 36 | } 37 | } 38 | 39 | // Client obtains an instance of the B2 client 40 | func Client() (*backblaze.B2, error) { 41 | c, err := backblaze.NewB2(backblaze.Credentials{ 42 | AccountID: opts.AccountID, 43 | KeyID: opts.ApplicationID, 44 | ApplicationKey: opts.ApplicationKey, 45 | }) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | c.Debug = opts.Debug 51 | return c, nil 52 | } 53 | -------------------------------------------------------------------------------- /b2/createbucket.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "gopkg.in/kothar/go-backblaze.v0" 7 | ) 8 | 9 | // CreateBucket is a command 10 | type CreateBucket struct { 11 | Public bool `short:"p" long:"public" description:"Make bucket contents public"` 12 | } 13 | 14 | func init() { 15 | parser.AddCommand("createbucket", "Create a new bucket", "", &CreateBucket{}) 16 | } 17 | 18 | // Execute the createbucket command 19 | func (o *CreateBucket) Execute(args []string) error { 20 | client, err := Client() 21 | if err != nil { 22 | return err 23 | } 24 | 25 | bucketType := backblaze.AllPrivate 26 | if o.Public { 27 | bucketType = backblaze.AllPublic 28 | } 29 | 30 | bucket, err := client.CreateBucket(opts.Bucket, bucketType) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | fmt.Println("Created bucket:", bucket.Name) 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /b2/delete.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // Delete is a command 11 | type Delete struct { 12 | Hide bool `long:"hide" description:"Hide the file, leaving previous versions in place"` 13 | All bool `short:"a" long:"all" description:"Remove all versions of a file"` 14 | } 15 | 16 | func init() { 17 | parser.AddCommand("delete", "Delete a file", 18 | "Specify just a filename to hide the file from listings. Specifiy a version id as fileName:versionId to permanently delete a file version.", 19 | &Delete{}) 20 | } 21 | 22 | // Execute the delete command 23 | func (o *Delete) Execute(args []string) error { 24 | client, err := Client() 25 | if err != nil { 26 | return err 27 | } 28 | 29 | bucket, err := client.Bucket(opts.Bucket) 30 | if err != nil { 31 | return err 32 | } 33 | if bucket == nil { 34 | return errors.New("Bucket not found: " + opts.Bucket) 35 | } 36 | 37 | for _, file := range args { 38 | // TODO handle wildcards 39 | 40 | if opts.Verbose { 41 | fmt.Println(file) 42 | } 43 | 44 | parts := strings.SplitN(file, ":", 2) 45 | if len(parts) > 1 { 46 | bucket.DeleteFileVersion(parts[0], parts[1]) 47 | } else { 48 | if o.Hide { 49 | bucket.HideFile(file) 50 | } else { 51 | // Get most recent versions 52 | count := 1 53 | if o.All { 54 | count = 1000 55 | } 56 | 57 | versions, err := bucket.ListFileVersions(file, "", count) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | count = 0 63 | for _, f := range versions.Files { 64 | if f.Name != file { 65 | break 66 | } 67 | 68 | if opts.Verbose && o.All { 69 | fmt.Printf(" %s %v\n", f.ID, time.Unix(f.UploadTimestamp/1000, f.UploadTimestamp%1000)) 70 | } 71 | 72 | if _, err := bucket.DeleteFileVersion(file, f.ID); err != nil { 73 | return err 74 | } 75 | count++ 76 | } 77 | if count == 0 { 78 | return errors.New("File not found: " + file) 79 | } 80 | } 81 | } 82 | } 83 | 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /b2/deletebucket.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // DeleteBucket is a command 8 | type DeleteBucket struct { 9 | } 10 | 11 | func init() { 12 | parser.AddCommand("deletebucket", "Delete a bucket", "", &DeleteBucket{}) 13 | } 14 | 15 | // Execute the deletebucket command 16 | func (o *DeleteBucket) Execute(args []string) error { 17 | if opts.Bucket == "" { 18 | return fmt.Errorf("No bucket specified") 19 | } 20 | 21 | client, err := Client() 22 | if err != nil { 23 | return err 24 | } 25 | 26 | bucket, err := client.Bucket(opts.Bucket) 27 | if err != nil { 28 | return err 29 | } 30 | if bucket == nil { 31 | return fmt.Errorf("Bucket not found: %s", opts.Bucket) 32 | } 33 | 34 | if err = bucket.Delete(); err != nil { 35 | return err 36 | } 37 | 38 | fmt.Println("Deleted bucket:", bucket.Name) 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /b2/get.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/hex" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "os" 11 | "path/filepath" 12 | "sync" 13 | "time" 14 | 15 | "github.com/dustin/go-humanize" 16 | "github.com/gosuri/uiprogress" 17 | "github.com/gosuri/uiprogress/util/strutil" 18 | 19 | "gopkg.in/kothar/go-backblaze.v0" 20 | ) 21 | 22 | // TODO support subdirectories 23 | // TODO support version id downloads 24 | 25 | // Get is a command 26 | type Get struct { 27 | Threads int `short:"j" long:"threads" default:"5" description:"Maximum simultaneous downloads to process"` 28 | Output string `short:"o" long:"output" default:"." description:"Output file name or directory"` 29 | Discard bool `long:"discard" description:"Discard downloaded data"` 30 | NoReadahead bool `long:"noreadahead" description:"Disable parallel readahead for large files"` 31 | } 32 | 33 | func init() { 34 | parser.AddCommand("get", "Download a file", 35 | "Downloads one or more files to the current directory. Specify the bucket with -b, and the filenames to download as extra arguments.", 36 | &Get{}) 37 | } 38 | 39 | // Execute the get command 40 | func (o *Get) Execute(args []string) error { 41 | client, err := Client() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | bucket, err := client.Bucket(opts.Bucket) 47 | if err != nil { 48 | return err 49 | } 50 | if bucket == nil { 51 | return errors.New("Bucket not found: " + opts.Bucket) 52 | } 53 | 54 | uiprogress.Start() 55 | tasks := make(chan string, o.Threads) 56 | group := sync.WaitGroup{} 57 | 58 | outDir := "." 59 | outName := "" 60 | 61 | info, err := os.Stat(o.Output) 62 | if err == nil { 63 | if info.IsDir() { 64 | outDir = o.Output 65 | } else if len(args) > 1 { 66 | return errors.New("Single (existing) output file specified for multiple targets: " + o.Output) 67 | } else { 68 | outName = o.Output 69 | } 70 | } else if os.IsNotExist(err) { 71 | parent := filepath.Dir(o.Output) 72 | info, err := os.Stat(parent) 73 | if os.IsNotExist(err) || !info.IsDir() { 74 | return errors.New("Directory does not exist: " + parent) 75 | } 76 | if len(args) > 1 { 77 | outDir = o.Output 78 | } else { 79 | outName = o.Output 80 | } 81 | } else { 82 | return err 83 | } 84 | 85 | // Create workers 86 | for i := 0; i < o.Threads; i++ { 87 | group.Add(1) 88 | go func() { 89 | for file := range tasks { 90 | 91 | var ( 92 | fileInfo *backblaze.File 93 | reader io.ReadCloser 94 | err error 95 | ) 96 | 97 | if o.NoReadahead { 98 | fileInfo, reader, err = bucket.DownloadFileByName(file) 99 | } else { 100 | fileInfo, reader, err = bucket.ReadaheadFileByName(file) 101 | } 102 | if err != nil { 103 | fmt.Println(err) 104 | // TODO terminate on errors 105 | } 106 | 107 | name := file 108 | if outName != "" { 109 | name = outName 110 | } 111 | path := filepath.Join(outDir, name) 112 | err = download(fileInfo, reader, path, o) 113 | if err != nil { 114 | fmt.Println(err) 115 | // TODO remove file if partially downloaded? 116 | } 117 | 118 | // TODO handle termination on error 119 | } 120 | group.Done() 121 | }() 122 | 123 | } 124 | 125 | for _, file := range args { 126 | // TODO handle wildcards 127 | 128 | tasks <- file 129 | } 130 | close(tasks) 131 | 132 | group.Wait() 133 | 134 | return nil 135 | } 136 | 137 | type progressWriter struct { 138 | bar *uiprogress.Bar 139 | w io.Writer 140 | } 141 | 142 | func (p *progressWriter) Write(b []byte) (int, error) { 143 | written, err := p.w.Write(b) 144 | p.bar.Set(p.bar.Current() + written) 145 | return written, err 146 | } 147 | 148 | func download(fileInfo *backblaze.File, reader io.ReadCloser, path string, o *Get) error { 149 | defer reader.Close() 150 | 151 | var writer = ioutil.Discard 152 | if !o.Discard { 153 | err := os.MkdirAll(filepath.Dir(path), 0777) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | file, err := os.Create(path) 159 | if err != nil { 160 | return err 161 | } 162 | defer file.Close() 163 | writer = file 164 | } 165 | 166 | if opts.Verbose { 167 | bar := uiprogress.AddBar(int(fileInfo.ContentLength)) 168 | 169 | if fileInfo.ContentLength > 1024*100 { 170 | start := time.Now() 171 | elapsed := time.Duration(1) 172 | count := 0 173 | bar.AppendFunc(func(b *uiprogress.Bar) string { 174 | count++ 175 | if count < 2 { 176 | return "" 177 | } 178 | 179 | // elapsed := b.TimeElapsed() 180 | if b.Current() < b.Total { 181 | elapsed = time.Now().Sub(start) 182 | } 183 | speed := uint64(float64(b.Current()) / elapsed.Seconds()) 184 | return humanize.IBytes(speed) + "/sec" 185 | }) 186 | } 187 | bar.AppendCompleted() 188 | bar.PrependFunc(func(b *uiprogress.Bar) string { return fmt.Sprintf("%10s", humanize.IBytes(uint64(b.Total))) }) 189 | bar.PrependFunc(func(b *uiprogress.Bar) string { return strutil.Resize(fileInfo.Name, 50) }) 190 | bar.Width = 20 191 | 192 | writer = &progressWriter{bar, writer} 193 | } 194 | 195 | sha := sha1.New() 196 | tee := io.MultiWriter(sha, writer) 197 | 198 | _, err := io.Copy(tee, reader) 199 | if err != nil { 200 | return err 201 | } 202 | 203 | // Check SHA 204 | sha1Hash := hex.EncodeToString(sha.Sum(nil)) 205 | if sha1Hash != fileInfo.ContentSha1 { 206 | return errors.New("Downloaded data does not match SHA1 hash") 207 | } 208 | 209 | return nil 210 | } 211 | -------------------------------------------------------------------------------- /b2/list.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | // List is a command 10 | type List struct { 11 | ListVersions bool `short:"a" long:"allVersions" description:"List all versions of files"` 12 | } 13 | 14 | func init() { 15 | parser.AddCommand("list", "List files in a bucket", "", &List{}) 16 | } 17 | 18 | // Execute the list command 19 | func (o *List) Execute(args []string) error { 20 | client, err := Client() 21 | if err != nil { 22 | return err 23 | } 24 | 25 | bucket, err := client.Bucket(opts.Bucket) 26 | if err != nil { 27 | return err 28 | } 29 | if bucket == nil { 30 | return errors.New("Bucket not found: " + opts.Bucket) 31 | } 32 | 33 | if o.ListVersions { 34 | response, err := bucket.ListFileVersions("", "", 100) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | if opts.Verbose { 40 | fmt.Printf("Contents of %s/\n", opts.Bucket) 41 | for _, file := range response.Files { 42 | fmt.Printf("%s\n%10d %s %-20s\n\n", file.ID, file.Size, time.Unix(file.UploadTimestamp/1000, file.UploadTimestamp%1000), file.Name) 43 | } 44 | } else { 45 | for _, file := range response.Files { 46 | fmt.Println(file.Name + ":" + file.ID) 47 | } 48 | } 49 | } else { 50 | response, err := bucket.ListFileNames("", 100) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | if opts.Verbose { 56 | fmt.Printf("Contents of %s/\n", opts.Bucket) 57 | for _, file := range response.Files { 58 | fmt.Printf("%10d %s %-20s\n", file.Size, time.Unix(file.UploadTimestamp/1000, file.UploadTimestamp%1000), file.Name) 59 | } 60 | } else { 61 | for _, file := range response.Files { 62 | fmt.Println(file.Name) 63 | } 64 | } 65 | } 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /b2/listbuckets.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // ListBuckets is a command 8 | type ListBuckets struct { 9 | } 10 | 11 | func init() { 12 | parser.AddCommand("listbuckets", "List buckets in an account", "", &ListBuckets{}) 13 | } 14 | 15 | // Execute the listbuckets command 16 | func (o *ListBuckets) Execute(args []string) error { 17 | client, err := Client() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | response, err := client.ListBuckets() 23 | if err != nil { 24 | return err 25 | } 26 | 27 | if opts.Verbose { 28 | fmt.Printf("%-30s%-35s%-15s\n", "Name", "Id", "Type") 29 | for _, bucket := range response { 30 | fmt.Printf("%-30s%-35s%-15s\n", bucket.Name, bucket.ID, bucket.BucketType) 31 | } 32 | } else { 33 | for _, bucket := range response { 34 | fmt.Println(bucket.Name) 35 | } 36 | } 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /b2/put.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "sync" 10 | "time" 11 | 12 | "github.com/dustin/go-humanize" 13 | "github.com/gosuri/uiprogress" 14 | "github.com/gosuri/uiprogress/util/strutil" 15 | 16 | "gopkg.in/kothar/go-backblaze.v0" 17 | ) 18 | 19 | // TODO support directories 20 | // TODO support replacing all previous versions 21 | 22 | // Put is a command 23 | type Put struct { 24 | Threads int `short:"j" long:"threads" default:"5" description:"Maximum simultaneous uploads to process"` 25 | Meta map[string]string `long:"meta" description:"Assign metadata to uploaded files"` 26 | } 27 | 28 | func init() { 29 | parser.AddCommand("put", "Store a file", 30 | "Uploads one or more files. Specify the bucket with -b, and the filenames to upload as extra arguments.", 31 | &Put{}) 32 | } 33 | 34 | // Execute the put command 35 | func (o *Put) Execute(args []string) error { 36 | client, err := Client() 37 | if err != nil { 38 | return err 39 | } 40 | 41 | bucket, err := client.Bucket(opts.Bucket) 42 | if err != nil { 43 | return err 44 | } 45 | if bucket == nil { 46 | return errors.New("Bucket not found: " + opts.Bucket) 47 | } 48 | 49 | uiprogress.Start() 50 | tasks := make(chan string, o.Threads) 51 | group := sync.WaitGroup{} 52 | 53 | // Create workers 54 | for i := 0; i < o.Threads; i++ { 55 | group.Add(1) 56 | go func() { 57 | for file := range tasks { 58 | _, err := upload(bucket, file, o.Meta) 59 | if err != nil { 60 | fmt.Println(err) 61 | } 62 | 63 | // TODO handle termination on error 64 | } 65 | group.Done() 66 | }() 67 | } 68 | 69 | for _, file := range args { 70 | tasks <- file 71 | } 72 | close(tasks) 73 | 74 | group.Wait() 75 | 76 | return nil 77 | } 78 | 79 | type progressReader struct { 80 | bar *uiprogress.Bar 81 | r io.ReadSeeker 82 | } 83 | 84 | func (p *progressReader) Read(b []byte) (int, error) { 85 | read, err := p.r.Read(b) 86 | p.bar.Set(p.bar.Current() + read) 87 | return read, err 88 | } 89 | 90 | func (p *progressReader) Seek(offset int64, whence int) (int64, error) { 91 | switch whence { 92 | case 0: 93 | p.bar.Set(int(offset)) 94 | case 1: 95 | p.bar.Set(p.bar.Current() + int(offset)) 96 | case 2: 97 | p.bar.Set(p.bar.Total - int(offset)) 98 | } 99 | return p.r.Seek(offset, whence) 100 | } 101 | 102 | func upload(bucket *backblaze.Bucket, file string, meta map[string]string) (*backblaze.File, error) { 103 | 104 | stat, err := os.Stat(file) 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | reader, err := os.Open(file) 110 | if err != nil { 111 | return nil, err 112 | } 113 | defer reader.Close() 114 | 115 | var r io.Reader = reader 116 | if opts.Verbose { 117 | bar := uiprogress.AddBar(int(stat.Size())) 118 | // TODO Stop bar refresh when complete 119 | 120 | if stat.Size() > 1024*100 { 121 | start := time.Now() 122 | elapsed := time.Duration(1) 123 | count := 0 124 | bar.AppendFunc(func(b *uiprogress.Bar) string { 125 | count++ 126 | if count < 2 { 127 | return "" 128 | } 129 | 130 | // elapsed := b.TimeElapsed() 131 | if b.Current() < b.Total { 132 | elapsed = time.Now().Sub(start) 133 | } 134 | speed := uint64(float64(b.Current()) / elapsed.Seconds()) 135 | return humanize.IBytes(speed) + "/sec" 136 | }) 137 | } 138 | bar.AppendCompleted() 139 | bar.PrependFunc(func(b *uiprogress.Bar) string { return fmt.Sprintf("%10s", humanize.IBytes(uint64(b.Total))) }) 140 | bar.PrependFunc(func(b *uiprogress.Bar) string { return strutil.Resize(file, 50) }) 141 | bar.Width = 20 142 | 143 | r = &progressReader{bar, reader} 144 | } 145 | 146 | return bucket.UploadFile(filepath.Base(file), meta, r) 147 | } 148 | -------------------------------------------------------------------------------- /backblaze.go: -------------------------------------------------------------------------------- 1 | // Package backblaze B2 API for Golang 2 | package backblaze // import "gopkg.in/kothar/go-backblaze.v0" 3 | 4 | import ( 5 | "bytes" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "sync" 11 | 12 | "github.com/pquerna/ffjson/ffjson" 13 | ) 14 | 15 | const ( 16 | b2Host = "https://api.backblazeb2.com" 17 | v1 = "/b2api/v1/" 18 | ) 19 | 20 | // Credentials are the identification required by the Backblaze B2 API 21 | // 22 | // The account ID is a 12-digit hex number that you can get from 23 | // your account page on backblaze.com. 24 | // 25 | // The application key is a 40-digit hex number that you can get from 26 | // your account page on backblaze.com. 27 | // 28 | // The key id is the application key id that you get when generating an 29 | // application key. If using the master application key, leave this set 30 | // to an empty string as your account id will be used instead. 31 | type Credentials struct { 32 | AccountID string 33 | ApplicationKey string 34 | KeyID string 35 | } 36 | 37 | // B2 implements a B2 API client. Do not modify state concurrently. 38 | type B2 struct { 39 | Credentials 40 | 41 | // If true, don't retry requests if authorization has expired 42 | NoRetry bool 43 | 44 | // If true, display debugging information about API calls 45 | Debug bool 46 | 47 | // Number of MaxIdleUploads to keep for reuse. 48 | // This must be set prior to creating a bucket struct 49 | MaxIdleUploads int 50 | 51 | // State 52 | mutex sync.Mutex 53 | host string 54 | auth *authorizationState 55 | httpClient http.Client 56 | } 57 | 58 | // The current auth state of the client. Can be individually invalidated by 59 | // requests which fail, tringgering a reauth the next time its validity is 60 | // checked 61 | type authorizationState struct { 62 | sync.Mutex 63 | *authorizeAccountResponse 64 | 65 | valid bool 66 | } 67 | 68 | func (a *authorizationState) isValid() bool { 69 | if a == nil { 70 | return false 71 | } 72 | 73 | a.Lock() 74 | defer a.Unlock() 75 | 76 | return a.valid 77 | } 78 | 79 | // Marks the authorization as invalid. This will result in a new Authorization 80 | // on the next API call. 81 | func (a *authorizationState) invalidate() { 82 | if a == nil { 83 | return 84 | } 85 | 86 | a.Lock() 87 | defer a.Unlock() 88 | 89 | a.valid = false 90 | a.authorizeAccountResponse = nil 91 | } 92 | 93 | // NewB2 creates a new Client for accessing the B2 API. 94 | // The AuthorizeAccount method will be called immediately. 95 | func NewB2(creds Credentials) (*B2, error) { 96 | c := &B2{ 97 | Credentials: creds, 98 | MaxIdleUploads: 1, 99 | } 100 | 101 | // Authorize account 102 | if err := c.AuthorizeAccount(); err != nil { 103 | return nil, err 104 | } 105 | 106 | return c, nil 107 | } 108 | 109 | // AuthorizeAccount is used to log in to the B2 API. 110 | func (c *B2) AuthorizeAccount() error { 111 | c.mutex.Lock() 112 | defer c.mutex.Unlock() 113 | 114 | return c.internalAuthorizeAccount() 115 | } 116 | 117 | // The body of AuthorizeAccount without a mutex 118 | func (c *B2) internalAuthorizeAccount() error { 119 | if c.host == "" { 120 | c.host = b2Host 121 | } 122 | 123 | req, err := http.NewRequest("GET", c.host+v1+"b2_authorize_account", nil) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | // Support the use of application keys. If a KeyID is not explicitly set, 129 | // use the account ID as the key ID 130 | keyID := c.KeyID 131 | if keyID == "" { 132 | keyID = c.AccountID 133 | } 134 | req.SetBasicAuth(keyID, c.ApplicationKey) 135 | 136 | resp, err := c.httpClient.Do(req) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | authResponse := &authorizeAccountResponse{} 142 | if err = c.parseResponse(resp, authResponse, nil); err != nil { 143 | return err 144 | } 145 | 146 | // Store token 147 | c.auth = &authorizationState{ 148 | authorizeAccountResponse: authResponse, 149 | valid: true, 150 | } 151 | 152 | // Set AccountID to the returned value from authorizeAccountResponse 153 | // This is for when an Application Key is used instead of Master Application Key 154 | if c.AccountID == "" { 155 | c.AccountID = authResponse.AccountID 156 | } 157 | 158 | return nil 159 | } 160 | 161 | // DownloadURL returns the URL prefix needed to construct download links. 162 | // Bucket.FileURL will costruct a full URL for given file names. 163 | func (c *B2) DownloadURL() (string, error) { 164 | c.mutex.Lock() 165 | defer c.mutex.Unlock() 166 | 167 | if !c.auth.isValid() { 168 | if err := c.internalAuthorizeAccount(); err != nil { 169 | return "", err 170 | } 171 | } 172 | return c.auth.DownloadURL, nil 173 | } 174 | 175 | // Create an authorized request using the client's credentials 176 | func (c *B2) authRequest(method, apiPath string, body io.Reader) (*http.Request, *authorizationState, error) { 177 | c.mutex.Lock() 178 | defer c.mutex.Unlock() 179 | 180 | if !c.auth.isValid() { 181 | if c.Debug { 182 | log.Println("No valid authorization token, re-authorizing client") 183 | } 184 | if err := c.internalAuthorizeAccount(); err != nil { 185 | return nil, nil, err 186 | } 187 | } 188 | 189 | path := c.auth.APIEndpoint + v1 + apiPath 190 | 191 | req, err := http.NewRequest(method, path, body) 192 | if err != nil { 193 | return nil, nil, err 194 | } 195 | 196 | req.Header.Add("Authorization", c.auth.AuthorizationToken) 197 | 198 | if c.Debug { 199 | log.Printf("authRequest: %s %s\n", method, req.URL) 200 | } 201 | 202 | return req, c.auth, nil 203 | } 204 | 205 | // Dispatch an authorized API GET request 206 | func (c *B2) authGet(apiPath string) (*http.Response, *authorizationState, error) { 207 | req, auth, err := c.authRequest("GET", apiPath, nil) 208 | if err != nil { 209 | return nil, nil, err 210 | } 211 | 212 | resp, err := c.httpClient.Do(req) 213 | return resp, auth, err 214 | } 215 | 216 | // Dispatch an authorized POST request 217 | func (c *B2) authPost(apiPath string, body io.Reader) (*http.Response, *authorizationState, error) { 218 | req, auth, err := c.authRequest("POST", apiPath, body) 219 | if err != nil { 220 | return nil, nil, err 221 | } 222 | 223 | resp, err := c.httpClient.Do(req) 224 | return resp, auth, err 225 | } 226 | 227 | // Looks for an error message in the response body and parses it into a 228 | // B2Error object 229 | func (c *B2) parseError(body []byte) error { 230 | b2err := &B2Error{} 231 | if ffjson.Unmarshal(body, b2err) != nil { 232 | return nil 233 | } 234 | return b2err 235 | } 236 | 237 | // Attempts to parse a response body into the provided result struct 238 | func (c *B2) parseResponse(resp *http.Response, result interface{}, auth *authorizationState) error { 239 | defer resp.Body.Close() 240 | 241 | // Read response body 242 | body, err := ioutil.ReadAll(resp.Body) 243 | if err != nil { 244 | return err 245 | } 246 | 247 | if c.Debug { 248 | log.Printf("Response: %s", body) 249 | } 250 | 251 | // Check response code 252 | switch resp.StatusCode { 253 | case 200: // Response is OK 254 | case 401: 255 | auth.invalidate() 256 | if err := c.parseError(body); err != nil { 257 | return err 258 | } 259 | return &B2Error{ 260 | Code: "UNAUTHORIZED", 261 | Message: "The account ID is wrong, the account does not have B2 enabled, or the application key is not valid", 262 | Status: resp.StatusCode, 263 | } 264 | default: 265 | if err := c.parseError(body); err != nil { 266 | return err 267 | } 268 | return &B2Error{ 269 | Code: "UNKNOWN", 270 | Message: "Unrecognised status code", 271 | Status: resp.StatusCode, 272 | } 273 | } 274 | 275 | return ffjson.Unmarshal(body, result) 276 | } 277 | 278 | // Perform a B2 API request with the provided request and response objects 279 | func (c *B2) apiRequest(apiPath string, request interface{}, response interface{}) error { 280 | body, err := ffjson.Marshal(request) 281 | if err != nil { 282 | return err 283 | } 284 | defer ffjson.Pool(body) 285 | 286 | if c.Debug { 287 | log.Println("----") 288 | log.Printf("apiRequest: %s %s", apiPath, body) 289 | } 290 | 291 | err = c.tryAPIRequest(apiPath, body, response) 292 | 293 | // Retry after non-fatal errors 294 | if b2err, ok := err.(*B2Error); ok { 295 | if !b2err.IsFatal() && !c.NoRetry { 296 | if c.Debug { 297 | log.Printf("Retrying request %q due to error: %v", apiPath, err) 298 | } 299 | 300 | return c.tryAPIRequest(apiPath, body, response) 301 | } 302 | } 303 | return err 304 | } 305 | 306 | func (c *B2) tryAPIRequest(apiPath string, body []byte, response interface{}) error { 307 | resp, auth, err := c.authPost(apiPath, bytes.NewReader(body)) 308 | if err != nil { 309 | if c.Debug { 310 | log.Println("B2.post returned an error: ", err) 311 | } 312 | return err 313 | } 314 | 315 | return c.parseResponse(resp, response, auth) 316 | } 317 | -------------------------------------------------------------------------------- /backblaze_test.go: -------------------------------------------------------------------------------- 1 | package backblaze 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | 11 | "github.com/pquerna/ffjson/ffjson" 12 | ) 13 | 14 | type response struct { 15 | code int 16 | body interface{} 17 | headers map[string]string 18 | } 19 | 20 | // Based on http://keighl.com/post/mocking-http-responses-in-golang/ 21 | func prepareResponses(responses []response) (*http.Client, *httptest.Server) { 22 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | if len(responses) == 0 { 24 | w.WriteHeader(500) 25 | w.Header().Set("Content-Type", "application/json") 26 | fmt.Fprintln(w, "No more responses") 27 | return 28 | } 29 | 30 | next := responses[0] 31 | responses = responses[1:] 32 | 33 | for k, v := range next.headers { 34 | w.Header().Add(k, v) 35 | } 36 | w.WriteHeader(next.code) 37 | w.Header().Set("Content-Type", "application/json") 38 | if body, ok := next.body.([]byte); ok { 39 | w.Write(body) 40 | } else { 41 | fmt.Fprint(w, toJSON(next.body)) 42 | } 43 | })) 44 | 45 | // Make a transport that reroutes all traffic to the example server 46 | transport := &http.Transport{ 47 | Proxy: func(req *http.Request) (*url.URL, error) { 48 | return url.Parse(server.URL + req.URL.Path) 49 | }, 50 | } 51 | 52 | // Make a http.Client with the transport 53 | client := &http.Client{Transport: transport} 54 | 55 | return client, server 56 | } 57 | 58 | func toJSON(o interface{}) string { 59 | bytes, err := ffjson.Marshal(o) 60 | if err != nil { 61 | log.Fatal(err) 62 | } 63 | return string(bytes) 64 | } 65 | 66 | func TestUnauthorizedError(T *testing.T) { 67 | err := &B2Error{Status: 401, Code: "expired_auth_token"} 68 | if err.IsFatal() { 69 | T.Fatal("401 expired_auth_token error should not be considered fatal") 70 | } 71 | } 72 | 73 | func TestAuth(T *testing.T) { 74 | accountID := "test" 75 | token := "testToken" 76 | apiURL := "http://api.url" 77 | downloadURL := "http://download.url" 78 | 79 | client, server := prepareResponses([]response{ 80 | {code: 200, body: authorizeAccountResponse{ 81 | AccountID: accountID, 82 | APIEndpoint: apiURL, 83 | AuthorizationToken: token, 84 | DownloadURL: downloadURL, 85 | }}, 86 | }) 87 | defer server.Close() 88 | 89 | b2 := &B2{ 90 | Credentials: Credentials{ 91 | AccountID: accountID, 92 | ApplicationKey: "test", 93 | }, 94 | Debug: testing.Verbose(), 95 | host: server.URL, 96 | httpClient: *client, 97 | } 98 | 99 | if err := b2.AuthorizeAccount(); err != nil { 100 | T.Fatal(err) 101 | } 102 | 103 | if b2.auth.AuthorizationToken != token { 104 | T.Errorf("Auth token not set correctly: expecting %q, saw %q", token, b2.auth.AuthorizationToken) 105 | } 106 | 107 | if b2.auth.APIEndpoint != apiURL { 108 | T.Errorf("API Endpoint not set correctly: expecting %q, saw %q", apiURL, b2.auth.APIEndpoint) 109 | } 110 | 111 | if b2.auth.DownloadURL != downloadURL { 112 | T.Errorf("Download URL not set correctly: expecting %q, saw %q", downloadURL, b2.auth.DownloadURL) 113 | } 114 | } 115 | 116 | func TestReAuth(T *testing.T) { 117 | 118 | accountID := "test" 119 | bucketID := "bucketid" 120 | token2 := "testToken2" 121 | 122 | client, server := prepareResponses([]response{ 123 | {code: 200, body: authorizeAccountResponse{ 124 | AccountID: accountID, 125 | APIEndpoint: "http://api.url", 126 | AuthorizationToken: "testToken", 127 | DownloadURL: "http://download.url", 128 | }}, 129 | {code: 401, body: B2Error{ 130 | Status: 401, 131 | Code: "expired_auth_token", 132 | Message: "Authentication token expired", 133 | }}, 134 | {code: 200, body: authorizeAccountResponse{ 135 | AccountID: accountID, 136 | APIEndpoint: "http://api.url", 137 | AuthorizationToken: token2, 138 | DownloadURL: "http://download.url", 139 | }}, 140 | {code: 200, body: listBucketsResponse{ 141 | Buckets: []*BucketInfo{ 142 | &BucketInfo{ 143 | ID: bucketID, 144 | AccountID: accountID, 145 | Name: "testbucket", 146 | BucketType: AllPrivate, 147 | }, 148 | }, 149 | }}, 150 | }) 151 | defer server.Close() 152 | 153 | b2 := &B2{ 154 | Credentials: Credentials{ 155 | AccountID: accountID, 156 | ApplicationKey: "test", 157 | }, 158 | Debug: testing.Verbose(), 159 | httpClient: *client, 160 | host: server.URL, 161 | } 162 | 163 | _, err := b2.ListBuckets() 164 | if err != nil { 165 | T.Fatal(err) 166 | } 167 | 168 | if b2.auth.AuthorizationToken != token2 { 169 | T.Errorf("Expected auth token after re-auth to be %q, saw %q", token2, b2.auth.AuthorizationToken) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /buckets.go: -------------------------------------------------------------------------------- 1 | package backblaze 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | ) 7 | 8 | // Bucket provides access to the files stored in a B2 Bucket 9 | type Bucket struct { 10 | *BucketInfo 11 | 12 | uploadAuthPool chan *UploadAuth 13 | 14 | b2 *B2 15 | } 16 | 17 | // UploadAuth encapsulates the details needed to upload a file to B2 18 | // These are pooled and must be returned when complete. 19 | type UploadAuth struct { 20 | AuthorizationToken string 21 | UploadURL *url.URL 22 | Valid bool 23 | } 24 | 25 | // CreateBucket creates a new B2 Bucket in the authorized account. 26 | // 27 | // Buckets can be named. The name must be globally unique. No account can use 28 | // a bucket with the same name. Buckets are assigned a unique bucketId which 29 | // is used when uploading, downloading, or deleting files. 30 | func (b *B2) CreateBucket(bucketName string, bucketType BucketType) (*Bucket, error) { 31 | return b.CreateBucketWithInfo(bucketName, bucketType, nil, nil) 32 | } 33 | 34 | // CreateBucketWithInfo extends CreateBucket to add bucket info and lifecycle rules to the creation request 35 | func (b *B2) CreateBucketWithInfo(bucketName string, bucketType BucketType, bucketInfo map[string]string, lifecycleRules []LifecycleRule) (*Bucket, error) { 36 | request := &createBucketRequest{ 37 | AccountID: b.AccountID, 38 | BucketName: bucketName, 39 | BucketType: bucketType, 40 | BucketInfo: bucketInfo, 41 | LifecycleRules: lifecycleRules, 42 | } 43 | response := &BucketInfo{} 44 | 45 | if err := b.apiRequest("b2_create_bucket", request, response); err != nil { 46 | return nil, err 47 | } 48 | 49 | bucket := &Bucket{ 50 | BucketInfo: response, 51 | uploadAuthPool: make(chan *UploadAuth, b.MaxIdleUploads), 52 | b2: b, 53 | } 54 | 55 | return bucket, nil 56 | } 57 | 58 | // deleteBucket removes the specified bucket from the authorized account. Only 59 | // buckets that contain no version of any files can be deleted. 60 | func (b *B2) deleteBucket(bucketID string) (*Bucket, error) { 61 | request := &deleteBucketRequest{ 62 | AccountID: b.AccountID, 63 | BucketID: bucketID, 64 | } 65 | response := &BucketInfo{} 66 | 67 | if err := b.apiRequest("b2_delete_bucket", request, response); err != nil { 68 | return nil, err 69 | } 70 | 71 | return &Bucket{ 72 | BucketInfo: response, 73 | uploadAuthPool: make(chan *UploadAuth, b.MaxIdleUploads), 74 | b2: b, 75 | }, nil 76 | } 77 | 78 | // Delete removes removes the bucket from the authorized account. Only buckets 79 | // that contain no version of any files can be deleted. 80 | func (b *Bucket) Delete() error { 81 | _, error := b.b2.deleteBucket(b.ID) 82 | return error 83 | } 84 | 85 | // ListBuckets lists buckets associated with an account, in alphabetical order 86 | // by bucket ID. 87 | func (b *B2) ListBuckets() ([]*Bucket, error) { 88 | request := &accountRequest{ 89 | ID: b.AccountID, 90 | } 91 | response := &listBucketsResponse{} 92 | 93 | if err := b.apiRequest("b2_list_buckets", request, response); err != nil { 94 | return nil, err 95 | } 96 | 97 | // Construct bucket list 98 | buckets := make([]*Bucket, len(response.Buckets)) 99 | for i, info := range response.Buckets { 100 | bucket := &Bucket{ 101 | BucketInfo: info, 102 | uploadAuthPool: make(chan *UploadAuth, b.MaxIdleUploads), 103 | b2: b, 104 | } 105 | 106 | switch info.BucketType { 107 | case AllPublic: 108 | case AllPrivate: 109 | case Snapshot: 110 | default: 111 | return nil, errors.New("Uncrecognised bucket type: " + string(bucket.BucketType)) 112 | } 113 | 114 | buckets[i] = bucket 115 | } 116 | 117 | return buckets, nil 118 | } 119 | 120 | // UpdateBucket allows properties of a bucket to be modified 121 | func (b *B2) updateBucket(request *updateBucketRequest) (*Bucket, error) { 122 | response := &BucketInfo{} 123 | 124 | if err := b.apiRequest("b2_update_bucket", request, response); err != nil { 125 | return nil, err 126 | } 127 | 128 | return &Bucket{ 129 | BucketInfo: response, 130 | uploadAuthPool: make(chan *UploadAuth, b.MaxIdleUploads), 131 | b2: b, 132 | }, nil 133 | } 134 | 135 | // Update allows the bucket type to be changed 136 | func (b *Bucket) Update(bucketType BucketType) error { 137 | return b.UpdateAll(bucketType, nil, nil, 0) 138 | } 139 | 140 | // UpdateAll allows all bucket properties to be changed 141 | // 142 | // bucketType (optional) -- One of: "allPublic", "allPrivate". "allPublic" means that anybody can download the files is the bucket; 143 | // "allPrivate" means that you need an authorization token to download them. 144 | // If not specified, setting will remain unchanged. 145 | // 146 | // bucketInfo (optional) -- User-defined information to be stored with the bucket. 147 | // If specified, the existing bucket info will be replaced with the new info. If not specified, setting will remain unchanged. 148 | // Cache-Control policies can be set here on a global level for all the files in the bucket. 149 | // 150 | // lifecycleRules (optional) -- The list of lifecycle rules for this bucket. 151 | // If specified, the existing lifecycle rules will be replaced with this new list. If not specified, setting will remain unchanged. 152 | // 153 | // ifRevisionIs (optional) -- When set (> 0), the update will only happen if the revision number stored in the B2 service matches the one passed in. 154 | // This can be used to avoid having simultaneous updates make conflicting changes. 155 | func (b *Bucket) UpdateAll(bucketType BucketType, bucketInfo map[string]string, lifecycleRules []LifecycleRule, ifRevisionIs int) error { 156 | _, err := b.b2.updateBucket(&updateBucketRequest{ 157 | AccountID: b.AccountID, 158 | BucketID: b.ID, 159 | BucketType: bucketType, 160 | BucketInfo: bucketInfo, 161 | LifecycleRules: lifecycleRules, 162 | IfRevisionIs: ifRevisionIs, 163 | }) 164 | return err 165 | } 166 | 167 | // Bucket looks up a bucket for the currently authorized client 168 | func (b *B2) Bucket(bucketName string) (*Bucket, error) { 169 | buckets, err := b.ListBuckets() 170 | if err != nil { 171 | return nil, err 172 | } 173 | 174 | for _, bucket := range buckets { 175 | if bucket.Name == bucketName { 176 | return bucket, nil 177 | } 178 | } 179 | 180 | return nil, nil 181 | } 182 | 183 | // GetUploadAuth retrieves the URL to use for uploading files. 184 | // 185 | // When you upload a file to B2, you must call b2_get_upload_url first to get 186 | // the URL for uploading directly to the place where the file will be stored. 187 | // 188 | // If the upload is successful, ReturnUploadAuth(*uploadAuth) should be called 189 | // to place it back in the pool for reuse. 190 | func (b *Bucket) GetUploadAuth() (*UploadAuth, error) { 191 | select { 192 | // Pop an UploadAuth from the pool 193 | case auth := <-b.uploadAuthPool: 194 | return auth, nil 195 | 196 | // If none are available, make a new one 197 | default: 198 | // Make a new one 199 | request := &bucketRequest{ 200 | ID: b.ID, 201 | } 202 | 203 | response := &getUploadURLResponse{} 204 | if err := b.b2.apiRequest("b2_get_upload_url", request, response); err != nil { 205 | return nil, err 206 | } 207 | 208 | // Set bucket auth 209 | uploadURL, err := url.Parse(response.UploadURL) 210 | if err != nil { 211 | return nil, err 212 | } 213 | auth := &UploadAuth{ 214 | AuthorizationToken: response.AuthorizationToken, 215 | UploadURL: uploadURL, 216 | Valid: true, 217 | } 218 | 219 | return auth, nil 220 | } 221 | } 222 | 223 | // ReturnUploadAuth returns an upload URL to the available pool. 224 | // This should not be called if the upload fails. 225 | // Instead request another GetUploadAuth() and retry. 226 | func (b *Bucket) ReturnUploadAuth(uploadAuth *UploadAuth) { 227 | if uploadAuth.Valid { 228 | select { 229 | case b.uploadAuthPool <- uploadAuth: 230 | default: 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /buckets_test.go: -------------------------------------------------------------------------------- 1 | package backblaze 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestListBuckets(T *testing.T) { 8 | 9 | accountID := "test" 10 | bucketID := "bucketid" 11 | 12 | client, server := prepareResponses([]response{ 13 | {code: 200, body: authorizeAccountResponse{ 14 | AccountID: accountID, 15 | APIEndpoint: "http://api.url", 16 | AuthorizationToken: "testToken", 17 | DownloadURL: "http://download.url", 18 | }}, 19 | {code: 200, body: listBucketsResponse{ 20 | Buckets: []*BucketInfo{ 21 | &BucketInfo{ 22 | ID: bucketID, 23 | AccountID: accountID, 24 | Name: "testbucket", 25 | BucketType: AllPrivate, 26 | }, 27 | }, 28 | }}, 29 | }) 30 | defer server.Close() 31 | 32 | b2 := &B2{ 33 | Credentials: Credentials{ 34 | AccountID: accountID, 35 | ApplicationKey: "test", 36 | }, 37 | Debug: testing.Verbose(), 38 | httpClient: *client, 39 | host: server.URL, 40 | } 41 | 42 | buckets, err := b2.ListBuckets() 43 | if err != nil { 44 | T.Fatal(err) 45 | } 46 | 47 | if len(buckets) != 1 { 48 | T.Errorf("Expected 1 bucket, received %d", len(buckets)) 49 | } 50 | if buckets[0].ID != bucketID { 51 | T.Errorf("Bucket ID does not match: expected %q, saw %q", bucketID, buckets[0].ID) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples_test.go: -------------------------------------------------------------------------------- 1 | package backblaze 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | func ExampleB2() { 9 | NewB2(Credentials{ 10 | AccountID: "", // Obtained from your B2 account page. 11 | ApplicationKey: "", // Obtained from your B2 account page. 12 | }) 13 | } 14 | 15 | func ExampleBucket() { 16 | var b2 B2 17 | // ... 18 | 19 | b2.CreateBucket("test_bucket", AllPrivate) 20 | } 21 | 22 | func ExampleBucket_UploadFile() { 23 | var bucket Bucket 24 | // ... 25 | 26 | path := "/path/to/file" 27 | reader, _ := os.Open(path) 28 | name := filepath.Base(path) 29 | metadata := make(map[string]string) 30 | 31 | bucket.UploadFile(name, metadata, reader) 32 | } 33 | -------------------------------------------------------------------------------- /files.go: -------------------------------------------------------------------------------- 1 | package backblaze 2 | 3 | import ( 4 | "bytes" 5 | "crypto/sha1" 6 | "encoding/hex" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "log" 12 | "net/http" 13 | "net/url" 14 | "strconv" 15 | "strings" 16 | 17 | "github.com/google/readahead" 18 | 19 | "github.com/pquerna/ffjson/ffjson" 20 | ) 21 | 22 | // ListFileNames lists the names of all files in a bucket, starting at a given name. 23 | // 24 | // See ListFileNamesWithPrefix 25 | func (b *Bucket) ListFileNames(startFileName string, maxFileCount int) (*ListFilesResponse, error) { 26 | return b.ListFileNamesWithPrefix(startFileName, maxFileCount, "", "") 27 | } 28 | 29 | // ListFileNamesWithPrefix lists the names of all files in a bucket, starting at a given name. 30 | // 31 | // This call returns at most 1000 file names per transaction, but it can be called repeatedly to scan through all of the file names in a bucket. 32 | // Each time you call, it returns a "nextFileName" that can be used as the starting point for the next call. 33 | // 34 | // If you set maxFileCount to more than 1000 (max 10,000) and more than 1000 are returned, the call will be billed as multiple transactions, 35 | // as if you had made requests in a loop asking for 1000 at a time. 36 | // 37 | // Files returned will be limited to those with the given prefix. The empty string matches all files. 38 | // 39 | // If a delimiter is provided, files returned will be limited to those within the top folder, or any one subfolder. 40 | // Folder names will also be returned. The delimiter character will be used to "break" file names into folders. 41 | func (b *Bucket) ListFileNamesWithPrefix(startFileName string, maxFileCount int, prefix, delimiter string) (*ListFilesResponse, error) { 42 | 43 | if maxFileCount > 10000 || maxFileCount < 0 { 44 | return nil, fmt.Errorf("maxFileCount must be in range 0 to 10,000") 45 | } 46 | 47 | request := &listFilesRequest{ 48 | BucketID: b.ID, 49 | StartFileName: startFileName, 50 | MaxFileCount: maxFileCount, 51 | Prefix: prefix, 52 | Delimiter: delimiter, 53 | } 54 | response := &ListFilesResponse{} 55 | 56 | if err := b.b2.apiRequest("b2_list_file_names", request, response); err != nil { 57 | return nil, err 58 | } 59 | 60 | return response, nil 61 | } 62 | 63 | // UploadFile calls UploadTypedFile with the b2/x-auto contentType 64 | func (b *Bucket) UploadFile(name string, meta map[string]string, file io.Reader) (*File, error) { 65 | return b.UploadTypedFile(name, "b2/x-auto", meta, file) 66 | } 67 | 68 | // UploadTypedFile uploads a file to B2, returning its unique file ID. 69 | // This method computes the hash of the file before passing it to UploadHashedFile 70 | func (b *Bucket) UploadTypedFile(name, contentType string, meta map[string]string, file io.Reader) (*File, error) { 71 | 72 | // Hash the upload 73 | hash := sha1.New() 74 | 75 | var reader io.Reader 76 | var contentLength int64 77 | if r, ok := file.(io.ReadSeeker); ok { 78 | // If the input is seekable, just hash then seek back to the beginning 79 | written, err := io.Copy(hash, r) 80 | if err != nil { 81 | return nil, err 82 | } 83 | r.Seek(0, 0) 84 | reader = r 85 | contentLength = written 86 | } else { 87 | // If the input is not seekable, buffer it while hashing, and use the buffer as input 88 | buffer := &bytes.Buffer{} 89 | r := io.TeeReader(file, buffer) 90 | 91 | written, err := io.Copy(hash, r) 92 | if err != nil { 93 | return nil, err 94 | } 95 | reader = buffer 96 | contentLength = written 97 | } 98 | 99 | sha1Hash := hex.EncodeToString(hash.Sum(nil)) 100 | f, err := b.UploadHashedTypedFile(name, contentType, meta, reader, sha1Hash, contentLength) 101 | 102 | // Retry after non-fatal errors 103 | if b2err, ok := err.(*B2Error); ok { 104 | if !b2err.IsFatal() && !b.b2.NoRetry { 105 | f, err = b.UploadHashedTypedFile(name, contentType, meta, reader, sha1Hash, contentLength) 106 | } 107 | } 108 | return f, err 109 | } 110 | 111 | // UploadHashedFile calls UploadHashedTypedFile with the b2/x-auto file type 112 | func (b *Bucket) UploadHashedFile( 113 | name string, meta map[string]string, file io.Reader, 114 | sha1Hash string, contentLength int64) (*File, error) { 115 | 116 | return b.UploadHashedTypedFile(name, "b2/x-auto", meta, file, sha1Hash, contentLength) 117 | } 118 | 119 | // UploadHashedTypedFile Uploads a file to B2, returning its unique file ID. 120 | // 121 | // This method will not retry if the upload fails, as the reader may have consumed 122 | // some bytes. If the error type is B2Error and IsFatal returns false, you may retry the 123 | // upload and expect it to succeed eventually. 124 | func (b *Bucket) UploadHashedTypedFile( 125 | name, contentType string, meta map[string]string, file io.Reader, 126 | sha1Hash string, contentLength int64) (*File, error) { 127 | 128 | auth, err := b.GetUploadAuth() 129 | if err != nil { 130 | return nil, err 131 | } 132 | 133 | if b.b2.Debug { 134 | fmt.Printf(" Upload: %s/%s\n", b.Name, name) 135 | fmt.Printf(" SHA1: %s\n", sha1Hash) 136 | fmt.Printf(" ContentLength: %d\n", contentLength) 137 | fmt.Printf(" ContentType: %s\n", contentType) 138 | } 139 | 140 | // Create authorized request 141 | req, err := http.NewRequest("POST", auth.UploadURL.String(), file) 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | req.Header.Set("Authorization", auth.AuthorizationToken) 147 | 148 | // Set file metadata 149 | req.ContentLength = contentLength 150 | // default content type 151 | req.Header.Set("Content-Type", contentType) 152 | req.Header.Set("X-Bz-File-Name", url.QueryEscape(name)) 153 | req.Header.Set("X-Bz-Content-Sha1", sha1Hash) 154 | 155 | if meta != nil { 156 | for k, v := range meta { 157 | req.Header.Add("X-Bz-Info-"+url.QueryEscape(k), url.QueryEscape(v)) 158 | } 159 | } 160 | 161 | resp, err := b.b2.httpClient.Do(req) 162 | if err != nil { 163 | auth.Valid = false 164 | return nil, err 165 | } 166 | 167 | // Place the UploadAuth back in the pool 168 | b.ReturnUploadAuth(auth) 169 | 170 | result := &File{} 171 | 172 | // We are not dealing with the b2 client auth token in this case, hence the nil auth 173 | if err := b.b2.parseResponse(resp, result, nil); err != nil { 174 | auth.Valid = false 175 | return nil, err 176 | } 177 | 178 | if sha1Hash != result.ContentSha1 { 179 | return nil, errors.New("SHA1 of uploaded file does not match local hash") 180 | } 181 | 182 | return result, nil 183 | } 184 | 185 | // CopyFile copies file 186 | // 187 | // If destination bucket is empty, file will be copy to the current file's bucket 188 | func (b *Bucket) CopyFile(fileID, fileName, destinationBucketId string, metadataDirective FileMetadataDirective) (*File, error) { 189 | request := &fileCopyRequest{ 190 | ID: fileID, 191 | Name: fileName, 192 | MetadataDirective: metadataDirective, 193 | } 194 | 195 | if destinationBucketId != "" { 196 | request.DestinationBucketID = destinationBucketId 197 | } 198 | 199 | response := &File{} 200 | 201 | if err := b.b2.apiRequest("b2_copy_file", request, response); err != nil { 202 | return nil, err 203 | } 204 | 205 | return response, nil 206 | } 207 | 208 | // GetFileInfo retrieves information about one file stored in B2. 209 | func (b *Bucket) GetFileInfo(fileID string) (*File, error) { 210 | request := &fileRequest{ 211 | ID: fileID, 212 | } 213 | response := &File{} 214 | 215 | if err := b.b2.apiRequest("b2_get_file_info", request, response); err != nil { 216 | return nil, err 217 | } 218 | 219 | return response, nil 220 | } 221 | 222 | // DownloadFileByID downloads a file from B2 using its unique ID 223 | func (c *B2) DownloadFileByID(fileID string) (*File, io.ReadCloser, error) { 224 | return c.DownloadFileRangeByID(fileID, nil) 225 | } 226 | 227 | // DownloadFileRangeByID downloads part of a file from B2 using its unique ID and a requested byte range. 228 | func (c *B2) DownloadFileRangeByID(fileID string, fileRange *FileRange) (*File, io.ReadCloser, error) { 229 | 230 | request := &fileRequest{ 231 | ID: fileID, 232 | } 233 | 234 | if c.Debug { 235 | fmt.Println("---") 236 | fmt.Printf(" Download by ID: %s\n", fileID) 237 | fmt.Printf(" Range: %+v\n", fileRange) 238 | fmt.Printf(" Request: %+v\n", request) 239 | } 240 | 241 | requestBody, err := ffjson.Marshal(request) 242 | if err != nil { 243 | return nil, nil, err 244 | } 245 | 246 | f, body, err := c.tryDownloadFileByID(requestBody, fileRange) 247 | 248 | // Retry after non-fatal errors 249 | if b2err, ok := err.(*B2Error); ok { 250 | if !b2err.IsFatal() && !c.NoRetry { 251 | return c.tryDownloadFileByID(requestBody, fileRange) 252 | } 253 | } 254 | return f, body, err 255 | } 256 | 257 | func (c *B2) tryDownloadFileByID(requestBody []byte, fileRange *FileRange) (*File, io.ReadCloser, error) { 258 | req, auth, err := c.authRequest("POST", "b2_download_file_by_id", bytes.NewReader(requestBody)) 259 | if err != nil { 260 | return nil, nil, err 261 | } 262 | 263 | if fileRange != nil { 264 | req.Header.Add("Range", fmt.Sprintf("bytes=%d-%d", fileRange.Start, fileRange.End)) 265 | } 266 | 267 | resp, err := c.httpClient.Do(req) 268 | if err != nil { 269 | return nil, nil, err 270 | } 271 | return c.downloadFile(resp, auth) 272 | } 273 | 274 | // FileURL returns a URL which may be used to download the latest version of a file. 275 | // This returned URL will only work for public buckets unless the correct authorization header is provided. 276 | func (b *Bucket) FileURL(fileName string) (string, error) { 277 | fileURL, _, err := b.internalFileURL(fileName) 278 | return fileURL, err 279 | } 280 | 281 | // The B2 authRequest method assumes we are making a call to the API endpoint, so here we need to check the 282 | // authorization again and pass it to the caller so that they can generate an authorized request if needed 283 | func (b *Bucket) internalFileURL(fileName string) (string, *authorizationState, error) { 284 | b.b2.mutex.Lock() 285 | defer b.b2.mutex.Unlock() 286 | 287 | if !b.b2.auth.isValid() { 288 | if err := b.b2.internalAuthorizeAccount(); err != nil { 289 | return "", nil, err 290 | } 291 | } 292 | return b.b2.auth.DownloadURL + "/file/" + b.Name + "/" + fileName, b.b2.auth, nil 293 | } 294 | 295 | // DownloadFileByName downloads one file by providing the name of the bucket and the name of the 296 | // file. 297 | func (b *Bucket) DownloadFileByName(fileName string) (*File, io.ReadCloser, error) { 298 | return b.DownloadFileRangeByName(fileName, nil) 299 | } 300 | 301 | // DownloadFileRangeByName downloads part of a file by providing the name of the bucket, the name of the 302 | // file, and a requested byte range 303 | func (b *Bucket) DownloadFileRangeByName(fileName string, fileRange *FileRange) (*File, io.ReadCloser, error) { 304 | 305 | if b.b2.Debug { 306 | fmt.Println("---") 307 | fmt.Printf(" Download by name: %s/%s\n", b.Name, fileName) 308 | fmt.Printf(" Range: %+v\n", fileRange) 309 | } 310 | 311 | f, body, err := b.tryDownloadFileByName(fileName, fileRange) 312 | 313 | // Retry after non-fatal errors 314 | if b2err, ok := err.(*B2Error); ok { 315 | if !b2err.IsFatal() && !b.b2.NoRetry { 316 | return b.tryDownloadFileByName(fileName, fileRange) 317 | } 318 | } 319 | return f, body, err 320 | } 321 | 322 | // ReadaheadFileByName attempts to load chunks of the file being downloaded ahead of time to improve transfer rates. 323 | // File ranges are downloaded using Content-Range requests. See DownloadFileRangeByName 324 | func (b *Bucket) ReadaheadFileByName(fileName string) (*File, io.ReadCloser, error) { 325 | resp, err := b.ListFileNames(fileName, 1) 326 | if err != nil { 327 | return nil, nil, err 328 | } 329 | if len(resp.Files) != 1 || resp.Files[0].Name != fileName { 330 | return nil, nil, fmt.Errorf("Unable to find file %s in bucket %s", fileName, b.Name) 331 | } 332 | 333 | file := &resp.Files[0].File 334 | r, err := b.b2.ReadaheadFile(file) 335 | return file, r, err 336 | } 337 | 338 | // ReadaheadFile attempts to load chunks of the file being downloaded ahead of time to improve transfer rates. 339 | // This method attempts to optimise the chunk size based on the file size and a target of 15 workers 340 | // 341 | // File ranges are downloaded using Content-Range requests. See DownloadFileRangeByName 342 | func (c *B2) ReadaheadFile(file *File) (io.ReadCloser, error) { 343 | numWorkers := 15 344 | chunkSize := int(file.ContentLength / int64(numWorkers*2)) 345 | if chunkSize < 1<<20 { 346 | chunkSize = 1 << 20 347 | } else if chunkSize > 10<<20 { 348 | chunkSize = 10 << 20 349 | } 350 | return c.ReadaheadFileOptions(file, chunkSize, numWorkers*2, numWorkers) 351 | } 352 | 353 | // ReadaheadFileOptions attempts to load chunks of the file being downloaded ahead of time to improve transfer rates. 354 | // This method extends ReadaheadFile with options to configure the chunk size, number of chunks to read ahead 355 | // and the number of workers to use. 356 | // 357 | // File ranges are downloaded using Content-Range requests. See DownloadFileRangeByName 358 | func (c *B2) ReadaheadFileOptions(file *File, chunkSize, chunkAhead, numWorkers int) (io.ReadCloser, error) { 359 | readerAt := &fileReaderAt{ 360 | b2: c, 361 | file: file, 362 | } 363 | 364 | reader := readahead.NewConcurrentReader(file.Name, readerAt, chunkSize, chunkAhead, numWorkers) 365 | return reader, nil 366 | } 367 | 368 | type fileReaderAt struct { 369 | b2 *B2 370 | file *File 371 | } 372 | 373 | func (r *fileReaderAt) ReadAt(p []byte, off int64) (n int, err error) { 374 | // Check range being requested is valid 375 | // Have we overshot? 376 | if off >= r.file.ContentLength { 377 | if r.b2.Debug { 378 | log.Printf("Requested offset %d is past end of file (%d)", off, r.file.ContentLength) 379 | } 380 | return 0, io.EOF 381 | } 382 | 383 | // Request range from B2 384 | fileRange := &FileRange{ 385 | Start: off, 386 | End: off + int64(len(p)), 387 | } 388 | if r.b2.Debug { 389 | log.Printf("Reading chunk of %d bytes at offset %d", len(p), off) 390 | } 391 | _, reader, err := r.b2.DownloadFileRangeByID(r.file.ID, fileRange) 392 | if err != nil { 393 | log.Println(err) 394 | return 0, err 395 | } 396 | defer reader.Close() 397 | 398 | // Read chunk 399 | n, err = io.ReadFull(reader, p) 400 | if r.b2.Debug { 401 | log.Printf("Read %d bytes of %d requested at offset %d", n, len(p), off) 402 | } 403 | if err != nil { 404 | if r.b2.Debug { 405 | log.Println(err) 406 | } 407 | } 408 | // Handle last chunk 409 | if off+int64(len(p)) > r.file.ContentLength { 410 | if int64(n) == r.file.ContentLength-off { 411 | err = io.EOF 412 | } 413 | } 414 | return n, err 415 | } 416 | 417 | func (b *Bucket) tryDownloadFileByName(fileName string, fileRange *FileRange) (*File, io.ReadCloser, error) { 418 | // Locate the file 419 | fileURL, auth, err := b.internalFileURL(fileName) 420 | if err != nil { 421 | return nil, nil, err 422 | } 423 | 424 | if b.b2.Debug { 425 | fmt.Printf(" Download URL: %s\n", fileURL) 426 | } 427 | 428 | // Make the download request 429 | req, err := http.NewRequest("GET", fileURL, nil) 430 | if err != nil { 431 | return nil, nil, err 432 | } 433 | req.Header.Add("Authorization", auth.AuthorizationToken) 434 | 435 | if fileRange != nil { 436 | req.Header.Add("Range", fmt.Sprintf("bytes=%d-%d", fileRange.Start, fileRange.End)) 437 | } 438 | 439 | resp, err := b.b2.httpClient.Do(req) 440 | if err != nil { 441 | return nil, nil, err 442 | } 443 | 444 | // Handle the response 445 | return b.b2.downloadFile(resp, auth) 446 | } 447 | 448 | func (c *B2) downloadFile(resp *http.Response, auth *authorizationState) (*File, io.ReadCloser, error) { 449 | success := false 450 | defer func() { 451 | if !success { 452 | resp.Body.Close() 453 | } 454 | }() 455 | 456 | if c.Debug { 457 | fmt.Printf(" Response status: %d\n", resp.StatusCode) 458 | fmt.Printf(" Headers: %+v\n", resp.Header) 459 | } 460 | 461 | switch resp.StatusCode { 462 | case 200: 463 | case 206: 464 | case 401: 465 | auth.invalidate() 466 | body, err := ioutil.ReadAll(resp.Body) 467 | if err != nil { 468 | return nil, nil, err 469 | } 470 | if err := c.parseError(body); err != nil { 471 | return nil, nil, err 472 | } 473 | return nil, nil, &B2Error{ 474 | Code: "UNAUTHORIZED", 475 | Message: "The account ID is wrong, the account does not have B2 enabled, or the application key is not valid", 476 | Status: resp.StatusCode, 477 | } 478 | default: 479 | body, err := ioutil.ReadAll(resp.Body) 480 | if err != nil { 481 | return nil, nil, err 482 | } 483 | if err := c.parseError(body); err != nil { 484 | return nil, nil, err 485 | } 486 | 487 | return nil, nil, fmt.Errorf("Unrecognised status code: %d", resp.StatusCode) 488 | } 489 | 490 | name, err := url.QueryUnescape(resp.Header.Get("X-Bz-File-Name")) 491 | if err != nil { 492 | return nil, nil, err 493 | } 494 | 495 | file := &File{ 496 | ID: resp.Header.Get("X-Bz-File-Id"), 497 | Name: name, 498 | ContentSha1: resp.Header.Get("X-Bz-Content-Sha1"), 499 | ContentType: resp.Header.Get("Content-Type"), 500 | FileInfo: make(map[string]string), 501 | } 502 | 503 | // Parse Content-Length 504 | size, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) 505 | if err != nil { 506 | return nil, nil, err 507 | } 508 | file.ContentLength = size 509 | 510 | // Parse Content-Range 511 | contentRange := resp.Header.Get("Content-Range") 512 | if contentRange != "" { 513 | var start, end, total int64 514 | _, err := fmt.Sscanf(contentRange, "bytes %d-%d/%d", &start, &end, &total) 515 | if err != nil { 516 | return nil, nil, fmt.Errorf("Unable to parse Content-Range header: %s", err) 517 | } 518 | if end-start+1 != file.ContentLength { 519 | return nil, nil, fmt.Errorf("Content-Range (%d-%d) does not match Content-Length (%d)", start, end, file.ContentLength) 520 | } 521 | } 522 | 523 | for k, v := range resp.Header { 524 | if strings.HasPrefix(k, "X-Bz-Info-") { 525 | key, err := url.QueryUnescape(k[len("X-Bz-Info-"):]) 526 | if err != nil { 527 | key = k[len("X-Bz-Info-"):] 528 | log.Printf("Unable to decode key: %q", key) 529 | } 530 | 531 | value, err := url.QueryUnescape(v[0]) 532 | if err != nil { 533 | value = v[0] 534 | log.Printf("Unable to decode value: %q", value) 535 | } 536 | file.FileInfo[key] = value 537 | } 538 | } 539 | 540 | success = true // Don't close the response body 541 | return file, resp.Body, nil 542 | } 543 | 544 | // ListFileVersions lists all of the versions of all of the files contained in 545 | // one bucket, in alphabetical order by file name, and by reverse of date/time 546 | // uploaded for versions of files with the same name. 547 | func (b *Bucket) ListFileVersions(startFileName, startFileID string, maxFileCount int) (*ListFileVersionsResponse, error) { 548 | request := &listFileVersionsRequest{ 549 | BucketID: b.ID, 550 | StartFileName: startFileName, 551 | StartFileID: startFileID, 552 | MaxFileCount: maxFileCount, 553 | } 554 | response := &ListFileVersionsResponse{} 555 | 556 | if err := b.b2.apiRequest("b2_list_file_versions", request, response); err != nil { 557 | return nil, err 558 | } 559 | 560 | return response, nil 561 | } 562 | 563 | // DeleteFileVersion deletes one version of a file from B2. 564 | // 565 | // If the version you delete is the latest version, and there are older 566 | // versions, then the most recent older version will become the current 567 | // version, and be the one that you'll get when downloading by name. See the 568 | // File Versions page for more details. 569 | func (b *Bucket) DeleteFileVersion(fileName, fileID string) (*FileStatus, error) { 570 | request := &fileVersionRequest{ 571 | Name: fileName, 572 | ID: fileID, 573 | } 574 | response := &FileStatus{} 575 | 576 | if err := b.b2.apiRequest("b2_delete_file_version", request, response); err != nil { 577 | return nil, err 578 | } 579 | 580 | return response, nil 581 | } 582 | 583 | // HideFile hides a file so that downloading by name will not find the file, 584 | // but previous versions of the file are still stored. See File Versions about 585 | // what it means to hide a file. 586 | func (b *Bucket) HideFile(fileName string) (*FileStatus, error) { 587 | request := &hideFileRequest{ 588 | BucketID: b.ID, 589 | FileName: fileName, 590 | } 591 | response := &FileStatus{} 592 | 593 | if err := b.b2.apiRequest("b2_hide_file", request, response); err != nil { 594 | return nil, err 595 | } 596 | 597 | return response, nil 598 | } 599 | -------------------------------------------------------------------------------- /files_test.go: -------------------------------------------------------------------------------- 1 | package backblaze 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "testing" 7 | ) 8 | 9 | func TestDownloadFile(T *testing.T) { 10 | 11 | accountID := "test" 12 | testFile := []byte("File contents") 13 | 14 | client, server := prepareResponses([]response{ 15 | {code: 200, body: authorizeAccountResponse{ 16 | AccountID: accountID, 17 | APIEndpoint: "http://api.url", 18 | AuthorizationToken: "testToken", 19 | DownloadURL: "http://download.url", 20 | }}, 21 | {code: 200, body: testFile}, 22 | }) 23 | defer server.Close() 24 | 25 | b2 := &B2{ 26 | Credentials: Credentials{ 27 | AccountID: accountID, 28 | ApplicationKey: "test", 29 | }, 30 | Debug: testing.Verbose(), 31 | httpClient: *client, 32 | host: server.URL, 33 | } 34 | 35 | _, reader, err := b2.DownloadFileRangeByID("fileId", &FileRange{0, 3}) 36 | if err != nil { 37 | T.Fatal(err) 38 | } else { 39 | defer reader.Close() 40 | body, err := ioutil.ReadAll(reader) 41 | if err != nil { 42 | T.Fatal(err) 43 | } 44 | if !bytes.Equal(body, testFile) { 45 | T.Errorf("Expected file contents to be [%s], saw [%s]", testFile[0:4], body) 46 | } 47 | } 48 | } 49 | 50 | func TestDownloadFileRange(T *testing.T) { 51 | 52 | accountID := "test" 53 | testFile := []byte("File contents") 54 | 55 | client, server := prepareResponses([]response{ 56 | {code: 200, body: authorizeAccountResponse{ 57 | AccountID: accountID, 58 | APIEndpoint: "http://api.url", 59 | AuthorizationToken: "testToken", 60 | DownloadURL: "http://download.url", 61 | }}, 62 | {code: 200, body: testFile[0:4], headers: map[string]string{ 63 | "Content-Range": "bytes 0-3/13", 64 | }}, 65 | }) 66 | defer server.Close() 67 | 68 | b2 := &B2{ 69 | Credentials: Credentials{ 70 | AccountID: accountID, 71 | ApplicationKey: "test", 72 | }, 73 | Debug: testing.Verbose(), 74 | httpClient: *client, 75 | host: server.URL, 76 | } 77 | 78 | _, reader, err := b2.DownloadFileRangeByID("fileId", &FileRange{0, 3}) 79 | if err != nil { 80 | T.Fatal(err) 81 | } else { 82 | defer reader.Close() 83 | body, err := ioutil.ReadAll(reader) 84 | if err != nil { 85 | T.Fatal(err) 86 | } 87 | if !bytes.Equal(body, testFile[0:4]) { 88 | T.Errorf("Expected file contents to be [%s], saw [%s]", testFile[0:4], body) 89 | } 90 | } 91 | } 92 | 93 | func TestDownloadReAuth(T *testing.T) { 94 | 95 | accountID := "test" 96 | token2 := "testToken2" 97 | testFile := "File contents" 98 | 99 | client, server := prepareResponses([]response{ 100 | {code: 200, body: authorizeAccountResponse{ 101 | AccountID: accountID, 102 | APIEndpoint: "http://api.url", 103 | AuthorizationToken: "testToken", 104 | DownloadURL: "http://download.url", 105 | }}, 106 | {code: 401, body: B2Error{ 107 | Status: 401, 108 | Code: "expired_auth_token", 109 | Message: "Authentication token expired", 110 | }}, 111 | {code: 200, body: authorizeAccountResponse{ 112 | AccountID: accountID, 113 | APIEndpoint: "http://api.url", 114 | AuthorizationToken: token2, 115 | DownloadURL: "http://download.url", 116 | }}, 117 | {code: 200, body: testFile}, 118 | }) 119 | defer server.Close() 120 | 121 | b2 := &B2{ 122 | Credentials: Credentials{ 123 | AccountID: accountID, 124 | ApplicationKey: "test", 125 | }, 126 | Debug: testing.Verbose(), 127 | httpClient: *client, 128 | host: server.URL, 129 | } 130 | 131 | _, reader, err := b2.DownloadFileByID("fileId") 132 | if err != nil { 133 | T.Fatal(err) 134 | } else { 135 | defer reader.Close() 136 | body, err := ioutil.ReadAll(reader) 137 | if err != nil { 138 | T.Fatal(err) 139 | } 140 | if string(body) != toJSON(testFile) { 141 | T.Errorf("Expected file contents to be [%s], saw [%s]", toJSON(testFile), body) 142 | } 143 | } 144 | 145 | if b2.auth.AuthorizationToken != token2 { 146 | T.Errorf("Expected auth token after re-auth to be %q, saw %q", token2, b2.auth.AuthorizationToken) 147 | } 148 | } 149 | --------------------------------------------------------------------------------