diff --git a/conf/defaults.ini b/conf/defaults.ini index 56fe0791c0d..d67385d1db5 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -366,6 +366,9 @@ data_keys_cache_cleanup_interval = 1m #################################### Snapshots ########################### [snapshots] +# set to false to remove snapshot functionality +enabled = true + # snapshot sharing options external_enabled = true external_snapshot_url = https://snapshots.raintank.io diff --git a/conf/sample.ini b/conf/sample.ini index 799151eb864..546f828ac45 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -372,6 +372,9 @@ #################################### Snapshots ########################### [snapshots] +# set to false to remove snapshot functionality +;enabled = true + # snapshot sharing options ;external_enabled = true ;external_snapshot_url = https://snapshots.raintank.io diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index dfbde50d60a..804fa0b2601 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -152,6 +152,7 @@ export interface BootData { */ export interface GrafanaConfig { isPublicDashboardView: boolean; + snapshotEnabled: boolean; datasources: { [str: string]: DataSourceInstanceSettings }; panels: { [key: string]: PanelPluginMeta }; auth: AuthSettings; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index a9ef4dbf9b3..1382f8c6aba 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -27,6 +27,7 @@ export interface AzureSettings { export class GrafanaBootConfig implements GrafanaConfig { isPublicDashboardView: boolean; + snapshotEnabled = true; datasources: { [str: string]: DataSourceInstanceSettings } = {}; panels: { [key: string]: PanelPluginMeta } = {}; auth: AuthSettings = {}; diff --git a/pkg/api/api.go b/pkg/api/api.go index 18fc62a9d84..807c33fe786 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -701,7 +701,7 @@ func (hs *HTTPServer) registerRoutes() { // Snapshots r.Post("/api/snapshots/", reqSnapshotPublicModeOrSignedIn, hs.CreateDashboardSnapshot) - r.Get("/api/snapshot/shared-options/", reqSignedIn, GetSharingOptions) + r.Get("/api/snapshot/shared-options/", reqSignedIn, hs.GetSharingOptions) r.Get("/api/snapshots/:key", routing.Wrap(hs.GetDashboardSnapshot)) r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrSignedIn, routing.Wrap(hs.DeleteDashboardSnapshotByDeleteKey)) r.Delete("/api/snapshots/:key", reqSignedIn, routing.Wrap(hs.DeleteDashboardSnapshot)) diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index aa8033a2d5c..5d017a82838 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -33,11 +33,12 @@ var client = &http.Client{ // Responses: // 200: getSharingOptionsResponse // 401: unauthorisedError -func GetSharingOptions(c *models.ReqContext) { +func (hs *HTTPServer) GetSharingOptions(c *models.ReqContext) { c.JSON(http.StatusOK, util.DynMap{ - "externalSnapshotURL": setting.ExternalSnapshotUrl, - "externalSnapshotName": setting.ExternalSnapshotName, - "externalEnabled": setting.ExternalEnabled, + "snapshotEnabled": hs.Cfg.SnapshotEnabled, + "externalSnapshotURL": hs.Cfg.ExternalSnapshotUrl, + "externalSnapshotName": hs.Cfg.ExternalSnapshotName, + "externalEnabled": hs.Cfg.ExternalEnabled, }) } @@ -48,7 +49,7 @@ type CreateExternalSnapshotResponse struct { DeleteUrl string `json:"deleteUrl"` } -func createExternalDashboardSnapshot(cmd dashboardsnapshots.CreateDashboardSnapshotCommand) (*CreateExternalSnapshotResponse, error) { +func createExternalDashboardSnapshot(cmd dashboardsnapshots.CreateDashboardSnapshotCommand, externalSnapshotUrl string) (*CreateExternalSnapshotResponse, error) { var createSnapshotResponse CreateExternalSnapshotResponse message := map[string]interface{}{ "name": cmd.Name, @@ -63,28 +64,28 @@ func createExternalDashboardSnapshot(cmd dashboardsnapshots.CreateDashboardSnaps return nil, err } - response, err := client.Post(setting.ExternalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes)) + resp, err := client.Post(externalSnapshotUrl+"/api/snapshots", "application/json", bytes.NewBuffer(messageBytes)) if err != nil { return nil, err } defer func() { - if err := response.Body.Close(); err != nil { + if err := resp.Body.Close(); err != nil { plog.Warn("Failed to close response body", "err", err) } }() - if response.StatusCode != 200 { - return nil, fmt.Errorf("create external snapshot response status code %d", response.StatusCode) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("create external snapshot response status code %d", resp.StatusCode) } - if err := json.NewDecoder(response.Body).Decode(&createSnapshotResponse); err != nil { + if err := json.NewDecoder(resp.Body).Decode(&createSnapshotResponse); err != nil { return nil, err } return &createSnapshotResponse, nil } -func createOriginalDashboardURL(appURL string, cmd *dashboardsnapshots.CreateDashboardSnapshotCommand) (string, error) { +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") @@ -105,6 +106,11 @@ func createOriginalDashboardURL(appURL string, cmd *dashboardsnapshots.CreateDas // 403: forbiddenError // 500: internalServerError func (hs *HTTPServer) CreateDashboardSnapshot(c *models.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) @@ -117,28 +123,28 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res cmd.ExternalURL = "" cmd.OrgID = c.OrgID cmd.UserID = c.UserID - originalDashboardURL, err := createOriginalDashboardURL(hs.Cfg.AppURL, &cmd) + originalDashboardURL, err := createOriginalDashboardURL(&cmd) if err != nil { return response.Error(http.StatusInternalServerError, "Invalid app URL", err) } if cmd.External { - if !setting.ExternalEnabled { + if !hs.Cfg.ExternalEnabled { c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil) return nil } - response, err := createExternalDashboardSnapshot(cmd) + resp, err := createExternalDashboardSnapshot(cmd, hs.Cfg.ExternalSnapshotUrl) if err != nil { c.JsonApiErr(http.StatusInternalServerError, "Failed to create external snapshot", err) return nil } - snapshotUrl = response.Url - cmd.Key = response.Key - cmd.DeleteKey = response.DeleteKey - cmd.ExternalURL = response.Url - cmd.ExternalDeleteURL = response.DeleteUrl + 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() @@ -195,6 +201,11 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *models.ReqContext) response.Res // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) GetDashboardSnapshot(c *models.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) @@ -230,26 +241,26 @@ func (hs *HTTPServer) GetDashboardSnapshot(c *models.ReqContext) response.Respon } func deleteExternalDashboardSnapshot(externalUrl string) error { - response, err := client.Get(externalUrl) + resp, err := client.Get(externalUrl) if err != nil { return err } defer func() { - if err := response.Body.Close(); err != nil { + if err := resp.Body.Close(); err != nil { plog.Warn("Failed to close response body", "err", err) } }() - if response.StatusCode == 200 { + 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 response.StatusCode == 500 { + if resp.StatusCode == 500 { var respJson map[string]interface{} - if err := json.NewDecoder(response.Body).Decode(&respJson); err != nil { + if err := json.NewDecoder(resp.Body).Decode(&respJson); err != nil { return err } @@ -258,7 +269,7 @@ func deleteExternalDashboardSnapshot(externalUrl string) error { } } - return fmt.Errorf("unexpected response when deleting external snapshot, status code: %d", response.StatusCode) + return fmt.Errorf("unexpected response when deleting external snapshot, status code: %d", resp.StatusCode) } // swagger:route GET /snapshots-delete/{deleteKey} snapshots deleteDashboardSnapshotByDeleteKey @@ -274,6 +285,11 @@ func deleteExternalDashboardSnapshot(externalUrl string) error { // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.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) @@ -314,6 +330,11 @@ func (hs *HTTPServer) DeleteDashboardSnapshotByDeleteKey(c *models.ReqContext) r // 404: notFoundError // 500: internalServerError func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.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) @@ -343,12 +364,12 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res dashboardID := queryResult.Dashboard.Get("id").MustInt64() if dashboardID != 0 { - guardian, err := guardian.New(c.Req.Context(), dashboardID, c.OrgID, c.SignedInUser) + g, err := guardian.New(c.Req.Context(), dashboardID, c.OrgID, c.SignedInUser) if err != nil { return response.Err(err) } - canEdit, err := guardian.CanEdit() + 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) @@ -379,6 +400,11 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *models.ReqContext) response.Res // 200: searchDashboardSnapshotsResponse // 500: internalServerError func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Response { + if !hs.Cfg.SnapshotEnabled { + c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) + return nil + } + query := c.Query("query") limit := c.QueryInt("limit") @@ -398,9 +424,9 @@ func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Re return response.Error(500, "Search failed", err) } - dtos := make([]*dashboardsnapshots.DashboardSnapshotDTO, len(searchQueryResult)) + dto := make([]*dashboardsnapshots.DashboardSnapshotDTO, len(searchQueryResult)) for i, snapshot := range searchQueryResult { - dtos[i] = &dashboardsnapshots.DashboardSnapshotDTO{ + dto[i] = &dashboardsnapshots.DashboardSnapshotDTO{ ID: snapshot.ID, Name: snapshot.Name, Key: snapshot.Key, @@ -414,7 +440,7 @@ func (hs *HTTPServer) SearchDashboardSnapshots(c *models.ReqContext) response.Re } } - return response.JSON(http.StatusOK, dtos) + return response.JSON(http.StatusOK, dto) } // swagger:parameters createDashboardSnapshot diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index a80eec50d8b..d0e142c391f 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -21,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/team/teamtest" + "github.com/grafana/grafana/pkg/setting" ) func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { @@ -64,7 +65,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { t.Run("When user has editor role and is not in the ACL", func(t *testing.T) { loggedInUserScenarioWithRole(t, "Should not be able to delete snapshot when calling DELETE on", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { - hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, 0, "")} + d := setUpSnapshotTest(t, 0, "") + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshot teamSvc := &teamtest.FakeService{} @@ -95,7 +97,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { rw.WriteHeader(200) externalRequest = req }) - hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, 0, ts.URL)} + d := setUpSnapshotTest(t, 0, ts.URL) + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey sc.fakeReqWithParams("GET", sc.url, map[string]string{"deleteKey": "12345"}).exec() @@ -138,7 +141,9 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { } dashSvc.On("GetDashboardACLInfoList", mock.Anything, mock.AnythingOfType("*dashboards.GetDashboardACLInfoListQuery")).Return(qResultACL, nil) guardian.InitLegacyGuardian(sc.sqlStore, dashSvc, teamSvc) - hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, 0, ts.URL), DashboardService: dashSvc} + d := setUpSnapshotTest(t, 0, ts.URL) + hs := buildHttpServer(d, true) + hs.DashboardService = dashSvc sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() @@ -159,7 +164,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { d := setUpSnapshotTest(t, testUserID, "") dashSvc := dashboards.NewFakeDashboardService(t) - hs := &HTTPServer{dashboardsnapshotsService: d, DashboardService: dashSvc} + hs := buildHttpServer(d, true) + hs.DashboardService = dashSvc sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() @@ -183,7 +189,9 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { }) dashSvc := dashboards.NewFakeDashboardService(t) - hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, testUserID, ts.URL), DashboardService: dashSvc} + d := setUpSnapshotTest(t, testUserID, ts.URL) + hs := buildHttpServer(d, true) + hs.DashboardService = dashSvc sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() @@ -204,7 +212,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { rw.WriteHeader(500) _, writeErr = rw.Write([]byte(`{"message":"Unexpected"}`)) }) - hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, testUserID, ts.URL)} + d := setUpSnapshotTest(t, testUserID, ts.URL) + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() @@ -218,7 +227,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { ts := setupRemoteServer(func(rw http.ResponseWriter, req *http.Request) { rw.WriteHeader(404) }) - hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, testUserID, ts.URL)} + d := setUpSnapshotTest(t, testUserID, ts.URL) + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() @@ -227,7 +237,8 @@ func TestDashboardSnapshotAPIEndpoint_singleSnapshot(t *testing.T) { loggedInUserScenarioWithRole(t, "Should be able to read a snapshot's unencrypted data when calling GET on", "GET", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { - hs := &HTTPServer{dashboardsnapshotsService: setUpSnapshotTest(t, 0, "")} + d := setUpSnapshotTest(t, 0, "") + hs := buildHttpServer(d, true) sc.handlerFunc = hs.GetDashboardSnapshot sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() @@ -262,7 +273,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) { "GET /snapshots/{key} should return 404 when the snapshot does not exist", "GET", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { d := setUpSnapshotTest(t) - hs := &HTTPServer{dashboardsnapshotsService: d} + hs := buildHttpServer(d, true) sc.handlerFunc = hs.GetDashboardSnapshot sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() @@ -273,7 +284,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) { "DELETE /snapshots/{key} should return 404 when the snapshot does not exist", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { d := setUpSnapshotTest(t) - hs := &HTTPServer{dashboardsnapshotsService: d} + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() @@ -284,7 +295,7 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) { "GET /snapshots-delete/{deleteKey} should return 404 when the snapshot does not exist", "DELETE", "/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", org.RoleEditor, func(sc *scenarioContext) { d := setUpSnapshotTest(t) - hs := &HTTPServer{dashboardsnapshotsService: d} + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec() @@ -295,48 +306,94 @@ func TestGetDashboardSnapshotNotFound(t *testing.T) { func TestGetDashboardSnapshotFailure(t *testing.T) { sqlmock := dbtest.NewFakeDB() - setUpSnapshotTest := func(t *testing.T) dashboardsnapshots.Service { + setUpSnapshotTest := func(t *testing.T, shouldMockDashSnapServ bool) dashboardsnapshots.Service { t.Helper() - dashSnapSvc := dashboardsnapshots.NewMockService(t) - dashSnapSvc. - On("GetDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.GetDashboardSnapshotQuery")). - Run(func(args mock.Arguments) {}). - Return(nil, errors.New("something went wrong")) - - return dashSnapSvc + if shouldMockDashSnapServ { + dashSnapSvc := dashboardsnapshots.NewMockService(t) + dashSnapSvc. + On("GetDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.GetDashboardSnapshotQuery")). + Run(func(args mock.Arguments) {}). + Return(nil, errors.New("something went wrong")) + return dashSnapSvc + } else { + return nil + } } loggedInUserScenarioWithRole(t, "GET /snapshots/{key} should return 404 when the snapshot does not exist", "GET", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { - d := setUpSnapshotTest(t) - hs := &HTTPServer{dashboardsnapshotsService: d} + d := setUpSnapshotTest(t, true) + hs := buildHttpServer(d, true) sc.handlerFunc = hs.GetDashboardSnapshot sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() assert.Equal(t, http.StatusInternalServerError, sc.resp.Code) }, sqlmock) + loggedInUserScenarioWithRole(t, + "GET /snapshots/{key} should return 403 when snapshot is disabled", "GET", + "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { + d := setUpSnapshotTest(t, false) + hs := buildHttpServer(d, false) + sc.handlerFunc = hs.GetDashboardSnapshot + sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() + + assert.Equal(t, http.StatusForbidden, sc.resp.Code) + }, sqlmock) + loggedInUserScenarioWithRole(t, "DELETE /snapshots/{key} should return 404 when the snapshot does not exist", "DELETE", "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { - d := setUpSnapshotTest(t) - hs := &HTTPServer{dashboardsnapshotsService: d} + d := setUpSnapshotTest(t, true) + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshot sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() assert.Equal(t, http.StatusInternalServerError, sc.resp.Code) }, sqlmock) + loggedInUserScenarioWithRole(t, + "DELETE /snapshots/{key} should return 403 when snapshot is disabled", "DELETE", + "/api/snapshots/12345", "/api/snapshots/:key", org.RoleEditor, func(sc *scenarioContext) { + d := setUpSnapshotTest(t, false) + hs := buildHttpServer(d, false) + sc.handlerFunc = hs.DeleteDashboardSnapshot + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"key": "12345"}).exec() + + assert.Equal(t, http.StatusForbidden, sc.resp.Code) + }, sqlmock) + loggedInUserScenarioWithRole(t, "GET /snapshots-delete/{deleteKey} should return 404 when the snapshot does not exist", "DELETE", "/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", org.RoleEditor, func(sc *scenarioContext) { - d := setUpSnapshotTest(t) - hs := &HTTPServer{dashboardsnapshotsService: d} + d := setUpSnapshotTest(t, true) + hs := buildHttpServer(d, true) sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec() assert.Equal(t, http.StatusInternalServerError, sc.resp.Code) }, sqlmock) + + loggedInUserScenarioWithRole(t, + "GET /snapshots-delete/{deleteKey} should return 403 when snapshot is disabled", "DELETE", + "/api/snapshots-delete/12345", "/api/snapshots-delete/:deleteKey", org.RoleEditor, func(sc *scenarioContext) { + d := setUpSnapshotTest(t, false) + hs := buildHttpServer(d, false) + sc.handlerFunc = hs.DeleteDashboardSnapshotByDeleteKey + sc.fakeReqWithParams("DELETE", sc.url, map[string]string{"deleteKey": "12345"}).exec() + + assert.Equal(t, http.StatusForbidden, sc.resp.Code) + }, sqlmock) +} + +func buildHttpServer(d dashboardsnapshots.Service, snapshotEnabled bool) *HTTPServer { + hs := &HTTPServer{ + dashboardsnapshotsService: d, + Cfg: &setting.Cfg{ + SnapshotEnabled: snapshotEnabled, + }, + } + return hs } diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 089b8f21a67..3c65148c382 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -209,6 +209,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "samlEnabled": hs.samlEnabled(), "samlName": hs.samlName(), "tokenExpirationDayLimit": hs.Cfg.SATokenExpirationDayLimit, + "snapshotEnabled": hs.Cfg.SnapshotEnabled, } if hs.ThumbService != nil { diff --git a/pkg/services/dashboardsnapshots/database/database.go b/pkg/services/dashboardsnapshots/database/database.go index a303b3810a1..dab53376535 100644 --- a/pkg/services/dashboardsnapshots/database/database.go +++ b/pkg/services/dashboardsnapshots/database/database.go @@ -15,13 +15,14 @@ import ( type DashboardSnapshotStore struct { store db.DB log log.Logger + cfg *setting.Cfg } // DashboardStore implements the Store interface var _ dashboardsnapshots.Store = (*DashboardSnapshotStore)(nil) -func ProvideStore(db db.DB) *DashboardSnapshotStore { - return &DashboardSnapshotStore{store: db, log: log.New("dashboardsnapshot.store")} +func ProvideStore(db db.DB, cfg *setting.Cfg) *DashboardSnapshotStore { + return &DashboardSnapshotStore{store: db, log: log.New("dashboardsnapshot.store"), cfg: cfg} } // DeleteExpiredSnapshots removes snapshots with old expiry dates. @@ -29,7 +30,7 @@ func ProvideStore(db db.DB) *DashboardSnapshotStore { // Snapshot expiry is decided by the user when they share the snapshot. func (d *DashboardSnapshotStore) DeleteExpiredSnapshots(ctx context.Context, cmd *dashboardsnapshots.DeleteExpiredSnapshotsCommand) error { return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { - if !setting.SnapShotRemoveExpired { + if !d.cfg.SnapShotRemoveExpired { d.log.Warn("[Deprecated] The snapshot_remove_expired setting is outdated. Please remove from your config.") return nil } diff --git a/pkg/services/dashboardsnapshots/database/database_test.go b/pkg/services/dashboardsnapshots/database/database_test.go index 6df8b42bcca..63f90107a6f 100644 --- a/pkg/services/dashboardsnapshots/database/database_test.go +++ b/pkg/services/dashboardsnapshots/database/database_test.go @@ -23,7 +23,7 @@ func TestIntegrationDashboardSnapshotDBAccess(t *testing.T) { t.Skip("skipping integration test") } sqlstore := db.InitTestDB(t) - dashStore := ProvideStore(sqlstore) + dashStore := ProvideStore(sqlstore, setting.NewCfg()) origSecret := setting.SecretKey setting.SecretKey = "dashboard_snapshot_testing" @@ -154,10 +154,10 @@ func TestIntegrationDeleteExpiredSnapshots(t *testing.T) { t.Skip("skipping integration test") } sqlstore := db.InitTestDB(t) - dashStore := ProvideStore(sqlstore) + dashStore := ProvideStore(sqlstore, setting.NewCfg()) t.Run("Testing dashboard snapshots clean up", func(t *testing.T) { - setting.SnapShotRemoveExpired = true + dashStore.cfg.SnapShotRemoveExpired = true nonExpiredSnapshot := createTestSnapshot(t, dashStore, "key1", 48000) createTestSnapshot(t, dashStore, "key2", -1200) diff --git a/pkg/services/dashboardsnapshots/service/service_test.go b/pkg/services/dashboardsnapshots/service/service_test.go index 27ef9761518..d18e77cea67 100644 --- a/pkg/services/dashboardsnapshots/service/service_test.go +++ b/pkg/services/dashboardsnapshots/service/service_test.go @@ -17,7 +17,7 @@ import ( func TestDashboardSnapshotsService(t *testing.T) { sqlStore := db.InitTestDB(t) - dsStore := dashsnapdb.ProvideStore(sqlStore) + dsStore := dashsnapdb.ProvideStore(sqlStore, setting.NewCfg()) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) s := ProvideService(dsStore, secretsService) diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index e7b48fe4a04..00740daa92c 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -376,13 +376,15 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b }) if c.IsSignedIn { - dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ - Text: "Snapshots", - SubTitle: "Interactive, publically available, point-in-time representations of dashboards", - Id: "dashboards/snapshots", - Url: s.cfg.AppSubURL + "/dashboard/snapshots", - Icon: "camera", - }) + if s.cfg.SnapshotEnabled { + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "Snapshots", + SubTitle: "Interactive, publically available, point-in-time representations of dashboards", + Id: "dashboards/snapshots", + Url: s.cfg.AppSubURL + "/dashboard/snapshots", + Icon: "camera", + }) + } dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ Text: "Library panels", diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 80830297702..e4f6d211b68 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -87,12 +87,6 @@ var ( CookieSameSiteDisabled bool CookieSameSiteMode http.SameSite - // Snapshots - ExternalSnapshotUrl string - ExternalSnapshotName string - ExternalEnabled bool - SnapShotRemoveExpired bool - // Dashboard history DashboardVersionsToKeep int MinRefreshInterval string @@ -407,6 +401,12 @@ type Cfg struct { DataSourceLimit int // Snapshots + SnapshotEnabled bool + ExternalSnapshotUrl string + ExternalSnapshotName string + ExternalEnabled bool + SnapShotRemoveExpired bool + SnapshotPublicMode bool ErrTemplateName string @@ -1702,11 +1702,13 @@ func IsLegacyAlertingEnabled() bool { func readSnapshotsSettings(cfg *Cfg, iniFile *ini.File) error { snapshots := iniFile.Section("snapshots") - ExternalSnapshotUrl = valueAsString(snapshots, "external_snapshot_url", "") - ExternalSnapshotName = valueAsString(snapshots, "external_snapshot_name", "") + cfg.SnapshotEnabled = snapshots.Key("enabled").MustBool(true) - ExternalEnabled = snapshots.Key("external_enabled").MustBool(true) - SnapShotRemoveExpired = snapshots.Key("snapshot_remove_expired").MustBool(true) + cfg.ExternalSnapshotUrl = valueAsString(snapshots, "external_snapshot_url", "") + cfg.ExternalSnapshotName = valueAsString(snapshots, "external_snapshot_name", "") + + cfg.ExternalEnabled = snapshots.Key("external_enabled").MustBool(true) + cfg.SnapShotRemoveExpired = snapshots.Key("snapshot_remove_expired").MustBool(true) cfg.SnapshotPublicMode = snapshots.Key("public_mode").MustBool(false) return nil diff --git a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx index 8064761de1e..357effdee2f 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -27,22 +27,11 @@ export function addPanelShareTab(tab: ShareModalTabModel) { customPanelTabs.push(tab); } -function getInitialState(props: Props): State { - const { tabs, activeTab } = getTabs(props); - - return { - tabs, - activeTab, - }; -} - -function getTabs(props: Props) { - const { panel, activeTab } = props; - +function getTabs(panel?: PanelModel, activeTab?: string) { const linkLabel = t('share-modal.tab-title.link', 'Link'); const tabs: ShareModalTabModel[] = [{ label: linkLabel, value: 'link', component: ShareLink }]; - if (contextSrv.isSignedIn) { + if (contextSrv.isSignedIn && config.snapshotEnabled) { const snapshotLabel = t('share-modal.tab-title.snapshot', 'Snapshot'); tabs.push({ label: snapshotLabel, value: 'snapshot', component: ShareSnapshot }); } @@ -87,6 +76,15 @@ interface State { activeTab: string; } +function getInitialState(props: Props): State { + const { tabs, activeTab } = getTabs(props.panel, props.activeTab); + + return { + tabs, + activeTab, + }; +} + export class ShareModal extends React.Component { constructor(props: Props) { super(props); @@ -98,13 +96,9 @@ export class ShareModal extends React.Component { } onSelectTab = (t: any) => { - this.setState({ activeTab: t.value }); + this.setState((prevState) => ({ ...prevState, activeTab: t.value })); }; - getTabs() { - return getTabs(this.props).tabs; - } - getActiveTab() { const { tabs, activeTab } = this.state; return tabs.find((t) => t.value === activeTab)!; @@ -114,12 +108,13 @@ export class ShareModal extends React.Component { const { panel } = this.props; const { activeTab } = this.state; const title = panel ? t('share-modal.panel.title', 'Share Panel') : t('share-modal.dashboard.title', 'Share'); + const tabs = getTabs(this.props.panel, this.state.activeTab).tabs; return (