From 021807e53e49fd29692374b44452dfbfbee361a1 Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Sat, 18 Oct 2025 22:38:27 -0600 Subject: [PATCH] landed code for creating community (untested) --- communityadmin.go | 140 ++++++++++++++++++++++++++++++++++-- database/audit.go | 12 ++++ database/community.go | 134 +++++++++++++++++++++++++++++++--- database/contactinfo.go | 12 ++++ database/services.go | 24 +++++++ main.go | 2 + ui/dialogs/commprofile.yaml | 3 - ui/dialogs/create_comm.yaml | 107 +++++++++++++++++++++++++++ ui/views/sb_ftrcomm.jet | 22 +++--- 9 files changed, 426 insertions(+), 30 deletions(-) create mode 100644 ui/dialogs/create_comm.yaml diff --git a/communityadmin.go b/communityadmin.go index 4e35386..6107dd9 100644 --- a/communityadmin.go +++ b/communityadmin.go @@ -54,6 +54,7 @@ func CommunityAdminMenu(ctxt ui.AmContext) (string, any, error) { return "framed_template", "menu.jet", nil } +// setupCommunityProfileDialog sets up fields in the Community Profile dialog. func setupCommunityProfileDialog(dlg *ui.Dialog, comm *database.Community) { dlg.SetCommunity(comm) if comm.IsAdmin { @@ -108,7 +109,6 @@ func CommunityProfileForm(ctxt ui.AmContext) (string, any, error) { dlg, err := ui.AmLoadDialog("commprofile") if err == nil { setupCommunityProfileDialog(dlg, comm) - dlg.Field("cc").Value = fmt.Sprintf("%d", comm.Id) dlg.Field("name").Value = comm.Name dlg.Field("alias").Value = comm.Alias dlg.Field("synopsis").SetVal(comm.Synopsis) @@ -143,10 +143,16 @@ func CommunityProfileForm(ctxt ui.AmContext) (string, any, error) { return ui.ErrorPage(ctxt, err) } -// levelFld is a quick routine to extract a level value from a drop-down. -func levelFld(d *ui.Dialog, name string) uint16 { - v, _ := strconv.Atoi(d.Field(name).Value) - return uint16(v) +// validateJoinKey is an extra validation step for the join key. +func validateJoinKey(dlg *ui.Dialog) error { + if dlg.Field("comtype").Value == "1" { + if dlg.Field("joinkey").IsEmpty() { + return errors.New("private community must specify a join key") + } + } else { + dlg.Field("joinkey").Value = "" + } + return nil } /* EditCommunityProfile updates the community's profile from the dialog. @@ -182,6 +188,10 @@ func EditCommunityProfile(ctxt ui.AmContext) (string, any, error) { if err != nil { return dlg.RenderError(ctxt, err.Error()) } + err = validateJoinKey(dlg) + if err != nil { + return dlg.RenderError(ctxt, err.Error()) + } var ci *database.ContactInfo ci, err = comm.ContactInfo() if err != nil { @@ -222,8 +232,8 @@ func EditCommunityProfile(ctxt ui.AmContext) (string, any, error) { } err = comm.SetProfileData(dlg.Field("name").Value, dlg.Field("alias").Value, dlg.Field("synopsis").ValPtr(), dlg.Field("rules").ValPtr(), dlg.Field("language").ValPtr(), joinkey, dlg.Field("membersonly").IsChecked(), - hidedir, hidesearch, levelFld(dlg, "read_lvl"), levelFld(dlg, "write_lvl"), levelFld(dlg, "create_lvl"), - levelFld(dlg, "delete_lvl"), levelFld(dlg, "join_lvl")) + hidedir, hidesearch, dlg.Field("read_lvl").GetLevel(), dlg.Field("write_lvl").GetLevel(), + dlg.Field("create_lvl").GetLevel(), dlg.Field("delete_lvl").GetLevel(), dlg.Field("join_lvl").GetLevel()) } if err == nil { flags.Set(database.CommunityFlagPicturesInPosts, dlg.Field("pic_in_post").IsChecked()) @@ -241,6 +251,14 @@ func EditCommunityProfile(ctxt ui.AmContext) (string, any, error) { return ui.ErrorPage(ctxt, err) } +/* CommunityLogoForm renders the form for changing the community logo. + * 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 CommunityLogoForm(ctxt ui.AmContext) (string, any, error) { err := ctxt.SetCommunityContext(ctxt.URLParam("cid")) if err != nil { @@ -263,6 +281,14 @@ func CommunityLogoForm(ctxt ui.AmContext) (string, any, error) { return ui.ErrorPage(ctxt, err) } +/* EditCommunityLogo handles setting the community logo. + * 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 EditCommunityLogo(ctxt ui.AmContext) (string, any, error) { err := ctxt.SetCommunityContext(ctxt.URLParam("cid")) if err != nil { @@ -341,3 +367,103 @@ func EditCommunityLogo(ctxt ui.AmContext) (string, any, error) { } return ui.ErrorPage(ctxt, errors.New("invalid button detected in logo upload")) } + +/* CreateCommunityForm renders the form for creating a new community. + * 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 CreateCommunityForm(ctxt ui.AmContext) (string, any, error) { + user := ctxt.CurrentUser() + if user.BaseLevel < uint16(ctxt.Globals().CommunityCreateLevel) { + ctxt.SetRC(http.StatusForbidden) + return ui.ErrorPage(ctxt, errors.New("you are not permitted to create a community")) + } + dlg, err := ui.AmLoadDialog("create_comm") + if err != nil { + dlg.Field("language").Value = "en-US" + dlg.Field("country").Value = "US" + return dlg.Render(ctxt) + } + return ui.ErrorPage(ctxt, err) +} + +/* CreateCommunity creates a new community. + * 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 CreateCommunity(ctxt ui.AmContext) (string, any, error) { + user := ctxt.CurrentUser() + if user.BaseLevel < uint16(ctxt.Globals().CommunityCreateLevel) { + ctxt.SetRC(http.StatusForbidden) + return ui.ErrorPage(ctxt, errors.New("you are not permitted to create a community")) + } + dlg, err := ui.AmLoadDialog("create_comm") + if err != nil { + dlg.LoadFromForm(ctxt) + action := dlg.WhichButton(ctxt) + if action == "cancel" { + return "redirect", "/", nil + } + if action == "create" { + err = dlg.Validate() + if err != nil { + return dlg.RenderError(ctxt, err.Error()) + } + err = validateJoinKey(dlg) + if err != nil { + return dlg.RenderError(ctxt, err.Error()) + } + var testcomm *database.Community + testcomm, err = database.AmGetCommunityByAlias(dlg.Field("alias").Value) + if err != nil { + return dlg.RenderError(ctxt, err.Error()) + } + if testcomm != nil { + return dlg.RenderError(ctxt, fmt.Sprintf("A community with the alias \"%s\" already exists; please try again.", testcomm.Alias)) + } + var hideDir, hideSearch bool + switch dlg.Field("hidemode").Value { + case "NONE": + hideDir = false + hideSearch = false + case "DIRECTORY": + hideDir = true + hideSearch = false + case "BOTH": + hideDir = true + hideSearch = true + } + var comm *database.Community + comm, err = database.AmCreateCommunity(dlg.Field("name").Value, dlg.Field("alias").Value, user.Uid, + dlg.Field("language").ValPtr(), dlg.Field("synopsis").ValPtr(), dlg.Field("rules").ValPtr(), + dlg.Field("joinkey").ValPtr(), hideDir, hideSearch, ctxt.RemoteIP()) + if err != nil { + return dlg.RenderError(ctxt, err.Error()) + } + ci := database.AmNewCommunityContactInfo(user.Uid, comm.Id) + ci.Locality = dlg.Field("loc").ValPtr() + ci.Region = dlg.Field("reg").ValPtr() + ci.PostalCode = dlg.Field("pcode").ValPtr() + ci.Country = dlg.Field("country").ValPtr() + _, err = ci.Save() + if err == nil { + err = comm.SetContactID(ci.ContactId) + } + if err != nil { + return dlg.RenderError(ctxt, err.Error()) + } + // new community is now created! redirect to the new profile + return "redirect", fmt.Sprintf("/comm/%s/profile", comm.Alias), nil + } + return dlg.RenderError(ctxt, "No known button click on POST to community creation.") + } + return ui.ErrorPage(ctxt, err) +} diff --git a/database/audit.go b/database/audit.go index 4f4d291..f68d216 100644 --- a/database/audit.go +++ b/database/audit.go @@ -46,6 +46,18 @@ const ( AuditAdminChangeUserAccount = 111 AuditAdminSetAccountSecurity = 112 AuditAdminLockUnlockAccount = 113 + AuditCommunityCreate = 201 + AuditCommunitySetMembership = 202 + AuditCommuntiyContactInfo = 203 + AuditCommunityFeatureSet = 204 + AuditCommunityName = 205 + AuditCommunityAlias = 206 + AuditCommunityCategory = 207 + AuditCommunityHideInfo = 208 + AuditCommunityMembersOnly = 209 + AuditCommunityJoinKey = 210 + AuditCommunitySecurity = 211 + AuditCommunityDelete = 212 ) // auditWriteQueue is a channel to store audit records in the background. diff --git a/database/community.go b/database/community.go index 1938dbc..cd944bc 100644 --- a/database/community.go +++ b/database/community.go @@ -10,6 +10,7 @@ package database import ( + "errors" "fmt" "slices" "strconv" @@ -248,9 +249,10 @@ func (c *Community) Flags() (*util.OptionSet, error) { return nil, err } if s == nil { - return nil, fmt.Errorf("missing flags for community %d", c.Id) + c.flags = util.NewOptionSet() + } else { + c.flags = util.OptionSetFromString(*s) } - c.flags = util.OptionSetFromString(*s) } return c.flags, nil } @@ -302,6 +304,17 @@ func (c *Community) SetProfileData(name string, alias string, synopsis *string, return err } +// SetContactID sets the contact ID for the community. +func (c *Community) SetContactID(cid int32) error { + c.Mutex.Lock() + defer c.Mutex.Unlock() + if _, err := amdb.Exec("UPDATE communities SET contactid = ? WHERE commid = ?", cid, c.Id); err != nil { + return err + } + c.ContactId = cid + return nil +} + /* AmGetCommunity returns a reference to the specified community. * Parameters: * id - The ID of the community. @@ -331,6 +344,27 @@ func AmGetCommunity(id int32) (*Community, error) { return rc.(*Community), err } +/* AmGetCommunityByAlias returns a reference to the specified community. + * Parameters: + * alias - The alias for the community. + * Returns: + * Pointer to Community containing community data, or nil + * Standard Go error status (nil if community not found) + */ +func AmGetCommunityByAlias(alias string) (*Community, error) { + rs, err := amdb.Query("SELECT commid FROM communities WHERE alias = ?", alias) + if err == nil { + if rs.Next() { + var cid int32 + rs.Scan(&cid) + return AmGetCommunity(cid) + } else { + return nil, nil + } + } + return nil, err +} + /* AmGetCommunityFromParam returns a reference to the specified community based on the parameter. * If the parameter is numeric, it's interpreted as a community ID. Otherwise, it's interpreted * as a community alias. @@ -349,17 +383,13 @@ func AmGetCommunityFromParam(param string) (*Community, error) { } // else fall through to trying as alias } - rs, err := amdb.Query("SELECT commid FROM communities WHERE alias = ?", param) + rc, err := AmGetCommunityByAlias(param) if err == nil { - if rs.Next() { - var cid int32 - rs.Scan(&cid) - return AmGetCommunity(cid) - } else { + if rc == nil { return nil, fmt.Errorf("community with alias \"%s\" not found", param) } } - return nil, err + return rc, err } /* AmGetCommunitiesForUser returns a list of communities the user is a member of. @@ -482,6 +512,8 @@ func AmGetCommunityProperty(cid int32, ndx int32) (*string, error) { p, err := internalGetCommProp(cid, ndx) if err != nil { return nil, err + } else if p == nil { + return nil, nil } return p.Data, nil } @@ -515,3 +547,87 @@ func AmSetCommunityProperty(cid int32, ndx int32, val *string) error { } return err } + +/* AmCreateCommunity creates a new community. + * Parameters: + * name - The name for the new community. + * alias - The alias for the new community. Must be unique. + * hostUid - The UID of the creator and new host of the community. + * language - Community default language. + * synopsis - Community synopsis string. + * rules - Community rules string. + * joinkey - Community join key, or empty string for a public community. + * hideDirectory - true to hide this community from the directory listings. + * hideSearch - true to hide this community from searches. + * remoteIP - Remote IP address for audit record. + * Returns: + * Pointer to new Community record, or nil. + * Standard Go error status. + */ +func AmCreateCommunity(name string, alias string, hostUid int32, language *string, synopsis *string, + rules *string, joinkey *string, hideDirectory bool, hideSearch bool, remoteIP string) (*Community, error) { + var ar *AuditRecord = nil + defer func() { + AmStoreAudit(ar) + }() + + unlock := true + amdb.Exec("LOCK TABLES communities WRITE, commftrs WRITE, commmember WRITE;") + defer func() { + if unlock { + amdb.Exec("UNLOCK TABLES;") + } + }() + + // validate alias does not already exist + rs, err := amdb.Query("SELECT commid FROM communities WHERE alias = ?", alias) + if err != nil { + return nil, err + } + if rs.Next() { + return nil, errors.New("a community with that alias already exists") + } + + // establish the community record + _, err = amdb.Exec(`INSERT INTO communities (createdate, lastaccess, lastupdate, read_lvl, write_lvl, + create_lvl, delete_lvl, join_lvl, host_uid, hide_dir, hide_search, commname, language, + synopsis, rules, joinkey, alias) VALUES (NOW(), NOW(), NOW(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + AmRoleList("Community.Read").Default().Level(), AmRoleList("Community.Write").Default().Level(), + AmRoleList("Community.Create").Default().Level(), AmRoleList("Community.Delete").Default().Level(), + AmRoleList("Community.Join").Default().Level(), hostUid, hideDirectory, hideSearch, name, language, + synopsis, rules, joinkey, alias) + if err != nil { + return nil, err + } + + // Read back the community, which also puts it in the cache. + comm, err := AmGetCommunityByAlias(alias) + if err != nil { + return nil, err + } else if comm == nil { + return nil, errors.New("unable to find newly-generated community") + } + + // Establish the community services. + err = AmEstablishCommunityServices(comm.Id) + if err != nil { + return nil, err + } + + // Ensure the new host has host privileges in the community. The host's membership is "locked" so they + // can't unjoin and leave the community hostless. + _, err = amdb.Exec("INSERT INTO commmember (commid, uid, granted_lvl, locked) VALUES (?, ?, ?, 1)", comm.Id, hostUid, + AmDefaultRole("Community.Creator").Level()) + if err != nil { + return nil, err + } + stuffMembership(comm.Id, hostUid, true, true, AmDefaultRole("Community.Creator").Level()) + + amdb.Exec("UNLOCK TABLES;") + unlock = false + + // operation was a success - add an audit record + ar = AmNewAudit(AuditCommunityCreate, hostUid, remoteIP, fmt.Sprintf("id=%d", comm.Id), + fmt.Sprintf("name=%s", comm.Name), fmt.Sprintf("alias=%s", comm.Alias)) + return comm, nil +} diff --git a/database/contactinfo.go b/database/contactinfo.go index ffe8402..386f9d6 100644 --- a/database/contactinfo.go +++ b/database/contactinfo.go @@ -250,3 +250,15 @@ func AmNewUserContactInfo(uid int32) *ContactInfo { rc := ContactInfo{OwnerUid: uid, OwnerCommId: -1} return &rc } + +/* AmNewCommunityContactInfo creates a new contact info record for the community. + * Parameters: + * uid - The UID of the host of this community. + * cid - The community ID of the owning community. + * Returns: + * New ContactInfo structure. + */ +func AmNewCommunityContactInfo(uid int32, cid int32) *ContactInfo { + rc := ContactInfo{OwnerUid: uid, OwnerCommId: cid} + return &rc +} diff --git a/database/services.go b/database/services.go index 3defcb9..d4308f7 100644 --- a/database/services.go +++ b/database/services.go @@ -114,3 +114,27 @@ func AmGetCommunityServices(cid int32) ([]*ServiceDef, error) { } return rc.([]*ServiceDef), nil } + +/* AmEstablishCommunityServices extablishes the service (feature) records for a new community. + * Parameters: + * cid - ID of the new community. + * Returns: + * Standard Go error status. + */ +func AmEstablishCommunityServices(cid int32) error { + dom := serviceRoot.byName["community"] + a := make([]*ServiceDef, 0, len(dom.Services)) + for i, svc := range dom.Services { + if svc.Default { + _, err := amdb.Exec("INSERT INTO commftrs (commid, ftr_code) VALUES (?, ?)", cid, svc.Index) + if err != nil { + return err + } + a = append(a, &(dom.Services[i])) + } + } + servicesCacheMutex.Lock() + servicesCache.Add(cid, a) + servicesCacheMutex.Unlock() + return nil +} diff --git a/main.go b/main.go index b4cbc77..fd013d6 100644 --- a/main.go +++ b/main.go @@ -64,6 +64,8 @@ func setupEcho() *echo.Echo { e.GET("/user/:uname", ui.AmWrap(ShowProfile)) e.POST("/quick_email", ui.AmWrap(QuickEMail)) e.GET("/sysadmin", ui.AmWrap(SysAdminMenu)) + e.GET("/create_comm", ui.AmWrap(CreateCommunityForm)) + e.POST("/create_comm", ui.AmWrap(CreateCommunity)) e.GET("/comm/:cid/profile", ui.AmWrap(ShowCommunity)) e.GET("/comm/:cid/admin", ui.AmWrap(CommunityAdminMenu)) e.GET("/comm/:cid/admin/profile", ui.AmWrap(CommunityProfileForm)) diff --git a/ui/dialogs/commprofile.yaml b/ui/dialogs/commprofile.yaml index a1d4ca8..c3d04ae 100644 --- a/ui/dialogs/commprofile.yaml +++ b/ui/dialogs/commprofile.yaml @@ -13,9 +13,6 @@ title: "Edit Community Profile:" subtitle: "[CNAME]" action: "/comm/[CID]/admin/profile" fields: - - type: "hidden" - name: "cc" - value: "" - type: "header" name: "header1" caption: "Basic Information" diff --git a/ui/dialogs/create_comm.yaml b/ui/dialogs/create_comm.yaml new file mode 100644 index 0000000..ef7de51 --- /dev/null +++ b/ui/dialogs/create_comm.yaml @@ -0,0 +1,107 @@ +# +# 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/. +# +name: "create.community" +formName: "createcommform" +menuSelector: "top" +title: "Create New Community" +action: "/create_comm" +fields: + - type: "header" + name: "header1" + caption: "Basic Information" + - type: "text" + name: "name" + caption: "Community Name" + required: true + size: 32 + maxlength: 128 + - type: "ams_id" + name: "alias" + caption: "Community Alias" + required: true + size: 32 + maxlength: 32 + - type: "text" + name: "synopsis" + caption: "Synopsis" + size: 32 + maxlength: 255 + - type: "text" + name: "rules" + caption: "Rules" + size: 32 + maxlength: 255 + - type: "localelist" + name: "language" + caption: "Primary Language" + required: true + - type: "header" + name: "header2" + caption: "Location" + - type: "text" + name: "loc" + caption: "City" + required: true + size: 32 + maxlength: 64 + - type: "text" + name: "reg" + caption: "State/Province" + required: true + size: 32 + maxlength: 64 + - type: "text" + name: "pcode" + caption: "Zip/Postal Code" + required: true + size: 32 + maxlength: 64 + - type: "countrylist" + name: "country" + caption: "Country" + required: true + - type: "header" + name: "header3" + caption: "Security" + - type: "dropdown" + name: "comtype" + caption: "Community type" + required: true + choices: + - id: "0" + text: "Public" + default: true + - id: "1" + text: "Private" + - type: "text" + name: "joinkey" + caption: "Join Key" + subcaption: "(for private communities)" + size: 32 + maxlength: 64 + - type: "dropdown" + name: "hidemode" + caption: "Community visibility" + required: true + choices: + - id: "NONE" + text: "Show in both directory and search" + default: true + - id: "DIRECTORY" + text: "Hide in directory, but not in search" + - id: "BOTH" + text: "Hide in both directory and search" + - type: "button" + name: "create" + caption: "Create" + param: "blue" + - type: "button" + name: "cancel" + caption: "Cancel" + param: "red" diff --git a/ui/views/sb_ftrcomm.jet b/ui/views/sb_ftrcomm.jet index 9d7a43e..f86a990 100644 --- a/ui/views/sb_ftrcomm.jet +++ b/ui/views/sb_ftrcomm.jet @@ -14,17 +14,17 @@
- {{ if len(sb.Items) > 0 }} - {{ range sb.Items }} -
- 🟣 - {{ .Text }} - {{ if .Flags["admin"] }}👑{{ end }} -
- {{ end }} - {{ else }} -
You are not a member of any communities.
+ {{ if len(sb.Items) > 0 }} + {{ range sb.Items }} +
+ 🟣 + {{ .Text }} + {{ if .Flags["admin"] }}👑{{ end }} +
{{ end }} + {{ else }} +
You are not a member of any communities.
+ {{ end }}
{{ if sb.Flags["canManage"] || sb.Flags["canCreate"] }}
@@ -35,7 +35,7 @@ {{ if sb.Flags["canCreate"] }}|{{ end }} {{ end }} {{ if sb.Flags["canCreate"] }} - Create New + Create New {{ end }} ]