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)
This commit is contained in:
Noah 2022-08-14 14:40:57 -07:00
parent 4ad7e00c44
commit 0caf12eb00
27 changed files with 1039 additions and 80 deletions

View File

@ -9,7 +9,7 @@ import (
// Branding // Branding
const ( const (
Title = "nonshy" Title = "nonshy"
Subtitle = "A purpose built social networking app." Subtitle = "A social network for nudists and exhibitionists."
) )
// Paths and layouts // Paths and layouts
@ -41,8 +41,21 @@ const (
// Skip the email verification step. The signup page will directly ask for // Skip the email verification step. The signup page will directly ask for
// email+username+password rather than only email and needing verification. // email+username+password rather than only email and needing verification.
SkipEmailVerification = false SkipEmailVerification = false
SignupTokenRedisKey = "signup-token/%s" 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 ( var (

View File

@ -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
}
})
}

View File

@ -4,8 +4,10 @@ import (
"net/http" "net/http"
"strings" "strings"
"git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/log" "git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/models" "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/session"
"git.kirsle.net/apps/gosocial/pkg/templates" "git.kirsle.net/apps/gosocial/pkg/templates"
) )
@ -31,10 +33,24 @@ func Login() http.HandlerFunc {
return 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. // Verify password.
if err := user.CheckPassword(password); err != nil { 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.") session.FlashError(w, r, "Incorrect username or password.")
templates.Redirect(w, r.URL.Path) templates.Redirect(w, r.URL.Path)
return return
@ -43,6 +59,11 @@ func Login() http.HandlerFunc {
// OK. Log in the user's session. // OK. Log in the user's session.
session.LoginUser(w, r, user) 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. // Redirect to their dashboard.
session.Flash(w, r, "Login successful.") session.Flash(w, r, "Login successful.")
templates.Redirect(w, "/me") templates.Redirect(w, "/me")

View File

@ -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
}
})
}

View File

@ -2,6 +2,7 @@ package account
import ( import (
"net/http" "net/http"
"strconv"
"git.kirsle.net/apps/gosocial/pkg/config" "git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/models" "git.kirsle.net/apps/gosocial/pkg/models"
@ -12,6 +13,15 @@ import (
// Search controller. // Search controller.
func Search() http.HandlerFunc { func Search() http.HandlerFunc {
tmpl := templates.Must("account/search.html") 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Search filters. // Search filters.
var ( var (
@ -20,8 +30,29 @@ func Search() http.HandlerFunc {
gender = r.FormValue("gender") gender = r.FormValue("gender")
orientation = r.FormValue("orientation") orientation = r.FormValue("orientation")
maritalStatus = r.FormValue("marital_status") 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 // Default
if isCertified == "" { if isCertified == "" {
isCertified = "true" isCertified = "true"
@ -29,6 +60,7 @@ func Search() http.HandlerFunc {
pager := &models.Pagination{ pager := &models.Pagination{
PerPage: config.PageSizeMemberSearch, PerPage: config.PageSizeMemberSearch,
Sort: sort,
} }
pager.ParsePage(r) pager.ParsePage(r)
@ -38,6 +70,8 @@ func Search() http.HandlerFunc {
Orientation: orientation, Orientation: orientation,
MaritalStatus: maritalStatus, MaritalStatus: maritalStatus,
Certified: isCertified == "true", Certified: isCertified == "true",
AgeMin: ageMin,
AgeMax: ageMax,
}, pager) }, pager)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't search users: %s", err) session.FlashError(w, r, "Couldn't search users: %s", err)
@ -54,6 +88,9 @@ func Search() http.HandlerFunc {
"Orientation": orientation, "Orientation": orientation,
"MaritalStatus": maritalStatus, "MaritalStatus": maritalStatus,
"EmailOrUsername": username, "EmailOrUsername": username,
"AgeMin": ageMin,
"AgeMax": ageMax,
"Sort": sort,
} }
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {

View File

@ -1,16 +1,35 @@
package account package account
import ( import (
"fmt"
"net/http" "net/http"
nm "net/mail"
"strings" "strings"
"time" "time"
"git.kirsle.net/apps/gosocial/pkg/config" "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/session"
"git.kirsle.net/apps/gosocial/pkg/templates" "git.kirsle.net/apps/gosocial/pkg/templates"
"git.kirsle.net/apps/gosocial/pkg/utility" "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). // User settings page. (/settings).
func Settings() http.HandlerFunc { func Settings() http.HandlerFunc {
tmpl := templates.Must("account/settings.html") tmpl := templates.Must("account/settings.html")
@ -84,7 +103,83 @@ func Settings() http.HandlerFunc {
session.Flash(w, r, "Website preferences updated!") session.Flash(w, r, "Website preferences updated!")
case "settings": 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: default:
session.FlashError(w, r, "Unknown POST intent value. Please try again.") 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, "/")
})
}

View File

@ -2,7 +2,9 @@ package middleware
import ( import (
"net/http" "net/http"
"time"
"git.kirsle.net/apps/gosocial/pkg/config"
"git.kirsle.net/apps/gosocial/pkg/controller/photo" "git.kirsle.net/apps/gosocial/pkg/controller/photo"
"git.kirsle.net/apps/gosocial/pkg/log" "git.kirsle.net/apps/gosocial/pkg/log"
"git.kirsle.net/apps/gosocial/pkg/session" "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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// User must be logged in. // 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) log.Error("LoginRequired: %s", err)
errhandler := templates.MakeErrorPage("Login Required", "You must be signed in to view this page.", http.StatusForbidden) errhandler := templates.MakeErrorPage("Login Required", "You must be signed in to view this page.", http.StatusForbidden)
errhandler.ServeHTTP(w, r) errhandler.ServeHTTP(w, r)
return 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) handler.ServeHTTP(w, r)
}) })
} }

View File

@ -68,3 +68,8 @@ func (p *CertificationPhoto) Save() error {
result := DB.Save(p) result := DB.Save(p)
return result.Error return result.Error
} }
// Delete the DB entry.
func (p *CertificationPhoto) Delete() error {
return DB.Delete(p).Error
}

View File

@ -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
}

View File

@ -32,6 +32,12 @@ const (
PhotoPrivate = "private" // private PhotoPrivate = "private" // private
) )
var PhotoVisibilityAll = []PhotoVisibility{
PhotoPublic,
PhotoFriends,
PhotoPrivate,
}
// CreatePhoto with most of the settings you want (not ID or timestamps) in the database. // CreatePhoto with most of the settings you want (not ID or timestamps) in the database.
func CreatePhoto(tmpl Photo) (*Photo, error) { func CreatePhoto(tmpl Photo) (*Photo, error) {
if tmpl.UserID == 0 { if tmpl.UserID == 0 {

View File

@ -119,6 +119,8 @@ type UserSearch struct {
Orientation string Orientation string
MaritalStatus string MaritalStatus string
Certified bool Certified bool
AgeMin int
AgeMax int
} }
// SearchUsers // SearchUsers
@ -175,10 +177,22 @@ func SearchUsers(search *UserSearch, pager *Pagination) ([]*User, error) {
placeholders = append(placeholders, search.Certified) 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( query = (&User{}).Preload().Where(
strings.Join(wheres, " AND "), strings.Join(wheres, " AND "),
placeholders..., placeholders...,
) ).Order(pager.Sort)
query.Model(&User{}).Count(&pager.Total) query.Model(&User{}).Count(&pager.Total)
result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&users) result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&users)
return users, result.Error return users, result.Error
@ -305,6 +319,12 @@ func (u *User) Save() error {
return result.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. // Print user object as pretty JSON.
func (u *User) Print() string { func (u *User) Print() string {
var ( var (

View File

@ -233,5 +233,8 @@ func ToDisk(filename string, extension string, img image.Image) error {
// Delete a photo from disk. // Delete a photo from disk.
func Delete(filename string) error { func Delete(filename string) error {
if len(filename) > 0 {
return os.Remove(DiskPath(filename)) return os.Remove(DiskPath(filename))
}
return errors.New("filename is required")
} }

104
pkg/ratelimit/ratelimit.go Normal file
View File

@ -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)
}

View File

@ -23,10 +23,13 @@ func New() http.Handler {
mux.HandleFunc("/login", account.Login()) mux.HandleFunc("/login", account.Login())
mux.HandleFunc("/logout", account.Logout()) mux.HandleFunc("/logout", account.Logout())
mux.HandleFunc("/signup", account.Signup()) 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. // Login Required. Pages that non-certified users can access.
mux.Handle("/me", middleware.LoginRequired(account.Dashboard())) mux.Handle("/me", middleware.LoginRequired(account.Dashboard()))
mux.Handle("/settings", middleware.LoginRequired(account.Settings())) mux.Handle("/settings", middleware.LoginRequired(account.Settings()))
mux.Handle("/account/delete", middleware.LoginRequired(account.Delete()))
mux.Handle("/u/", middleware.LoginRequired(account.Profile())) mux.Handle("/u/", middleware.LoginRequired(account.Profile()))
mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload())) mux.Handle("/photo/upload", middleware.LoginRequired(photo.Upload()))
mux.Handle("/photo/u/", middleware.LoginRequired(photo.UserPhotos())) mux.Handle("/photo/u/", middleware.LoginRequired(photo.UserPhotos()))

View File

@ -47,6 +47,13 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
} }
return value[:n] 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 // SincePrettyCoarse formats a time.Duration in plain English. Intended for "joined 2 months ago" type
// strings - returns the coarsest level of granularity. // strings - returns the coarsest level of granularity.
func SincePrettyCoarse() func(time.Time) template.HTML { 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 { return func(since time.Time) template.HTML {
var ( return template.HTML(utility.FormatDurationCoarse(time.Since(since)))
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)
} }
} }

42
pkg/utility/time.go Normal file
View File

@ -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)
}

View File

@ -1,3 +1,4 @@
{{define "title"}}My Dashboard{{end}}
{{define "content"}} {{define "content"}}
<div class="container"> <div class="container">
<section class="hero is-info is-bold"> <section class="hero is-info is-bold">

View File

@ -0,0 +1,72 @@
{{define "title"}}Delete Account{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
Delete Account
</h1>
</div>
</div>
</section>
<div class="block p-4">
<div class="columns is-centered">
<div class="column is-half">
<div class="card" style="max-width: 512px">
<header class="card-header has-background-danger">
<p class="card-header-title has-text-light">
<span class="icon"><i class="fa fa-trash"></i></span>
Delete My Account
</p>
</header>
<div class="card-content">
<form method="POST" action="/account/delete">
{{InputCSRF}}
<div class="block content">
<p>
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:
</p>
<ul>
<li>Your account and profile data.</li>
<li>Your photos and their data.</li>
<li>Your certification photo.</li>
<li>Your friends, direct messages, forum posts, comments, likes, and so on.</li>
<li>
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.
</li>
</ul>
<p>
To confirm deletion of your account, please enter your current account
password into the box below.
</p>
</div>
<div class="field">
<label class="label" for="password">Your current password:</label>
<input type="password" class="input"
name="password"
id="password"
placeholder="Password">
</div>
<div class="block has-text-center">
<button type="submit" class="button is-danger">Delete My Account</button>
<a href="/me" class="button is-success">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}

View File

@ -0,0 +1,62 @@
{{define "title"}}Log In{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">Reset Password</h1>
</div>
</div>
</section>
<div class="block p-4">
<form action="/forgot-password" method="POST">
{{ InputCSRF }}
<!-- With token: set a new password -->
{{if and .Token .User}}
<input type="hidden" name="token" value="{{.Token.Token}}">
<div class="field">
<label class="label">Username:</label>
<p>{{.User.Username}}</p>
</div>
<div class="field">
<label class="label" for="password">New password:</label>
<input type="password" class="input"
name="password"
id="password"
placeholder="Password">
</div>
<div class="field">
<label class="label" for="confirm">Confirm new password:</label>
<input type="password" class="input"
name="confirm"
id="confirm"
placeholder="Password">
</div>
<div class="field">
<button type="submit" class="button is-primary">Set password and sign in</button>
</div>
{{else}}
<p class="block">
Forgot your password? Enter your account username or email address below and we can
e-mail you a link to set a new password.
</p>
<div class="field">
<label class="label" for="username">Username or email:</label>
<input type="text" class="input" name="username" placeholder="username" autocomplete="off" required>
</div>
<div class="field">
<button type="submit" class="button is-primary">Send me a link to reset my password</button>
</div>
{{end}}
</form>
</div>
</div>
{{end}}

View File

@ -10,17 +10,26 @@
</div> </div>
</section> </section>
<div class="block p-2"> <div class="block p-4">
<form action="/login" method="POST"> <form action="/login" method="POST">
{{ InputCSRF }} {{ InputCSRF }}
<label for="username">Username or email:</label> <div class="field">
<label class="label" for="username">Username or email:</label>
<input type="text" class="input" name="username" placeholder="username" autocomplete="off"> <input type="text" class="input" name="username" placeholder="username" autocomplete="off">
</div>
<label for="password">Password:</label> <div class="field">
<label class="label" for="password">Password:</label>
<input type="password" class="input" name="password" placeholder="password"> <input type="password" class="input" name="password" placeholder="password">
<p class="help">
<a href="/forgot-password">Forgot?</a>
</p>
</div>
<div class="field">
<button type="submit" class="button is-primary">Log in</button> <button type="submit" class="button is-primary">Log in</button>
</div>
</form> </form>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
{{define "title"}}Friends{{end}} {{define "title"}}People{{end}}
{{define "content"}} {{define "content"}}
<div class="container"> <div class="container">
{{$Root := .}} {{$Root := .}}
@ -66,6 +66,32 @@
</div> </div>
</div> </div>
<div class="column">
<div class="field">
<label class="label">Age:</label>
<div class="columns is-mobile">
<div class="column">
<div class="select">
<select name="age_min">
<option value="">Min</option>
{{range IterRange 18 120}}
<option value="{{.}}"{{if eq $Root.AgeMin .}} selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
<div class="select">
<select name="age_max">
<option value="">Max</option>
{{range IterRange 18 120}}
<option value="{{.}}"{{if eq $Root.AgeMax .}} selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
</div>
</div>
</div>
</div>
<div class="column"> <div class="column">
<div class="field"> <div class="field">
<label class="label" for="gender">Gender:</label> <label class="label" for="gender">Gender:</label>
@ -109,7 +135,21 @@
</div> </div>
</div> </div>
<div class="has-text-centered"> <div class="columns is-centered">
<div class="column is-narrow pr-1">
<strong>Sort by:</strong>
</div>
<div class="column is-narrow pl-1">
<div class="select is-full-width">
<select id="sort" name="sort">
<option value="last_login_at desc"{{if eq .Sort "last_login_at desc"}} selected{{end}}>Last login</option>
<option value="created_at desc"{{if eq .Sort "created_at desc"}} selected{{end}}>Signup date</option>
<option value="username"{{if eq .Sort "username"}} selected{{end}}>Username (a-z)</option>
<option value="lower(name)"{{if eq .Sort "lower(name)"}} selected{{end}}>Name (a-z)</option>
</select>
</div>
</div>
<div class="column is-narrow">
<a href="/members" class="button">Reset</a> <a href="/members" class="button">Reset</a>
<button type="submit" class="button is-success"> <button type="submit" class="button is-success">
<span>Search</span> <span>Search</span>
@ -118,6 +158,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>

View File

@ -204,25 +204,13 @@
</div> </div>
<div class="column"> <div class="column">
<div class="card block" id="verification">
<header class="card-header has-background-info">
<p class="card-header-title has-text-light">
<i class="fa fa-camera pr-2"></i>
Verification Photo
</p>
</header>
<div class="card-content">
xxx
</div>
</div>
<!-- Website Preferences --> <!-- Website Preferences -->
<form method="POST" action="/settings"> <form method="POST" action="/settings">
<input type="hidden" name="intent" value="preferences"> <input type="hidden" name="intent" value="preferences">
{{InputCSRF}} {{InputCSRF}}
<div class="card block" id="prefs"> <div class="card mb-5" id="prefs">
<header class="card-header has-background-success"> <header class="card-header has-background-success">
<p class="card-header-title"> <p class="card-header-title">
<i class="fa fa-square-check pr-2"></i> <i class="fa fa-square-check pr-2"></i>
@ -260,29 +248,40 @@
<input type="hidden" name="intent" value="settings"> <input type="hidden" name="intent" value="settings">
{{InputCSRF}} {{InputCSRF}}
<div class="card block" id="account"> <div class="card mb-5" id="account">
<header class="card-header has-background-danger"> <header class="card-header has-background-warning">
<p class="card-header-title has-text-light"> <p class="card-header-title">
<i class="fa fa-gear pr-2"></i> <i class="fa fa-gear pr-2"></i>
Account Settings Account Settings
</p> </p>
</header> </header>
<div class="card-content"> <div class="card-content">
<div class="field">
<label class="label" for="old_password">
Current Password
</label>
<input type="password" class="input"
name="old_password"
id="old_password"
placeholder="Current password"
required>
<p class="help">
Enter your current password before making any changes to your
email address or setting a new password.
</p>
</div>
<div class="field"> <div class="field">
<label class="label" for="change_email">Change Email</label> <label class="label" for="change_email">Change Email</label>
<input type="email" class="input" <input type="email" class="input"
id="change_email" id="change_email"
name="change_email" name="change_email"
placeholder="name@domain.com"> placeholder="name@domain.com"
value="{{.CurrentUser.Email}}">
</div> </div>
<div class="field"> <div class="field">
<label class="label">Change Password</label> <label class="label">Change Password</label>
<input type="password" class="input mb-2"
name="old_password"
placeholder="Current password">
<input type="password" class="input mb-2" <input type="password" class="input mb-2"
name="new_password" name="new_password"
placeholder="New password"> placeholder="New password">
@ -300,6 +299,28 @@
</div> </div>
</form> </form>
<!-- Delete Account -->
<div class="card mb-5" id="account">
<header class="card-header has-background-danger">
<p class="card-header-title has-text-light">
<i class="fa fa-gear pr-2"></i>
Delete Account
</p>
</header>
<div class="card-content">
<p class="block">
If you would like to delete your account, please click
on the button below.
</p>
<p class="block">
<a href="/account/delete" class="button is-danger">
Delete My Account
</a>
</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,3 +1,4 @@
{{define "title"}}Admin Dashboard{{end}}
{{define "content"}} {{define "content"}}
<div class="container"> <div class="container">
<section class="hero is-danger is-bold"> <section class="hero is-danger is-bold">

View File

@ -123,6 +123,8 @@
<div class="navbar-dropdown is-right"> <div class="navbar-dropdown is-right">
<a class="navbar-item" href="/me">Dashboard</a> <a class="navbar-item" href="/me">Dashboard</a>
<a class="navbar-item" href="/u/{{.CurrentUser.Username}}">My Profile</a> <a class="navbar-item" href="/u/{{.CurrentUser.Username}}">My Profile</a>
<a class="navbar-item" href="/photo/u/{{.CurrentUser.Username}}">My Photos</a>
<a class="navbar-item" href="/photo/upload">Upload Photo</a>
<a class="navbar-item" href="/settings">Settings</a> <a class="navbar-item" href="/settings">Settings</a>
{{if .CurrentUser.IsAdmin}} {{if .CurrentUser.IsAdmin}}
<a class="navbar-item has-text-danger" href="/admin">Admin</a> <a class="navbar-item has-text-danger" href="/admin">Admin</a>

View File

@ -0,0 +1,25 @@
{{define "content"}}
<html>
<body bakground="#ffffff" color="#000000" link="#0000FF" vlink="#990099" alink="#FF0000">
<basefont face="Arial,Helvetica,sans-serif" size="3" color="#000000"></basefont>
<h1>Reset your password</h1>
<p>Dear {{.Data.Username}},</p>
<p>
Somebody (hopefully you) has requested a password change to your account. To set a new
password, please visit the link below. If you did not request this password reset, please
notify support.
</p>
<p>
<a href="{{.Data.URL}}" target="_blank">{{.Data.URL}}</a>
</p>
<p>
This is an automated e-mail; do not reply to this message.
</p>
</body>
</html>
{{end}}

View File

@ -5,10 +5,17 @@
<h1>Verify your email</h1> <h1>Verify your email</h1>
{{if .Data.ChangeEmail}}
<p>
Somebody (hopefully you) has requested an e-mail change on your account. To verify your
new e-mail address, please click on the link below.
</p>
{{else}}
<p> <p>
Welcome to {{.Data.Title}}! To get started creating your account, verify your e-mail address Welcome to {{.Data.Title}}! To get started creating your account, verify your e-mail address
by clicking on the link below: by clicking on the link below:
</p> </p>
{{end}}
<p> <p>
<a href="{{.Data.URL}}" target="_blank">{{.Data.URL}}</a> <a href="{{.Data.URL}}" target="_blank">{{.Data.URL}}</a>

View File

@ -1,10 +1,10 @@
{{define "title"}}A social network for real nudists and exhibitionists{{end}} {{define "title"}}A social network for real nudists and exhibitionists{{end}}
{{define "content"}} {{define "content"}}
<div class="block"> <div class="block">
<section class="hero is-info is-bold"> <section class="hero is-light is-bold">
<div class="hero-body"> <div class="hero-body">
<div class="container"> <div class="container">
<h1 class="title">{{ .Title }}</h1> <h1 class="title">{{ PrettyTitle }}</h1>
<h2 class="subtitle">{{ .Subtitle }}</h2> <h2 class="subtitle">{{ .Subtitle }}</h2>
</div> </div>
</div> </div>