diff --git a/data/sessions/5/e/5e40ff05d87ac75cba0634e7350c263c4c45f202 b/data/sessions/5/e/5e40ff05d87ac75cba0634e7350c263c4c45f202 new file mode 100644 index 00000000000..f1f06840570 Binary files /dev/null and b/data/sessions/5/e/5e40ff05d87ac75cba0634e7350c263c4c45f202 differ diff --git a/grafana-pro b/grafana-pro index e9ecefd55f9..f0a58bc6f6a 100755 Binary files a/grafana-pro and b/grafana-pro differ diff --git a/pkg/cmd/web.go b/pkg/cmd/web.go index 5f11cce9efa..7a0b27b28fc 100644 --- a/pkg/cmd/web.go +++ b/pkg/cmd/web.go @@ -10,11 +10,14 @@ import ( "github.com/Unknwon/macaron" "github.com/codegangsta/cli" + "github.com/macaron-contrib/session" "github.com/torkelo/grafana-pro/pkg/log" "github.com/torkelo/grafana-pro/pkg/middleware" "github.com/torkelo/grafana-pro/pkg/routes" + "github.com/torkelo/grafana-pro/pkg/routes/login" "github.com/torkelo/grafana-pro/pkg/setting" + "github.com/torkelo/grafana-pro/pkg/stores/rethink" ) var CmdWeb = cli.Command{ @@ -29,27 +32,15 @@ func newMacaron() *macaron.Macaron { m := macaron.New() m.Use(middleware.Logger()) m.Use(macaron.Recovery()) - m.Use(macaron.Static( - path.Join(setting.StaticRootPath, "public"), - macaron.StaticOptions{ - SkipLogging: true, - Prefix: "public", - }, - )) - m.Use(macaron.Static( - path.Join(setting.StaticRootPath, "public/app"), - macaron.StaticOptions{ - SkipLogging: true, - Prefix: "app", - }, - )) - m.Use(macaron.Static( - path.Join(setting.StaticRootPath, "public/img"), - macaron.StaticOptions{ - SkipLogging: true, - Prefix: "img", - }, - )) + + mapStatic(m, "public", "public") + mapStatic(m, "public/app", "app") + mapStatic(m, "public/img", "img") + + m.Use(session.Sessioner(session.Options{ + Provider: setting.SessionProvider, + Config: *setting.SessionConfig, + })) m.Use(macaron.Renderer(macaron.RenderOptions{ Directory: path.Join(setting.StaticRootPath, "views"), @@ -61,16 +52,31 @@ func newMacaron() *macaron.Macaron { return m } +func mapStatic(m *macaron.Macaron, dir string, prefix string) { + m.Use(macaron.Static( + path.Join(setting.StaticRootPath, dir), + macaron.StaticOptions{ + SkipLogging: true, + Prefix: prefix, + }, + )) +} + func runWeb(*cli.Context) { setting.NewConfigContext() setting.InitServices() + rethink.Init() log.Info("Starting Grafana-Pro v.1-alpha") m := newMacaron() + auth := middleware.Auth() + // index - m.Get("/", routes.Index) + m.Get("/", auth, routes.Index) + m.Get("/login", routes.Index) + m.Post("/login", login.LoginPost) var err error listenAddr := fmt.Sprintf("%s:%s", setting.HttpAddr, setting.HttpPort) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go new file mode 100644 index 00000000000..3693e22b853 --- /dev/null +++ b/pkg/middleware/auth.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "errors" + "strconv" + + "github.com/Unknwon/macaron" + "github.com/macaron-contrib/session" + "github.com/torkelo/grafana-pro/pkg/models" +) + +func authGetRequestAccountId(c *Context, sess session.Store) (int, error) { + accountId := sess.Get("accountId") + + urlQuery := c.Req.URL.Query() + if len(urlQuery["render"]) > 0 { + accId, _ := strconv.Atoi(urlQuery["accountId"][0]) + sess.Set("accountId", accId) + accountId = accId + } + + if accountId == nil { + return -1, errors.New("Auth: session account id not found") + } + + return accountId.(int), nil +} + +func authDenied(c *Context) { + c.Redirect("/login") +} + +func Auth() macaron.Handler { + return func(c *Context, sess session.Store) { + accountId, err := authGetRequestAccountId(c, sess) + + if err != nil && c.Req.URL.Path != "/login" { + authDenied(c) + return + } + + account, err := models.GetAccount(accountId) + if err != nil { + authDenied(c) + return + } + + usingAccount, err := models.GetAccount(account.UsingAccountId) + if err != nil { + authDenied(c) + return + } + + c.UserAccount = account + c.Account = usingAccount + } +} diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 28d39ee5a14..02aa5caf371 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -1,7 +1,8 @@ package middleware import ( - "time" + "encoding/json" + "io/ioutil" "github.com/Unknwon/macaron" "github.com/macaron-contrib/session" @@ -21,13 +22,12 @@ type Context struct { } func GetContextHandler() macaron.Handler { - return func(c *macaron.Context) { + return func(c *macaron.Context, sess session.Store) { ctx := &Context{ Context: c, + Session: sess, } - ctx.Data["PageStartTime"] = time.Now() - c.Map(ctx) } } @@ -50,3 +50,9 @@ func (ctx *Context) Handle(status int, title string, err error) { ctx.HTML(status, "index") } + +func (ctx *Context) JsonBody(model interface{}) bool { + b, _ := ioutil.ReadAll(ctx.Req.Body) + err := json.Unmarshal(b, &model) + return err == nil +} diff --git a/pkg/models/account.go b/pkg/models/account.go index 7d198e72d28..c468e057f08 100644 --- a/pkg/models/account.go +++ b/pkg/models/account.go @@ -5,6 +5,19 @@ import ( "time" ) +var ( + CreateAccount func(acccount *Account) error + UpdateAccount func(acccount *Account) error + GetAccountByLogin func(emailOrName string) (*Account, error) + GetAccount func(accountId int) (*Account, error) + GetOtherAccountsFor func(accountId int) ([]*OtherAccount, error) +) + +// Typed errors +var ( + ErrAccountNotFound = errors.New("Account not found") +) + type CollaboratorLink struct { AccountId int Role string diff --git a/pkg/routes/apimodel/models.go b/pkg/routes/apimodel/models.go new file mode 100644 index 00000000000..c6ab14afe36 --- /dev/null +++ b/pkg/routes/apimodel/models.go @@ -0,0 +1,36 @@ +package apimodel + +import ( + "crypto/md5" + "fmt" + "strings" + + "github.com/torkelo/grafana-pro/pkg/models" +) + +type LoginResultDto struct { + Status string `json:"status"` + User CurrentUserDto `json:"user"` +} + +type CurrentUserDto struct { + Login string `json:"login"` + Email string `json:"email"` + GravatarUrl string `json:"gravatarUrl"` +} + +func NewCurrentUserDto(account *models.Account) *CurrentUserDto { + model := &CurrentUserDto{} + if account != nil { + model.Login = account.Login + model.Email = account.Email + model.GravatarUrl = getGravatarUrl(account.Email) + } + return model +} + +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)) +} diff --git a/pkg/routes/index.go b/pkg/routes/index.go index 50fde3fafd9..3867b2912ca 100644 --- a/pkg/routes/index.go +++ b/pkg/routes/index.go @@ -1,12 +1,15 @@ package routes -import "github.com/torkelo/grafana-pro/pkg/middleware" +import ( + "github.com/torkelo/grafana-pro/pkg/middleware" + "github.com/torkelo/grafana-pro/pkg/routes/apimodel" +) func Index(ctx *middleware.Context) { + ctx.Data["User"] = apimodel.NewCurrentUserDto(ctx.UserAccount) ctx.HTML(200, "index") } func NotFound(ctx *middleware.Context) { - ctx.Data["Title"] = "Page Not Found" ctx.Handle(404, "index", nil) } diff --git a/pkg/routes/login/login.go b/pkg/routes/login/login.go new file mode 100644 index 00000000000..c8b0a9cde2e --- /dev/null +++ b/pkg/routes/login/login.go @@ -0,0 +1,56 @@ +package login + +import ( + "github.com/gin-gonic/gin" + "github.com/torkelo/grafana-pro/pkg/log" + "github.com/torkelo/grafana-pro/pkg/middleware" + "github.com/torkelo/grafana-pro/pkg/models" + "github.com/torkelo/grafana-pro/pkg/routes/apimodel" +) + +type loginJsonModel struct { + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` + Remember bool `json:"remember"` +} + +func LoginPost(c *middleware.Context) { + var loginModel loginJsonModel + + if !c.JsonBody(&loginModel) { + c.JSON(400, gin.H{"status": "bad request"}) + return + } + + account, err := models.GetAccountByLogin(loginModel.Email) + if err != nil { + c.JSON(401, gin.H{"status": "unauthorized"}) + return + } + + if loginModel.Password != account.Password { + c.JSON(401, gin.H{"status": "unauthorized"}) + return + } + + loginUserWithAccount(account, c) + + var resp = &apimodel.LoginResultDto{} + resp.Status = "Logged in" + resp.User.Login = account.Login + + c.JSON(200, resp) +} + +func loginUserWithAccount(account *models.Account, c *middleware.Context) { + if account == nil { + log.Error(3, "Account login with nil account") + } + + c.Session.Set("accountId", account.Id) +} + +func LogoutPost(c *middleware.Context) { + c.Session.Delete("accountId") + c.JSON(200, gin.H{"status": "logged out"}) +} diff --git a/pkg/stores/rethink/rethink.go b/pkg/stores/rethink/rethink.go new file mode 100644 index 00000000000..d388d2feec4 --- /dev/null +++ b/pkg/stores/rethink/rethink.go @@ -0,0 +1,197 @@ +package rethink + +import ( + "errors" + "time" + + r "github.com/dancannon/gorethink" + + "github.com/torkelo/grafana-pro/pkg/log" + "github.com/torkelo/grafana-pro/pkg/models" +) + +var ( + session *r.Session + dbName string = "grafana" +) + +func Init() { + log.Info("Initializing rethink storage") + + var err error + session, err = r.Connect(r.ConnectOpts{ + Address: "localhost:28015", + Database: dbName, + MaxIdle: 10, + IdleTimeout: time.Second * 10, + }) + + if err != nil { + log.Error(3, "Failed to connect to rethink database %v", err) + } + + createRethinkDBTablesAndIndices() + + models.GetAccount = GetAccount + models.GetAccountByLogin = GetAccountByLogin +} + +func createRethinkDBTablesAndIndices() { + + r.DbCreate(dbName).Exec(session) + + // create tables + r.Db(dbName).TableCreate("dashboards").Exec(session) + r.Db(dbName).TableCreate("accounts").Exec(session) + r.Db(dbName).TableCreate("master").Exec(session) + + // create dashboard accountId + slug index + r.Db(dbName).Table("dashboards").IndexCreateFunc("AccountIdSlug", func(row r.Term) interface{} { + return []interface{}{row.Field("AccountId"), row.Field("Slug")} + }).Exec(session) + + r.Db(dbName).Table("dashboards").IndexCreate("AccountId").Exec(session) + r.Db(dbName).Table("accounts").IndexCreate("Login").Exec(session) + + // create account collaborator index + r.Db(dbName).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(3, "Failed to insert master ids row", err) + } +} + +func 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(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 CreateAccount(account *models.Account) error { + accountId, err := getNextAccountId() + if err != nil { + return err + } + + account.Id = accountId + account.UsingAccountId = accountId + + resp, err := r.Table("accounts").Insert(account).RunWrite(session) + if err != nil { + return err + } + + if resp.Inserted == 0 { + return errors.New("Failed to insert acccount") + } + + return nil +} + +func GetAccountByLogin(emailOrName string) (*models.Account, error) { + resp, err := r.Table("accounts").GetAllByIndex("Login", emailOrName).Run(session) + + if err != nil { + return nil, err + } + + var account models.Account + err = resp.One(&account) + if err != nil { + return nil, models.ErrAccountNotFound + } + + return &account, nil +} + +func GetAccount(id int) (*models.Account, error) { + resp, err := r.Table("accounts").Get(id).Run(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 UpdateAccount(account *models.Account) error { + resp, err := r.Table("accounts").Update(account).RunWrite(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 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(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 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(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 +} diff --git a/pkg/stores/store.go b/pkg/stores/store.go index a8ec5b512a2..f8a3c08fd16 100644 --- a/pkg/stores/store.go +++ b/pkg/stores/store.go @@ -1,10 +1,6 @@ package stores -import ( - "errors" - - "github.com/torkelo/grafana-pro/pkg/models" -) +import "github.com/torkelo/grafana-pro/pkg/models" type Store interface { GetDashboard(slug string, accountId int) (*models.Dashboard, error) @@ -19,11 +15,6 @@ type Store interface { Close() } -// Typed errors -var ( - ErrAccountNotFound = errors.New("Account not found") -) - func New() Store { return NewRethinkStore(&RethinkCfg{DatabaseName: "grafana"}) }