diff --git a/exports/vcard_xml.go b/exports/vcard_xml.go index dbb1b7d..92b1c1c 100644 --- a/exports/vcard_xml.go +++ b/exports/vcard_xml.go @@ -13,6 +13,7 @@ import ( "context" "encoding/xml" "errors" + "time" "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/util" @@ -280,3 +281,137 @@ func VCardFromContactInfo(ctx context.Context, target *VCard, ci *database.Conta } return nil } + +// VCardSetContactINfo fills the ContactInfo object with data from the VCard. +func VCardSetContactInfo(ci *database.ContactInfo, vc *VCard) { + ci.GivenName = util.IIF(vc.Name.Given == "", nil, &vc.Name.Given) + ci.FamilyName = util.IIF(vc.Name.Family == "", nil, &vc.Name.Family) + if vc.Name.Middle == "" { + ci.MiddleInit = nil + } else { + s := vc.Name.Middle[0:1] + ci.MiddleInit = &s + } + ci.Prefix = util.IIF(vc.Name.Prefix == "", nil, &vc.Name.Prefix) + ci.Suffix = util.IIF(vc.Name.Suffix == "", nil, &vc.Name.Suffix) + if vc.Org != nil { + ci.Company = &(vc.Org.OrgName) + } + if vc.URL != "" { + ci.URL = &(vc.URL) + } + addr := VCardSelectAddress(vc) + if addr != nil { + ci.Addr1 = util.IIF(addr.Street == "", nil, &addr.Street) + ci.Addr2 = util.IIF(addr.ExtAddr == "", nil, &addr.ExtAddr) + ci.Locality = util.IIF(addr.Locality == "", nil, &addr.Locality) + ci.Region = util.IIF(addr.Region == "", nil, &addr.Region) + ci.PostalCode = util.IIF(addr.PCode == "", nil, &addr.PCode) + ci.Country = util.IIF(addr.Country == "", nil, &addr.Country) + } + email, err := VCardGetEmailAddress(vc) + if err == nil { + ci.Email = &email + } + phone, fax, mobile := VCardSelectPhones(vc) + if phone != nil { + ci.Phone = &(phone.Number) + } + if fax != nil { + ci.Fax = &(fax.Number) + } + if mobile != nil { + ci.Mobile = &(mobile.Number) + } +} + +// VCardSelectAddress selects a valid address from the VCard. +func VCardSelectAddress(vc *VCard) *VCAddress { + if vc.Address == nil || len(*vc.Address) == 0 { + return nil + } + if len(*vc.Address) == 1 { + return &((*vc.Address)[0]) + } + for i := range *vc.Address { + if (*vc.Address)[i].Preferred != nil { + return &((*vc.Address)[i]) + } + } + return &((*vc.Address)[0]) +} + +// VCardSelectPhones finds the phone, fax, and mobile numbers in the telephone block. +func VCardSelectPhones(vc *VCard) (*VCTelephone, *VCTelephone, *VCTelephone) { + if vc.Tel == nil || len(*vc.Tel) == 0 { + return nil, nil, nil + } + var mobile *VCTelephone = nil + for i := range *vc.Tel { + if (*vc.Tel)[i].Cell != nil { + if mobile == nil || (*vc.Tel)[i].Preferred != nil { + mobile = &((*vc.Tel)[i]) + } + } + } + var fax *VCTelephone = nil + for i := range *vc.Tel { + if (*vc.Tel)[i].Fax != nil { + if fax == nil || (*vc.Tel)[i].Preferred != nil { + fax = &((*vc.Tel)[i]) + } + } + } + var phone *VCTelephone = nil + for i := range *vc.Tel { + if (*vc.Tel)[i].Voice != nil && (*vc.Tel)[i].Cell == nil { + if phone == nil || (*vc.Tel)[i].Preferred != nil { + phone = &((*vc.Tel)[i]) + } + } + } + return phone, fax, mobile +} + +// VCardGetEmailAddress finds a useful E-mail address in a VCard. +func VCardGetEmailAddress(vc *VCard) (string, error) { + if vc.Email == nil || len(*vc.Email) == 0 { + return "", errors.New("no E-mail address found for user") + } + addrs := make([]*VCEmail, 0, len(*vc.Email)) + for i, a := range *vc.Email { + if a.Internet != nil { + addrs = append(addrs, &((*vc.Email)[i])) + } + } + if len(addrs) == 0 { + return "", errors.New("no Internet E-mail addresses found for user") + } + if len(addrs) == 1 { + return addrs[0].UserID, nil + } + for _, a := range addrs { + if a.Preferred != nil { + return a.UserID, nil + } + } + for _, a := range addrs { + if a.Home != nil { + return a.UserID, nil + } + } + return addrs[0].UserID, nil +} + +// VCardGetBirthday extracts the birthday from the VCard as a time value. +func VCardGetBirthday(vc *VCard) (*time.Time, error) { + s := vc.BDay + if s == "" { + return nil, nil + } + if len(s) > 8 { + s = s[:8] + } + val, err := time.Parse(ISO8601_DATE, s) + return &val, err +} diff --git a/exports/viu_xml.go b/exports/viu_xml.go index 5558aba..93096a1 100644 --- a/exports/viu_xml.go +++ b/exports/viu_xml.go @@ -18,6 +18,7 @@ import ( "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/util" + log "github.com/sirupsen/logrus" ) /* @@ -186,10 +187,80 @@ func VIUStreamCommunityMemberList(ctx context.Context, w io.Writer, comm *databa if err != nil { return fmt.Errorf("error converting user %d: %v", u.Uid, err) } - enc.Encode(encodedUser) + err = enc.Encode(encodedUser) + if err != nil { + log.Warnf("error dumping XML for user %d: %v", u.Uid, err) + } } // Write the trailing tag. _, err = w.Write([]byte("\r\n")) return err } + +func VIUCreateUser(ctx context.Context, udata *VIUUser, loader *database.User, ipaddr string) error { + if !database.AmIsValidAmsterdamID(udata.Username) { + return fmt.Errorf("the username \"%s\" is not a valid Amsterdam ID") + } + email, err := VCardGetEmailAddress(&(udata.VCard)) + if err != nil { + return err + } + ban, err := database.AmIsEmailAddressBanned(ctx, email) + if err != nil { + return err + } else if ban { + return fmt.Errorf("the E-mail address %s has been banned", email) + } + dob, err := VCardGetBirthday(&(udata.VCard)) + if err != nil { + return err + } + pwd := udata.Password.Hash + if udata.Password.Prehashed { + pwd = "" + } + + user, err := database.AmCreateNewUser(ctx, udata.Username, pwd, udata.PasswordReminder, dob, ipaddr) + if err != nil { + return err + } + ci := database.AmNewUserContactInfo(user.Uid) + VCardSetContactInfo(ci, &(udata.VCard)) + ci.PrivateAddr = udata.Options.HideAddr + ci.PrivatePhone = udata.Options.HidePhone + ci.PrivateFax = udata.Options.HideFax + ci.PrivateEmail = udata.Options.HideEmail + _, err = ci.Save(ctx, loader, ipaddr) + if err != nil { + return err + } + err = user.SetContactID(ctx, ci.ContactId) + if err != nil { + return err + } + // TODO + return nil +} + +func VIUImportUserList(ctx context.Context, r io.Reader, loader *database.User, ipaddr string) (int, []string, error) { + dec := xml.NewDecoder(r) + var importData VIUBase + err := dec.Decode(&importData) + if err != nil { + return 0, make([]string, 0), err + } + + scroll := make([]string, 0, len(importData.Users)) + userCount := 0 + for _, udata := range importData.Users { + err = VIUCreateUser(ctx, &udata, loader, ipaddr) + if err != nil { + scroll = append(scroll, fmt.Sprintf("Error creating user \"%s\": %v", udata.Username, err)) + } else { + scroll = append(scroll, fmt.Sprintf("User \"%v\" created", udata.Username)) + userCount++ + } + } + return userCount, scroll, nil +} diff --git a/main.go b/main.go index 64dbf5b..3038cb8 100644 --- a/main.go +++ b/main.go @@ -111,6 +111,7 @@ func setupEcho() *echo.Echo { sysGroup.GET("/ipban/add", ui.AmWrap(AddIPBanForm)) sysGroup.POST("/ipban/add", ui.AmWrap(AddIPBan)) sysGroup.Match(GetAndPost, "/audit", ui.AmWrap(SystemAudit)) + sysGroup.Match(GetAndPost, "/import", ui.AmWrap(UserImport)) // community group uiset2 := make([]echo.MiddlewareFunc, len(uiset), len(uiset)+1) @@ -247,7 +248,7 @@ func main() { }() stime := time.Since(start) - log.Infof("Amsterdam startup sequence completed in %v", stime) + log.Infof("Amsterdam %s startup sequence completed in %v", config.AMSTERDAM_VERSION, stime) // Start server go func() { diff --git a/sysadmin.go b/sysadmin.go index bc22617..60d2ebb 100644 --- a/sysadmin.go +++ b/sysadmin.go @@ -684,6 +684,13 @@ func AddIPBan(ctxt ui.AmContext) (string, any) { return dlg.RenderError(ctxt, err.Error()) } +/* SystemAudit displays the system audit loga. + * Parameters: + * ctxt - The AmContext for the request. + * Returns: + * Command string dictating what to be rendered. + * Data as a parameter for the command string. + */ func SystemAudit(ctxt ui.AmContext) (string, any) { if !database.AmTestPermission("Global.SysAdminAccess", ctxt.CurrentUser().BaseLevel) { return "error", ENOACCESS @@ -748,3 +755,45 @@ func SystemAudit(ctxt ui.AmContext) (string, any) { ctxt.SetFrameTitle("System Audit Records") return "framed", "audit.jet" } + +/* UserImport handles importing user accounts. + * Parameters: + * ctxt - The AmContext for the request. + * Returns: + * Command string dictating what to be rendered. + * Data as a parameter for the command string. + */ +func UserImport(ctxt ui.AmContext) (string, any) { + if !database.AmTestPermission("Global.SysAdminAccess", ctxt.CurrentUser().BaseLevel) { + return "error", ENOACCESS + } + + if ctxt.Verb() == "GET" { + ctxt.SetFrameTitle("Import User Accounts") + return "framed", "import_users.jet" + } + + if ctxt.FormFieldIsSet("cancel") { + return "redirect", "/sysadmin" + } else if !ctxt.FormFieldIsSet("upload") { + return "error", EBUTTON + } + + importData, err := ctxt.FormFile("idata") + if err != nil { + ctxt.VarMap().Set("errorMessage", err.Error()) + ctxt.SetFrameTitle("Import User Accounts") + return "framed", "import_users.jet" + } + + f, err := importData.Open() + if err != nil { + ctxt.VarMap().Set("errorMessage", err.Error()) + ctxt.SetFrameTitle("Import User Accounts") + return "framed", "import_users.jet" + } + + f.Close() + + return "error", "Not yet implemented" +} diff --git a/ui/menudefs.yaml b/ui/menudefs.yaml index 638447b..92d54d7 100644 --- a/ui/menudefs.yaml +++ b/ui/menudefs.yaml @@ -50,7 +50,7 @@ menudefs: link: "/sysadmin/audit" permission: "Global.SysAdminAccess" - text: "Import User Accounts" - link: "/TODO/sysadmin/import" + link: "/sysadmin/import" permission: "Global.SysAdminAccess" - id: "communityadmin" title: "Community Administration:" diff --git a/ui/views/import_users.jet b/ui/views/import_users.jet new file mode 100644 index 0000000..85ddeb4 --- /dev/null +++ b/ui/views/import_users.jet @@ -0,0 +1,64 @@ +{* + * Amsterdam Web Communities System + * Copyright (c) 2025-2026 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/. + *} + +
+
+

Import User Accounts

+
+
+ + {{ if isset(errorMessage) }} + +
+
+
+ ⚠️ +
+
+

{{ CapitalizeString(errorMessage) }}.

+
+
+
+ {{ end }} + + +
+
+
+ + +
+ + +
+ + +
+ + +
+

User Upload Guidelines:

+
    +
  • The user accounts will be imported as a venice-import-users XML file.
  • +
+
+
+
+