From abfaa9d7687e8a78fd03cdaf2df7badfbcdaf7d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Sun, 24 May 2020 16:34:32 +0200 Subject: [PATCH] feat: user signup --- main.go | 84 +++++++++++-------- models/summary.go | 1 + models/user.go | 10 ++- routes/index.go | 138 ++++++++++++++++++++++++++++---- routes/{common.go => routes.go} | 7 +- services/user.go | 26 ++++++ utils/strings.go | 10 +++ views/alerts.tpl.html | 8 +- views/index.tpl.html | 12 ++- views/signup.tpl.html | 42 ++++++++++ 10 files changed, 280 insertions(+), 58 deletions(-) rename routes/{common.go => routes.go} (82%) create mode 100644 utils/strings.go create mode 100644 views/signup.tpl.html diff --git a/main.go b/main.go index 9bc6d6d..8a8b9e3 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,6 @@ package main import ( - "crypto/md5" - "encoding/hex" "encoding/json" "github.com/gorilla/handlers" "github.com/gorilla/securecookie" @@ -19,7 +17,6 @@ import ( "github.com/jinzhu/gorm" "github.com/joho/godotenv" "github.com/rubenv/sql-migrate" - uuid "github.com/satori/go.uuid" ini "gopkg.in/ini.v1" "github.com/muety/wakapi/middlewares" @@ -33,6 +30,19 @@ import ( _ "github.com/jinzhu/gorm/dialects/sqlite" ) +var ( + db *gorm.DB + config *models.Config +) + +var ( + aliasService *services.AliasService + heartbeatService *services.HeartbeatService + userService *services.UserService + summaryService *services.SummaryService + aggregationService *services.AggregationService +) + // TODO: Refactor entire project to be structured after business domains func readConfig() *models.Config { @@ -125,14 +135,16 @@ func readConfig() *models.Config { func main() { // Read Config - config := readConfig() + config = readConfig() + // Enable line numbers in logging if config.IsDev() { log.SetFlags(log.LstdFlags | log.Lshortfile) } // Connect to database - db, err := gorm.Open(config.DbDialect, utils.MakeConnectionString(config)) + var err error + db, err = gorm.Open(config.DbDialect, utils.MakeConnectionString(config)) if config.DbDialect == "sqlite3" { db.DB().Exec("PRAGMA foreign_keys = ON;") } @@ -150,34 +162,34 @@ func main() { migrateDo := databaseMigrateActions(config.DbDialect) migrateDo(db) - // Custom migrations and initial data - addDefaultUser(db, config) - migrateLanguages(db, config) - // Services - aliasSrvc := &services.AliasService{Config: config, Db: db} - heartbeatSrvc := &services.HeartbeatService{Config: config, Db: db} - userSrvc := &services.UserService{Config: config, Db: db} - summarySrvc := &services.SummaryService{Config: config, Db: db, HeartbeatService: heartbeatSrvc, AliasService: aliasSrvc} - aggregationSrvc := &services.AggregationService{Config: config, Db: db, UserService: userSrvc, SummaryService: summarySrvc, HeartbeatService: heartbeatSrvc} + aliasService = &services.AliasService{Config: config, Db: db} + heartbeatService = &services.HeartbeatService{Config: config, Db: db} + userService = &services.UserService{Config: config, Db: db} + summaryService = &services.SummaryService{Config: config, Db: db, HeartbeatService: heartbeatService, AliasService: aliasService} + aggregationService = &services.AggregationService{Config: config, Db: db, UserService: userService, SummaryService: summaryService, HeartbeatService: heartbeatService} - svcs := []services.Initializable{aliasSrvc, heartbeatSrvc, summarySrvc, userSrvc, aggregationSrvc} + svcs := []services.Initializable{aliasService, heartbeatService, userService, summaryService, aggregationService} for _, s := range svcs { s.Init() } + // Custom migrations and initial data + addDefaultUser() + migrateLanguages() + // Aggregate heartbeats to summaries and persist them - go aggregationSrvc.Schedule() + go aggregationService.Schedule() if config.CleanUp { - go heartbeatSrvc.ScheduleCleanUp() + go heartbeatService.ScheduleCleanUp() } // Handlers - heartbeatHandler := routes.NewHeartbeatHandler(config, heartbeatSrvc) - summaryHandler := routes.NewSummaryHandler(config, summarySrvc) + heartbeatHandler := routes.NewHeartbeatHandler(config, heartbeatService) + summaryHandler := routes.NewSummaryHandler(config, summaryService) healthHandler := routes.NewHealthHandler(db) - indexHandler := routes.NewIndexHandler(config, userSrvc) + indexHandler := routes.NewIndexHandler(config, userService) // Setup Routers router := mux.NewRouter() @@ -191,7 +203,7 @@ func main() { corsMiddleware := handlers.CORS() authenticateMiddleware := middlewares.NewAuthenticateMiddleware( config, - userSrvc, + userService, []string{"/api/health"}, ).Handler @@ -204,6 +216,7 @@ func main() { indexRouter.Path("/").Methods(http.MethodGet).HandlerFunc(indexHandler.Index) indexRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(indexHandler.Login) indexRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(indexHandler.Logout) + indexRouter.Path("/signup").Methods(http.MethodGet, http.MethodPost).HandlerFunc(indexHandler.Signup) // Summary Routes summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.Index) @@ -254,8 +267,8 @@ func databaseMigrateActions(dbDialect string) func(db *gorm.DB) { return migrateDo } -func migrateLanguages(db *gorm.DB, cfg *models.Config) { - for k, v := range cfg.CustomLanguages { +func migrateLanguages() { + for k, v := range config.CustomLanguages { result := db.Model(models.Heartbeat{}). Where("language = ?", ""). Where("entity LIKE ?", "%."+k). @@ -269,17 +282,18 @@ func migrateLanguages(db *gorm.DB, cfg *models.Config) { } } -func addDefaultUser(db *gorm.DB, cfg *models.Config) { - pw := md5.Sum([]byte(cfg.DefaultUserPassword)) - pwString := hex.EncodeToString(pw[:]) - apiKey := uuid.NewV4().String() - u := &models.User{ID: cfg.DefaultUserName, Password: pwString, ApiKey: apiKey} - result := db.FirstOrCreate(u, &models.User{ID: u.ID}) - if result.Error != nil { - log.Println("Unable to create default user.") - log.Fatal(result.Error) - } - if result.RowsAffected > 0 { - log.Printf("Created default user '%s' with password '%s' and API key '%s'.\n", u.ID, cfg.DefaultUserPassword, u.ApiKey) +func addDefaultUser() { + u, created, err := userService.CreateOrGet(&models.Signup{ + Username: config.DefaultUserName, + Password: config.DefaultUserPassword, + }) + + if err != nil { + log.Println("unable to create default user") + log.Fatal(err) + } else if created { + log.Printf("created default user '%s' with password '%s' and API key '%s'\n", u.ID, config.DefaultUserPassword, u.ApiKey) + } else { + log.Printf("default user '%s' already existing\n", u.ID) } } diff --git a/models/summary.go b/models/summary.go index f662128..13115ea 100644 --- a/models/summary.go +++ b/models/summary.go @@ -40,4 +40,5 @@ type SummaryViewModel struct { *Summary LanguageColors map[string]string Error string + Success string } diff --git a/models/user.go b/models/user.go index 687949f..e1cf662 100644 --- a/models/user.go +++ b/models/user.go @@ -7,6 +7,12 @@ type User struct { } type Login struct { - Username string `json:"username"` - Password string `json:"password"` + Username string `schema:"username"` + Password string `schema:"password"` +} + +type Signup struct { + Username string `schema:"username"` + Password string `schema:"password"` + PasswordRepeat string `schema:"password_repeat"` } diff --git a/routes/index.go b/routes/index.go index e8c85ae..a23cc53 100644 --- a/routes/index.go +++ b/routes/index.go @@ -1,11 +1,13 @@ package routes import ( + "fmt" "github.com/gorilla/schema" "github.com/muety/wakapi/models" "github.com/muety/wakapi/services" "github.com/muety/wakapi/utils" "net/http" + "net/url" ) type IndexHandler struct { @@ -14,6 +16,7 @@ type IndexHandler struct { } var loginDecoder = schema.NewDecoder() +var signupDecoder = schema.NewDecoder() func NewIndexHandler(config *models.Config, userService *services.UserService) *IndexHandler { return &IndexHandler{ @@ -32,15 +35,18 @@ func (h *IndexHandler) Index(w http.ResponseWriter, r *http.Request) { return } - if err := r.URL.Query().Get("error"); err != "" { - if err == "unauthorized" { - respondError(w, err, http.StatusUnauthorized) - } else { - respondError(w, err, http.StatusInternalServerError) - } + if handleAlerts(w, r, "") { return } + // TODO: make this more generic and reusable + if success := r.URL.Query().Get("success"); success != "" { + templates["index.tpl.html"].Execute(w, struct { + Success string + Error string + }{Success: success}) + return + } templates["index.tpl.html"].Execute(w, nil) } @@ -49,30 +55,35 @@ func (h *IndexHandler) Login(w http.ResponseWriter, r *http.Request) { loadTemplates() } + if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" { + http.Redirect(w, r, "/summary", http.StatusFound) + return + } + var login models.Login if err := r.ParseForm(); err != nil { - respondError(w, "missing parameters", http.StatusBadRequest) + respondAlert(w, "missing parameters", "", "", http.StatusBadRequest) return } if err := loginDecoder.Decode(&login, r.PostForm); err != nil { - respondError(w, "missing parameters", http.StatusBadRequest) + respondAlert(w, "missing parameters", "", "", http.StatusBadRequest) return } user, err := h.userSrvc.GetUserById(login.Username) if err != nil { - respondError(w, "resource not found", http.StatusNotFound) + respondAlert(w, "resource not found", "", "", http.StatusNotFound) return } if !utils.CheckPassword(user, login.Password) { - respondError(w, "invalid credentials", http.StatusUnauthorized) + respondAlert(w, "invalid credentials", "", "", http.StatusUnauthorized) return } encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login) if err != nil { - respondError(w, "internal server error", http.StatusInternalServerError) + respondAlert(w, "internal server error", "", "", http.StatusInternalServerError) return } @@ -96,9 +107,108 @@ func (h *IndexHandler) Logout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) } -func respondError(w http.ResponseWriter, error string, status int) { +func (h *IndexHandler) Signup(w http.ResponseWriter, r *http.Request) { + if h.config.IsDev() { + loadTemplates() + } + + if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" { + http.Redirect(w, r, "/summary", http.StatusFound) + return + } + + switch r.Method { + case http.MethodPost: + h.handlePostSignup(w, r) + return + default: + h.handleGetSignup(w, r) + return + } +} + +func (h *IndexHandler) handleGetSignup(w http.ResponseWriter, r *http.Request) { + if h.config.IsDev() { + loadTemplates() + } + + if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" { + http.Redirect(w, r, "/summary", http.StatusFound) + return + } + + if handleAlerts(w, r, "signup.tpl.html") { + return + } + + templates["signup.tpl.html"].Execute(w, nil) +} + +func (h *IndexHandler) handlePostSignup(w http.ResponseWriter, r *http.Request) { + if h.config.IsDev() { + loadTemplates() + } + + if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" { + http.Redirect(w, r, "/summary", http.StatusFound) + return + } + + var signup models.Signup + if err := r.ParseForm(); err != nil { + respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest) + return + } + if err := signupDecoder.Decode(&signup, r.PostForm); err != nil { + respondAlert(w, "missing parameters", "", "signup.tpl.html", http.StatusBadRequest) + return + } + + if signup.Password != signup.PasswordRepeat { + respondAlert(w, "passwords do not match", "", "signup.tpl.html", http.StatusBadRequest) + return + } + + _, created, err := h.userSrvc.CreateOrGet(&signup) + if err != nil { + respondAlert(w, "failed to create new user", "", "signup.tpl.html", http.StatusInternalServerError) + return + } + if !created { + respondAlert(w, "user already existing", "", "signup.tpl.html", http.StatusConflict) + return + } + + msg := url.QueryEscape("account created successfully") + http.Redirect(w, r, fmt.Sprintf("/?success=%s", msg), http.StatusFound) +} + +func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) { w.WriteHeader(status) - templates["index.tpl.html"].Execute(w, struct { - Error string + if tplName == "" { + tplName = "index.tpl.html" + } + templates[tplName].Execute(w, struct { + Error string + Success string }{Error: error}) } + +// TODO: do better +func handleAlerts(w http.ResponseWriter, r *http.Request, tplName string) bool { + if err := r.URL.Query().Get("error"); err != "" { + if err == "unauthorized" { + respondAlert(w, err, "", tplName, http.StatusUnauthorized) + } else { + respondAlert(w, err, "", tplName, http.StatusInternalServerError) + } + return true + } + + if success := r.URL.Query().Get("success"); success != "" { + respondAlert(w, "", success, tplName, http.StatusOK) + return true + } + + return false +} diff --git a/routes/common.go b/routes/routes.go similarity index 82% rename from routes/common.go rename to routes/routes.go index c902a49..ed1e79d 100644 --- a/routes/common.go +++ b/routes/routes.go @@ -6,6 +6,7 @@ import ( "html/template" "io/ioutil" "path" + "strings" ) func init() { @@ -17,8 +18,10 @@ var templates map[string]*template.Template func loadTemplates() { tplPath := "views" tpls := template.New("").Funcs(template.FuncMap{ - "json": utils.Json, - "date": utils.FormatDateHuman, + "json": utils.Json, + "date": utils.FormatDateHuman, + "title": strings.Title, + "capitalize": utils.Capitalize, }) templates = make(map[string]*template.Template) diff --git a/services/user.go b/services/user.go index 74cfa18..4970ef8 100644 --- a/services/user.go +++ b/services/user.go @@ -1,8 +1,11 @@ package services import ( + "crypto/md5" + "encoding/hex" "github.com/jinzhu/gorm" "github.com/muety/wakapi/models" + uuid "github.com/satori/go.uuid" ) type UserService struct { @@ -37,3 +40,26 @@ func (srv *UserService) GetAll() ([]*models.User, error) { } return users, nil } + +func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool, error) { + pw := md5.Sum([]byte(signup.Password)) + pwString := hex.EncodeToString(pw[:]) + apiKey := uuid.NewV4().String() + + u := &models.User{ + ID: signup.Username, + ApiKey: apiKey, + Password: pwString, + } + + result := srv.Db.FirstOrCreate(u, &models.User{ID: u.ID}) + if err := result.Error; err != nil { + return nil, false, err + } + + if result.RowsAffected == 1 { + return u, true, nil + } + + return u, false, nil +} diff --git a/utils/strings.go b/utils/strings.go new file mode 100644 index 0000000..3175d79 --- /dev/null +++ b/utils/strings.go @@ -0,0 +1,10 @@ +package utils + +import ( + "fmt" + "strings" +) + +func Capitalize(s string) string { + return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:]) +} diff --git a/views/alerts.tpl.html b/views/alerts.tpl.html index f6d1e4d..b26c5d9 100644 --- a/views/alerts.tpl.html +++ b/views/alerts.tpl.html @@ -1,7 +1,13 @@ {{ if .Error }}
- Error: {{ .Error }} + Error: {{ .Error | capitalize }}
+{{ else if .Success }} +
+
+ {{ .Success | capitalize }} +
+
{{ end }} \ No newline at end of file diff --git a/views/index.tpl.html b/views/index.tpl.html index 6b86297..0d556b2 100644 --- a/views/index.tpl.html +++ b/views/index.tpl.html @@ -14,16 +14,20 @@
- +
-
- + + +
diff --git a/views/signup.tpl.html b/views/signup.tpl.html new file mode 100644 index 0000000..25a55a5 --- /dev/null +++ b/views/signup.tpl.html @@ -0,0 +1,42 @@ + + +{{ template "head.tpl.html" . }} + + +
+

Sign Up

+
+ +{{ template "alerts.tpl.html" . }} + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +{{ template "footer.tpl.html" . }} + +{{ template "foot.tpl.html" . }} + + + \ No newline at end of file