diff --git a/b2.go b/b2/b2.go similarity index 79% rename from b2.go rename to b2/b2.go index b38b86f..231c666 100644 --- a/b2.go +++ b/b2/b2.go @@ -9,7 +9,8 @@ import ( "io/ioutil" "net/http" "net/url" - "time" + "strconv" + "strings" ) // APIurl base address for the B2 API @@ -21,53 +22,19 @@ const APIsuffix = "/b2api/v1" // GoodStatus status code for a successful API call const GoodStatus = 200 +// HeaderInfoPrefix the prefix for file header info +const HeaderInfoPrefix = "X-Bz-Info-" + // ErrGeneric generic error from API var ErrGeneric = errors.New("Received invalid response from B2 API") // B2 communicates to B2 API and holds information for the connection type B2 struct { - AccountID string `json:"accountId"` - APIUrl string `json:"apiUrl"` - AuthToken string `json:"authorizationToken"` - DownloadURL string `json:"downloadUrl"` - AppKey string `json:"-"` - Upload *Upload `json:"-"` -} - -// Upload B2 upload information -type Upload struct { - BucketID string `json:"bucketId"` - UploadURL string `json:"uploadUrl"` - AuthToken string `json:"authorizationToken"` -} - -// Bucket B2 bucket type -type Bucket struct { - AccountID string `json:"accountId"` - ID string `json:"bucketId"` - Name string `json:"bucketName"` - Type string `json:"bucketType"` -} - -// FileInfo B2 file information -type FileInfo struct { - AccountID string `json:"accountId"` - ID string `json:"fileId"` - Name string `json:"fileName"` - BucketID string `json:"bucketId"` - Length int64 `json:"contentLength"` - Sha1 string `json:"contentSha1"` - Type string `json:"contentType"` - Info map[string]string `json:"fileInfo"` -} - -// FileName B2 file name -type FileName struct { - ID string `json:"fileId"` - Name string `json:"fileName"` - Action string `json:"action"` - Size int64 `json:"size"` - Timestamp int64 `json:"uploadTimestamp"` + AccountID string `json:"accountId"` + APIUrl string `json:"apiUrl"` + AuthToken string `json:"authorizationToken"` + DownloadURL string `json:"downloadUrl"` + AppKey string `json:"-"` } // Err B2 error information @@ -81,6 +48,7 @@ func (b *Err) Error() string { return fmt.Sprintf("code: '%s' status: '%d' message: '%s'", b.Code, b.Status, b.Message) } +// readResp take an http response from the B2 API and unmarshal it to the appropriate type func readResp(resp *http.Response, output interface{}) error { data, err := ioutil.ReadAll(resp.Body) if err != nil { @@ -96,6 +64,7 @@ func readResp(resp *http.Response, output interface{}) error { return nil } + // errors are generated anytime there is not a status code of GoodStatus errb2 := &Err{} err = json.Unmarshal(data, errb2) if err != nil { @@ -105,6 +74,34 @@ func readResp(resp *http.Response, output interface{}) error { return errb2 } +func (b *B2) readHeaderFileInfo(header http.Header) (*FileInfo, error) { + var err error + info := &FileInfo{conn: b} + info.AccountID = b.AccountID + info.Type = header.Get("Content-Type") + info.ID = header.Get("X-Bz-File-Id") + info.Length, err = strconv.ParseInt(header.Get("Content-Length"), 10, 64) + if err != nil { + return nil, err + } + info.Name, err = url.QueryUnescape(header.Get("X-Bz-File-Name")) + if err != nil { + return nil, err + } + info.Sha1 = header.Get("X-Bz-Content-Sha1") + + for headerName, val := range header { + if !strings.HasPrefix(headerName, HeaderInfoPrefix) { + continue + } + + // B2 does not support multiple values per header + info.Info[headerName[len(HeaderInfoPrefix):]] = val[0] + } + + return info, nil +} + // NewB2 create a new B2 API handler func NewB2(accountID string, applicationKey string) (*B2, error) { req, err := http.NewRequest("GET", APIurl+APIsuffix+"/b2_authorize_account", nil) @@ -147,7 +144,7 @@ func (b *B2) CreateBucket(bucketName string, bucketType string) (*Bucket, error) return nil, err } - bucket := &Bucket{} + bucket := &Bucket{conn: b} err = readResp(resp, bucket) if err != nil { return nil, err @@ -178,7 +175,7 @@ func (b *B2) DeleteBucket(bucketID string) (*Bucket, error) { return nil, err } - bucket := &Bucket{} + bucket := &Bucket{conn: b} err = readResp(resp, bucket) if err != nil { @@ -218,60 +215,8 @@ func (b *B2) GetUploadURL(bucketID string) (*Upload, error) { return upload, nil } -// UploadFile uploads one file to B2 -func (b *B2) UploadFile(data io.Reader, fileName string, fileSize int64, contentType string, sha1 string, mtime *time.Time, info map[string]string) (*FileInfo, error) { - if b.Upload == nil { - return nil, errors.New("Must run GetUploadURL and set B2.Upload to upload") - } - - req, err := http.NewRequest("POST", b.Upload.UploadURL, data) - if err != nil { - return nil, err - } - - req.ContentLength = fileSize - - if contentType == "" { - contentType = "b2/x-auto" - } - - fileEncoded, err := url.Parse(fileName) - if err != nil { - return nil, err - } - - fileName = fileEncoded.String() - - req.Header.Add("Authorization", b.Upload.AuthToken) - req.Header.Add("X-Bz-File-Name", fileName) - req.Header.Add("Content-Type", contentType) - req.Header.Add("X-Bz-Content-Sha1", sha1) - if mtime != nil { - req.Header.Add("X-Bz-Info-src_last_modified_millis", fmt.Sprint(mtime.UnixNano()/1000000)) - } - - if info != nil { - for name, value := range info { - req.Header.Add("X-Bz-Info-"+name, value) - } - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - - fileInfo := &FileInfo{} - err = readResp(resp, fileInfo) - if err != nil { - return nil, err - } - - return fileInfo, nil -} - // DownloadFileByID Downloads one file from B2 -func (b *B2) DownloadFileByID(fileID string, output io.Writer) (http.Header, error) { +func (b *B2) DownloadFileByID(fileID string, output io.Writer) (*FileInfo, error) { req, err := http.NewRequest("GET", b.DownloadURL+APIsuffix+"/b2_download_file_by_id", nil) if err != nil { return nil, err @@ -299,11 +244,11 @@ func (b *B2) DownloadFileByID(fileID string, output io.Writer) (http.Header, err return nil, err } - return resp.Header, nil + return b.readHeaderFileInfo(resp.Header) } // DownloadFileByName downloads one file by providing the name of the bucket and the name of the file -func (b *B2) DownloadFileByName(bucketName string, fileName string, output io.Writer) (http.Header, error) { +func (b *B2) DownloadFileByName(bucketName string, fileName string, output io.Writer) (*FileInfo, error) { urlFileName, err := url.Parse(fileName) if err != nil { return nil, err @@ -332,7 +277,7 @@ func (b *B2) DownloadFileByName(bucketName string, fileName string, output io.Wr return nil, err } - return resp.Header, nil + return b.readHeaderFileInfo(resp.Header) } // UpdateBucket update an existing bucket @@ -358,7 +303,7 @@ func (b *B2) UpdateBucket(bucketID string, bucketType string) (*Bucket, error) { return nil, err } - bucket := &Bucket{} + bucket := &Bucket{conn: b} err = readResp(resp, bucket) if err != nil { return nil, err @@ -389,7 +334,7 @@ func (b *B2) DeleteFileVersion(fileName string, fileID string) (*FileInfo, error return nil, err } - fileInfo := &FileInfo{} + fileInfo := &FileInfo{conn: b} err = readResp(resp, fileInfo) if err != nil { return nil, err @@ -427,10 +372,14 @@ func (b *B2) ListBuckets() ([]Bucket, error) { return nil, err } + for i := range buckets.Buckets { + buckets.Buckets[i].conn = b + } + return buckets.Buckets, nil } -// ListFileNames Lists the names of all files in a bucket, starting at a given name +// ListFileNames Lists the names of all files in a bucket, starting at a given name func (b *B2) ListFileNames(bucketID string, startFileName string, maxFileCount int) ([]FileName, string, error) { data, err := json.Marshal(struct { BucketID string `json:"bucketId"` @@ -465,6 +414,10 @@ func (b *B2) ListFileNames(bucketID string, startFileName string, maxFileCount i return nil, "", err } + for i := range list.Files { + list.Files[i].conn = b + } + return list.Files, list.NextFileName, nil } @@ -506,6 +459,10 @@ func (b *B2) ListFileVersions(bucketID string, startFileName string, startFileID return nil, "", "", err } + for i := range list.Files { + list.Files[i].conn = b + } + return list.Files, list.NextFileID, list.NextFileName, nil } @@ -529,7 +486,7 @@ func (b *B2) GetFileInfo(fileID string) (*FileInfo, error) { return nil, err } - info := &FileInfo{} + info := &FileInfo{conn: b} err = readResp(resp, info) if err != nil { return nil, err @@ -559,7 +516,7 @@ func (b *B2) HideFile(bucketID string, fileName string) (*FileName, error) { return nil, err } - info := &FileName{} + info := &FileName{conn: b} err = readResp(resp, info) if err != nil { return nil, err diff --git a/b2/bucket.go b/b2/bucket.go new file mode 100644 index 0000000..af9c695 --- /dev/null +++ b/b2/bucket.go @@ -0,0 +1,65 @@ +package b2 + +import ( + "io" + "time" +) + +// Bucket B2 bucket type +type Bucket struct { + AccountID string `json:"accountId"` + ID string `json:"bucketId"` + Name string `json:"bucketName"` + Type string `json:"bucketType"` + conn *B2 + upload *Upload +} + +// Delete deletes this bucket +func (b *Bucket) Delete() error { + _, err := b.conn.DeleteBucket(b.ID) + return err +} + +// Update updates this bucket +func (b *Bucket) Update(bucketType string) error { + bucket, err := b.conn.UpdateBucket(b.ID, bucketType) + if err != nil { + return err + } + + b.AccountID = bucket.AccountID + b.ID = bucket.ID + b.Name = bucket.Name + b.Type = bucket.Type + + return nil +} + +// ListFileNames Lists the names of all files in a bucket, starting a given name +func (b *Bucket) ListFileNames(startFileName string, maxFileCount int) ([]FileName, string, error) { + return b.conn.ListFileNames(b.ID, startFileName, maxFileCount) +} + +// ListFileVersions lists all of the versions of all of the files contained in one bucket, in alphabetical order by file name, and by reverse of date/time uploaded for versions of files with the same name +func (b *Bucket) ListFileVersions(startFileName string, startFileID string, maxFileCount int) ([]FileName, string, string, error) { + return b.conn.ListFileVersions(b.ID, startFileName, startFileID, maxFileCount) +} + +// HideFile hides a file so that downloading by name will not find the file, but previous versions of the file are still stored. See File Versions about what it means to hide a file +func (b *Bucket) HideFile(fileName string) (*FileName, error) { + return b.conn.HideFile(b.ID, fileName) +} + +// UploadFile uploads one file to B2 +func (b *Bucket) UploadFile(data io.Reader, fileName string, fileSize int64, contentType string, sha1 string, mtime *time.Time, info map[string]string) (*FileInfo, error) { + if b.upload == nil { + var err error + b.upload, err = b.conn.GetUploadURL(b.ID) + if err != nil { + return nil, err + } + } + + return b.upload.UploadFile(data, fileName, fileSize, contentType, sha1, mtime, info) +} diff --git a/b2/fileinfo.go b/b2/fileinfo.go new file mode 100644 index 0000000..c6e80fd --- /dev/null +++ b/b2/fileinfo.go @@ -0,0 +1,33 @@ +package b2 + +import ( + "io" +) + +// FileInfo B2 file information +type FileInfo struct { + AccountID string `json:"accountId"` + ID string `json:"fileId"` + Name string `json:"fileName"` + BucketID string `json:"bucketId"` + Length int64 `json:"contentLength"` + Sha1 string `json:"contentSha1"` + Type string `json:"contentType"` + Info map[string]string `json:"fileInfo"` + conn *B2 +} + +// Download downloads this file ID's content +func (f *FileInfo) Download(output io.Writer) (*FileInfo, error) { + return f.conn.DownloadFileByID(f.ID, output) +} + +// Delete deletes this version of the file +func (f *FileInfo) Delete() (*FileInfo, error) { + return f.conn.DeleteFileVersion(f.Name, f.ID) +} + +// Hide hides a file so that downloading by name will not find the file, but previous versions of the file are still stored. See File Versions about what it means to hide a file +func (f *FileInfo) Hide() (*FileName, error) { + return f.conn.HideFile(f.BucketID, f.Name) +} diff --git a/b2/filename.go b/b2/filename.go new file mode 100644 index 0000000..6f4da90 --- /dev/null +++ b/b2/filename.go @@ -0,0 +1,16 @@ +package b2 + +// FileName B2 file name +type FileName struct { + ID string `json:"fileId"` + Name string `json:"fileName"` + Action string `json:"action"` + Size int64 `json:"size"` + Timestamp int64 `json:"uploadTimestamp"` + conn *B2 +} + +// GetFileInfo Gets information about one file stored in B2 +func (f *FileName) GetFileInfo() (*FileInfo, error) { + return f.conn.GetFileInfo(f.ID) +} diff --git a/b2/upload.go b/b2/upload.go new file mode 100644 index 0000000..2dc496d --- /dev/null +++ b/b2/upload.go @@ -0,0 +1,69 @@ +package b2 + +import ( + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// Upload B2 upload information +type Upload struct { + BucketID string `json:"bucketId"` + UploadURL string `json:"uploadUrl"` + AuthToken string `json:"authorizationToken"` +} + +// UploadFile uploads one file to B2 +func (u *Upload) UploadFile(data io.Reader, fileName string, fileSize int64, contentType string, sha1 string, mtime *time.Time, info map[string]string) (*FileInfo, error) { + req, err := http.NewRequest("POST", u.UploadURL, data) + if err != nil { + return nil, err + } + + // content length is necessary for buffers like os.File + req.ContentLength = fileSize + + // use B2's autodetect content type if one is not passed + if contentType == "" { + contentType = "b2/x-auto" + } + + // encode fileName via URL encoding per B2's documentation + fileEncoded, err := url.Parse(fileName) + if err != nil { + return nil, err + } + + fileName = fileEncoded.String() + + req.Header.Add("Authorization", u.AuthToken) + req.Header.Add("X-Bz-File-Name", fileName) + req.Header.Add("Content-Type", contentType) + req.Header.Add("X-Bz-Content-Sha1", sha1) + + // B2 requires time to be in UNIX milliseconds + if mtime != nil { + req.Header.Add("X-Bz-Info-src_last_modified_millis", fmt.Sprint(mtime.UnixNano()/1000000)) + } + + if info != nil { + for name, value := range info { + req.Header.Add("X-Bz-Info-"+name, value) + } + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + fileInfo := &FileInfo{} + err = readResp(resp, fileInfo) + if err != nil { + return nil, err + } + + return fileInfo, nil +}