package user import ( "context" "crypto/rand" "database/sql" "encoding/base64" "fmt" "net/http" "strconv" "strings" "time" "golang.org/x/crypto/argon2" "git.awl.red/~neallred/recipes/htmx" "git.awl.red/~neallred/recipes/htp" "git.awl.red/~neallred/recipes/logging" "git.awl.red/~neallred/recipes/model" ) type authKeyType struct{} var authKey = authKeyType{} func AuthFromContext(ctx context.Context) (*UserAuth, error) { val := ctx.Value(authKey) auth, ok := val.(*UserAuth) if !ok { return nil, fmt.Errorf("Could not get auth from context") } if auth == nil { return nil, fmt.Errorf("Could not get valid auth from context") } return auth, nil } func OptionalAuthFromContext(ctx context.Context) *UserAuth { val := ctx.Value(authKey) auth, ok := val.(*UserAuth) if !ok { return nil } if auth == nil { return nil } return auth } func AuthToReq(r *http.Request, auth *UserAuth) *http.Request { return r.WithContext(context.WithValue(r.Context(), authKey, auth)) } type AddRoleRequest struct { Role *model.Role `json:"role"` UserId *int64 `json:"userId"` } type SignupType int const ( InvalidSignup SignupType = 0 UserPass SignupType = 1 ) type RestApi struct { db *sql.DB } func NewRestApi(db *sql.DB) RestApi { return RestApi{ db: db, } } type HtmlApi struct { db *sql.DB } func NewHtmlApi(db *sql.DB) HtmlApi { return HtmlApi{ db: db, } } func (a HtmlApi) GetSignup(w http.ResponseWriter, r *http.Request) { optionalAuth := OptionalAuthFromContext(r.Context()) if optionalAuth != nil { http.Redirect(w, r, "/recipes", http.StatusTemporaryRedirect) return } props := (&htmx.FullPageProps{}).Nav(optionalAuth).Title("Signup") htmx.Tmpl["signup.html"].ExecuteTemplate(w, "base", props) } func (a HtmlApi) GetLogin(w http.ResponseWriter, r *http.Request) { optionalAuth := OptionalAuthFromContext(r.Context()) if optionalAuth != nil { http.Redirect(w, r, "/recipes", http.StatusTemporaryRedirect) return } props := (&htmx.FullPageProps{}).Nav(optionalAuth).Title("Login") htmx.Tmpl["login.html"].ExecuteTemplate(w, "base", props) } func (a HtmlApi) PostSignup(w http.ResponseWriter, r *http.Request) { log := logging.FromReq(r) err := r.ParseForm() if err != nil { htp.W400(w, log, err, "ventriloquial", htp.IssUsrNoUser) return } user := r.PostForm.Get("username") pass := r.PostForm.Get("password") if user == "" { htp.W400(w, log, nil, "clinquant", htp.IssUsrNoUser) return } if pass == "" { htp.W400(w, log, nil, "lathed", htp.IssUsrNoPass) return } optionalAuth := OptionalAuthFromContext(r.Context()) if optionalAuth != nil { w.Header().Set("HX-Redirect", "/recipes") w.WriteHeader(http.StatusOK) return } sessionToken, expires, err := a.Signup(user, pass) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(err.Error())) return } writeSession(w, sessionToken, expires) w.Header().Set("HX-Redirect", "/recipes") w.WriteHeader(http.StatusOK) } func (a HtmlApi) Signup(user, pass string) (string, time.Time, error) { now := time.Now() salt := getToken(256) hashed := pwToHash(pass, salt) query := "INSERT INTO user (username,password,salt) VALUES (?,?,?) RETURNING id" var userId int64 tx, err := a.db.BeginTx(context.Background(), nil) if err != nil { return "", now, fmt.Errorf("%s: %s: %s", "nullism", htp.IssUsrCreate, err.Error()) } defer tx.Rollback() newIdRow := tx.QueryRow(query, user, hashed, salt) if err := newIdRow.Err(); err != nil { return "", now, fmt.Errorf("%s: %s: %s", "improvisatorial", htp.IssUsrCreate, err.Error()) } err = newIdRow.Scan(&userId) if err != nil { return "", now, fmt.Errorf("%s: %s: %s", "animalities", htp.IssUsrCreate, err.Error()) } var addRolesQuery strings.Builder addRolesQuery.WriteString(`INSERT INTO user_role (user_id,role_id) VALUES (?, (SELECT id FROM role WHERE role='ANONYMOUS')) `) userRoleArgs := []any{userId} existingUsersRow := tx.QueryRow("SELECT count(*) from user;") var userCount int err = existingUsersRow.Scan(&userCount) if err != nil { return "", now, fmt.Errorf("%s: %s: %s", "reinduct", htp.IssUsrCreate, err.Error()) } if userCount == 0 { return "", now, fmt.Errorf("%s: %s: %s", "vassalling", htp.BugAdminSignup, err.Error()) } if userCount == 1 { addRolesQuery.WriteString(`, (?, (SELECT id FROM role WHERE role='USER')), (?, (SELECT id FROM role WHERE role='ADMIN'));`) userRoleArgs = append(userRoleArgs, userId, userId) } else { addRolesQuery.WriteRune(';') } _, err = tx.Exec(addRolesQuery.String(), userRoleArgs...) if err != nil { return "", now, fmt.Errorf("%s: %s: %s", "orthophyre", htp.IssUsrCreate, err.Error()) } addUserGroupArgs := []any{userId} var addUserGroupQuery strings.Builder addUserGroupQuery.WriteString(`INSERT INTO user_rgroup (user_id, rgroup_id) VALUES (?,(SELECT id FROM rgroup WHERE name='public'))`) if userCount == 1 { addUserGroupQuery.WriteString(`, (?,(SELECT id FROM rgroup WHERE name='logged_in'))`) addUserGroupArgs = append(addUserGroupArgs, userId) } addUserGroupQuery.WriteRune(';') _, err = tx.Exec(addUserGroupQuery.String(), addUserGroupArgs...) if err != nil { return "", now, fmt.Errorf("%s: %s: %s", "embroilments", htp.IssUsrCreate, err.Error()) } sessionToken, expires, err := createSession(tx, userId) if err != nil { return "", now, fmt.Errorf("%s: %s: %s", "upstrive", htp.IssUsrCreate, err.Error()) } if tx.Commit() != nil { return "", now, fmt.Errorf("%s: %s: %s", "arris", htp.IssUsrCreate, err.Error()) } return sessionToken, expires, nil } func (api HtmlApi) Logout(w http.ResponseWriter, r *http.Request) { log := logging.FromReq(r) authCookie, err := r.Cookie("auth") var zeroTime time.Time if authCookie == nil || err == http.ErrNoCookie { writeSession(w, "", zeroTime) if r.Method == http.MethodGet { http.Redirect(w, r, "/recipes", http.StatusTemporaryRedirect) } else { w.Header().Set("HX-Redirect", "/recipes") w.WriteHeader(http.StatusOK) } return } if err != nil { htp.W500(w, log, err, "unnicknamed", htp.IssUsrLogoutSessionFind) return } _, err = api.db.Exec("DELETE FROM session WHERE token = ?;", authCookie.Value) if err != nil { htp.W500(w, log, err, "coadaptation", htp.IssUsrSessionDel) return } writeSession(w, "", zeroTime) if r.Method == http.MethodGet { http.Redirect(w, r, "/recipes", http.StatusTemporaryRedirect) } else { w.Header().Set("HX-Redirect", "/recipes") w.WriteHeader(http.StatusOK) } return } func getToken(length int) string { randomBytes := make([]byte, length) _, err := rand.Read(randomBytes) if err != nil { panic(err) } return base64.StdEncoding.EncodeToString(randomBytes)[:length] } // interface usage allows passing *sql.DB or *sql.Tx func createSession(db Execer, userId int64) (string, time.Time, error) { // create a session sessionToken := getToken(256) expiry := time.Now().AddDate(0, 0, 14) qSession := "INSERT INTO session (user_id,token,expires) VALUES (?,?,?);" if _, err := db.Exec(qSession, userId, sessionToken, expiry); err != nil { return "", time.Now(), err } return sessionToken, expiry, nil } func writeSession(w http.ResponseWriter, token string, expires time.Time) { sessionCookie := http.Cookie{ HttpOnly: true, Path: "/", SameSite: http.SameSiteStrictMode, Name: "auth", Expires: expires, Value: token, } http.SetCookie(w, &sessionCookie) } type Execer interface { Exec(query string, args ...any) (sql.Result, error) } func pwToHash(pw string, salt string) string { hashedPasswordBytes := argon2.IDKey([]byte(pw), []byte(salt), 1, 64*1024, 4, 256) loginPasswordHash := base64.StdEncoding.EncodeToString(hashedPasswordBytes) return loginPasswordHash } func (api HtmlApi) PostLogin(w http.ResponseWriter, r *http.Request) { log := logging.FromReq(r) err := r.ParseForm() if err != nil { htp.W400(w, log, err, "ventriloquial", htp.IssUsrNoUser) return } user := r.PostForm.Get("username") pass := r.PostForm.Get("password") if user == "" { htp.W400(w, log, nil, "clinquant", htp.IssUsrNoUser) return } if pass == "" { htp.W400(w, log, nil, "lathed", htp.IssUsrNoPass) return } qCheckUsers := "SELECT id,password,salt FROM user WHERE username=?;" userRows, err := api.db.Query(qCheckUsers, user) if err != nil { htp.W500(w, log, err, "haemochrome", htp.IssUsrLoginFind) return } var id int64 var dbPass string var salt string numUsers := 0 for userRows.Next() { err := userRows.Scan(&id, &dbPass, &salt) if err != nil { htp.W500(w, log, err, "allocochick", htp.BugScan) return } numUsers++ } if numUsers != 1 { if numUsers == 0 { w.WriteHeader(http.StatusUnauthorized) } else { w.WriteHeader(http.StatusBadRequest) } log.Error("Bad number of users", "expected", 1, "actual", numUsers) return } loginPasswordHash := pwToHash(pass, salt) if dbPass != loginPasswordHash { w.WriteHeader(http.StatusForbidden) w.Write([]byte("403")) log.Info("Password hash mismatch") return } // create a session sessionToken, expires, err := createSession(api.db, id) if err != nil { htp.W500(w, log, err, "prakrit", htp.IssUsrLoginSession) return } writeSession(w, sessionToken, expires) w.Header().Set("HX-Redirect", "/recipes") w.WriteHeader(http.StatusOK) } type WhoamiToken struct { Username string UserId int64 Token string Expires time.Time } type WhoamiResponse struct { Username string `json:"username"` UserId int64 `json:"userId"` Roles []model.Role `json:"roles"` LogoutTime time.Time `json:"logoutTime"` } type UserAuth struct { Username string UserId int64 Roles []model.Role Expires time.Time Priveleges map[model.Role]bool `json:"-"` Groups map[int64]string `json:"-"` } func (ua UserAuth) Can(role model.Role) bool { if ua.Priveleges == nil { return false } can, _ := ua.Priveleges[role] return can } func (ua *UserAuth) IsNil() bool { return ua == nil } func (ua *UserAuth) IsUser() bool { if ua.IsNil() { return false } isUser, _ := ua.Priveleges[model.RoleUser] return isUser } func (ua *UserAuth) IsAdmin() bool { if ua.IsNil() { return false } isUser, _ := ua.Priveleges[model.RoleAdmin] return isUser } func (ua *UserAuth) IsAnonymous() bool { if ua.IsNil() { return true } isUser, _ := ua.Priveleges[model.RoleAnonymous] return isUser } func findInList(list []model.Role, role model.Role) bool { for _, v := range list { if role == v { return true } } return false } func (ua *UserAuth) populatePrivileges() { isAdmin := findInList(ua.Roles, model.RoleAdmin) ua.Priveleges = map[model.Role]bool{ model.RoleAnonymous: true, model.RoleUser: isAdmin || findInList(ua.Roles, model.RoleUser), model.RoleAdmin: isAdmin, } } func (ua *UserAuth) addGroups(groupIdsStr string, groupNames []string) error { ua.Groups = map[int64]string{1: "public"} if groupIdsStr == "" { return nil } groupIdsStrs := strings.Split(groupIdsStr, ",") for i, groupStr := range groupIdsStrs { groupId, err := strconv.ParseInt(groupStr, 10, 64) if err != nil { return err } ua.Groups[groupId] = groupNames[i] } return nil } var ( ErrFailGetAuthCookie = fmt.Errorf("fail fetching auth cookie") ErrNoAuthCookie = fmt.Errorf("no auth cookie") errBadQuery = func(err error) error { return fmt.Errorf("error forming sql query: %s", err.Error()) } errExpiredToken = fmt.Errorf("token expired, need to log in again") ) const qToken = `SELECT u.username, s.user_id, s.token, s.expires, group_concat(ug.rgroup_id), group_concat(g.name) FROM session s JOIN user u ON s.user_id=u.id JOIN user_rgroup ug ON ug.user_id=u.id JOIN rgroup g ON ug.rgroup_id=g.id WHERE token=? GROUP BY s.id LIMIT 1;` func Auth(db *sql.DB, token string, setPrivelges bool) (*UserAuth, error) { var whoamiToken WhoamiToken row := db.QueryRow(qToken, token) if err := row.Err(); err != nil { return nil, fmt.Errorf("fail selecting whoami token %+v", err) } var groupIdsStr string var groupNamesStr string err := row.Scan( &whoamiToken.Username, &whoamiToken.UserId, &whoamiToken.Token, &whoamiToken.Expires, &groupIdsStr, &groupNamesStr, ) if err != nil { return nil, fmt.Errorf("fail selecting whoami token %+v", err) } if whoamiToken.Expires.Before(time.Now()) { return nil, errExpiredToken } userId := whoamiToken.UserId roles, err := getUserRoles(db, userId) if err != nil { return nil, fmt.Errorf("could not get roles for userId %d", userId) } userAuth := UserAuth{ Username: whoamiToken.Username, UserId: userId, Roles: roles, Expires: whoamiToken.Expires, } userAuth.populatePrivileges() userAuth.addGroups(groupIdsStr, strings.Split(groupNamesStr, ",")) return &userAuth, nil } func AuthFromHttp(w http.ResponseWriter, r *http.Request, db *sql.DB) (*UserAuth, error) { authCookie, err := r.Cookie("auth") if err != nil { return nil, ErrFailGetAuthCookie } if authCookie == nil || authCookie.Value == "" { return nil, ErrNoAuthCookie } return Auth(db, authCookie.Value, true) } func (api RestApi) Auth(w http.ResponseWriter, r *http.Request, needsPriveleges bool) (*UserAuth, error) { authCookie, err := r.Cookie("auth") if err != nil { return nil, ErrFailGetAuthCookie } if authCookie == nil || authCookie.Value == "" { return nil, ErrNoAuthCookie } return Auth(api.db, authCookie.Value, needsPriveleges) } const getUserRolesQuery = `SELECT r.role FROM role AS r INNER JOIN user_role AS ur ON ur.role_id = r.id AND ur.user_id = ?;` func getUserRoles(db *sql.DB, userId int64) ([]model.Role, error) { var roles []model.Role rolesRows, err := db.Query(getUserRolesQuery, userId) if err != nil { return nil, err } for rolesRows.Next() { var role model.Role err := rolesRows.Scan(&role) if err != nil { return nil, err } roles = append(roles, role) } return roles, nil } var AuthCtxKey = &contextKey{"userAuth"} type contextKey struct { name string } func (api RestApi) RecipeGroups(w http.ResponseWriter, r *http.Request) { log := logging.FromReq(r) userAuth, err := AuthFromContext(r.Context()) if err != nil { htp.W500(w, log, err, "sough", htp.BugLogin) return } htp.Json(w, log, "jural", userAuth.Groups) } func (api HtmlApi) UpdateUser(w http.ResponseWriter, r *http.Request) { log := logging.FromReq(r) userAuth, err := AuthFromContext(r.Context()) if err != nil { htp.W500(w, log, fmt.Errorf("%s: %s", htp.WhyNoContext, err), "archnesses", htp.BugLogin) return } err = r.ParseForm() if err != nil { htp.W400(w, log, err, "permutatorial", htp.IssUsrNoUser) return } currentPassword := r.PostForm.Get("current_password") newPassword := r.PostForm.Get("new_password") confirmPassword := r.PostForm.Get("confirm_password") if currentPassword == "" || newPassword == "" || confirmPassword == "" { htp.W400(w, log, err, "ytterbia", "update user must send current_password, new_password, and confirm_password:") return } if newPassword != confirmPassword { htp.W400(w, log, err, "coralberry", "new password does not match confirm password") return } var existingHash string var salt string existingRow := api.db.QueryRow("SELECT password,salt FROM user WHERE id=?;", userAuth.UserId) err = existingRow.Scan(&existingHash, &salt) if err != nil { htp.W500(w, log, err, "palliobranchiate", "update user: bad read existing user password") return } userInputHash := pwToHash(currentPassword, salt) if userInputHash != existingHash { htp.W400(w, log, err, "nannybush", "update user: passwords do not match") return } newPwHash := pwToHash(newPassword, salt) tx, err := api.db.Begin() if err != nil { htp.W500(w, log, err, "unsmirched", "change user: could not start transaction") return } defer tx.Rollback() _, err = tx.Exec("UPDATE user SET password=? WHERE id=?;", newPwHash, userAuth.UserId) if err != nil { htp.W500(w, log, err, "chandleress", "change user: could not update password") return } _, err = tx.Exec("DELETE FROM session WHERE user_id=?;", userAuth.UserId) if err != nil { htp.W500(w, log, err, "postically", "change user: could not clear sessions") return } sessionToken, expires, err := createSession(tx, userAuth.UserId) if err != nil { htp.W500(w, log, err, "isophone", "change user: could not add session") return } tx.Commit() writeSession(w, sessionToken, expires) w.Header().Set("HX-Redirect", "/recipes") w.WriteHeader(http.StatusOK) } func (api HtmlApi) GetUserEdit(w http.ResponseWriter, r *http.Request) { log := logging.FromReq(r) userAuth, err := AuthFromContext(r.Context()) if err != nil { htp.W500(w, log, fmt.Errorf("%s: %s", htp.WhyNoContext, err), "archnesses", htp.BugLogin) return } props := htmx.FullPageProps{} props.Title("Edit user").Nav(userAuth) htmx.Write(w, r, "user_edit.html", props) } func (api RestApi) AddRole(w http.ResponseWriter, r *http.Request) { log := logging.FromReq(r) var addRoleRequest AddRoleRequest if !htp.Read(w, r, log, "convexly", &addRoleRequest) { return } if addRoleRequest.UserId == nil || addRoleRequest.Role == nil { htp.W500(w, log, fmt.Errorf("no user id or role in add request"), "cherishes", htp.BadRolAdd) return } tx, err := api.db.Begin() if err != nil { htp.W500(w, log, err, "inocarpus", htp.IssRolAdd) return } defer tx.Rollback() _, err = tx.Exec( "INSERT INTO user_role (user_id,role_id) VALUES (?,(SELECT id FROM role WHERE role = ?)) ON CONFLICT DO NOTHING;", *addRoleRequest.UserId, string(*addRoleRequest.Role), ) if err != nil { htp.W500(w, log, err, "pseudoreformed", htp.IssRolAdd) return } if *addRoleRequest.Role == model.RoleUser { _, err := tx.Exec( "INSERT INTO user_rgroup (user_id,rgroup_id) VALUES (?,(SELECT id from rgroup WHERE name = 'logged_in')) ON CONFLICT DO NOTHING;", *addRoleRequest.UserId, ) if err != nil { htp.W500(w, log, err, "influents", htp.IssRolAdd) return } } err = tx.Commit() if err != nil { htp.W500(w, log, err, "micher", htp.IssRolAdd) return } w.WriteHeader(http.StatusOK) } type LaterRecipe struct { Id int64 `json:"id"` RecipeId int64 `json:"recipeId"` Preview string `json:"preview"` } type LaterNotes struct { Recipes []LaterRecipe `json:"recipes"` Notes string `json:"notes"` } type LaterNotesHtml struct { htmx.FullPageProps Recipes []LaterRecipe Notes string } func (api HtmlApi) AddLaterRecipe(w http.ResponseWriter, r *http.Request) { log := logging.FromReq(r) userAuth, err := AuthFromContext(r.Context()) if err != nil { htp.W500(w, log, fmt.Errorf("%s: %s", htp.WhyNoContext, err), "markman", htp.BugLogin) return } recipeId := r.PathValue("id") if recipeId == "" { w.WriteHeader(http.StatusBadRequest) return } recipeIdInt, err := strconv.ParseInt(recipeId, 10, 64) if err != nil { htp.W400(w, log, err, "semimedicinal", htp.IssLtrAdd) return } if recipeIdInt < 0 || recipeIdInt > 1_000_000_000 { htp.W400(w, log, err, "elusoriness", htp.IssId) } _, err = api.db.Exec( fmt.Sprintf(` WITH rec AS (SELECT title from recipe WHERE id = %s) INSERT INTO user_recipe_for_later (preview, recipe_id, user_id) VALUES ((SELECT title from rec),?,?) ON CONFLICT DO NOTHING;`, recipeId), recipeId, userAuth.UserId, ) if err != nil { htp.W500(w, log, err, "resurrectionism", htp.IssLtrAdd) return } w.WriteHeader(http.StatusOK) } func (api HtmlApi) DeleteLaterRecipe(w http.ResponseWriter, r *http.Request) { log := logging.FromReq(r) userAuth, err := AuthFromContext(r.Context()) if err != nil { htp.W500(w, log, fmt.Errorf("%s: %s", htp.WhyNoContext, err), "stinkeroos", htp.BugLogin) return } recipeId := r.PathValue("id") if recipeId == "" { w.WriteHeader(http.StatusBadRequest) return } recipeIdInt, err := strconv.ParseInt(recipeId, 10, 64) if err != nil { htp.W400(w, log, err, "hypertranscendent", htp.IssLtrAdd) return } if recipeIdInt < 1 || recipeIdInt > 1_000_000_000 { htp.W400(w, log, err, "dimerism", htp.IssId) } _, err = api.db.Exec("DELETE FROM user_recipe_for_later WHERE id = ? AND user_id = ?;", recipeIdInt, userAuth.UserId) if err != nil { htp.W500(w, log, fmt.Errorf("%s: %s", htp.WhyNoContext, err), "antischolastic", htp.IssLtrDel) return } w.WriteHeader(http.StatusOK) } func (api HtmlApi) getLaterNotes(w http.ResponseWriter, r *http.Request) (any, bool) { log := logging.FromReq(r) userAuth, err := AuthFromContext(r.Context()) if err != nil { htp.W500(w, log, fmt.Errorf("%s: %s", htp.WhyNoContext, err), "sinusoid", htp.BugLogin) return nil, false } laterNotes := LaterNotesHtml{ Recipes: []LaterRecipe{}, } laterNotesRow := api.db.QueryRow("SELECT notes FROM user_later_notes WHERE user_id=?;", userAuth.UserId) err = laterNotesRow.Scan(&laterNotes.Notes) if err != nil && err != sql.ErrNoRows { htp.W500(w, log, err, "unpatristically", htp.BugScan) return nil, false } laterRecipeRows, err := api.db.Query("SELECT id,recipe_id,preview FROM user_recipe_for_later WHERE user_id=?;", userAuth.UserId) if err != nil { htp.W500(w, log, err, "lionism", htp.BugLogin) return nil, false } for laterRecipeRows.Next() { laterRecipe := LaterRecipe{} err := laterRecipeRows.Scan(&laterRecipe.Id, &laterRecipe.RecipeId, &laterRecipe.Preview) if err != nil { htp.W500(w, log, err, "laryngectomized", htp.BugScan) return nil, false } laterNotes.Recipes = append(laterNotes.Recipes, laterRecipe) } laterNotes.Nav(userAuth).Title("Saved recipes") return laterNotes, true } func (api HtmlApi) GetLaterNotes(w http.ResponseWriter, r *http.Request) { laterNotes, ok := api.getLaterNotes(w, r) if !ok { return } htmx.Write(w, r, "recipes_for_later.html", laterNotes) } func (api HtmlApi) GetLaterNotesEdit(w http.ResponseWriter, r *http.Request) { laterNotes, ok := api.getLaterNotes(w, r) if !ok { return } htmx.Write(w, r, "recipes_for_later_edit.html", laterNotes) } type NotesInput struct { Notes string `json:"notes"` } func (api HtmlApi) SetLaterNotes(w http.ResponseWriter, r *http.Request) { log := logging.FromReq(r) userAuth, err := AuthFromContext(r.Context()) if err != nil { htp.W500(w, log, fmt.Errorf("%s: %s", htp.WhyNoContext, err), "subdentate", htp.BugLogin) return } err = r.ParseForm() if err != nil { htp.W400(w, log, err, "permutatorial", htp.IssUsrNoUser) return } notes := r.PostForm.Get("notes") _, err = api.db.Exec(`INSERT INTO user_later_notes (user_id, notes) VALUES (?,?) ON CONFLICT (user_id) DO UPDATE SET notes = ?;`, userAuth.UserId, notes, notes) if err != nil && err != sql.ErrNoRows { htp.W500(w, log, err, "nametape", "Could not update saved recipe notes") return } w.Header().Set("HX-Redirect", "/recipes_for_later") w.WriteHeader(http.StatusOK) } type UserRole struct { Id int64 `json:"id"` Username string `json:"username"` Updated time.Time `json:"updated"` Roles []model.Role `json:"roles"` } func (api RestApi) GetUserRoles(w http.ResponseWriter, r *http.Request) { log := logging.FromReq(r) rows, err := api.db.Query(`SELECT u.id,u.username,u.updated,group_concat(r.role) FROM user u LEFT JOIN user_role ur ON ur.user_id = u.id LEFT JOIN role r ON ur.role_id = r.id GROUP BY u.id ;`) if err != nil { htp.W500(w, log, err, "naivete", "Could not load roles") return } var userRoles []UserRole for rows.Next() { rw := UserRole{} var rolesStr string err := rows.Scan(&rw.Id, &rw.Username, &rw.Updated, &rolesStr) if err != nil { htp.W500(w, log, err, "unglory", htp.BugScan) return } rw.Roles = []model.Role{} for _, role := range strings.Split(rolesStr, ",") { rw.Roles = append(rw.Roles, model.Role(role)) } userRoles = append(userRoles, rw) } htp.Json(w, log, "silicyl", userRoles) }