Merge pull request #777 from writefreely/reset-password
Support resetting password via email Closes T508
This commit is contained in:
commit
e3323d11c8
160
account.go
160
account.go
|
@ -14,6 +14,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/mailgun/mailgun-go"
|
"github.com/mailgun/mailgun-go"
|
||||||
|
"github.com/writefreely/writefreely/spam"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
@ -324,6 +325,7 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
To string
|
To string
|
||||||
Message template.HTML
|
Message template.HTML
|
||||||
Flashes []template.HTML
|
Flashes []template.HTML
|
||||||
|
EmailEnabled bool
|
||||||
LoginUsername string
|
LoginUsername string
|
||||||
}{
|
}{
|
||||||
StaticPage: pageForReq(app, r),
|
StaticPage: pageForReq(app, r),
|
||||||
|
@ -331,6 +333,7 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
To: r.FormValue("to"),
|
To: r.FormValue("to"),
|
||||||
Message: template.HTML(""),
|
Message: template.HTML(""),
|
||||||
Flashes: []template.HTML{},
|
Flashes: []template.HTML{},
|
||||||
|
EmailEnabled: app.cfg.Email.Enabled(),
|
||||||
LoginUsername: getTempInfo(app, "login-user", r, w),
|
LoginUsername: getTempInfo(app, "login-user", r, w),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1238,6 +1241,163 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func viewResetPassword(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
token := r.FormValue("t")
|
||||||
|
resetting := false
|
||||||
|
var userID int64 = 0
|
||||||
|
if token != "" {
|
||||||
|
// Show new password page
|
||||||
|
userID = app.db.GetUserFromPasswordReset(token)
|
||||||
|
if userID == 0 {
|
||||||
|
return impart.HTTPError{http.StatusNotFound, ""}
|
||||||
|
}
|
||||||
|
resetting = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
newPass := r.FormValue("new-pass")
|
||||||
|
if newPass == "" {
|
||||||
|
// Send password reset email
|
||||||
|
return handleResetPasswordInit(app, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do actual password reset
|
||||||
|
// Assumes token has been validated above
|
||||||
|
err := doAutomatedPasswordChange(app, userID, newPass)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = app.db.ConsumePasswordResetToken(token)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't consume token %s for user %d!!! %s", token, userID, err)
|
||||||
|
}
|
||||||
|
addSessionFlash(app, w, r, "Your password was reset. Now you can log in below.", nil)
|
||||||
|
return impart.HTTPError{http.StatusFound, "/login"}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _ := getSessionFlashes(app, w, r, nil)
|
||||||
|
|
||||||
|
// Show reset password page
|
||||||
|
d := struct {
|
||||||
|
page.StaticPage
|
||||||
|
Flashes []string
|
||||||
|
EmailEnabled bool
|
||||||
|
CSRFField template.HTML
|
||||||
|
Token string
|
||||||
|
IsResetting bool
|
||||||
|
IsSent bool
|
||||||
|
}{
|
||||||
|
StaticPage: pageForReq(app, r),
|
||||||
|
Flashes: f,
|
||||||
|
EmailEnabled: app.cfg.Email.Enabled(),
|
||||||
|
CSRFField: csrf.TemplateField(r),
|
||||||
|
Token: token,
|
||||||
|
IsResetting: resetting,
|
||||||
|
IsSent: r.FormValue("sent") == "1",
|
||||||
|
}
|
||||||
|
err := pages["reset.tmpl"].ExecuteTemplate(w, "base", d)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to render password reset page: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func doAutomatedPasswordChange(app *App, userID int64, newPass string) error {
|
||||||
|
// Do password reset
|
||||||
|
hashedPass, err := auth.HashPass([]byte(newPass))
|
||||||
|
if err != nil {
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do update
|
||||||
|
err = app.db.ChangePassphrase(userID, true, "", hashedPass)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleResetPasswordInit(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
returnLoc := impart.HTTPError{http.StatusFound, "/reset"}
|
||||||
|
|
||||||
|
if !app.cfg.Email.Enabled() {
|
||||||
|
// Email isn't configured, so there's nothing to do; send back to the reset form, where they'll get an explanation
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := spam.GetIP(r)
|
||||||
|
alias := r.FormValue("alias")
|
||||||
|
|
||||||
|
u, err := app.db.GetUserForAuth(alias)
|
||||||
|
if err != nil {
|
||||||
|
if strings.IndexAny(alias, "@") > 0 {
|
||||||
|
addSessionFlash(app, w, r, ErrUserNotFoundEmail.Message, nil)
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
addSessionFlash(app, w, r, ErrUserNotFound.Message, nil)
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
if u.IsAdmin() {
|
||||||
|
// Prevent any reset emails on admin accounts
|
||||||
|
log.Error("Admin reset attempt", `Someone just tried to reset the password for an admin (ID %d - %s). IP address: %s`, u.ID, u.Username, ip)
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
if u.Email.String == "" {
|
||||||
|
err := impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Please contact us (" + app.cfg.App.Host + "/contact) to reset your password."}
|
||||||
|
addSessionFlash(app, w, r, err.Message, nil)
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
if isSet, _ := app.db.IsUserPassSet(u.ID); !isSet {
|
||||||
|
err = loginViaEmail(app, u.Username, "/me/settings")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
addSessionFlash(app, w, r, "We've emailed you a link to log in with.", nil)
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := app.db.CreatePasswordResetToken(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error resetting password: %s", err)
|
||||||
|
addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil)
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
|
||||||
|
err = emailPasswordReset(app, u.EmailClear(app.keys), token)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error emailing password reset: %s", err)
|
||||||
|
addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil)
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
|
||||||
|
addSessionFlash(app, w, r, "We sent an email to the address associated with this account.", nil)
|
||||||
|
returnLoc.Message += "?sent=1"
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
|
||||||
|
func emailPasswordReset(app *App, toEmail, token string) error {
|
||||||
|
// Send email
|
||||||
|
gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
|
||||||
|
footerPara := "Didn't request this password reset? Your account is still safe, and you can safely ignore this email."
|
||||||
|
|
||||||
|
plainMsg := fmt.Sprintf("We received a request to reset your password on %s. Please click the following link to continue (or copy and paste it into your browser): %s/reset?t=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara)
|
||||||
|
m := mailgun.NewMessage(app.cfg.App.SiteName+" <noreply-password@"+app.cfg.Email.Domain+">", "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail))
|
||||||
|
m.AddTag("Password Reset")
|
||||||
|
m.SetHtml(fmt.Sprintf(`<html>
|
||||||
|
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||||
|
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
|
||||||
|
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
|
||||||
|
<p>We received a request to reset your password on %s. Please click the following link to continue:</p>
|
||||||
|
<p style="font-size:1.2em;margin-bottom:1.5em;"><a href="%s/reset?t=%s">Reset your password</a></p>
|
||||||
|
<p style="font-size: 0.86em;margin:1em auto">%s</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara))
|
||||||
|
_, _, err := gun.Send(m)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func loginViaEmail(app *App, alias, redirectTo string) error {
|
func loginViaEmail(app *App, alias, redirectTo string) error {
|
||||||
if !app.cfg.Email.Enabled() {
|
if !app.cfg.Email.Enabled() {
|
||||||
return fmt.Errorf("EMAIL ISN'T CONFIGURED on this server")
|
return fmt.Errorf("EMAIL ISN'T CONFIGURED on this server")
|
||||||
|
|
31
database.go
31
database.go
|
@ -586,6 +586,37 @@ func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int,
|
||||||
return u.String(), nil
|
return u.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) CreatePasswordResetToken(userID int64) (string, error) {
|
||||||
|
t := id.Generate62RandomString(32)
|
||||||
|
|
||||||
|
_, err := db.Exec("INSERT INTO password_resets (user_id, token, used, created) VALUES (?, ?, 0, "+db.now()+")", userID, t)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't INSERT password_resets: %v", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) GetUserFromPasswordReset(token string) int64 {
|
||||||
|
var userID int64
|
||||||
|
err := db.QueryRow("SELECT user_id FROM password_resets WHERE token = ? AND used = 0 AND created > "+db.dateSub(3, "HOUR"), token).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return userID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) ConsumePasswordResetToken(t string) error {
|
||||||
|
_, err := db.Exec("UPDATE password_resets SET used = 1 WHERE token = ?", t)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't UPDATE password_resets: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName string) (*PublicPost, error) {
|
func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName string) (*PublicPost, error) {
|
||||||
var userID, collID int64 = -1, -1
|
var userID, collID int64 = -1, -1
|
||||||
var coll *Collection
|
var coll *Collection
|
||||||
|
|
|
@ -831,6 +831,9 @@ input {
|
||||||
margin: 0 auto 3em;
|
margin: 0 auto 3em;
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
|
|
||||||
|
&.toosmall {
|
||||||
|
max-width: 25em;
|
||||||
|
}
|
||||||
&.tight {
|
&.tight {
|
||||||
max-width: 30em;
|
max-width: 30em;
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,6 +61,13 @@ func (db *datastore) typeVarChar(l int) string {
|
||||||
return fmt.Sprintf("VARCHAR(%d)", l)
|
return fmt.Sprintf("VARCHAR(%d)", l)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) typeVarBinary(l int) string {
|
||||||
|
if db.driverName == driverSQLite {
|
||||||
|
return "BLOB"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("VARBINARY(%d)", l)
|
||||||
|
}
|
||||||
|
|
||||||
func (db *datastore) typeBool() string {
|
func (db *datastore) typeBool() string {
|
||||||
if db.driverName == driverSQLite {
|
if db.driverName == driverSQLite {
|
||||||
return "INTEGER"
|
return "INTEGER"
|
||||||
|
|
|
@ -69,6 +69,7 @@ var migrations = []Migration{
|
||||||
New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11
|
New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11
|
||||||
New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0)
|
New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0)
|
||||||
New("support newsletters", supportLetters), // V12 -> V13
|
New("support newsletters", supportLetters), // V12 -> V13
|
||||||
|
New("support password resetting", supportPassReset), // V13 -> V14
|
||||||
}
|
}
|
||||||
|
|
||||||
// CurrentVer returns the current migration version the application is on
|
// CurrentVer returns the current migration version the application is on
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2023 Musing Studio LLC.
|
||||||
|
*
|
||||||
|
* This file is part of WriteFreely.
|
||||||
|
*
|
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, included
|
||||||
|
* in the LICENSE file in this source code package.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
func supportPassReset(db *datastore) error {
|
||||||
|
t, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = t.Exec(`CREATE TABLE password_resets (
|
||||||
|
user_id ` + db.typeInt() + ` not null,
|
||||||
|
token ` + db.typeChar(32) + ` not null primary key,
|
||||||
|
used ` + db.typeBool() + ` default 0 not null,
|
||||||
|
created ` + db.typeDateTime() + ` not null
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = t.Commit()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -3,6 +3,12 @@
|
||||||
<meta itemprop="description" content="Log into {{.SiteName}}.">
|
<meta itemprop="description" content="Log into {{.SiteName}}.">
|
||||||
<style>
|
<style>
|
||||||
input{margin-bottom:0.5em;}
|
input{margin-bottom:0.5em;}
|
||||||
|
p.forgot {
|
||||||
|
font-size: 0.8em;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
max-width: 16rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
@ -19,6 +25,7 @@ input{margin-bottom:0.5em;}
|
||||||
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
|
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
|
||||||
<input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br />
|
<input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br />
|
||||||
<input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />
|
<input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />
|
||||||
|
{{if .EmailEnabled}}<p class="forgot"><a href="/reset">Forgot password?</a></p>{{end}}
|
||||||
{{if .To}}<input type="hidden" name="to" value="{{.To}}" />{{end}}
|
{{if .To}}<input type="hidden" name="to" value="{{.To}}" />{{end}}
|
||||||
<input type="submit" id="btn-login" value="Login" />
|
<input type="submit" id="btn-login" value="Login" />
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
{{define "head"}}<title>Reset password — {{.SiteName}}</title>
|
||||||
|
<style>
|
||||||
|
input {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="toosmall content-container clean">
|
||||||
|
<h1>Reset your password</h1>
|
||||||
|
|
||||||
|
{{ if .DisablePasswordAuth }}
|
||||||
|
<div class="alert info">
|
||||||
|
<p><strong>Password login is disabled on this server</strong>, so it's not possible to reset your password.</p>
|
||||||
|
</div>
|
||||||
|
{{ else if not .EmailEnabled }}
|
||||||
|
<div class="alert info">
|
||||||
|
<p><strong>Email is not configured on this server!</strong> Please <a href="/contact">contact your admin</a> to reset your password.</p>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
{{if .Flashes}}<ul class="errors">
|
||||||
|
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||||
|
</ul>{{end}}
|
||||||
|
|
||||||
|
{{if .IsResetting}}
|
||||||
|
<form method="post" action="/reset" onsubmit="disableSubmit()">
|
||||||
|
<label>
|
||||||
|
<p>New Password</p>
|
||||||
|
<input type="password" name="new-pass" autocomplete="new-password" placeholder="New password" tabindex="1" />
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="t" value="{{.Token}}" />
|
||||||
|
<input type="submit" id="btn-login" value="Reset Password" />
|
||||||
|
{{ .CSRFField }}
|
||||||
|
</form>
|
||||||
|
{{else if not .IsSent}}
|
||||||
|
<form action="/reset" method="post" onsubmit="disableSubmit()">
|
||||||
|
<label>
|
||||||
|
<p>Username</p>
|
||||||
|
<input type="text" name="alias" placeholder="Username" autofocus />
|
||||||
|
</label>
|
||||||
|
{{ .CSRFField }}
|
||||||
|
<input type="submit" id="btn-login" value="Reset Password" />
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var $btn = document.getElementById("btn-login");
|
||||||
|
function disableSubmit() {
|
||||||
|
$btn.disabled = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
||||||
|
{{end}}
|
|
@ -184,6 +184,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET")
|
write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET")
|
||||||
|
|
||||||
// Handle special pages first
|
// Handle special pages first
|
||||||
|
write.Path("/reset").Handler(csrf.Protect(apper.App().keys.CSRFKey)(handler.Web(viewResetPassword, UserLevelNoneRequired)))
|
||||||
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
|
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
|
||||||
write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired))
|
write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired))
|
||||||
write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET")
|
write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET")
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2023 Musing Studio LLC.
|
||||||
|
*
|
||||||
|
* This file is part of WriteFreely.
|
||||||
|
*
|
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, included
|
||||||
|
* in the LICENSE file in this source code package.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package spam
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetIP(r *http.Request) string {
|
||||||
|
h := r.Header.Get("X-Forwarded-For")
|
||||||
|
if h == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ips := strings.Split(h, ",")
|
||||||
|
return strings.TrimSpace(ips[0])
|
||||||
|
}
|
Loading…
Reference in New Issue