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
4adffe9fa9
commit
49ffa277e8
|
@ -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 (
|
||||||
|
|
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"
|
"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")
|
||||||
|
|
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 (
|
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 {
|
||||||
|
|
|
@ -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, "/")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
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
|
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 {
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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
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("/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()))
|
||||||
|
|
|
@ -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
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"}}
|
{{define "content"}}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<section class="hero is-info is-bold">
|
<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>
|
</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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
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>
|
<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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user