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:
parent
4ad7e00c44
commit
0caf12eb00
|
@ -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 (
|
||||
|
|
52
pkg/controller/account/delete.go
Normal file
52
pkg/controller/account/delete.go
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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")
|
||||
|
|
161
pkg/controller/account/reset_password.go
Normal file
161
pkg/controller/account/reset_password.go
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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, "/")
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
123
pkg/models/deletion/delete_user.go
Normal file
123
pkg/models/deletion/delete_user.go
Normal 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
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
104
pkg/ratelimit/ratelimit.go
Normal file
104
pkg/ratelimit/ratelimit.go
Normal 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)
|
||||
}
|
|
@ -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()))
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
42
pkg/utility/time.go
Normal file
42
pkg/utility/time.go
Normal 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)
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
{{define "title"}}My Dashboard{{end}}
|
||||
{{define "content"}}
|
||||
<div class="container">
|
||||
<section class="hero is-info is-bold">
|
||||
|
|
72
web/templates/account/delete.html
Normal file
72
web/templates/account/delete.html
Normal 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}}
|
62
web/templates/account/forgot_password.html
Normal file
62
web/templates/account/forgot_password.html
Normal 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}}
|
|
@ -10,17 +10,26 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<div class="block p-2">
|
||||
<div class="block p-4">
|
||||
<form action="/login" method="POST">
|
||||
{{ InputCSRF }}
|
||||
|
||||
<label for="username">Username or email:</label>
|
||||
<input type="text" class="input" name="username" placeholder="username" autocomplete="off">
|
||||
<div class="field">
|
||||
<label class="label" for="username">Username or email:</label>
|
||||
<input type="text" class="input" name="username" placeholder="username" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" class="input" name="password" placeholder="password">
|
||||
<div class="field">
|
||||
<label class="label" for="password">Password:</label>
|
||||
<input type="password" class="input" name="password" placeholder="password">
|
||||
<p class="help">
|
||||
<a href="/forgot-password">Forgot?</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button is-primary">Log in</button>
|
||||
<div class="field">
|
||||
<button type="submit" class="button is-primary">Log in</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{{define "title"}}Friends{{end}}
|
||||
{{define "title"}}People{{end}}
|
||||
{{define "content"}}
|
||||
<div class="container">
|
||||
{{$Root := .}}
|
||||
|
@ -66,6 +66,32 @@
|
|||
</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="field">
|
||||
<label class="label" for="gender">Gender:</label>
|
||||
|
@ -109,12 +135,27 @@
|
|||
</div>
|
||||
|
||||
</div>
|
||||
<div class="has-text-centered">
|
||||
<a href="/members" class="button">Reset</a>
|
||||
<button type="submit" class="button is-success">
|
||||
<span>Search</span>
|
||||
<span class="icon"><i class="fa fa-search"></i></span>
|
||||
</button>
|
||||
<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>
|
||||
<button type="submit" class="button is-success">
|
||||
<span>Search</span>
|
||||
<span class="icon"><i class="fa fa-search"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -204,25 +204,13 @@
|
|||
</div>
|
||||
|
||||
<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 -->
|
||||
<form method="POST" action="/settings">
|
||||
<input type="hidden" name="intent" value="preferences">
|
||||
{{InputCSRF}}
|
||||
|
||||
<div class="card block" id="prefs">
|
||||
<div class="card mb-5" id="prefs">
|
||||
<header class="card-header has-background-success">
|
||||
<p class="card-header-title">
|
||||
<i class="fa fa-square-check pr-2"></i>
|
||||
|
@ -260,29 +248,40 @@
|
|||
<input type="hidden" name="intent" value="settings">
|
||||
{{InputCSRF}}
|
||||
|
||||
<div class="card block" id="account">
|
||||
<header class="card-header has-background-danger">
|
||||
<p class="card-header-title has-text-light">
|
||||
<div class="card mb-5" id="account">
|
||||
<header class="card-header has-background-warning">
|
||||
<p class="card-header-title">
|
||||
<i class="fa fa-gear pr-2"></i>
|
||||
Account Settings
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
||||
<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">
|
||||
<label class="label" for="change_email">Change Email</label>
|
||||
<input type="email" class="input"
|
||||
id="change_email"
|
||||
name="change_email"
|
||||
placeholder="name@domain.com">
|
||||
placeholder="name@domain.com"
|
||||
value="{{.CurrentUser.Email}}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<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"
|
||||
name="new_password"
|
||||
placeholder="New password">
|
||||
|
@ -300,6 +299,28 @@
|
|||
</div>
|
||||
</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>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
{{define "title"}}Admin Dashboard{{end}}
|
||||
{{define "content"}}
|
||||
<div class="container">
|
||||
<section class="hero is-danger is-bold">
|
||||
|
|
|
@ -123,6 +123,8 @@
|
|||
<div class="navbar-dropdown is-right">
|
||||
<a class="navbar-item" href="/me">Dashboard</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>
|
||||
{{if .CurrentUser.IsAdmin}}
|
||||
<a class="navbar-item has-text-danger" href="/admin">Admin</a>
|
||||
|
|
25
web/templates/email/reset_password.html
Normal file
25
web/templates/email/reset_password.html
Normal 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}}
|
|
@ -5,10 +5,17 @@
|
|||
|
||||
<h1>Verify your email</h1>
|
||||
|
||||
{{if .Data.ChangeEmail}}
|
||||
<p>
|
||||
Welcome to {{.Data.Title}}! To get started creating your account, verify your e-mail address
|
||||
by clicking on the link below:
|
||||
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>
|
||||
Welcome to {{.Data.Title}}! To get started creating your account, verify your e-mail address
|
||||
by clicking on the link below:
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<p>
|
||||
<a href="{{.Data.URL}}" target="_blank">{{.Data.URL}}</a>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
{{define "title"}}A social network for real nudists and exhibitionists{{end}}
|
||||
{{define "content"}}
|
||||
<div class="block">
|
||||
<section class="hero is-info is-bold">
|
||||
<section class="hero is-light is-bold">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">{{ .Title }}</h1>
|
||||
<h1 class="title">{{ PrettyTitle }}</h1>
|
||||
<h2 class="subtitle">{{ .Subtitle }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
|
Reference in New Issue
Block a user