From 344562c55c9a13fab0c54eafff142d9986b80aab Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Thu, 26 Feb 2026 16:19:24 -0700 Subject: [PATCH] completed member export and removed some "breaking" bugs, still work to be done on the XML output --- community.go | 2 +- communityadmin.go | 39 ++++++++++++++++++++++- config/config.go | 3 +- database/securitydefs.yaml | 12 +++++++ database/user.go | 6 ++-- exports/vcard_xml.go | 24 +++++++------- exports/viu_xml.go | 64 ++++++++++++++++++++++++++++++++++++-- main.go | 2 ++ ui/views/memberlist.jet | 4 +-- util/util.go | 8 +++++ 10 files changed, 141 insertions(+), 23 deletions(-) diff --git a/community.go b/community.go index a8c894c..36d09b4 100644 --- a/community.go +++ b/community.go @@ -361,7 +361,7 @@ func MemberList(ctxt ui.AmContext) (string, any) { ctxt.VarMap().Set("oper", "st") ctxt.VarMap().Set("term", "") ctxt.VarMap().Set("ofs", ofs) - ctxt.SetFrameTitle("List Members") + ctxt.SetFrameTitle("Members of Community " + comm.Name) listMax := int(ctxt.Globals().MaxCommunityMemberPage) results, total, err := comm.ListMembers(ctxt.Ctx(), database.ListMembersFieldNone, database.ListMembersOperNone, "", ofs*listMax, listMax, showHidden) if err != nil { diff --git a/communityadmin.go b/communityadmin.go index 9a7fc62..2f0fde6 100644 --- a/communityadmin.go +++ b/communityadmin.go @@ -14,6 +14,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "strconv" "strings" @@ -21,12 +22,48 @@ import ( "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/email" + "git.erbosoft.com/amy/amsterdam/exports" "git.erbosoft.com/amy/amsterdam/ui" "git.erbosoft.com/amy/amsterdam/util" "github.com/labstack/echo/v4" log "github.com/sirupsen/logrus" ) +/* ExportCommunityMembers exports the members of the community as XML. + * Parameters: + * ctxt - The AmContext for the request. + * Returns: + * Command string dictating what to be rendered. + * Data as a parameter for the command string. + */ +func ExportCommunityMembers(ctxt ui.AmContext) (string, any) { + comm := ctxt.CurrentCommunity() + if !comm.TestPermission("Community.ShowAdmin", ctxt.EffectiveLevel()) { + return "error", ENOACCESS + } + + // use a dedicated goroutine to generate the streamed XML and send it into one end of a pipe + filename := time.Now().Format("exported-users-20060102.xml") + r, w := io.Pipe() + go func() { + start := time.Now() + err := exports.VIUStreamCommunityMemberList(context.Background(), w, comm) + if err != nil { + log.Errorf("ExportCommunityMembers task failed with %v", err) + s := fmt.Sprintf("\r\n", err) + w.Write([]byte(s)) + } + w.Close() + dur := time.Since(start) + log.Infof("ExportCommunityMembers task completed in %v", dur) + }() + + // Now we connect the outlet end of the pipe to the output to the browser. + ctxt.SetOutputType("text/xml") + ctxt.SetHeader("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + return "stream", r +} + /* CommunityAdminMenu renders the community administration menu. * Parameters: * ctxt - The AmContext for the request. @@ -35,7 +72,7 @@ import ( * Data as a parameter for the command string. */ func CommunityAdminMenu(ctxt ui.AmContext) (string, any) { - comm := ctxt.CurrentCommunity() // set by middleware + comm := ctxt.CurrentCommunity() if !comm.TestPermission("Community.ShowAdmin", ctxt.EffectiveLevel()) { return "error", ENOACCESS } diff --git a/config/config.go b/config/config.go index b6cd906..a11e636 100644 --- a/config/config.go +++ b/config/config.go @@ -14,7 +14,6 @@ import ( _ "embed" "errors" "fmt" - "maps" "os" "regexp" "strconv" @@ -180,7 +179,7 @@ func overlayStringArray(loaded, defaulted []string) []string { m[s] = true } rc := make([]string, 0, len(m)) - for s := range maps.Keys(m) { + for s := range m { rc = append(rc, s) } return rc diff --git a/database/securitydefs.yaml b/database/securitydefs.yaml index e1cb55e..4406ffb 100644 --- a/database/securitydefs.yaml +++ b/database/securitydefs.yaml @@ -195,6 +195,18 @@ lists: - "Community.Member" - "UnrestrictedUser" - "Community.Cohost" + - name: "Community.AllUserlevels" + roles: + - "NotInList" + - "Global.Anonymous" + - "Global.Unverified" + - "Global.Normal" + - "Community.Member" + - "UnrestrictedUser" + - "Community.Cohost" + - "Community.Host" + - "Global.PFY" + - "Global.BOFH" - name: "Conference.Read" roles: - "Global.Anonymous" diff --git a/database/user.go b/database/user.go index 731381a..e66e28c 100644 --- a/database/user.go +++ b/database/user.go @@ -100,7 +100,7 @@ func (p *UserPrefs) Localizer() lctime.Localizer { func (p *UserPrefs) LanguageTag() *language.Tag { lt, err := language.Parse(p.ReadLocale()) if err != nil { - log.Fatalf("BOGUS LANGUAGE TAG %s in user prefs for uid %d", p.LocaleID, p.Uid) + log.Errorf("BOGUS LANGUAGE TAG %s in user prefs for uid %d", p.LocaleID, p.Uid) return nil } return < @@ -115,7 +115,7 @@ func (p *UserPrefs) MessagePrinter() *message.Printer { func (p *UserPrefs) Location() *time.Location { rc, err := time.LoadLocation(p.TimeZoneID) if err != nil { - log.Fatalf("BOGUS TIMEZONE TAG %s in user prefs for uid %d", p.TimeZoneID, p.Uid) + log.Errorf("BOGUS TIMEZONE TAG %s in user prefs for uid %d", p.TimeZoneID, p.Uid) return time.Local } return rc @@ -669,7 +669,7 @@ func crackAuthString(authString string) (int32, string, error) { * Parameters: * ctx - Standard Go context value. * authString - The stored cookie authentication string. - * remoteIP - The remote IP address wheter trhe user is logging in from. + * remoteIP - The remote IP address where the user is logging in from. * Returns: * Pointer to the authenticated User, or nil. * Standard Go error status. diff --git a/exports/vcard_xml.go b/exports/vcard_xml.go index ba83917..2e2e46f 100644 --- a/exports/vcard_xml.go +++ b/exports/vcard_xml.go @@ -199,12 +199,12 @@ type VCKey struct { func VCardFromContactInfo(ctx context.Context, target *VCard, ci *database.ContactInfo) error { target.Version = "2.0" target.FullName = ci.FullName(false) - target.Name.Family = util.IIF(ci.FamilyName != nil, *ci.FamilyName, "") - target.Name.Given = util.IIF(ci.GivenName != nil, *ci.GivenName, "") - target.Name.Middle = util.IIF(ci.MiddleInit != nil, *ci.MiddleInit+".", "") - target.Name.Prefix = util.IIF(ci.Prefix != nil, *ci.Prefix, "") - target.Name.Suffix = util.IIF(ci.Suffix != nil, *ci.Suffix, "") - target.URL = util.IIF(ci.URL != nil, *ci.URL, "") + target.Name.Family = util.SRef(ci.FamilyName) + target.Name.Given = util.SRef(ci.GivenName) + target.Name.Middle = util.SRef(ci.MiddleInit) + target.Name.Prefix = util.SRef(ci.Prefix) + target.Name.Suffix = util.SRef(ci.Suffix) + target.URL = util.SRef(ci.URL) if ci.LastUpdate != nil { target.LastUpdate = ci.LastUpdate.Format(ISO8601) } @@ -213,12 +213,12 @@ func VCardFromContactInfo(ctx context.Context, target *VCard, ci *database.Conta addr[0].Home.Local = "HOME" addr[0].Postal.Local = "POSTAL" addr[0].Preferred.Local = "PREF" - addr[0].Street = util.IIF(ci.Addr1 != nil, *ci.Addr1, "") - addr[0].ExtAddr = util.IIF(ci.Addr2 != nil, *ci.Addr2, "") - addr[0].Locality = util.IIF(ci.Locality != nil, *ci.Locality, "") - addr[0].Region = util.IIF(ci.Region != nil, *ci.Region, "") - addr[0].PCode = util.IIF(ci.PostalCode != nil, *ci.PostalCode, "") - addr[0].Country = util.IIF(ci.Country != nil, *ci.Country, "") + addr[0].Street = util.SRef(ci.Addr1) + addr[0].ExtAddr = util.SRef(ci.Addr2) + addr[0].Locality = util.SRef(ci.Locality) + addr[0].Region = util.SRef(ci.Region) + addr[0].PCode = util.SRef(ci.PostalCode) + addr[0].Country = util.SRef(ci.Country) target.Address = &addr phcount := util.IIF(ci.Phone != nil, 1, 0) + util.IIF(ci.Fax != nil, 1, 0) + util.IIF(ci.Mobile != nil, 1, 0) diff --git a/exports/viu_xml.go b/exports/viu_xml.go index 9bd3049..5f1a7ab 100644 --- a/exports/viu_xml.go +++ b/exports/viu_xml.go @@ -12,6 +12,9 @@ package exports import ( "context" "encoding/xml" + "fmt" + "io" + "strings" "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/util" @@ -73,6 +76,7 @@ type VIUCommunityJoin struct { Community string `xml:",chardata"` // name of community joined } +// VIUUserFromUser fills in a VIUUser structure with details from the given user. func VIUUserFromUser(ctx context.Context, target *VIUUser, u *database.User) error { // Fill base fields first. target.ID = int(u.Uid) @@ -80,7 +84,7 @@ func VIUUserFromUser(ctx context.Context, target *VIUUser, u *database.User) err target.Password.Prehashed = true target.Password.Hash = u.Passhash target.PasswordReminder = u.PassReminder - target.Description = util.IIF(u.Description != nil, *u.Description, "") + target.Description = util.SRef(u.Description) // Get the contact info. ci, err := u.ContactInfo(ctx) @@ -126,10 +130,66 @@ func VIUUserFromUser(ctx context.Context, target *VIUUser, u *database.User) err return err } + // Fill options from user flags. target.Options.PostPictures = flags.Get(database.UserFlagPicturesInPosts) target.Options.OptOut = flags.Get(database.UserFlagMassMailOptOut) target.Options.NoPhoto = flags.Get(database.UserFlagDisallowSetPhoto) - // TODO - fill in Joins + // Get the list of communities for the user and set up the Joins list. + comms, err := database.AmGetCommunitiesForUser(ctx, u.Uid) + if err != nil { + return err + } + target.Joins = make([]VIUCommunityJoin, len(comms)) + roles := database.AmRoleList("Community.AllUserlevels") + for i, c := range comms { + _, _, level, err := c.Membership(ctx, u) + if err != nil { + return err + } + target.Joins[i].Role = roles.FindForLevel(level).ID() + target.Joins[i].Community = c.Alias + } + return nil } + +// VIUStreamCommunityMembersList streams a list of users of the given community to the given stream in VIU format. +func VIUStreamCommunityMemberList(ctx context.Context, w io.Writer, comm *database.Community) error { + // Get the list of community members. + total, err := comm.MemberCount(ctx, false) + if err != nil { + return err + } + users, _, err := comm.ListMembers(ctx, database.ListMembersFieldNone, database.ListMembersOperNone, "", 0, total, false) + if err != nil { + return err + } + + // Write the header of the file. + var b strings.Builder + b.WriteString(xml.Header) + b.WriteString("\r\n\r\n") + _, err = w.Write([]byte(b.String())) + if err != nil { + return err + } + + // Create the XML encoder. + enc := xml.NewEncoder(w) + enc.Indent(" ", " ") + + // Build each user and then encode them to the output. + for _, u := range users { + var encodedUser VIUUser + err = VIUUserFromUser(ctx, &encodedUser, u) + if err != nil { + return fmt.Errorf("error converting user %d: %v", u.Uid, err) + } + enc.Encode(encodedUser) + } + + // Write the trailing tag. + _, err = w.Write([]byte("\r\n")) + return err +} diff --git a/main.go b/main.go index 54aedc3..64dbf5b 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,7 @@ var GetAndPost = []string{http.MethodGet, http.MethodPost} // setupEcho creates, configures, and returns a new Echo instance. func setupEcho() *echo.Echo { e := echo.New() + e.HideBanner = true e.Logger = &EchoLogrusAdapter{} e.Renderer = &ui.TemplateRenderer{} e.HTTPErrorHandler = AmErrorHandler @@ -125,6 +126,7 @@ func setupEcho() *echo.Echo { commGroup.POST("/unjoin", ui.AmWrap(UnjoinCommunityConfirm)) commGroup.GET("/members", ui.AmWrap(MemberList)) commGroup.POST("/members", ui.AmWrap(MemberSearch)) + commGroup.GET("/members/export", ui.AmWrap(ExportCommunityMembers)) commGroup.GET("/invite", ui.AmWrap(InviteToCommunity)) commGroup.GET("/find", ui.AmWrap(FindPostsPageCommunity)) commGroup.POST("/find", ui.AmWrap(FindPostsCommunity)) diff --git a/ui/views/memberlist.jet b/ui/views/memberlist.jet index e88c7df..d451e00 100644 --- a/ui/views/memberlist.jet +++ b/ui/views/memberlist.jet @@ -10,7 +10,7 @@
-

Find:

+

Members of Community:

{{ comm.Name }}

@@ -18,7 +18,7 @@ {{ if canExport }} {{ end }} diff --git a/util/util.go b/util/util.go index 258d7ad..20e5819 100644 --- a/util/util.go +++ b/util/util.go @@ -189,3 +189,11 @@ func IIF[A any](expr bool, v1, v2 A) A { return v2 } } + +// SRef dereferences the string pointer if it's not nil, or returns an empty string if it is nil. +func SRef(s *string) string { + if s == nil { + return "" + } + return *s +}