mirror of https://github.com/grafana/grafana
parent
fcdcd63dc7
commit
e750080f00
@ -1 +1 @@ |
||||
Subproject commit 4b5eadf7b59898e6622a75e0a57081103dd78b2a |
||||
Subproject commit 79beefe57c608b3cd933c5b1f772c8707731a64c |
||||
@ -1,84 +0,0 @@ |
||||
package api |
||||
|
||||
import ( |
||||
"fmt" |
||||
"html/template" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"github.com/gorilla/sessions" |
||||
|
||||
"github.com/torkelo/grafana-pro/pkg/components" |
||||
"github.com/torkelo/grafana-pro/pkg/configuration" |
||||
"github.com/torkelo/grafana-pro/pkg/log" |
||||
"github.com/torkelo/grafana-pro/pkg/models" |
||||
"github.com/torkelo/grafana-pro/pkg/setting" |
||||
"github.com/torkelo/grafana-pro/pkg/stores" |
||||
) |
||||
|
||||
type HttpServer struct { |
||||
port string |
||||
shutdown chan bool |
||||
store stores.Store |
||||
renderer *components.PhantomRenderer |
||||
router *gin.Engine |
||||
cfg *configuration.Cfg |
||||
} |
||||
|
||||
var sessionStore = sessions.NewCookieStore([]byte("something-very-secret")) |
||||
|
||||
func NewHttpServer(cfg *configuration.Cfg, store stores.Store) *HttpServer { |
||||
self := &HttpServer{} |
||||
self.cfg = cfg |
||||
self.port = cfg.Http.Port |
||||
self.store = store |
||||
self.renderer = &components.PhantomRenderer{ImagesDir: "data/png", PhantomDir: "_vendor/phantomjs"} |
||||
|
||||
return self |
||||
} |
||||
|
||||
func (self *HttpServer) ListenAndServe() { |
||||
defer func() { self.shutdown <- true }() |
||||
|
||||
gin.SetMode(gin.ReleaseMode) |
||||
self.router = gin.New() |
||||
self.router.Use(gin.Recovery(), apiLogger(), CacheHeadersMiddleware()) |
||||
|
||||
self.router.Static("/public", "./public") |
||||
self.router.Static("/app", "./public/app") |
||||
self.router.Static("/img", "./public/img") |
||||
|
||||
// register & parse templates
|
||||
templates := template.New("templates") |
||||
templates.Delims("[[", "]]") |
||||
templates.ParseFiles("./views/index.html") |
||||
self.router.SetHTMLTemplate(templates) |
||||
|
||||
for _, fn := range routeHandlers { |
||||
fn(self) |
||||
} |
||||
|
||||
// register default route
|
||||
self.router.GET("/", self.auth(), self.index) |
||||
self.router.GET("/dashboard/*_", self.auth(), self.index) |
||||
self.router.GET("/admin/*_", self.auth(), self.index) |
||||
self.router.GET("/account/*_", self.auth(), self.index) |
||||
|
||||
listenAddr := fmt.Sprintf("%s:%s", setting.HttpAddr, setting.HttpPort) |
||||
log.Info("Listen: %v://%s%s", setting.Protocol, listenAddr, setting.AppSubUrl) |
||||
self.router.Run(listenAddr) |
||||
} |
||||
|
||||
func (self *HttpServer) index(c *gin.Context) { |
||||
viewModel := &IndexDto{} |
||||
userAccount, _ := c.Get("userAccount") |
||||
account, _ := userAccount.(*models.Account) |
||||
initCurrentUserDto(&viewModel.User, account) |
||||
|
||||
c.HTML(200, "index.html", viewModel) |
||||
} |
||||
|
||||
func CacheHeadersMiddleware() gin.HandlerFunc { |
||||
return func(c *gin.Context) { |
||||
c.Writer.Header().Add("Cache-Control", "max-age=0, public, must-revalidate, proxy-revalidate") |
||||
} |
||||
} |
||||
@ -1,147 +0,0 @@ |
||||
package api |
||||
|
||||
import ( |
||||
"strconv" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
) |
||||
|
||||
func init() { |
||||
addRoutes(func(self *HttpServer) { |
||||
self.addRoute("POST", "/api/account/collaborators/add", self.addCollaborator) |
||||
self.addRoute("POST", "/api/account/collaborators/remove", self.removeCollaborator) |
||||
self.addRoute("GET", "/api/account/", self.getAccount) |
||||
self.addRoute("GET", "/api/account/others", self.getOtherAccounts) |
||||
self.addRoute("POST", "/api/account/using/:id", self.setUsingAccount) |
||||
}) |
||||
} |
||||
|
||||
func (self *HttpServer) getAccount(c *gin.Context, auth *authContext) { |
||||
var account = auth.userAccount |
||||
|
||||
model := accountInfoDto{ |
||||
Name: account.Name, |
||||
Email: account.Email, |
||||
AccountName: account.AccountName, |
||||
} |
||||
|
||||
for _, collaborator := range account.Collaborators { |
||||
model.Collaborators = append(model.Collaborators, &collaboratorInfoDto{ |
||||
AccountId: collaborator.AccountId, |
||||
Role: collaborator.Role, |
||||
Email: collaborator.Email, |
||||
}) |
||||
} |
||||
|
||||
c.JSON(200, model) |
||||
} |
||||
|
||||
func (self *HttpServer) getOtherAccounts(c *gin.Context, auth *authContext) { |
||||
var account = auth.userAccount |
||||
|
||||
otherAccounts, err := self.store.GetOtherAccountsFor(account.Id) |
||||
if err != nil { |
||||
c.JSON(500, gin.H{"message": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
var result []*otherAccountDto |
||||
result = append(result, &otherAccountDto{ |
||||
Id: account.Id, |
||||
Role: "owner", |
||||
IsUsing: account.Id == account.UsingAccountId, |
||||
Name: account.Email, |
||||
}) |
||||
|
||||
for _, other := range otherAccounts { |
||||
result = append(result, &otherAccountDto{ |
||||
Id: other.Id, |
||||
Role: other.Role, |
||||
Name: other.Name, |
||||
IsUsing: other.Id == account.UsingAccountId, |
||||
}) |
||||
} |
||||
|
||||
c.JSON(200, result) |
||||
} |
||||
|
||||
func (self *HttpServer) addCollaborator(c *gin.Context, auth *authContext) { |
||||
var model addCollaboratorDto |
||||
|
||||
if !c.EnsureBody(&model) { |
||||
c.JSON(400, gin.H{"message": "Invalid request"}) |
||||
return |
||||
} |
||||
|
||||
collaborator, err := self.store.GetAccountByLogin(model.Email) |
||||
if err != nil { |
||||
c.JSON(404, gin.H{"message": "Collaborator not found"}) |
||||
return |
||||
} |
||||
|
||||
userAccount := auth.userAccount |
||||
|
||||
if collaborator.Id == userAccount.Id { |
||||
c.JSON(400, gin.H{"message": "Cannot add yourself as collaborator"}) |
||||
return |
||||
} |
||||
|
||||
err = userAccount.AddCollaborator(collaborator) |
||||
if err != nil { |
||||
c.JSON(400, gin.H{"message": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
err = self.store.UpdateAccount(userAccount) |
||||
if err != nil { |
||||
c.JSON(500, gin.H{"message": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
c.Abort(204) |
||||
} |
||||
|
||||
func (self *HttpServer) removeCollaborator(c *gin.Context, auth *authContext) { |
||||
var model removeCollaboratorDto |
||||
if !c.EnsureBody(&model) { |
||||
c.JSON(400, gin.H{"message": "Invalid request"}) |
||||
return |
||||
} |
||||
|
||||
account := auth.userAccount |
||||
account.RemoveCollaborator(model.AccountId) |
||||
|
||||
err := self.store.UpdateAccount(account) |
||||
if err != nil { |
||||
c.JSON(500, gin.H{"message": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
c.Abort(204) |
||||
} |
||||
|
||||
func (self *HttpServer) setUsingAccount(c *gin.Context, auth *authContext) { |
||||
idString := c.Params.ByName("id") |
||||
id, _ := strconv.Atoi(idString) |
||||
|
||||
account := auth.userAccount |
||||
otherAccount, err := self.store.GetAccount(id) |
||||
if err != nil { |
||||
c.JSON(500, gin.H{"message": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
if otherAccount.Id != account.Id && !otherAccount.HasCollaborator(account.Id) { |
||||
c.Abort(401) |
||||
return |
||||
} |
||||
|
||||
account.UsingAccountId = otherAccount.Id |
||||
err = self.store.UpdateAccount(account) |
||||
if err != nil { |
||||
c.JSON(500, gin.H{"message": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
c.Abort(204) |
||||
} |
||||
@ -1,70 +0,0 @@ |
||||
package api |
||||
|
||||
import ( |
||||
"errors" |
||||
"strconv" |
||||
|
||||
"github.com/torkelo/grafana-pro/pkg/models" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"github.com/gorilla/sessions" |
||||
) |
||||
|
||||
type authContext struct { |
||||
account *models.Account |
||||
userAccount *models.Account |
||||
} |
||||
|
||||
func (auth *authContext) getAccountId() int { |
||||
return auth.account.Id |
||||
} |
||||
|
||||
func (self *HttpServer) authDenied(c *gin.Context) { |
||||
c.Writer.Header().Set("Location", "/login") |
||||
c.Abort(302) |
||||
} |
||||
|
||||
func authGetRequestAccountId(c *gin.Context, session *sessions.Session) (int, error) { |
||||
accountId := session.Values["accountId"] |
||||
|
||||
urlQuery := c.Request.URL.Query() |
||||
if len(urlQuery["render"]) > 0 { |
||||
accId, _ := strconv.Atoi(urlQuery["accountId"][0]) |
||||
session.Values["accountId"] = accId |
||||
accountId = accId |
||||
} |
||||
|
||||
if accountId == nil { |
||||
return -1, errors.New("Auth: session account id not found") |
||||
} |
||||
|
||||
return accountId.(int), nil |
||||
} |
||||
|
||||
func (self *HttpServer) auth() gin.HandlerFunc { |
||||
return func(c *gin.Context) { |
||||
session, _ := sessionStore.Get(c.Request, "grafana-session") |
||||
accountId, err := authGetRequestAccountId(c, session) |
||||
|
||||
if err != nil && c.Request.URL.Path != "/login" { |
||||
self.authDenied(c) |
||||
return |
||||
} |
||||
|
||||
account, err := self.store.GetAccount(accountId) |
||||
if err != nil { |
||||
self.authDenied(c) |
||||
return |
||||
} |
||||
|
||||
usingAccount, err := self.store.GetAccount(account.UsingAccountId) |
||||
if err != nil { |
||||
self.authDenied(c) |
||||
return |
||||
} |
||||
|
||||
c.Set("userAccount", account) |
||||
c.Set("usingAccount", usingAccount) |
||||
session.Save(c.Request, c.Writer) |
||||
} |
||||
} |
||||
@ -1,87 +0,0 @@ |
||||
package api |
||||
|
||||
import ( |
||||
log "github.com/alecthomas/log4go" |
||||
"github.com/gin-gonic/gin" |
||||
"github.com/torkelo/grafana-pro/pkg/models" |
||||
) |
||||
|
||||
func init() { |
||||
addRoutes(func(self *HttpServer) { |
||||
self.addRoute("GET", "/api/dashboards/:slug", self.getDashboard) |
||||
self.addRoute("GET", "/api/search/", self.search) |
||||
self.addRoute("POST", "/api/dashboard/", self.postDashboard) |
||||
self.addRoute("DELETE", "/api/dashboard/:slug", self.deleteDashboard) |
||||
}) |
||||
} |
||||
|
||||
func (self *HttpServer) getDashboard(c *gin.Context, auth *authContext) { |
||||
slug := c.Params.ByName("slug") |
||||
|
||||
dash, err := self.store.GetDashboard(slug, auth.getAccountId()) |
||||
if err != nil { |
||||
c.JSON(404, newErrorResponse("Dashboard not found")) |
||||
return |
||||
} |
||||
|
||||
dash.Data["id"] = dash.Id |
||||
|
||||
c.JSON(200, dash.Data) |
||||
} |
||||
|
||||
func (self *HttpServer) deleteDashboard(c *gin.Context, auth *authContext) { |
||||
slug := c.Params.ByName("slug") |
||||
|
||||
dash, err := self.store.GetDashboard(slug, auth.getAccountId()) |
||||
if err != nil { |
||||
c.JSON(404, newErrorResponse("Dashboard not found")) |
||||
return |
||||
} |
||||
|
||||
err = self.store.DeleteDashboard(slug, auth.getAccountId()) |
||||
if err != nil { |
||||
c.JSON(500, newErrorResponse("Failed to delete dashboard: "+err.Error())) |
||||
return |
||||
} |
||||
|
||||
var resp = map[string]interface{}{"title": dash.Title} |
||||
|
||||
c.JSON(200, resp) |
||||
} |
||||
|
||||
func (self *HttpServer) search(c *gin.Context, auth *authContext) { |
||||
query := c.Params.ByName("q") |
||||
|
||||
results, err := self.store.Query(query, auth.getAccountId()) |
||||
if err != nil { |
||||
log.Error("Store query error: %v", err) |
||||
c.JSON(500, newErrorResponse("Failed")) |
||||
return |
||||
} |
||||
|
||||
c.JSON(200, results) |
||||
} |
||||
|
||||
func (self *HttpServer) postDashboard(c *gin.Context, auth *authContext) { |
||||
var command saveDashboardCommand |
||||
|
||||
if c.EnsureBody(&command) { |
||||
dashboard := models.NewDashboard("test") |
||||
dashboard.Data = command.Dashboard |
||||
dashboard.Title = dashboard.Data["title"].(string) |
||||
dashboard.AccountId = auth.getAccountId() |
||||
dashboard.UpdateSlug() |
||||
|
||||
if dashboard.Data["id"] != nil { |
||||
dashboard.Id = dashboard.Data["id"].(string) |
||||
} |
||||
|
||||
err := self.store.SaveDashboard(dashboard) |
||||
if err == nil { |
||||
c.JSON(200, gin.H{"status": "success", "slug": dashboard.Slug}) |
||||
return |
||||
} |
||||
} |
||||
|
||||
c.JSON(500, gin.H{"error": "bad request"}) |
||||
} |
||||
@ -1,29 +0,0 @@ |
||||
package api |
||||
|
||||
type accountInfoDto struct { |
||||
Email string `json:"email"` |
||||
Name string `json:"name"` |
||||
AccountName string `json:"accountName"` |
||||
Collaborators []*collaboratorInfoDto `json:"collaborators"` |
||||
} |
||||
|
||||
type collaboratorInfoDto struct { |
||||
AccountId int `json:"accountId"` |
||||
Email string `json:"email"` |
||||
Role string `json:"role"` |
||||
} |
||||
|
||||
type addCollaboratorDto struct { |
||||
Email string `json:"email" binding:"required"` |
||||
} |
||||
|
||||
type removeCollaboratorDto struct { |
||||
AccountId int `json:"accountId" binding:"required"` |
||||
} |
||||
|
||||
type otherAccountDto struct { |
||||
Id int `json:"id"` |
||||
Name string `json:"name"` |
||||
Role string `json:"role"` |
||||
IsUsing bool `json:"isUsing"` |
||||
} |
||||
@ -1,63 +0,0 @@ |
||||
package api |
||||
|
||||
import ( |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
|
||||
"github.com/torkelo/grafana-pro/pkg/log" |
||||
) |
||||
|
||||
var ( |
||||
green = string([]byte{27, 91, 57, 55, 59, 52, 50, 109}) |
||||
white = string([]byte{27, 91, 57, 48, 59, 52, 55, 109}) |
||||
yellow = string([]byte{27, 91, 57, 55, 59, 52, 51, 109}) |
||||
red = string([]byte{27, 91, 57, 55, 59, 52, 49, 109}) |
||||
reset = string([]byte{27, 91, 48, 109}) |
||||
) |
||||
|
||||
func ignoreLoggingRequest(code int, contentType string) bool { |
||||
return code == 304 || |
||||
strings.HasPrefix(contentType, "application/javascript") || |
||||
strings.HasPrefix(contentType, "text/") || |
||||
strings.HasPrefix(contentType, "application/x-font-woff") |
||||
} |
||||
|
||||
func apiLogger() gin.HandlerFunc { |
||||
return func(c *gin.Context) { |
||||
// Start timer
|
||||
start := time.Now() |
||||
|
||||
// Process request
|
||||
c.Next() |
||||
|
||||
code := c.Writer.Status() |
||||
contentType := c.Writer.Header().Get("Content-Type") |
||||
|
||||
// ignore logging some requests
|
||||
if ignoreLoggingRequest(code, contentType) { |
||||
return |
||||
} |
||||
|
||||
// save the IP of the requester
|
||||
requester := c.Request.Header.Get("X-Real-IP") |
||||
// if the requester-header is empty, check the forwarded-header
|
||||
if len(requester) == 0 { |
||||
requester = c.Request.Header.Get("X-Forwarded-For") |
||||
} |
||||
// if the requester is still empty, use the hard-coded address from the socket
|
||||
if len(requester) == 0 { |
||||
requester = c.Request.RemoteAddr |
||||
} |
||||
|
||||
end := time.Now() |
||||
latency := end.Sub(start) |
||||
log.Info("[http] %s %s %3d %12v %s", |
||||
c.Request.Method, c.Request.URL.Path, |
||||
code, |
||||
latency, |
||||
c.Errors.String(), |
||||
) |
||||
} |
||||
} |
||||
@ -1,70 +0,0 @@ |
||||
package api |
||||
|
||||
import ( |
||||
"github.com/gin-gonic/gin" |
||||
"github.com/torkelo/grafana-pro/pkg/models" |
||||
|
||||
log "github.com/alecthomas/log4go" |
||||
) |
||||
|
||||
func init() { |
||||
addRoutes(func(self *HttpServer) { |
||||
self.router.GET("/login", self.index) |
||||
self.router.POST("/login", self.loginPost) |
||||
self.router.POST("/logout", self.logoutPost) |
||||
}) |
||||
} |
||||
|
||||
type loginJsonModel struct { |
||||
Email string `json:"email" binding:"required"` |
||||
Password string `json:"password" binding:"required"` |
||||
Remember bool `json:"remember"` |
||||
} |
||||
|
||||
func (self *HttpServer) loginPost(c *gin.Context) { |
||||
var loginModel loginJsonModel |
||||
|
||||
if !c.EnsureBody(&loginModel) { |
||||
c.JSON(400, gin.H{"status": "bad request"}) |
||||
return |
||||
} |
||||
|
||||
account, err := self.store.GetAccountByLogin(loginModel.Email) |
||||
if err != nil { |
||||
c.JSON(400, gin.H{"status": err.Error()}) |
||||
return |
||||
} |
||||
|
||||
if loginModel.Password != account.Password { |
||||
c.JSON(401, gin.H{"status": "unauthorized"}) |
||||
return |
||||
} |
||||
|
||||
loginUserWithAccount(account, c) |
||||
|
||||
var resp = &LoginResultDto{} |
||||
resp.Status = "Logged in" |
||||
resp.User.Login = account.Login |
||||
|
||||
c.JSON(200, resp) |
||||
} |
||||
|
||||
func loginUserWithAccount(account *models.Account, c *gin.Context) { |
||||
if account == nil { |
||||
log.Error("Account login with nil account") |
||||
} |
||||
session, err := sessionStore.Get(c.Request, "grafana-session") |
||||
if err != nil { |
||||
log.Error("Failed to get session %v", err) |
||||
} |
||||
session.Values["accountId"] = account.Id |
||||
session.Save(c.Request, c.Writer) |
||||
} |
||||
|
||||
func (self *HttpServer) logoutPost(c *gin.Context) { |
||||
session, _ := sessionStore.Get(c.Request, "grafana-session") |
||||
session.Values = nil |
||||
session.Save(c.Request, c.Writer) |
||||
|
||||
c.JSON(200, gin.H{"status": "logged out"}) |
||||
} |
||||
@ -1,52 +0,0 @@ |
||||
package api |
||||
|
||||
import ( |
||||
"crypto/md5" |
||||
"fmt" |
||||
"strings" |
||||
|
||||
"github.com/torkelo/grafana-pro/pkg/models" |
||||
) |
||||
|
||||
type saveDashboardCommand struct { |
||||
Id string `json:"id"` |
||||
Title string `json:"title"` |
||||
Dashboard map[string]interface{} |
||||
} |
||||
|
||||
type errorResponse struct { |
||||
Message string `json:"message"` |
||||
} |
||||
|
||||
type IndexDto struct { |
||||
User CurrentUserDto |
||||
} |
||||
|
||||
type CurrentUserDto struct { |
||||
Login string `json:"login"` |
||||
Email string `json:"email"` |
||||
GravatarUrl string `json:"gravatarUrl"` |
||||
} |
||||
|
||||
type LoginResultDto struct { |
||||
Status string `json:"status"` |
||||
User CurrentUserDto `json:"user"` |
||||
} |
||||
|
||||
func newErrorResponse(message string) *errorResponse { |
||||
return &errorResponse{Message: message} |
||||
} |
||||
|
||||
func initCurrentUserDto(userDto *CurrentUserDto, account *models.Account) { |
||||
if account != nil { |
||||
userDto.Login = account.Login |
||||
userDto.Email = account.Email |
||||
userDto.GravatarUrl = getGravatarUrl(account.Email) |
||||
} |
||||
} |
||||
|
||||
func getGravatarUrl(text string) string { |
||||
hasher := md5.New() |
||||
hasher.Write([]byte(strings.ToLower(text))) |
||||
return fmt.Sprintf("https://secure.gravatar.com/avatar/%x?s=90&default=mm", hasher.Sum(nil)) |
||||
} |
||||
@ -1 +0,0 @@ |
||||
package api |
||||
@ -1,112 +0,0 @@ |
||||
package api |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"net/http" |
||||
|
||||
log "github.com/alecthomas/log4go" |
||||
"github.com/gin-gonic/gin" |
||||
"github.com/golang/oauth2" |
||||
"github.com/torkelo/grafana-pro/pkg/models" |
||||
"github.com/torkelo/grafana-pro/pkg/stores" |
||||
) |
||||
|
||||
var ( |
||||
githubOAuthConfig *oauth2.Config |
||||
githubRedirectUrl string = "http://localhost:3000/oauth2/github/callback" |
||||
githubAuthUrl string = "https://github.com/login/oauth/authorize" |
||||
githubTokenUrl string = "https://github.com/login/oauth/access_token" |
||||
) |
||||
|
||||
func init() { |
||||
addRoutes(func(self *HttpServer) { |
||||
if !self.cfg.Http.GithubOAuth.Enabled { |
||||
return |
||||
} |
||||
|
||||
self.router.GET("/oauth2/github", self.oauthGithub) |
||||
self.router.GET("/oauth2/github/callback", self.oauthGithubCallback) |
||||
|
||||
options := &oauth2.Options{ |
||||
ClientID: self.cfg.Http.GithubOAuth.ClientId, |
||||
ClientSecret: self.cfg.Http.GithubOAuth.ClientSecret, |
||||
RedirectURL: githubRedirectUrl, |
||||
Scopes: []string{"user:email"}, |
||||
} |
||||
|
||||
cfg, err := oauth2.NewConfig(options, githubAuthUrl, githubTokenUrl) |
||||
|
||||
if err != nil { |
||||
log.Error("Failed to init github auth %v", err) |
||||
} |
||||
|
||||
githubOAuthConfig = cfg |
||||
}) |
||||
} |
||||
|
||||
func (self *HttpServer) oauthGithub(c *gin.Context) { |
||||
url := githubOAuthConfig.AuthCodeURL("", "online", "auto") |
||||
c.Redirect(302, url) |
||||
} |
||||
|
||||
type githubUserInfoDto struct { |
||||
Login string `json:"login"` |
||||
Name string `json:"name"` |
||||
Email string `json:"email"` |
||||
Company string `json:"company"` |
||||
} |
||||
|
||||
func (self *HttpServer) oauthGithubCallback(c *gin.Context) { |
||||
code := c.Request.URL.Query()["code"][0] |
||||
log.Info("OAuth code: %v", code) |
||||
|
||||
transport, err := githubOAuthConfig.NewTransportWithCode(code) |
||||
if err != nil { |
||||
c.String(500, "Failed to exchange oauth token: "+err.Error()) |
||||
return |
||||
} |
||||
|
||||
client := http.Client{Transport: transport} |
||||
resp, err := client.Get("https://api.github.com/user") |
||||
if err != nil { |
||||
c.String(500, err.Error()) |
||||
return |
||||
} |
||||
|
||||
var userInfo githubUserInfoDto |
||||
decoder := json.NewDecoder(resp.Body) |
||||
err = decoder.Decode(&userInfo) |
||||
if err != nil { |
||||
c.String(500, err.Error()) |
||||
return |
||||
} |
||||
|
||||
if len(userInfo.Email) < 5 { |
||||
c.String(500, "Invalid email") |
||||
return |
||||
} |
||||
|
||||
// try find existing account
|
||||
account, err := self.store.GetAccountByLogin(userInfo.Email) |
||||
|
||||
// create account if missing
|
||||
if err == stores.ErrAccountNotFound { |
||||
account = &models.Account{ |
||||
Login: userInfo.Login, |
||||
Email: userInfo.Email, |
||||
Name: userInfo.Name, |
||||
Company: userInfo.Company, |
||||
} |
||||
|
||||
if err = self.store.CreateAccount(account); err != nil { |
||||
log.Error("Failed to create account %v", err) |
||||
c.String(500, "Failed to create account") |
||||
return |
||||
} |
||||
} |
||||
|
||||
// login
|
||||
loginUserWithAccount(account, c) |
||||
|
||||
c.Redirect(302, "/") |
||||
} |
||||
@ -1,113 +0,0 @@ |
||||
package api |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"net/http" |
||||
|
||||
log "github.com/alecthomas/log4go" |
||||
"github.com/gin-gonic/gin" |
||||
"github.com/golang/oauth2" |
||||
"github.com/torkelo/grafana-pro/pkg/models" |
||||
"github.com/torkelo/grafana-pro/pkg/stores" |
||||
) |
||||
|
||||
var ( |
||||
googleOAuthConfig *oauth2.Config |
||||
googleRedirectUrl string = "http://localhost:3000/oauth2/google/callback" |
||||
googleAuthUrl string = "https://accounts.google.com/o/oauth2/auth" |
||||
googleTokenUrl string = "https://accounts.google.com/o/oauth2/token" |
||||
googleScopeProfile string = "https://www.googleapis.com/auth/userinfo.profile" |
||||
googleScopeEmail string = "https://www.googleapis.com/auth/userinfo.email" |
||||
) |
||||
|
||||
func init() { |
||||
addRoutes(func(self *HttpServer) { |
||||
if !self.cfg.Http.GoogleOAuth.Enabled { |
||||
return |
||||
} |
||||
|
||||
self.router.GET("/oauth2/google", self.oauthGoogle) |
||||
self.router.GET("/oauth2/google/callback", self.oauthGoogleCallback) |
||||
|
||||
options := &oauth2.Options{ |
||||
ClientID: self.cfg.Http.GoogleOAuth.ClientId, |
||||
ClientSecret: self.cfg.Http.GoogleOAuth.ClientSecret, |
||||
RedirectURL: googleRedirectUrl, |
||||
Scopes: []string{googleScopeEmail, googleScopeProfile}, |
||||
} |
||||
|
||||
cfg, err := oauth2.NewConfig(options, googleAuthUrl, googleTokenUrl) |
||||
|
||||
if err != nil { |
||||
log.Error("Failed to init google auth %v", err) |
||||
} |
||||
|
||||
googleOAuthConfig = cfg |
||||
}) |
||||
} |
||||
|
||||
func (self *HttpServer) oauthGoogle(c *gin.Context) { |
||||
url := googleOAuthConfig.AuthCodeURL("", "online", "auto") |
||||
c.Redirect(302, url) |
||||
} |
||||
|
||||
type googleUserInfoDto struct { |
||||
Email string `json:"email"` |
||||
GivenName string `json:"givenName"` |
||||
FamilyName string `json:"familyName"` |
||||
Name string `json:"name"` |
||||
} |
||||
|
||||
func (self *HttpServer) oauthGoogleCallback(c *gin.Context) { |
||||
code := c.Request.URL.Query()["code"][0] |
||||
log.Info("OAuth code: %v", code) |
||||
|
||||
transport, err := googleOAuthConfig.NewTransportWithCode(code) |
||||
if err != nil { |
||||
c.String(500, "Failed to exchange oauth token: "+err.Error()) |
||||
return |
||||
} |
||||
|
||||
client := http.Client{Transport: transport} |
||||
resp, err := client.Get("https://www.googleapis.com/oauth2/v1/userinfo?alt=json") |
||||
if err != nil { |
||||
c.String(500, err.Error()) |
||||
return |
||||
} |
||||
|
||||
var userInfo googleUserInfoDto |
||||
decoder := json.NewDecoder(resp.Body) |
||||
err = decoder.Decode(&userInfo) |
||||
if err != nil { |
||||
c.String(500, err.Error()) |
||||
return |
||||
} |
||||
|
||||
if len(userInfo.Email) < 5 { |
||||
c.String(500, "Invalid email") |
||||
return |
||||
} |
||||
|
||||
// try find existing account
|
||||
account, err := self.store.GetAccountByLogin(userInfo.Email) |
||||
|
||||
// create account if missing
|
||||
if err == stores.ErrAccountNotFound { |
||||
account = &models.Account{ |
||||
Login: userInfo.Email, |
||||
Email: userInfo.Email, |
||||
Name: userInfo.Name, |
||||
} |
||||
|
||||
if err = self.store.CreateAccount(account); err != nil { |
||||
log.Error("Failed to create account %v", err) |
||||
c.String(500, "Failed to create account") |
||||
return |
||||
} |
||||
} |
||||
|
||||
// login
|
||||
loginUserWithAccount(account, c) |
||||
|
||||
c.Redirect(302, "/") |
||||
} |
||||
@ -1,44 +0,0 @@ |
||||
package api |
||||
|
||||
import ( |
||||
log "github.com/alecthomas/log4go" |
||||
"github.com/gin-gonic/gin" |
||||
"github.com/torkelo/grafana-pro/pkg/models" |
||||
) |
||||
|
||||
func init() { |
||||
addRoutes(func(self *HttpServer) { |
||||
self.router.GET("/register/*_", self.index) |
||||
self.router.POST("/api/register/user", self.registerUserPost) |
||||
}) |
||||
} |
||||
|
||||
type registerAccountJsonModel struct { |
||||
Email string `json:"email" binding:"required"` |
||||
Password string `json:"password" binding:"required"` |
||||
Password2 bool `json:"remember2"` |
||||
} |
||||
|
||||
func (self *HttpServer) registerUserPost(c *gin.Context) { |
||||
var registerModel registerAccountJsonModel |
||||
|
||||
if !c.EnsureBody(®isterModel) { |
||||
c.JSON(400, gin.H{"status": "bad request"}) |
||||
return |
||||
} |
||||
|
||||
account := models.Account{ |
||||
Login: registerModel.Email, |
||||
Email: registerModel.Email, |
||||
Password: registerModel.Password, |
||||
} |
||||
|
||||
err := self.store.CreateAccount(&account) |
||||
if err != nil { |
||||
log.Error("Failed to create user account, email: %v, error: %v", registerModel.Email, err) |
||||
c.JSON(500, gin.H{"status": "failed to create account"}) |
||||
return |
||||
} |
||||
|
||||
c.JSON(200, gin.H{"status": "ok"}) |
||||
} |
||||
@ -1,36 +0,0 @@ |
||||
package api |
||||
|
||||
import ( |
||||
"strconv" |
||||
|
||||
"github.com/gin-gonic/gin" |
||||
"github.com/torkelo/grafana-pro/pkg/components" |
||||
"github.com/torkelo/grafana-pro/pkg/utils" |
||||
) |
||||
|
||||
func init() { |
||||
addRoutes(func(self *HttpServer) { |
||||
self.addRoute("GET", "/render/*url", self.renderToPng) |
||||
}) |
||||
} |
||||
|
||||
func (self *HttpServer) renderToPng(c *gin.Context, auth *authContext) { |
||||
accountId := auth.getAccountId() |
||||
queryReader := utils.NewUrlQueryReader(c.Request.URL) |
||||
queryParams := "?render&accountId=" + strconv.Itoa(accountId) + "&" + c.Request.URL.RawQuery |
||||
|
||||
renderOpts := &components.RenderOpts{ |
||||
Url: c.Params.ByName("url") + queryParams, |
||||
Width: queryReader.Get("width", "800"), |
||||
Height: queryReader.Get("height", "400"), |
||||
} |
||||
|
||||
renderOpts.Url = "http://localhost:3000" + renderOpts.Url |
||||
|
||||
pngPath, err := self.renderer.RenderToPng(renderOpts) |
||||
if err != nil { |
||||
c.HTML(500, "error.html", nil) |
||||
} |
||||
|
||||
c.File(pngPath) |
||||
} |
||||
@ -1,36 +0,0 @@ |
||||
package api |
||||
|
||||
import ( |
||||
"github.com/gin-gonic/gin" |
||||
"github.com/torkelo/grafana-pro/pkg/models" |
||||
) |
||||
|
||||
type routeHandlerRegisterFn func(self *HttpServer) |
||||
type routeHandlerFn func(c *gin.Context, auth *authContext) |
||||
|
||||
var routeHandlers = make([]routeHandlerRegisterFn, 0) |
||||
|
||||
func getRouteHandlerWrapper(handler routeHandlerFn) gin.HandlerFunc { |
||||
return func(c *gin.Context) { |
||||
authContext := authContext{ |
||||
account: c.MustGet("usingAccount").(*models.Account), |
||||
userAccount: c.MustGet("userAccount").(*models.Account), |
||||
} |
||||
handler(c, &authContext) |
||||
} |
||||
} |
||||
|
||||
func (self *HttpServer) addRoute(method string, path string, handler routeHandlerFn) { |
||||
switch method { |
||||
case "GET": |
||||
self.router.GET(path, self.auth(), getRouteHandlerWrapper(handler)) |
||||
case "POST": |
||||
self.router.POST(path, self.auth(), getRouteHandlerWrapper(handler)) |
||||
case "DELETE": |
||||
self.router.DELETE(path, self.auth(), getRouteHandlerWrapper(handler)) |
||||
} |
||||
} |
||||
|
||||
func addRoutes(fn routeHandlerRegisterFn) { |
||||
routeHandlers = append(routeHandlers, fn) |
||||
} |
||||
@ -1 +0,0 @@ |
||||
package api |
||||
@ -1,45 +0,0 @@ |
||||
package stores |
||||
|
||||
import ( |
||||
"time" |
||||
|
||||
r "github.com/dancannon/gorethink" |
||||
|
||||
"github.com/torkelo/grafana-pro/pkg/log" |
||||
) |
||||
|
||||
type rethinkStore struct { |
||||
session *r.Session |
||||
} |
||||
|
||||
type RethinkCfg struct { |
||||
DatabaseName string |
||||
} |
||||
|
||||
type Account struct { |
||||
Id int `gorethink:"id"` |
||||
NextDashboardId int |
||||
} |
||||
|
||||
func NewRethinkStore(config *RethinkCfg) *rethinkStore { |
||||
log.Info("Initializing rethink storage") |
||||
|
||||
session, err := r.Connect(r.ConnectOpts{ |
||||
Address: "localhost:28015", |
||||
Database: config.DatabaseName, |
||||
MaxIdle: 10, |
||||
IdleTimeout: time.Second * 10, |
||||
}) |
||||
|
||||
if err != nil { |
||||
log.Error(3, "Failed to connect to rethink database %v", err) |
||||
} |
||||
|
||||
createRethinkDBTablesAndIndices(config, session) |
||||
|
||||
return &rethinkStore{ |
||||
session: session, |
||||
} |
||||
} |
||||
|
||||
func (self *rethinkStore) Close() {} |
||||
@ -1,136 +0,0 @@ |
||||
package stores |
||||
|
||||
import ( |
||||
"errors" |
||||
|
||||
r "github.com/dancannon/gorethink" |
||||
"github.com/torkelo/grafana-pro/pkg/models" |
||||
) |
||||
|
||||
func (self *rethinkStore) getNextAccountId() (int, error) { |
||||
resp, err := r.Table("master").Get("ids").Update(map[string]interface{}{ |
||||
"NextAccountId": r.Row.Field("NextAccountId").Add(1), |
||||
}, r.UpdateOpts{ReturnChanges: true}).RunWrite(self.session) |
||||
|
||||
if err != nil { |
||||
return 0, err |
||||
} |
||||
|
||||
change := resp.Changes[0] |
||||
|
||||
if change.NewValue == nil { |
||||
return 0, errors.New("Failed to get new value after incrementing account id") |
||||
} |
||||
|
||||
return int(change.NewValue.(map[string]interface{})["NextAccountId"].(float64)), nil |
||||
} |
||||
|
||||
func (self *rethinkStore) CreateAccount(account *models.Account) error { |
||||
accountId, err := self.getNextAccountId() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
account.Id = accountId |
||||
account.UsingAccountId = accountId |
||||
|
||||
resp, err := r.Table("accounts").Insert(account).RunWrite(self.session) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if resp.Inserted == 0 { |
||||
return errors.New("Failed to insert acccount") |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (self *rethinkStore) GetAccountByLogin(emailOrName string) (*models.Account, error) { |
||||
resp, err := r.Table("accounts").GetAllByIndex("Login", emailOrName).Run(self.session) |
||||
|
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
var account models.Account |
||||
err = resp.One(&account) |
||||
if err != nil { |
||||
return nil, ErrAccountNotFound |
||||
} |
||||
|
||||
return &account, nil |
||||
} |
||||
|
||||
func (self *rethinkStore) GetAccount(id int) (*models.Account, error) { |
||||
resp, err := r.Table("accounts").Get(id).Run(self.session) |
||||
|
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
var account models.Account |
||||
err = resp.One(&account) |
||||
if err != nil { |
||||
return nil, errors.New("Not found") |
||||
} |
||||
|
||||
return &account, nil |
||||
} |
||||
|
||||
func (self *rethinkStore) UpdateAccount(account *models.Account) error { |
||||
resp, err := r.Table("accounts").Update(account).RunWrite(self.session) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if resp.Replaced == 0 && resp.Unchanged == 0 { |
||||
return errors.New("Could not find account to update") |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (self *rethinkStore) getNextDashboardNumber(accountId int) (int, error) { |
||||
resp, err := r.Table("accounts").Get(accountId).Update(map[string]interface{}{ |
||||
"NextDashboardId": r.Row.Field("NextDashboardId").Add(1), |
||||
}, r.UpdateOpts{ReturnChanges: true}).RunWrite(self.session) |
||||
|
||||
if err != nil { |
||||
return 0, err |
||||
} |
||||
|
||||
change := resp.Changes[0] |
||||
|
||||
if change.NewValue == nil { |
||||
return 0, errors.New("Failed to get next dashboard id, no new value after update") |
||||
} |
||||
|
||||
return int(change.NewValue.(map[string]interface{})["NextDashboardId"].(float64)), nil |
||||
} |
||||
|
||||
func (self *rethinkStore) GetOtherAccountsFor(accountId int) ([]*models.OtherAccount, error) { |
||||
resp, err := r.Table("accounts"). |
||||
GetAllByIndex("CollaboratorAccountId", accountId). |
||||
Map(func(row r.Term) interface{} { |
||||
return map[string]interface{}{ |
||||
"id": row.Field("id"), |
||||
"Name": row.Field("Email"), |
||||
"Role": row.Field("Collaborators").Filter(map[string]interface{}{ |
||||
"AccountId": accountId, |
||||
}).Nth(0).Field("Role"), |
||||
} |
||||
}).Run(self.session) |
||||
|
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
var list []*models.OtherAccount |
||||
err = resp.All(&list) |
||||
if err != nil { |
||||
return nil, errors.New("Failed to read available accounts") |
||||
} |
||||
|
||||
return list, nil |
||||
} |
||||
@ -1,79 +0,0 @@ |
||||
package stores |
||||
|
||||
import ( |
||||
"errors" |
||||
|
||||
log "github.com/alecthomas/log4go" |
||||
r "github.com/dancannon/gorethink" |
||||
"github.com/torkelo/grafana-pro/pkg/models" |
||||
) |
||||
|
||||
func (self *rethinkStore) SaveDashboard(dash *models.Dashboard) error { |
||||
resp, err := r.Table("dashboards").Insert(dash, r.InsertOpts{Conflict: "update"}).RunWrite(self.session) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
log.Info("Inserted: %v, Errors: %v, Updated: %v", resp.Inserted, resp.Errors, resp.Updated) |
||||
log.Info("First error:", resp.FirstError) |
||||
if len(resp.GeneratedKeys) > 0 { |
||||
dash.Id = resp.GeneratedKeys[0] |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (self *rethinkStore) GetDashboard(slug string, accountId int) (*models.Dashboard, error) { |
||||
resp, err := r.Table("dashboards"). |
||||
GetAllByIndex("AccountIdSlug", []interface{}{accountId, slug}). |
||||
Run(self.session) |
||||
|
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
var dashboard models.Dashboard |
||||
err = resp.One(&dashboard) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return &dashboard, nil |
||||
} |
||||
|
||||
func (self *rethinkStore) DeleteDashboard(slug string, accountId int) error { |
||||
resp, err := r.Table("dashboards"). |
||||
GetAllByIndex("AccountIdSlug", []interface{}{accountId, slug}). |
||||
Delete().RunWrite(self.session) |
||||
|
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if resp.Deleted != 1 { |
||||
return errors.New("Did not find dashboard to delete") |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func (self *rethinkStore) Query(query string, accountId int) ([]*models.SearchResult, error) { |
||||
docs, err := r.Table("dashboards"). |
||||
GetAllByIndex("AccountId", []interface{}{accountId}). |
||||
Filter(r.Row.Field("Title").Match(".*")).Run(self.session) |
||||
|
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
results := make([]*models.SearchResult, 0, 50) |
||||
var dashboard models.Dashboard |
||||
for docs.Next(&dashboard) { |
||||
results = append(results, &models.SearchResult{ |
||||
Title: dashboard.Title, |
||||
Id: dashboard.Slug, |
||||
}) |
||||
} |
||||
|
||||
return results, nil |
||||
} |
||||
@ -1,39 +0,0 @@ |
||||
package stores |
||||
|
||||
import ( |
||||
log "github.com/alecthomas/log4go" |
||||
r "github.com/dancannon/gorethink" |
||||
) |
||||
|
||||
func createRethinkDBTablesAndIndices(config *RethinkCfg, session *r.Session) { |
||||
|
||||
r.DbCreate(config.DatabaseName).Exec(session) |
||||
|
||||
// create tables
|
||||
r.Db(config.DatabaseName).TableCreate("dashboards").Exec(session) |
||||
r.Db(config.DatabaseName).TableCreate("accounts").Exec(session) |
||||
r.Db(config.DatabaseName).TableCreate("master").Exec(session) |
||||
|
||||
// create dashboard accountId + slug index
|
||||
r.Db(config.DatabaseName).Table("dashboards").IndexCreateFunc("AccountIdSlug", func(row r.Term) interface{} { |
||||
return []interface{}{row.Field("AccountId"), row.Field("Slug")} |
||||
}).Exec(session) |
||||
|
||||
r.Db(config.DatabaseName).Table("dashboards").IndexCreate("AccountId").Exec(session) |
||||
r.Db(config.DatabaseName).Table("accounts").IndexCreate("Login").Exec(session) |
||||
|
||||
// create account collaborator index
|
||||
r.Db(config.DatabaseName).Table("accounts"). |
||||
IndexCreateFunc("CollaboratorAccountId", func(row r.Term) interface{} { |
||||
return row.Field("Collaborators").Map(func(row r.Term) interface{} { |
||||
return row.Field("AccountId") |
||||
}) |
||||
}, r.IndexCreateOpts{Multi: true}).Exec(session) |
||||
|
||||
// make sure master ids row exists
|
||||
_, err := r.Table("master").Insert(map[string]interface{}{"id": "ids", "NextAccountId": 0}).RunWrite(session) |
||||
if err != nil { |
||||
log.Error("Failed to insert master ids row", err) |
||||
} |
||||
|
||||
} |
||||
@ -1,56 +0,0 @@ |
||||
package stores |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/dancannon/gorethink" |
||||
. "github.com/smartystreets/goconvey/convey" |
||||
"github.com/torkelo/grafana-pro/pkg/models" |
||||
) |
||||
|
||||
func TestRethinkStore(t *testing.T) { |
||||
store := NewRethinkStore(&RethinkCfg{DatabaseName: "tests"}) |
||||
defer gorethink.DbDrop("tests").Exec(store.session) |
||||
|
||||
Convey("Insert dashboard", t, func() { |
||||
dashboard := models.NewDashboard("test") |
||||
dashboard.AccountId = 1 |
||||
|
||||
err := store.SaveDashboard(dashboard) |
||||
So(err, ShouldBeNil) |
||||
So(dashboard.Id, ShouldNotBeEmpty) |
||||
|
||||
read, err := store.GetDashboard("test", 1) |
||||
So(err, ShouldBeNil) |
||||
So(read, ShouldNotBeNil) |
||||
}) |
||||
|
||||
Convey("can get next account id", t, func() { |
||||
id, err := store.getNextAccountId() |
||||
So(err, ShouldBeNil) |
||||
So(id, ShouldNotEqual, 0) |
||||
|
||||
id2, err := store.getNextAccountId() |
||||
So(id2, ShouldEqual, id+1) |
||||
}) |
||||
|
||||
Convey("can create account", t, func() { |
||||
account := &models.Account{UserName: "torkelo", Email: "mupp", Login: "test@test.com"} |
||||
err := store.CreateAccount(account) |
||||
So(err, ShouldBeNil) |
||||
So(account.Id, ShouldNotEqual, 0) |
||||
|
||||
read, err := store.GetUserAccountLogin("test@test.com") |
||||
So(err, ShouldBeNil) |
||||
So(read.Id, ShouldEqual, account.DatabaseId) |
||||
}) |
||||
|
||||
Convey("can get next dashboard id", t, func() { |
||||
account := &models.Account{UserName: "torkelo", Email: "mupp"} |
||||
err := store.CreateAccount(account) |
||||
dashId, err := store.getNextDashboardNumber(account.Id) |
||||
So(err, ShouldBeNil) |
||||
So(dashId, ShouldEqual, 1) |
||||
}) |
||||
|
||||
} |
||||
@ -1,20 +0,0 @@ |
||||
package stores |
||||
|
||||
import "github.com/torkelo/grafana-pro/pkg/models" |
||||
|
||||
type Store interface { |
||||
GetDashboard(slug string, accountId int) (*models.Dashboard, error) |
||||
SaveDashboard(dash *models.Dashboard) error |
||||
DeleteDashboard(slug string, accountId int) error |
||||
Query(query string, acccountId int) ([]*models.SearchResult, error) |
||||
CreateAccount(acccount *models.Account) error |
||||
UpdateAccount(acccount *models.Account) error |
||||
GetAccountByLogin(emailOrName string) (*models.Account, error) |
||||
GetAccount(accountId int) (*models.Account, error) |
||||
GetOtherAccountsFor(accountId int) ([]*models.OtherAccount, error) |
||||
Close() |
||||
} |
||||
|
||||
func New() Store { |
||||
return NewRethinkStore(&RethinkCfg{DatabaseName: "grafana"}) |
||||
} |
||||
Loading…
Reference in new issue