mirror of https://github.com/grafana/grafana
parent
1108101087
commit
af15e3c0d0
@ -0,0 +1,320 @@ |
||||
package imguploader |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"crypto/hmac" |
||||
"crypto/sha256" |
||||
"encoding/base64" |
||||
"encoding/xml" |
||||
"fmt" |
||||
"io" |
||||
"io/ioutil" |
||||
"mime" |
||||
"net/http" |
||||
"net/url" |
||||
"os" |
||||
"path" |
||||
"sort" |
||||
"strconv" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/grafana/grafana/pkg/log" |
||||
"github.com/grafana/grafana/pkg/util" |
||||
) |
||||
|
||||
type AzureBlobUploader struct { |
||||
account_name string |
||||
account_key string |
||||
container_name string |
||||
log log.Logger |
||||
} |
||||
|
||||
func NewAzureBlobUploader(account_name string, account_key string, container_name string) *AzureBlobUploader { |
||||
return &AzureBlobUploader{ |
||||
account_name: account_name, |
||||
account_key: account_key, |
||||
container_name: container_name, |
||||
log: log.New("azureBlobUploader"), |
||||
} |
||||
} |
||||
|
||||
// Receive path of image on disk and return azure blob url
|
||||
func (az *AzureBlobUploader) Upload(ctx context.Context, imageDiskPath string) (string, error) { |
||||
// setup client
|
||||
blob := NewStorageClient(az.account_name, az.account_key) |
||||
|
||||
file, err := os.Open(imageDiskPath) |
||||
|
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
randomFileName := util.GetRandomString(30) + ".png" |
||||
// upload image
|
||||
az.log.Debug("Uploading image to azure_blob", "conatiner_name", az.container_name, "blob_name", randomFileName) |
||||
resp, err := blob.FileUpload(az.container_name, randomFileName, file) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
if resp.StatusCode > 400 && resp.StatusCode < 600 { |
||||
body, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
||||
aerr := &Error{ |
||||
Code: resp.StatusCode, |
||||
Status: resp.Status, |
||||
Body: body, |
||||
Header: resp.Header, |
||||
} |
||||
aerr.parseXML() |
||||
resp.Body.Close() |
||||
return "", aerr |
||||
} |
||||
|
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
url := fmt.Sprintf("https://%s.blob.core.windows.net/%s/%s", az.account_name, az.container_name, randomFileName) |
||||
return url, nil |
||||
} |
||||
|
||||
// --- AZURE LIBRARY
|
||||
type Blobs struct { |
||||
XMLName xml.Name `xml:"EnumerationResults"` |
||||
Items []Blob `xml:"Blobs>Blob"` |
||||
} |
||||
|
||||
type Blob struct { |
||||
Name string `xml:"Name"` |
||||
Property Property `xml:"Properties"` |
||||
} |
||||
|
||||
type Property struct { |
||||
LastModified string `xml:"Last-Modified"` |
||||
Etag string `xml:"Etag"` |
||||
ContentLength int `xml:"Content-Length"` |
||||
ContentType string `xml:"Content-Type"` |
||||
BlobType string `xml:"BlobType"` |
||||
LeaseStatus string `xml:"LeaseStatus"` |
||||
} |
||||
|
||||
type Error struct { |
||||
Code int |
||||
Status string |
||||
Body []byte |
||||
Header http.Header |
||||
|
||||
AzureCode string |
||||
} |
||||
|
||||
func (e *Error) Error() string { |
||||
return fmt.Sprintf("status %d: %s", e.Code, e.Body) |
||||
} |
||||
|
||||
func (e *Error) parseXML() { |
||||
var xe xmlError |
||||
_ = xml.NewDecoder(bytes.NewReader(e.Body)).Decode(&xe) |
||||
e.AzureCode = xe.Code |
||||
} |
||||
|
||||
type xmlError struct { |
||||
XMLName xml.Name `xml:"Error"` |
||||
Code string |
||||
Message string |
||||
} |
||||
|
||||
const ms_date_layout = "Mon, 02 Jan 2006 15:04:05 GMT" |
||||
const version = "2017-04-17" |
||||
|
||||
var client = &http.Client{} |
||||
|
||||
type StorageClient struct { |
||||
Auth *Auth |
||||
Transport http.RoundTripper |
||||
} |
||||
|
||||
func (c *StorageClient) transport() http.RoundTripper { |
||||
if c.Transport != nil { |
||||
return c.Transport |
||||
} |
||||
return http.DefaultTransport |
||||
} |
||||
|
||||
func NewStorageClient(account, accessKey string) *StorageClient { |
||||
return &StorageClient{ |
||||
Auth: &Auth{ |
||||
account, |
||||
accessKey, |
||||
}, |
||||
Transport: nil, |
||||
} |
||||
} |
||||
|
||||
func (c *StorageClient) absUrl(format string, a ...interface{}) string { |
||||
part := fmt.Sprintf(format, a...) |
||||
return fmt.Sprintf("https://%s.blob.core.windows.net/%s", c.Auth.Account, part) |
||||
} |
||||
|
||||
func copyHeadersToRequest(req *http.Request, headers map[string]string) { |
||||
for k, v := range headers { |
||||
req.Header[k] = []string{v} |
||||
} |
||||
} |
||||
|
||||
func (c *StorageClient) FileUpload(container, blobName string, body io.Reader) (*http.Response, error) { |
||||
blobName = escape(blobName) |
||||
extension := strings.ToLower(path.Ext(blobName)) |
||||
contentType := mime.TypeByExtension(extension) |
||||
buf := new(bytes.Buffer) |
||||
buf.ReadFrom(body) |
||||
req, err := http.NewRequest( |
||||
"PUT", |
||||
c.absUrl("%s/%s", container, blobName), |
||||
buf, |
||||
) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
copyHeadersToRequest(req, map[string]string{ |
||||
"x-ms-blob-type": "BlockBlob", |
||||
"x-ms-date": time.Now().UTC().Format(ms_date_layout), |
||||
"x-ms-version": version, |
||||
"Accept-Charset": "UTF-8", |
||||
"Content-Type": contentType, |
||||
"Content-Length": strconv.Itoa(buf.Len()), |
||||
}) |
||||
|
||||
c.Auth.SignRequest(req) |
||||
|
||||
return c.transport().RoundTrip(req) |
||||
} |
||||
|
||||
func escape(content string) string { |
||||
content = url.QueryEscape(content) |
||||
// the Azure's behavior uses %20 to represent whitespace instead of + (plus)
|
||||
content = strings.Replace(content, "+", "%20", -1) |
||||
// the Azure's behavior uses slash instead of + %2F
|
||||
content = strings.Replace(content, "%2F", "/", -1) |
||||
|
||||
return content |
||||
} |
||||
|
||||
type Auth struct { |
||||
Account string |
||||
Key string |
||||
} |
||||
|
||||
func (a *Auth) SignRequest(req *http.Request) { |
||||
strToSign := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s", |
||||
strings.ToUpper(req.Method), |
||||
tryget(req.Header, "Content-Encoding"), |
||||
tryget(req.Header, "Content-Language"), |
||||
tryget(req.Header, "Content-Length"), |
||||
tryget(req.Header, "Content-MD5"), |
||||
tryget(req.Header, "Content-Type"), |
||||
tryget(req.Header, "Date"), |
||||
tryget(req.Header, "If-Modified-Since"), |
||||
tryget(req.Header, "If-Match"), |
||||
tryget(req.Header, "If-None-Match"), |
||||
tryget(req.Header, "If-Unmodified-Since"), |
||||
tryget(req.Header, "Range"), |
||||
a.canonicalizedHeaders(req), |
||||
a.canonicalizedResource(req), |
||||
) |
||||
decodedKey, _ := base64.StdEncoding.DecodeString(a.Key) |
||||
|
||||
sha256 := hmac.New(sha256.New, []byte(decodedKey)) |
||||
sha256.Write([]byte(strToSign)) |
||||
|
||||
signature := base64.StdEncoding.EncodeToString(sha256.Sum(nil)) |
||||
|
||||
copyHeadersToRequest(req, map[string]string{ |
||||
"Authorization": fmt.Sprintf("SharedKey %s:%s", a.Account, signature), |
||||
}) |
||||
} |
||||
|
||||
func tryget(headers map[string][]string, key string) string { |
||||
// We default to empty string for "0" values to match server side behavior when generating signatures.
|
||||
if len(headers[key]) > 0 { // && headers[key][0] != "0" { //&& key != "Content-Length" {
|
||||
return headers[key][0] |
||||
} |
||||
return "" |
||||
} |
||||
|
||||
//
|
||||
// The following is copied ~95% verbatim from:
|
||||
// http://github.com/loldesign/azure/ -> core/core.go
|
||||
//
|
||||
|
||||
/* |
||||
Based on Azure docs: |
||||
Link: http://msdn.microsoft.com/en-us/library/windowsazure/dd179428.aspx#Constructing_Element
|
||||
|
||||
1) Retrieve all headers for the resource that begin with x-ms-, including the x-ms-date header. |
||||
2) Convert each HTTP header name to lowercase. |
||||
3) Sort the headers lexicographically by header name, in ascending order. Note that each header may appear only once in the string. |
||||
4) Unfold the string by replacing any breaking white space with a single space. |
||||
5) Trim any white space around the colon in the header. |
||||
6) Finally, append a new line character to each canonicalized header in the resulting list. Construct the CanonicalizedHeaders string by concatenating all headers in this list into a single string. |
||||
*/ |
||||
func (a *Auth) canonicalizedHeaders(req *http.Request) string { |
||||
var buffer bytes.Buffer |
||||
|
||||
for key, value := range req.Header { |
||||
lowerKey := strings.ToLower(key) |
||||
|
||||
if strings.HasPrefix(lowerKey, "x-ms-") { |
||||
if buffer.Len() == 0 { |
||||
buffer.WriteString(fmt.Sprintf("%s:%s", lowerKey, value[0])) |
||||
} else { |
||||
buffer.WriteString(fmt.Sprintf("\n%s:%s", lowerKey, value[0])) |
||||
} |
||||
} |
||||
} |
||||
|
||||
splitted := strings.Split(buffer.String(), "\n") |
||||
sort.Strings(splitted) |
||||
|
||||
return strings.Join(splitted, "\n") |
||||
} |
||||
|
||||
/* |
||||
Based on Azure docs |
||||
Link: http://msdn.microsoft.com/en-us/library/windowsazure/dd179428.aspx#Constructing_Element
|
||||
|
||||
1) Beginning with an empty string (""), append a forward slash (/), followed by the name of the account that owns the resource being accessed. |
||||
2) Append the resource's encoded URI path, without any query parameters. |
||||
3) Retrieve all query parameters on the resource URI, including the comp parameter if it exists. |
||||
4) Convert all parameter names to lowercase. |
||||
5) Sort the query parameters lexicographically by parameter name, in ascending order. |
||||
6) URL-decode each query parameter name and value. |
||||
7) Append each query parameter name and value to the string in the following format, making sure to include the colon (:) between the name and the value: |
||||
parameter-name:parameter-value |
||||
|
||||
8) If a query parameter has more than one value, sort all values lexicographically, then include them in a comma-separated list: |
||||
parameter-name:parameter-value-1,parameter-value-2,parameter-value-n |
||||
|
||||
9) Append a new line character (\n) after each name-value pair. |
||||
|
||||
Rules: |
||||
1) Avoid using the new line character (\n) in values for query parameters. If it must be used, ensure that it does not affect the format of the canonicalized resource string. |
||||
2) Avoid using commas in query parameter values. |
||||
*/ |
||||
func (a *Auth) canonicalizedResource(req *http.Request) string { |
||||
var buffer bytes.Buffer |
||||
|
||||
buffer.WriteString(fmt.Sprintf("/%s%s", a.Account, req.URL.Path)) |
||||
queries := req.URL.Query() |
||||
|
||||
for key, values := range queries { |
||||
sort.Strings(values) |
||||
buffer.WriteString(fmt.Sprintf("\n%s:%s", key, strings.Join(values, ","))) |
||||
} |
||||
|
||||
splitted := strings.Split(buffer.String(), "\n") |
||||
sort.Strings(splitted) |
||||
|
||||
return strings.Join(splitted, "\n") |
||||
} |
||||
@ -0,0 +1,24 @@ |
||||
package imguploader |
||||
|
||||
import ( |
||||
"context" |
||||
"testing" |
||||
|
||||
"github.com/grafana/grafana/pkg/setting" |
||||
. "github.com/smartystreets/goconvey/convey" |
||||
) |
||||
|
||||
func TestUploadToAzureBlob(t *testing.T) { |
||||
SkipConvey("[Integration test] for external_image_store.azure_blob", t, func() { |
||||
err := setting.NewConfigContext(&setting.CommandLineArgs{ |
||||
HomePath: "../../../", |
||||
}) |
||||
|
||||
uploader, _ := NewImageUploader() |
||||
|
||||
path, err := uploader.Upload(context.Background(), "../../../public/img/logo_transparent_400x.png") |
||||
|
||||
So(err, ShouldBeNil) |
||||
So(path, ShouldNotEqual, "") |
||||
}) |
||||
} |
||||
Loading…
Reference in new issue