diff --git a/communityadmin.go b/communityadmin.go index e684f6c..21b27c6 100644 --- a/communityadmin.go +++ b/communityadmin.go @@ -12,6 +12,7 @@ package main import ( "errors" + "fmt" "net/http" "git.erbosoft.com/amy/amsterdam/database" @@ -48,3 +49,76 @@ func CommunityAdminMenu(ctxt ui.AmContext) (string, any, error) { ctxt.VarMap().Set("amsterdam_pageTitle", menu.Title+" - "+comm.Name) return "framed_template", "menu.jet", nil } + +func setupCommunityProfileDialog(dlg *ui.Dialog, comm *database.Community) { + dlg.SetCommunity(comm) + if comm.IsAdmin { + dlg.Field("comtype").Disabled = true + dlg.Field("joinkey").Disabled = true + dlg.Field("membersonly").Disabled = true + dlg.Field("hidemode").Disabled = true + dlg.Field("read_lvl").Disabled = true + dlg.Field("write_lvl").Disabled = true + dlg.Field("create_lvl").Disabled = true + dlg.Field("delete_lvl").Disabled = true + dlg.Field("join_lvl").Disabled = true + } +} + +func CommunityProfileForm(ctxt ui.AmContext) (string, any, error) { + err := ctxt.SetCommunityContext(ctxt.URLParam("cid")) + if err != nil { + ctxt.SetRC(http.StatusNotFound) + return ui.ErrorPage(ctxt, err) + } + comm := ctxt.CurrentCommunity() + if !comm.TestPermission("Community.Write", ctxt.EffectiveLevel()) { + ctxt.SetRC(http.StatusForbidden) + return ui.ErrorPage(ctxt, errors.New("you are not permitted to access this page")) + } + var ci *database.ContactInfo + ci, err = comm.ContactInfo() + if err != nil { + return ui.ErrorPage(ctxt, err) + } + flags, err := comm.Flags() + if err != nil { + return ui.ErrorPage(ctxt, err) + } + 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) + dlg.Field("rules").SetVal(comm.Rules) + dlg.Field("language").SetVal(comm.Language) + dlg.Field("url").SetVal(ci.URL) + // TODO: set logo URL + dlg.Field("company").SetVal(ci.Company) + dlg.Field("addr1").SetVal(ci.Addr1) + dlg.Field("addr2").SetVal(ci.Addr2) + dlg.Field("loc").SetVal(ci.Locality) + dlg.Field("reg").SetVal(ci.Region) + dlg.Field("pcode").SetVal(ci.PostalCode) + dlg.Field("country").SetVal(ci.Country) + if comm.Public() { + dlg.Field("comtype").Value = "0" + dlg.Field("joinkey").Value = "" + } else { + dlg.Field("comtype").Value = "1" + dlg.Field("joinkey").SetVal(comm.JoinKey) + } + dlg.Field("membersonly").SetChecked(comm.MembersOnly) + dlg.Field("hidemode").Value = comm.HideMode() + dlg.Field("read_lvl").Value = fmt.Sprintf("%d", comm.ReadLevel) + dlg.Field("write_lvl").Value = fmt.Sprintf("%d", comm.WriteLevel) + dlg.Field("create_lvl").Value = fmt.Sprintf("%d", comm.CreateLevel) + dlg.Field("delete_lvl").Value = fmt.Sprintf("%d", comm.DeleteLevel) + dlg.Field("join_lvl").Value = fmt.Sprintf("%d", comm.JoinLevel) + dlg.Field("pic_in_post").SetChecked(flags.Get(database.CommunityFlagPicturesInPosts)) + return dlg.Render(ctxt) + } + return ui.ErrorPage(ctxt, err) +} diff --git a/database/community.go b/database/community.go index ce93325..a9f6881 100644 --- a/database/community.go +++ b/database/community.go @@ -47,14 +47,38 @@ type Community struct { Rules *string `dd:"rules"` JoinKey *string `db:"joinkey"` Alias string `db:"alias"` + flags *util.OptionSet } +// CommunityProperties represents a property entry for a community. +type CommunityProperties struct { + Cid int32 `db:"cid"` + Index int32 `db:"ndx"` + Data *string `db:"data"` +} + +// Community property indexes defined. +const ( + CommunityPropFlags = int32(0) // "flags" user property +) + +// Flag values for community property index CommunityPropFlags defined. +const ( + CommunityFlagPicturesInPosts = uint(0) +) + // communityCache is the cache for Community objects. var communityCache *lru.TwoQueueCache = nil // getCommunityMutex is a mutex on AmGetCommunity. var getCommunityMutex sync.Mutex +// communityPropCache is the cache for CommunityProperties objects. +var communityPropCache *lru.Cache = nil + +// getCommunityPropMutex is a mutex on AmGetCommunityProperty. +var getCommunityPropMutex sync.Mutex + // memberCacheData caches membership information for communities. type memberCacheData struct { isMember bool @@ -76,7 +100,7 @@ func stuffMembership(cid int32, uid int32, member bool, locked bool, level uint1 memberMutex.Unlock() } -// init initializes the community cache. +// init initializes the caches. func init() { var err error communityCache, err = lru.New2Q(50) @@ -87,6 +111,10 @@ func init() { if err != nil { panic(err) } + communityPropCache, err = lru.New(100) + if err != nil { + panic(err) + } } // Public returns true if the community is public. @@ -121,6 +149,16 @@ func (c *Community) LanguageTag() (*language.Tag, error) { return &t, nil } +func (c *Community) HideMode() string { + if c.HideFromSearch { + return "BOTH" + } else if c.HideFromDirectory { + return "DIRECTORY" + } else { + return "NONE" + } +} + /* Membership returns the details of the specified user's membership in the community. * Parameters: * u - The user to check the membership of. @@ -200,6 +238,35 @@ func (c *Community) PermissionLevel(perm string) uint16 { } } +// GetFlags retrieves the flags from the properties. +func (c *Community) Flags() (*util.OptionSet, error) { + c.Mutex.Lock() + defer c.Mutex.Unlock() + if c.flags == nil { + s, err := AmGetCommunityProperty(c.Id, CommunityPropFlags) + if err != nil { + return nil, err + } + if s == nil { + return nil, fmt.Errorf("missing flags for community %d", c.Id) + } + c.flags = util.OptionSetFromString(*s) + } + return c.flags, nil +} + +// SaveFlags writes the flags to the database and stores them. +func (c *Community) SaveFlags(f *util.OptionSet) error { + s := f.AsString() + c.Mutex.Lock() + defer c.Mutex.Unlock() + err := AmSetCommunityProperty(c.Id, CommunityPropFlags, &s) + if err == nil { + c.flags = f + } + return err +} + /* AmGetCommunity returns a reference to the specified community. * Parameters: * id - The ID of the community. @@ -342,3 +409,74 @@ func AmAutoJoinCommunities(user *User) error { } return err } + +// internalGetProp is a helper used by the property functions. +func internalGetCommProp(cid int32, ndx int32) (*CommunityProperties, error) { + var err error = nil + key := fmt.Sprintf("%d:%d", cid, ndx) + getCommunityPropMutex.Lock() + defer getCommunityPropMutex.Unlock() + rc, ok := communityPropCache.Get(key) + if !ok { + var dbdata []CommunityProperties + err = amdb.Select(&dbdata, "SELECT * from propcomm WHERE cid = ? AND ndx = ?", cid, ndx) + if err != nil { + return nil, err + } + if len(dbdata) == 0 { + return nil, nil + } + if len(dbdata) > 1 { + return nil, fmt.Errorf("AmGetCommunityProperty(%d): too many responses(%d)", cid, len(dbdata)) + } + rc = &(dbdata[0]) + communityPropCache.Add(key, rc) + } + return rc.(*CommunityProperties), nil +} + +/* AmGetCommunityProperty retrieves the value of a user property. + * Parameters: + * cid - The ID of the community to get the property for. + * ndx - The index of the property to retrieve. + * Returns: + * Value of the property string. + * Standard Go error status. + */ +func AmGetCommunityProperty(cid int32, ndx int32) (*string, error) { + p, err := internalGetCommProp(cid, ndx) + if err != nil { + return nil, err + } + return p.Data, nil +} + +/* AmSetCommunityProperty sets the value of a community property. + * Parameters: + * cid - The ID of the community 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 AmSetCommunityProperty(cid int32, ndx int32, val *string) error { + p, err := internalGetCommProp(cid, ndx) + if err != nil { + return err + } + getCommunityPropMutex.Lock() + defer getCommunityPropMutex.Unlock() + if p != nil { + _, err = amdb.Exec("UPDATE propcomm SET data = ? WHERE cid = ? AND ndx = ?", val, cid, ndx) + if err == nil { + p.Data = val + } + } else { + prop := CommunityProperties{Cid: cid, Index: ndx, Data: val} + _, err := amdb.NamedExec("INSERT INTO propcomm (cid, ndx, data) VALUES(:cid, :ndx, :data)", prop) + if err == nil { + communityPropCache.Add(fmt.Sprintf("%d:%d", cid, ndx), prop) + } + } + return err +} diff --git a/database/security.go b/database/security.go index 1a24352..5a4575b 100644 --- a/database/security.go +++ b/database/security.go @@ -11,6 +11,7 @@ package database import ( _ "embed" + "fmt" "strconv" "strings" @@ -184,6 +185,7 @@ type Role interface { ID() string Name() string Level() uint16 + LevelStr() string } func (r *CfgRole) ID() string { @@ -198,6 +200,10 @@ func (r *CfgRole) Level() uint16 { return r.level } +func (r *CfgRole) LevelStr() string { + return fmt.Sprintf("%d", r.level) +} + // RoleList defines a list of security roles. type RoleList interface { Roles() []Role diff --git a/database/securitydefs.yaml b/database/securitydefs.yaml index 2488f30..46f6eda 100644 --- a/database/securitydefs.yaml +++ b/database/securitydefs.yaml @@ -184,5 +184,3 @@ permissions: role: "Community.AnyAdmin" - name: "Community.MassMail" role: "Community.AnyAdmin" - - name: "Community.Destroy" - role: "Community.Host" diff --git a/main.go b/main.go index 05bd6c6..df59211 100644 --- a/main.go +++ b/main.go @@ -66,6 +66,7 @@ func setupEcho() *echo.Echo { e.GET("/sysadmin", ui.AmWrap(SysAdminMenu)) 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)) return e } diff --git a/ui/dialog.go b/ui/dialog.go index 4ce016c..01be195 100644 --- a/ui/dialog.go +++ b/ui/dialog.go @@ -23,17 +23,26 @@ import ( "gopkg.in/yaml.v3" ) +// DialogItemChoice holds a dialog item choice (only needed in case of defined dropdowns) +type DialogItemChoice struct { + Id string `yaml:"id"` + Text string `yaml:"text"` + Default bool `yaml:"default,omitempty"` +} + // DialogItem holds the dialog item definition. type DialogItem struct { - Type string `yaml:"type"` - Name string `yaml:"name"` - Caption string `yaml:"caption,omitempty"` - Subcaption string `yaml:"subcaption,omitempty"` - Required bool `yaml:"required,omitempty"` - Size int `yaml:"size,omitempty"` - MaxLength int `yaml:"maxlength,omitempty"` - Value string `yaml:"value,omitempty"` - Param string `yaml:"param,omitempty"` + Type string `yaml:"type"` + Name string `yaml:"name"` + Caption string `yaml:"caption,omitempty"` + Subcaption string `yaml:"subcaption,omitempty"` + Required bool `yaml:"required,omitempty"` + Disabled bool `yaml:"disabled,omitempty"` + Size int `yaml:"size,omitempty"` + MaxLength int `yaml:"maxlength,omitempty"` + Value string `yaml:"value,omitempty"` + Param string `yaml:"param,omitempty"` + Choices []DialogItemChoice `yaml:"choices,omitempty"` AuxData any } @@ -44,6 +53,7 @@ type Dialog struct { Options string `yaml:"options,omitempty"` MenuSelector string `yaml:"menuSelector,omitempty"` Title string `yaml:"title"` + Subtitle string `yaml:"subtitle,omitempty"` Action string `yaml:"action"` Instructions string `yaml:"instructions,omitempty"` Fields []DialogItem `yaml:"fields"` @@ -82,6 +92,9 @@ func AmLoadDialog(name string) (*Dialog, error) { if fld.Type == "date" && fld.Param == "" { d.Fields[i].Param = "year:-100" } + if fld.Type == "dropdown" && len(fld.Choices) == 0 { + return nil, fmt.Errorf("dropdown field %s in dialog %s has no choices", fld.Name, name) + } } return &d, nil } @@ -184,11 +197,29 @@ func (fld *DialogItem) SetVal(p *string) { } } +// SetInt sets the value of a field to an integer. +func (fld *DialogItem) SetInt(v int) { + fld.Value = fmt.Sprintf("%d", v) +} + // IsEmpty returns true if the field is empty. func (fld *DialogItem) IsEmpty() bool { return len(fld.Value) == 0 } +// SetCommunity alters a dialog's content to reflect the community. +func (d *Dialog) SetCommunity(comm *database.Community) { + d.Title = strings.ReplaceAll(d.Title, "[CNAME]", comm.Name) + d.Subtitle = strings.ReplaceAll(d.Subtitle, "[CNAME]", comm.Name) + d.Action = strings.ReplaceAll(d.Action, "[CID]", comm.Alias) + for i, fld := range d.Fields { + switch fld.Type { + case "userphoto", "communitylogo": + d.Fields[i].Param = strings.ReplaceAll(fld.Param, "[CID]", comm.Alias) + } + } +} + /* Field returns a pointer to a dialog's field, given its name. * Parameters: * name - The name of the field to find. @@ -224,6 +255,46 @@ func (d *Dialog) Render(ctxt AmContext) (string, any, error) { if d.Fields[i].Value == "" { d.Fields[i].Value = config.GlobalConfig.Defaults.TimeZone } + case "dropdown": + defv := "" + for _, ch := range fld.Choices { + if ch.Default { + defv = ch.Id + break + } + } + if d.Fields[i].Value == "" { + d.Fields[i].Value = defv + } else { + ok := false + for _, ch := range fld.Choices { + if d.Fields[i].Value == ch.Id { + ok = true + break + } + } + if !ok { + d.Fields[i].Value = defv + } + } + case "rolelist": + rl := database.AmRoleList(fld.Param) + defv := rl.Default().LevelStr() + if d.Fields[i].Value == "" { + d.Fields[i].Value = defv + } else { + ok := false + for _, r := range rl.Roles() { + if d.Fields[i].Value == r.LevelStr() { + ok = true + break + } + if !ok { + d.Fields[i].Value = defv + } + } + } + // TODO: want to do something like dropdown but not sure what yet } } if d.MenuSelector != "" && d.MenuSelector != "nochange" { @@ -312,7 +383,7 @@ func (d *Dialog) LoadFromForm(ctxt AmContext) { } } d.Fields[i].AuxData = dvals - case "userphoto": + case "userphoto", "communitylogo": d.Fields[i].Value = ctxt.FormField(fmt.Sprintf("%s_data", fld.Name)) default: d.Fields[i].Value = ctxt.FormField(fld.Name) @@ -468,20 +539,23 @@ func validateDateField(fld *DialogItem) error { // validators maps the field types to validator functions. var validators = map[string]validatorFunc{ - "ams_id": validateAmsIdField, - "button": nilValidator, - "checkbox": nilValidator, - "countrylist": validateCountryField, - "date": validateDateField, - "email": validateEmailField, - "header": nilValidator, - "hidden": nilValidator, - "integer": validateIntegerField, - "localelist": nilValidator, // TODO - "password": validateTextField, - "text": validateTextField, - "tzlist": nilValidator, // TODO - "userphoto": nilValidator, + "ams_id": validateAmsIdField, + "button": nilValidator, + "checkbox": nilValidator, + "communitylogo": nilValidator, + "countrylist": validateCountryField, + "date": validateDateField, + "dropdown": nilValidator, + "email": validateEmailField, + "header": nilValidator, + "hidden": nilValidator, + "integer": validateIntegerField, + "localelist": nilValidator, + "password": validateTextField, + "rolelist": nilValidator, + "text": validateTextField, + "tzlist": nilValidator, + "userphoto": nilValidator, } /* Validate validates the values in the dialog. diff --git a/ui/dialogs/commprofile.yaml b/ui/dialogs/commprofile.yaml new file mode 100644 index 0000000..a0d1f58 --- /dev/null +++ b/ui/dialogs/commprofile.yaml @@ -0,0 +1,172 @@ +# +# 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: "community.profile" +formName: "comprofform" +menuSelector: "community" +title: "Edit Community Profile:" +subtitle: "[CNAME]" +action: "/comm/[CID]/admin/profile" +fields: + - type: "hidden" + name: "cc" + value: "" + - 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: "text" + name: "url" + caption: "Home Page" + subcaption: "(URL)" + size: 32 + maxlength: 255 + - type: "communitylogo" + name: "logo" + caption: "Community logo" + param: "/TODO/comm/[CID]/admin/logo" + - type: "header" + name: "header2" + caption: "Location" + - type: "text" + name: "company" + caption: "Company" + size: 32 + maxlength: 255 + - type: "text" + name: "addr1" + caption: "Address" + size: 32 + maxlength: 255 + - type: "text" + name: "addr2" + caption: "Address" + subcaption: "(line 2)" + size: 32 + maxlength: 255 + - 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: "checkbox" + name: "membersonly" + caption: "Allow only members to access this community" + - 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: "rolelist" + name: "read_lvl" + caption: "Security level required to read contents" + required: true + param: "Community.Read" + - type: "rolelist" + name: "write_lvl" + caption: "Security level required to update profile" + required: true + param: "Community.Write" + - type: "rolelist" + name: "create_lvl" + caption: "Security level required to create new subobjects" + required: true + param: "Community.Create" + - type: "rolelist" + name: "delete_lvl" + caption: "Security level required to delete community" + required: true + param: "Community.Delete" + - type: "rolelist" + name: "join_lvl" + caption: "Security level required to join community" + required: true + param: "Community.Join" + - type: "header" + name: "header4" + caption: "Conferencing Options" + - type: "checkbox" + name: "pic_in_post" + caption: "Display user pictures next to posts in conferences" + subcaption: "(by default; user can override)" + - type: "button" + name: "update" + caption: "Update" + param: "blue" + - type: "button" + name: "cancel" + caption: "Cancel" + param: "red" diff --git a/ui/menudefs.yaml b/ui/menudefs.yaml index ef5f23e..dcc2925 100644 --- a/ui/menudefs.yaml +++ b/ui/menudefs.yaml @@ -61,15 +61,15 @@ menudefs: community. Use with care and review all changes before applying them to the community. items: - text: "Community Profile" - link: "/TODO/comm/[CID]/admin/profile" - permission: "Community.ShowAdmin" + link: "/comm/[CID]/admin/profile" + permission: "Community.Write" - text: "Set Community Category" link: "/TODO/comm/[CID]/admin/category" - permission: "Community.ShowAdmin" + permission: "Community.Write" ifdef: "USECAT" - text: "Set Community Services" link: "/TODO/comm/[CID]/admin/services" - permission: "Community.ShowAdmin" + permission: "Community.Write" disabled: true - text: "Membership Control" link: "/TODO/comm/[CID]/admin/members" @@ -82,5 +82,5 @@ menudefs: permission: "Community.ShowAdmin" - text: "Delete Community" link: "/TODO/comm/[CID]/admin/delete" - permission: "Community.Destroy" + permission: "Community.Delete" hazard: true diff --git a/ui/templates.go b/ui/templates.go index b36a2a3..c414728 100644 --- a/ui/templates.go +++ b/ui/templates.go @@ -23,6 +23,7 @@ import ( "time" "git.erbosoft.com/amy/amsterdam/config" + "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/util" "github.com/CloudyKit/jet/v6" "github.com/CloudyKit/jet/v6/loaders/embedfs" @@ -235,6 +236,10 @@ func SetupTemplates() { s := a.Get(0).Convert(reflect.TypeFor[string]()).String() return reflect.ValueOf(AmMenu(s)) }) + views.AddGlobalFunc("AmRoleList", func(a jet.Arguments) reflect.Value { + s := a.Get(0).Convert(reflect.TypeFor[string]()).String() + return reflect.ValueOf(database.AmRoleList(s)) + }) views.AddGlobalFunc("CapitalizeString", func(a jet.Arguments) reflect.Value { s := a.Get(0).Convert(reflect.TypeFor[string]()).String() return reflect.ValueOf(util.CapitalizeString(s)) diff --git a/ui/views/dialog.jet b/ui/views/dialog.jet index 9ce8064..1650630 100644 --- a/ui/views/dialog.jet +++ b/ui/views/dialog.jet @@ -10,6 +10,9 @@