partial implementation of community profile page - next, straighten out left menus
This commit is contained in:
+136
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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/.
|
||||
*/
|
||||
// Package main contains the high-level Amsterdam logic.
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.erbosoft.com/amy/amsterdam/database"
|
||||
"git.erbosoft.com/amy/amsterdam/ui"
|
||||
"github.com/biter777/countries"
|
||||
"golang.org/x/text/language/display"
|
||||
)
|
||||
|
||||
func ShowCommunity(ctxt ui.AmContext) (string, any, error) {
|
||||
me := ctxt.CurrentUser()
|
||||
prefs, err := me.Prefs()
|
||||
if err != nil {
|
||||
return ui.ErrorPage(ctxt, err)
|
||||
}
|
||||
globals, err := database.AmGlobals()
|
||||
if err != nil {
|
||||
return ui.ErrorPage(ctxt, err)
|
||||
}
|
||||
globalFlags, err := globals.Flags()
|
||||
if err != nil {
|
||||
return ui.ErrorPage(ctxt, err)
|
||||
}
|
||||
comm, err := database.AmGetCommunityFromParam(ctxt.URLParam("cid"))
|
||||
if err != nil {
|
||||
ctxt.SetRC(http.StatusNotFound)
|
||||
return ui.ErrorPage(ctxt, err)
|
||||
}
|
||||
member, _, level, err := comm.Membership(me)
|
||||
if err != nil {
|
||||
return ui.ErrorPage(ctxt, err)
|
||||
}
|
||||
effectiveLevel := me.BaseLevel
|
||||
if member && level > effectiveLevel {
|
||||
effectiveLevel = level
|
||||
}
|
||||
ci, err := comm.ContactInfo()
|
||||
if err != nil {
|
||||
return ui.ErrorPage(ctxt, err)
|
||||
}
|
||||
host, err := comm.Host()
|
||||
if err != nil {
|
||||
return ui.ErrorPage(ctxt, err)
|
||||
}
|
||||
var cats []*database.Category
|
||||
if !globalFlags.Get(database.GlobalFlagNoCategories) {
|
||||
cats, err = database.AmGetCategoryHierarchy(comm.CategoryId)
|
||||
if err != nil {
|
||||
return ui.ErrorPage(ctxt, err)
|
||||
}
|
||||
}
|
||||
var pvtAddr bool
|
||||
if database.AmTestPermission("Global.SeeHiddenContactInfo", effectiveLevel) {
|
||||
pvtAddr = false
|
||||
} else {
|
||||
pvtAddr = ci.PrivateAddr
|
||||
}
|
||||
|
||||
ctxt.VarMap().Set("commName", comm.Name)
|
||||
// TODO: set photo URL
|
||||
tz := prefs.Location()
|
||||
loc := prefs.Localizer()
|
||||
ctxt.VarMap().Set("dateCreated", loc.Strftime("%x %X", comm.CreateDate.In(tz)))
|
||||
if comm.LastAccess != nil {
|
||||
ctxt.VarMap().Set("dateLastAccess", loc.Strftime("%x %X", (*comm.LastAccess).In(tz)))
|
||||
}
|
||||
if comm.LastUpdate != nil {
|
||||
ctxt.VarMap().Set("dateLastUpdate", loc.Strftime("%x %X", (*comm.LastUpdate).In(tz)))
|
||||
}
|
||||
if !member && effectiveLevel >= comm.JoinLevel {
|
||||
ctxt.VarMap().Set("canJoin", true)
|
||||
}
|
||||
if member && !me.IsAnon {
|
||||
ctxt.VarMap().Set("canInvite", true)
|
||||
}
|
||||
ctxt.VarMap().Set("public", comm.Public())
|
||||
if !globalFlags.Get(database.GlobalFlagNoCategories) {
|
||||
ctxt.VarMap().Set("categories", cats)
|
||||
}
|
||||
if comm.Synopsis != nil && *comm.Synopsis != "" {
|
||||
ctxt.VarMap().Set("description", *comm.Synopsis)
|
||||
}
|
||||
ctxt.VarMap().Set("hostName", host.Username)
|
||||
if ci.Company != nil && *ci.Company != "" {
|
||||
ctxt.VarMap().Set("company", *ci.Company)
|
||||
}
|
||||
if !pvtAddr && ci.Addr1 != nil && *ci.Addr1 != "" {
|
||||
ctxt.VarMap().Set("addr1", *ci.Addr1)
|
||||
}
|
||||
if !pvtAddr && ci.Addr2 != nil && *ci.Addr2 != "" {
|
||||
ctxt.VarMap().Set("addr2", *ci.Addr2)
|
||||
}
|
||||
var b strings.Builder
|
||||
if ci.Locality != nil {
|
||||
b.WriteString(*ci.Locality)
|
||||
if ci.Region != nil {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
}
|
||||
if ci.Region != nil {
|
||||
b.WriteString(*ci.Region)
|
||||
}
|
||||
if ci.PostalCode != nil {
|
||||
b.WriteString(" " + *ci.PostalCode)
|
||||
}
|
||||
ctxt.VarMap().Set("addrLast", b.String())
|
||||
if ci.Country != nil && *ci.Country != "" {
|
||||
country := countries.ByName(*ci.Country)
|
||||
ctxt.VarMap().Set("country", country.String())
|
||||
}
|
||||
tag, err := comm.LanguageTag()
|
||||
if err == nil && tag != nil {
|
||||
ctxt.VarMap().Set("language", display.Languages(*prefs.LanguageTag()).Name(tag))
|
||||
}
|
||||
if comm.Rules != nil && *comm.Rules != "" {
|
||||
ctxt.VarMap().Set("rules", *comm.Rules)
|
||||
}
|
||||
if ci.URL != nil && *ci.URL != "" {
|
||||
ctxt.VarMap().Set("homePage", *ci.URL)
|
||||
}
|
||||
|
||||
ctxt.VarMap().Set("amsterdam_pageTitle", "Community Profile: "+comm.Name)
|
||||
return "framed_template", "comprofile.jet", nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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/.
|
||||
*/
|
||||
// The database package contains database management and storage logic.
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Category is the structure defining a category.
|
||||
type Category struct {
|
||||
CatId int32 `db:"catid"`
|
||||
Parent int32 `db:"parent"`
|
||||
SymLink int32 `db:"symlink"`
|
||||
HideDirectory int32 `db:"hide_dir"`
|
||||
HideSearch int32 `db:"hide_search"`
|
||||
Name string `db:"name"`
|
||||
}
|
||||
|
||||
// allCategories is the list of all categories loaded from the database.
|
||||
var allCategories []Category
|
||||
|
||||
// categoryIdMap maps IDs to categories.
|
||||
var categoryIdMap map[int32]*Category = make(map[int32]*Category)
|
||||
|
||||
// categoryMutex syncs the loading of the categories.
|
||||
var categoryMutex sync.Mutex
|
||||
|
||||
// loadCategories loads the categories list from the database.
|
||||
func loadCategories() error {
|
||||
categoryMutex.Lock()
|
||||
defer categoryMutex.Unlock()
|
||||
if allCategories == nil {
|
||||
rs, err := amdb.Query("SELECT COUNT(*) FROM refcategory")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !rs.Next() {
|
||||
return errors.New("internal error loading categories")
|
||||
}
|
||||
var ncats int32
|
||||
rs.Scan(&ncats)
|
||||
allCategories = make([]Category, 0, ncats)
|
||||
err = amdb.Select(&allCategories, "SELECT * FROM refcategory ORDER BY parent, name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, c := range allCategories {
|
||||
categoryIdMap[c.CatId] = &(allCategories[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/* AmGetCategory returns the category for the given name.
|
||||
* Parameters:
|
||||
* catid - The ID of the category to get.
|
||||
* Returns:
|
||||
* Pointer to the appropriate Category, or nil.
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func AmGetCategory(catid int32) (*Category, error) {
|
||||
err := loadCategories()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := categoryIdMap[catid]
|
||||
d := 5
|
||||
for c.SymLink != -1 {
|
||||
d--
|
||||
if d == 0 {
|
||||
return nil, errors.New("symlink resolution error")
|
||||
}
|
||||
c = categoryIdMap[c.SymLink]
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
/* AmGetCategoryHierarchy returns the category hierarchy for the given ID.
|
||||
* Parameters:
|
||||
* catid - The ID of the category to get.
|
||||
* Returns:
|
||||
* Array of pointers to the categories in hierarchical order, or nil.
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func AmGetCategoryHierarchy(catid int32) ([]*Category, error) {
|
||||
err := loadCategories()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// walk all the way to the "root" (parent = -1)
|
||||
p := catid
|
||||
ia := make([]*Category, 0, 3)
|
||||
for p != -1 {
|
||||
c := categoryIdMap[p]
|
||||
for c.SymLink != -1 {
|
||||
c = categoryIdMap[c.SymLink]
|
||||
}
|
||||
ia = append(ia, c)
|
||||
p = c.Parent
|
||||
}
|
||||
// reverse the array for return
|
||||
rc := make([]*Category, 0, len(ia))
|
||||
for i := range ia {
|
||||
rc = append(rc, ia[len(ia)-(i+1)])
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
+151
-1
@@ -12,10 +12,13 @@ package database
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.erbosoft.com/amy/amsterdam/util"
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// Community struct contains the high level data for a community.
|
||||
@@ -52,6 +55,27 @@ var communityCache *lru.TwoQueueCache = nil
|
||||
// getCommunityMutex is a mutex on AmGetCommunity.
|
||||
var getCommunityMutex sync.Mutex
|
||||
|
||||
// memberCacheData caches membership information for communities.
|
||||
type memberCacheData struct {
|
||||
isMember bool
|
||||
locked bool
|
||||
level uint16
|
||||
}
|
||||
|
||||
// memberCache contains the memberCacheData entries.
|
||||
var memberCache *lru.Cache = nil
|
||||
|
||||
// memberMutex syncs access to the memberCache.
|
||||
var memberMutex sync.Mutex
|
||||
|
||||
// stuffMembership stuffs a membership record into the cache.
|
||||
func stuffMembership(cid int32, uid int32, member bool, locked bool, level uint16) {
|
||||
key := fmt.Sprintf("%d:%d", cid, uid)
|
||||
memberMutex.Lock()
|
||||
memberCache.Add(key, &memberCacheData{isMember: member, locked: locked, level: level})
|
||||
memberMutex.Unlock()
|
||||
}
|
||||
|
||||
// init initializes the community cache.
|
||||
func init() {
|
||||
var err error
|
||||
@@ -59,6 +83,98 @@ func init() {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
memberCache, err = lru.New(250)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Public returns true if the community is public.
|
||||
func (c *Community) Public() bool {
|
||||
return c.JoinKey == nil || *c.JoinKey == ""
|
||||
}
|
||||
|
||||
// ContactInfo returns the contact info structure for the community.
|
||||
func (c *Community) ContactInfo() (*ContactInfo, error) {
|
||||
if c.ContactId < 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return AmGetContactInfo(c.ContactId)
|
||||
}
|
||||
|
||||
// Host returns the reference to the host of the community.
|
||||
func (c *Community) Host() (*User, error) {
|
||||
if c.HostUid == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return AmGetUser(*c.HostUid)
|
||||
}
|
||||
|
||||
func (c *Community) LanguageTag() (*language.Tag, error) {
|
||||
if c.Language == nil {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := language.Parse(*c.Language)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
/* Membership returns the details of the specified user's membership in the community.
|
||||
* Parameters:
|
||||
* u - The user to check the membership of.
|
||||
* Returns:
|
||||
* true if the user is a member, false if not.
|
||||
* true if the user's membership is "locked" (cannot unjoin), false if not.
|
||||
* User's access level in the community, or 0 if the user is not a member.
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func (c *Community) Membership(u *User) (bool, bool, uint16, error) {
|
||||
key := fmt.Sprintf("%d:%d", c.Id, u.Uid)
|
||||
memberMutex.Lock()
|
||||
defer memberMutex.Unlock()
|
||||
mbr, ok := memberCache.Get(key)
|
||||
if ok {
|
||||
m := mbr.(*memberCacheData)
|
||||
return m.isMember, m.locked, m.level, nil
|
||||
}
|
||||
if AmTestPermission("Community.NoJoinRequired", u.BaseLevel) {
|
||||
// "no join required" - they are effectively a member, but don't cache that
|
||||
return true, false, u.BaseLevel, nil
|
||||
}
|
||||
rs, err := amdb.Query("SELECT locked, granted_lvl FROM commmember WHERE commid = ? AND uid = ?", c.Id, u.Uid)
|
||||
if err == nil {
|
||||
if rs.Next() {
|
||||
var locked bool
|
||||
var level uint16
|
||||
rs.Scan(&locked, &level)
|
||||
memberCache.Add(key, &memberCacheData{isMember: true, locked: locked, level: level})
|
||||
return true, locked, level, nil
|
||||
}
|
||||
memberCache.Add(key, &memberCacheData{isMember: false, locked: false, level: uint16(0)})
|
||||
}
|
||||
return false, false, uint16(0), err
|
||||
}
|
||||
|
||||
/* TestPermission is shorthand that tests if a user has a permission with respect to the community.
|
||||
* Parameters:
|
||||
* user - The user to be checked.
|
||||
* perm - The permission to be tested.
|
||||
* Returns:
|
||||
* true if the user has the permission, false if not.
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func (c *Community) TestPermission(user *User, perm string) (bool, error) {
|
||||
member, _, level, err := c.Membership(user)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
effectiveLevel := user.BaseLevel
|
||||
if member && level > effectiveLevel {
|
||||
effectiveLevel = level
|
||||
}
|
||||
return AmTestPermission(perm, effectiveLevel), nil
|
||||
}
|
||||
|
||||
/* AmGetCommunity returns a reference to the specified community.
|
||||
@@ -79,7 +195,9 @@ func AmGetCommunity(id int32) (*Community, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(dbdata) > 1 {
|
||||
if len(dbdata) == 0 {
|
||||
return nil, fmt.Errorf("community with ID %d not found", id)
|
||||
} else if len(dbdata) > 1 {
|
||||
return nil, fmt.Errorf("AmGetCommunity(%d): too many responses(%d)", id, len(dbdata))
|
||||
}
|
||||
rc = &(dbdata[0])
|
||||
@@ -88,6 +206,37 @@ func AmGetCommunity(id int32) (*Community, error) {
|
||||
return rc.(*Community), 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.
|
||||
* Parameters:
|
||||
* id - The ID of the community.
|
||||
* Returns:
|
||||
* Pointer to Community containing community data, or nil
|
||||
* Standard Go error status
|
||||
*/
|
||||
func AmGetCommunityFromParam(param string) (*Community, error) {
|
||||
if util.IsNumeric(param) {
|
||||
v, _ := strconv.Atoi(param)
|
||||
c, err := AmGetCommunity(int32(v))
|
||||
if err == nil {
|
||||
return c, nil
|
||||
}
|
||||
// else fall through to trying as alias
|
||||
}
|
||||
rs, err := amdb.Query("SELECT commid FROM communities WHERE alias = ?", param)
|
||||
if err == nil {
|
||||
if rs.Next() {
|
||||
var cid int32
|
||||
rs.Scan(&cid)
|
||||
return AmGetCommunity(cid)
|
||||
} else {
|
||||
return nil, fmt.Errorf("community with alias \"%s\" not found", param)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
/* AmGetCommunitiesForUser returns a list of communities the user is a member of.
|
||||
* Parameters:
|
||||
* uid - The ID of the user.
|
||||
@@ -164,6 +313,7 @@ func AmAutoJoinCommunities(user *User) error {
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
stuffMembership(cid, user.Uid, true, lock, grantLevel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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/.
|
||||
*/
|
||||
// The database package contains database management and storage logic.
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"git.erbosoft.com/amy/amsterdam/util"
|
||||
)
|
||||
|
||||
// Globals contains the global data.
|
||||
type Globals struct {
|
||||
Mutex sync.Mutex
|
||||
PostsPerPage int32 `db:"posts_per_page"`
|
||||
OldPostsAtTop int32 `db:"old_posts_at_top"`
|
||||
MaxSearchPage int32 `db:"max_search_page"`
|
||||
MaxCommunityMemberPage int32 `db:"max_comm_mbr_page"`
|
||||
MaxConferenceMemberPage int32 `db:"max_conf_mbr_page"`
|
||||
FrontPagePosts int32 `db:"fp_posts"`
|
||||
NumAuditPage int32 `db:"num_audit_page"`
|
||||
CommunityCreateLevel int32 `db:"comm_create_lvl"`
|
||||
flags *util.OptionSet
|
||||
}
|
||||
|
||||
// GlobalProperties contains global property entries.
|
||||
type GlobalProperties struct {
|
||||
Index int32 `db:"ndx"`
|
||||
Data string `db:"data"`
|
||||
}
|
||||
|
||||
// Global property indexes defined.
|
||||
const (
|
||||
GlobalPropFlags = int32(0)
|
||||
)
|
||||
|
||||
// Global flag indexes defined.
|
||||
const (
|
||||
GlobalFlagPicturesInPosts = uint(0)
|
||||
GlobalFlagNoCategories = uint(1)
|
||||
)
|
||||
|
||||
// theGlobals contains the singleton instance of Globals.
|
||||
var theGlobals *Globals = nil
|
||||
|
||||
// globalsMutex controls access to theGlobals.
|
||||
var globalsMutex sync.Mutex
|
||||
|
||||
// globalProps is the global properties store.
|
||||
var globalProps map[int32]string = make(map[int32]string)
|
||||
|
||||
// globalPropMutex controls access to globalProps.
|
||||
var globalPropMutex sync.Mutex
|
||||
|
||||
// Flags returns the global flags.
|
||||
func (g *Globals) Flags() (*util.OptionSet, error) {
|
||||
g.Mutex.Lock()
|
||||
defer g.Mutex.Unlock()
|
||||
if g.flags == nil {
|
||||
s, err := AmGetGlobalProperty(GlobalPropFlags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.flags = util.OptionSetFromString(s)
|
||||
}
|
||||
return g.flags, nil
|
||||
}
|
||||
|
||||
// SaveFlags saves off the global flags.
|
||||
func (g *Globals) SaveFlags(f *util.OptionSet) error {
|
||||
s := f.AsString()
|
||||
g.Mutex.Lock()
|
||||
defer g.Mutex.Unlock()
|
||||
err := AmSetGlobalProperty(GlobalPropFlags, s)
|
||||
if err == nil {
|
||||
g.flags = f
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// AmGlobals returns trhe pointer to the singleton Globals instance.
|
||||
func AmGlobals() (*Globals, error) {
|
||||
globalsMutex.Lock()
|
||||
defer globalsMutex.Unlock()
|
||||
if theGlobals == nil {
|
||||
var dbdata []Globals
|
||||
err := amdb.Select(&dbdata, "SELECT * FROM globals")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(dbdata) > 1 {
|
||||
return nil, errors.New("should only be one globals record")
|
||||
}
|
||||
theGlobals = &(dbdata[0])
|
||||
}
|
||||
return theGlobals, nil
|
||||
}
|
||||
|
||||
/* AmGetGlobalProperty returns the value of a global property.
|
||||
* Parameters:
|
||||
* index - The index of the property to retrieve.
|
||||
* Returns:
|
||||
* Value of the property, or empty string.
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func AmGetGlobalProperty(index int32) (string, error) {
|
||||
globalPropMutex.Lock()
|
||||
defer globalPropMutex.Unlock()
|
||||
rc, ok := globalProps[index]
|
||||
if !ok {
|
||||
rs, err := amdb.Query("SELECT data FROM propglobal WHERE ndx = ?", index)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rs.Next() {
|
||||
rs.Scan(&rc)
|
||||
globalProps[index] = rc
|
||||
return rc, nil
|
||||
}
|
||||
rc = ""
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
/* AmSetGlobalProperty sets the value of a global property.
|
||||
* Parameters:
|
||||
* index - The index of the property to set.
|
||||
* value - The value of the property to set.
|
||||
* Returns:
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func AmSetGlobalProperty(index int32, value string) error {
|
||||
globalPropMutex.Lock()
|
||||
defer globalPropMutex.Unlock()
|
||||
_, updateMode := globalProps[index]
|
||||
if !updateMode {
|
||||
rs, err := amdb.Query("SELECT data FROM propglobal WHERE ndx = ?", index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updateMode = rs.Next()
|
||||
}
|
||||
var err error = nil
|
||||
if updateMode {
|
||||
_, err = amdb.Exec("UPDATE propglobal SET data = ? WHERE ndx = ?", value, index)
|
||||
} else {
|
||||
_, err = amdb.Exec("INSERT INTO propglobal (ndx, data) VALUES (?, ?)", index, value)
|
||||
}
|
||||
if err == nil {
|
||||
globalProps[index] = value
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#
|
||||
# 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/.
|
||||
#
|
||||
domains:
|
||||
- domain: "community"
|
||||
services:
|
||||
- id: "Profile"
|
||||
index: 0
|
||||
default: true
|
||||
locked: true
|
||||
requirePermission: ""
|
||||
requireRole: ""
|
||||
linkSequence: 4900
|
||||
link: "/comm/[CID]/profile"
|
||||
title: "Profile"
|
||||
- id: "Admin"
|
||||
index: 1
|
||||
default: true
|
||||
locked: true
|
||||
requirePermission: "Community.Read"
|
||||
requireRole: "Community.AnyAdmin"
|
||||
linkSequence: 5000
|
||||
link: "/TODO/comm/[CID]/admin"
|
||||
title: "Administration"
|
||||
- id: "Conference"
|
||||
index: 3
|
||||
default: true
|
||||
locked: false
|
||||
requirePermission: "Community.Read"
|
||||
requireRole: ""
|
||||
linkSequence: 500
|
||||
link: "/TODO/comm/[CID]/conf"
|
||||
title: "Conferences"
|
||||
- id: "Members"
|
||||
index: 4
|
||||
default: true
|
||||
locked: true
|
||||
requirePermission: "Community.Read"
|
||||
requireRole: "Community.Member"
|
||||
linkSequence: 4800
|
||||
link: "/TODO/comm/[CID]/members"
|
||||
title: "Members"
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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/.
|
||||
*/
|
||||
// The database package contains database management and storage logic.
|
||||
package database
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ServiceDef holds the definition for an individual service.
|
||||
type ServiceDef struct {
|
||||
Id string `yaml:"id"`
|
||||
Index int16 `yaml:"index"`
|
||||
Default bool `yaml:"default"`
|
||||
Locked bool `yaml:"locked"`
|
||||
RequirePermission string `yaml:"requirePermission"`
|
||||
RequireRole string `yaml:"requireRole"`
|
||||
LinkSequence int `yaml:"linkSequence"`
|
||||
Link int `yaml:"link"`
|
||||
Title string `yaml:"title"`
|
||||
}
|
||||
|
||||
// ServiceDomain holds each individual configured service domain.
|
||||
type ServiceDomain struct {
|
||||
DomainName string `yaml:"domain"`
|
||||
Services []ServiceDef `yaml:"services"`
|
||||
byId map[string]*ServiceDef
|
||||
byIndex map[int16]*ServiceDef
|
||||
seqOrder []*ServiceDef
|
||||
}
|
||||
|
||||
// ServiceConfiguration holds the service configuration.
|
||||
type ServiceConfiguration struct {
|
||||
Domains []ServiceDomain `yaml:"domains"`
|
||||
byName map[string]*ServiceDomain
|
||||
}
|
||||
|
||||
//go:embed servicedefs.yaml
|
||||
var initServiceData []byte
|
||||
|
||||
// The service configuration loaded from YAML.
|
||||
var serviceRoot ServiceConfiguration
|
||||
|
||||
// The services cache for communities.
|
||||
var servicesCache *lru.TwoQueueCache
|
||||
|
||||
// Mutex on the services cache.
|
||||
var servicesCacheMutex sync.Mutex
|
||||
|
||||
// init loads the service configuration and builds all the internal indexes.
|
||||
func init() {
|
||||
var err error
|
||||
if err = yaml.Unmarshal(initServiceData, &serviceRoot); err != nil {
|
||||
panic(err) // can't happen
|
||||
}
|
||||
serviceRoot.byName = make(map[string]*ServiceDomain)
|
||||
for i, dom := range serviceRoot.Domains {
|
||||
serviceRoot.Domains[i].byId = make(map[string]*ServiceDef)
|
||||
serviceRoot.Domains[i].byIndex = make(map[int16]*ServiceDef)
|
||||
sqo := make([]*ServiceDef, 0, len(serviceRoot.Domains[i].Services))
|
||||
for j, svc := range serviceRoot.Domains[i].Services {
|
||||
serviceRoot.Domains[i].byId[svc.Id] = &(serviceRoot.Domains[i].Services[j])
|
||||
serviceRoot.Domains[i].byIndex[svc.Index] = &(serviceRoot.Domains[i].Services[j])
|
||||
sqo = append(sqo, &(serviceRoot.Domains[i].Services[j]))
|
||||
}
|
||||
slices.SortFunc(sqo, func(a, b *ServiceDef) int {
|
||||
return a.LinkSequence - b.LinkSequence
|
||||
})
|
||||
serviceRoot.Domains[i].seqOrder = sqo
|
||||
serviceRoot.byName[dom.DomainName] = &(serviceRoot.Domains[i])
|
||||
}
|
||||
servicesCache, err = lru.New2Q(50)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
/* AmGetCommunityServices returns all the community service definitions for a community.
|
||||
* Parameters:
|
||||
* cid - Community ID to get services for.
|
||||
* Returns:
|
||||
* Array of ServiceDef pointers for the community's services.
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func AmGetCommunityServices(cid int32) ([]*ServiceDef, error) {
|
||||
servicesCacheMutex.Lock()
|
||||
defer servicesCacheMutex.Unlock()
|
||||
rc, ok := servicesCache.Get(cid)
|
||||
if !ok {
|
||||
rs, err := amdb.Query("SELECT ftr_code FROM commftrs WHERE commid = ?", cid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dom := serviceRoot.byName["community"]
|
||||
a := make([]*ServiceDef, 0, len(dom.Services))
|
||||
for rs.Next() {
|
||||
var ndx int16
|
||||
rs.Scan(&ndx)
|
||||
a = append(a, dom.byIndex[ndx])
|
||||
}
|
||||
servicesCache.Add(cid, a)
|
||||
rc = a
|
||||
}
|
||||
return rc.([]*ServiceDef), nil
|
||||
}
|
||||
@@ -59,6 +59,7 @@ 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("/comm/:cid/profile", ui.AmWrap(ShowCommunity))
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,141 @@
|
||||
{*
|
||||
* 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/.
|
||||
*}
|
||||
<div class="p-4">
|
||||
<!-- Page Title -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-blue-800 text-4xl font-bold inline">Community Profile:</h1>
|
||||
<span class="text-blue-800 text-2xl font-bold ml-2">{{ commName }}</span>
|
||||
<hr class="border-2 border-gray-400 w-4/5 mt-2 mb-6">
|
||||
</div>
|
||||
<!-- Community Profile Card -->
|
||||
<div class="bg-gray-50 p-6 rounded-lg mb-8 max-w-4xl">
|
||||
<div class="flex gap-6">
|
||||
<!-- Left Column: Image and Metadata -->
|
||||
<div class="flex-shrink-0 w-32">
|
||||
<div class="border-2 border-gray-300 rounded mb-4">
|
||||
<img src="/img/static/default-community.jpg"
|
||||
alt="{{ commName}} community logo" class="w-full h-auto">
|
||||
</div>
|
||||
<div class="text-xs text-gray-700 space-y-2 mb-4">
|
||||
<div><strong>Community created:</strong><br>{{ dateCreated }}</div>
|
||||
{{ if isset(dateLastAccess) }}
|
||||
<div><strong>Last accessed:</strong><br>{{ dateLastAccess }}</div>
|
||||
{{ end }}
|
||||
{{ if isset(dateLastUpdate) }}
|
||||
<div><strong>Profile last updated:</strong><br>{{ dateLastUpdate }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ if isset(canJoin) }}
|
||||
<div class="text-center">
|
||||
<a href="/TODO/comm/join"
|
||||
class="inline-block bg-blue-600 hover:bg-blue-700 text-white px-4 py-1 rounded text-xs font-medium transition-colors">
|
||||
Join Now
|
||||
</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if isset(canInvite) }}
|
||||
<div class="text-center">
|
||||
<a href="/TODO/comm/invite"
|
||||
class="inline-block bg-blue-600 hover:bg-blue-700 text-white px-4 py-1 rounded text-xs font-medium transition-colors">
|
||||
Invite
|
||||
</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Community Information -->
|
||||
<div class="flex-1 text-sm text-black">
|
||||
<div class="space-y-4">
|
||||
<!-- Community Type and Category -->
|
||||
<div>
|
||||
{{ if public }}
|
||||
<div class="text-lg font-bold underline mb-2">Public Community</div>
|
||||
{{ else }}
|
||||
<div class="text-lg font-bold underline mb-2">Private Community</div>
|
||||
{{ end }}
|
||||
{{ if isset(categories) }}
|
||||
<div>
|
||||
<strong>Category:</strong>
|
||||
{{ range i := categories }}
|
||||
{{ if i > 0 }}: {{ end }}
|
||||
<a href="/TODO/find/communities-for-category"
|
||||
class="text-blue-700 hover:text-blue-900">{{ .Name }}</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ if isset(description) }}
|
||||
<!-- Description -->
|
||||
<div class="italic text-gray-700">{{ description }}</div>
|
||||
{{ end }}
|
||||
|
||||
<!-- Host -->
|
||||
<div>
|
||||
<strong>Host:</strong>
|
||||
<a href="/users/{{ hostName }}" class="text-blue-700 hover:text-blue-900">{{ hostName }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="pt-2 border-t border-gray-300">
|
||||
<strong>Location:</strong>
|
||||
<div class="mt-1">
|
||||
{{ if isset(company) }}
|
||||
<div>{{ company }}</div>
|
||||
{{ end }}
|
||||
{{ if isset(addr1) }}
|
||||
<div>{{ addr1 }}</div>
|
||||
{{ end }}
|
||||
{{ if isset(addr2) }}
|
||||
<div>{{ addr2 }}</div>
|
||||
{{ end }}
|
||||
<div>{{ addrLast }}</div>
|
||||
{{ if isset(country) }}
|
||||
<div>{{ country }}</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ if isset(language) || isset(rules) || isset(homePage) }}
|
||||
<!-- Additional Information -->
|
||||
<div class="pt-2 border-t border-gray-300 space-y-2">
|
||||
{{ if isset(language) }}
|
||||
<div><strong>Primary Language:</strong> {{ language }}</div>
|
||||
{{ end }}
|
||||
{{ if isset(rules) }}
|
||||
<div><strong>Standards of Conduct:</strong> {{ rules }}</div>
|
||||
{{ end }}
|
||||
{{ if isset(homePage) }}
|
||||
<div>
|
||||
<strong>Homepage:</strong>
|
||||
<a href="{{ homepage }}" target="_blank"
|
||||
class="text-blue-700 hover:text-blue-900">{{ homepage }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Community Information Note -->
|
||||
<div class="max-w-4xl p-4 bg-blue-50 border-l-4 border-blue-400">
|
||||
<p class="text-sm text-gray-700">
|
||||
<strong>About This Community:</strong>
|
||||
{{ if public }}
|
||||
This is a public community open to all users of the {{ GlobalConfig.Site.Title }} site.
|
||||
You can join discussions in the conferences, view member lists, and participate in community activities.
|
||||
{{ else }}
|
||||
This is a private community, open by invitation only. You can only participate if you have been invited
|
||||
by one of the community hosts.
|
||||
{{ end }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,5 +26,11 @@
|
||||
<div class="text-small">You are not a member of any communities.</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="mt-3 text-center">
|
||||
<span class="text-black text-xs font-bold">
|
||||
[ <a href="/TODO/manage_comms" class="text-blue-700 hover:text-blue-900">Manage</a> |
|
||||
<a href="/TODO/create_comm" class="text-blue-700 hover:text-blue-900">Create New</a> ]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+18
-6
@@ -339,6 +339,18 @@ func ShowProfile(ctxt ui.AmContext) (string, any, error) {
|
||||
if err != nil {
|
||||
return ui.ErrorPage(ctxt, err)
|
||||
}
|
||||
var pvtAddr, pvtPhone, pvtFax, pvtEmail bool
|
||||
if database.AmTestPermission("Global.SeeHiddenContactInfo", me.BaseLevel) {
|
||||
pvtAddr = false
|
||||
pvtPhone = false
|
||||
pvtFax = false
|
||||
pvtEmail = false
|
||||
} else {
|
||||
pvtAddr = ci.PrivateAddr
|
||||
pvtPhone = ci.PrivatePhone
|
||||
pvtFax = ci.PrivateFax
|
||||
pvtEmail = ci.PrivateEmail
|
||||
}
|
||||
|
||||
// Fill all the page variables for display.
|
||||
ctxt.VarMap().Set("uid", user.Uid)
|
||||
@@ -369,7 +381,7 @@ func ShowProfile(ctxt ui.AmContext) (string, any, error) {
|
||||
if user.Description != nil {
|
||||
ctxt.VarMap().Set("description", *user.Description)
|
||||
}
|
||||
if !ci.PrivateEmail && ci.Email != nil {
|
||||
if !pvtEmail && ci.Email != nil {
|
||||
ctxt.VarMap().Set("email", *ci.Email)
|
||||
}
|
||||
if ci.URL != nil && *ci.URL != "" {
|
||||
@@ -378,10 +390,10 @@ func ShowProfile(ctxt ui.AmContext) (string, any, error) {
|
||||
if ci.Company != nil {
|
||||
ctxt.VarMap().Set("company", *ci.Company)
|
||||
}
|
||||
if !ci.PrivateAddr && ci.Addr1 != nil {
|
||||
if !pvtAddr && ci.Addr1 != nil {
|
||||
ctxt.VarMap().Set("addr1", *ci.Addr1)
|
||||
}
|
||||
if !ci.PrivateAddr && ci.Addr2 != nil {
|
||||
if !pvtAddr && ci.Addr2 != nil {
|
||||
ctxt.VarMap().Set("addr2", *ci.Addr2)
|
||||
}
|
||||
b.Reset()
|
||||
@@ -402,13 +414,13 @@ func ShowProfile(ctxt ui.AmContext) (string, any, error) {
|
||||
country := countries.ByName(*ci.Country)
|
||||
ctxt.VarMap().Set("country", country.String())
|
||||
}
|
||||
if !ci.PrivatePhone && ci.Phone != nil {
|
||||
if !pvtPhone && ci.Phone != nil {
|
||||
ctxt.VarMap().Set("phone", *ci.Phone)
|
||||
}
|
||||
if !ci.PrivateFax && ci.Fax != nil {
|
||||
if !pvtFax && ci.Fax != nil {
|
||||
ctxt.VarMap().Set("fax", *ci.Fax)
|
||||
}
|
||||
if !ci.PrivatePhone && ci.Mobile != nil {
|
||||
if !pvtPhone && ci.Mobile != nil {
|
||||
ctxt.VarMap().Set("mobile", *ci.Mobile)
|
||||
}
|
||||
ctxt.VarMap().Set("amsterdam_pageTitle", fmt.Sprintf("User Profile - %s", user.Username))
|
||||
|
||||
+24
-1
@@ -10,7 +10,20 @@
|
||||
// Package util contains utility definitions.
|
||||
package util
|
||||
|
||||
import "unicode"
|
||||
import (
|
||||
"regexp"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var numeric *regexp.Regexp
|
||||
|
||||
func init() {
|
||||
re, err := regexp.Compile("^[0-9]+$")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
numeric = re
|
||||
}
|
||||
|
||||
/* CapitalizeString changes the first character of the string to a capital.
|
||||
* Parameters:
|
||||
@@ -26,3 +39,13 @@ func CapitalizeString(s string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/* IsNumeric returns true if the string is numeric (all digits).
|
||||
* Parameters:
|
||||
* s - String to be tested.
|
||||
* Returns:
|
||||
* true if string is numeric, false if not.
|
||||
*/
|
||||
func IsNumeric(s string) bool {
|
||||
return numeric.MatchString(s)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user