mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
WIP: add permission check for GetDashboard
This commit is contained in:
parent
ef0facc987
commit
3913f16550
@ -234,7 +234,7 @@ func (hs *HttpServer) registerRoutes() {
|
|||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
r.Group("/dashboards", func() {
|
r.Group("/dashboards", func() {
|
||||||
r.Combo("/db/:slug").Get(GetDashboard).Delete(DeleteDashboard)
|
r.Combo("/db/:slug").Get(wrap(GetDashboard)).Delete(DeleteDashboard)
|
||||||
|
|
||||||
r.Get("/id/:dashboardId/versions", wrap(GetDashboardVersions))
|
r.Get("/id/:dashboardId/versions", wrap(GetDashboardVersions))
|
||||||
r.Get("/id/:dashboardId/versions/:id", wrap(GetDashboardVersion))
|
r.Get("/id/:dashboardId/versions/:id", wrap(GetDashboardVersion))
|
||||||
|
@ -17,6 +17,7 @@ import (
|
|||||||
m "github.com/grafana/grafana/pkg/models"
|
m "github.com/grafana/grafana/pkg/models"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
"github.com/grafana/grafana/pkg/services/alerting"
|
"github.com/grafana/grafana/pkg/services/alerting"
|
||||||
|
"github.com/grafana/grafana/pkg/services/guardian"
|
||||||
"github.com/grafana/grafana/pkg/services/search"
|
"github.com/grafana/grafana/pkg/services/search"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
@ -35,24 +36,31 @@ func isDashboardStarredByUser(c *middleware.Context, dashId int64) (bool, error)
|
|||||||
return query.Result, nil
|
return query.Result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetDashboard(c *middleware.Context) {
|
func GetDashboard(c *middleware.Context) Response {
|
||||||
slug := strings.ToLower(c.Params(":slug"))
|
slug := strings.ToLower(c.Params(":slug"))
|
||||||
|
|
||||||
query := m.GetDashboardQuery{Slug: slug, OrgId: c.OrgId}
|
query := m.GetDashboardQuery{Slug: slug, OrgId: c.OrgId}
|
||||||
err := bus.Dispatch(&query)
|
err := bus.Dispatch(&query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JsonApiErr(404, "Dashboard not found", nil)
|
return ApiError(404, "Dashboard not found", err)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isStarred, err := isDashboardStarredByUser(c, query.Result.Id)
|
|
||||||
if err != nil {
|
|
||||||
c.JsonApiErr(500, "Error while checking if dashboard was starred by user", err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dash := query.Result
|
dash := query.Result
|
||||||
|
|
||||||
|
canView, canEdit, canSave, err := getPermissions(dash, c.OrgRole, c.IsGrafanaAdmin, c.OrgId, c.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return ApiError(500, "Error while checking dashboard permissions", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canView {
|
||||||
|
return ApiError(403, "Access denied to this dashboard", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
isStarred, err := isDashboardStarredByUser(c, dash.Id)
|
||||||
|
if err != nil {
|
||||||
|
return ApiError(500, "Error while checking if dashboard was starred by user", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Finding creator and last updater of the dashboard
|
// Finding creator and last updater of the dashboard
|
||||||
updater, creator := "Anonymous", "Anonymous"
|
updater, creator := "Anonymous", "Anonymous"
|
||||||
if dash.UpdatedBy > 0 {
|
if dash.UpdatedBy > 0 {
|
||||||
@ -72,8 +80,8 @@ func GetDashboard(c *middleware.Context) {
|
|||||||
Slug: slug,
|
Slug: slug,
|
||||||
Type: m.DashTypeDB,
|
Type: m.DashTypeDB,
|
||||||
CanStar: c.IsSignedIn,
|
CanStar: c.IsSignedIn,
|
||||||
CanSave: c.OrgRole == m.ROLE_ADMIN || c.OrgRole == m.ROLE_EDITOR,
|
CanSave: canSave,
|
||||||
CanEdit: canEditDashboard(c.OrgRole),
|
CanEdit: canEdit,
|
||||||
Created: dash.Created,
|
Created: dash.Created,
|
||||||
Updated: dash.Updated,
|
Updated: dash.Updated,
|
||||||
UpdatedBy: updater,
|
UpdatedBy: updater,
|
||||||
@ -85,9 +93,27 @@ func GetDashboard(c *middleware.Context) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(ben): copy this performance metrics logic for the new API endpoints added
|
|
||||||
c.TimeRequest(metrics.M_Api_Dashboard_Get)
|
c.TimeRequest(metrics.M_Api_Dashboard_Get)
|
||||||
c.JSON(200, dto)
|
return Json(200, dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPermissions(dash *m.Dashboard, orgRole m.RoleType, isGrafanaAdmin bool, orgId int64, userId int64) (bool, bool, bool, error) {
|
||||||
|
if !dash.HasAcl {
|
||||||
|
return true, canEditDashboard(orgRole), orgRole == m.ROLE_ADMIN || orgRole == m.ROLE_EDITOR, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dashId := dash.Id
|
||||||
|
|
||||||
|
if !dash.IsFolder {
|
||||||
|
dashId = dash.ParentId
|
||||||
|
}
|
||||||
|
|
||||||
|
canView, canEdit, canSave, err := guardian.CheckDashboardPermissions(dashId, orgRole, isGrafanaAdmin, orgId, userId)
|
||||||
|
if err != nil {
|
||||||
|
return false, false, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return canView, canEdit, canSave, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUserLogin(userId int64) string {
|
func getUserLogin(userId int64) string {
|
||||||
|
@ -23,6 +23,11 @@ func TestDashboardAclApiEndpoint(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
bus.AddHandler("test", func(query *models.GetUserGroupsByUserQuery) error {
|
||||||
|
query.Result = []*models.UserGroup{}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
Convey("When user is org admin", func() {
|
Convey("When user is org admin", func() {
|
||||||
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/1/acl", "/api/dashboards/:id/acl", models.ROLE_ADMIN, func(sc *scenarioContext) {
|
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/1/acl", "/api/dashboards/:id/acl", models.ROLE_ADMIN, func(sc *scenarioContext) {
|
||||||
Convey("Should be able to access ACL", func() {
|
Convey("Should be able to access ACL", func() {
|
||||||
|
179
pkg/api/dashboard_test.go
Normal file
179
pkg/api/dashboard_test.go
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/api/dtos"
|
||||||
|
"github.com/grafana/grafana/pkg/bus"
|
||||||
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
|
||||||
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDashboardApiEndpoint(t *testing.T) {
|
||||||
|
Convey("Given a dashboard with a parent folder which does not have an acl", t, func() {
|
||||||
|
fakeDash := models.NewDashboard("Child dash")
|
||||||
|
fakeDash.ParentId = 1
|
||||||
|
fakeDash.HasAcl = false
|
||||||
|
|
||||||
|
bus.AddHandler("test", func(query *models.GetDashboardQuery) error {
|
||||||
|
query.Result = fakeDash
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("When user is an Org Viewer", func() {
|
||||||
|
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", models.ROLE_VIEWER, func(sc *scenarioContext) {
|
||||||
|
sc.handlerFunc = GetDashboard
|
||||||
|
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||||
|
So(sc.resp.Code, ShouldEqual, 200)
|
||||||
|
|
||||||
|
dash := dtos.DashboardFullWithMeta{}
|
||||||
|
err := json.NewDecoder(sc.resp.Body).Decode(&dash)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
|
Convey("Should not be able to edit or save dashboard", func() {
|
||||||
|
So(dash.Meta.CanEdit, ShouldBeFalse)
|
||||||
|
So(dash.Meta.CanSave, ShouldBeFalse)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("When user is an Org Read Only Editor", func() {
|
||||||
|
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", models.ROLE_READ_ONLY_EDITOR, func(sc *scenarioContext) {
|
||||||
|
sc.handlerFunc = GetDashboard
|
||||||
|
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||||
|
So(sc.resp.Code, ShouldEqual, 200)
|
||||||
|
|
||||||
|
dash := dtos.DashboardFullWithMeta{}
|
||||||
|
err := json.NewDecoder(sc.resp.Body).Decode(&dash)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
|
Convey("Should be able to edit but not save the dashboard", func() {
|
||||||
|
So(dash.Meta.CanEdit, ShouldBeTrue)
|
||||||
|
So(dash.Meta.CanSave, ShouldBeFalse)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("When user is an Org Editor", func() {
|
||||||
|
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||||
|
sc.handlerFunc = GetDashboard
|
||||||
|
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||||
|
|
||||||
|
So(sc.resp.Code, ShouldEqual, 200)
|
||||||
|
|
||||||
|
dash := dtos.DashboardFullWithMeta{}
|
||||||
|
err := json.NewDecoder(sc.resp.Body).Decode(&dash)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
|
Convey("Should be able to edit or save dashboard", func() {
|
||||||
|
So(dash.Meta.CanEdit, ShouldBeTrue)
|
||||||
|
So(dash.Meta.CanSave, ShouldBeTrue)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("Given a dashboard with a parent folder which has an acl", t, func() {
|
||||||
|
fakeDash := models.NewDashboard("Child dash")
|
||||||
|
fakeDash.ParentId = 1
|
||||||
|
fakeDash.HasAcl = true
|
||||||
|
|
||||||
|
bus.AddHandler("test", func(query *models.GetDashboardQuery) error {
|
||||||
|
query.Result = fakeDash
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.AddHandler("test", func(query *models.GetUserGroupsByUserQuery) error {
|
||||||
|
query.Result = []*models.UserGroup{}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("When user is an Org Viewer and has no permissions for this dashboard", func() {
|
||||||
|
bus.AddHandler("test", func(query *models.GetDashboardPermissionsQuery) error {
|
||||||
|
query.Result = []*models.DashboardAclInfoDTO{}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", models.ROLE_VIEWER, func(sc *scenarioContext) {
|
||||||
|
sc.handlerFunc = GetDashboard
|
||||||
|
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||||
|
|
||||||
|
Convey("Should be denied access", func() {
|
||||||
|
So(sc.resp.Code, ShouldEqual, 403)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("When user is an Org Editor and has no permissions for this dashboard", func() {
|
||||||
|
bus.AddHandler("test", func(query *models.GetDashboardPermissionsQuery) error {
|
||||||
|
query.Result = []*models.DashboardAclInfoDTO{}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", models.ROLE_EDITOR, func(sc *scenarioContext) {
|
||||||
|
sc.handlerFunc = GetDashboard
|
||||||
|
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||||
|
|
||||||
|
Convey("Should be denied access", func() {
|
||||||
|
So(sc.resp.Code, ShouldEqual, 403)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("When user is an Org Viewer but has an edit permission", func() {
|
||||||
|
mockResult := []*models.DashboardAclInfoDTO{
|
||||||
|
{Id: 1, OrgId: 1, DashboardId: 2, UserId: 1, PermissionType: models.PERMISSION_EDIT},
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.AddHandler("test", func(query *models.GetDashboardPermissionsQuery) error {
|
||||||
|
query.Result = mockResult
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", models.ROLE_VIEWER, func(sc *scenarioContext) {
|
||||||
|
sc.handlerFunc = GetDashboard
|
||||||
|
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||||
|
|
||||||
|
So(sc.resp.Code, ShouldEqual, 200)
|
||||||
|
|
||||||
|
dash := dtos.DashboardFullWithMeta{}
|
||||||
|
err := json.NewDecoder(sc.resp.Body).Decode(&dash)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
|
Convey("Should be able to get dashboard with edit rights", func() {
|
||||||
|
So(dash.Meta.CanEdit, ShouldBeTrue)
|
||||||
|
So(dash.Meta.CanSave, ShouldBeTrue)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Convey("When user is an Org Editor but has a view permission", func() {
|
||||||
|
mockResult := []*models.DashboardAclInfoDTO{
|
||||||
|
{Id: 1, OrgId: 1, DashboardId: 2, UserId: 1, PermissionType: models.PERMISSION_VIEW},
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.AddHandler("test", func(query *models.GetDashboardPermissionsQuery) error {
|
||||||
|
query.Result = mockResult
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/2", "/api/dashboards/:id", models.ROLE_VIEWER, func(sc *scenarioContext) {
|
||||||
|
sc.handlerFunc = GetDashboard
|
||||||
|
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||||
|
|
||||||
|
So(sc.resp.Code, ShouldEqual, 200)
|
||||||
|
|
||||||
|
dash := dtos.DashboardFullWithMeta{}
|
||||||
|
err := json.NewDecoder(sc.resp.Body).Decode(&dash)
|
||||||
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
|
Convey("Should not be able to edit or save dashboard", func() {
|
||||||
|
So(dash.Meta.CanEdit, ShouldBeFalse)
|
||||||
|
So(dash.Meta.CanSave, ShouldBeFalse)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
@ -6,7 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RemoveRestrictedDashboards filters out dashboards from the list that the user does have access to
|
// RemoveRestrictedDashboards filters out dashboards from the list that the user does have access to
|
||||||
func RemoveRestrictedDashboards(dashList []int64, orgId int64, userId int64) ([]int64, error) {
|
func FilterRestrictedDashboards(dashList []int64, orgId int64, userId int64) ([]int64, error) {
|
||||||
user, err := getUser(userId)
|
user, err := getUser(userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -54,15 +54,59 @@ func CanDeleteFromAcl(dashboardId int64, role m.RoleType, isGrafanaAdmin bool, o
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
minimumPermission := m.PERMISSION_EDIT
|
||||||
|
return checkPermission(minimumPermission, permissions, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckDashboardPermissions determines if a user has permission to view, edit or save a dashboard
|
||||||
|
func CheckDashboardPermissions(dashboardId int64, role m.RoleType, isGrafanaAdmin bool, orgId int64, userId int64) (bool, bool, bool, error) {
|
||||||
|
if role == m.ROLE_ADMIN || isGrafanaAdmin {
|
||||||
|
return true, true, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
permissions, err := getDashboardPermissions(dashboardId)
|
||||||
|
if err != nil {
|
||||||
|
return false, false, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(permissions) == 0 {
|
||||||
|
return false, false, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
minimumPermission := m.PERMISSION_VIEW
|
||||||
|
canView, err := checkPermission(minimumPermission, permissions, userId)
|
||||||
|
if err != nil {
|
||||||
|
return false, false, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
minimumPermission = m.PERMISSION_READ_ONLY_EDIT
|
||||||
|
canEdit, err := checkPermission(minimumPermission, permissions, userId)
|
||||||
|
if err != nil {
|
||||||
|
return false, false, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
minimumPermission = m.PERMISSION_EDIT
|
||||||
|
canSave, err := checkPermission(minimumPermission, permissions, userId)
|
||||||
|
if err != nil {
|
||||||
|
return false, false, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return canView, canEdit, canSave, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPermission(minimumPermission m.PermissionType, permissions []*m.DashboardAclInfoDTO, userId int64) (bool, error) {
|
||||||
userGroups, err := getUserGroupsByUser(userId)
|
userGroups, err := getUserGroupsByUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
for _, p := range permissions {
|
for _, p := range permissions {
|
||||||
if p.UserId == userId && p.PermissionType == m.PERMISSION_EDIT {
|
if p.UserId == userId && p.PermissionType >= minimumPermission {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ug := range userGroups {
|
for _, ug := range userGroups {
|
||||||
if ug.Id == p.UserGroupId && p.PermissionType == m.PERMISSION_EDIT {
|
if ug.Id == p.UserGroupId && p.PermissionType >= minimumPermission {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,7 +22,7 @@ func TestGuardian(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
filteredHitlist, err := RemoveRestrictedDashboards(hitList, orgId, userId)
|
filteredHitlist, err := FilterRestrictedDashboards(hitList, orgId, userId)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
Convey("should return all dashboards", func() {
|
Convey("should return all dashboards", func() {
|
||||||
@ -38,7 +38,7 @@ func TestGuardian(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
filteredHitlist, err := RemoveRestrictedDashboards(hitList, orgId, userId)
|
filteredHitlist, err := FilterRestrictedDashboards(hitList, orgId, userId)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
Convey("should return all dashboards", func() {
|
Convey("should return all dashboards", func() {
|
||||||
@ -58,7 +58,7 @@ func TestGuardian(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
filteredHitlist, err := RemoveRestrictedDashboards(hitList, orgId, userId)
|
filteredHitlist, err := FilterRestrictedDashboards(hitList, orgId, userId)
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
Convey("should return dashboard that editor has access to", func() {
|
Convey("should return dashboard that editor has access to", func() {
|
||||||
|
@ -108,7 +108,7 @@ func removeRestrictedDashboardsFromList(hits HitList, query *Query) (HitList, er
|
|||||||
dashboardIds = append(dashboardIds, hit.Id)
|
dashboardIds = append(dashboardIds, hit.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredHits, err := guardian.RemoveRestrictedDashboards(dashboardIds, query.OrgId, query.UserId)
|
filteredHits, err := guardian.FilterRestrictedDashboards(dashboardIds, query.OrgId, query.UserId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user