mirror of
https://github.com/mickael-kerjean/filestash.git
synced 2025-10-27 19:53:41 +08:00
578 lines
13 KiB
Go
578 lines
13 KiB
Go
package plg_backend_backblaze
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha1"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
. "github.com/mickael-kerjean/filestash/server/common"
|
|
)
|
|
|
|
var backblaze_cache AppCache
|
|
|
|
type Backblaze struct {
|
|
params map[string]string
|
|
Buckets map[string]string
|
|
ApiUrl string `json:"apiUrl"`
|
|
DownloadUrl string `json:"downloadUrl"`
|
|
AccountId string `json:"accountId"`
|
|
Token string `json:"authorizationToken"`
|
|
Status int `json:"status"`
|
|
}
|
|
|
|
type BackblazeError struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Status int `json:"status"`
|
|
}
|
|
|
|
func init() {
|
|
Backend.Register("backblaze", Backblaze{})
|
|
backblaze_cache = NewAppCache()
|
|
}
|
|
|
|
func (this Backblaze) Init(params map[string]string, app *App) (IBackend, error) {
|
|
this.params = params
|
|
|
|
// By default backblaze required quite a few API calls to just find the data that's under a given bucket
|
|
// This would result in a slow application hence we are caching everyting that's in the hot path
|
|
if obj := backblaze_cache.Get(params); obj != nil {
|
|
return obj.(*Backblaze), nil
|
|
}
|
|
|
|
// To perform some query, we need to first know things like where we will have to query, get a token, ...
|
|
res, err := this.request("GET", "https://api.backblazeb2.com/b2api/v2/b2_authorize_account", nil, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body, err := io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(body, &this); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Extract bucket related information as backblaze use bucketId as an identifer
|
|
// BucketId is just some internal ref as people expect to see the bucketName
|
|
res, err = this.request(
|
|
"POST",
|
|
this.ApiUrl+"/b2api/v2/b2_list_buckets",
|
|
strings.NewReader(fmt.Sprintf(
|
|
`{"accountId":"%s"}`,
|
|
this.AccountId,
|
|
)),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body, err = io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var buckets struct {
|
|
Buckets []struct {
|
|
BucketId string `json:"bucketId"`
|
|
BucketName string `json:"bucketName"`
|
|
} `json:"buckets"`
|
|
}
|
|
if err = json.Unmarshal(body, &buckets); err != nil {
|
|
return nil, err
|
|
}
|
|
this.Buckets = make(map[string]string, len(buckets.Buckets))
|
|
for i := range buckets.Buckets {
|
|
this.Buckets[buckets.Buckets[i].BucketName] = buckets.Buckets[i].BucketId
|
|
}
|
|
delete(params, "password")
|
|
backblaze_cache.Set(params, &this)
|
|
return this, nil
|
|
}
|
|
|
|
func (this Backblaze) LoginForm() Form {
|
|
return Form{
|
|
Elmnts: []FormElement{
|
|
FormElement{
|
|
Name: "type",
|
|
Type: "hidden",
|
|
Value: "backblaze",
|
|
},
|
|
FormElement{
|
|
Name: "username",
|
|
Type: "text",
|
|
Placeholder: "KeyID",
|
|
},
|
|
FormElement{
|
|
Name: "password",
|
|
Type: "password",
|
|
Placeholder: "applicationKey",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (this Backblaze) Ls(path string) ([]os.FileInfo, error) {
|
|
if path == "/" {
|
|
files := make([]os.FileInfo, 0, len(this.Buckets))
|
|
for key := range this.Buckets {
|
|
files = append(files, File{
|
|
FName: key,
|
|
FType: "directory",
|
|
})
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// prepare the query
|
|
p := this.path(path)
|
|
reqJSON, _ := json.Marshal(struct {
|
|
BucketId string `json:"bucketId"`
|
|
Delimiter string `json:"delimiter"`
|
|
MaxFileCount int `json:"maxFileCount"`
|
|
Prefix string `json:"prefix"`
|
|
}{p.BucketId, "/", 10000, p.Prefix})
|
|
res, err := this.request(
|
|
"POST",
|
|
this.ApiUrl+"/b2api/v2/b2_list_file_names",
|
|
bytes.NewReader(reqJSON),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body, err := io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resBody struct {
|
|
Files []struct {
|
|
FType string `json:"action"`
|
|
Size int64 `json:"contentLength"`
|
|
Name string `json:"fileName"`
|
|
Time int64 `json:"uploadTimestamp"`
|
|
} `json:"files"`
|
|
}
|
|
if err = json.Unmarshal(body, &resBody); err != nil {
|
|
return nil, err
|
|
}
|
|
files := make([]os.FileInfo, len(resBody.Files))
|
|
for i := range resBody.Files {
|
|
files[i] = File{
|
|
FName: strings.TrimSuffix(strings.TrimPrefix(resBody.Files[i].Name, p.Prefix), "/"),
|
|
FType: func() string {
|
|
if resBody.Files[i].FType == "folder" {
|
|
return "directory"
|
|
}
|
|
return "file"
|
|
}(),
|
|
FSize: resBody.Files[i].Size,
|
|
FTime: resBody.Files[i].Time / 1000,
|
|
}
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func (this Backblaze) Cat(path string) (io.ReadCloser, error) {
|
|
res, err := this.request(
|
|
"GET",
|
|
this.DownloadUrl+"/file"+path+"?Authorization="+this.Token,
|
|
nil, nil,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return res.Body, nil
|
|
}
|
|
|
|
func (this Backblaze) Mkdir(path string) error {
|
|
p := this.path(path)
|
|
|
|
if p.BucketId == "" {
|
|
bucketName := ""
|
|
if bp := strings.Split(path, "/"); len(bp) > 1 {
|
|
bucketName = bp[1]
|
|
}
|
|
if bucketName == "" {
|
|
return ErrNotValid
|
|
}
|
|
res, err := this.request(
|
|
"POST",
|
|
this.ApiUrl+"/b2api/v2/b2_create_bucket",
|
|
strings.NewReader(fmt.Sprintf(
|
|
`{"accountId": "%s", "bucketName": "%s", "bucketType": "allPrivate"}`,
|
|
this.AccountId,
|
|
bucketName,
|
|
)),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err := io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var resError BackblazeError
|
|
if err := json.Unmarshal(body, &resError); err != nil {
|
|
return err
|
|
}
|
|
if resError.Message != "" {
|
|
return NewError(resError.Message, resError.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return this.Touch(path + ".bzEmpty")
|
|
}
|
|
|
|
func (this Backblaze) Rm(path string) error {
|
|
p := this.path(path)
|
|
if p.BucketId == "" {
|
|
return ErrNotValid
|
|
}
|
|
if p.Prefix == "" {
|
|
backblaze_cache.Del(this.params) // cache invalidation
|
|
res, err := this.request(
|
|
"POST",
|
|
this.ApiUrl+"/b2api/v2/b2_delete_bucket",
|
|
strings.NewReader(fmt.Sprintf(
|
|
`{"accountId": "%s", "bucketId": "%s"}`,
|
|
this.AccountId,
|
|
p.BucketId,
|
|
)),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err := io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var resError BackblazeError
|
|
if err := json.Unmarshal(body, &resError); err != nil {
|
|
return err
|
|
}
|
|
if resError.Message != "" {
|
|
return NewError(resError.Message, resError.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Backblaze doesn't provide a recursive API to delete => requires multiple steps
|
|
// Step 1: find every files in a folder: b2_list_file_names
|
|
res, err := this.request(
|
|
"POST",
|
|
this.ApiUrl+"/b2api/v2/b2_list_file_names",
|
|
strings.NewReader(fmt.Sprintf(
|
|
`{"bucketId": "%s", "maxFileCount": 10000, "delimiter": "/", "prefix": "%s"}`,
|
|
p.BucketId, p.Prefix,
|
|
)),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err := io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
bRes := struct {
|
|
Files []struct {
|
|
FileId string `json:"fileId"`
|
|
FileName string `json:"fileName"`
|
|
} `json:"files"`
|
|
}{}
|
|
if err = json.Unmarshal(body, &bRes); err != nil {
|
|
return err
|
|
}
|
|
// Step 2: delete files 1 by 1: b2_delete_file_version
|
|
for i := range bRes.Files {
|
|
res, err := this.request(
|
|
"POST",
|
|
this.ApiUrl+"/b2api/v2/b2_delete_file_version",
|
|
strings.NewReader(fmt.Sprintf(
|
|
`{"fileName": "%s", "fileId": "%s"}`,
|
|
bRes.Files[i].FileName, bRes.Files[i].FileId,
|
|
)),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if body, err = io.ReadAll(res.Body); err != nil {
|
|
return err
|
|
}
|
|
res.Body.Close()
|
|
var resError BackblazeError
|
|
if err := json.Unmarshal(body, &resError); err != nil {
|
|
return err
|
|
}
|
|
if resError.Message != "" {
|
|
return NewError(resError.Message, resError.Status)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (this Backblaze) Mv(from string, to string) error {
|
|
return ErrNotSupported
|
|
}
|
|
|
|
func (this Backblaze) Touch(path string) error {
|
|
p := this.path(path)
|
|
|
|
// Step 1: get the URL we will proceed to the upload
|
|
res, err := this.request(
|
|
"POST",
|
|
this.ApiUrl+"/b2api/v2/b2_get_upload_url",
|
|
strings.NewReader(fmt.Sprintf(`{"bucketId": "%s"}`, p.BucketId)),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err := io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var resBody struct {
|
|
UploadUrl string `json:"uploadUrl"`
|
|
Token string `json:"authorizationToken"`
|
|
}
|
|
if err := json.Unmarshal(body, &resBody); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Step 2: perform the upload of the empty file
|
|
res, err = this.request(
|
|
"POST",
|
|
resBody.UploadUrl,
|
|
nil,
|
|
func(r *http.Request) {
|
|
r.Header.Set("Authorization", resBody.Token)
|
|
r.Header.Set("X-Bz-File-Name", url.QueryEscape(p.Prefix))
|
|
r.Header.Set("Content-Type", "application/octet-stream")
|
|
r.Header.Set("Content-Length", "0")
|
|
r.Header.Set("X-Bz-Content-Sha1", "da39a3ee5e6b4b0d3255bfef95601890afd80709")
|
|
},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err = io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var resError BackblazeError
|
|
if err := json.Unmarshal(body, &resError); err != nil {
|
|
return err
|
|
}
|
|
if resError.Message != "" {
|
|
return NewError(resError.Message, resError.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (this Backblaze) Save(path string, file io.Reader) error {
|
|
p := this.path(path)
|
|
|
|
// Step 1: get the URL we will proceed to the upload
|
|
res, err := this.request(
|
|
"POST",
|
|
this.ApiUrl+"/b2api/v2/b2_get_upload_url",
|
|
strings.NewReader(fmt.Sprintf(`{"bucketId": "%s"}`, p.BucketId)),
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err := io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var resBody struct {
|
|
UploadUrl string `json:"uploadUrl"`
|
|
Token string `json:"authorizationToken"`
|
|
}
|
|
if err := json.Unmarshal(body, &resBody); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Step 2: get details backblaze requires to perform the upload
|
|
backblazeFileDetail := struct {
|
|
path string
|
|
ContentLength int64
|
|
Sha1 []byte
|
|
}{}
|
|
backblazeFileDetail.path = filepath.Join(
|
|
GetAbsolutePath(TMP_PATH),
|
|
"data_"+QuickString(20)+".dat",
|
|
)
|
|
f, err := os.OpenFile(backblazeFileDetail.path, os.O_CREATE|os.O_RDWR, os.ModePerm)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
defer os.Remove(backblazeFileDetail.path)
|
|
io.Copy(f, file)
|
|
if obj, ok := file.(io.Closer); ok {
|
|
obj.Close()
|
|
}
|
|
s, err := f.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
backblazeFileDetail.ContentLength = s.Size()
|
|
f.Seek(0, io.SeekStart)
|
|
h := sha1.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return err
|
|
}
|
|
backblazeFileDetail.Sha1 = h.Sum(nil)
|
|
|
|
// Step 3: perform the upload
|
|
f.Seek(0, io.SeekStart)
|
|
res, err = this.request(
|
|
"POST",
|
|
resBody.UploadUrl,
|
|
f,
|
|
func(r *http.Request) {
|
|
r.ContentLength = backblazeFileDetail.ContentLength
|
|
r.Header.Set("Authorization", resBody.Token)
|
|
r.Header.Set("X-Bz-File-Name", url.QueryEscape(p.Prefix))
|
|
r.Header.Set("Content-Type", "application/octet-stream")
|
|
r.Header.Set("X-Bz-Content-Sha1", fmt.Sprintf("%x", backblazeFileDetail.Sha1))
|
|
},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err = io.ReadAll(res.Body)
|
|
res.Body.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var resError BackblazeError
|
|
if err := json.Unmarshal(body, &resError); err != nil {
|
|
return err
|
|
}
|
|
if resError.Message != "" {
|
|
return NewError(resError.Message, resError.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (this Backblaze) Meta(path string) Metadata {
|
|
m := Metadata{
|
|
CanRename: NewBool(false),
|
|
CanMove: NewBool(false),
|
|
}
|
|
if path == "/" {
|
|
m.CanCreateFile = NewBool(false)
|
|
m.CanUpload = NewBool(false)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (this Backblaze) request(method string, url string, body io.Reader, fn func(req *http.Request)) (*http.Response, error) {
|
|
req, err := http.NewRequest(method, url, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if this.Token == "" {
|
|
req.Header.Set(
|
|
"Authorization",
|
|
fmt.Sprintf(
|
|
"Basic %s",
|
|
base64.StdEncoding.EncodeToString(
|
|
[]byte(fmt.Sprintf("%s:%s", this.params["username"], this.params["password"])),
|
|
),
|
|
),
|
|
)
|
|
} else {
|
|
req.Header.Set("Authorization", this.Token)
|
|
}
|
|
req.Header.Set("User-Agent", "Filestash "+APP_VERSION+"."+BUILD_DATE)
|
|
req.Header.Set("Accept", "application/json")
|
|
//req.Header.Set("X-Bz-Test-Mode", "force_cap_exceeded")
|
|
if fn != nil {
|
|
fn(req)
|
|
}
|
|
if req.Body != nil {
|
|
defer req.Body.Close()
|
|
}
|
|
|
|
res, err := HTTPClient.Do(req)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
|
|
if res.StatusCode == 401 {
|
|
res.Body.Close()
|
|
return res, ErrAuthenticationFailed
|
|
} else if res.StatusCode == 403 {
|
|
res.Body.Close()
|
|
return res, ErrNotAllowed
|
|
} else if res.StatusCode == 429 {
|
|
res.Body.Close()
|
|
retryAfter, err := strconv.Atoi(res.Header.Get("Retry-After"))
|
|
if err == nil && retryAfter < 10 && retryAfter >= 0 {
|
|
time.Sleep(time.Duration(retryAfter) * time.Second)
|
|
return this.request(method, url, body, fn)
|
|
}
|
|
return res, ErrCongestion
|
|
} else if res.StatusCode == 503 {
|
|
res.Body.Close()
|
|
retryAfter, err := strconv.Atoi(res.Header.Get("Retry-After"))
|
|
if err == nil && retryAfter < 10 && retryAfter >= 0 {
|
|
time.Sleep(time.Duration(retryAfter) * time.Second)
|
|
return this.request(method, url, body, fn)
|
|
}
|
|
return res, ErrCongestion
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
type BackblazePath struct {
|
|
BucketId string
|
|
Prefix string
|
|
}
|
|
|
|
func (this Backblaze) path(path string) BackblazePath {
|
|
bp := strings.Split(path, "/")
|
|
bucket := ""
|
|
if len(bp) > 1 {
|
|
bucket = bp[1]
|
|
}
|
|
prefix := ""
|
|
if len(bp) > 2 {
|
|
prefix = strings.Join(bp[2:], "/")
|
|
}
|
|
|
|
return BackblazePath{
|
|
this.Buckets[bucket],
|
|
prefix,
|
|
}
|
|
}
|