1package features
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"strings"
8
9	"github.com/grafana/grafana-plugin-sdk-go/backend"
10
11	"github.com/grafana/grafana/pkg/bus"
12	"github.com/grafana/grafana/pkg/models"
13	"github.com/grafana/grafana/pkg/services/guardian"
14)
15
16type actionType string
17
18const (
19	ActionSaved    actionType = "saved"
20	ActionDeleted  actionType = "deleted"
21	EditingStarted actionType = "editing-started"
22	//EditingFinished actionType = "editing-finished"
23
24	GitopsChannel = "grafana/dashboard/gitops"
25)
26
27// DashboardEvent events related to dashboards
28type dashboardEvent struct {
29	UID       string                 `json:"uid"`
30	Action    actionType             `json:"action"` // saved, editing, deleted
31	User      *models.UserDisplayDTO `json:"user,omitempty"`
32	SessionID string                 `json:"sessionId,omitempty"`
33	Message   string                 `json:"message,omitempty"`
34	Dashboard *models.Dashboard      `json:"dashboard,omitempty"`
35	Error     string                 `json:"error,omitempty"`
36}
37
38// DashboardHandler manages all the `grafana/dashboard/*` channels
39type DashboardHandler struct {
40	Publisher   models.ChannelPublisher
41	ClientCount models.ChannelClientCount
42}
43
44// GetHandlerForPath called on init
45func (h *DashboardHandler) GetHandlerForPath(_ string) (models.ChannelHandler, error) {
46	return h, nil // all dashboards share the same handler
47}
48
49// OnSubscribe for now allows anyone to subscribe to any dashboard
50func (h *DashboardHandler) OnSubscribe(ctx context.Context, user *models.SignedInUser, e models.SubscribeEvent) (models.SubscribeReply, backend.SubscribeStreamStatus, error) {
51	parts := strings.Split(e.Path, "/")
52	if parts[0] == "gitops" {
53		// gitops gets all changes for everything, so lets make sure it is an admin user
54		if !user.HasRole(models.ROLE_ADMIN) {
55			return models.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, nil
56		}
57		return models.SubscribeReply{
58			Presence: true,
59		}, backend.SubscribeStreamStatusOK, nil
60	}
61
62	// make sure can view this dashboard
63	if len(parts) == 2 && parts[0] == "uid" {
64		query := models.GetDashboardQuery{Uid: parts[1], OrgId: user.OrgId}
65		if err := bus.DispatchCtx(ctx, &query); err != nil {
66			logger.Error("Error getting dashboard", "query", query, "error", err)
67			return models.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, nil
68		}
69
70		dash := query.Result
71		guard := guardian.New(ctx, dash.Id, user.OrgId, user)
72		if canView, err := guard.CanView(); err != nil || !canView {
73			return models.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, nil
74		}
75
76		return models.SubscribeReply{
77			Presence:  true,
78			JoinLeave: true,
79		}, backend.SubscribeStreamStatusOK, nil
80	}
81
82	// Unknown path
83	logger.Error("Unknown dashboard channel", "path", e.Path)
84	return models.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, nil
85}
86
87// OnPublish is called when someone begins to edit a dashboard
88func (h *DashboardHandler) OnPublish(ctx context.Context, user *models.SignedInUser, e models.PublishEvent) (models.PublishReply, backend.PublishStreamStatus, error) {
89	parts := strings.Split(e.Path, "/")
90	if parts[0] == "gitops" {
91		// gitops gets all changes for everything, so lets make sure it is an admin user
92		if !user.HasRole(models.ROLE_ADMIN) {
93			return models.PublishReply{}, backend.PublishStreamStatusPermissionDenied, nil
94		}
95
96		// Eventually this could broadcast a message back to the dashboard saying a pull request exists
97		return models.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("not implemented yet")
98	}
99
100	// make sure can view this dashboard
101	if len(parts) == 2 && parts[0] == "uid" {
102		event := dashboardEvent{}
103		err := json.Unmarshal(e.Data, &event)
104		if err != nil || event.UID != parts[1] {
105			return models.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("bad request")
106		}
107		if event.Action != EditingStarted {
108			// just ignore the event
109			return models.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("ignore???")
110		}
111		query := models.GetDashboardQuery{Uid: parts[1], OrgId: user.OrgId}
112		if err := bus.DispatchCtx(ctx, &query); err != nil {
113			logger.Error("Unknown dashboard", "query", query)
114			return models.PublishReply{}, backend.PublishStreamStatusNotFound, nil
115		}
116
117		guard := guardian.New(ctx, query.Result.Id, user.OrgId, user)
118		canEdit, err := guard.CanEdit()
119		if err != nil {
120			return models.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("internal error")
121		}
122
123		// Ignore edit events if the user can not edit
124		if !canEdit {
125			return models.PublishReply{}, backend.PublishStreamStatusNotFound, nil // NOOP
126		}
127
128		// Tell everyone who is editing
129		event.User = user.ToUserDisplayDTO()
130
131		msg, err := json.Marshal(event)
132		if err != nil {
133			return models.PublishReply{}, backend.PublishStreamStatusNotFound, fmt.Errorf("internal error")
134		}
135		return models.PublishReply{Data: msg}, backend.PublishStreamStatusOK, nil
136	}
137
138	return models.PublishReply{}, backend.PublishStreamStatusNotFound, nil
139}
140
141// DashboardSaved should broadcast to the appropriate stream
142func (h *DashboardHandler) publish(orgID int64, event dashboardEvent) error {
143	msg, err := json.Marshal(event)
144	if err != nil {
145		return err
146	}
147
148	// Only broadcast non-error events
149	if event.Error == "" {
150		err = h.Publisher(orgID, "grafana/dashboard/uid/"+event.UID, msg)
151		if err != nil {
152			return err
153		}
154	}
155
156	// Send everything to the gitops channel
157	return h.Publisher(orgID, GitopsChannel, msg)
158}
159
160// DashboardSaved will broadcast to all connected dashboards
161func (h *DashboardHandler) DashboardSaved(orgID int64, user *models.UserDisplayDTO, message string, dashboard *models.Dashboard, err error) error {
162	if err != nil && !h.HasGitOpsObserver(orgID) {
163		return nil // only broadcast if it was OK
164	}
165
166	msg := dashboardEvent{
167		UID:       dashboard.Uid,
168		Action:    ActionSaved,
169		User:      user,
170		Message:   message,
171		Dashboard: dashboard,
172	}
173
174	if err != nil {
175		msg.Error = err.Error()
176	}
177
178	return h.publish(orgID, msg)
179}
180
181// DashboardDeleted will broadcast to all connected dashboards
182func (h *DashboardHandler) DashboardDeleted(orgID int64, user *models.UserDisplayDTO, uid string) error {
183	return h.publish(orgID, dashboardEvent{
184		UID:    uid,
185		Action: ActionDeleted,
186		User:   user,
187	})
188}
189
190// HasGitOpsObserver will return true if anyone is listening to the `gitops` channel
191func (h *DashboardHandler) HasGitOpsObserver(orgID int64) bool {
192	count, err := h.ClientCount(orgID, GitopsChannel)
193	if err != nil {
194		logger.Error("error getting client count", "error", err)
195		return false
196	}
197	return count > 0
198}
199