grafana/pkg/api/dashboard_snapshot.go

524 lines
15 KiB
Go
Raw Normal View History

2015-03-21 07:53:16 -05:00
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/services/auth/identity"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/setting"
2015-03-21 07:53:16 -05:00
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
2015-03-21 07:53:16 -05:00
)
var client = &http.Client{
Timeout: time.Second * 5,
Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
}
// swagger:route GET /snapshot/shared-options snapshots getSharingOptions
//
// Get snapshot sharing settings.
//
// Responses:
// 200: getSharingOptionsResponse
// 401: unauthorisedError
func (hs *HTTPServer) GetSharingOptions(c *contextmodel.ReqContext) {
2022-04-15 07:01:58 -05:00
c.JSON(http.StatusOK, util.DynMap{
"snapshotEnabled": hs.Cfg.SnapshotEnabled,
"externalSnapshotURL": hs.Cfg.ExternalSnapshotUrl,
"externalSnapshotName": hs.Cfg.ExternalSnapshotName,
"externalEnabled": hs.Cfg.ExternalEnabled,
})
}
type CreateExternalSnapshotResponse struct {
Key string `json:"key"`
DeleteKey string `json:"deleteKey"`
Url string `json:"url"`
DeleteUrl string `json:"deleteUrl"`
}
func createExternalDashboardSnapshot(cmd dashboardsnapshots.CreateDashboardSnapshotCommand, externalSnapshotUrl string) (*CreateExternalSnapshotResponse, error) {
var createSnapshotResponse CreateExternalSnapshotResponse
message := map[string]any{
"name": cmd.Name,
"expires": cmd.Expires,
"dashboard": cmd.Dashboard,
"key": cmd.Key,
"deleteKey": cmd.DeleteKey,
}
messageBytes, err := simplejson.NewFromAny(message).Encode()
if err != nil {
return nil, err
}
resp, err := client.Post(externalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes))
if err != nil {
return nil, err
}
defer func() {
if err := resp.Body.Close(); err != nil {
plog.Warn("Failed to close response body", "err", err)
}
}()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("create external snapshot response status code %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&createSnapshotResponse); err != nil {
return nil, err
}
return &createSnapshotResponse, nil
}
func createOriginalDashboardURL(cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (string, error) {
dashUID := cmd.Dashboard.Get("uid").MustString("")
if ok := util.IsValidShortUID(dashUID); !ok {
return "", fmt.Errorf("invalid dashboard UID")
}
return fmt.Sprintf("/d/%v", dashUID), nil
}
// swagger:route POST /snapshots snapshots createDashboardSnapshot
//
// When creating a snapshot using the API, you have to provide the full dashboard payload including the snapshot data. This endpoint is designed for the Grafana UI.
//
// Snapshot public mode should be enabled or authentication is required.
//
// Responses:
// 200: createDashboardSnapshotResponse
// 401: unauthorisedError
// 403: forbiddenError
// 500: internalServerError
func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) response.Response {
if !hs.Cfg.SnapshotEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
return nil
}
cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
if cmd.Name == "" {
cmd.Name = "Unnamed snapshot"
}
userID, err := identity.UserIdentifier(c.SignedInUser.GetNamespacedID())
if err != nil {
return response.Error(http.StatusInternalServerError,
"Failed to create external snapshot", err)
}
var snapshotUrl string
cmd.ExternalURL = ""
cmd.OrgID = c.SignedInUser.GetOrgID()
cmd.UserID = userID
originalDashboardURL, err := createOriginalDashboardURL(&cmd)
if err != nil {
return response.Error(http.StatusInternalServerError, "Invalid app URL", err)
}
if cmd.External {
if !hs.Cfg.ExternalEnabled {
c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
return nil
}
resp, err := createExternalDashboardSnapshot(cmd, hs.Cfg.ExternalSnapshotUrl)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Failed to create external snapshot", err)
return nil
}
snapshotUrl = resp.Url
cmd.Key = resp.Key
cmd.DeleteKey = resp.DeleteKey
cmd.ExternalURL = resp.Url
cmd.ExternalDeleteURL = resp.DeleteUrl
cmd.Dashboard = simplejson.New()
metrics.MApiDashboardSnapshotExternal.Inc()
} else {
cmd.Dashboard.SetPath([]string{"snapshot", "originalUrl"}, originalDashboardURL)
if cmd.Key == "" {
var err error
cmd.Key, err = util.GetRandomString(32)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
return nil
}
}
if cmd.DeleteKey == "" {
var err error
cmd.DeleteKey, err = util.GetRandomString(32)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
return nil
}
}
snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key)
metrics.MApiDashboardSnapshotCreate.Inc()
}
2015-03-21 07:53:16 -05:00
result, err := hs.dashboardsnapshotsService.CreateDashboardSnapshot(c.Req.Context(), &cmd)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Failed to create snapshot", err)
return nil
2015-03-21 07:53:16 -05:00
}
2022-04-15 07:01:58 -05:00
c.JSON(http.StatusOK, util.DynMap{
"key": cmd.Key,
"deleteKey": cmd.DeleteKey,
"url": snapshotUrl,
"deleteUrl": setting.ToAbsUrl("api/snapshots-delete/" + cmd.DeleteKey),
"id": result.ID,
})
return nil
}
// GET /api/snapshots/:key
// swagger:route GET /snapshots/{key} snapshots getDashboardSnapshot
//
// Get Snapshot by Key.
//
// Responses:
// 200: getDashboardSnapshotResponse
// 400: badRequestError
// 404: notFoundError
// 500: internalServerError
func (hs *HTTPServer) GetDashboardSnapshot(c *contextmodel.ReqContext) response.Response {
if !hs.Cfg.SnapshotEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
return nil
}
key := web.Params(c.Req)[":key"]
if len(key) == 0 {
return response.Error(http.StatusBadRequest, "Empty snapshot key", nil)
}
query := &dashboardsnapshots.GetDashboardSnapshotQuery{Key: key}
2015-03-21 07:53:16 -05:00
queryResult, err := hs.dashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query)
2015-03-21 07:53:16 -05:00
if err != nil {
return response.Err(err)
2015-03-21 07:53:16 -05:00
}
snapshot := queryResult
// expired snapshots should also be removed from db
if snapshot.Expires.Before(time.Now()) {
return response.Error(404, "Dashboard snapshot not found", err)
}
dto := dtos.DashboardFullWithMeta{
Dashboard: snapshot.Dashboard,
Meta: dtos.DashboardMeta{
Type: dashboards.DashTypeSnapshot,
IsSnapshot: true,
Created: snapshot.Created,
Expires: snapshot.Expires,
},
}
metrics.MApiDashboardSnapshotGet.Inc()
2015-03-24 10:49:12 -05:00
2022-04-15 07:01:58 -05:00
return response.JSON(http.StatusOK, dto).SetHeader("Cache-Control", "public, max-age=3600")
}
func deleteExternalDashboardSnapshot(externalUrl string) error {
resp, err := client.Get(externalUrl)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
plog.Warn("Failed to close response body", "err", err)
}
}()
if resp.StatusCode == 200 {
return nil
}
// Gracefully ignore "snapshot not found" errors as they could have already
// been removed either via the cleanup script or by request.
if resp.StatusCode == 500 {
var respJson map[string]any
if err := json.NewDecoder(resp.Body).Decode(&respJson); err != nil {
return err
}
if respJson["message"] == "Failed to get dashboard snapshot" {
return nil
}
}
return fmt.Errorf("unexpected response when deleting external snapshot, status code: %d", resp.StatusCode)
}
// swagger:route GET /snapshots-delete/{deleteKey} snapshots deleteDashboardSnapshotByDeleteKey
//
// Delete Snapshot by deleteKey.
//
// Snapshot public mode should be enabled or authentication is required.
//
// Responses:
// 200: okResponse
// 401: unauthorisedError
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *contextmodel.ReqContext) response.Response {
if !hs.Cfg.SnapshotEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
return nil
}
key := web.Params(c.Req)[":deleteKey"]
if len(key) == 0 {
return response.Error(404, "Snapshot not found", nil)
}
query := &dashboardsnapshots.GetDashboardSnapshotQuery{DeleteKey: key}
queryResult, err := hs.dashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query)
if err != nil {
return response.Err(err)
}
if queryResult.External {
err := deleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL)
if err != nil {
return response.Error(500, "Failed to delete external dashboard", err)
}
}
cmd := &dashboardsnapshots.DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey}
if err := hs.dashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil {
return response.Error(500, "Failed to delete dashboard snapshot", err)
}
2022-04-15 07:01:58 -05:00
return response.JSON(http.StatusOK, util.DynMap{
"message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.",
"id": queryResult.ID,
})
}
// swagger:route DELETE /snapshots/{key} snapshots deleteDashboardSnapshot
//
// Delete Snapshot by Key.
//
// Responses:
// 200: okResponse
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) response.Response {
if !hs.Cfg.SnapshotEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
return nil
}
key := web.Params(c.Req)[":key"]
if len(key) == 0 {
return response.Error(http.StatusNotFound, "Snapshot not found", nil)
}
query := &dashboardsnapshots.GetDashboardSnapshotQuery{Key: key}
queryResult, err := hs.dashboardsnapshotsService.GetDashboardSnapshot(c.Req.Context(), query)
if err != nil {
return response.Err(err)
}
if queryResult == nil {
return response.Error(http.StatusNotFound, "Failed to get dashboard snapshot", nil)
}
if queryResult.External {
err := deleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to delete external dashboard", err)
}
}
// Dashboard can be empty (creation error or external snapshot). This means that the mustInt here returns a 0,
// which before RBAC would result in a dashboard which has no ACL. A dashboard without an ACL would fallback
// to the users org role, which for editors and admins would essentially always be allowed here. With RBAC,
// all permissions must be explicit, so the lack of a rule for dashboard 0 means the guardian will reject.
dashboardID := queryResult.Dashboard.Get("id").MustInt64()
if dashboardID != 0 {
g, err := guardian.New(c.Req.Context(), dashboardID, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
if !errors.Is(err, dashboards.ErrDashboardNotFound) {
return response.Err(err)
}
} else {
canEdit, err := g.CanEdit()
// check for permissions only if the dashboard is found
if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) {
return response.Error(http.StatusInternalServerError, "Error while checking permissions for snapshot", err)
}
if !canEdit && queryResult.UserID != c.SignedInUser.UserID && !errors.Is(err, dashboards.ErrDashboardNotFound) {
return response.Error(http.StatusForbidden, "Access denied to this snapshot", nil)
}
}
}
cmd := &dashboardsnapshots.DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey}
2015-03-24 10:49:12 -05:00
if err := hs.dashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil {
return response.Error(http.StatusInternalServerError, "Failed to delete dashboard snapshot", err)
}
2022-04-15 07:01:58 -05:00
return response.JSON(http.StatusOK, util.DynMap{
"message": "Snapshot deleted. It might take an hour before it's cleared from any CDN caches.",
"id": queryResult.ID,
})
2015-03-21 07:53:16 -05:00
}
// swagger:route GET /dashboard/snapshots snapshots searchDashboardSnapshots
//
// List snapshots.
//
// Responses:
// 200: searchDashboardSnapshotsResponse
// 500: internalServerError
func (hs *HTTPServer) SearchDashboardSnapshots(c *contextmodel.ReqContext) response.Response {
if !hs.Cfg.SnapshotEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
return nil
}
2016-01-19 07:05:24 -06:00
query := c.Query("query")
limit := c.QueryInt("limit")
2016-01-19 07:05:24 -06:00
if limit == 0 {
limit = 1000
}
searchQuery := dashboardsnapshots.GetDashboardSnapshotsQuery{
Name: query,
Limit: limit,
OrgID: c.SignedInUser.GetOrgID(),
SignedInUser: c.SignedInUser,
2016-01-19 07:05:24 -06:00
}
searchQueryResult, err := hs.dashboardsnapshotsService.SearchDashboardSnapshots(c.Req.Context(), &searchQuery)
2016-01-19 07:05:24 -06:00
if err != nil {
return response.Error(500, "Search failed", err)
2016-01-19 07:05:24 -06:00
}
dto := make([]*dashboardsnapshots.DashboardSnapshotDTO, len(searchQueryResult))
for i, snapshot := range searchQueryResult {
dto[i] = &dashboardsnapshots.DashboardSnapshotDTO{
ID: snapshot.ID,
2016-01-20 01:23:44 -06:00
Name: snapshot.Name,
Key: snapshot.Key,
OrgID: snapshot.OrgID,
UserID: snapshot.UserID,
2016-01-20 01:23:44 -06:00
External: snapshot.External,
ExternalURL: snapshot.ExternalURL,
2016-01-20 01:23:44 -06:00
Expires: snapshot.Expires,
Created: snapshot.Created,
Updated: snapshot.Updated,
}
}
return response.JSON(http.StatusOK, dto)
}
// swagger:parameters createDashboardSnapshot
type CreateSnapshotParams struct {
// in:body
// required:true
Body dashboardsnapshots.CreateDashboardSnapshotCommand `json:"body"`
}
// swagger:parameters searchDashboardSnapshots
type GetSnapshotsParams struct {
// Search Query
// in:query
Query string `json:"query"`
// Limit the number of returned results
// in:query
// default:1000
Limit int64 `json:"limit"`
}
// swagger:parameters getDashboardSnapshot
type GetDashboardSnapshotParams struct {
// in:path
Key string `json:"key"`
}
// swagger:parameters deleteDashboardSnapshot
type DeleteDashboardSnapshotParams struct {
// in:path
Key string `json:"key"`
}
// swagger:parameters deleteDashboardSnapshotByDeleteKey
type DeleteSnapshotByDeleteKeyParams struct {
// in:path
DeleteKey string `json:"deleteKey"`
}
// swagger:response createDashboardSnapshotResponse
type CreateSnapshotResponse struct {
// in:body
Body struct {
// Unique key
Key string `json:"key"`
// Unique key used to delete the snapshot. It is different from the key so that only the creator can delete the snapshot.
DeleteKey string `json:"deleteKey"`
URL string `json:"url"`
DeleteUrl string `json:"deleteUrl"`
// Snapshot id
ID int64 `json:"id"`
} `json:"body"`
}
// swagger:response searchDashboardSnapshotsResponse
type SearchDashboardSnapshotsResponse struct {
// in:body
Body []*dashboardsnapshots.DashboardSnapshotDTO `json:"body"`
}
// swagger:response getDashboardSnapshotResponse
type GetDashboardSnapshotResponse DashboardResponse
// swagger:response getSharingOptionsResponse
type GetSharingOptionsResponse struct {
// in:body
Body struct {
ExternalSnapshotURL string `json:"externalSnapshotURL"`
ExternalSnapshotName string `json:"externalSnapshotName"`
ExternalEnabled bool `json:"externalEnabled"`
} `json:"body"`
}