From 9d2c57815e558a0963121e50bc550157ed16e128 Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Sat, 11 Oct 2025 16:49:17 -0600 Subject: [PATCH] fully implemented profile editing --- database/contactinfo.go | 34 ++++++ database/passchange.go | 57 ++++++++++ database/user.go | 231 ++++++++++++++++++++++++++++++++++------ go.mod | 1 + go.sum | 2 + main.go | 1 + ui/dialog.go | 56 ++++++++-- ui/dialogs/profile.yaml | 2 +- userdata.go | 137 +++++++++++++++++++++++- util/optionset.go | 67 ++++++++++++ 10 files changed, 545 insertions(+), 43 deletions(-) create mode 100644 database/passchange.go create mode 100644 util/optionset.go diff --git a/database/contactinfo.go b/database/contactinfo.go index 5b9105f..38a7c99 100644 --- a/database/contactinfo.go +++ b/database/contactinfo.go @@ -125,6 +125,7 @@ func (ci *ContactInfo) Save() (bool, error) { if err != nil { return false, err } + contactCache.Add(ci.ContactId, ci) } else { res, err := amdb.NamedExec(`INSERT INTO contacts (given_name, family_name, middle_init, prefix, suffix, company, addr1, addr2, locality, region, pcode, country, phone, fax, mobile, email, pvt_addr, pvt_phone, pvt_fax, @@ -151,6 +152,39 @@ func (ci *ContactInfo) Save() (bool, error) { return emailChange, nil } +// Clone makes a copy of the ContactInfo. +func (ci *ContactInfo) Clone() *ContactInfo { + newstr := ContactInfo{ + ContactId: ci.ContactId, + GivenName: ci.GivenName, + FamilyName: ci.FamilyName, + MiddleInit: ci.MiddleInit, + Prefix: ci.Prefix, + Suffix: ci.Suffix, + Company: ci.Company, + Addr1: ci.Addr1, + Addr2: ci.Addr2, + Locality: ci.Locality, + Region: ci.Region, + PostalCode: ci.PostalCode, + Country: ci.Country, + Phone: ci.Phone, + Fax: ci.Fax, + Mobile: ci.Mobile, + Email: ci.Mobile, + PrivateAddr: ci.PrivateAddr, + PrivatePhone: ci.PrivatePhone, + PrivateFax: ci.PrivateFax, + PrivateEmail: ci.PrivateEmail, + OwnerUid: ci.OwnerUid, + OwnerCommId: ci.OwnerCommId, + PhotoURL: ci.PhotoURL, + URL: ci.URL, + LastUpdate: ci.LastUpdate, + } + return &newstr +} + // contactCache is the cache for ContactInfo objects. var contactCache *lru.TwoQueueCache = nil diff --git a/database/passchange.go b/database/passchange.go new file mode 100644 index 0000000..19c46bd --- /dev/null +++ b/database/passchange.go @@ -0,0 +1,57 @@ +/* + * 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 ( + "time" + + "git.erbosoft.com/amy/amsterdam/util" +) + +// PasswordChangeRequest represents a temporary password change request. +type PasswordChangeRequest struct { + Uid int32 + Username string + Email string + Authentication int32 + Expires time.Time +} + +// passwordRequests contains a map of password change requests currently managed. +var passwordRequests map[int32]*PasswordChangeRequest = make(map[int32]*PasswordChangeRequest) + +/* AmNewPasswordChangeRequest creates a new password change request and enrolls it. + * Parameters: + * uid - The UID of the user. + * username - The user name of the user. + * email - The E-mail address of the user. + * Returns: + * Pointer to the new PasswordChangeRequest. + */ +func AmNewPasswordChangeRequest(uid int32, username string, email string) *PasswordChangeRequest { + rc := PasswordChangeRequest{Uid: uid, Username: username, Email: email, + Authentication: util.GenerateRandomConfirmationNumber(), Expires: time.Now().Add(time.Hour)} + passwordRequests[uid] = &rc + return &rc +} + +/* AmGetPasswordChangeRequest retrieves the password change request for a UID. + * Parameters: + * uid - The UID to retrieve the request for. + * Returns: + * The PasswordChangeRequest pointer, or nil. + */ +func AmGetPasswordChangeRequest(uid int32) *PasswordChangeRequest { + rc := passwordRequests[uid] + if rc != nil { + delete(passwordRequests, uid) + } + return rc +} diff --git a/database/user.go b/database/user.go index 59ebdd0..aa8af93 100644 --- a/database/user.go +++ b/database/user.go @@ -25,45 +25,39 @@ import ( log "github.com/sirupsen/logrus" ) -// PasswordChangeRequest represents a temporary password change request. -type PasswordChangeRequest struct { - Uid int32 - Username string - Email string - Authentication int32 - Expires time.Time +// UserPrefs represents the user's preferences in a table (one row per user). +type UserPrefs struct { + Uid int32 `db:"uid"` + TimeZoneID string `db:"tzid"` + LocaleID string `db:"localeid"` } -// passwordRequests contains a map of password change requests currently managed. -var passwordRequests map[int32]*PasswordChangeRequest = make(map[int32]*PasswordChangeRequest) +// ReadLocale reads the locale out of the prefs, adjusting for Go use. +func (p *UserPrefs) ReadLocale() string { + return strings.Replace(p.LocaleID, "_", "-", -1) +} -/* AmNewPasswordChangeRequest creates a new password change request and enrolls it. - * Parameters: - * uid - The UID of the user. - * username - The user name of the user. - * email - The E-mail address of the user. - * Returns: - * Pointer to the new PasswordChangeRequest. - */ -func AmNewPasswordChangeRequest(uid int32, username string, email string) *PasswordChangeRequest { - rc := PasswordChangeRequest{Uid: uid, Username: username, Email: email, - Authentication: util.GenerateRandomConfirmationNumber(), Expires: time.Now().Add(time.Hour)} - passwordRequests[uid] = &rc +// WriteLocale writes the locale into the prefs, adjusting for backward compatibility. +func (p *UserPrefs) WriteLocale(loc string) { + p.LocaleID = strings.Replace(loc, "-", "_", -1) +} + +// Clone duplicates the user preferences. +func (p *UserPrefs) Clone() *UserPrefs { + rc := *p return &rc } -/* AmGetPasswordChangeRequest retrieves the password change request for a UID. - * Parameters: - * uid - The UID to retrieve the request for. - * Returns: - * The PasswordChangeRequest pointer, or nil. - */ -func AmGetPasswordChangeRequest(uid int32) *PasswordChangeRequest { - rc := passwordRequests[uid] - if rc != nil { - delete(passwordRequests, uid) +// Save saves off the user preferences, replacing the prefs on the user if necessary. +func (p *UserPrefs) Save(u *User) error { + if u != nil && u.Uid != p.Uid { + return errors.New("internal mismatch of IDs") } - return rc + _, err := amdb.NamedExec("UPDATE userprefs SET localeid = :localeid, tzid = :tzid WHERE uid = :uid", p) + if err == nil && u != nil { + u.prefs = p + } + return err } // User represents a user in the Amsterdam database. @@ -85,6 +79,8 @@ type User struct { PassReminder string `db:"passreminder"` Description *string `db:"description"` DOB *time.Time `db:"dob"` + flags *util.OptionSet + prefs *UserPrefs } // UserProperties represents a property entry for a user. @@ -94,22 +90,44 @@ type UserProperties struct { Data *string `db:"data"` } +// User property indexes defined. +const ( + UserPropFlags = int32(0) // "flags" user property +) + +// Flag values for user property index UserPropFlags defined. +const ( + UserFlagPicturesInPosts = uint(0) + UserFlagDisallowSetPhoto = uint(1) + UserFlagMassMailOptOut = uint(2) +) + // userCache is the cache for User objects. var userCache *lru.TwoQueueCache = nil // getUserMutex is a mutex on AmGetUser. var getUserMutex sync.Mutex +// userPropCache is the cache for UserProperties objects. +var userPropCache *lru.Cache = nil + +// getUserPropMutex is a mutex on AmGetUserProperty. +var getUserPropMutex sync.Mutex + // anonUid is the UID of the "anonymous" user. var anonUid int32 = -1 -// init initializes the user cache. +// init initializes the caches. func init() { var err error userCache, err = lru.New2Q(100) if err != nil { panic(err) } + userPropCache, err = lru.New(100) + if err != nil { + panic(err) + } } // ContactInfo returns the contact info structure for the user. @@ -219,6 +237,83 @@ func (u *User) ChangePassword(password string, remoteIP string) error { return err } +// GetFlags retrieves the flags from the properties. +func (u *User) Flags() (*util.OptionSet, error) { + u.Mutex.Lock() + defer u.Mutex.Unlock() + if u.flags == nil { + s, err := AmGetUserProperty(u.Uid, UserPropFlags) + if err != nil { + return nil, err + } + if s == nil { + return nil, fmt.Errorf("missing flags for user %d", u.Uid) + } + u.flags = util.OptionSetFromString(*s) + } + return u.flags, nil +} + +// SaveFlags writes the flags to the database and stores them. +func (u *User) SaveFlags(f *util.OptionSet) error { + s := f.AsString() + u.Mutex.Lock() + defer u.Mutex.Unlock() + err := AmSetUserProperty(u.Uid, UserPropFlags, &s) + if err == nil { + u.flags = f + } + return err +} + +// FlagValue returns the boolean value of one of the user flags. +func (u *User) FlagValue(ndx uint) bool { + f, err := u.Flags() + if err != nil { + log.Errorf("flag retrieval error for user %d: %v", u.Uid, err) + return false + } + return f.Get(ndx) +} + +// Prefs returns the user's preferences record. +func (u *User) Prefs() (*UserPrefs, error) { + u.Mutex.Lock() + defer u.Mutex.Unlock() + if u.prefs == nil { + var dbdata []UserPrefs + err := amdb.Select(&dbdata, "SELECT * FROM userprefs WHERE uid = ?", u.Uid) + if err != nil { + return nil, err + } + if len(dbdata) != 1 { + return nil, fmt.Errorf("invalid preferences records for user %d", u.Uid) + } + u.prefs = &(dbdata[0]) + } + return u.prefs, nil +} + +/* SetProfileData sets the "profile" variables for this user. + * Parameters: + * reminder - Password reminder string. + * dob - Date of birth field. + * descr - Description string. + * Returns: + * Standard Go error status. + */ +func (u *User) SetProfileData(reminder string, dob *time.Time, descr *string) error { + u.Mutex.Lock() + defer u.Mutex.Unlock() + _, err := amdb.Exec("UPDATE users SET passreminder = ?, dob = ?, description = ? WHERE uid = ?", reminder, dob, descr, u.Uid) + if err == nil { + u.PassReminder = reminder + u.DOB = dob + u.Description = descr + } + return err +} + /* AmGetUser returns a reference to the specified user. * Parameters: * uid - The UID of the user. @@ -545,3 +640,73 @@ func AmCreateNewUser(username string, password string, reminder string, dob *tim ar = AmNewAudit(AuditAccountCreated, user.Uid, remoteIP) return user, nil } + +func internalGetProp(uid int32, ndx int32) (*UserProperties, error) { + var err error = nil + key := fmt.Sprintf("%d:%d", uid, ndx) + getUserPropMutex.Lock() + defer getUserPropMutex.Unlock() + rc, ok := userPropCache.Get(key) + if !ok { + var dbdata []UserProperties + err = amdb.Select(&dbdata, "SELECT * from propuser WHERE uid = ? AND ndx = ?", uid, ndx) + if err != nil { + return nil, err + } + if len(dbdata) == 0 { + return nil, nil + } + if len(dbdata) > 1 { + return nil, fmt.Errorf("AmGetUserProperty(%d): too many responses(%d)", uid, len(dbdata)) + } + rc = &(dbdata[0]) + userPropCache.Add(key, rc) + } + return rc.(*UserProperties), nil +} + +/* AmGetUserProperty retrieves the value of a user property. + * Parameters: + * uid - The UID of the user to get the property for. + * ndx - The index of the property to retrieve. + * Returns: + * Value of the property string. + * Standard Go error status. + */ +func AmGetUserProperty(uid int32, ndx int32) (*string, error) { + p, err := internalGetProp(uid, ndx) + if err != nil { + return nil, err + } + return p.Data, nil +} + +/* AmSetUserProperty sets the value of a user property. + * Parameters: + * uid - The UID of the user to set the property for. + * ndx - The index of the property to set. + * val - The new value of the property. + * Returns: + * Standard Go error status. + */ +func AmSetUserProperty(uid int32, ndx int32, val *string) error { + p, err := internalGetProp(uid, ndx) + if err != nil { + return err + } + getUserPropMutex.Lock() + defer getUserPropMutex.Unlock() + if p != nil { + _, err = amdb.Exec("UPDATE propuser SET data = ? WHERE uid = ? AND ndx = ?", val, uid, ndx) + if err == nil { + p.Data = val + } + } else { + prop := UserProperties{Uid: uid, Index: ndx, Data: val} + _, err := amdb.NamedExec("INSERT INTO propuser (uid, ndx, data) VALUES(:uid, :ndx, :data)", prop) + if err == nil { + userPropCache.Add(fmt.Sprintf("%d:%d", uid, ndx), prop) + } + } + return err +} diff --git a/go.mod b/go.mod index 92aa5a0..8e7ccb7 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/CloudyKit/jet/v6 v6.3.1 github.com/alexflint/go-arg v1.6.0 github.com/biter777/countries v1.7.5 + github.com/bits-and-blooms/bitset v1.24.0 github.com/go-sql-driver/mysql v1.9.3 github.com/gorilla/sessions v1.4.0 github.com/hashicorp/golang-lru v1.0.2 diff --git a/go.sum b/go.sum index 7dc454d..5f96b0f 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+W github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= github.com/biter777/countries v1.7.5 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q= github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E= +github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM= +github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/main.go b/main.go index 03a2251..858005f 100644 --- a/main.go +++ b/main.go @@ -53,6 +53,7 @@ func setupEcho() *echo.Echo { e.POST("/verify", ui.AmWrap(VerifyEMail)) e.GET("/passrecovery/:uid/:auth", ui.AmWrap(PasswordRecovery)) e.GET("/profile", ui.AmWrap(EditProfileForm)) + e.POST("/profile", ui.AmWrap(EditProfile)) return e } diff --git a/ui/dialog.go b/ui/dialog.go index c1c2013..0ae7f58 100644 --- a/ui/dialog.go +++ b/ui/dialog.go @@ -47,6 +47,7 @@ type Dialog struct { Action string `yaml:"action"` Instructions string `yaml:"instructions,omitempty"` Fields []DialogItem `yaml:"fields"` + fldmap map[string]*DialogItem } // VRange is used as a return type for ValueRange. @@ -68,11 +69,13 @@ func AmLoadDialog(name string) (*Dialog, error) { var d Dialog err = yaml.Unmarshal(b, &d) if err == nil { - // "nil-patch" certain fields + // "nil-patch" certain fields and create the fast-lookup map if d.MenuSelector == "" { d.MenuSelector = "nochange" } + d.fldmap = make(map[string]*DialogItem) for i, fld := range d.Fields { + d.fldmap[fld.Name] = &(d.Fields[i]) if fld.Type == "button" && fld.Param == "" { d.Fields[i].Param = "blue" } @@ -106,6 +109,17 @@ func (fld *DialogItem) IsChecked() bool { return false } +// SetChecked sets the value of a checkbox. +func (fld *DialogItem) SetChecked(val bool) { + if fld.Type == "checkbox" { + if val { + fld.Value = "Y" + } else { + fld.Value = "" + } + } +} + // ValueInt returns the value of the field as an integer. func (fld *DialogItem) ValueInt() (int, error) { return strconv.Atoi(fld.Value) @@ -134,6 +148,25 @@ func (fld *DialogItem) AsDate() *time.Time { return nil } +// SetDate sets the value of the dialog item as a date. +func (fld *DialogItem) SetDate(d *time.Time) { + if fld.Type == "date" { + dvs := make([]int, 3) + if d == nil { + dvs[0] = -1 + dvs[1] = -1 + dvs[2] = -1 + fld.Value = "" + } else { + dvs[0] = int(d.Month()) - int(time.January) + 1 + dvs[1] = d.Day() + dvs[2] = d.Year() + fld.Value = fmt.Sprintf("%04d%02d%02d", dvs[2], dvs[0], dvs[1]) + } + fld.AuxData = dvs + } +} + // ValPtr returns the value of a field as a string pointer, or nil if the field is empty. func (fld *DialogItem) ValPtr() *string { if fld.Value == "" { @@ -142,6 +175,20 @@ func (fld *DialogItem) ValPtr() *string { return &fld.Value } +// SetVal sets the value of a field from a string pointer. +func (fld *DialogItem) SetVal(p *string) { + if p == nil { + fld.Value = "" + } else { + fld.Value = *p + } +} + +// IsEmpty returns true if the field is empty. +func (fld *DialogItem) IsEmpty() bool { + return len(fld.Value) == 0 +} + /* Field returns a pointer to a dialog's field, given its name. * Parameters: * name - The name of the field to find. @@ -149,12 +196,7 @@ func (fld *DialogItem) ValPtr() *string { * Pointer to the field, or nil. */ func (d *Dialog) Field(name string) *DialogItem { - for i, f := range d.Fields { - if f.Name == name { - return &(d.Fields[i]) - } - } - return nil + return d.fldmap[name] } /* Render sets up the rendering parameters to send this dialog to the output. diff --git a/ui/dialogs/profile.yaml b/ui/dialogs/profile.yaml index 2425ad6..136022f 100644 --- a/ui/dialogs/profile.yaml +++ b/ui/dialogs/profile.yaml @@ -17,7 +17,7 @@ fields: value: "" - type: "header" name: "header1" - caption: "Name" + caption: "Password" subcaption: "To change your password, enter a new password into the fields below." - type: "password" name: "pass1" diff --git a/userdata.go b/userdata.go index 0a7096e..21eb7e3 100644 --- a/userdata.go +++ b/userdata.go @@ -11,8 +11,11 @@ package main import ( "errors" + "net/url" + "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/ui" + "git.erbosoft.com/amy/amsterdam/util" ) /* EditProfileForm renders the Amsterdam profile editing form. @@ -36,8 +39,138 @@ func EditProfileForm(ctxt ui.AmContext) (string, any, error) { dlg, err := ui.AmLoadDialog("profile") if err == nil { dlg.Field("tgt").Value = target - // TODO: load fields from current user - return dlg.Render(ctxt) + var ci *database.ContactInfo + ci, err = u.ContactInfo() + if err == nil { + var prefs *database.UserPrefs + prefs, err = u.Prefs() + if err == nil { + dlg.Field("remind").Value = u.PassReminder + dlg.Field("prefix").SetVal(ci.Prefix) + dlg.Field("first").Value = ci.GivenName + dlg.Field("mid").SetVal(ci.MiddleInit) + dlg.Field("last").Value = ci.FamilyName + dlg.Field("suffix").SetVal(ci.Suffix) + dlg.Field("company").SetVal(ci.Company) + dlg.Field("addr1").SetVal(ci.Addr1) + dlg.Field("addr2").SetVal(ci.Addr2) + dlg.Field("pvt_addr").SetChecked(ci.PrivateAddr) + dlg.Field("loc").SetVal(ci.Locality) + dlg.Field("reg").SetVal(ci.Region) + dlg.Field("pcode").SetVal(ci.PostalCode) + dlg.Field("country").SetVal(ci.Country) + dlg.Field("phone").SetVal(ci.Phone) + dlg.Field("mobile").SetVal(ci.Mobile) + dlg.Field("pvt_phone").SetChecked(ci.PrivatePhone) + dlg.Field("fax").SetVal(ci.Fax) + dlg.Field("pvt_fax").SetChecked(ci.PrivateFax) + dlg.Field("email").SetVal(ci.Email) + dlg.Field("pvt_email").SetChecked(ci.PrivateEmail) + dlg.Field("url").SetVal(ci.URL) + dlg.Field("dob").SetDate(u.DOB) + dlg.Field("descr").SetVal(u.Description) + // TODO: do something for user photo + dlg.Field("pic_in_post").SetChecked(u.FlagValue(database.UserFlagPicturesInPosts)) + dlg.Field("no_mass_mail").SetChecked(u.FlagValue(database.UserFlagMassMailOptOut)) + dlg.Field("locale").Value = prefs.ReadLocale() + dlg.Field("tz").Value = prefs.TimeZoneID + return dlg.Render(ctxt) + } + } + } + return ui.ErrorPage(ctxt, err) +} + +func EditProfile(ctxt ui.AmContext) (string, any, error) { + u := ctxt.CurrentUser() + if u.IsAnon { + return ui.ErrorPage(ctxt, errors.New("you are not logged in")) + } + dlg, err := ui.AmLoadDialog("profile") + if err == nil { + dlg.LoadFromForm(ctxt) + target := dlg.Field("tgt").Value + if target == "" { + target = "/" + } + + action := dlg.WhichButton(ctxt) + if action == "cancel" { // Cancel button pressed + return "redirect", target, nil + } + if action == "update" { + var ci *database.ContactInfo + ci, err = u.ContactInfo() + if err == nil { + var prefs *database.UserPrefs + emailChange := false + prefs, err = u.Prefs() + if err == nil && !(dlg.Field("pass1").IsEmpty() && dlg.Field("pass2").IsEmpty()) { + p1 := dlg.Field("pass1").Value + if p1 == dlg.Field("pass2").Value { + err = u.ChangePassword(p1, ctxt.RemoteIP()) + } else { + err = errors.New("passwords do not match") + } + } + if err == nil { + nci := ci.Clone() + nci.Prefix = dlg.Field("prefix").ValPtr() + nci.GivenName = dlg.Field("first").Value + nci.MiddleInit = dlg.Field("mid").ValPtr() + nci.FamilyName = dlg.Field("last").Value + nci.Suffix = dlg.Field("suffix").ValPtr() + nci.Company = dlg.Field("company").ValPtr() + nci.Addr1 = dlg.Field("addr1").ValPtr() + nci.Addr2 = dlg.Field("addr2").ValPtr() + nci.PrivateAddr = dlg.Field("pvt_addr").IsChecked() + nci.Locality = dlg.Field("loc").ValPtr() + nci.Region = dlg.Field("reg").ValPtr() + nci.PostalCode = dlg.Field("pcode").ValPtr() + nci.Country = dlg.Field("country").ValPtr() + nci.Phone = dlg.Field("phone").ValPtr() + nci.Mobile = dlg.Field("mobile").ValPtr() + nci.PrivatePhone = dlg.Field("pvt_phone").IsChecked() + nci.Fax = dlg.Field("fax").ValPtr() + nci.PrivateFax = dlg.Field("pvt_fax").IsChecked() + nci.Email = dlg.Field("email").ValPtr() + nci.PrivateEmail = dlg.Field("pvt_email").IsChecked() + nci.URL = dlg.Field("url").ValPtr() + emailChange, err = nci.Save() + ci = nci + } + if err == nil { + nprefs := prefs.Clone() + nprefs.WriteLocale(dlg.Field("locale").Value) + nprefs.TimeZoneID = dlg.Field("tz").Value + err = nprefs.Save(u) + } + if err == nil { + var f *util.OptionSet + f, err = u.Flags() + if err == nil { + nf := f.Clone() + nf.Set(database.UserFlagPicturesInPosts, dlg.Field("pic_in_post").IsChecked()) + nf.Set(database.UserFlagMassMailOptOut, dlg.Field("no_mass_mail").IsChecked()) + err = u.SaveFlags(nf) + } + } + if err == nil { + err = u.SetProfileData(dlg.Field("remind").Value, dlg.Field("dob").AsDate(), dlg.Field("descr").ValPtr()) + } + if err == nil { + if emailChange { + err = sendEmailConfirmationEmail(u, ci, ctxt.RemoteIP()) + if err == nil { + return "redirect", "/verify?tgt=" + url.PathEscape(target), nil + } + } else { + return "redirect", target, nil + } + } + } + } + return dlg.RenderError(ctxt, "No known button click on POST to profile.") } return ui.ErrorPage(ctxt, err) } diff --git a/util/optionset.go b/util/optionset.go new file mode 100644 index 0000000..ccab53b --- /dev/null +++ b/util/optionset.go @@ -0,0 +1,67 @@ +/* + * 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/. + */ + +// Package util contains utility definitions. +package util + +import ( + "strings" + + "github.com/bits-and-blooms/bitset" +) + +// optionAlphabet is the alphabet from which OptionSets serialize to and from strings. +const optionAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~" + +// OptionSet is a bit set that can be persisted as a specially-constructed string. +type OptionSet struct { + bits *bitset.BitSet +} + +// Get retrieves the value of a bit from the given set. +func (s *OptionSet) Get(ndx uint) bool { + return s.bits.Test(ndx) +} + +// Set sets the value of a bit in the given set. +func (s *OptionSet) Set(ndx uint, v bool) { + if v { + s.bits = s.bits.Set(ndx) + } else { + s.bits = s.bits.Clear(ndx) + } +} + +// AsString returns the option set's value as a string. +func (s *OptionSet) AsString() string { + var b strings.Builder + for i, e := s.bits.NextSet(0); e; i, e = s.bits.NextSet(i + 1) { + b.WriteByte(optionAlphabet[int(i)]) + } + return b.String() +} + +// Clone creates a clone of this OptionSet. +func (s *OptionSet) Clone() *OptionSet { + return &OptionSet{bits: s.bits.Clone()} +} + +// NewOptionSet creates and returns an empty option set. +func NewOptionSet() *OptionSet { + return &OptionSet{bits: bitset.New(uint(len(optionAlphabet)))} +} + +// OptionSetFromString converts a string into a corresponding OptionSet. +func OptionSetFromString(s string) *OptionSet { + bs := bitset.New(uint(len(optionAlphabet))) + for _, ch := range s { + bs = bs.Set(uint(strings.IndexRune(optionAlphabet, ch))) + } + return &OptionSet{bits: bs} +}