diff --git a/database/ipban.go b/database/ipban.go new file mode 100644 index 0000000..8d6284d --- /dev/null +++ b/database/ipban.go @@ -0,0 +1,81 @@ +/* + * 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/. + */ +// The database package contains database management and storage logic. +package database + +import ( + "fmt" + "math/big" + "net" + "sync" + "time" +) + +// low64mask is bigint 0xFFFFFFFFFFFFFFFF, used in splitting large addresses. +var low64mask *big.Int + +// knownBans is a cache of known banned addresses. +var knownBans map[string]string + +// knownGood is a cache of known good IP addresses. +var knownGood map[string]bool + +// banMutex synchronizes access to our cache. +var banMutex sync.Mutex + +// init initializes the internals in this file. +func init() { + a := big.NewInt(1) + b := big.NewInt(0).Lsh(a, 64) + low64mask = big.NewInt(0).Sub(b, big.NewInt(1)) + knownBans = make(map[string]string) + knownGood = make(map[string]bool) +} + +/* AmTestIPBan tests an IP address to see if it's on the banned list. + * Parameters: + * ip_address - The IP address to be tested. + * Returns: + * Ban message if the address is banned, or empty string if it isn't. + * Standard Go error status. + */ +func AmTestIPBan(ip_address string) (string, error) { + banMutex.Lock() + defer banMutex.Unlock() + rc := knownBans[ip_address] + if rc != "" { + return rc, nil + } + if knownGood[ip_address] { + return "", nil + } + addr := net.ParseIP(ip_address) + if addr == nil { + return "", fmt.Errorf("invalid address %s", ip_address) + } + iv := big.NewInt(0) + iv.SetBytes(addr) + iv_lo := big.NewInt(0).And(iv, low64mask).Uint64() + iv_hi := big.NewInt(0).Rsh(iv, 64).Uint64() + rows, err := amdb.Query(` + SELECT message FROM ipban WHERE (address_lo & mask_lo) = (? & mask_lo) + AND (address_hi & mask_hi) = (? & mask_hi) AND (expire IS NULL OR expire >= ?) + AND enable <> 0 ORDER BY mask_hi DESC, mask_lo DESC`, iv_lo, iv_hi, time.Now()) + if err != nil { + return "", err + } + defer rows.Close() + if rows.Next() { + rows.Scan(&rc) + knownBans[ip_address] = rc + return rc, nil + } + knownGood[ip_address] = true + return "", nil +} diff --git a/database/validate.go b/database/validate.go new file mode 100644 index 0000000..71073aa --- /dev/null +++ b/database/validate.go @@ -0,0 +1,27 @@ +/* + * 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/. + */ +// The database package contains database management and storage logic. +package database + +import "strings" + +const AMS_ID_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_~*'$" + +// AmIsValidAmsterdamID returns true if the given string is a valid Amsterdam ID. +func AmIsValidAmsterdamID(test string) bool { + if len(test) < 1 { + return false + } + for _, r := range test { + if !strings.ContainsRune(AMS_ID_CHARS, r) { + return false + } + } + return true +} diff --git a/login.go b/login.go index cd4e384..66282d0 100644 --- a/login.go +++ b/login.go @@ -9,7 +9,11 @@ // Package main contains the high-level Amsterdam logic. package main -import "git.erbosoft.com/amy/amsterdam/ui" +import ( + "fmt" + + "git.erbosoft.com/amy/amsterdam/ui" +) /* LoginForm renders the Amsterdam login form. * Parameters: @@ -20,8 +24,20 @@ import "git.erbosoft.com/amy/amsterdam/ui" * Standard Go error status. */ func LoginForm(ctxt ui.AmContext) (string, any, error) { + // Get target URI. + target := ctxt.Parameter("tgt") + if target == "" { + target = "/" + } + + // If user is already logged in, this is a no-op. + if !ctxt.CurrentUser().IsAnon { + return "redirect", target, nil + } + dlg, err := ui.AmLoadDialog("login") if err == nil { + dlg.Field("tgt").Value = target ctxt.VarMap().Set("amsterdam_pageTitle", "Log In") return dlg.Render(ctxt) } @@ -37,6 +53,18 @@ func LoginForm(ctxt ui.AmContext) (string, any, error) { * Standard Go error status. */ func NewAccountUserAgreement(ctxt ui.AmContext) (string, any, error) { + // Get target URI. + target := ctxt.Parameter("tgt") + if target == "" { + target = "/" + } + + // If user is already logged in, this is an error. + if !ctxt.CurrentUser().IsAnon { + return ui.ErrorPage(ctxt, fmt.Errorf("You cannot create a bew account while logged in on an existing one. You must log out first.")) + } + + ctxt.VarMap().Set("target", target) ctxt.VarMap().Set("amsterdam_pageTitle", "New Account User Agreement") return "framed_template", "agreement.jet", nil } @@ -50,8 +78,20 @@ func NewAccountUserAgreement(ctxt ui.AmContext) (string, any, error) { * Standard Go error status. */ func NewAccountForm(ctxt ui.AmContext) (string, any, error) { + // Get target URI. + target := ctxt.Parameter("tgt") + if target == "" { + target = "/" + } + + // If user is already logged in, this is an error. + if !ctxt.CurrentUser().IsAnon { + return ui.ErrorPage(ctxt, fmt.Errorf("You cannot create a bew account while logged in on an existing one. You must log out first.")) + } + dlg, err := ui.AmLoadDialog("newaccount") if err == nil { + dlg.Field("tgt").Value = target dlg.Field("country").Value = "XX" ctxt.VarMap().Set("amsterdam_pageTitle", "Create New Account") return dlg.Render(ctxt) diff --git a/setup/database.sql b/setup/database.sql index 173dbbf..b3f2917 100644 --- a/setup/database.sql +++ b/setup/database.sql @@ -425,14 +425,16 @@ CREATE TABLE imagestore ( # Table listing IP addresses that are banned from logging in or registering. CREATE TABLE ipban ( id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, - address BIGINT NOT NULL, - mask BIGINT NOT NULL, + address_lo BIGINT UNSIGNED NOT NULL, + address_hi BIGINT UNSIGNED NOT NULL, + mask_lo BIGINT UNSIGNED NOT NULL, + mask_hi BIGINT UNSIGNED NOT NULL, enable TINYINT NOT NULL DEFAULT 1, expire DATETIME, message VARCHAR(255) NOT NULL, block_by INT NOT NULL, block_on DATETIME NOT NULL, - INDEX by_mask (mask), + INDEX by_mask (mask_hi, mask_lo), INDEX by_date (block_on) ); diff --git a/ui/amcontext.go b/ui/amcontext.go index e7b79cc..a734187 100644 --- a/ui/amcontext.go +++ b/ui/amcontext.go @@ -13,6 +13,7 @@ package ui import ( "bytes" "net/http" + "strconv" "git.erbosoft.com/amy/amsterdam/database" "github.com/CloudyKit/jet/v6" @@ -25,8 +26,11 @@ import ( // AmContext is the interface for Amsterdam's wrapper context that exposes the required functionality. type AmContext interface { CurrentUser() *database.User + FormField(string) string + FormFieldInt(string) (int, error) RC() int OutputType() string + Parameter(string) string Render(string) error SubRender(string) ([]byte, error) Session() *sessions.Session @@ -57,6 +61,27 @@ func (c *amContext) CurrentUser() *database.User { return u } +/* FormField returns the value of a form field from the request. + * Parameters: + * name - The name of the field to retrieve. + * Returns: + * The value given to that named field. + */ +func (c *amContext) FormField(name string) string { + return c.echoContext.FormValue(name) +} + +/* FormField returns the value of a form field from the request, as an integer. + * Parameters: + * name - The name of the field to retrieve. + * Returns: + * The value given to that named field. + * Standard Go error status. + */ +func (c *amContext) FormFieldInt(name string) (int, error) { + return strconv.Atoi(c.echoContext.FormValue(name)) +} + // RC returns the HTTP result code for the current operation. func (c *amContext) RC() int { return c.httprc @@ -67,6 +92,20 @@ func (c *amContext) OutputType() string { return c.outputType } +/* Parameter returns the value of a parameter (query parameter or form field) from the request. + * Parameters: + * name - The name of the field to retrieve. + * Returns: + * The value given to that named field. + */ +func (c *amContext) Parameter(name string) string { + rc := c.echoContext.QueryParam(name) + if rc == "" && c.echoContext.Request().Method == "POST" { + rc = c.echoContext.FormValue(name) + } + return rc +} + /* Render renders a template to the output. Called at the top level only. * Parameters: * name = The name of the tempate to be rendered. diff --git a/ui/dialog.go b/ui/dialog.go index 22d93fe..8a6b3a9 100644 --- a/ui/dialog.go +++ b/ui/dialog.go @@ -13,7 +13,9 @@ package ui import ( "embed" "fmt" + "net/mail" + "git.erbosoft.com/amy/amsterdam/database" "gopkg.in/yaml.v3" ) @@ -28,6 +30,7 @@ type DialogItem struct { MaxLength int `yaml:"maxlength,omitempty"` Value string `yaml:"value,omitempty"` Param string `yaml:"param,omitempty"` + AuxData any } // Dialog holds the dialog definition. @@ -72,6 +75,18 @@ func AmLoadDialog(name string) (*Dialog, error) { return nil, err } +// DateValues returns the date values stored in a date field. +func (fld *DialogItem) DateValues() []int { + if fld.Type == "date" && fld.AuxData != nil { + return fld.AuxData.([]int) + } + rc := make([]int, 3) + rc[0] = -1 + rc[1] = -1 + rc[2] = -1 + return rc +} + /* Field returns a pointer to a dialog's field, given its name. * Parameters: * name - The name of the field to find. @@ -107,3 +122,179 @@ func (d *Dialog) Render(ctxt AmContext) (string, any, error) { ctxt.VarMap().Set("amsterdam_dialog", d) return "framed_template", "dialog.jet", nil } + +/* LoadFromForm loads the values in a dialog from the form fields in the request. + * Parameters: + * ctxt - The AmContext for this request. + */ +func (d *Dialog) LoadFromForm(ctxt AmContext) { + for _, fld := range d.Fields { + if fld.Type == "date" { + fld.Value = "" + dvals := make([]int, 3) + var err error + dvals[0], err = ctxt.FormFieldInt(fmt.Sprintf("%s_month", fld.Name)) + if err != nil { + dvals[0] = -1 + fld.Value = fmt.Sprintf("!undefined month %s: %v", fld.Name, err) + } + dvals[1], err = ctxt.FormFieldInt(fmt.Sprintf("%s_day", fld.Name)) + if err != nil { + dvals[1] = -1 + if fld.Value == "" { + fld.Value = fmt.Sprintf("!undefined day %s: %v", fld.Name, err) + } + } + dvals[2], err = ctxt.FormFieldInt(fmt.Sprintf("%s_year", fld.Name)) + if err != nil { + dvals[2] = -1 + if fld.Value == "" { + fld.Value = fmt.Sprintf("!undefined year %s: %v", fld.Name, err) + } + } + if dvals[0] > 0 && dvals[1] > 0 && dvals[2] > 0 { + fld.Value = fmt.Sprintf("%04d%02d%02d", dvals[2], dvals[0], dvals[1]) + } else if fld.Value == "" && fld.Required { + if dvals[0] <= 0 { + fld.Value = fmt.Sprintf("!month not set %s", fld.Name) + } else if dvals[1] <= 0 { + fld.Value = fmt.Sprintf("!day not set %s", fld.Name) + } else if dvals[2] <= 0 { + fld.Value = fmt.Sprintf("!year not set %s", fld.Name) + } + } + fld.AuxData = dvals + } else { + fld.Value = ctxt.FormField(fld.Name) + } + } +} + +// validatorFunc is a function that validates the contents of a dialog item. +type validatorFunc func(*DialogItem) error + +// nilValidator is a validator function that doesn't do anything. +func nilValidator(*DialogItem) error { + return nil +} + +/* validateTextField validates a text field. + * Parameters: + * fld - The field to be validated. + * Returns: + * Standard Go error status. + */ +func validateTextField(fld *DialogItem) error { + if len(fld.Value) == 0 && fld.Required { + return fmt.Errorf("value of field \"%s\" is required", fld.Caption) + } + if len(fld.Value) > fld.MaxLength { + return fmt.Errorf("value of field \"%s\" can be no longer than %d characters", fld.Caption, fld.MaxLength) + } + return nil +} + +/* validateAmsIdField validates an Amsterdam ID field. + * Parameters: + * fld - The field to be validated. + * Returns: + * Standard Go error status. + */ +func validateAmsIdField(fld *DialogItem) error { + err := validateTextField(fld) + if err == nil { + if !database.AmIsValidAmsterdamID(fld.Value) { + err = fmt.Errorf("value of field \"%s\" is not a valid identifier", fld.Caption) + } + } + return err +} + +/* validateEmailField validates an E-mail address field. + * Parameters: + * fld - The field to be validated. + * Returns: + * Standard Go error status. + */ +func validateEmailField(fld *DialogItem) error { + err := validateTextField(fld) + if err == nil { + _, err = mail.ParseAddress(fld.Value) + } + return err +} + +/* validateCountryField validates a country code field. + * Parameters: + * fld - The field to be validated. + * Returns: + * Standard Go error status. + */ +func validateCountryField(fld *DialogItem) error { + if fld.Value == "XX" && fld.Required { + return fmt.Errorf("country field \"%s\" not set", fld.Caption) + } + return nil +} + +/* validateDateField validates a date field. + * Parameters: + * fld - The field to be validated. + * Returns: + * Standard Go error status. + */ +func validateDateField(fld *DialogItem) error { + if len(fld.Value) == 0 && fld.Required { + return fmt.Errorf("date value %s not set", fld.Caption) + } + if fld.Value[0] == '!' { + return fmt.Errorf("date value %s erroneous: %s", fld.Caption, fld.Value[1:]) + } + if fld.AuxData == nil { + return fmt.Errorf("date value %s not set properly", fld.Caption) + } + dv := fld.AuxData.([]int) + if dv[0] > 12 || dv[1] > 31 { + return fmt.Errorf("date value %s malformed", fld.Caption) + } + q := fmt.Sprintf("%04d%02d%02d", dv[2], dv[0], dv[1]) + if q != fld.Value { + return fmt.Errorf("date value %s should be %s but is %s", fld.Caption, q, fld.Value) + } + return nil +} + +// validators maps the field types to validator functions. +var validators = map[string]validatorFunc{ + "ams_id": validateAmsIdField, + "button": nilValidator, + "checkbox": nilValidator, + "countrylist": validateCountryField, + "date": validateDateField, + "email": validateEmailField, + "header": nilValidator, + "hidden": nilValidator, + "password": validateTextField, + "text": validateTextField, +} + +/* Validate validates the values in the dialog. + * Returns: + * Standard Go error status. + */ +func (d *Dialog) Validate() error { + for _, fld := range d.Fields { + if len(fld.Value) > 0 || fld.Required { + vfunc := validators[fld.Type] + if vfunc != nil { + err := vfunc(&fld) + if err != nil { + return err + } + } else { + return fmt.Errorf("don't know how to validate field %s of type %s", fld.Name, fld.Type) + } + } + } + return nil +} diff --git a/ui/render_wrap.go b/ui/render_wrap.go index f026d0c..665b7ab 100644 --- a/ui/render_wrap.go +++ b/ui/render_wrap.go @@ -12,7 +12,9 @@ package ui import ( "fmt" + "net/http" + "git.erbosoft.com/amy/amsterdam/database" "github.com/labstack/echo/v4" ) @@ -21,6 +23,8 @@ func sendPageData(ctxt echo.Context, amctxt AmContext, command string, data any) switch command { case "bytes": err = ctxt.Blob(amctxt.RC(), amctxt.OutputType(), data.([]byte)) + case "redirect": + err = ctxt.Redirect(http.StatusFound, data.(string)) case "string": err = ctxt.String(amctxt.RC(), fmt.Sprintf("%v", data)) case "template": @@ -64,6 +68,15 @@ func AmWrap(myfunc func(AmContext) (string, any, error)) echo.HandlerFunc { ctxt.Logger().Errorf("Session creation error: %v", aerr) return aerr } + banmsg, banerr := database.AmTestIPBan(ctxt.RealIP()) + if banerr != nil { + ctxt.Logger().Warnf("address %s could not be tested: %v", ctxt.RealIP(), banerr) + // but let the request pass anyway + } else if banmsg != "" { + amctxt.VarMap().Set("amsterdam_pageTitle", "IP Address Banned") + amctxt.VarMap().Set("message", banmsg) + return sendPageData(ctxt, amctxt, "framed_template", "ipban.jet") + } what, rc, err := myfunc(amctxt) if err == nil { if err = amctxt.Session().Save(ctxt.Request(), ctxt.Response()); err != nil { diff --git a/ui/views/agreement.jet b/ui/views/agreement.jet index 0e27808..77d9515 100644 --- a/ui/views/agreement.jet +++ b/ui/views/agreement.jet @@ -16,7 +16,7 @@