From 49ffa277e8031c130f18eafff943a30a3a3c758f Mon Sep 17 00:00:00 2001 From: Noah Date: Sun, 14 Aug 2022 14:40:57 -0700 Subject: [PATCH] User Account Busywork * Add "forgot password" workflow. * Add ability to change user email address (confirmation link sent) * Add ability to change user's password. * Add rate limiter to deter brute force login attempts. * Add user deep delete functionality (delete account). * Ping user LastLoginAt every 8 hours for long-lived session cookies. * Add age filters to user search page. * Add sort options to user search (last login, created, username/name) --- pkg/config/config.go | 17 ++- pkg/controller/account/delete.go | 52 +++++++ pkg/controller/account/login.go | 23 ++- pkg/controller/account/reset_password.go | 161 +++++++++++++++++++++ pkg/controller/account/search.go | 37 +++++ pkg/controller/account/settings.go | 146 ++++++++++++++++++- pkg/middleware/authentication.go | 13 +- pkg/models/certification.go | 5 + pkg/models/deletion/delete_user.go | 123 ++++++++++++++++ pkg/models/photo.go | 6 + pkg/models/user.go | 22 ++- pkg/photo/upload.go | 5 +- pkg/ratelimit/ratelimit.go | 104 +++++++++++++ pkg/router/router.go | 3 + pkg/templates/template_funcs.go | 43 +----- pkg/utility/time.go | 42 ++++++ web/templates/account/dashboard.html | 1 + web/templates/account/delete.html | 72 +++++++++ web/templates/account/forgot_password.html | 62 ++++++++ web/templates/account/login.html | 21 ++- web/templates/account/search.html | 55 ++++++- web/templates/account/settings.html | 63 +++++--- web/templates/admin/dashboard.html | 1 + web/templates/base.html | 2 + web/templates/email/reset_password.html | 25 ++++ web/templates/email/verify_email.html | 11 +- web/templates/index.html | 4 +- 27 files changed, 1039 insertions(+), 80 deletions(-) create mode 100644 pkg/controller/account/delete.go create mode 100644 pkg/controller/account/reset_password.go create mode 100644 pkg/models/deletion/delete_user.go create mode 100644 pkg/ratelimit/ratelimit.go create mode 100644 pkg/utility/time.go create mode 100644 web/templates/account/delete.html create mode 100644 web/templates/account/forgot_password.html create mode 100644 web/templates/email/reset_password.html diff --git a/pkg/config/config.go b/pkg/config/config.go index 89c0ab5..1794805 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,7 +9,7 @@ import ( // Branding const ( Title = "nonshy" - Subtitle = "A purpose built social networking app." + Subtitle = "A social network for nudists and exhibitionists." ) // Paths and layouts @@ -41,8 +41,21 @@ const ( // Skip the email verification step. The signup page will directly ask for // email+username+password rather than only email and needing verification. SkipEmailVerification = false + SignupTokenRedisKey = "signup-token/%s" - SignupTokenExpires = 24 * time.Hour + ResetPasswordRedisKey = "reset-password/%s" + ChangeEmailRedisKey = "change-email/%s" + SignupTokenExpires = 24 * time.Hour // used for all tokens so far + + // Rate limit + RateLimitRedisKey = "rate-limit/%s/%s" // namespace, id + LoginRateLimitWindow = 1 * time.Hour + LoginRateLimit = 10 // 10 failed login attempts = locked for full hour + LoginRateLimitCooldownAt = 3 // 3 failed attempts = start throttling + LoginRateLimitCooldown = 30 * time.Second + + // How frequently to refresh LastLoginAt since sessions are long-lived. + LastLoginAtCooldown = 8 * time.Hour ) var ( diff --git a/pkg/controller/account/delete.go b/pkg/controller/account/delete.go new file mode 100644 index 0000000..7a60829 --- /dev/null +++ b/pkg/controller/account/delete.go @@ -0,0 +1,52 @@ +package account + +import ( + "net/http" + "strings" + + "git.kirsle.net/apps/gosocial/pkg/models/deletion" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" +) + +// Delete account page (self service). +func Delete() http.HandlerFunc { + tmpl := templates.Must("account/delete.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "Couldn't get your current user: %s", err) + templates.Redirect(w, "/") + return + } + + // Confirm deletion. + if r.Method == http.MethodPost { + var password = strings.TrimSpace(r.PostFormValue("password")) + if err := currentUser.CheckPassword(password); err != nil { + session.FlashError(w, r, "You must enter your correct account password to delete your account.") + templates.Redirect(w, r.URL.Path) + return + } + + // Delete their account! + if err := deletion.DeleteUser(currentUser); err != nil { + session.FlashError(w, r, "Error while deleting your account: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Sign them out. + session.LogoutUser(w, r) + session.Flash(w, r, "Your account has been deleted.") + templates.Redirect(w, "/") + return + } + + var vars = map[string]interface{}{} + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/account/login.go b/pkg/controller/account/login.go index 969792c..438c1a6 100644 --- a/pkg/controller/account/login.go +++ b/pkg/controller/account/login.go @@ -4,8 +4,10 @@ import ( "net/http" "strings" + "git.kirsle.net/apps/gosocial/pkg/config" "git.kirsle.net/apps/gosocial/pkg/log" "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/ratelimit" "git.kirsle.net/apps/gosocial/pkg/session" "git.kirsle.net/apps/gosocial/pkg/templates" ) @@ -31,10 +33,24 @@ func Login() http.HandlerFunc { return } - log.Warn("err: %+v user: %+v", err, user) + // Rate limit failed login attempts. + limiter := &ratelimit.Limiter{ + Namespace: "login", + ID: user.ID, + Limit: config.LoginRateLimit, + Window: config.LoginRateLimitWindow, + CooldownAt: config.LoginRateLimitCooldownAt, + Cooldown: config.LoginRateLimitCooldown, + } // Verify password. if err := user.CheckPassword(password); err != nil { + if err := limiter.Ping(); err != nil { + session.FlashError(w, r, err.Error()) + templates.Redirect(w, r.URL.Path) + return + } + session.FlashError(w, r, "Incorrect username or password.") templates.Redirect(w, r.URL.Path) return @@ -43,6 +59,11 @@ func Login() http.HandlerFunc { // OK. Log in the user's session. session.LoginUser(w, r, user) + // Clear their rate limiter. + if err := limiter.Clear(); err != nil { + log.Error("Failed to clear login rate limiter: %s", err) + } + // Redirect to their dashboard. session.Flash(w, r, "Login successful.") templates.Redirect(w, "/me") diff --git a/pkg/controller/account/reset_password.go b/pkg/controller/account/reset_password.go new file mode 100644 index 0000000..eeab427 --- /dev/null +++ b/pkg/controller/account/reset_password.go @@ -0,0 +1,161 @@ +package account + +import ( + "fmt" + "net/http" + "strings" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/mail" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/redis" + "git.kirsle.net/apps/gosocial/pkg/session" + "git.kirsle.net/apps/gosocial/pkg/templates" + "github.com/google/uuid" +) + +// ResetToken goes in Redis. +type ResetToken struct { + UserID uint64 + Token string +} + +// Delete the token. +func (t ResetToken) Delete() error { + return redis.Delete(fmt.Sprintf(config.ResetPasswordRedisKey, t.Token)) +} + +// ForgotPassword controller. +func ForgotPassword() http.HandlerFunc { + tmpl := templates.Must("account/forgot_password.html") + + vagueSuccessMessage := "If that username or email existed, we have sent " + + "an email to the address on file with a link to reset your password. " + + "Please check your email inbox for the link." + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ( + tokenStr = r.FormValue("token") // GET or POST + token ResetToken + user *models.User + ) + + // If given a token, validate it first. + if tokenStr != "" { + if err := redis.Get(fmt.Sprintf(config.ResetPasswordRedisKey, tokenStr), &token); err != nil || token.Token != tokenStr { + session.FlashError(w, r, "Invalid password reset token. Please try again from the beginning.") + templates.Redirect(w, r.URL.Path) + return + } + + // Get the target user by ID. + if target, err := models.GetUser(token.UserID); err != nil { + session.FlashError(w, r, "Couldn't look up the user for this token. Please try again.") + templates.Redirect(w, r.URL.Path) + return + } else { + user = target + } + } + + // POSTing: + // - To begin the reset flow (username only) + // - To finalize (username + passwords + validated token) + if r.Method == http.MethodPost { + var ( + username = strings.TrimSpace(strings.ToLower(r.PostFormValue("username"))) + password1 = strings.TrimSpace(r.PostFormValue("password")) + password2 = strings.TrimSpace(r.PostFormValue("confirm")) + ) + + // Find the user. If we came here by token, we already have it, + // otherwise the username post param is required. + if user == nil { + if username == "" { + session.FlashError(w, r, "Username or email address is required.") + templates.Redirect(w, r.URL.Path) + return + } + + target, err := models.FindUser(username) + if err != nil { + session.Flash(w, r, vagueSuccessMessage) + templates.Redirect(w, r.URL.Path) + return + } + + user = target + } + + // With a validated token? + if token.Token != "" { + if password1 == "" { + session.FlashError(w, r, "A password is required.") + templates.Redirect(w, r.URL.Path+"?token="+token.Token) + return + } else if password1 != password2 { + session.FlashError(w, r, "Your passwords do not match.") + templates.Redirect(w, r.URL.Path+"?token="+token.Token) + return + } + + // Set the new password. + user.HashPassword(password1) + if err := user.Save(); err != nil { + session.FlashError(w, r, "Error saving your user: %s", err) + templates.Redirect(w, r.URL.Path+"?token="+token.Token) + return + } else { + // All done! Burn the reset token. + if err := token.Delete(); err != nil { + log.Error("ResetToken.Delete(%s): %s", token.Token, err) + } + + if err := session.LoginUser(w, r, user); err != nil { + session.FlashError(w, r, "Your password was reset and you can now log in.") + templates.Redirect(w, "/login") + return + } else { + session.Flash(w, r, "Your password has been reset and you are now logged in to your account.") + templates.Redirect(w, "/me") + return + } + } + } + + // Create a reset token. + token := ResetToken{ + UserID: user.ID, + Token: uuid.New().String(), + } + if err := redis.Set(fmt.Sprintf(config.ResetPasswordRedisKey, token.Token), token, config.SignupTokenExpires); err != nil { + session.FlashError(w, r, "Couldn't create a reset token: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Email them their reset link. + if err := mail.Send(mail.Message{ + To: user.Email, + Subject: "Reset your forgotten password", + Template: "email/reset_password.html", + Data: map[string]interface{}{ + "Username": user.Username, + "URL": config.Current.BaseURL + "/forgot-password?token=" + token.Token, + }, + }); err != nil { + session.FlashError(w, r, "Error sending an email: %s", err) + } + } + + var vars = map[string]interface{}{ + "Token": token, + "User": user, + } + if err := tmpl.Execute(w, r, vars); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} diff --git a/pkg/controller/account/search.go b/pkg/controller/account/search.go index 15f0eeb..f30304d 100644 --- a/pkg/controller/account/search.go +++ b/pkg/controller/account/search.go @@ -2,6 +2,7 @@ package account import ( "net/http" + "strconv" "git.kirsle.net/apps/gosocial/pkg/config" "git.kirsle.net/apps/gosocial/pkg/models" @@ -12,6 +13,15 @@ import ( // Search controller. func Search() http.HandlerFunc { tmpl := templates.Must("account/search.html") + + // Whitelist for ordering options. + var sortWhitelist = []string{ + "last_login_at desc", + "created_at desc", + "username", + "lower(name)", + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Search filters. var ( @@ -20,8 +30,29 @@ func Search() http.HandlerFunc { gender = r.FormValue("gender") orientation = r.FormValue("orientation") maritalStatus = r.FormValue("marital_status") + sort = r.FormValue("sort") + sortOK bool + ageMin int + ageMax int ) + ageMin, _ = strconv.Atoi(r.FormValue("age_min")) + ageMax, _ = strconv.Atoi(r.FormValue("age_max")) + if ageMin > ageMax { + ageMin, ageMax = ageMax, ageMin + } + + // Sort options. + for _, v := range sortWhitelist { + if sort == v { + sortOK = true + break + } + } + if !sortOK { + sort = "last_login_at desc" + } + // Default if isCertified == "" { isCertified = "true" @@ -29,6 +60,7 @@ func Search() http.HandlerFunc { pager := &models.Pagination{ PerPage: config.PageSizeMemberSearch, + Sort: sort, } pager.ParsePage(r) @@ -38,6 +70,8 @@ func Search() http.HandlerFunc { Orientation: orientation, MaritalStatus: maritalStatus, Certified: isCertified == "true", + AgeMin: ageMin, + AgeMax: ageMax, }, pager) if err != nil { session.FlashError(w, r, "Couldn't search users: %s", err) @@ -54,6 +88,9 @@ func Search() http.HandlerFunc { "Orientation": orientation, "MaritalStatus": maritalStatus, "EmailOrUsername": username, + "AgeMin": ageMin, + "AgeMax": ageMax, + "Sort": sort, } if err := tmpl.Execute(w, r, vars); err != nil { diff --git a/pkg/controller/account/settings.go b/pkg/controller/account/settings.go index f091b94..c05b060 100644 --- a/pkg/controller/account/settings.go +++ b/pkg/controller/account/settings.go @@ -1,16 +1,35 @@ package account import ( + "fmt" "net/http" + nm "net/mail" "strings" "time" "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/mail" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/redis" "git.kirsle.net/apps/gosocial/pkg/session" "git.kirsle.net/apps/gosocial/pkg/templates" "git.kirsle.net/apps/gosocial/pkg/utility" + "github.com/google/uuid" ) +// ChangeEmailToken for Redis. +type ChangeEmailToken struct { + Token string + UserID uint64 + NewEmail string +} + +// Delete the change email token. +func (t ChangeEmailToken) Delete() error { + return redis.Delete(fmt.Sprintf(config.ChangeEmailRedisKey, t.Token)) +} + // User settings page. (/settings). func Settings() http.HandlerFunc { tmpl := templates.Must("account/settings.html") @@ -84,7 +103,83 @@ func Settings() http.HandlerFunc { session.Flash(w, r, "Website preferences updated!") case "settings": - fallthrough + var ( + oldPassword = r.PostFormValue("old_password") + changeEmail = strings.TrimSpace(strings.ToLower(r.PostFormValue("change_email"))) + password1 = strings.TrimSpace(strings.ToLower(r.PostFormValue("new_password"))) + password2 = r.PostFormValue("new_password2") + ) + + // Their old password is needed to make any changes to their account. + if err := user.CheckPassword(oldPassword); err != nil { + session.FlashError(w, r, "Could not make changes to your account settings as the 'current password' you entered was incorrect.") + templates.Redirect(w, r.URL.Path) + return + } + + // Changing their email? + if changeEmail != user.Email { + // Validate the email. + if _, err := nm.ParseAddress(changeEmail); err != nil { + session.FlashError(w, r, "The email address you entered is not valid: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + // Email must not already exist. + if _, err := models.FindUser(changeEmail); err == nil { + session.FlashError(w, r, "That email address is already in use.") + templates.Redirect(w, r.URL.Path) + return + } + + // Create a tokenized link. + token := ChangeEmailToken{ + Token: uuid.New().String(), + UserID: user.ID, + NewEmail: changeEmail, + } + if err := redis.Set(fmt.Sprintf(config.ChangeEmailRedisKey, token.Token), token, config.SignupTokenExpires); err != nil { + session.FlashError(w, r, "Failed to create change email token: %s", err) + templates.Redirect(w, r.URL.Path) + return + } + + err := mail.Send(mail.Message{ + To: changeEmail, + Subject: "Verify your e-mail address", + Template: "email/verify_email.html", + Data: map[string]interface{}{ + "Title": config.Title, + "URL": config.Current.BaseURL + "/settings/confirm-email?token=" + token.Token, + "ChangeEmail": true, + }, + }) + if err != nil { + session.FlashError(w, r, "Error sending a confirmation email to %s: %s", changeEmail, err) + } else { + session.Flash(w, r, "Please verify your new email address. A link has been sent to %s to confirm.", changeEmail) + } + } + + // Changing their password? + if password1 != "" { + if password2 != password1 { + session.FlashError(w, r, "Couldn't change your password: your new passwords do not match.") + } else { + // Hash the new password. + if err := user.HashPassword(password1); err != nil { + session.FlashError(w, r, "Failed to hash your new password: %s", err) + } else { + // Save the user row. + if err := user.Save(); err != nil { + session.FlashError(w, r, "Failed to update your password in the database: %s", err) + } else { + session.Flash(w, r, "Your password has been updated.") + } + } + } + } default: session.FlashError(w, r, "Unknown POST intent value. Please try again.") } @@ -99,3 +194,52 @@ func Settings() http.HandlerFunc { } }) } + +// ConfirmEmailChange after a user tries to change their email. +func ConfirmEmailChange() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var tokenStr = r.FormValue("token") + + if tokenStr != "" { + var token ChangeEmailToken + if err := redis.Get(fmt.Sprintf(config.ChangeEmailRedisKey, tokenStr), &token); err != nil { + session.FlashError(w, r, "Invalid token. Please try again to change your email address.") + templates.Redirect(w, "/") + return + } + + // Verify new email still doesn't already exist. + if _, err := models.FindUser(token.NewEmail); err == nil { + session.FlashError(w, r, "Couldn't update your email address: it is already in use by another member.") + templates.Redirect(w, "/") + return + } + + // Look up the user. + user, err := models.GetUser(token.UserID) + if err != nil { + session.FlashError(w, r, "Didn't find the user that this email change was for. Please try again.") + templates.Redirect(w, "/") + return + } + + // Burn the token. + if err := token.Delete(); err != nil { + log.Error("ChangeEmail: couldn't delete Redis token: %s", err) + } + + // Make the change. + user.Email = token.NewEmail + if err := user.Save(); err != nil { + session.FlashError(w, r, "Couldn't save the change to your user: %s", err) + } else { + session.Flash(w, r, "Your email address has been confirmed and updated.") + templates.Redirect(w, "/") + } + } else { + session.FlashError(w, r, "Invalid change email token. Please try again.") + } + + templates.Redirect(w, "/") + }) +} diff --git a/pkg/middleware/authentication.go b/pkg/middleware/authentication.go index 8adfa3c..a3983a7 100644 --- a/pkg/middleware/authentication.go +++ b/pkg/middleware/authentication.go @@ -2,7 +2,9 @@ package middleware import ( "net/http" + "time" + "git.kirsle.net/apps/gosocial/pkg/config" "git.kirsle.net/apps/gosocial/pkg/controller/photo" "git.kirsle.net/apps/gosocial/pkg/log" "git.kirsle.net/apps/gosocial/pkg/session" @@ -14,13 +16,22 @@ func LoginRequired(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // User must be logged in. - if _, err := session.CurrentUser(r); err != nil { + user, err := session.CurrentUser(r) + if err != nil { log.Error("LoginRequired: %s", err) errhandler := templates.MakeErrorPage("Login Required", "You must be signed in to view this page.", http.StatusForbidden) errhandler.ServeHTTP(w, r) return } + // Ping LastLoginAt for long lived sessions. + if time.Since(user.LastLoginAt) > config.LastLoginAtCooldown { + user.LastLoginAt = time.Now() + if err := user.Save(); err != nil { + log.Error("LoginRequired: couldn't refresh LastLoginAt for user %s: %s", user.Username, err) + } + } + handler.ServeHTTP(w, r) }) } diff --git a/pkg/models/certification.go b/pkg/models/certification.go index 21f2182..646eabe 100644 --- a/pkg/models/certification.go +++ b/pkg/models/certification.go @@ -68,3 +68,8 @@ func (p *CertificationPhoto) Save() error { result := DB.Save(p) return result.Error } + +// Delete the DB entry. +func (p *CertificationPhoto) Delete() error { + return DB.Delete(p).Error +} diff --git a/pkg/models/deletion/delete_user.go b/pkg/models/deletion/delete_user.go new file mode 100644 index 0000000..10dd7a4 --- /dev/null +++ b/pkg/models/deletion/delete_user.go @@ -0,0 +1,123 @@ +package deletion + +import ( + "fmt" + + "git.kirsle.net/apps/gosocial/pkg/log" + "git.kirsle.net/apps/gosocial/pkg/models" + "git.kirsle.net/apps/gosocial/pkg/photo" +) + +// DeleteUser wipes a user and all associated data from the database. +func DeleteUser(user *models.User) error { + log.Error("BEGIN DeleteUser(%d, %s)", user.ID, user.Username) + + // Remove all linked tables and assets. + type remover struct { + Step string + Fn func(uint64) error + } + + var todo = []remover{ + {"Photos", DeleteUserPhotos}, + {"Certification Photo", DeleteCertification}, + {"Messages", DeleteUserMessages}, + {"Friends", DeleteFriends}, + {"Profile Fields", DeleteProfile}, + } + for _, item := range todo { + if err := item.Fn(user.ID); err != nil { + return fmt.Errorf("%s: %s", item.Step, err) + } + } + + // Remove the user itself. + return user.Delete() +} + +// DeleteUserPhotos scrubs data for deleting a user. +func DeleteUserPhotos(userID uint64) error { + log.Error("DeleteUser: BEGIN DeleteUserPhotos(%d)", userID) + + // Deeply scrub all user photos. + pager := &models.Pagination{ + Page: 1, + PerPage: 20, + Sort: "photos.id", + } + + for { + photos, err := models.PaginateUserPhotos( + userID, + models.PhotoVisibilityAll, + true, + pager, + ) + + if err != nil { + return err + } + + if len(photos) == 0 { + break + } + + for _, item := range photos { + log.Warn("DeleteUserPhotos(%d): remove file %s", userID, item.Filename) + photo.Delete(item.Filename) + if item.CroppedFilename != "" { + log.Warn("DeleteUserPhotos(%d): remove file %s", userID, item.CroppedFilename) + photo.Delete(item.CroppedFilename) + } + if err := item.Delete(); err != nil { + return err + } + } + } + + log.Error("DeleteUser: END DeleteUserPhotos(%d)", userID) + return nil +} + +// DeleteCertification scrubs data for deleting a user. +func DeleteCertification(userID uint64) error { + log.Error("DeleteUser: DeleteCertification(%d)", userID) + if cert, err := models.GetCertificationPhoto(userID); err == nil { + if cert.Filename != "" { + log.Warn("DeleteCertification(%d): remove file %s", userID, cert.Filename) + photo.Delete(cert.Filename) + } + return cert.Delete() + } + return nil +} + +// DeleteUserMessages scrubs data for deleting a user. +func DeleteUserMessages(userID uint64) error { + log.Error("DeleteUser: DeleteUserMessages(%d)", userID) + result := models.DB.Where( + "source_user_id = ? OR target_user_id = ?", + userID, userID, + ).Delete(&models.Message{}) + return result.Error +} + +// DeleteFriends scrubs data for deleting a user. +func DeleteFriends(userID uint64) error { + log.Error("DeleteUser: DeleteUserFriends(%d)", userID) + result := models.DB.Where( + "source_user_id = ? OR target_user_id = ?", + userID, userID, + ).Delete(&models.Friend{}) + return result.Error +} + +// DeleteProfile scrubs data for deleting a user. +func DeleteProfile(userID uint64) error { + log.Error("DeleteUser: DeleteProfile(%d)", userID) + result := models.DB.Where( + "user_id = ?", + userID, + ).Delete(&models.ProfileField{}) + return result.Error +} diff --git a/pkg/models/photo.go b/pkg/models/photo.go index 0bce017..d9ce4f6 100644 --- a/pkg/models/photo.go +++ b/pkg/models/photo.go @@ -32,6 +32,12 @@ const ( PhotoPrivate = "private" // private ) +var PhotoVisibilityAll = []PhotoVisibility{ + PhotoPublic, + PhotoFriends, + PhotoPrivate, +} + // CreatePhoto with most of the settings you want (not ID or timestamps) in the database. func CreatePhoto(tmpl Photo) (*Photo, error) { if tmpl.UserID == 0 { diff --git a/pkg/models/user.go b/pkg/models/user.go index 34bb7eb..2f31d3b 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -119,6 +119,8 @@ type UserSearch struct { Orientation string MaritalStatus string Certified bool + AgeMin int + AgeMax int } // SearchUsers @@ -175,10 +177,22 @@ func SearchUsers(search *UserSearch, pager *Pagination) ([]*User, error) { placeholders = append(placeholders, search.Certified) } + if search.AgeMin > 0 { + date := time.Now().AddDate(-search.AgeMin, 0, 0) + wheres = append(wheres, "birthdate <= ?") + placeholders = append(placeholders, date) + } + + if search.AgeMax > 0 { + date := time.Now().AddDate(-search.AgeMax-1, 0, 0) + wheres = append(wheres, "birthdate >= ?") + placeholders = append(placeholders, date) + } + query = (&User{}).Preload().Where( strings.Join(wheres, " AND "), placeholders..., - ) + ).Order(pager.Sort) query.Model(&User{}).Count(&pager.Total) result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&users) return users, result.Error @@ -305,6 +319,12 @@ func (u *User) Save() error { return result.Error } +// Delete a user. NOTE: use the models/deletion/DeleteUser() function +// instead of this to do a deep scrub of all related data! +func (u *User) Delete() error { + return DB.Delete(u).Error +} + // Print user object as pretty JSON. func (u *User) Print() string { var ( diff --git a/pkg/photo/upload.go b/pkg/photo/upload.go index d92bb23..5b0f4de 100644 --- a/pkg/photo/upload.go +++ b/pkg/photo/upload.go @@ -233,5 +233,8 @@ func ToDisk(filename string, extension string, img image.Image) error { // Delete a photo from disk. func Delete(filename string) error { - return os.Remove(DiskPath(filename)) + if len(filename) > 0 { + return os.Remove(DiskPath(filename)) + } + return errors.New("filename is required") } diff --git a/pkg/ratelimit/ratelimit.go b/pkg/ratelimit/ratelimit.go new file mode 100644 index 0000000..c80532f --- /dev/null +++ b/pkg/ratelimit/ratelimit.go @@ -0,0 +1,104 @@ +package ratelimit + +import ( + "fmt" + "time" + + "git.kirsle.net/apps/gosocial/pkg/config" + "git.kirsle.net/apps/gosocial/pkg/redis" + "git.kirsle.net/apps/gosocial/pkg/utility" +) + +// Limiter implements a Redis-backed rate limit for logins or otherwise. +type Limiter struct { + Namespace string // kind of rate limiter ("login") + ID interface{} // unique ID of the resource being pinged (str or ints) + Limit int // how many pings within the window period + Window time.Duration // the window period/expiration of Redis key + CooldownAt int // how many pings before the cooldown is enforced + Cooldown time.Duration // time to wait between fails +} + +// Redis object behind the rate limiter. +type Data struct { + Pings int + NotBefore time.Time +} + +// Ping the rate limiter. +func (l *Limiter) Ping() error { + var ( + key = l.Key() + now = time.Now() + ) + + // Get stored data from Redis if any. + var data Data + redis.Get(key, &data) + + // Are we cooling down? + if now.Before(data.NotBefore) { + return fmt.Errorf( + "You are doing that too often. Please wait %s before trying again.", + utility.FormatDurationCoarse(data.NotBefore.Sub(now)), + ) + } + + // Increment the ping count. + data.Pings++ + + // Have we hit the wall? + if data.Pings >= l.Limit { + return fmt.Errorf( + "You have hit the rate limit; please wait the full %s before trying again.", + utility.FormatDurationCoarse(l.Window), + ) + } + + // Are we throttled? + if data.Pings >= l.CooldownAt { + data.NotBefore = now.Add(l.Cooldown) + if err := redis.Set(key, data, l.Window); err != nil { + return fmt.Errorf("Couldn't set Redis key for rate limiter: %s", err) + } + return fmt.Errorf( + "Please wait %s before trying again. You have %d more attempt(s) remaining before you will be locked "+ + "out for %s.", + utility.FormatDurationCoarse(l.Cooldown), + l.Limit-data.Pings, + utility.FormatDurationCoarse(l.Window), + ) + } + + // Save their ping count to Redis. + if err := redis.Set(key, data, l.Window); err != nil { + return fmt.Errorf("Couldn't set Redis key for rate limiter: %s", err) + } + + return nil +} + +// Clear the rate limiter, cleaning up the Redis key (e.g., after successful login). +func (l *Limiter) Clear() error { + return redis.Delete(l.Key()) +} + +// Key formats the Redis key. +func (l *Limiter) Key() string { + var str string + switch t := l.ID.(type) { + case int: + str = fmt.Sprintf("%d", t) + case uint64: + str = fmt.Sprintf("%d", t) + case int64: + str = fmt.Sprintf("%d", t) + case uint32: + str = fmt.Sprintf("%d", t) + case int32: + str = fmt.Sprintf("%d", t) + default: + str = fmt.Sprintf("%s", t) + } + return fmt.Sprintf(config.RateLimitRedisKey, l.Namespace, str) +} diff --git a/pkg/router/router.go b/pkg/router/router.go index 1bf7ede..8dec359 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -23,10 +23,13 @@ func New() http.Handler { mux.HandleFunc("/login", account.Login()) mux.HandleFunc("/logout", account.Logout()) mux.HandleFunc("/signup", account.Signup()) + mux.HandleFunc("/forgot-password", account.ForgotPassword()) + mux.HandleFunc("/settings/confirm-email", account.ConfirmEmailChange()) // Login Required. Pages that non-certified users can access. mux.Handle("/me", middleware.LoginRequired(account.Dashboard())) mux.Handle("/settings", middleware.LoginRequired(account.Settings())) + mux.Handle("/account/delete", middleware.LoginRequired(account.Delete())) mux.Handle("/u/", middleware.LoginRequired(account.Profile())) mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload())) mux.Handle("/photo/u/", middleware.LoginRequired(photo.UserPhotos())) diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index 7ed857b..4e3b21a 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -47,6 +47,13 @@ func TemplateFuncs(r *http.Request) template.FuncMap { } return value[:n] }, + "IterRange": func(start, n int) []int { + var result = []int{} + for i := start; i <= n; i++ { + result = append(result, i) + } + return result + }, } } @@ -69,42 +76,8 @@ func InputCSRF(r *http.Request) func() template.HTML { // SincePrettyCoarse formats a time.Duration in plain English. Intended for "joined 2 months ago" type // strings - returns the coarsest level of granularity. func SincePrettyCoarse() func(time.Time) template.HTML { - var result = func(text string, v int64) template.HTML { - if v == 1 { - text = strings.TrimSuffix(text, "s") - } - return template.HTML(fmt.Sprintf(text, v)) - } - return func(since time.Time) template.HTML { - var ( - duration = time.Since(since) - ) - - if duration.Seconds() < 60.0 { - return result("%d seconds", int64(duration.Seconds())) - } - - if duration.Minutes() < 60.0 { - return result("%d minutes", int64(duration.Minutes())) - } - - if duration.Hours() < 24.0 { - return result("%d hours", int64(duration.Hours())) - } - - days := int64(duration.Hours() / 24) - if days < 30 { - return result("%d days", days) - } - - months := int64(days / 30) - if months < 12 { - return result("%d months", months) - } - - years := int64(days / 365) - return result("%d years", years) + return template.HTML(utility.FormatDurationCoarse(time.Since(since))) } } diff --git a/pkg/utility/time.go b/pkg/utility/time.go new file mode 100644 index 0000000..bbfea7c --- /dev/null +++ b/pkg/utility/time.go @@ -0,0 +1,42 @@ +package utility + +import ( + "fmt" + "strings" + "time" +) + +// FormatDurationCoarse returns a pretty printed duration with coarse granularity. +func FormatDurationCoarse(duration time.Duration) string { + var result = func(text string, v int64) string { + if v == 1 { + text = strings.TrimSuffix(text, "s") + } + return fmt.Sprintf(text, v) + } + + if duration.Seconds() < 60.0 { + return result("%d seconds", int64(duration.Seconds())) + } + + if duration.Minutes() < 60.0 { + return result("%d minutes", int64(duration.Minutes())) + } + + if duration.Hours() < 24.0 { + return result("%d hours", int64(duration.Hours())) + } + + days := int64(duration.Hours() / 24) + if days < 30 { + return result("%d days", days) + } + + months := int64(days / 30) + if months < 12 { + return result("%d months", months) + } + + years := int64(days / 365) + return result("%d years", years) +} diff --git a/web/templates/account/dashboard.html b/web/templates/account/dashboard.html index 03206e0..653cc62 100644 --- a/web/templates/account/dashboard.html +++ b/web/templates/account/dashboard.html @@ -1,3 +1,4 @@ +{{define "title"}}My Dashboard{{end}} {{define "content"}}
diff --git a/web/templates/account/delete.html b/web/templates/account/delete.html new file mode 100644 index 0000000..8f00dec --- /dev/null +++ b/web/templates/account/delete.html @@ -0,0 +1,72 @@ +{{define "title"}}Delete Account{{end}} +{{define "content"}} +
+
+
+
+

+ Delete Account +

+
+
+
+ +
+
+
+
+
+

+ + Delete My Account +

+
+
+
+ {{InputCSRF}} +
+

+ We're sorry to see you go! If you wish to delete your account, you may do + so on this page. Your account will not be recoverable after deletion! We + will remove everything we know about your account from this server: +

+ +
    +
  • Your account and profile data.
  • +
  • Your photos and their data.
  • +
  • Your certification photo.
  • +
  • Your friends, direct messages, forum posts, comments, likes, and so on.
  • +
  • + Your username ({{.CurrentUser.Username}}) will be made available again + and somebody else (or you, should you sign up again) may be able to use + it in the future. +
  • +
+ +

+ To confirm deletion of your account, please enter your current account + password into the box below. +

+
+ +
+ + +
+ +
+ + Cancel +
+
+
+
+
+
+
+ +
+{{end}} \ No newline at end of file diff --git a/web/templates/account/forgot_password.html b/web/templates/account/forgot_password.html new file mode 100644 index 0000000..280aaaf --- /dev/null +++ b/web/templates/account/forgot_password.html @@ -0,0 +1,62 @@ +{{define "title"}}Log In{{end}} +{{define "content"}} +
+
+
+
+

Reset Password

+
+
+
+ +
+
+ {{ InputCSRF }} + + + {{if and .Token .User}} + + +
+ +

{{.User.Username}}

+
+ +
+ + +
+ +
+ + +
+ +
+ +
+ {{else}} +

+ Forgot your password? Enter your account username or email address below and we can + e-mail you a link to set a new password. +

+ +
+ + +
+ +
+ +
+ {{end}} +
+
+
+{{end}} \ No newline at end of file diff --git a/web/templates/account/login.html b/web/templates/account/login.html index 0cf56c2..13d647c 100644 --- a/web/templates/account/login.html +++ b/web/templates/account/login.html @@ -10,17 +10,26 @@
-
+
{{ InputCSRF }} - - +
+ + +
- - +
+ + +

+ Forgot? +

+
- +
+ +
diff --git a/web/templates/account/search.html b/web/templates/account/search.html index cd95acd..8db1176 100644 --- a/web/templates/account/search.html +++ b/web/templates/account/search.html @@ -1,4 +1,4 @@ -{{define "title"}}Friends{{end}} +{{define "title"}}People{{end}} {{define "content"}}
{{$Root := .}} @@ -66,6 +66,32 @@
+
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+
@@ -109,12 +135,27 @@
-
- Reset - +
+
+ Sort by: +
+
+
+ +
+
+
+ Reset + +
diff --git a/web/templates/account/settings.html b/web/templates/account/settings.html index 3524d15..467423a 100644 --- a/web/templates/account/settings.html +++ b/web/templates/account/settings.html @@ -204,25 +204,13 @@
-
-
-

- - Verification Photo -

-
- -
- xxx -
-
{{InputCSRF}} -
+

@@ -260,29 +248,40 @@ {{InputCSRF}} -

-
-

+

+
+

Account Settings

-
+
+ + +

+ Enter your current password before making any changes to your + email address or setting a new password. +

+
+ placeholder="name@domain.com" + value="{{.CurrentUser.Email}}">
- @@ -300,6 +299,28 @@
+ +
+
+

+ + Delete Account +

+
+ +
+

+ If you would like to delete your account, please click + on the button below. +

+ +

+ + Delete My Account + +

+
+
diff --git a/web/templates/admin/dashboard.html b/web/templates/admin/dashboard.html index 1296a9b..30c5cdc 100644 --- a/web/templates/admin/dashboard.html +++ b/web/templates/admin/dashboard.html @@ -1,3 +1,4 @@ +{{define "title"}}Admin Dashboard{{end}} {{define "content"}}
diff --git a/web/templates/base.html b/web/templates/base.html index 743ae04..5e0d011 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -123,6 +123,8 @@