diff --git a/.env-dev b/.env-dev new file mode 100644 index 0000000..c88f67a --- /dev/null +++ b/.env-dev @@ -0,0 +1,89 @@ +####################################### +###### App ###### +####################################### +APP_NAME=Cup +APP_ENV=local # local | testing | production +APP_DEBUG_MODE=true +App_HTTP_HOST=localhost +App_HTTP_PORT=8080 +App_USE_HTTPS=false +App_USE_LETSENCRYPT=false +App_USE_CORESERVICES=true +APP_LETSENCRYPT_EMAIL=mail@example.com +App_HTTPS_HOSTS=example.com, www.example.com +App_REDIRECT_HTTP_TO_HTTPS=false +App_CERT_FILE_PATH=tls/server.crt +App_KEY_FILE_PATH=tls/server.key + +####################################### +###### TEMPLATES ###### +####################################### +TEMPLATE_ENABLE=true +APP_ENABLE=true +CDNEnable=false +COOKIE_SECRET=13d6b4dff8f84a10851021ec8608f814570d562c92fe6b5ec4c9f595bcb3234b + +####################################### +###### JWT ###### +####################################### +JWT_SECRET=dkfTgonmgaAdlgkw +JWT_LIFESPAN_MINUTES=4320 # expires after 3 days + +####################################### +###### DATABASE ###### +####################################### +DB_DRIVER=sqlite # mysql | postgres | sqlite +#_____ MYSQL _____# +MYSQL_HOST=db-host-here +MYSQL_DB_NAME=db-name-here +MYSQL_PORT=3306 +MYSQL_USERNAME=db-user-here +MYSQL_PASSWORD=db-password-here +MYSQL_CHARSET=utf8mb4 + +#_____ postgres _____# +POSTGRES_HOST=localhost +POSTGRES_USER=user +POSTGRES_PASSWORD=secret +POSTGRES_DB_NAME=db_test +POSTGRES_PORT=5432 +POSTGRES_SSL_MODE=disable +POSTGRES_TIMEZONE=America/Argentina/Buenos_Aires + +#_____ SQLITE _____# +SQLITE_DB_PATH=storage/sqlite/sqlite.db + +####################################### +###### CACHE ###### +####################################### +CACHE_DRIVER=redis +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +####################################### +###### Emails ###### +####################################### +EMAILS_DRIVER=smtp # smtp | sparkpost | sendgrid | mailgun +#_____ SMTP _____# +SMTP_HOST= +SMTP_PORT=25 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_TLS_SKIP_VERIFY_HOST=true # (set true for development only!) + +#_____ sparkpost _____# +SPARKPOST_BASE_URL=https://api.sparkpost.com +SPARKPOST_API_VERSION=1 +SPARKPOST_API_KEY=sparkpost-api-key-here # the api key + +#_____ sendgrid _____# +SENDGRID_HOST=https://api.sendgrid.com +SENDGRID_ENDPOINT=/v3/mail/send +SENDGRID_API_KEY=sendgrid-api-key-here # the api key + +#_____ mailgun _____# +MAILGUN_DOMAIN=your-domain.com # your domain +MAILGUN_API_KEY=mailgun-api-key-here # the api key +MAILGUN_TLS_SKIP_VERIFY_HOST=true # (set true for development only!) diff --git a/.env-example b/.env-example index b83c72d..d0780b5 100644 --- a/.env-example +++ b/.env-example @@ -8,6 +8,7 @@ App_HTTP_HOST=localhost App_HTTP_PORT=8080 App_USE_HTTPS=false App_USE_LETSENCRYPT=false +App_USE_CORESERVICES=false APP_LETSENCRYPT_EMAIL=mail@example.com App_HTTPS_HOSTS=example.com, www.example.com App_REDIRECT_HTTP_TO_HTTPS=false @@ -18,6 +19,10 @@ App_KEY_FILE_PATH=tls/server.key ###### TEMPLATES ###### ####################################### TEMPLATE_ENABLE=true +COOKIE_SECRET=13d6b4dff8f84a10851021ec8608f814570d562c92fe6b5ec4c9f595bcb3234b +APP_ENABLE=true +CDNEnable=false + ####################################### ###### JWT ###### @@ -47,7 +52,7 @@ POSTGRES_SSL_MODE=disable POSTGRES_TIMEZONE=America/Argentina/Buenos_Aires #_____ SQLITE _____# -SQLITE_DB_PATH=storage/sqlite.db +SQLITE_DB_PATH=storage/sqlite/sqlite.db ####################################### ###### CACHE ###### diff --git a/.gitignore b/.gitignore index 8d4682e..90a928b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ logs/* tls/* !tls/.gitkeep .DS_Store +storage/sqlite/* +.idea +cup diff --git a/README.md b/README.md index 0eee0df..4ae95df 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![gocondor logo](https://git.smarteching.com/avatars/cd7cd5b690adc8e5ec6d6cdb117f1bf5a9e9353dae111bfbb394d2c3d4497537?size=200) +![goffee logo](https://git.smarteching.com/avatars/cd7cd5b690adc8e5ec6d6cdb117f1bf5a9e9353dae111bfbb394d2c3d4497537?size=200) # Cup of Goffee ## What is Goffee? @@ -73,9 +73,9 @@ then `Goffee` locates the matching [handler](https://git.smarteching.com/goffee/ │ ├── config/ --------------------------> main configs │ ├── events/ --------------------------> contains events │ │ ├── jobs/ ------------------------> contains the event jobs -│ ├── controllers/ ------------------------> route's controllers +│ ├── controllers/ ---------------------> route's controllers │ ├── logs/ ----------------------------> app log files -│ ├── hooks/ ---------------------> app hooks +│ ├── hooks/ ---------------------------> app hooks │ ├── models/ --------------------------> database models │ ├── storage/ -------------------------> a place to store files │ ├── tls/ -----------------------------> tls certificates @@ -86,7 +86,7 @@ then `Goffee` locates the matching [handler](https://git.smarteching.com/goffee/ │ ├── main.go --------------------------> go main file │ ├── README.md ------------------------> readme file │ ├── register-events.go ---------------> register events and jobs -│ ├── register-global-hooks.go ---> register global middlewares +│ ├── register-global-hooks.go ---------> register global middlewares │ ├── routes.go ------------------------> app routes │ ├── run-auto-migrations.go -----------> database migrations ``` \ No newline at end of file diff --git a/config/queue.go b/config/queue.go new file mode 100644 index 0000000..8060572 --- /dev/null +++ b/config/queue.go @@ -0,0 +1,20 @@ +// Copyright (c) 2025 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package config + +import "git.smarteching.com/goffee/core" + +// Retrieve the main config for the Queue +func GetQueueConfig() core.QueueConfig { + //##################################### + //# Main configuration for Queue ##### + //##################################### + + return core.QueueConfig{ + // For enabling and disabling the queue system + // set to true to enable it, set to false to disable + EnableQueue: false, + } +} diff --git a/controllers/adminusers.go b/controllers/adminusers.go new file mode 100644 index 0000000..9ffb762 --- /dev/null +++ b/controllers/adminusers.go @@ -0,0 +1,604 @@ +// Copyright (c) 2024 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package controllers + +import ( + "errors" + "fmt" + "strconv" + + "git.smarteching.com/goffee/core" + "git.smarteching.com/goffee/core/template/components" + "git.smarteching.com/goffee/cup/events" + "git.smarteching.com/goffee/cup/models" + "git.smarteching.com/goffee/cup/utils" + "gorm.io/gorm" +) + +func AdminUsersList(c *core.Context) *core.Response { + + // initiate authority + auth := new(utils.Authority) + var session = new(utils.SessionUser) + // true if session is active + hassession := session.Init(c) + + if !hassession { + type emptytemplate struct{} + emptyData := emptytemplate{} + return c.Response.Template("nopermission.html", emptyData) + } + + session_uid := session.GetUserID() + // check if user has role admin + is_admin, _ := auth.CheckUserRole(c, session_uid, "admin") + + if !is_admin { + type emptytemplate struct{} + emptyData := emptytemplate{} + return c.Response.Template("nopermission.html", emptyData) + } + + // continue if has session and is admin + var users []models.User + db := c.GetGorm() + db.Find(&users) + + // -- response template + type templateData struct { + TableUsers components.ContentTable + AddButton components.ContentHref + } + + cols := []components.ContentTableTH{ + { + Value: "UID", + ValueType: "number", + }, + { + Value: "Name", + ValueType: "string", + }, + { + Value: "Fullname", + ValueType: "string", + }, + { + Value: "Email", + ValueType: "string", + }, + { + Value: "Roles", + ValueType: "string", + }, + { + Value: "Created", + }, + { + Value: "Updated", + }, + { + ValueType: "href", + }, + } + + var listroles string + rows := make([][]components.ContentTableTD, len(users)) + for i, u := range users { + + roles, _ := auth.GetUserRoles(c, u.ID) + listroles = "" + + for _, role := range roles { + if listroles != "" { + listroles += ", " + } + listroles += role.Name + } + + row := []components.ContentTableTD{ + {Value: strconv.Itoa(int(u.ID))}, + {Value: u.Name}, + {Value: u.Fullname}, + {Value: u.Email}, + {Value: listroles}, + {Value: utils.FormatUnix(u.Created)}, + {Value: utils.FormatUnix(u.Updated)}, + {Value: components.ContentHref{ + Text: "edit", + Link: "/admin/users/edit/" + strconv.Itoa(int(u.ID)), + TypeClass: "outline-secondary", + }}, + } + rows[i] = row + } + + tmplData := templateData{ + TableUsers: components.ContentTable{ + ID: "table_demo", + AllTH: cols, + AllTD: rows, + }, + AddButton: components.ContentHref{ + Text: "Register", + Link: "/admin/users/add", + IsButton: true, + TypeClass: "outline-primary", + }, + } + return c.Response.Template("admin_userlist.html", tmplData) +} + +func AdminUsersAdd(c *core.Context) *core.Response { + + // initiate authority + auth := new(utils.Authority) + var session = new(utils.SessionUser) + // true if session is active + hassession := session.Init(c) + + if !hassession { + type emptytemplate struct{} + emptyData := emptytemplate{} + return c.Response.Template("nopermission.html", emptyData) + } + + session_uid := session.GetUserID() + // check if user has role admin + is_admin, _ := auth.CheckUserRole(c, session_uid, "admin") + + if !is_admin { + type emptytemplate struct{} + emptyData := emptytemplate{} + return c.Response.Template("nopermission.html", emptyData) + } + + // check if is submit + submit := c.GetRequestParam("submit").(string) + errormessages := make([]string, 0) + var listroles []components.FormCheckboxItem + systemroles, _ := auth.GetAllRoles(c) + + for _, systemrole := range systemroles { + var userrole components.FormCheckboxItem + userrole.Label = systemrole.Name + userrole.Name = "roles" + userrole.Value = systemrole.Slug + if systemrole.Slug == "authenticated" { + userrole.IsChecked = true + } + listroles = append(listroles, userrole) + } + + name := "" + fullname := "" + email := "" + password := "" + + if submit != "" { + + name = c.GetRequestParam("name").(string) + fullname = c.GetRequestParam("fullname").(string) + email = c.GetRequestParam("email").(string) + password = c.GetRequestParam("password").(string) + roles := c.GetRequesForm("roles").([]string) + + // check if email exists + var user models.User + res := c.GetGorm().Where("email = ?", c.CastToString(email)).First(&user) + if res.Error != nil && !errors.Is(res.Error, gorm.ErrRecordNotFound) { + c.GetLogger().Error(res.Error.Error()) + errormessages = append(errormessages, "Error") + } + if res.Error == nil { + errormessages = append(errormessages, "Error, email already exists in the database") + } + + // validation data + data := map[string]interface{}{ + "name": name, + "fullname": fullname, + "email": email, + "password": password, + } + // validation rules + rules := map[string]interface{}{ + "name": "required|alphaNumeric", + "fullname": "required", + "email": "required|email", + "password": "required|length:6,20", + } + // validate + v := c.GetValidator().Validate(data, rules) + if v.Failed() { + c.GetLogger().Error(v.GetErrorMessagesJson()) + for _, v := range v.GetErrorMessagesMap() { + errormessages = append(errormessages, v) + } + } + + if len(errormessages) == 0 { + + //hash the password + passwordHashed, _ := c.GetHashing().HashPassword(c.CastToString(password)) + // store the record in db + user = models.User{ + Name: c.CastToString(name), + Fullname: c.CastToString(fullname), + Email: c.CastToString(email), + Password: passwordHashed, + } + res = c.GetGorm().Create(&user) + if res.Error != nil { + c.GetLogger().Error(res.Error.Error()) + errormessages = append(errormessages, res.Error.Error()) + } else { + + // assign roles + for _, role := range roles { + auth.AssignRoleToUser(c, user.ID, role) + } + + // fire user registered event + err := c.GetEventsManager().Fire(&core.Event{Name: events.USER_REGISTERED, Payload: map[string]interface{}{ + "user": user, + }}) + if err != nil { + c.GetLogger().Error(err.Error()) + errormessages = append(errormessages, "Internal server error") + } else { + // redirect to list + return c.Response.Redirect("/admin/users") + } + } + } + } + // -- response template + type templateData struct { + FieldName components.FormInput + FieldFullname components.FormInput + FieldEmail components.FormInput + FieldRoles components.FormCheckbox + FieldPassword components.FormInput + ErrorMessages []string + SubmitButton components.FormButton + } + + tmplData := templateData{ + FieldName: components.FormInput{ + ID: "name", + Label: "Name", + Type: "text", + Value: name, + IsRequired: true, + }, + FieldFullname: components.FormInput{ + ID: "fullname", + Label: "Full name", + Type: "text", + Value: fullname, + IsRequired: true, + }, + FieldEmail: components.FormInput{ + ID: "email", + Label: "e-mail", + Type: "text", + Value: email, + //Autocomplete: true, + IsRequired: true, + }, + FieldRoles: components.FormCheckbox{ + Label: "Roles", + AllCheckbox: listroles, + }, + FieldPassword: components.FormInput{ + ID: "password", + Label: "Password", + Type: "password", + Value: password, + IsRequired: true, + }, + SubmitButton: components.FormButton{ + ID: "submit", + Text: "Add user", + Value: "submit", + IsSubmit: true, + TypeClass: "primary", + }, + ErrorMessages: errormessages, + } + return c.Response.Template("admin_useradd.html", tmplData) + +} + +func AdminUsersEdit(c *core.Context) *core.Response { + + // check if is submit + submit := c.GetRequestParam("submit").(string) + + user_id := c.GetPathParam("id") + + errormessages := make([]string, 0) + // initiate authority + auth := new(utils.Authority) + + var listroles []components.FormCheckboxItem + + systemroles, _ := auth.GetAllRoles(c) + user_id_uint, _ := strconv.ParseUint(user_id.(string), 10, 32) + + userroles, _ := auth.GetUserRoles(c, uint(user_id_uint)) + + for _, systemrole := range systemroles { + var userrole components.FormCheckboxItem + userrole.Label = systemrole.Name + userrole.Name = "roles" + userrole.Value = systemrole.Slug + for _, ur := range userroles { + if ur.Slug == systemrole.Slug { + userrole.IsChecked = true + break + } + } + listroles = append(listroles, userrole) + } + + var origin_user models.User + + db := c.GetGorm() + // check if existes + result_db := db.First(&origin_user, user_id) + if result_db.RowsAffected == 0 { + c.GetLogger().Error("User ID not found") + return c.Response.Redirect("/admin/users") + } + + name := origin_user.Name + fullname := origin_user.Fullname + email := origin_user.Email + password := "" + user_id_string := c.GetPathParam("id").(string) + + if submit != "" { + + name = c.GetRequestParam("name").(string) + fullname = c.GetRequestParam("fullname").(string) + email = c.GetRequestParam("email").(string) + password = c.GetRequestParam("password").(string) + roles := c.GetRequesForm("roles").([]string) + key := c.GetRequestParam("key") + + // check if email exists + var user models.User + res := c.GetGorm().Where("email = ? AND id != ?", c.CastToString(email), key).First(&user) + if res.Error != nil && !errors.Is(res.Error, gorm.ErrRecordNotFound) { + c.GetLogger().Error(res.Error.Error()) + errormessages = append(errormessages, "Error") + } + if res.Error == nil { + errormessages = append(errormessages, "Error, email already exists to another user") + } + + // validation data + data := map[string]interface{}{ + "name": name, + "fullname": fullname, + "email": email, + } + // validation rules + rules := map[string]interface{}{ + "name": "required|alphaNumeric", + "fullname": "required", + "email": "required|email", + } + + // nee update password + if password != "" { + data["password"] = password + rules["password"] = "required|length:6,20" + } + // validate + v := c.GetValidator().Validate(data, rules) + if v.Failed() { + c.GetLogger().Error(v.GetErrorMessagesJson()) + for _, v := range v.GetErrorMessagesMap() { + errormessages = append(errormessages, v) + } + } + + if len(errormessages) == 0 { + + //hash the password + if password != "" { + passwordHashed, _ := c.GetHashing().HashPassword(c.CastToString(password)) + origin_user.Password = passwordHashed + } + // store the record in db + origin_user.Name = name + origin_user.Fullname = fullname + origin_user.Email = email + + result_db = db.Save(&origin_user) + if result_db.RowsAffected == 0 { + c.GetLogger().Error("Admin user: error updating") + errormessages = append(errormessages, fmt.Sprintf("Error updating user %s:", user_id_string)) + } else { + + // delete roles + auth.RevokeAllUserRole(c, origin_user.ID) + // assign roles + for _, role := range roles { + auth.AssignRoleToUser(c, origin_user.ID, role) + } + + return c.Response.Redirect("/admin/users") + } + } + } + // -- response template + type templateData struct { + FieldName components.FormInput + FieldFullname components.FormInput + FieldEmail components.FormInput + FieldRoles components.FormCheckbox + FieldPassword components.FormInput + FieldKey components.FormInput + ErrorMessages []string + SubmitButton components.FormButton + DeleteButton components.FormButton + } + + tmplData := templateData{ + FieldName: components.FormInput{ + ID: "name", + Label: "Name", + Type: "text", + Value: name, + IsRequired: true, + }, + FieldFullname: components.FormInput{ + ID: "fullname", + Label: "Full name", + Type: "text", + Value: fullname, + IsRequired: true, + }, + FieldEmail: components.FormInput{ + ID: "email", + Label: "e-mail", + Type: "text", + Value: email, + //Autocomplete: true, + IsRequired: true, + }, + FieldRoles: components.FormCheckbox{ + Label: "Roles", + AllCheckbox: listroles, + }, + FieldPassword: components.FormInput{ + ID: "password", + Label: "Password", + Type: "password", + Hint: "Leave blank if you don't want to change it", + Value: password, + IsRequired: false, + }, + FieldKey: components.FormInput{ + ID: "key", + Type: "hidden", + Value: user_id_string, + }, + SubmitButton: components.FormButton{ + ID: "submit", + Text: "Update user", + Value: "submit", + IsSubmit: true, + TypeClass: "primary", + }, + DeleteButton: components.FormButton{ + ID: "submit", + Text: "Delete user", + Value: "submit", + IsSubmit: true, + TypeClass: "warning", + }, + ErrorMessages: errormessages, + } + return c.Response.Template("admin_useredit.html", tmplData) + +} + +func AdminUsersDelete(c *core.Context) *core.Response { + + user_id := c.GetRequestParam("key").(string) + + errormessages := make([]string, 0) + warningmessages := make([]string, 0) + + var origin_user models.User + + db := c.GetGorm() + // check if existes + result_db := db.First(&origin_user, user_id) + if result_db.RowsAffected == 0 { + errormessages = append(errormessages, "User ID not found") + } else { + // check if is the seed user + seed := "1" + if user_id == seed { + errormessages = append(errormessages, "You can't delete the seed user") + } + + } + + // sample warning message + warningmessages = append(warningmessages, fmt.Sprintf("Are you sure you want to cancel the account %s?", origin_user.Name)) + + // -- response template + type templateData struct { + ErrorMessages []string + WarningMessages []string + FieldKey components.FormInput + ConfirmButton components.FormButton + BackButton components.ContentHref + } + + tmplData := templateData{ + FieldKey: components.FormInput{ + ID: "key", + Type: "hidden", + Value: user_id, + }, + ConfirmButton: components.FormButton{ + ID: "submit", + Text: "Confirm", + Value: "submit", + IsSubmit: true, + TypeClass: "primary", + }, + BackButton: components.ContentHref{ + Link: "/admin/users", + Text: "Cancel", + IsButton: true, + }, + ErrorMessages: errormessages, + WarningMessages: warningmessages, + } + return c.Response.Template("admin_confirmuserdel.html", tmplData) + +} + +func AdminUsersDelConfirm(c *core.Context) *core.Response { + + user_id := c.GetRequestParam("key").(string) + + var origin_user models.User + + db := c.GetGorm() + // check if existes + result_db := db.First(&origin_user, user_id) + if result_db.RowsAffected != 0 { + // check if is the seed user + seed := "1" + if user_id != seed { + + // initiate authority + auth := new(utils.Authority) + // Delete the user + // fire user delete event + err := c.GetEventsManager().Fire(&core.Event{Name: events.USER_DELETED, Payload: map[string]interface{}{ + "user": origin_user, + }}) + if err != nil { + c.GetLogger().Error(err.Error()) + } + auth.RevokeAllUserRole(c, origin_user.ID) + result_db.Unscoped().Delete(&origin_user) + } + } + + return c.Response.Redirect("/admin/users") + +} diff --git a/controllers/authentication.go b/controllers/authentication.go index a58b71d..9e3eff9 100644 --- a/controllers/authentication.go +++ b/controllers/authentication.go @@ -22,12 +22,11 @@ import ( "git.smarteching.com/goffee/cup/utils" "github.com/google/uuid" "gorm.io/gorm" - - "git.smarteching.com/goffee/core/template/components" ) func Signup(c *core.Context) *core.Response { name := c.GetRequestParam("name") + fullname := c.GetRequestParam("fullname") email := c.GetRequestParam("email") password := c.GetRequestParam("password") // check if email exists @@ -48,14 +47,16 @@ func Signup(c *core.Context) *core.Response { // validation data data := map[string]interface{}{ "name": name, + "fullname": fullname, "email": email, "password": password, } // validation rules rules := map[string]interface{}{ "name": "required|alphaNumeric", + "fullname": "required", "email": "required|email", - "password": "required|length:6,10", + "password": "required|length:6,20", } // validate v := c.GetValidator().Validate(data, rules) @@ -75,6 +76,7 @@ func Signup(c *core.Context) *core.Response { // store the record in db user = models.User{ Name: c.CastToString(name), + Fullname: c.CastToString(fullname), Email: c.CastToString(email), Password: passwordHashed, } @@ -223,6 +225,12 @@ func Signin(c *core.Context) *core.Response { userAgent := c.GetUserAgent() hashedCacheKey := utils.CreateAuthTokenHashedCacheKey(user.ID, userAgent) err = c.GetCache().Set(hashedCacheKey, token) + + // delete data from old sessions + sessionKey := fmt.Sprintf("sess_%v", userAgent) + hashedSessionKey := utils.CreateAuthTokenHashedCacheKey(user.ID, sessionKey) + _ = c.GetCache().Delete(hashedSessionKey) + if err != nil { c.GetLogger().Error(err.Error()) if TemplateEnable { @@ -421,8 +429,28 @@ func SetNewPassword(c *core.Context) *core.Response { } func Signout(c *core.Context) *core.Response { - tokenRaw := c.GetHeader("Authorization") - token := strings.TrimSpace(strings.Replace(tokenRaw, "Bearer", "", 1)) + + // check if template engine is enable + TemplateEnableStr := os.Getenv("TEMPLATE_ENABLE") + if TemplateEnableStr == "" { + TemplateEnableStr = "false" + } + TemplateEnable, _ := strconv.ParseBool(TemplateEnableStr) + + token := "" + + if TemplateEnable { + // get cookie + usercookie, err := c.GetCookie() + if err != nil { + + } + token = usercookie.Token + } else { + tokenRaw := c.GetHeader("Authorization") + token = strings.TrimSpace(strings.Replace(tokenRaw, "Bearer", "", 1)) + } + if token == "" { return c.Response.SetStatusCode(http.StatusUnauthorized).Json(c.MapToJson(map[string]interface{}{ "message": "unauthorized", @@ -448,43 +476,3 @@ func Signout(c *core.Context) *core.Response { "message": "signed out successfully", })) } - -// Show basic app login -func AppLogin(c *core.Context) *core.Response { - - // first, include all compoments - // first, include all compoments - type templateData struct { - PageCard components.PageCard - } - - // now fill data of the components - tmplData := templateData{ - PageCard: components.PageCard{ - CardTitle: "Card title", - CardBody: "Loerm ipsum at deim", - }, - } - return c.Response.Template("login.html", tmplData) -} - -// Show basic app sample -func AppSample(c *core.Context) *core.Response { - - // first, include all compoments - type templateData struct { - PageCard components.PageCard - } - - // now fill data of the components - tmplData := templateData{ - PageCard: components.PageCard{ - CardTitle: "Protected page", - CardBody: "If you can see this page, your are loggedin", - }, - } - //fmt.Printf("Outside cookie user is: %s", user.Email) - - return c.Response.Template("app.html", tmplData) - -} diff --git a/controllers/queuesample.go b/controllers/queuesample.go new file mode 100644 index 0000000..d289aee --- /dev/null +++ b/controllers/queuesample.go @@ -0,0 +1,52 @@ +// Copyright (c) 2025 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package controllers + +import ( + "encoding/json" + "log" + "time" + + "git.smarteching.com/goffee/core" + "git.smarteching.com/goffee/cup/workers" + "github.com/hibiken/asynq" +) + +// Make samples queues +func Queuesample(c *core.Context) *core.Response { + + // Get client queue asynq + client := c.GetQueueClient() + + // Create a task with typename and payload. + payload, err := json.Marshal(workers.EmailTaskPayload{UserID: 42}) + if err != nil { + log.Fatal(err) + } + + t1 := asynq.NewTask(workers.TypeWelcomeEmail, payload) + + t2 := asynq.NewTask(workers.TypeReminderEmail, payload) + + // Process the task immediately. + info, err := client.Enqueue(t1) + if err != nil { + log.Fatal(err) + } + log.Printf(" [*] Successfully enqueued task: %+v", info) + + // Process 2 task 1 min later. + for i := 1; i < 3; i++ { + info, err = client.Enqueue(t2, asynq.ProcessIn(1*time.Minute)) + if err != nil { + log.Fatal(err) + } + log.Printf(" [*] Successfully enqueued task: %+v", info) + } + + message := "{\"message\": \"Task queued\"}" + return c.Response.Json(message) + +} diff --git a/controllers/sample.go b/controllers/sample.go index 1243d5d..9f5687b 100644 --- a/controllers/sample.go +++ b/controllers/sample.go @@ -5,8 +5,11 @@ package controllers import ( + "fmt" + "git.smarteching.com/goffee/core" "git.smarteching.com/goffee/core/template/components" + "git.smarteching.com/goffee/cup/utils" ) // Show basic template @@ -28,3 +31,119 @@ func Sample(c *core.Context) *core.Response { return c.Response.Template("basic.html", tmplData) } + +// Show basic app login +func AppLogin(c *core.Context) *core.Response { + + // first, include all compoments + // first, include all compoments + type templateData struct { + PageCard components.PageCard + } + + // now fill data of the components + tmplData := templateData{ + PageCard: components.PageCard{ + CardTitle: "Card title", + CardBody: "Loerm ipsum at deim", + }, + } + return c.Response.Template("login.html", tmplData) +} + +// Show basic app login +func AppSession(c *core.Context) *core.Response { + + var session = new(utils.SessionUser) + + // true if session is active + hassession := session.Init(c) + + //session.Set("numberdos", 66) + + type templateData struct { + PageCard components.PageCard + } + + // now fill data of the components + tmplData := templateData{} + + if hassession { + + sesiondata := "" + cardtitle := fmt.Sprintf("Session user id: %v", session.GetUserID()) + numberdos, ok := session.Get("numberdos") + + if numberdos != nil { + numberdos = numberdos.(float64) + 10 + } else { + numberdos = 10 + } + + session.Set("numberdos", numberdos) + + if ok { + sesiondata = fmt.Sprintf("OK, Session numberdos has %v", numberdos) + } else { + sesiondata = fmt.Sprintf("No ok, session numberdos has %v", numberdos) + } + + // delete single + //session.Delete("numberdos") + + // delete all data + //session.Flush() + + tmplData = templateData{ + PageCard: components.PageCard{ + CardTitle: cardtitle, + CardBody: sesiondata, + }, + } + + return c.Response.Template("appsession.html", tmplData) + + } else { + + return c.Response.Template("login.html", tmplData) + + } + +} + +// Show basic app sample +func AppSample(c *core.Context) *core.Response { + + // first, include all compoments + type templateData struct { + PageCard components.PageCard + ContentDropdown components.ContentDropdown + } + + // now fill data of the components + tmplData := templateData{ + PageCard: components.PageCard{ + CardTitle: "Protected page", + CardBody: "If you can see this page, your are loggedin", + }, + ContentDropdown: components.ContentDropdown{ + Label: "dropdown", + Items: []components.ContentDropdownItem{ + { + Text: "Signout", + Link: "#", + ID: "signout", + }, + { + Text: "item disabled", + Link: "#", + IsDisabled: true, + }, + }, + }, + } + //fmt.Printf("Outside cookie user is: %s", user.Email) + + return c.Response.Template("app.html", tmplData) + +} diff --git a/controllers/themedemo.go b/controllers/themedemo.go index 1373052..03c55c1 100644 --- a/controllers/themedemo.go +++ b/controllers/themedemo.go @@ -6,6 +6,7 @@ package controllers import ( "fmt" + "math/rand/v2" "os" "strconv" @@ -114,10 +115,10 @@ func Themeform(c *core.Context) *core.Response { optionc.Value = "buenosaires" optionc.Label = "Buenos Aires" allOptionsc = append(allOptionsc, optionc) - optionc.ID = "sogas" - optionc.Name = "sogas" - optionc.Value = "Sogamoso" - optionc.Label = "Sogamoso" + optionc.ID = "london" + optionc.Name = "london" + optionc.Value = "london" + optionc.Label = "London" //optionc.IsChecked = true allOptionsc = append(allOptionsc, optionc) @@ -642,9 +643,42 @@ func Themecontent(c *core.Context) *core.Response { // first, include all compoments type templateData struct { - ContentTable components.ContentTable + ContentTable components.ContentTable + ContentTabledetail components.ContentTabledetail + ContentGraph components.ContentGraph + FieldText components.FormInput + FormSelectCityM components.FormSelect + Pagination components.ContentPagination + ShouldShowPagination bool } + // for select options + var allOptions []components.FormSelectOption + var option components.FormSelectOption + option.Value = "ch" + option.Caption = "China" + allOptions = append(allOptions, option) + option.Value = "ba" + option.Caption = "Buenos Aires" + allOptions = append(allOptions, option) + option.Value = "fr" + option.Caption = "France" + selectedOption := option + allOptions = append(allOptions, option) + option.Value = "kr" + option.Caption = "Korea" + allOptions = append(allOptions, option) + + // for custom attributes in form + var customAtt []components.CustomAtt + var attribute components.CustomAtt + attribute.AttName = "code" + attribute.AttValue = "five" + customAtt = append(customAtt, attribute) + attribute.AttName = "mytag" + attribute.AttValue = "My value" + customAtt = append(customAtt, attribute) + // TABLES // for th head var allTh []components.ContentTableTH @@ -667,7 +701,7 @@ func Themecontent(c *core.Context) *core.Response { var allTd [][]components.ContentTableTD //var vals []components.ContentTableTD // rows - for i := 1; i <= 10; i++ { + for i := 1; i <= 28; i++ { vals := make([]components.ContentTableTD, len(allTh)) for b := 0; b < len(allTh)-2; b++ { vals[b].Value = fmt.Sprintf("%s%d%d", "TD data: ", i, b) @@ -686,14 +720,117 @@ func Themecontent(c *core.Context) *core.Response { allTd = append(allTd, vals) } + // Pagination demo + // start config + limit := 10 + shouldShowPagination := false + pageViewTableOffset := 0 + // Get the length of AllOptions + totalrecords := len(allTd) + // get current table offset + tpage := c.RequestParamExists("tpage") + if tpage { + pageViewTableOffset, _ = strconv.Atoi(c.GetRequestParam("tpage").(string)) + } + + // start default option paginator + var pagination components.ContentPagination + pagination.PageStartRecord = pageViewTableOffset + 1 + pagination.PageEndRecord = 0 + pagination.TotalRecords = 0 + pagination.PrevLink = "" + pagination.NextLink = "" + + // check current page + // fake function to emulate a query offset + newTd := getPaginatedPageViews(allTd, limit, pageViewTableOffset) + + if len(newTd) > 0 { + pagination.TotalRecords = totalrecords + pagination.PageStartRecord = pageViewTableOffset + 1 + pagination.PageEndRecord = pageViewTableOffset + len(newTd) + shouldShowPagination = totalrecords > limit + } + + if shouldShowPagination && pageViewTableOffset != 0 { + pagination.PrevLink = fmt.Sprintf("/themecontent?tpage=%d", pageViewTableOffset-limit) + } + + if shouldShowPagination && pageViewTableOffset+limit < totalrecords { + pagination.NextLink = fmt.Sprintf("/themecontent?tpage=%d", pageViewTableOffset+limit) + } + + // for td items in table detail + var allTdetail []components.ContentTabledetailTD + // table detail + var thd components.ContentTabledetailTD + thd.Caption = "Continent" + thd.Value = "Asia" + allTdetail = append(allTdetail, thd) + thd.Caption = "Country" + thd.Value = "South Korea" + allTdetail = append(allTdetail, thd) + thd.Caption = "Capital" + thd.Value = "Seoul" + allTdetail = append(allTdetail, thd) + thd.Caption = "Details" + thd.ValueType = "href" // column type href + thd.Value = components.ContentHref{ + Text: "edit", + Link: "#", + } + allTdetail = append(allTdetail, thd) + thd.Caption = "Notifications" + thd.ValueType = "badge" // column type href + thd.Value = components.ContentBadge{ + Text: "success", + TypeClass: "success", + } + allTdetail = append(allTdetail, thd) + + // random values for pie + one := rand.IntN(50) + two := rand.IntN(50) + three := rand.IntN(50) + valuesgraph := fmt.Sprintf("%d|%d|%d", one, two, three) + // now fill data of the components tmplData := templateData{ + FormSelectCityM: components.FormSelect{ + ID: "city", + Label: "Select city", + AllOptions: allOptions, + SelectedOption: selectedOption, + IsMultiple: true, + }, + FieldText: components.FormInput{ + ID: "text", + Label: "Name", + Type: "text", + Hint: "This is sample hint", + Placeholder: "Enter your name", + CustomAtt: customAtt, + }, ContentTable: components.ContentTable{ ID: "table_demo", AllTH: allTh, - AllTD: allTd, + AllTD: newTd, }, + ContentTabledetail: components.ContentTabledetail{ + ID: "table_demodetail", + Title: "Sample table detail", + HeadClass: "table-warning", + AllTD: allTdetail, + }, + ContentGraph: components.ContentGraph{ + Graph: "pie", + Labels: "Berlin|Paris|Venecia", + Values: valuesgraph, + }, + Pagination: pagination, + ShouldShowPagination: shouldShowPagination, } + return c.Response.Template("custom_theme_contentpage.html", tmplData) } else { @@ -704,3 +841,23 @@ func Themecontent(c *core.Context) *core.Response { } } + +func getPaginatedPageViews(values [][]components.ContentTableTD, limit int, offset int) [][]components.ContentTableTD { + + // Validate the offset and adjust if necessary + if offset < 0 { + offset = 0 // Ensure offset is not negative + } else if offset >= len(values) { + var emptytd [][]components.ContentTableTD + return emptytd + } + + // Calculate the end index (limit the slice to the size of the array) + end := offset + limit + if end > len(values) { + end = len(values) // Ensure end doesn't exceed the length of the array + } + + return values[offset:end] // Slice the array + +} diff --git a/events/event-names.go b/events/event-names.go index 0e5f431..14afec8 100644 --- a/events/event-names.go +++ b/events/event-names.go @@ -2,5 +2,6 @@ package events // event names const USER_REGISTERED = "user-registered" +const USER_DELETED = "user-deleted" const USER_PASSWORD_RESET_REQUESTED = "user-password-reset-requested" const PASSWORD_CHANGED = "password-changed" diff --git a/go.mod b/go.mod index 2243d4d..bdafa0f 100644 --- a/go.mod +++ b/go.mod @@ -7,44 +7,58 @@ replace ( git.smarteching.com/goffee/cup/models => ./models ) -go 1.23.1 +go 1.24.1 require ( - git.smarteching.com/goffee/core v1.7.3 + git.smarteching.com/goffee/core v1.9.5 github.com/google/uuid v1.6.0 + github.com/hibiken/asynq v0.25.1 github.com/joho/godotenv v1.5.1 github.com/julienschmidt/httprouter v1.3.0 - gorm.io/gorm v1.25.12 + gorm.io/gorm v1.30.0 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect + git.smarteching.com/zeni/go-chart/v2 v2.1.4 // indirect github.com/SparkPost/gosparkpost v0.2.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/go-chi/chi/v5 v5.0.8 // indirect + github.com/go-chi/chi/v5 v5.2.2 // indirect github.com/go-ozzo/ozzo-validation v3.6.0+incompatible // indirect - github.com/go-sql-driver/mysql v1.7.0 // indirect - github.com/golang-jwt/jwt/v5 v5.0.0 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/harranali/mailing v1.2.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.3.1 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.5 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/json-iterator/go v1.1.10 // indirect - github.com/mailgun/mailgun-go/v4 v4.10.0 // indirect - github.com/mattn/go-sqlite3 v1.14.17 // indirect - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect - github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailgun/errors v0.4.0 // indirect + github.com/mailgun/mailgun-go/v4 v4.23.0 // indirect + github.com/mattn/go-sqlite3 v1.14.28 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/redis/go-redis/v9 v9.0.5 // indirect + github.com/redis/go-redis/v9 v9.11.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sendgrid/rest v2.6.9+incompatible // indirect - github.com/sendgrid/sendgrid-go v3.12.0+incompatible // indirect - golang.org/x/crypto v0.11.0 // indirect - golang.org/x/net v0.10.0 // indirect - golang.org/x/text v0.14.0 // indirect - gorm.io/driver/mysql v1.5.1 // indirect - gorm.io/driver/postgres v1.5.2 // indirect - gorm.io/driver/sqlite v1.5.2 // indirect + github.com/sendgrid/sendgrid-go v3.16.1+incompatible // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spf13/cast v1.9.2 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/image v0.29.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/time v0.12.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gorm.io/driver/mysql v1.6.0 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index e052570..0ea3916 100644 --- a/go.sum +++ b/go.sum @@ -1,51 +1,70 @@ -git.smarteching.com/goffee/core v1.7.3 h1:GlZ7B/QwAQ6eSQcYBtqlglZoqA7tFYiXqvV2z27xuQY= -git.smarteching.com/goffee/core v1.7.3/go.mod h1:QQNIHVN6qjJBtq42WCQMrLYN9oFE3wm26SLU8ZxNTec= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +git.smarteching.com/goffee/core v1.9.2 h1:SpLAhsTxssPItgkBYLN3UxMH5s+q8qVtbvmRom3WKh8= +git.smarteching.com/goffee/core v1.9.2/go.mod h1:L9a+kL1RVHRHzp+DzCS1apwVLyZAvGE6B94UlyIMhIg= +git.smarteching.com/goffee/core v1.9.5 h1:rq6vI4WSUMGQNzJvhNWmtY2ycC7UszEvXpQ7uUR8sZY= +git.smarteching.com/goffee/core v1.9.5/go.mod h1:ifiBgTOR4zCMzdGsabNrEO792EHny2o149NGe3TSlms= +git.smarteching.com/zeni/go-chart/v2 v2.1.4 h1:pF06+F6eqJLIG8uMiTVPR5TygPGMjM/FHMzTxmu5V/Q= +git.smarteching.com/zeni/go-chart/v2 v2.1.4/go.mod h1:b3ueW9h3pGGXyhkormZAvilHaG4+mQti+bMNPdQBeOQ= github.com/SparkPost/gosparkpost v0.2.0 h1:yzhHQT7cE+rqzd5tANNC74j+2x3lrPznqPJrxC1yR8s= github.com/SparkPost/gosparkpost v0.2.0/go.mod h1:S9WKcGeou7cbPpx0kTIgo8Q69WZvUmVeVzbD+djalJ4= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/brianvoe/gofakeit/v6 v6.21.0 h1:tNkm9yxEbpuPK8Bx39tT4sSc5i9SUGiciLdNix+VDQY= github.com/brianvoe/gofakeit/v6 v6.21.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= -github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao= -github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= -github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= -github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/buger/jsonparser v1.0.0/go.mod h1:tgcrVJ81GPSF0mz+0nu1Xaz0fazGPrmmJfJtxjbHhUQ= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ= -github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64= -github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= -github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= -github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 h1:E2s37DuLxFhQDg5gKsWoLBOB0n+ZW8s599zru8FJ2/Y= -github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= -github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= -github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-ozzo/ozzo-validation v3.6.0+incompatible h1:msy24VGS42fKO9K1vLz82/GeYW1cILu7Nuuj1N3BBkE= github.com/go-ozzo/ozzo-validation v3.6.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU= -github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo= +github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= -github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= -github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/harranali/mailing v1.2.0 h1:ihIyJwB8hyRVcdk+v465wk1PHMrSrgJqo/kMd+gZClY= github.com/harranali/mailing v1.2.0/go.mod h1:4a5N3yG98pZKluMpmcYlTtll7bisvOfGQEMIng3VQk4= +github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw= +github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU= -github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= github.com/jhillyerd/enmime v0.8.0/go.mod h1:MBHs3ugk03NGjMM6PuRynlKf+HA5eSillZ+TRCm73AE= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -54,62 +73,122 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= -github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailgun/mailgun-go/v4 v4.10.0 h1:e5LVsxpqjOYRyaOWifrJORoLQZTYDP+g4ljfmf9G2zE= -github.com/mailgun/mailgun-go/v4 v4.10.0/go.mod h1:L9s941Lgk7iB3TgywTPz074pK2Ekkg4kgbnAaAyJ2z8= +github.com/mailgun/errors v0.4.0 h1:6LFBvod6VIW83CMIOT9sYNp28TCX0NejFPP4dSX++i8= +github.com/mailgun/errors v0.4.0/go.mod h1:xGBaaKdEdQT0/FhwvoXv4oBaqqmVZz9P1XEnvD/onc0= +github.com/mailgun/mailgun-go/v4 v4.23.0 h1:jPEMJzzin2s7lvehcfv/0UkyBu18GvcURPr2+xtZRbk= +github.com/mailgun/mailgun-go/v4 v4.23.0/go.mod h1:imTtizoFtpfZqPqGP8vltVBB6q9yWcv6llBhfFeElZU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= -github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o= -github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= +github.com/redis/go-redis/v9 v9.7.1 h1:4LhKRCIduqXqtvCUlaq9c8bdHOkICjDMrr1+Zb3osAc= +github.com/redis/go-redis/v9 v9.7.1/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= +github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= -github.com/sendgrid/sendgrid-go v3.12.0+incompatible h1:/N2vx18Fg1KmQOh6zESc5FJB8pYwt5QFBDflYPh1KVg= -github.com/sendgrid/sendgrid-go v3.12.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= +github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBUpXyTcTXy/Rl9N9aZ/Qw= +github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= +github.com/sendgrid/sendgrid-go v3.16.1+incompatible h1:zWhTmB0Y8XCDzeWIm2/BIt1GjJohAA0p6hVEaDtHWWs= +github.com/sendgrid/sendgrid-go v3.16.1+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= +github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas= +golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw= -gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o= -gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0= -gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8= -gorm.io/driver/sqlite v1.5.2 h1:TpQ+/dqCY4uCigCFyrfnrJnrW9zjpelWVoEVNy5qJkc= -gorm.io/driver/sqlite v1.5.2/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= -gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= +gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= +gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= diff --git a/main.go b/main.go index fd1f7b4..5fac06e 100644 --- a/main.go +++ b/main.go @@ -26,14 +26,25 @@ var resources embed.FS func main() { app := core.New() basePath, err := os.Getwd() + runMode := "dev" + if len(os.Args) > 1 { + if os.Args[1] == "prod" || os.Args[1] == "dev" { + runMode = os.Args[1] + } + } if err != nil { log.Fatal("error getting current working dir") } app.SetBasePath(basePath) + app.SetRunMode(runMode) app.MakeDirs("logs", "storage", "storage/sqlite", "tls") // Handle the reading of the .env file if config.GetEnvFileConfig().UseDotEnvFile { - envVars, err := godotenv.Read(".env") + envfile := ".env-dev" + if runMode == "prod" { + envfile = ".env" + } + envVars, err := godotenv.Read(envfile) if err != nil { log.Fatal("Error loading .env file") } @@ -51,6 +62,9 @@ func main() { registerGlobalHooks() registerRoutes() registerEvents() + if config.GetQueueConfig().EnableQueue == true { + registerQueues() + } if config.GetGormConfig().EnableGorm == true { RunAutoMigrations() } diff --git a/models/base.go b/models/base.go new file mode 100644 index 0000000..5350de2 --- /dev/null +++ b/models/base.go @@ -0,0 +1,11 @@ +// Copyright (c) 2024 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package models + +type BaseModel struct { + ID uint `json:"id" gorm:"primaryKey; column:id"` + Created int64 `gorm:"autoCreateTime"` + Updated int64 `gorm:"autoUpdateTime"` +} diff --git a/models/permission.go b/models/permission.go new file mode 100644 index 0000000..933b16d --- /dev/null +++ b/models/permission.go @@ -0,0 +1,16 @@ +// Copyright (c) 2025 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package models + +type Permission struct { + BaseModel + Name string + Slug string +} + +// TableName sets the table name +func (Permission) TableName() string { + return "permissions" +} diff --git a/models/role-permissions.go b/models/role-permissions.go new file mode 100644 index 0000000..99cf3fe --- /dev/null +++ b/models/role-permissions.go @@ -0,0 +1,16 @@ +// Copyright (c) 2025 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package models + +type RolePermission struct { + BaseModel + RoleID uint // Role id + PermissionID uint // Permission id +} + +// TableName sets the table name +func (RolePermission) TableName() string { + return "role_permissions" +} diff --git a/models/roles.go b/models/roles.go new file mode 100644 index 0000000..decd675 --- /dev/null +++ b/models/roles.go @@ -0,0 +1,16 @@ +// Copyright (c) 2025 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package models + +type Role struct { + BaseModel + Name string // The name of the role + Slug string // String based unique identifier of the role, (use hyphen seperated role name '-', instead of space) +} + +// TableName sets the table name +func (Role) TableName() string { + return "roles" +} diff --git a/models/user-roles.go b/models/user-roles.go new file mode 100644 index 0000000..1dfcc18 --- /dev/null +++ b/models/user-roles.go @@ -0,0 +1,16 @@ +// Copyright (c) 2025 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package models + +type UserRole struct { + BaseModel + UserID uint // The user id + RoleID uint // The role id +} + +// TableName sets the table name +func (UserRole) TableName() string { + return "user_roles" +} diff --git a/models/user.go b/models/user.go index c85a386..3a229d9 100644 --- a/models/user.go +++ b/models/user.go @@ -5,11 +5,10 @@ package models -import "gorm.io/gorm" - type User struct { - gorm.Model + BaseModel Name string + Fullname string Email string Password string } diff --git a/register-queues.go b/register-queues.go new file mode 100644 index 0000000..a1cf059 --- /dev/null +++ b/register-queues.go @@ -0,0 +1,30 @@ +// Copyright (c) 2025 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package main + +import ( + "git.smarteching.com/goffee/core" + "git.smarteching.com/goffee/cup/workers" +) + +// Register queues +func registerQueues() { + + var queque = new(core.Queuemux) + queque.QueueInit() + //######################################## + //# quques registration ##### + //######################################## + + // register your queues here ... + + queque.AddWork(workers.TypeWelcomeEmail, workers.HandleWelcomeEmailTask) + queque.AddWork(workers.TypeReminderEmail, workers.HandleReminderEmailTask) + + //######################################## + // Start queue server, DO NOT TOUCH + //######################################## + go queque.RunQueueserver() +} diff --git a/routes.go b/routes.go index 18c72be..f8d7fec 100644 --- a/routes.go +++ b/routes.go @@ -26,6 +26,7 @@ func registerRoutes() { controller.Get("/themecontent", controllers.Themecontent) controller.Get("/themepanel", controllers.Themedemo) controller.Get("/themeelements", controllers.ThemeElements) + controller.Get("/queuesample", controllers.Queuesample) // Uncomment the lines below to enable authentication controller.Post("/signup", controllers.Signup) @@ -34,11 +35,29 @@ func registerRoutes() { controller.Post("/reset-password", controllers.ResetPasswordRequest) controller.Post("/reset-password/code/:code", controllers.SetNewPassword) + // Uncomment the lines below to enable user administration + controller.Get("/admin/users", controllers.AdminUsersList) + controller.Post("/admin/users", controllers.AdminUsersList) + controller.Get("/admin/users/add", controllers.AdminUsersAdd) + controller.Post("/admin/users/add", controllers.AdminUsersAdd) + controller.Get("/admin/users/edit/:id", controllers.AdminUsersEdit) + controller.Post("/admin/users/edit/:id", controllers.AdminUsersEdit) + controller.Post("/admin/users/delete", controllers.AdminUsersDelete) + controller.Post("/admin/users/deleteconfirm", controllers.AdminUsersDelConfirm) + //controller.Get("/admin/users/roles", controllers.Signout) + //controller.Get("/admin/users/permissions", controllers.ResetPasswordRequest) + controller.Get("/dashboard", controllers.WelcomeToDashboard, hooks.AuthCheck) + // templates demos + controller.Get("/signout", controllers.Signout) + controller.Get("/appsample", controllers.AppSample, hooks.AuthCheck) controller.Post("/appsample", controllers.AppSample, hooks.AuthCheck) controller.Get("/applogin", controllers.AppLogin, hooks.CheckSessionCookie) controller.Post("/applogin", controllers.AppLogin, hooks.CheckSessionCookie) + + controller.Get("/appsession", controllers.AppSession) + controller.Post("/appsession", controllers.AppSession) } diff --git a/run-auto-migrations.go b/run-auto-migrations.go index 8410637..996ae15 100644 --- a/run-auto-migrations.go +++ b/run-auto-migrations.go @@ -6,8 +6,12 @@ package main import ( + "errors" + "git.smarteching.com/goffee/core" "git.smarteching.com/goffee/cup/models" + "git.smarteching.com/goffee/cup/utils" + "gorm.io/gorm" ) func RunAutoMigrations() { @@ -17,5 +21,18 @@ func RunAutoMigrations() { //############################## // Add auto migrations for your models here... - db.AutoMigrate(&models.User{}) + db.AutoMigrate(&models.UserRole{}) + db.AutoMigrate(&models.Role{}) + db.AutoMigrate(&models.RolePermission{}) + db.AutoMigrate(&models.Permission{}) + + // End your auto migrations + + // Create seed data data, DO NOT TOUCH + if err := db.AutoMigrate(&models.User{}); err == nil && db.Migrator().HasTable(&models.User{}) { + if err := db.First(&models.User{}).Error; errors.Is(err, gorm.ErrRecordNotFound) { + utils.CreateSeedData() + } + } + } diff --git a/storage/app/img/goffee.png b/storage/app/img/goffee.png new file mode 100644 index 0000000..d0491fc Binary files /dev/null and b/storage/app/img/goffee.png differ diff --git a/storage/app/index.html b/storage/app/index.html new file mode 100644 index 0000000..326fcb0 --- /dev/null +++ b/storage/app/index.html @@ -0,0 +1,9 @@ + + + + Title! + + +

This is an example conten.

+ + diff --git a/storage/public/app.js b/storage/public/app.js index 75654fb..57fdf3f 100644 --- a/storage/public/app.js +++ b/storage/public/app.js @@ -1 +1,14 @@ -console.log("Start Goffee app"); \ No newline at end of file +console.log("Start Goffee app"); + +let elem = document.querySelector('#signout'); +if (elem) { +document.getElementById("signout").onclick = (_event) => { + fetch('/signout').then(response => response.json()) + .then(data => { + if (data['message'] == "signed out successfully") { + // Refresh the page + location.reload(); + } + }) + }; +} \ No newline at end of file diff --git a/storage/public/img/gopher_read.png b/storage/public/img/gopher_read.png new file mode 100644 index 0000000..6714ef4 Binary files /dev/null and b/storage/public/img/gopher_read.png differ diff --git a/storage/sqlite.db b/storage/sqlite.db deleted file mode 100644 index b10cc17..0000000 Binary files a/storage/sqlite.db and /dev/null differ diff --git a/storage/sqlite/sqlite.db b/storage/sqlite/sqlite.db new file mode 100644 index 0000000..97751be Binary files /dev/null and b/storage/sqlite/sqlite.db differ diff --git a/storage/templates/admin_confirmuserdel.html b/storage/templates/admin_confirmuserdel.html new file mode 100644 index 0000000..5fd0d71 --- /dev/null +++ b/storage/templates/admin_confirmuserdel.html @@ -0,0 +1,34 @@ + + + {{template "page_head" "Goffee"}} + +
+

User delete confirmation

+ {{if .ErrorMessages }} +
+ +
    + {{range $i, $a := .ErrorMessages}} +
  • {{$a}}
  • + {{end}} +
+
+ {{else}} + {{if .WarningMessages }} +
    + {{range $o, $u := .WarningMessages}} +
  • {{$u}}
  • + {{end}} +
+ {{end}} +
+ {{template "form_input" .FieldKey}} + {{template "form_button" .ConfirmButton}} +
+ {{end}} +
+ {{template "content_href" .BackButton}} +
+ {{template "page_footer"}} + + diff --git a/storage/templates/admin_useradd.html b/storage/templates/admin_useradd.html new file mode 100644 index 0000000..bc5d780 --- /dev/null +++ b/storage/templates/admin_useradd.html @@ -0,0 +1,27 @@ + + + {{template "page_head" "Goffee"}} + +
+

Add user form

+ {{if .ErrorMessages }}
+ +
    + {{range $i, $a := .ErrorMessages}} +
  • {{$a}}
  • + {{end}} +
+
{{end}} +
+ {{template "form_input" .FieldName}} + {{template "form_input" .FieldFullname}} + {{template "form_input" .FieldEmail}} + {{template "form_checkbox" .FieldRoles}} + {{template "form_input" .FieldPassword}} +
+ {{template "form_button" .SubmitButton}} +
+
+ {{template "page_footer"}} + + diff --git a/storage/templates/admin_useredit.html b/storage/templates/admin_useredit.html new file mode 100644 index 0000000..3c49e08 --- /dev/null +++ b/storage/templates/admin_useredit.html @@ -0,0 +1,32 @@ + + + {{template "page_head" "Goffee"}} + +
+

Edit user form

+ {{if .ErrorMessages }}
+ +
    + {{range $i, $a := .ErrorMessages}} +
  • {{$a}}
  • + {{end}} +
+
{{end}} +
+ {{template "form_input" .FieldName}} + {{template "form_input" .FieldFullname}} + {{template "form_input" .FieldEmail}} + {{template "form_checkbox" .FieldRoles}} + {{template "form_input" .FieldPassword}} + {{template "form_input" .FieldKey}} + {{template "form_button" .SubmitButton}} +
+
+
+ {{template "form_input" .FieldKey}} + {{template "form_button" .DeleteButton}} +
+
+ {{template "page_footer"}} + + diff --git a/storage/templates/admin_userlist.html b/storage/templates/admin_userlist.html new file mode 100644 index 0000000..9981d46 --- /dev/null +++ b/storage/templates/admin_userlist.html @@ -0,0 +1,18 @@ + + + {{template "page_head" "Goffee"}} + +
+
+

Users

+
+ {{template "content_href" .AddButton}} +
+
+
+ {{template "content_table" .TableUsers}} +
+
+ {{template "page_footer"}} + + diff --git a/storage/templates/app.html b/storage/templates/app.html index 21831b7..2bc5f93 100644 --- a/storage/templates/app.html +++ b/storage/templates/app.html @@ -4,8 +4,10 @@
+ {{template "content_dropdown" .ContentDropdown}} {{template "page_card" .PageCard}} {{ define "page_card_content" }} + {{ end }}
diff --git a/storage/templates/appsession.html b/storage/templates/appsession.html new file mode 100644 index 0000000..32364f0 --- /dev/null +++ b/storage/templates/appsession.html @@ -0,0 +1,12 @@ + + + {{template "page_head" "Sample page test session vars"}} + +
+
+ {{template "page_card" .PageCard}} +
+
+ {{template "page_footer"}} + + \ No newline at end of file diff --git a/storage/templates/custom_theme_contentpage.html b/storage/templates/custom_theme_contentpage.html index e1d71e9..422c253 100644 --- a/storage/templates/custom_theme_contentpage.html +++ b/storage/templates/custom_theme_contentpage.html @@ -7,7 +7,22 @@ Content demos
{{template "content_table" .ContentTable}} + {{if .ShouldShowPagination}} + {{template "content_pagination" .Pagination}} + {{end}}
+
+
+

Pie chart

+ {{template "content_graph" .ContentGraph}} +
+ {{template "form_input" .FieldText}} + {{template "form_select" .FormSelectCityM}} +
+
+ {{template "content_tabledetail" .ContentTabledetail}} +
+
{{template "page_footer"}} diff --git a/storage/templates/nopermission.html b/storage/templates/nopermission.html new file mode 100644 index 0000000..bec8f39 --- /dev/null +++ b/storage/templates/nopermission.html @@ -0,0 +1,12 @@ + + + {{template "page_head" "No permission"}} + +
+
+ You do not have permission to visit this page. +
+
+ {{template "page_footer"}} + + \ No newline at end of file diff --git a/utils/authority.go b/utils/authority.go new file mode 100644 index 0000000..823f845 --- /dev/null +++ b/utils/authority.go @@ -0,0 +1,436 @@ +// Copyright (c) 2025 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package utils + +import ( + "errors" + "fmt" + + "git.smarteching.com/goffee/core" + "git.smarteching.com/goffee/cup/models" + "gorm.io/gorm" +) + +type Authority struct{} + +var auth *Authority + +var ( + ErrPermissionInUse = errors.New("cannot delete assigned permission") + ErrPermissionNotFound = errors.New("permission not found") + ErrRoleInUse = errors.New("cannot delete assigned role") + ErrRoleNotFound = errors.New("role not found") +) + +// Add a new role to the database +func (a *Authority) CreateRole(c *core.Context, r models.Role) error { + roleSlug := r.Slug + var dbRole models.Role + res := c.GetGorm().Where("slug = ?", roleSlug).First(&dbRole) + if res.Error != nil { + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + // create + createRes := c.GetGorm().Create(&r) + if createRes.Error != nil { + return createRes.Error + } + return nil + } + return res.Error + } + + return errors.New(fmt.Sprintf("role '%v' already exists", roleSlug)) +} + +// Add a new permission to the database +func (a *Authority) CreatePermission(c *core.Context, p models.Permission) error { + permSlug := p.Slug + var dbPerm models.Permission + res := c.GetGorm().Where("slug = ?", permSlug).First(&dbPerm) + if res.Error != nil { + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + // create + createRes := c.GetGorm().Create(&p) + if createRes.Error != nil { + return createRes.Error + } + return nil + } + return res.Error + } + + return errors.New(fmt.Sprintf("permission '%v' already exists", permSlug)) +} + +// Assigns a group of permissions to a given role +func (a *Authority) AssignPermissionsToRole(c *core.Context, roleSlug string, permSlugs []string) error { + var role models.Role + rRes := c.GetGorm().Where("slug = ?", roleSlug).First(&role) + if rRes.Error != nil { + if errors.Is(rRes.Error, gorm.ErrRecordNotFound) { + return ErrRoleNotFound + } + return rRes.Error + } + var perms []models.Permission + for _, permSlug := range permSlugs { + var perm models.Permission + pRes := c.GetGorm().Where("slug = ?", permSlug).First(&perm) + if pRes.Error != nil { + if errors.Is(pRes.Error, gorm.ErrRecordNotFound) { + return ErrPermissionNotFound + } + return pRes.Error + } + perms = append(perms, perm) + } + tx := c.GetGorm().Begin() + for _, perm := range perms { + var rolePerm models.RolePermission + res := c.GetGorm().Where("role_id = ?", role.ID).Where("permission_id =?", perm.ID).First(&rolePerm) + if res.Error != nil && errors.Is(res.Error, gorm.ErrRecordNotFound) { + cRes := tx.Create(&models.RolePermission{RoleID: role.ID, PermissionID: perm.ID}) + if cRes.Error != nil { + tx.Rollback() + return cRes.Error + } + } + if res.Error != nil && !errors.Is(res.Error, gorm.ErrRecordNotFound) { + tx.Rollback() + return res.Error + } + if rolePerm != (models.RolePermission{}) { + tx.Rollback() + return errors.New(fmt.Sprintf("permission '%v' is aleady assigned to the role '%v'", perm.Name, role.Name)) + } + rolePerm = models.RolePermission{} + } + return tx.Commit().Error +} + +// Assigns a role to a given user +func (a *Authority) AssignRoleToUser(c *core.Context, userID uint, roleSlug string) error { + var role models.Role + res := c.GetGorm().Where("slug = ?", roleSlug).First(&role) + if res.Error != nil { + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + return ErrRoleNotFound + } + return res.Error + } + var userRole models.UserRole + res = c.GetGorm().Where("user_id = ?", userID).Where("role_id = ?", role.ID).First(&userRole) + if res.Error != nil && errors.Is(res.Error, gorm.ErrRecordNotFound) { + c.GetGorm().Create(&models.UserRole{UserID: userID, RoleID: role.ID}) + return nil + } + if res.Error != nil && !errors.Is(res.Error, gorm.ErrRecordNotFound) { + return res.Error + } + + return errors.New(fmt.Sprintf("this role '%v' is aleady assigned to the user", roleSlug)) +} + +// Checks if a role is assigned to a user +func (a *Authority) CheckUserRole(c *core.Context, userID uint, roleSlug string) (bool, error) { + // find the role + var role models.Role + res := c.GetGorm().Where("slug = ?", roleSlug).First(&role) + if res.Error != nil { + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + return false, ErrRoleNotFound + } + return false, res.Error + } + + // check if the role is a assigned + var userRole models.UserRole + res = c.GetGorm().Where("user_id = ?", userID).Where("role_id = ?", role.ID).First(&userRole) + if res.Error != nil { + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + return false, nil + } + return false, res.Error + } + + return true, nil +} + +// Checks if a permission is assigned to a user +func (a *Authority) CheckUserPermission(c *core.Context, userID uint, permSlug string) (bool, error) { + // the user role + var userRoles []models.UserRole + res := c.GetGorm().Where("user_id = ?", userID).Find(&userRoles) + if res.Error != nil { + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + return false, nil + } + return false, res.Error + } + + //prepare an array of role ids + var roleIDs []interface{} + for _, r := range userRoles { + roleIDs = append(roleIDs, r.RoleID) + } + + // find the permission + var perm models.Permission + res = c.GetGorm().Where("slug = ?", permSlug).First(&perm) + if res.Error != nil { + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + return false, ErrPermissionNotFound + } + return false, res.Error + } + + // find the role permission + var rolePermission models.RolePermission + res = c.GetGorm().Where("role_id IN (?)", roleIDs).Where("permission_id = ?", perm.ID).First(&rolePermission) + if res.Error != nil && errors.Is(res.Error, gorm.ErrRecordNotFound) { + return false, nil + } + if res.Error != nil && !errors.Is(res.Error, gorm.ErrRecordNotFound) { + return false, res.Error + } + + return true, nil +} + +// Checks if a permission is assigned to a role +func (a *Authority) CheckRolePermission(c *core.Context, roleSlug string, permSlug string) (bool, error) { + // find the role + var role models.Role + res := c.GetGorm().Where("slug = ?", roleSlug).First(&role) + if res.Error != nil { + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + return false, ErrRoleNotFound + } + return false, res.Error + } + + // find the permission + var perm models.Permission + res = c.GetGorm().Where("slug = ?", permSlug).First(&perm) + if res.Error != nil { + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + return false, ErrPermissionNotFound + } + return false, res.Error + } + + // find the rolePermission + var rolePermission models.RolePermission + res = c.GetGorm().Where("role_id = ?", role.ID).Where("permission_id = ?", perm.ID).First(&rolePermission) + if res.Error != nil { + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + return false, nil + } + return false, res.Error + } + + return true, nil +} + +// Revokes a roles's permission +func (a *Authority) RevokeRolePermission(c *core.Context, roleSlug string, permSlug string) error { + // find the role + var role models.Role + res := c.GetGorm().Where("slug = ?", roleSlug).First(&role) + if res.Error != nil { + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + return ErrRoleNotFound + } + return res.Error + } + + // find the permission + var perm models.Permission + res = c.GetGorm().Where("slug = ?", permSlug).First(&perm) + if res.Error != nil { + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + return ErrPermissionNotFound + } + return res.Error + } + + // revoke the permission + rRes := c.GetGorm().Where("role_id = ?", role.ID).Where("permission_id = ?", perm.ID).Delete(models.RolePermission{}) + if rRes.Error != nil { + return rRes.Error + } + + return nil +} + +// Revokes a user's role +func (a *Authority) RevokeUserRole(c *core.Context, userID uint, roleSlug string) error { + // find the role + var role models.Role + res := c.GetGorm().Where("slug = ?", roleSlug).First(&role) + if res.Error != nil { + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + return ErrRoleNotFound + } + return res.Error + } + + // revoke the role + rRes := c.GetGorm().Where("user_id = ?", userID).Where("role_id = ?", role.ID).Delete(models.UserRole{}) + if rRes.Error != nil { + return rRes.Error + } + + return nil +} + +// Revokes all user's role +func (a *Authority) RevokeAllUserRole(c *core.Context, userID uint) error { + // revoke the role + rRes := c.GetGorm().Where("user_id = ?", userID).Delete(models.UserRole{}) + if rRes.Error != nil { + return rRes.Error + } + + return nil +} + +// Returns all stored roles +func (a *Authority) GetAllRoles(c *core.Context) ([]models.Role, error) { + var roles []models.Role + res := c.GetGorm().Find(&roles) + if res.Error != nil { + return nil, res.Error + } + + return roles, nil +} + +// Returns all user assigned roles +func (a *Authority) GetUserRoles(c *core.Context, userID uint) ([]models.Role, error) { + var userRoles []models.UserRole + res := c.GetGorm().Where("user_id = ?", userID).Find(&userRoles) + if res.Error != nil { + return nil, res.Error + } + + var roleIDs []interface{} + for _, r := range userRoles { + roleIDs = append(roleIDs, r.RoleID) + } + + var roles []models.Role + res = c.GetGorm().Where("id IN (?)", roleIDs).Find(&roles) + if res.Error != nil { + return nil, res.Error + } + + return roles, nil +} + +// Returns all role assigned permissions +func (a *Authority) GetRolePermissions(c *core.Context, roleSlug string) ([]models.Permission, error) { + var role models.Role + res := c.GetGorm().Where("slug = ?", roleSlug).Find(&role) + if res.Error != nil { + return nil, res.Error + } + + var rolePerms []models.RolePermission + res = c.GetGorm().Where("role_id = ?", role.ID).Find(&rolePerms) + if res.Error != nil { + return nil, res.Error + } + var permIDs []interface{} + for _, rolePerm := range rolePerms { + permIDs = append(permIDs, rolePerm.PermissionID) + } + + var perms []models.Permission + res = c.GetGorm().Where("id IN (?)", permIDs).Find(&perms) + if res.Error != nil { + return nil, res.Error + } + + return perms, nil +} + +// Returns all stored permissions +func (a *Authority) GetAllPermissions(c *core.Context) ([]models.Permission, error) { + var perms []models.Permission + res := c.GetGorm().Find(&perms) + if res.Error != nil { + return nil, res.Error + } + + return perms, nil +} + +// Deletes a given role even if it's has assigned permissions +func (a *Authority) DeleteRole(c *core.Context, roleSlug string) error { + // find the role + var role models.Role + res := c.GetGorm().Where("slug = ?", roleSlug).First(&role) + if res.Error != nil { + return res.Error + } + + // check if the role is assigned to a user + var ca int64 + res = c.GetGorm().Model(models.UserRole{}).Where("role_id = ?", role.ID).Count(&ca) + if res.Error != nil { + return res.Error + } + + if ca != 0 { + // role is assigned + return ErrRoleInUse + } + tx := c.GetGorm().Begin() + // revoke the assignment of permissions before deleting the role + dRes := tx.Where("role_id = ?", role.ID).Delete(models.RolePermission{}) + if dRes.Error != nil { + tx.Rollback() + return dRes.Error + } + + // delete the role + dRes = c.GetGorm().Where("slug = ?", roleSlug).Delete(models.Role{}) + if dRes.Error != nil { + tx.Rollback() + return dRes.Error + } + + return tx.Commit().Error +} + +// Deletes a given permission +func (a *Authority) DeletePermission(c *core.Context, permSlug string) error { + // find the permission + var perm models.Permission + res := c.GetGorm().Where("slug = ?", permSlug).First(&perm) + if res.Error != nil { + return res.Error + } + + // check if the permission is assigned to a role + var rolePermission models.RolePermission + res = c.GetGorm().Where("permission_id = ?", perm.ID).First(&rolePermission) + if res.Error != nil && !errors.Is(res.Error, gorm.ErrRecordNotFound) { + return res.Error + } + + if res.Error == nil { + return ErrPermissionInUse + } + + // delete the permission + dRes := c.GetGorm().Where("slug = ?", permSlug).Delete(models.Permission{}) + if dRes.Error != nil { + return dRes.Error + } + + return nil +} diff --git a/utils/helpers.go b/utils/helpers.go index 00525b7..8ca7dd8 100644 --- a/utils/helpers.go +++ b/utils/helpers.go @@ -8,8 +8,69 @@ package utils import ( "crypto/md5" "fmt" + "log" + "time" + + "git.smarteching.com/goffee/core" + "git.smarteching.com/goffee/cup/models" ) +func CreateSeedData() { + + db := core.ResolveGorm() + var hashing = new(core.Hashing) + var role models.Role + + // seed user + password := "goffee" + name := "admin" + fullname := "Goffee administrator" + email := "change@me.com" + passwordHashed, _ := hashing.HashPassword(password) + + user := models.User{ + Name: name, + Fullname: fullname, + Email: email, + Password: passwordHashed, + } + result := db.Create(&user) + if result.Error != nil { + log.Fatal("Can't create seed user in database") + } + // seed roles + roles := []models.Role{ + {Name: "Administrator", Slug: "admin"}, + {Name: "Authenticated", Slug: "authenticated"}, + } + + for _, role := range roles { + result = db.Create(&role) + if result.Error != nil { + log.Fatal("Can't create seed role in database") + } + } + + // seed permission + permission := models.Permission{Name: "Users administration", Slug: "admin-users"} + result = db.Create(&permission) + if result.Error != nil { + log.Fatal("Can't create seed permission in database") + } + result = db.Where("slug = ?", "admin").First(&role) + if result.Error != nil { + log.Fatal("Can't find user admin in database") + } + result = db.Create(&models.RolePermission{RoleID: role.ID, PermissionID: permission.ID}) + if result.Error != nil { + log.Fatal("Can't register permission role in database") + } + result = db.Create(&models.UserRole{UserID: user.ID, RoleID: role.ID}) + if result.Error != nil { + log.Fatal("Can't assign role administrator to user in database") + } +} + // generate a hashed string to be used as key for caching auth jwt token func CreateAuthTokenHashedCacheKey(userID uint, userAgent string) string { cacheKey := fmt.Sprintf("userid:_%v_useragent:_%v_jwt_token", userID, userAgent) @@ -17,3 +78,7 @@ func CreateAuthTokenHashedCacheKey(userID uint, userAgent string) string { return hashedCacheKey } + +func FormatUnix(value int64) string { + return time.Unix(value, 0).Format("2006-01-02 15:04:05") +} diff --git a/utils/session.go b/utils/session.go new file mode 100644 index 0000000..27e202d --- /dev/null +++ b/utils/session.go @@ -0,0 +1,170 @@ +// Copyright (c) 2024 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package utils + +import ( + "errors" + "fmt" + "sync" + "time" + + "encoding/json" + + "git.smarteching.com/goffee/core" + "git.smarteching.com/goffee/cup/models" + "gorm.io/gorm" +) + +type SessionUser struct { + mu sync.RWMutex + context *core.Context + userID uint + hashedSessionKey string + authenticated bool + sessionStart time.Time + values map[string]interface{} +} + +// start the struct +func (s *SessionUser) Init(c *core.Context) bool { + + // check session cookie + pass := true + token := "" + s.context = c + + payload := make(map[string]interface{}) + // get cookie + usercookie, err := c.GetCookie() + if err != nil { + + } + + token = usercookie.Token + + if token == "" { + + pass = false + + } else { + + payload, err = c.GetJWT().DecodeToken(token) + + if err != nil { + + pass = false + + } else { + + userID := uint(c.CastToInt(payload["userID"])) + userAgent := c.GetUserAgent() + + // get data from redis + hashedCacheKey := CreateAuthTokenHashedCacheKey(userID, userAgent) + cachedToken, err := c.GetCache().Get(hashedCacheKey) + + if err != nil { + pass = false + } else if cachedToken != token { + pass = false + } else { + var user models.User + res := c.GetGorm().Where("id = ?", userID).First(&user) + if res.Error != nil && !errors.Is(res.Error, gorm.ErrRecordNotFound) { + pass = false + } + // if have session start the struct + if pass { + userAgent := c.GetUserAgent() + sessionKey := fmt.Sprintf("sess_%v", userAgent) + s.hashedSessionKey = CreateAuthTokenHashedCacheKey(userID, sessionKey) + + s.values = make(map[string]interface{}) + s.authenticated = true + s.userID = userID + value, _ := c.GetCache().Get(s.hashedSessionKey) + + if len(value) > 0 { + _ = json.Unmarshal([]byte(value), &s.values) + } + + return true + + } else { + + s.hashedSessionKey = "" + s.authenticated = false + s.userID = 0 + return false + } + } + } + } + + return false +} + +func (s *SessionUser) Set(key string, value interface{}) error { + s.mu.Lock() + s.values[key] = value + s.mu.Unlock() + return s.Save() +} + +func (s *SessionUser) Get(key string) (interface{}, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + val, ok := s.values[key] + return val, ok +} + +func (s *SessionUser) Delete(key string) interface{} { + s.mu.RLock() + v, ok := s.values[key] + s.mu.RUnlock() + if ok { + s.mu.Lock() + delete(s.values, key) + s.mu.Unlock() + } + s.Save() + return v +} + +func (s *SessionUser) Flush() error { + s.mu.Lock() + s.context.GetCache().Delete(s.hashedSessionKey) + s.mu.Unlock() + return nil +} + +func (s *SessionUser) Save() error { + + var value string + + s.mu.RLock() + + if len(s.values) > 0 { + buf, err := json.Marshal(&s.values) + if err != nil { + s.mu.RUnlock() + return err + } + value = string(buf) + } + + if len(value) > 0 { + s.context.GetCache().Set(s.hashedSessionKey, value) + } else { + s.context.GetCache().Delete(s.hashedSessionKey) + } + s.mu.RUnlock() + return nil +} + +func (s *SessionUser) GetUserID() uint { + + return s.userID +} diff --git a/workers/workers.go b/workers/workers.go new file mode 100644 index 0000000..e7a9cad --- /dev/null +++ b/workers/workers.go @@ -0,0 +1,39 @@ +package workers + +import ( + "context" + "encoding/json" + "log" + + "github.com/hibiken/asynq" +) + +// A list of task types. +const ( + TypeWelcomeEmail = "email:welcome" + TypeReminderEmail = "email:reminder" +) + +// Task payload for any email related tasks. +type EmailTaskPayload struct { + // ID for the email recipient. + UserID int +} + +func HandleWelcomeEmailTask(ctx context.Context, t *asynq.Task) error { + var p EmailTaskPayload + if err := json.Unmarshal(t.Payload(), &p); err != nil { + return err + } + log.Printf(" [*] Send Welcome Email to User %d", p.UserID) + return nil +} + +func HandleReminderEmailTask(ctx context.Context, t *asynq.Task) error { + var p EmailTaskPayload + if err := json.Unmarshal(t.Payload(), &p); err != nil { + return err + } + log.Printf(" [*] Send Reminder Email to User %d", p.UserID) + return nil +}