diff --git a/database/user.go b/database/user.go index b90c655..f10c354 100644 --- a/database/user.go +++ b/database/user.go @@ -92,6 +92,42 @@ func (u *User) NewAuthToken() (string, error) { return fmt.Sprintf("AQAT:%d|%s|%d|", u.Uid, newToken, checkValue), nil } +/* ConfirmEMailAddress checks the E-mail confirmation number and sets "verified" status if it's OK. + * Parameters: + * confnum - The entered confirmation number. + * remoteIP - The remote IP address for audit messages. + * Returns: + * Standard Go error status. + */ +func (u *User) ConfirmEMailAddress(confnum int32, remoteIP string) error { + var ar *AuditRecord = nil + defer func() { + AmStoreAudit(ar) + }() + + log.Debugf("ConfirmEMailAddress for UID %d", u.Uid) + u.Mutex.Lock() + defer u.Mutex.Unlock() + if u.VerifyEMail || AmTestPermission("Global.NoEmailVerify", u.BaseLevel) { + log.Debug("...user has either already confirmed or is exempt") + return nil + } + if confnum != u.EmailConfNum { + log.Warn("...confirmation number incorrect") + ar = AmNewAudit(AuditVerifyEmailFail, u.Uid, remoteIP, "Invalid confirmation number") + return errors.New("confirmation number is incorrect. Please try again") + } + _, err := amdb.Exec("UPDATE users SET verify_email = 1, base_lvl = ? WHERE uid = ?", + AmDefaultRole("Global.AfterVerify").Level(), u.Uid) + if err == nil { + u.VerifyEMail = true + u.BaseLevel = AmDefaultRole("Global.AfterVerify").Level() + // TODO: auto-join communities if necessary + ar = AmNewAudit(AuditVerifyEmailOK, u.Uid, remoteIP) + } + return err +} + /* AmGetUser returns a reference to the specified user. * Parameters: * uid - The UID of the user. diff --git a/login.go b/login.go index e2e90d7..e7c3097 100644 --- a/login.go +++ b/login.go @@ -12,6 +12,7 @@ package main import ( "errors" "fmt" + "net/url" "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/email" @@ -118,8 +119,11 @@ func Login(ctxt ui.AmContext) (string, any, error) { log.Errorf("unable to generate auth string for uid %d: %v", user.Uid, cerr) } } - // TODO: bounce to E-mail verify if we can do so - return "redirect", target, nil + if user.VerifyEMail { + return "redirect", target, nil + } else { + return "redirect", "/verify?tgt=" + url.PathEscape(target), nil + } } dlg.Field("pass").Value = "" return dlg.RenderError(ctxt, "No known button click on POST to login function.") @@ -149,6 +153,100 @@ func Logout(ctxt ui.AmContext) (string, any, error) { return "redirect", target, nil } +/* VerifyEmailForm renders the E-mail address verification form. + * Parameters: + * ctxt - The AmContext for the request. + * Returns: + * Command string dictating what to be rendered. + * Data as a parameter for the command string. + * Standard Go error status. + */ +func VerifyEmailForm(ctxt ui.AmContext) (string, any, error) { + // Get target URI. + target := ctxt.Parameter("tgt") + if target == "" { + target = "/" + } + + // If user is not logged in, this is an error. + user := ctxt.CurrentUser() + if !user.IsAnon { + return ui.ErrorPage(ctxt, errors.New("you must log in before you can verify your account's E-mail address")) + } + + // If user is already verified, this is a no-op. + if user.VerifyEMail { + return "redirect", target, nil + } + + dlg, err := ui.AmLoadDialog("verify_email") + if err == nil { + dlg.Field("tgt").Value = target + return dlg.Render(ctxt) + } + return ui.ErrorPage(ctxt, err) +} + +func VerifyEMail(ctxt ui.AmContext) (string, any, error) { + // If user is not logged in, this is an error. + user := ctxt.CurrentUser() + if !user.IsAnon { + return ui.ErrorPage(ctxt, errors.New("you must log in before you can verify your account's E-mail address")) + } + + dlg, err := ui.AmLoadDialog("verify_email") + if err == nil { + dlg.LoadFromForm(ctxt) + target := dlg.Field("tgt").Value + if target == "" { + target = "/" + } + + // If user is already verified, this is a no-op. + if user.VerifyEMail { + return "redirect", target, nil + } + + action := dlg.WhichButton(ctxt) + if action == "cancel" { // Cancel button pressed + return "redirect", target, nil + } + if action == "sendagain" { + var ci *database.ContactInfo + ci, err = user.ContactInfo() + if err == nil { + if ci != nil && ci.Email != nil && *ci.Email != "" { + msg := email.AmNewEmailMessage(user.Uid, ctxt.RemoteIP()) + msg.AddTo(*ci.Email, "") + msg.SetTemplate("verify_email.jet") + msg.AddVariable("username", user.Username) + msg.AddVariable("confnum", user.EmailConfNum) + msg.Send() + } else { + err = errors.New("cannot find email address") + } + } + if err == nil { + return dlg.RenderInfo(ctxt, "Verification message has been sent to your E-mail address.") + } else { + return dlg.RenderError(ctxt, err.Error()) + } + } + if action == "ok" { + err = dlg.Validate() + if err == nil { + err = user.ConfirmEMailAddress(dlg.Field("num").AuxData.(int32), ctxt.RemoteIP()) + if err == nil { + return "redirect", target, nil + } + } + return dlg.RenderError(ctxt, err.Error()) + } + return dlg.RenderError(ctxt, "No known button click on POST to verify function.") + } + return ui.ErrorPage(ctxt, err) +} + /* NewAccountUserAgreement renders the Amsterdam user agreement for new accounts. * Parameters: * ctxt - The AmContext for the request. @@ -166,7 +264,7 @@ func NewAccountUserAgreement(ctxt ui.AmContext) (string, any, error) { // If user is already logged in, this is an error. if !ctxt.CurrentUser().IsAnon { - return ui.ErrorPage(ctxt, fmt.Errorf("you cannot create a new account while logged in on an existing one. You must log out first")) + return ui.ErrorPage(ctxt, errors.New("you cannot create a new account while logged in on an existing one. You must log out first")) } ctxt.VarMap().Set("target", target) diff --git a/main.go b/main.go index c328e30..e43531e 100644 --- a/main.go +++ b/main.go @@ -48,6 +48,8 @@ func setupEcho() *echo.Echo { e.GET("/logout", ui.AmWrap(Logout)) e.GET("/newacct", ui.AmWrap(NewAccountUserAgreement)) e.GET("/newacct2", ui.AmWrap(NewAccountForm)) + e.GET("/verify", ui.AmWrap(VerifyEmailForm)) + e.POST("/verify", ui.AmWrap(VerifyEMail)) return e } diff --git a/ui/dialog.go b/ui/dialog.go index e03503b..bd65126 100644 --- a/ui/dialog.go +++ b/ui/dialog.go @@ -14,6 +14,8 @@ import ( "embed" "fmt" "net/mail" + "strconv" + "strings" "git.erbosoft.com/amy/amsterdam/database" "gopkg.in/yaml.v3" @@ -95,6 +97,17 @@ func (fld *DialogItem) IsChecked() bool { return false } +// ValueRange returns the minimum and maximum values for an integer field. +func (fld *DialogItem) ValueRange() (int, int) { + if fld.Type == "integer" && fld.Param != "" { + parms := strings.Split(fld.Param, "-") + low, _ := strconv.Atoi(parms[0]) + high, _ := strconv.Atoi(parms[1]) + return low, high + } + return -1, -1 +} + /* Field returns a pointer to a dialog's field, given its name. * Parameters: * name - The name of the field to find. @@ -259,6 +272,34 @@ func validateTextField(fld *DialogItem) error { return nil } +/* validateIntegerField validates an integer field. + * Parameters: + * fld - The field to be validated. + * Returns: + * Standard Go error status. + */ +func validateIntegerField(fld *DialogItem) error { + err := validateTextField(fld) + if err == nil { + var v int + v, err = strconv.Atoi(fld.Value) + if err == nil { + fld.AuxData = v // cache parsed value + lo, hi := fld.ValueRange() + if lo != -1 && hi != -1 { + if v < lo { + return fmt.Errorf("value of field \"%s\" cannot be less than %d", fld.Caption, lo) + } else if v > hi { + return fmt.Errorf("value of field \"%s\" cannot be greater than %d", fld.Caption, hi) + } + } + } else { + return fmt.Errorf("value of field \"%s\" is not a valid integer", fld.Caption) + } + } + return nil +} + /* validateAmsIdField validates an Amsterdam ID field. * Parameters: * fld - The field to be validated. @@ -339,6 +380,7 @@ var validators = map[string]validatorFunc{ "email": validateEmailField, "header": nilValidator, "hidden": nilValidator, + "integer": validateIntegerField, "password": validateTextField, "text": validateTextField, } diff --git a/ui/dialogs/verify_email.yaml b/ui/dialogs/verify_email.yaml new file mode 100644 index 0000000..744df85 --- /dev/null +++ b/ui/dialogs/verify_email.yaml @@ -0,0 +1,39 @@ +# +# Amsterdam Web Communities System +# Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# +name: "verify" +formName: "verifyform" +menuSelector: "top" +title: "Verify E-mail Address" +action: "/verify" +instructions: > + Check your E-mail, then enter the confirmation number that was E-mailed to you in the field below. + (The E-mail might take a few minutes or so to get to you, so be patient.) Once you do so, your account + will be fully validated. If you have not received your confirmation within a few minutes, click on the + Send Again button below. +fields: + - type: "hidden" + name: "tgt" + value: "" + - type: "integer" + name: "num" + caption: "Confirmation Number" + size: 7 + param: "1000000-9999999" + - type: "button" + name: "ok" + caption: "OK" + param: "blue" + - type: "button" + name: "sendagain" + caption: "Send Again" + param: "gray" + - type: "button" + name: "cancel" + caption: "Cancel" + param: "red" diff --git a/ui/render_wrap.go b/ui/render_wrap.go index 14f78f8..edd72eb 100644 --- a/ui/render_wrap.go +++ b/ui/render_wrap.go @@ -13,6 +13,7 @@ package ui import ( "fmt" "net/http" + "net/url" "git.erbosoft.com/amy/amsterdam/config" "git.erbosoft.com/amy/amsterdam/database" @@ -99,6 +100,11 @@ func AmWrap(myfunc func(AmContext) (string, any, error)) echo.HandlerFunc { } else { log.Warnf("unable to rotate login cookie: %v", cerr) } + if !user.VerifyEMail { + // bounce to E-mail verification before we go anywhere + return sendPageData(ctxt, amctxt, "redirect", + "/verify?tgt="+url.PathEscape(ctxt.Request().URL.Path)) + } } else { log.Errorf("login cookie bogus, do not use: %v", cerr) amctxt.ClearLoginCookie() diff --git a/ui/views/dialog.jet b/ui/views/dialog.jet index 7e89e11..d93aeb1 100644 --- a/ui/views/dialog.jet +++ b/ui/views/dialog.jet @@ -67,6 +67,22 @@ value="{{ .Value }}" class="flex-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> + {{ else if .Type == "integer" }} +