From 5082e2bbc2df501a272a4c0955c7d4e045acf1f5 Mon Sep 17 00:00:00 2001
From: Amy Gale Ruth Bowersox
Date: Fri, 26 Sep 2025 23:45:38 -0600
Subject: [PATCH] dialog loading, validation, more logic in forms, IP address
banning
---
database/ipban.go | 81 +++++++++++++++++
database/validate.go | 27 ++++++
login.go | 42 ++++++++-
setup/database.sql | 8 +-
ui/amcontext.go | 39 +++++++++
ui/dialog.go | 191 +++++++++++++++++++++++++++++++++++++++++
ui/render_wrap.go | 13 +++
ui/views/agreement.jet | 2 +-
ui/views/dialog.jet | 13 +--
ui/views/ipban.jet | 17 ++++
10 files changed, 422 insertions(+), 11 deletions(-)
create mode 100644 database/ipban.go
create mode 100644 database/validate.go
create mode 100644 ui/views/ipban.jet
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 @@
+ onclick="window.location.assign('/newacct2?tgt={{ target | url }}')">I Accept
diff --git a/ui/views/dialog.jet b/ui/views/dialog.jet
index ce099cd..fb3ecfa 100644
--- a/ui/views/dialog.jet
+++ b/ui/views/dialog.jet
@@ -80,6 +80,7 @@
{{ else if .Type == "date" }}
+ {{ dv := .DateValues() }}