community profile and left menu implementation done, not quite working yet

This commit is contained in:
2025-10-16 23:05:34 -06:00
parent 681b30272d
commit 65c739dc57
17 changed files with 412 additions and 72 deletions
+177 -20
View File
@@ -19,6 +19,7 @@ import (
"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/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
@@ -30,12 +31,19 @@ import (
type AmContext interface {
ClearLoginCookie()
ClearSession()
CurrentCommunity() *database.Community
CurrentUser() *database.User
CurrentUserId() int32
Done()
EffectiveLevel() uint16
FormField(string) string
FormFieldInt(string) (int, error)
FormFieldIsSet(string) bool
FormFile(string) (*multipart.FileHeader, error)
Globals() *database.Globals
GlobalFlags() *util.OptionSet
IsMember() bool
LeftMenu() string
RC() int
OutputType() string
Parameter(string) string
@@ -43,11 +51,14 @@ type AmContext interface {
ReplaceUser(*database.User)
SaveSession() error
SubRender(string) ([]byte, error)
SetCommunityContext(string) error
SetLeftMenu(string)
SetLoginCookie(string)
SetOutputType(string)
SetRC(int)
GetScratch(string) any
SetScratch(string, any)
TestPermission(string) bool
URLParam(string) string
URLParamInt(string) (int, error)
URLPath() string
@@ -56,12 +67,18 @@ type AmContext interface {
// amContext is the internal structure that implements AmContext.
type amContext struct {
echoContext echo.Context
httprc int
rendervars jet.VarMap
outputType string
scratchpad map[string]any
session *sessions.Session
echoContext echo.Context
httprc int
rendervars jet.VarMap
outputType string
scratchpad map[string]any
session *sessions.Session
globals *database.Globals
globalFlags *util.OptionSet
user *database.User
effectiveLevel uint16
community *database.Community
isMember bool
}
// ClearLoginCookie overwrites and removes the login cookie.
@@ -77,15 +94,26 @@ func (c *amContext) ClearLoginCookie() {
// ClearSession clears the current session.
func (c *amContext) ClearSession() {
AmResetSession(c.session)
c.user = nil
c.effectiveLevel = 0
}
// CurrentCommunity returns the current community, if one's been set.
func (c *amContext) CurrentCommunity() *database.Community {
return c.community
}
// CurrentUser returns the current user from the session.
func (c *amContext) CurrentUser() *database.User {
u, err := database.AmGetUser(AmSessionUid(c.session))
if err != nil {
log.Errorf("unable to retrieve current user")
if c.user == nil {
u, err := database.AmGetUser(AmSessionUid(c.session))
if err != nil {
log.Errorf("unable to retrieve current user")
}
c.user = u
c.effectiveLevel = u.BaseLevel
}
return u
return c.user
}
// CurrentUserId returns the current user ID.
@@ -93,6 +121,16 @@ func (c *amContext) CurrentUserId() int32 {
return AmSessionUid(c.session)
}
// Done signals that we're done with this context and it can be recycled.
func (c *amContext) Done() {
amContextRecycleBin <- c
}
// EffectiveLevel returns the user's effective access level (in terms of current community, if any).
func (c *amContext) EffectiveLevel() uint16 {
return c.effectiveLevel
}
/* FormField returns the value of a form field from the request.
* Parameters:
* name - The name of the field to retrieve.
@@ -133,6 +171,26 @@ func (c *amContext) FormFile(name string) (*multipart.FileHeader, error) {
return c.echoContext.FormFile(name)
}
// Globals returns a reference to the database globals.
func (c *amContext) Globals() *database.Globals {
return c.globals
}
// GlobalFlags returns a reference to the database global flags.
func (c *amContext) GlobalFlags() *util.OptionSet {
return c.globalFlags
}
// IsMember returns true if the user is a member of the current community.
func (c *amContext) IsMember() bool {
return c.isMember
}
// LeftMenu returns the current left menu selector.
func (c *amContext) LeftMenu() string {
return c.session.Values["leftMenu"].(string)
}
// RC returns the HTTP result code for the current operation.
func (c *amContext) RC() int {
return c.httprc
@@ -168,6 +226,8 @@ func (c *amContext) RemoteIP() string {
*/
func (c *amContext) ReplaceUser(u *database.User) {
AmSetSessionUser(c.session, u)
c.user = u
c.effectiveLevel = u.BaseLevel
}
// SaveSession saves the session link to cookies.
@@ -201,6 +261,34 @@ func (c *amContext) SubRender(name string) ([]byte, error) {
return buf.Bytes(), err
}
/* SetCommunityContext establishes the community context from a (ID or alias) parameter.
* Parameters:
* param - String parameter selecting the community.
* Returns:
* Standard Go error status.
*/
func (c *amContext) SetCommunityContext(param string) error {
comm, err := database.AmGetCommunityFromParam(param)
if err != nil {
return err
}
mbr, _, level, err := comm.Membership(c.CurrentUser())
if err != nil {
return err
}
c.community = comm
c.isMember = mbr
if level > c.effectiveLevel {
c.effectiveLevel = level
}
return nil
}
// SetLeftMenu sets the current topmost left menu name value.
func (c *amContext) SetLeftMenu(name string) {
c.session.Values["leftMenu"] = name
}
/* SetLoginCookie adds the login cookie to the result output.
* Parameters:
* auth - The auth string to set.
@@ -240,6 +328,11 @@ func (c *amContext) SetScratch(name string, val any) {
c.scratchpad[name] = val
}
// TestPermission tests the current user against permissions.
func (c *amContext) TestPermission(perm string) bool {
return database.AmTestPermission(perm, c.effectiveLevel)
}
// URLParam returns the value of a URL parameter.
func (c *amContext) URLParam(name string) string {
return c.echoContext.Param(name)
@@ -267,22 +360,42 @@ var defoptions *sessions.Options = &sessions.Options{
HttpOnly: true,
}
/* NewAmContext creates a new AmContext wrapping the Echo context.
// freeContext is a free list for amContext structures.
var freeContext util.FreeList[amContext]
// amContextRecycleBin is the channel we put contexts on to be recycled.
var amContextRecycleBin chan *amContext
/* AmCreateContext creates a new AmContext wrapping the Echo context.
* Parameters:
* ctxt - The Echo context to be wrapped.
* Returns:
* A new Amsterdam context wrapping that context.
* Standard Go error status.
*/
func NewAmContext(ctxt echo.Context) (AmContext, error) {
rc := amContext{
echoContext: ctxt,
httprc: http.StatusOK,
rendervars: make(jet.VarMap),
outputType: "",
scratchpad: nil,
func AmCreateContext(ctxt echo.Context) (AmContext, error) {
rc := freeContext.Get()
if rc == nil {
rc = &amContext{
httprc: http.StatusOK,
rendervars: make(jet.VarMap),
outputType: "",
scratchpad: nil,
}
}
ctxt.Set("amsterdam_context", &rc)
var err error
if rc.globals, err = database.AmGlobals(); err != nil {
amContextRecycleBin <- rc
return nil, err
}
if rc.globalFlags, err = rc.globals.Flags(); err != nil {
amContextRecycleBin <- rc
return nil, err
}
rc.echoContext = ctxt
ctxt.Set("amsterdam_context", rc)
sess, err := session.Get("AMSTERDAM_SESSION", ctxt)
if err == nil {
rc.session = sess
@@ -293,7 +406,14 @@ func NewAmContext(ctxt echo.Context) (AmContext, error) {
AmHitSession(sess)
}
}
return &rc, err
rc.user, err = database.AmGetUser(AmSessionUid(sess))
if err == nil {
rc.effectiveLevel = rc.user.BaseLevel
} else {
rc.user = nil
rc.effectiveLevel = database.AmRole("NotInList").Level()
}
return rc, err
}
/* AmContextFromEchoContext returns the AmContext associated with an Echo context.
@@ -312,3 +432,40 @@ func AmContextFromEchoContext(ctxt echo.Context) AmContext {
}
return nil
}
// contextRecycler is the task that recycles context blocks.
func contextRecycler(incoming chan *amContext, done chan bool) {
for c := range incoming {
c.echoContext = nil
c.httprc = http.StatusOK
for k := range c.rendervars {
delete(c.rendervars, k)
}
c.outputType = ""
if c.scratchpad != nil {
for k := range c.scratchpad {
delete(c.scratchpad, k)
}
}
c.session = nil
c.globals = nil
c.globalFlags = nil
c.user = nil
c.effectiveLevel = 0
c.community = nil
c.isMember = false
freeContext.Put(c)
}
done <- true
}
// SetupAmContext starts the recycler for contexts.
func SetupAmContext() func() {
amContextRecycleBin = make(chan *amContext, 16)
done := make(chan bool)
go contextRecycler(amContextRecycleBin, done)
return func() {
close(amContextRecycleBin)
<-done
}
}
+3
View File
@@ -226,6 +226,9 @@ func (d *Dialog) Render(ctxt AmContext) (string, any, error) {
}
}
}
if d.MenuSelector != "" && d.MenuSelector != "nochange" {
ctxt.SetLeftMenu(d.MenuSelector)
}
ctxt.VarMap().Set("amsterdam_required", required)
ctxt.VarMap().Set("amsterdam_dialog", d)
ctxt.VarMap().Set("amsterdam_pageTitle", d.Title)
+89 -3
View File
@@ -11,12 +11,19 @@ package ui
import (
_ "embed"
"fmt"
"slices"
"strconv"
"strings"
"sync"
"git.erbosoft.com/amy/amsterdam/database"
"git.erbosoft.com/amy/amsterdam/util"
lru "github.com/hashicorp/golang-lru"
"gopkg.in/yaml.v3"
)
// MenuItem represents an itrem within a menu definition.
// MenuItem represents an item within a menu definition.
type MenuItem struct {
Text string `yaml:"text"`
Link string `yaml:"link"`
@@ -35,10 +42,23 @@ func (mi *MenuItem) Show(ctxt AmContext) bool {
switch mi.P.PermSet {
case "user":
eperm = u.BaseLevel
case "community":
eperm = ctxt.EffectiveLevel()
default:
eperm = database.AmRole("NotInList").Level()
}
return database.AmTestPermission(mi.Permission, eperm)
if util.IsNumeric(mi.Permission) {
v, _ := strconv.Atoi(mi.Permission)
return uint16(v) <= eperm
}
switch mi.P.PermSet {
case "user":
return database.AmTestPermission(mi.Permission, eperm)
case "community":
return ctxt.CurrentCommunity().TestPermission(mi.Permission, eperm)
default:
return false
}
}
// MenuDefinition represents a full menu definition.
@@ -48,6 +68,7 @@ type MenuDefinition struct {
PermSet string `yaml:"permSet"`
Warning string `yaml:"warning"`
Items []MenuItem `yaml:"items"`
Tag string
}
// MenuDefs represents the set of all menu definitions.
@@ -62,9 +83,19 @@ var initMenuData []byte
// menuDefinitions gives the menu definitions.
var menuDefinitions MenuDefs
// Cache of community menus.
var menuCache *lru.Cache
// Mutex controlling access to the cache.
var menuCacheMutex sync.Mutex
// init loads the menu definitions.
func init() {
if err := yaml.Unmarshal(initMenuData, &menuDefinitions); err != nil {
var err error
if menuCache, err = lru.New(100); err != nil {
panic(err)
}
if err = yaml.Unmarshal(initMenuData, &menuDefinitions); err != nil {
panic(err) // can't happen
}
menuDefinitions.table = make(map[string]*MenuDefinition)
@@ -73,6 +104,7 @@ func init() {
for j := range menuDefinitions.D[i].Items {
menuDefinitions.D[i].Items[j].P = &(menuDefinitions.D[i])
}
menuDefinitions.D[i].Tag = ""
}
}
@@ -80,3 +112,57 @@ func init() {
func AmMenu(name string) *MenuDefinition {
return menuDefinitions.table[name]
}
/* AmBuildCommunityMenu buids a community menu for the specified community.
* Parameters:
* comm - The community to build the menu for.
* Returns:
* The new menu definition.
* Standard Go error status.
*/
func AmBuildCommunityMenu(comm *database.Community) (*MenuDefinition, error) {
menuCacheMutex.Lock()
defer menuCacheMutex.Unlock()
m, ok := menuCache.Get(comm.Id)
if ok {
return m.(*MenuDefinition), nil
}
sdef, err := database.AmGetCommunityServices(comm.Id)
if err != nil {
return nil, err
}
slices.SortFunc(sdef, func(a, b *database.ServiceDef) int {
return a.LinkSequence - b.LinkSequence
})
mia := make([]MenuItem, len(sdef))
for i, sd := range sdef {
mia[i].Text = sd.Title
mia[i].Link = strings.ReplaceAll(sd.Link, "[CID]", comm.Alias)
mia[i].Disabled = false
if sd.RequirePermission == "" {
if sd.RequireRole == "" {
mia[i].Permission = ""
} else {
mia[i].Permission = fmt.Sprintf("%d", database.AmRole(sd.RequireRole).Level())
}
} else if sd.RequireRole == "" {
mia[i].Permission = sd.RequirePermission
} else {
v1 := comm.PermissionLevel(sd.RequirePermission)
v2 := database.AmRole(sd.RequireRole).Level()
if v2 > v1 {
v1 = v2
}
mia[i].Permission = fmt.Sprintf("%d", v1)
}
}
md := MenuDefinition{
ID: "community",
Title: comm.Name,
PermSet: "community",
Items: mia,
Tag: "community",
}
menuCache.Add(comm.Id, &md)
return &md, nil
}
+17 -1
View File
@@ -34,6 +34,21 @@ func sendPageData(ctxt echo.Context, amctxt AmContext, command string, data any)
err = ctxt.Render(amctxt.RC(), fmt.Sprintf("%v", data), amctxt)
case "framed_template":
amctxt.VarMap().Set("amsterdam_innerPage", data)
menus := make([]*MenuDefinition, 2)
switch amctxt.LeftMenu() {
case "top":
menus[0] = AmMenu("top")
case "community":
md, err := AmBuildCommunityMenu(amctxt.CurrentCommunity())
if err != nil {
return err
}
menus[0] = md
default:
return fmt.Errorf("unknown left menu context: %s", amctxt.LeftMenu())
}
menus[1] = AmMenu("fixed")
amctxt.VarMap().Set("amsterdam_leftMenus", menus)
err = ctxt.Render(amctxt.RC(), "frame.jet", amctxt)
default:
err = fmt.Errorf("unknown rendering type: %s", command)
@@ -69,11 +84,12 @@ func ErrorPage(ctxt AmContext, input_err error) (string, any, error) {
func AmWrap(myfunc func(AmContext) (string, any, error)) echo.HandlerFunc {
return func(ctxt echo.Context) error {
// Create the AmContext.
amctxt, aerr := NewAmContext(ctxt)
amctxt, aerr := AmCreateContext(ctxt)
if aerr != nil {
ctxt.Logger().Errorf("Session creation error: %v", aerr)
return aerr
}
defer amctxt.Done()
// Check IP banning.
banmsg, banerr := database.AmTestIPBan(ctxt.RealIP())
+11 -5
View File
@@ -81,11 +81,17 @@
<div class="flex">
<!-- LEFT SIDEBAR -->
<div class="w-48 bg-blue-400 p-2">
{{ .SetScratch("__menu", AmMenu("top")) }}
{{ .SubRender("menu_left.jet") | raw }}
<div class="mb-2 mt-2">&nbsp;</div>
{{ .SetScratch("__menu", AmMenu("fixed")) }}
{{ .SubRender("menu_left.jet") | raw }}
{{ range i, m := amsterdam_leftMenus }}
{{ if i > 0 }}
<div class="mb-2 mt-2">&nbsp;</div>
{{ end }}
{{ .SetScratch("__menu", m) }}
{{ if m.Tag == "community" }}
{{ .SubRender("menu_left_comm.jet") | raw }}
{{ else }}
{{ .SubRender("menu_left.jet") | raw }}
{{ end }}
{{ end }}
</div>
<!-- MAIN CONTENT -->
+31
View File
@@ -0,0 +1,31 @@
{*
* 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/.
*}
{{ menu := .GetScratch("__menu") }}
{{ comm := .CurrentCommunity() }}
<div class="mb-2 mt-2">
<div class="mb-1">
<img src="/img/builtin/default-community.jpg"
alt="{{ comm.Name }}" class="w-28 h-16 rounded">
</div>
<div class="font-bold mb-1">{{ menu.Title }}</div>
{{ ctxt := . }}
{{ range menu.Items }}
{{ if .Show(ctxt) }}
{{ if .Disabled }}
<div class="text-gray-500 mb-1">{{ .Text }}</div>
{{ else }}
<a href="{{ .Link }}" class="text-blue-700 hover:text-blue-900">{{ .Text }}</a>
{{ end }}
{{ end }}
{{ end }}
{{ if .IsMember() }}
<div class="mb-1">&nbsp;</div>
<div class="mb-1"><a href="/TODO/comm/{{ comm.Alias }}/unjoin">Unjoin</a></div>
{{ end }}
</div>