Merge branch 'master' into feature/discord
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
package dashdiffs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
diff "github.com/yudai/gojsondiff"
|
||||
deltaFormatter "github.com/yudai/gojsondiff/formatter"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUnsupportedDiffType occurs when an invalid diff type is used.
|
||||
ErrUnsupportedDiffType = errors.New("dashdiff: unsupported diff type")
|
||||
|
||||
// ErrNilDiff occurs when two compared interfaces are identical.
|
||||
ErrNilDiff = errors.New("dashdiff: diff is nil")
|
||||
|
||||
diffLogger = log.New("dashdiffs")
|
||||
)
|
||||
|
||||
type DiffType int
|
||||
|
||||
const (
|
||||
DiffJSON DiffType = iota
|
||||
DiffBasic
|
||||
DiffDelta
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
OrgId int64
|
||||
Base DiffTarget
|
||||
New DiffTarget
|
||||
DiffType DiffType
|
||||
}
|
||||
|
||||
type DiffTarget struct {
|
||||
DashboardId int64
|
||||
Version int
|
||||
UnsavedDashboard *simplejson.Json
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Delta []byte `json:"delta"`
|
||||
}
|
||||
|
||||
func ParseDiffType(diff string) DiffType {
|
||||
switch diff {
|
||||
case "json":
|
||||
return DiffJSON
|
||||
case "basic":
|
||||
return DiffBasic
|
||||
case "delta":
|
||||
return DiffDelta
|
||||
}
|
||||
return DiffBasic
|
||||
}
|
||||
|
||||
// CompareDashboardVersionsCommand computes the JSON diff of two versions,
|
||||
// assigning the delta of the diff to the `Delta` field.
|
||||
func CalculateDiff(options *Options) (*Result, error) {
|
||||
baseVersionQuery := models.GetDashboardVersionQuery{
|
||||
DashboardId: options.Base.DashboardId,
|
||||
Version: options.Base.Version,
|
||||
OrgId: options.OrgId,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(&baseVersionQuery); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newVersionQuery := models.GetDashboardVersionQuery{
|
||||
DashboardId: options.New.DashboardId,
|
||||
Version: options.New.Version,
|
||||
OrgId: options.OrgId,
|
||||
}
|
||||
|
||||
if err := bus.Dispatch(&newVersionQuery); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseData := baseVersionQuery.Result.Data
|
||||
newData := newVersionQuery.Result.Data
|
||||
|
||||
left, jsonDiff, err := getDiff(baseData, newData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &Result{}
|
||||
|
||||
switch options.DiffType {
|
||||
case DiffDelta:
|
||||
|
||||
deltaOutput, err := deltaFormatter.NewDeltaFormatter().Format(jsonDiff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Delta = []byte(deltaOutput)
|
||||
|
||||
case DiffJSON:
|
||||
jsonOutput, err := NewJSONFormatter(left).Format(jsonDiff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Delta = []byte(jsonOutput)
|
||||
|
||||
case DiffBasic:
|
||||
basicOutput, err := NewBasicFormatter(left).Format(jsonDiff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Delta = basicOutput
|
||||
|
||||
default:
|
||||
return nil, ErrUnsupportedDiffType
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getDiff computes the diff of two dashboard versions.
|
||||
func getDiff(baseData, newData *simplejson.Json) (interface{}, diff.Diff, error) {
|
||||
leftBytes, err := baseData.Encode()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
rightBytes, err := newData.Encode()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
jsonDiff, err := diff.New().Compare(leftBytes, rightBytes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !jsonDiff.Modified() {
|
||||
return nil, nil, ErrNilDiff
|
||||
}
|
||||
|
||||
left := make(map[string]interface{})
|
||||
err = json.Unmarshal(leftBytes, &left)
|
||||
return left, jsonDiff, nil
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package dashdiffs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
|
||||
diff "github.com/yudai/gojsondiff"
|
||||
)
|
||||
|
||||
// A BasicDiff holds the stateful values that are used when generating a basic
|
||||
// diff from JSON tokens.
|
||||
type BasicDiff struct {
|
||||
narrow string
|
||||
keysIdent int
|
||||
writing bool
|
||||
LastIndent int
|
||||
Block *BasicBlock
|
||||
Change *BasicChange
|
||||
Summary *BasicSummary
|
||||
}
|
||||
|
||||
// A BasicBlock represents a top-level element in a basic diff.
|
||||
type BasicBlock struct {
|
||||
Title string
|
||||
Old interface{}
|
||||
New interface{}
|
||||
Change ChangeType
|
||||
Changes []*BasicChange
|
||||
Summaries []*BasicSummary
|
||||
LineStart int
|
||||
LineEnd int
|
||||
}
|
||||
|
||||
// A BasicChange represents the change from an old to new value. There are many
|
||||
// BasicChanges in a BasicBlock.
|
||||
type BasicChange struct {
|
||||
Key string
|
||||
Old interface{}
|
||||
New interface{}
|
||||
Change ChangeType
|
||||
LineStart int
|
||||
LineEnd int
|
||||
}
|
||||
|
||||
// A BasicSummary represents the changes within a basic block that're too deep
|
||||
// or verbose to be represented in the top-level BasicBlock element, or in the
|
||||
// BasicChange. Instead of showing the values in this case, we simply print
|
||||
// the key and count how many times the given change was applied to that
|
||||
// element.
|
||||
type BasicSummary struct {
|
||||
Key string
|
||||
Change ChangeType
|
||||
Count int
|
||||
LineStart int
|
||||
LineEnd int
|
||||
}
|
||||
|
||||
type BasicFormatter struct {
|
||||
jsonDiff *JSONFormatter
|
||||
tpl *template.Template
|
||||
}
|
||||
|
||||
func NewBasicFormatter(left interface{}) *BasicFormatter {
|
||||
tpl := template.Must(template.New("block").Funcs(tplFuncMap).Parse(tplBlock))
|
||||
tpl = template.Must(tpl.New("change").Funcs(tplFuncMap).Parse(tplChange))
|
||||
tpl = template.Must(tpl.New("summary").Funcs(tplFuncMap).Parse(tplSummary))
|
||||
|
||||
return &BasicFormatter{
|
||||
jsonDiff: NewJSONFormatter(left),
|
||||
tpl: tpl,
|
||||
}
|
||||
}
|
||||
|
||||
// Format takes the diff of two JSON documents, and returns the difference
|
||||
// between them summarized in an HTML document.
|
||||
func (b *BasicFormatter) Format(d diff.Diff) ([]byte, error) {
|
||||
// calling jsonDiff.Format(d) populates the JSON diff's "Lines" value,
|
||||
// which we use to compute the basic dif
|
||||
_, err := b.jsonDiff.Format(d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bd := &BasicDiff{}
|
||||
blocks := bd.Basic(b.jsonDiff.Lines)
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
err = b.tpl.ExecuteTemplate(buf, "block", blocks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// Basic transforms a slice of JSONLines into a slice of BasicBlocks.
|
||||
func (b *BasicDiff) Basic(lines []*JSONLine) []*BasicBlock {
|
||||
// init an array you can append to for the basic "blocks"
|
||||
blocks := make([]*BasicBlock, 0)
|
||||
|
||||
for _, line := range lines {
|
||||
if b.returnToTopLevelKey(line) {
|
||||
if b.Block != nil {
|
||||
blocks = append(blocks, b.Block)
|
||||
}
|
||||
}
|
||||
|
||||
// Record the last indent level at each pass in case we need to
|
||||
// check for a change in depth inside the JSON data structures.
|
||||
b.LastIndent = line.Indent
|
||||
|
||||
if line.Indent == 1 {
|
||||
if block, ok := b.handleTopLevelChange(line); ok {
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
}
|
||||
|
||||
// Here is where we handle changes for all types, appending each change
|
||||
// to the current block based on the value.
|
||||
//
|
||||
// Values which only occupy a single line in JSON (like a string or
|
||||
// int, for example) are treated as "Basic Changes" that we append to
|
||||
// the current block as soon as they're detected.
|
||||
//
|
||||
// Values which occupy multiple lines (either slices or maps) are
|
||||
// treated as "Basic Summaries". When we detect the "ChangeNil" type,
|
||||
// we know we've encountered one of these types, so we record the
|
||||
// starting position as well the type of the change, and stop
|
||||
// performing comparisons until we find the end of that change. Upon
|
||||
// finding the change, we append it to the current block, and begin
|
||||
// performing comparisons again.
|
||||
if line.Indent > 1 {
|
||||
// check to ensure a single line change
|
||||
if b.isSingleLineChange(line) {
|
||||
switch line.Change {
|
||||
case ChangeAdded, ChangeDeleted:
|
||||
|
||||
b.Block.Changes = append(b.Block.Changes, &BasicChange{
|
||||
Key: line.Key,
|
||||
Change: line.Change,
|
||||
New: line.Val,
|
||||
LineStart: line.LineNum,
|
||||
})
|
||||
|
||||
case ChangeOld:
|
||||
b.Change = &BasicChange{
|
||||
Key: line.Key,
|
||||
Change: line.Change,
|
||||
Old: line.Val,
|
||||
LineStart: line.LineNum,
|
||||
}
|
||||
|
||||
case ChangeNew:
|
||||
b.Change.New = line.Val
|
||||
b.Change.LineEnd = line.LineNum
|
||||
b.Block.Changes = append(b.Block.Changes, b.Change)
|
||||
|
||||
default:
|
||||
//ok
|
||||
}
|
||||
|
||||
// otherwise, we're dealing with a change at a deeper level. We
|
||||
// know there's a change somewhere in the JSON tree, but we
|
||||
// don't know exactly where, so we go deeper.
|
||||
} else {
|
||||
|
||||
// if the change is anything but unchanged, continue processing
|
||||
//
|
||||
// we keep "narrowing" the key as we go deeper, in order to
|
||||
// correctly report the key name for changes found within an
|
||||
// object or array.
|
||||
if line.Change != ChangeUnchanged {
|
||||
if line.Key != "" {
|
||||
b.narrow = line.Key
|
||||
b.keysIdent = line.Indent
|
||||
}
|
||||
|
||||
// if the change isn't nil, and we're not already writing
|
||||
// out a change, then we've found something.
|
||||
//
|
||||
// First, try to determine the title of the embedded JSON
|
||||
// object. If it's an empty string, then we're in an object
|
||||
// or array, so we default to using the "narrowed" key.
|
||||
//
|
||||
// We also start recording the basic summary, until we find
|
||||
// the next `ChangeUnchanged`.
|
||||
if line.Change != ChangeNil {
|
||||
if !b.writing {
|
||||
b.writing = true
|
||||
key := b.Block.Title
|
||||
|
||||
if b.narrow != "" {
|
||||
key = b.narrow
|
||||
if b.keysIdent > line.Indent {
|
||||
key = b.Block.Title
|
||||
}
|
||||
}
|
||||
|
||||
b.Summary = &BasicSummary{
|
||||
Key: key,
|
||||
Change: line.Change,
|
||||
LineStart: line.LineNum,
|
||||
}
|
||||
}
|
||||
}
|
||||
// if we find a `ChangeUnchanged`, we do one of two things:
|
||||
//
|
||||
// - if we're recording a change already, then we know
|
||||
// we've come to the end of that change block, so we write
|
||||
// that change out be recording the line number of where
|
||||
// that change ends, and append it to the current block's
|
||||
// summary.
|
||||
//
|
||||
// - if we're not recording a change, then we do nothing,
|
||||
// since the BasicDiff doesn't report on unchanged JSON
|
||||
// values.
|
||||
} else {
|
||||
if b.writing {
|
||||
b.writing = false
|
||||
b.Summary.LineEnd = line.LineNum
|
||||
b.Block.Summaries = append(b.Block.Summaries, b.Summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
// returnToTopLevelKey indicates that we've moved from a key at one level deep
|
||||
// in the JSON document to a top level key.
|
||||
//
|
||||
// In order to produce distinct "blocks" when rendering the basic diff,
|
||||
// we need a way to distinguish between different sections of data.
|
||||
// To do this, we consider the value(s) of each top-level JSON key to
|
||||
// represent a distinct block for Grafana's JSON data structure, so
|
||||
// we perform this check to see if we've entered a new "block". If we
|
||||
// have, we simply append the existing block to the array of blocks.
|
||||
func (b *BasicDiff) returnToTopLevelKey(line *JSONLine) bool {
|
||||
return b.LastIndent == 2 && line.Indent == 1 && line.Change == ChangeNil
|
||||
}
|
||||
|
||||
// handleTopLevelChange handles a change on one of the top-level keys on a JSON
|
||||
// document.
|
||||
//
|
||||
// If the line's indentation is at level 1, then we know it's a top
|
||||
// level key in the JSON document. As mentioned earlier, we treat these
|
||||
// specially as they indicate their values belong to distinct blocks.
|
||||
//
|
||||
// At level 1, we only record single-line changes, ie, the "added",
|
||||
// "deleted", "old" or "new" cases, since we know those values aren't
|
||||
// arrays or maps. We only handle these cases at level 2 or deeper,
|
||||
// since for those we either output a "change" or "summary". This is
|
||||
// done for formatting reasons only, so we have logical "blocks" to
|
||||
// display.
|
||||
func (b *BasicDiff) handleTopLevelChange(line *JSONLine) (*BasicBlock, bool) {
|
||||
switch line.Change {
|
||||
case ChangeNil:
|
||||
if line.Change == ChangeNil {
|
||||
if line.Key != "" {
|
||||
b.Block = &BasicBlock{
|
||||
Title: line.Key,
|
||||
Change: line.Change,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case ChangeAdded, ChangeDeleted:
|
||||
return &BasicBlock{
|
||||
Title: line.Key,
|
||||
Change: line.Change,
|
||||
New: line.Val,
|
||||
LineStart: line.LineNum,
|
||||
}, true
|
||||
|
||||
case ChangeOld:
|
||||
b.Block = &BasicBlock{
|
||||
Title: line.Key,
|
||||
Old: line.Val,
|
||||
Change: line.Change,
|
||||
LineStart: line.LineNum,
|
||||
}
|
||||
|
||||
case ChangeNew:
|
||||
b.Block.New = line.Val
|
||||
b.Block.LineEnd = line.LineNum
|
||||
|
||||
// For every "old" change there is a corresponding "new", which
|
||||
// is why we wait until we detect the "new" change before
|
||||
// appending the change.
|
||||
return b.Block, true
|
||||
default:
|
||||
// ok
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// isSingleLineChange ensures we're iterating over a single line change (ie,
|
||||
// either a single line or a old-new value pair was changed in the JSON file).
|
||||
func (b *BasicDiff) isSingleLineChange(line *JSONLine) bool {
|
||||
return line.Key != "" && line.Val != nil && !b.writing
|
||||
}
|
||||
|
||||
// encStateMap is used in the template helper
|
||||
var (
|
||||
encStateMap = map[ChangeType]string{
|
||||
ChangeAdded: "added",
|
||||
ChangeDeleted: "deleted",
|
||||
ChangeOld: "changed",
|
||||
ChangeNew: "changed",
|
||||
}
|
||||
|
||||
// tplFuncMap is the function map for each template
|
||||
tplFuncMap = template.FuncMap{
|
||||
"getChange": func(c ChangeType) string {
|
||||
state, ok := encStateMap[c]
|
||||
if !ok {
|
||||
return "changed"
|
||||
}
|
||||
return state
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
// tplBlock is the container for the basic diff. It iterates over each
|
||||
// basic block, expanding each "change" and "summary" belonging to every
|
||||
// block.
|
||||
tplBlock = `{{ define "block" -}}
|
||||
{{ range . }}
|
||||
<div class="diff-group">
|
||||
<div class="diff-block">
|
||||
<h2 class="diff-block-title">
|
||||
<i class="diff-circle diff-circle-{{ getChange .Change }} fa fa-circle"></i>
|
||||
<strong class="diff-title">{{ .Title }}</strong> {{ getChange .Change }}
|
||||
</h2>
|
||||
|
||||
|
||||
<!-- Overview -->
|
||||
{{ if .Old }}
|
||||
<div class="diff-label">{{ .Old }}</div>
|
||||
<i class="diff-arrow fa fa-long-arrow-right"></i>
|
||||
{{ end }}
|
||||
{{ if .New }}
|
||||
<div class="diff-label">{{ .New }}</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if .LineStart }}
|
||||
<diff-link-json
|
||||
line-link="{{ .LineStart }}"
|
||||
line-display="{{ .LineStart }}{{ if .LineEnd }} - {{ .LineEnd }}{{ end }}"
|
||||
switch-view="ctrl.getDiff('html')"
|
||||
/>
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Basic Changes -->
|
||||
{{ range .Changes }}
|
||||
<ul class="diff-change-container">
|
||||
{{ template "change" . }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
|
||||
<!-- Basic Summary -->
|
||||
{{ range .Summaries }}
|
||||
{{ template "summary" . }}
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}`
|
||||
|
||||
// tplChange is the template for basic changes.
|
||||
tplChange = `{{ define "change" -}}
|
||||
<li class="diff-change-group">
|
||||
<span class="bullet-position-container">
|
||||
<div class="diff-change-item diff-change-title">{{ getChange .Change }} {{ .Key }}</div>
|
||||
|
||||
<div class="diff-change-item">
|
||||
{{ if .Old }}
|
||||
<div class="diff-label">{{ .Old }}</div>
|
||||
<i class="diff-arrow fa fa-long-arrow-right"></i>
|
||||
{{ end }}
|
||||
{{ if .New }}
|
||||
<div class="diff-label">{{ .New }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ if .LineStart }}
|
||||
<diff-link-json
|
||||
line-link="{{ .LineStart }}"
|
||||
line-display="{{ .LineStart }}{{ if .LineEnd }} - {{ .LineEnd }}{{ end }}"
|
||||
switch-view="ctrl.getDiff('json')"
|
||||
/>
|
||||
{{ end }}
|
||||
</span>
|
||||
</li>
|
||||
{{ end }}`
|
||||
|
||||
// tplSummary is for basic summaries.
|
||||
tplSummary = `{{ define "summary" -}}
|
||||
<div class="diff-group-name">
|
||||
<i class="diff-circle diff-circle-{{ getChange .Change }} fa fa-circle-o diff-list-circle"></i>
|
||||
|
||||
{{ if .Count }}
|
||||
<strong>{{ .Count }}</strong>
|
||||
{{ end }}
|
||||
|
||||
{{ if .Key }}
|
||||
<strong class="diff-summary-key">{{ .Key }}</strong>
|
||||
{{ getChange .Change }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .LineStart }}
|
||||
<diff-link-json
|
||||
line-link="{{ .LineStart }}"
|
||||
line-display="{{ .LineStart }}{{ if .LineEnd }} - {{ .LineEnd }}{{ end }}"
|
||||
switch-view="ctrl.getDiff('json')"
|
||||
/>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}`
|
||||
)
|
||||
@@ -0,0 +1,477 @@
|
||||
package dashdiffs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"sort"
|
||||
|
||||
diff "github.com/yudai/gojsondiff"
|
||||
)
|
||||
|
||||
type ChangeType int
|
||||
|
||||
const (
|
||||
ChangeNil ChangeType = iota
|
||||
ChangeAdded
|
||||
ChangeDeleted
|
||||
ChangeOld
|
||||
ChangeNew
|
||||
ChangeUnchanged
|
||||
)
|
||||
|
||||
var (
|
||||
// changeTypeToSymbol is used for populating the terminating characer in
|
||||
// the diff
|
||||
changeTypeToSymbol = map[ChangeType]string{
|
||||
ChangeNil: "",
|
||||
ChangeAdded: "+",
|
||||
ChangeDeleted: "-",
|
||||
ChangeOld: "-",
|
||||
ChangeNew: "+",
|
||||
}
|
||||
|
||||
// changeTypeToName is used for populating class names in the diff
|
||||
changeTypeToName = map[ChangeType]string{
|
||||
ChangeNil: "same",
|
||||
ChangeAdded: "added",
|
||||
ChangeDeleted: "deleted",
|
||||
ChangeOld: "old",
|
||||
ChangeNew: "new",
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
// tplJSONDiffWrapper is the template that wraps a diff
|
||||
tplJSONDiffWrapper = `{{ define "JSONDiffWrapper" -}}
|
||||
{{ range $index, $element := . }}
|
||||
{{ template "JSONDiffLine" $element }}
|
||||
{{ end }}
|
||||
{{ end }}`
|
||||
|
||||
// tplJSONDiffLine is the template that prints each line in a diff
|
||||
tplJSONDiffLine = `{{ define "JSONDiffLine" -}}
|
||||
<p id="l{{ .LineNum }}" class="diff-line diff-json-{{ cton .Change }}">
|
||||
<span class="diff-line-number">
|
||||
{{if .LeftLine }}{{ .LeftLine }}{{ end }}
|
||||
</span>
|
||||
<span class="diff-line-number">
|
||||
{{if .RightLine }}{{ .RightLine }}{{ end }}
|
||||
</span>
|
||||
<span class="diff-value diff-indent-{{ .Indent }}" title="{{ .Text }}">
|
||||
{{ .Text }}
|
||||
</span>
|
||||
<span class="diff-line-icon">{{ ctos .Change }}</span>
|
||||
</p>
|
||||
{{ end }}`
|
||||
)
|
||||
|
||||
var diffTplFuncs = template.FuncMap{
|
||||
"ctos": func(c ChangeType) string {
|
||||
if symbol, ok := changeTypeToSymbol[c]; ok {
|
||||
return symbol
|
||||
}
|
||||
return ""
|
||||
},
|
||||
"cton": func(c ChangeType) string {
|
||||
if name, ok := changeTypeToName[c]; ok {
|
||||
return name
|
||||
}
|
||||
return ""
|
||||
},
|
||||
}
|
||||
|
||||
// JSONLine contains the data required to render each line of the JSON diff
|
||||
// and contains the data required to produce the tokens output in the basic
|
||||
// diff.
|
||||
type JSONLine struct {
|
||||
LineNum int `json:"line"`
|
||||
LeftLine int `json:"leftLine"`
|
||||
RightLine int `json:"rightLine"`
|
||||
Indent int `json:"indent"`
|
||||
Text string `json:"text"`
|
||||
Change ChangeType `json:"changeType"`
|
||||
Key string `json:"key"`
|
||||
Val interface{} `json:"value"`
|
||||
}
|
||||
|
||||
func NewJSONFormatter(left interface{}) *JSONFormatter {
|
||||
tpl := template.Must(template.New("JSONDiffWrapper").Funcs(diffTplFuncs).Parse(tplJSONDiffWrapper))
|
||||
tpl = template.Must(tpl.New("JSONDiffLine").Funcs(diffTplFuncs).Parse(tplJSONDiffLine))
|
||||
|
||||
return &JSONFormatter{
|
||||
left: left,
|
||||
Lines: []*JSONLine{},
|
||||
tpl: tpl,
|
||||
path: []string{},
|
||||
size: []int{},
|
||||
lineCount: 0,
|
||||
inArray: []bool{},
|
||||
}
|
||||
}
|
||||
|
||||
type JSONFormatter struct {
|
||||
left interface{}
|
||||
path []string
|
||||
size []int
|
||||
inArray []bool
|
||||
lineCount int
|
||||
leftLine int
|
||||
rightLine int
|
||||
line *AsciiLine
|
||||
Lines []*JSONLine
|
||||
tpl *template.Template
|
||||
}
|
||||
|
||||
type AsciiLine struct {
|
||||
// the type of change
|
||||
change ChangeType
|
||||
|
||||
// the actual changes - no formatting
|
||||
key string
|
||||
val interface{}
|
||||
|
||||
// level of indentation for the current line
|
||||
indent int
|
||||
|
||||
// buffer containing the fully formatted line
|
||||
buffer *bytes.Buffer
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) Format(diff diff.Diff) (result string, err error) {
|
||||
if v, ok := f.left.(map[string]interface{}); ok {
|
||||
f.formatObject(v, diff)
|
||||
} else if v, ok := f.left.([]interface{}); ok {
|
||||
f.formatArray(v, diff)
|
||||
} else {
|
||||
return "", fmt.Errorf("expected map[string]interface{} or []interface{}, got %T",
|
||||
f.left)
|
||||
}
|
||||
|
||||
b := &bytes.Buffer{}
|
||||
err = f.tpl.ExecuteTemplate(b, "JSONDiffWrapper", f.Lines)
|
||||
if err != nil {
|
||||
fmt.Printf("%v\n", err)
|
||||
return "", err
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) formatObject(left map[string]interface{}, df diff.Diff) {
|
||||
f.addLineWith(ChangeNil, "{")
|
||||
f.push("ROOT", len(left), false)
|
||||
f.processObject(left, df.Deltas())
|
||||
f.pop()
|
||||
f.addLineWith(ChangeNil, "}")
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) formatArray(left []interface{}, df diff.Diff) {
|
||||
f.addLineWith(ChangeNil, "[")
|
||||
f.push("ROOT", len(left), true)
|
||||
f.processArray(left, df.Deltas())
|
||||
f.pop()
|
||||
f.addLineWith(ChangeNil, "]")
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) processArray(array []interface{}, deltas []diff.Delta) error {
|
||||
patchedIndex := 0
|
||||
for index, value := range array {
|
||||
f.processItem(value, deltas, diff.Index(index))
|
||||
patchedIndex++
|
||||
}
|
||||
|
||||
// additional Added
|
||||
for _, delta := range deltas {
|
||||
switch delta.(type) {
|
||||
case *diff.Added:
|
||||
d := delta.(*diff.Added)
|
||||
// skip items already processed
|
||||
if int(d.Position.(diff.Index)) < len(array) {
|
||||
continue
|
||||
}
|
||||
f.printRecursive(d.Position.String(), d.Value, ChangeAdded)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) processObject(object map[string]interface{}, deltas []diff.Delta) error {
|
||||
names := sortKeys(object)
|
||||
for _, name := range names {
|
||||
value := object[name]
|
||||
f.processItem(value, deltas, diff.Name(name))
|
||||
}
|
||||
|
||||
// Added
|
||||
for _, delta := range deltas {
|
||||
switch delta.(type) {
|
||||
case *diff.Added:
|
||||
d := delta.(*diff.Added)
|
||||
f.printRecursive(d.Position.String(), d.Value, ChangeAdded)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) processItem(value interface{}, deltas []diff.Delta, position diff.Position) error {
|
||||
matchedDeltas := f.searchDeltas(deltas, position)
|
||||
positionStr := position.String()
|
||||
if len(matchedDeltas) > 0 {
|
||||
for _, matchedDelta := range matchedDeltas {
|
||||
|
||||
switch matchedDelta.(type) {
|
||||
case *diff.Object:
|
||||
d := matchedDelta.(*diff.Object)
|
||||
switch value.(type) {
|
||||
case map[string]interface{}:
|
||||
//ok
|
||||
default:
|
||||
return errors.New("Type mismatch")
|
||||
}
|
||||
o := value.(map[string]interface{})
|
||||
|
||||
f.newLine(ChangeNil)
|
||||
f.printKey(positionStr)
|
||||
f.print("{")
|
||||
f.closeLine()
|
||||
f.push(positionStr, len(o), false)
|
||||
f.processObject(o, d.Deltas)
|
||||
f.pop()
|
||||
f.newLine(ChangeNil)
|
||||
f.print("}")
|
||||
f.printComma()
|
||||
f.closeLine()
|
||||
|
||||
case *diff.Array:
|
||||
d := matchedDelta.(*diff.Array)
|
||||
switch value.(type) {
|
||||
case []interface{}:
|
||||
//ok
|
||||
default:
|
||||
return errors.New("Type mismatch")
|
||||
}
|
||||
a := value.([]interface{})
|
||||
|
||||
f.newLine(ChangeNil)
|
||||
f.printKey(positionStr)
|
||||
f.print("[")
|
||||
f.closeLine()
|
||||
f.push(positionStr, len(a), true)
|
||||
f.processArray(a, d.Deltas)
|
||||
f.pop()
|
||||
f.newLine(ChangeNil)
|
||||
f.print("]")
|
||||
f.printComma()
|
||||
f.closeLine()
|
||||
|
||||
case *diff.Added:
|
||||
d := matchedDelta.(*diff.Added)
|
||||
f.printRecursive(positionStr, d.Value, ChangeAdded)
|
||||
f.size[len(f.size)-1]++
|
||||
|
||||
case *diff.Modified:
|
||||
d := matchedDelta.(*diff.Modified)
|
||||
savedSize := f.size[len(f.size)-1]
|
||||
f.printRecursive(positionStr, d.OldValue, ChangeOld)
|
||||
f.size[len(f.size)-1] = savedSize
|
||||
f.printRecursive(positionStr, d.NewValue, ChangeNew)
|
||||
|
||||
case *diff.TextDiff:
|
||||
savedSize := f.size[len(f.size)-1]
|
||||
d := matchedDelta.(*diff.TextDiff)
|
||||
f.printRecursive(positionStr, d.OldValue, ChangeOld)
|
||||
f.size[len(f.size)-1] = savedSize
|
||||
f.printRecursive(positionStr, d.NewValue, ChangeNew)
|
||||
|
||||
case *diff.Deleted:
|
||||
d := matchedDelta.(*diff.Deleted)
|
||||
f.printRecursive(positionStr, d.Value, ChangeDeleted)
|
||||
|
||||
default:
|
||||
return errors.New("Unknown Delta type detected")
|
||||
}
|
||||
|
||||
}
|
||||
} else {
|
||||
f.printRecursive(positionStr, value, ChangeUnchanged)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) searchDeltas(deltas []diff.Delta, position diff.Position) (results []diff.Delta) {
|
||||
results = make([]diff.Delta, 0)
|
||||
for _, delta := range deltas {
|
||||
switch delta.(type) {
|
||||
case diff.PostDelta:
|
||||
if delta.(diff.PostDelta).PostPosition() == position {
|
||||
results = append(results, delta)
|
||||
}
|
||||
case diff.PreDelta:
|
||||
if delta.(diff.PreDelta).PrePosition() == position {
|
||||
results = append(results, delta)
|
||||
}
|
||||
default:
|
||||
panic("heh")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) push(name string, size int, array bool) {
|
||||
f.path = append(f.path, name)
|
||||
f.size = append(f.size, size)
|
||||
f.inArray = append(f.inArray, array)
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) pop() {
|
||||
f.path = f.path[0 : len(f.path)-1]
|
||||
f.size = f.size[0 : len(f.size)-1]
|
||||
f.inArray = f.inArray[0 : len(f.inArray)-1]
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) addLineWith(change ChangeType, value string) {
|
||||
f.line = &AsciiLine{
|
||||
change: change,
|
||||
indent: len(f.path),
|
||||
buffer: bytes.NewBufferString(value),
|
||||
}
|
||||
f.closeLine()
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) newLine(change ChangeType) {
|
||||
f.line = &AsciiLine{
|
||||
change: change,
|
||||
indent: len(f.path),
|
||||
buffer: bytes.NewBuffer([]byte{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) closeLine() {
|
||||
leftLine := 0
|
||||
rightLine := 0
|
||||
f.lineCount++
|
||||
|
||||
switch f.line.change {
|
||||
case ChangeAdded, ChangeNew:
|
||||
f.rightLine++
|
||||
rightLine = f.rightLine
|
||||
|
||||
case ChangeDeleted, ChangeOld:
|
||||
f.leftLine++
|
||||
leftLine = f.leftLine
|
||||
|
||||
case ChangeNil, ChangeUnchanged:
|
||||
f.rightLine++
|
||||
f.leftLine++
|
||||
rightLine = f.rightLine
|
||||
leftLine = f.leftLine
|
||||
}
|
||||
|
||||
s := f.line.buffer.String()
|
||||
f.Lines = append(f.Lines, &JSONLine{
|
||||
LineNum: f.lineCount,
|
||||
RightLine: rightLine,
|
||||
LeftLine: leftLine,
|
||||
Indent: f.line.indent,
|
||||
Text: s,
|
||||
Change: f.line.change,
|
||||
Key: f.line.key,
|
||||
Val: f.line.val,
|
||||
})
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) printKey(name string) {
|
||||
if !f.inArray[len(f.inArray)-1] {
|
||||
f.line.key = name
|
||||
fmt.Fprintf(f.line.buffer, `"%s": `, name)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) printComma() {
|
||||
f.size[len(f.size)-1]--
|
||||
if f.size[len(f.size)-1] > 0 {
|
||||
f.line.buffer.WriteRune(',')
|
||||
}
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) printValue(value interface{}) {
|
||||
switch value.(type) {
|
||||
case string:
|
||||
f.line.val = value
|
||||
fmt.Fprintf(f.line.buffer, `"%s"`, value)
|
||||
case nil:
|
||||
f.line.val = "null"
|
||||
f.line.buffer.WriteString("null")
|
||||
default:
|
||||
f.line.val = value
|
||||
fmt.Fprintf(f.line.buffer, `%#v`, value)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) print(a string) {
|
||||
f.line.buffer.WriteString(a)
|
||||
}
|
||||
|
||||
func (f *JSONFormatter) printRecursive(name string, value interface{}, change ChangeType) {
|
||||
switch value.(type) {
|
||||
case map[string]interface{}:
|
||||
f.newLine(change)
|
||||
f.printKey(name)
|
||||
f.print("{")
|
||||
f.closeLine()
|
||||
|
||||
m := value.(map[string]interface{})
|
||||
size := len(m)
|
||||
f.push(name, size, false)
|
||||
|
||||
keys := sortKeys(m)
|
||||
for _, key := range keys {
|
||||
f.printRecursive(key, m[key], change)
|
||||
}
|
||||
f.pop()
|
||||
|
||||
f.newLine(change)
|
||||
f.print("}")
|
||||
f.printComma()
|
||||
f.closeLine()
|
||||
|
||||
case []interface{}:
|
||||
f.newLine(change)
|
||||
f.printKey(name)
|
||||
f.print("[")
|
||||
f.closeLine()
|
||||
|
||||
s := value.([]interface{})
|
||||
size := len(s)
|
||||
f.push("", size, true)
|
||||
for _, item := range s {
|
||||
f.printRecursive("", item, change)
|
||||
}
|
||||
f.pop()
|
||||
|
||||
f.newLine(change)
|
||||
f.print("]")
|
||||
f.printComma()
|
||||
f.closeLine()
|
||||
|
||||
default:
|
||||
f.newLine(change)
|
||||
f.printKey(name)
|
||||
f.printValue(value)
|
||||
f.printComma()
|
||||
f.closeLine()
|
||||
}
|
||||
}
|
||||
|
||||
func sortKeys(m map[string]interface{}) (keys []string) {
|
||||
keys = make([]string, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package dashdiffs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestDiff(t *testing.T) {
|
||||
// Sample json docs for tests only
|
||||
const (
|
||||
leftJSON = `{
|
||||
"key": "value",
|
||||
"object": {
|
||||
"key": "value",
|
||||
"anotherObject": {
|
||||
"same": "this field is the same in rightJSON",
|
||||
"change": "this field should change in rightJSON",
|
||||
"delete": "this field doesn't appear in rightJSON"
|
||||
}
|
||||
},
|
||||
"array": [
|
||||
"same",
|
||||
"change",
|
||||
"delete"
|
||||
],
|
||||
"embeddedArray": {
|
||||
"array": [
|
||||
"same",
|
||||
"change",
|
||||
"delete"
|
||||
]
|
||||
}
|
||||
}`
|
||||
|
||||
rightJSON = `{
|
||||
"key": "differentValue",
|
||||
"object": {
|
||||
"key": "value",
|
||||
"newKey": "value",
|
||||
"anotherObject": {
|
||||
"same": "this field is the same in rightJSON",
|
||||
"change": "this field should change in rightJSON",
|
||||
"add": "this field is added"
|
||||
}
|
||||
},
|
||||
"array": [
|
||||
"same",
|
||||
"changed!",
|
||||
"add"
|
||||
],
|
||||
"embeddedArray": {
|
||||
"array": [
|
||||
"same",
|
||||
"changed!",
|
||||
"add"
|
||||
]
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
||||
Convey("Testing dashboard diffs", t, func() {
|
||||
|
||||
// Compute the diff between the two JSON objects
|
||||
baseData, err := simplejson.NewJson([]byte(leftJSON))
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
newData, err := simplejson.NewJson([]byte(rightJSON))
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
left, jsonDiff, err := getDiff(baseData, newData)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
Convey("The JSONFormatter should produce the expected JSON tokens", func() {
|
||||
f := NewJSONFormatter(left)
|
||||
_, err := f.Format(jsonDiff)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
// Total up the change types. If the number of different change
|
||||
// types is correct, it means that the diff is producing correct
|
||||
// output to the template rendered.
|
||||
changeCounts := make(map[ChangeType]int)
|
||||
for _, line := range f.Lines {
|
||||
changeCounts[line.Change]++
|
||||
}
|
||||
|
||||
// The expectedChangeCounts here were determined by manually
|
||||
// looking at the JSON
|
||||
expectedChangeCounts := map[ChangeType]int{
|
||||
ChangeNil: 12,
|
||||
ChangeAdded: 2,
|
||||
ChangeDeleted: 1,
|
||||
ChangeOld: 5,
|
||||
ChangeNew: 5,
|
||||
ChangeUnchanged: 5,
|
||||
}
|
||||
So(changeCounts, ShouldResemble, expectedChangeCounts)
|
||||
})
|
||||
|
||||
Convey("The BasicFormatter should produce the expected BasicBlocks", func() {
|
||||
f := NewBasicFormatter(left)
|
||||
_, err := f.Format(jsonDiff)
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
bd := &BasicDiff{}
|
||||
blocks := bd.Basic(f.jsonDiff.Lines)
|
||||
|
||||
changeCounts := make(map[ChangeType]int)
|
||||
for _, block := range blocks {
|
||||
for _, change := range block.Changes {
|
||||
changeCounts[change.Change]++
|
||||
}
|
||||
|
||||
for _, summary := range block.Summaries {
|
||||
changeCounts[summary.Change]++
|
||||
}
|
||||
|
||||
changeCounts[block.Change]++
|
||||
}
|
||||
|
||||
expectedChangeCounts := map[ChangeType]int{
|
||||
ChangeNil: 3,
|
||||
ChangeAdded: 2,
|
||||
ChangeDeleted: 1,
|
||||
ChangeOld: 3,
|
||||
}
|
||||
So(changeCounts, ShouldResemble, expectedChangeCounts)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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, "")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package imguploader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"golang.org/x/oauth2/google"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenUrl string = "https://www.googleapis.com/auth/devstorage.read_write"
|
||||
uploadUrl string = "https://www.googleapis.com/upload/storage/v1/b/%s/o?uploadType=media&name=%s&predefinedAcl=publicRead"
|
||||
)
|
||||
|
||||
type GCSUploader struct {
|
||||
keyFile string
|
||||
bucket string
|
||||
path string
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func NewGCSUploader(keyFile, bucket, path string) *GCSUploader {
|
||||
return &GCSUploader{
|
||||
keyFile: keyFile,
|
||||
bucket: bucket,
|
||||
path: path,
|
||||
log: log.New("gcsuploader"),
|
||||
}
|
||||
}
|
||||
|
||||
func (u *GCSUploader) Upload(ctx context.Context, imageDiskPath string) (string, error) {
|
||||
fileName := util.GetRandomString(20) + ".png"
|
||||
key := path.Join(u.path, fileName)
|
||||
|
||||
u.log.Debug("Opening key file ", u.keyFile)
|
||||
data, err := ioutil.ReadFile(u.keyFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
u.log.Debug("Creating JWT conf")
|
||||
conf, err := google.JWTConfigFromJSON(data, tokenUrl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
u.log.Debug("Creating HTTP client")
|
||||
client := conf.Client(ctx)
|
||||
err = u.uploadFile(client, imageDiskPath, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("https://storage.googleapis.com/%s/%s", u.bucket, key), nil
|
||||
}
|
||||
|
||||
func (u *GCSUploader) uploadFile(client *http.Client, imageDiskPath, key string) error {
|
||||
u.log.Debug("Opening image file ", imageDiskPath)
|
||||
|
||||
fileReader, err := os.Open(imageDiskPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reqUrl := fmt.Sprintf(uploadUrl, u.bucket, key)
|
||||
u.log.Debug("Request URL: ", reqUrl)
|
||||
|
||||
req, err := http.NewRequest("POST", reqUrl, fileReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", "image/png")
|
||||
u.log.Debug("Sending POST request to GCS")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("GCS response status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package imguploader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestUploadToGCS(t *testing.T) {
|
||||
SkipConvey("[Integration test] for external_image_store.gcs", t, func() {
|
||||
setting.NewConfigContext(&setting.CommandLineArgs{
|
||||
HomePath: "../../../",
|
||||
})
|
||||
|
||||
gcsUploader, _ := NewImageUploader()
|
||||
|
||||
path, err := gcsUploader.Upload(context.Background(), "../../../public/img/logo_transparent_400x.png")
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
So(path, ShouldNotEqual, "")
|
||||
})
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
package imguploader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"regexp"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
type ImageUploader interface {
|
||||
Upload(path string) (string, error)
|
||||
Upload(ctx context.Context, path string) (string, error)
|
||||
}
|
||||
|
||||
type NopImageUploader struct {
|
||||
}
|
||||
|
||||
func (NopImageUploader) Upload(path string) (string, error) {
|
||||
func (NopImageUploader) Upload(ctx context.Context, path string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -27,15 +29,27 @@ func NewImageUploader() (ImageUploader, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bucket := s3sec.Key("bucket").MustString("")
|
||||
region := s3sec.Key("region").MustString("")
|
||||
path := s3sec.Key("path").MustString("")
|
||||
bucketUrl := s3sec.Key("bucket_url").MustString("")
|
||||
accessKey := s3sec.Key("access_key").MustString("")
|
||||
secretKey := s3sec.Key("secret_key").MustString("")
|
||||
info, err := getRegionAndBucketFromUrl(bucketUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
if path != "" && path[len(path)-1:] != "/" {
|
||||
path += "/"
|
||||
}
|
||||
|
||||
return NewS3Uploader(info.region, info.bucket, "public-read", accessKey, secretKey), nil
|
||||
if bucket == "" || region == "" {
|
||||
info, err := getRegionAndBucketFromUrl(bucketUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucket = info.bucket
|
||||
region = info.region
|
||||
}
|
||||
|
||||
return NewS3Uploader(region, bucket, path, "public-read", accessKey, secretKey), nil
|
||||
case "webdav":
|
||||
webdavSec, err := setting.Cfg.GetSection("external_image_storage.webdav")
|
||||
if err != nil {
|
||||
@@ -47,10 +61,39 @@ func NewImageUploader() (ImageUploader, error) {
|
||||
return nil, fmt.Errorf("Could not find url key for image.uploader.webdav")
|
||||
}
|
||||
|
||||
public_url := webdavSec.Key("public_url").String()
|
||||
username := webdavSec.Key("username").String()
|
||||
password := webdavSec.Key("password").String()
|
||||
|
||||
return NewWebdavImageUploader(url, username, password)
|
||||
return NewWebdavImageUploader(url, username, password, public_url)
|
||||
case "gcs":
|
||||
gcssec, err := setting.Cfg.GetSection("external_image_storage.gcs")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyFile := gcssec.Key("key_file").MustString("")
|
||||
bucketName := gcssec.Key("bucket").MustString("")
|
||||
path := gcssec.Key("path").MustString("")
|
||||
|
||||
return NewGCSUploader(keyFile, bucketName, path), nil
|
||||
case "azure_blob":
|
||||
azureBlobSec, err := setting.Cfg.GetSection("external_image_storage.azure_blob")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account_name := azureBlobSec.Key("account_name").MustString("")
|
||||
account_key := azureBlobSec.Key("account_key").MustString("")
|
||||
container_name := azureBlobSec.Key("container_name").MustString("")
|
||||
|
||||
return NewAzureBlobUploader(account_name, account_key, container_name), nil
|
||||
case "local":
|
||||
return NewLocalImageUploader()
|
||||
}
|
||||
|
||||
if setting.ImageUploadProvider != "" {
|
||||
log.Error2("The external image storage configuration is invalid", "unsupported provider", setting.ImageUploadProvider)
|
||||
}
|
||||
|
||||
return NopImageUploader{}, nil
|
||||
|
||||
@@ -96,5 +96,70 @@ func TestImageUploaderFactory(t *testing.T) {
|
||||
So(original.username, ShouldEqual, "username")
|
||||
So(original.password, ShouldEqual, "password")
|
||||
})
|
||||
|
||||
Convey("GCS uploader", func() {
|
||||
var err error
|
||||
|
||||
setting.NewConfigContext(&setting.CommandLineArgs{
|
||||
HomePath: "../../../",
|
||||
})
|
||||
|
||||
setting.ImageUploadProvider = "gcs"
|
||||
|
||||
gcpSec, err := setting.Cfg.GetSection("external_image_storage.gcs")
|
||||
gcpSec.NewKey("key_file", "/etc/secrets/project-79a52befa3f6.json")
|
||||
gcpSec.NewKey("bucket", "project-grafana-east")
|
||||
|
||||
uploader, err := NewImageUploader()
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
original, ok := uploader.(*GCSUploader)
|
||||
|
||||
So(ok, ShouldBeTrue)
|
||||
So(original.keyFile, ShouldEqual, "/etc/secrets/project-79a52befa3f6.json")
|
||||
So(original.bucket, ShouldEqual, "project-grafana-east")
|
||||
})
|
||||
|
||||
Convey("AzureBlobUploader config", func() {
|
||||
setting.NewConfigContext(&setting.CommandLineArgs{
|
||||
HomePath: "../../../",
|
||||
})
|
||||
setting.ImageUploadProvider = "azure_blob"
|
||||
|
||||
Convey("with container name", func() {
|
||||
azureBlobSec, err := setting.Cfg.GetSection("external_image_storage.azure_blob")
|
||||
azureBlobSec.NewKey("account_name", "account_name")
|
||||
azureBlobSec.NewKey("account_key", "account_key")
|
||||
azureBlobSec.NewKey("container_name", "container_name")
|
||||
|
||||
uploader, err := NewImageUploader()
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
original, ok := uploader.(*AzureBlobUploader)
|
||||
|
||||
So(ok, ShouldBeTrue)
|
||||
So(original.account_name, ShouldEqual, "account_name")
|
||||
So(original.account_key, ShouldEqual, "account_key")
|
||||
So(original.container_name, ShouldEqual, "container_name")
|
||||
})
|
||||
})
|
||||
|
||||
Convey("Local uploader", func() {
|
||||
var err error
|
||||
|
||||
setting.NewConfigContext(&setting.CommandLineArgs{
|
||||
HomePath: "../../../",
|
||||
})
|
||||
|
||||
setting.ImageUploadProvider = "local"
|
||||
|
||||
uploader, err := NewImageUploader()
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
original, ok := uploader.(*LocalUploader)
|
||||
|
||||
So(ok, ShouldBeTrue)
|
||||
So(original, ShouldNotBeNil)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package imguploader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
type LocalUploader struct {
|
||||
}
|
||||
|
||||
func (u *LocalUploader) Upload(ctx context.Context, imageOnDiskPath string) (string, error) {
|
||||
filename := filepath.Base(imageOnDiskPath)
|
||||
image_url := setting.ToAbsUrl(path.Join("public/img/attachments", filename))
|
||||
return image_url, nil
|
||||
}
|
||||
|
||||
func NewLocalImageUploader() (*LocalUploader, error) {
|
||||
return &LocalUploader{}, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package imguploader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestUploadToLocal(t *testing.T) {
|
||||
Convey("[Integration test] for external_image_store.local", t, func() {
|
||||
localUploader, _ := NewLocalImageUploader()
|
||||
path, err := localUploader.Upload(context.Background(), "../../../public/img/logo_transparent_400x.png")
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
So(path, ShouldContainSubstring, "/public/img/attachments")
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package imguploader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws/credentials"
|
||||
"github.com/aws/aws-sdk-go/aws/credentials/ec2rolecreds"
|
||||
"github.com/aws/aws-sdk-go/aws/ec2metadata"
|
||||
"github.com/aws/aws-sdk-go/aws/endpoints"
|
||||
"github.com/aws/aws-sdk-go/aws/session"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
@@ -17,16 +19,18 @@ import (
|
||||
type S3Uploader struct {
|
||||
region string
|
||||
bucket string
|
||||
path string
|
||||
acl string
|
||||
secretKey string
|
||||
accessKey string
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func NewS3Uploader(region, bucket, acl, accessKey, secretKey string) *S3Uploader {
|
||||
func NewS3Uploader(region, bucket, path, acl, accessKey, secretKey string) *S3Uploader {
|
||||
return &S3Uploader{
|
||||
region: region,
|
||||
bucket: bucket,
|
||||
path: path,
|
||||
acl: acl,
|
||||
accessKey: accessKey,
|
||||
secretKey: secretKey,
|
||||
@@ -34,7 +38,7 @@ func NewS3Uploader(region, bucket, acl, accessKey, secretKey string) *S3Uploader
|
||||
}
|
||||
}
|
||||
|
||||
func (u *S3Uploader) Upload(imageDiskPath string) (string, error) {
|
||||
func (u *S3Uploader) Upload(ctx context.Context, imageDiskPath string) (string, error) {
|
||||
sess, err := session.NewSession()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -53,8 +57,10 @@ func (u *S3Uploader) Upload(imageDiskPath string) (string, error) {
|
||||
Credentials: creds,
|
||||
}
|
||||
|
||||
key := util.GetRandomString(20) + ".png"
|
||||
log.Debug("Uploading image to s3", "bucket = ", u.bucket, ", key = ", key)
|
||||
s3_endpoint, _ := endpoints.DefaultResolver().EndpointFor("s3", u.region)
|
||||
key := u.path + util.GetRandomString(20) + ".png"
|
||||
image_url := s3_endpoint.URL + "/" + u.bucket + "/" + key
|
||||
log.Debug("Uploading image to s3", "url = ", image_url)
|
||||
|
||||
file, err := os.Open(imageDiskPath)
|
||||
if err != nil {
|
||||
@@ -77,6 +83,5 @@ func (u *S3Uploader) Upload(imageDiskPath string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return "https://" + u.bucket + ".s3.amazonaws.com/" + key, nil
|
||||
return image_url, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package imguploader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -8,14 +9,14 @@ import (
|
||||
)
|
||||
|
||||
func TestUploadToS3(t *testing.T) {
|
||||
SkipConvey("[Integration test] for external_image_store.webdav", t, func() {
|
||||
SkipConvey("[Integration test] for external_image_store.s3", t, func() {
|
||||
setting.NewConfigContext(&setting.CommandLineArgs{
|
||||
HomePath: "../../../",
|
||||
})
|
||||
|
||||
s3Uploader, _ := NewImageUploader()
|
||||
|
||||
path, err := s3Uploader.Upload("../../../public/img/logo_transparent_400x.png")
|
||||
path, err := s3Uploader.Upload(context.Background(), "../../../public/img/logo_transparent_400x.png")
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
So(path, ShouldNotEqual, "")
|
||||
|
||||
@@ -2,6 +2,7 @@ package imguploader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
@@ -14,14 +15,17 @@ import (
|
||||
)
|
||||
|
||||
type WebdavUploader struct {
|
||||
url string
|
||||
username string
|
||||
password string
|
||||
url string
|
||||
username string
|
||||
password string
|
||||
public_url string
|
||||
}
|
||||
|
||||
var netTransport = &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
Dial: (&net.Dialer{
|
||||
Timeout: 60 * time.Second,
|
||||
Timeout: 60 * time.Second,
|
||||
DualStack: true,
|
||||
}).Dial,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
}
|
||||
@@ -31,9 +35,10 @@ var netClient = &http.Client{
|
||||
Transport: netTransport,
|
||||
}
|
||||
|
||||
func (u *WebdavUploader) Upload(pa string) (string, error) {
|
||||
func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error) {
|
||||
url, _ := url.Parse(u.url)
|
||||
url.Path = path.Join(url.Path, util.GetRandomString(20)+".png")
|
||||
filename := util.GetRandomString(20) + ".png"
|
||||
url.Path = path.Join(url.Path, filename)
|
||||
|
||||
imgData, err := ioutil.ReadFile(pa)
|
||||
req, err := http.NewRequest("PUT", url.String(), bytes.NewReader(imgData))
|
||||
@@ -53,13 +58,20 @@ func (u *WebdavUploader) Upload(pa string) (string, error) {
|
||||
return "", fmt.Errorf("Failed to upload image. Returned statuscode %v body %s", res.StatusCode, body)
|
||||
}
|
||||
|
||||
if u.public_url != "" {
|
||||
publicURL, _ := url.Parse(u.public_url)
|
||||
publicURL.Path = path.Join(publicURL.Path, filename)
|
||||
return publicURL.String(), nil
|
||||
}
|
||||
|
||||
return url.String(), nil
|
||||
}
|
||||
|
||||
func NewWebdavImageUploader(url, username, passwrod string) (*WebdavUploader, error) {
|
||||
func NewWebdavImageUploader(url, username, password, public_url string) (*WebdavUploader, error) {
|
||||
return &WebdavUploader{
|
||||
url: url,
|
||||
username: username,
|
||||
password: passwrod,
|
||||
url: url,
|
||||
username: username,
|
||||
password: password,
|
||||
public_url: public_url,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
package imguploader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestUploadToWebdav(t *testing.T) {
|
||||
webdavUploader, _ := NewWebdavImageUploader("http://localhost:9998/dav/", "username", "password")
|
||||
|
||||
// Can be tested with this docker container: https://hub.docker.com/r/morrisjobke/webdav/
|
||||
SkipConvey("[Integration test] for external_image_store.webdav", t, func() {
|
||||
path, err := webdavUploader.Upload("../../../public/img/logo_transparent_400x.png")
|
||||
webdavUploader, _ := NewWebdavImageUploader("http://localhost:8888/webdav/", "test", "test", "")
|
||||
path, err := webdavUploader.Upload(context.Background(), "../../../public/img/logo_transparent_400x.png")
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
So(path, ShouldNotEqual, "")
|
||||
So(path, ShouldStartWith, "http://localhost:8888/webdav/")
|
||||
})
|
||||
|
||||
SkipConvey("[Integration test] for external_image_store.webdav with public url", t, func() {
|
||||
webdavUploader, _ := NewWebdavImageUploader("http://localhost:8888/webdav/", "test", "test", "http://publicurl:8888/webdav")
|
||||
path, err := webdavUploader.Upload(context.Background(), "../../../public/img/logo_transparent_400x.png")
|
||||
|
||||
So(err, ShouldBeNil)
|
||||
So(path, ShouldStartWith, "http://publicurl:8888/webdav/")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -15,19 +16,25 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/middleware"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
type RenderOpts struct {
|
||||
Path string
|
||||
Width string
|
||||
Height string
|
||||
Timeout string
|
||||
OrgId int64
|
||||
Timezone string
|
||||
Path string
|
||||
Width string
|
||||
Height string
|
||||
Timeout string
|
||||
OrgId int64
|
||||
UserId int64
|
||||
OrgRole models.RoleType
|
||||
Timezone string
|
||||
IsAlertContext bool
|
||||
Encoding string
|
||||
}
|
||||
|
||||
var ErrTimeout = errors.New("Timeout error. You can set timeout in seconds with &timeout url parameter")
|
||||
var rendererLog log.Logger = log.New("png-renderer")
|
||||
|
||||
func isoTimeOffsetToPosixTz(isoOffset string) string {
|
||||
@@ -65,38 +72,58 @@ func RenderToPng(params *RenderOpts) (string, error) {
|
||||
localDomain = setting.HttpAddr
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s://%s:%s/%s", setting.Protocol, localDomain, setting.HttpPort, params.Path)
|
||||
// &render=1 signals to the legacy redirect layer to
|
||||
// avoid redirect these requests.
|
||||
url := fmt.Sprintf("%s://%s:%s/%s&render=1", setting.Protocol, localDomain, setting.HttpPort, params.Path)
|
||||
|
||||
binPath, _ := filepath.Abs(filepath.Join(setting.PhantomDir, executable))
|
||||
scriptPath, _ := filepath.Abs(filepath.Join(setting.PhantomDir, "render.js"))
|
||||
pngPath, _ := filepath.Abs(filepath.Join(setting.ImagesDir, util.GetRandomString(20)))
|
||||
pngPath = pngPath + ".png"
|
||||
|
||||
renderKey := middleware.AddRenderAuthKey(params.OrgId)
|
||||
orgRole := params.OrgRole
|
||||
if params.IsAlertContext {
|
||||
orgRole = models.ROLE_ADMIN
|
||||
}
|
||||
renderKey := middleware.AddRenderAuthKey(params.OrgId, params.UserId, orgRole)
|
||||
defer middleware.RemoveRenderAuthKey(renderKey)
|
||||
|
||||
timeout, err := strconv.Atoi(params.Timeout)
|
||||
if err != nil {
|
||||
timeout = 15
|
||||
}
|
||||
|
||||
phantomDebugArg := "--debug=false"
|
||||
if log.GetLogLevelFor("png-renderer") >= log.LvlDebug {
|
||||
phantomDebugArg = "--debug=true"
|
||||
}
|
||||
|
||||
cmdArgs := []string{
|
||||
"--ignore-ssl-errors=true",
|
||||
"--web-security=false",
|
||||
phantomDebugArg,
|
||||
scriptPath,
|
||||
"url=" + url,
|
||||
"width=" + params.Width,
|
||||
"height=" + params.Height,
|
||||
"png=" + pngPath,
|
||||
"domain=" + localDomain,
|
||||
"timeout=" + strconv.Itoa(timeout),
|
||||
"renderKey=" + renderKey,
|
||||
}
|
||||
|
||||
if params.Encoding != "" {
|
||||
cmdArgs = append([]string{fmt.Sprintf("--output-encoding=%s", params.Encoding)}, cmdArgs...)
|
||||
}
|
||||
|
||||
cmd := exec.Command(binPath, cmdArgs...)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
output, err := cmd.StdoutPipe()
|
||||
|
||||
if err != nil {
|
||||
rendererLog.Error("Could not acquire stdout pipe", err)
|
||||
return "", err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd.Stderr = cmd.Stdout
|
||||
|
||||
if params.Timezone != "" {
|
||||
baseEnviron := os.Environ()
|
||||
@@ -105,29 +132,27 @@ func RenderToPng(params *RenderOpts) (string, error) {
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
rendererLog.Error("Could not start command", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
go io.Copy(os.Stdout, stdout)
|
||||
go io.Copy(os.Stdout, stderr)
|
||||
logWriter := log.NewLogWriter(rendererLog, log.LvlDebug, "[phantom] ")
|
||||
go io.Copy(logWriter, output)
|
||||
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
cmd.Wait()
|
||||
if err := cmd.Wait(); err != nil {
|
||||
rendererLog.Error("failed to render an image", "error", err)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
timeout, err := strconv.Atoi(params.Timeout)
|
||||
if err != nil {
|
||||
timeout = 15
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(time.Duration(timeout) * time.Second):
|
||||
if err := cmd.Process.Kill(); err != nil {
|
||||
rendererLog.Error("failed to kill", "error", err)
|
||||
}
|
||||
return "", fmt.Errorf("PhantomRenderer::renderToPng timeout (>%vs)", timeout)
|
||||
return "", ErrTimeout
|
||||
case <-done:
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package securejsondata
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/log"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
@@ -10,7 +11,12 @@ type SecureJsonData map[string][]byte
|
||||
func (s SecureJsonData) Decrypt() map[string]string {
|
||||
decrypted := make(map[string]string)
|
||||
for key, data := range s {
|
||||
decrypted[key] = string(util.Decrypt(data, setting.SecretKey))
|
||||
decryptedData, err := util.Decrypt(data, setting.SecretKey)
|
||||
if err != nil {
|
||||
log.Fatal(4, err.Error())
|
||||
}
|
||||
|
||||
decrypted[key] = string(decryptedData)
|
||||
}
|
||||
return decrypted
|
||||
}
|
||||
@@ -18,7 +24,12 @@ func (s SecureJsonData) Decrypt() map[string]string {
|
||||
func GetEncryptedJsonData(sjd map[string]string) SecureJsonData {
|
||||
encrypted := make(SecureJsonData)
|
||||
for key, data := range sjd {
|
||||
encrypted[key] = util.Encrypt([]byte(data), setting.SecretKey)
|
||||
encryptedData, err := util.Encrypt([]byte(data), setting.SecretKey)
|
||||
if err != nil {
|
||||
log.Fatal(4, err.Error())
|
||||
}
|
||||
|
||||
encrypted[key] = encryptedData
|
||||
}
|
||||
return encrypted
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user