diff --git a/conferenceadmin.go b/conferenceadmin.go index 95864aa..8ea329c 100644 --- a/conferenceadmin.go +++ b/conferenceadmin.go @@ -14,6 +14,8 @@ import ( "errors" "fmt" "net/http" + "strconv" + "strings" "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/ui" @@ -213,6 +215,153 @@ func ConferenceAliasAdd(ctxt ui.AmContext) (string, any, error) { return "framed_template", "conf_aliases.jet", nil } +// CMData is the result data passed to the conference members page. +type CMData struct { + User *database.User + Level uint16 +} + +// fieldMap maps field names to search field indexes. +var fieldMap = map[string]int{ + "name": database.SearchUserFieldName, + "descr": database.SearchUserFieldDescription, + "first": database.SearchUserFieldFirstName, + "last": database.SearchUserFieldLastName, +} + +// operMap maps operator names to search operator indices. +var operMap = map[string]int{ + "st": database.SearchUserOperPrefix, + "in": database.SearchUserOperSubstring, + "re": database.SearchUserOperRegex, +} + +/* ConferenceMembers shows the conference members and allows their access levels to be adjusted. + * Parameters: + * ctxt - The AmContext for the request. + * Returns: + * Command string dictating what to be rendered. + * Data as a parameter for the command string. + * Standard Go error status. + */ +func ConferenceMembers(ctxt ui.AmContext) (string, any, error) { + comm := ctxt.CurrentCommunity() + conf := ctxt.GetScratch("currentConference").(*database.Conference) + myLevel := ctxt.GetScratch("levelInConference").(uint16) + if !conf.TestPermission("Conference.Change", myLevel) { + ctxt.SetRC(http.StatusForbidden) + return ui.ErrorPage(ctxt, ENOPERM) + } + + // Set the first batch of page variables. + ctxt.VarMap().Set("commName", comm.Name) + ctxt.VarMap().Set("confName", conf.Name) + ctxt.VarMap().Set("backLink", fmt.Sprintf("/comm/%s/conf/%s/manage", comm.Alias, ctxt.GetScratch("currentAlias"))) + ctxt.VarMap().Set("selfLink", fmt.Sprintf("/comm/%s/conf/%s/members", comm.Alias, ctxt.GetScratch("currentAlias"))) + ctxt.VarMap().Set("roleList", database.AmRoleList("Conference.UserLevels")) + ctxt.VarMap().Set("amsterdam_pageTitle", fmt.Sprintf("Membership in Conference: %s", conf.Name)) + + // Get the search parameter values and adjust them. + mode := ctxt.Parameter("mode") + field := ctxt.Parameter("field") + oper := ctxt.Parameter("oper") + term := ctxt.Parameter("term") + offsetStr := ctxt.Parameter("ofs") + if mode == "" { + mode = "conf" + } + if field == "" { + field = "name" + } + if oper == "" { + oper = "st" + } + offset := 0 + if offsetStr != "" { + var err error + offset, err = strconv.Atoi(offsetStr) + if err != nil { + offset = 0 + } + } + maxPage := ctxt.Globals().MaxSearchPage + + // Adjust the offset based on the page buttons. + if ctxt.HasParameter("prev") { + offset = max(0, offset-int(maxPage)) + } else if ctxt.HasParameter("next") { + offset += int(maxPage) + } + + // Write the search parameters back to the page variables. + ctxt.VarMap().Set("mode", mode) + ctxt.VarMap().Set("field", field) + ctxt.VarMap().Set("oper", oper) + ctxt.VarMap().Set("term", term) + ctxt.VarMap().Set("offset", offset) + ctxt.VarMap().Set("max", maxPage) + + if ctxt.HasParameter("update") { + // TODO: update the levels + } + + // Get the member list for the conference. + members, err := conf.Members(ctxt.Ctx()) + if err != nil { + return ui.ErrorPage(ctxt, err) + } + + // Generate the result list. + total := 0 + var mr []CMData + switch mode { + case "conf": + total = len(members) + if offset > 0 { + members = members[offset:] + } + if len(members) > int(maxPage) { + members = members[:maxPage] + } + mr = make([]CMData, len(members)) + for i := range members { + mr[i].User, _ = database.AmGetUser(ctxt.Ctx(), members[i].Uid) + mr[i].Level = members[i].Level + } + case "comm": + ulist, t, err := database.AmSearchCommunityMembers(ctxt.Ctx(), comm, fieldMap[field], operMap[oper], term, offset, int(maxPage)) + if err != nil { + return ui.ErrorPage(ctxt, err) + } + total = t + mr = make([]CMData, len(ulist)) + for i := range ulist { + mr[i].User = ulist[i] + mr[i].Level = 0 + for j := range members { + if members[j].Uid == ulist[i].Uid { + mr[i].Level = members[j].Level + break + } + } + } + } + + // Set the last few variables and return. + ctxt.VarMap().Set("resultList", mr) + ctxt.VarMap().Set("total", total) + ctxt.VarMap().Set("validUids", strings.Join(util.Map(mr, func(cd CMData) string { + return fmt.Sprintf("%d", cd.User.Uid) + }), ",")) + if offset > 0 { + ctxt.VarMap().Set("showPrev", true) + } + if (offset + len(mr)) < total { + ctxt.VarMap().Set("showNext", true) + } + return "framed_template", "conf_members.jet", nil +} + /* CreateConferenceForm displays the dialog for creating a new conference. * Parameters: * ctxt - The AmContext for the request. diff --git a/database/community.go b/database/community.go index b874e50..7ec6ca7 100644 --- a/database/community.go +++ b/database/community.go @@ -1056,3 +1056,93 @@ func AmSearchCommunities(ctx context.Context, field int, oper int, term string, } return rc, total, nil } + +/* AmSearchCommunityMembers searches for members of a community matching certain criteria. + * Parameters: + * ctx - Standard Go context value. + * c - The community within which to search. + * field - A value indicating which field to search: + * SearchUserFieldName - The user name. + * SearchUserFieldDescription - The user description. + * SearchUserFieldFirstName - The user's first name. + * SearchUserFieldLastName - The user's last name. + * oper - The operation to perform on the search field: + * SearchUserOperPrefix - The specified field has the string "term" as a prefix. + * SearchUserOperSubstring - The specified field contains the string "term". + * SearchUserOperRegex - The specified field matches the regular expression in "term". + * term - The search term, as specified above. + * offset - Number of users to skip at beginning of list. + * max - Maximum number of users to return. + * Returns: + * Array of User pointers representing the return elements. + * The total number of users matching this query (could be greater than max) + * Standard Go error status. + */ +func AmSearchCommunityMembers(ctx context.Context, c *Community, field int, oper int, term string, offset int, max int) ([]*User, int, error) { + var queryPortion strings.Builder + switch field { + case SearchUserFieldName: + queryPortion.WriteString("u.username ") + case SearchUserFieldDescription: + queryPortion.WriteString("u.description ") + case SearchUserFieldFirstName: + queryPortion.WriteString("c.given_name ") + case SearchUserFieldLastName: + queryPortion.WriteString("c.family_name ") + default: + return nil, -1, errors.New("invalid field selector") + } + switch oper { + case SearchUserOperPrefix: + queryPortion.WriteString("LIKE '") + queryPortion.WriteString(util.SqlEscape(term, true)) + queryPortion.WriteString("%'") + case SearchUserOperSubstring: + queryPortion.WriteString("LIKE '%") + queryPortion.WriteString(util.SqlEscape(term, true)) + queryPortion.WriteString("%'") + case SearchUserOperRegex: + queryPortion.WriteString("REGEXP '") + queryPortion.WriteString(util.SqlEscape(term, false)) + queryPortion.WriteString("'") + default: + return nil, -1, errors.New("invalid operator selector") + } + q := queryPortion.String() + row := amdb.QueryRowContext(ctx, `SELECT COUNT(*) FROM users u, contacts c, commmember m WHERE u.contactid = c.contactid AND u.uid = m.uid + AND m.commid = ? AND u.is_anon = 0 AND `+q, c.Id) + var total int + err := row.Scan(&total) + if err != nil { + return nil, -1, err + } + if total == 0 { + return make([]*User, 0), 0, nil + } + var rs *sql.Rows + if offset > 0 { + rs, err = amdb.QueryContext(ctx, `SELECT u.uid FROM users u, contacts c, commmember m WHERE u.contactid = c.contactid AND u.uid = m.uid + AND m.commid = ? AND u.is_anon = 0 AND `+q+" ORDER BY u.username LIMIT ? OFFSET ?", c.Id, max, offset) + } else { + rs, err = amdb.QueryContext(ctx, `SELECT u.uid FROM users u, contacts c, commmember m WHERE u.contactid = c.contactid AND u.uid = m.uid + AND m.commid = ? AND u.is_anon = 0 AND `+q+" ORDER BY u.username LIMIT ?", c.Id, max) + } + if err != nil { + return nil, total, err + } + rc := make([]*User, 0, min(max, 10000)) + for rs.Next() { + var uid int32 + if err = rs.Scan(&uid); err == nil { + var u *User + u, err = AmGetUser(ctx, uid) + if err == nil { + rc = append(rc, u) + } + } + if err != nil { + log.Errorf("AmSearchCommunityMembers scan error: %v", err) + } + } + return rc, total, nil +} diff --git a/database/conference.go b/database/conference.go index 65dbcbc..4249f14 100644 --- a/database/conference.go +++ b/database/conference.go @@ -14,6 +14,8 @@ import ( "database/sql" "errors" "fmt" + "slices" + "strings" "sync" "time" @@ -44,6 +46,7 @@ type Conference struct { flags *util.OptionSet } +// ConferenceSettings represents a user's settings within the conference. type ConferenceSettings struct { ConfId int32 `db:"confid"` // conference ID Uid int32 `db:"uid"` // user ID @@ -53,6 +56,13 @@ type ConferenceSettings struct { newflag bool } +// ConferenceMember represents the membership entries in a conference. +type ConferenceMember struct { + ConfId int32 `db:"confid"` // conference ID + Uid int32 `db:"uid"` // user ID + Level uint16 `db:"granted_lvl"` // level granted within the conference +} + // ConferenceProperties represents a property entry for a conference. type ConferenceProperties struct { ConfId int32 `db:"confid"` // conference ID @@ -162,6 +172,7 @@ func (c *Conference) AddAlias(ctx context.Context, alias string, u *User, ipaddr return nil } +// RemoveAlias removes an alias from the conference. func (c *Conference) RemoveAlias(ctx context.Context, alias string, u *User, ipaddr string) error { row := amdb.QueryRowContext(ctx, "SELECT COUNT(*) FROM confalias WHERE confid = ?", c.ConfId) aliasCount := 0 @@ -200,24 +211,30 @@ func (c *Conference) RemoveAlias(ctx context.Context, alias string, u *User, ipa // Hosts returns the list of users that host this conference. func (c *Conference) Hosts(ctx context.Context) ([]*User, error) { - rs, err := amdb.QueryContext(ctx, "SELECT uid FROM confmember WHERE confid = ? AND granted_lvl = ?", - c.ConfId, AmRole("Conference.Host").Level()) + var uids []int32 + err := amdb.SelectContext(ctx, &uids, "SELECT uid FROM confmember WHERE confid = ? AND granted_lvl = ?", c.ConfId, AmRole("Conference.Host").Level()) if err != nil { return nil, err } - rc := make([]*User, 0, 5) - for rs.Next() { - var uid int32 - if err = rs.Scan(&uid); err == nil { - u, err := AmGetUser(ctx, uid) - if err == nil { - rc = append(rc, u) - } + rc := make([]*User, 0, len(uids)) + for _, uid := range uids { + u, err := AmGetUser(ctx, uid) + if err == nil { + rc = append(rc, u) } } + slices.SortFunc(rc, func(a, b *User) int { + return strings.Compare(strings.ToLower(a.Username), strings.ToLower(b.Username)) + }) return rc, nil } +// Hosts returns the list of users that host this conference, quietly. +func (c *Conference) HostsQ(ctx context.Context) []*User { + rc, _ := c.Hosts(ctx) + return rc +} + // InCommunity returns true if the specified conference is in the community. func (c *Conference) InCommunity(ctx context.Context, comm *Community) (bool, error) { row := amdb.QueryRowContext(ctx, "SELECT commid FROM commtoconf WHERE commid = ? AND confid = ?", comm.Id, c.ConfId) @@ -274,10 +291,11 @@ func (c *Conference) ContainedBy(ctx context.Context) ([]*Community, error) { return rc, nil } -// Hosts returns the list of users that host this conference, quietly. -func (c *Conference) HostsQ(ctx context.Context) []*User { - rc, _ := c.Hosts(ctx) - return rc +// Members returns all the members of this conference, with their granted user levels. +func (c *Conference) Members(ctx context.Context) ([]ConferenceMember, error) { + var rc []ConferenceMember + err := amdb.SelectContext(ctx, &rc, "SELECT * FROM confmember WHERE confid = ?", c.ConfId) + return rc, err } // Membership returns a membership flag and granted level for the user in this conference. @@ -294,6 +312,31 @@ func (c *Conference) Membership(ctx context.Context, u *User) (bool, uint16, err return false, 0, err } +// SetMembership sets the membership level for the given user in this conference. +func (c *Conference) SetMembership(ctx context.Context, u *User, level uint16, by *User, ipaddr string) error { + if level == 0 { + _, err := amdb.ExecContext(ctx, "DELETE FROM confmember WHERE confid = ? AND uid = ?", c.ConfId, u.Uid) + return err + } + row := amdb.QueryRowContext(ctx, "SELECT granted_lvl FROM confmember WHERE confid = ? AND uid = ?", c.ConfId, u.Uid) + var oldLevel uint16 + err := row.Scan(&oldLevel) + switch err { + case nil: + if oldLevel == level { + return nil + } + _, err = amdb.ExecContext(ctx, "UPDATE confmember SET granted_lvl = ? WHERE confid = ? AND uid = ?", level, c.ConfId, u.Uid) + case sql.ErrNoRows: + _, err = amdb.ExecContext(ctx, "INSERT INTO confmember (confid, uid, granted_lvl) VALUES (?, ?, ?)", c.ConfId, u.Uid, level) + } + if err != nil { + ar := AmNewAudit(AuditConferenceMembership, by.Uid, ipaddr, fmt.Sprintf("conf=%d", c.ConfId), fmt.Sprintf("uid=%d", u.Uid), fmt.Sprintf("level=%d", level)) + AmStoreAudit(ar) + } + return err +} + /* TestPermission is shorthand that tests if a user has a permission with respect to the conference. * Parameters: * user - The user to be checked. diff --git a/database/securitydefs.yaml b/database/securitydefs.yaml index 7daa19c..320a6eb 100644 --- a/database/securitydefs.yaml +++ b/database/securitydefs.yaml @@ -1,6 +1,6 @@ # # Amsterdam Web Communities System -# Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved +# 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 @@ -15,7 +15,7 @@ scopes: index: 6 roles: - name: "NotInList" - display: "Not In List" + display: "(not in list)" scope: Global value: "0" - name: "UnrestrictedUser" diff --git a/main.go b/main.go index 97bef1d..e8750c8 100644 --- a/main.go +++ b/main.go @@ -81,9 +81,9 @@ func setupEcho() *echo.Echo { // community group commGroup := e.Group("/comm/:cid", ui.SetCommunity) - fn1 := ui.AmWrap(ShowCommunity) - commGroup.GET("", fn1) - commGroup.GET("/profile", fn1) + fn = ui.AmWrap(ShowCommunity) + commGroup.GET("", fn) + commGroup.GET("/profile", fn) commGroup.GET("/join", ui.AmWrap(JoinCommunity)) commGroup.POST("/join", ui.AmWrap(JoinCommunityWithKey)) commGroup.GET("/unjoin", ui.AmWrap(UnjoinCommunity)) @@ -116,6 +116,9 @@ func setupEcho() *echo.Echo { confGroup.POST("/edit", ui.AmWrap(EditConference)) confGroup.GET("/aliases", ui.AmWrap(ConferenceAliasForm)) confGroup.POST("/aliases", ui.AmWrap(ConferenceAliasAdd)) + fn = ui.AmWrap(ConferenceMembers) + confGroup.GET("/members", fn) + confGroup.POST("/members", fn) confGroup.GET("/hotlist", ui.AmWrap(AddToHotlist)) confGroup.GET("/invite", ui.AmWrap(InviteToConference)) confGroup.GET("/r/:topic", ui.AmWrap(ReadPosts), ui.SetTopic) diff --git a/ui/views/conf_members.jet b/ui/views/conf_members.jet new file mode 100644 index 0000000..a8b69d5 --- /dev/null +++ b/ui/views/conf_members.jet @@ -0,0 +1,141 @@ +{* + * 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/. + *} +
+ +
+
+

Set Conference Membership:

+ {{ confName }} +
+
+
+ + +
+ Redisplay Member List | + Return to Manage Conference Menu +
+ + +
+
+ + + +
+

Find Members of Community "{{ commName }}":

+
+
+ Display all community members whose + +
+ + +
+ + +
+ +
+ +
+
+
+
+
+ +
+
+

+ {{ if mode == "conf" }} + Conference Members: + {{ else if mode == "comm" }} + Search Results: + {{ end }} +

+ {{ if total > 0 }} +
(Displaying {{ offset + 1 }}-{{ offset + len(resultList) }} of {{ total }})
+ {{ else }} +
(None found)
+ {{ end }} + {{ if isset(showPrev) || isset(showNext) }} +
+
+ + + + + + {{ if isset(showPrev) }} + + {{ end }} + {{ if isset(showNext) }} + + {{ end }} +
+
+ {{ end }} +
+ +
+ + + + + + +
+
+ {{ range _, rx := resultList }} +
+ 🟣 + + + +
+ {{ end }} +
+ +
+
+
+
+ +
diff --git a/util/util.go b/util/util.go index 0e4cd0a..0d834b5 100644 --- a/util/util.go +++ b/util/util.go @@ -139,3 +139,19 @@ func WordRunLengthAfterPrefix(s string, nrunes int) (int, bool) { } return WordRunLength(s[ofs:]) } + +/* Map applies a transformation function on all elements of an array of one type, turning it into an + * array of another type. + * Parameters: + * in - The input array to be transformed. + * fn - The function to be executed on each element. + * Returns: + * The array of new elements. + */ +func Map[A, B any](in []A, fn func(A) B) []B { + rc := make([]B, len(in)) + for i, v := range in { + rc[i] = fn(v) + } + return rc +}