Files
amsterdam/ui/amcontext.go
T

613 lines
16 KiB
Go

/*
* 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/.
*/
// Package ui holds the support for the Amsterdam user interface, wrapping Echo and Jet templates.
package ui
import (
"context"
"fmt"
"mime/multipart"
"net/http"
"strconv"
"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/labstack/echo/v4"
log "github.com/sirupsen/logrus"
)
/*----------------------------------------------------------------------------
* AmContext interface
*----------------------------------------------------------------------------
*/
const (
FrameMetaHttpEquiv = 0 // <meta http-equiv="...">
)
// AmContext is the interface for Amsterdam's wrapper context that exposes the required functionality.
type AmContext interface {
AddFrameMetadata(int, string, string)
AddHeader(string, string)
ClearCommunityContext()
ClearLoginCookie()
ClearSession()
Ctx() context.Context
CurrentCommunity() *database.Community
CurrentUser() *database.User
CurrentUserId() int32
EffectiveLevel() uint16
FormField(string) string
FormFieldInt(string) (int, error)
FormFieldIsSet(string) bool
FormFile(string) (*multipart.FileHeader, error)
FrameTitle() string
FrameMetadata(int) map[string]string
Globals() *database.Globals
GlobalFlags() *util.OptionSet
HasParameter(string) bool
IsMember() bool
IsMemberLocked() bool
LeftMenu() string
OutputType() string
Parameter(string) string
QueryParamInt(string, int) int
RemoteIP() string
ReplaceUser(*database.User)
SaveSession() error
SetCommunityContext(string) error
SetFrameTitle(string)
SetHeader(string, string)
SetLeftMenu(string)
SetLoginCookie(string)
SetOutputType(string)
GetScratch(string) any
SetScratch(string, any)
GetSession(string) any
SetSession(string, any)
IsSession(string) bool
TestPermission(string) bool
URLParam(string) string
URLParamInt(string) (int, error)
URLPath() string
VarMap() jet.VarMap
Verb() string
}
/*----------------------------------------------------------------------------
* AmContext implementation
*----------------------------------------------------------------------------
*/
// amContext is the internal structure that implements AmContext.
type amContext struct {
echoContext echo.Context
rendervars jet.VarMap
frameTitle string
frameMeta map[int]map[string]string
outputType string
session AmSession
globals *database.Globals
globalFlags *util.OptionSet
user *database.User
effectiveLevel uint16
community *database.Community
isMember bool
isMemberLocked bool
}
// AddFrameMetadata adds frame metadata of specified types.
func (c *amContext) AddFrameMetadata(selector int, name string, value string) {
mv, ok := c.frameMeta[selector]
if !ok {
mv = make(map[string]string)
c.frameMeta[selector] = mv
}
mv[name] = value
}
// AddHeader adds a header to the response.
func (c *amContext) AddHeader(key, value string) {
c.echoContext.Response().Header().Add(key, value)
}
// ClearCommunityContext clears the community context so changes will be reflected.
func (c *amContext) ClearCommunityContext() {
c.community = nil
c.isMember = false
c.isMemberLocked = false
c.effectiveLevel = c.user.BaseLevel
}
// ClearLoginCookie overwrites and removes the login cookie.
func (c *amContext) ClearLoginCookie() {
cookie := new(http.Cookie)
cookie.Name = config.GlobalConfig.Site.LoginCookieName
cookie.Value = ""
cookie.Path = "/"
cookie.Expires = time.Now()
c.echoContext.SetCookie(cookie)
}
// ClearSession clears the current session.
func (c *amContext) ClearSession() {
c.session.Reset(c.echoContext.Request().Context())
c.user = nil
c.effectiveLevel = 0
}
// Ctx returns the current context.Context for the request.
func (c *amContext) Ctx() context.Context {
return c.echoContext.Request().Context()
}
// CurrentCommunity returns the current community, if one's been set.
func (c *amContext) CurrentCommunity() *database.Community {
if c.community == nil {
cv, ok := c.session.Get("lastCommunity")
if ok && !c.CurrentUser().IsAnon {
c.SetCommunityContext(fmt.Sprintf("%d", cv))
}
}
return c.community
}
// CurrentUser returns the current user from the session.
func (c *amContext) CurrentUser() *database.User {
if c.user == nil {
id, ok := c.session.Uid()
var err error
var u *database.User
if ok {
u, err = database.AmGetUser(c.echoContext.Request().Context(), id)
} else {
u, err = database.AmGetAnonUser(c.echoContext.Request().Context())
}
if err != nil {
log.Errorf("unable to retrieve current user")
}
c.user = u
c.effectiveLevel = u.BaseLevel
}
return c.user
}
// CurrentUserId returns the current user ID.
func (c *amContext) CurrentUserId() int32 {
if rc, ok := c.session.Uid(); ok {
return rc
}
u, err := database.AmGetAnonUser(c.echoContext.Request().Context())
if err == nil {
return u.Uid
}
return 0
}
// 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.
* Returns:
* The value given to that named field.
*/
func (c *amContext) FormField(name string) string {
return c.echoContext.FormValue(name)
}
/* FormFieldInt returns the value of a form field from the request, as an integer.
* Parameters:
* name - The name of the field to retrieve.
* Returns:
* The value given to that named field.
* Standard Go error status.
*/
func (c *amContext) FormFieldInt(name string) (int, error) {
return strconv.Atoi(c.echoContext.FormValue(name))
}
/* FormFieldIsSet returns true if a given form field is set.
* Parameters:
* name - The name of the field to test.
* Returns:
* true if the field is set, false if not.
*/
func (c *amContext) FormFieldIsSet(name string) bool {
req := c.echoContext.Request()
if req.Form == nil {
_ = req.FormValue(name) // force form to be loaded
}
return req.Form.Has(name)
}
// FormFile returns a "file" parameter from a multipart upload form.
func (c *amContext) FormFile(name string) (*multipart.FileHeader, error) {
return c.echoContext.FormFile(name)
}
// FrameMetadata returns the frame metadata for a specified type.
func (c *amContext) FrameMetadata(selector int) map[string]string {
rmap, ok := c.frameMeta[selector]
if !ok {
rmap = make(map[string]string)
}
return rmap
}
// FrameTitle returns the frame title.
func (c *amContext) FrameTitle() string {
return c.frameTitle
}
// 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
}
// HasParameter tests to see if we have a parameter.
func (c *amContext) HasParameter(name string) bool {
s := c.echoContext.QueryParam(name)
if s != "" {
return true
}
s = c.echoContext.FormValue(name)
if s != "" {
return true
}
return false
}
// IsMember returns true if the user is a member of the current community.
func (c *amContext) IsMember() bool {
return c.isMember
}
// IsMemberLocked returns true if the user is a "locked" member of the currentr community (cannot unjoin).
func (c *amContext) IsMemberLocked() bool {
return c.isMemberLocked
}
// LeftMenu returns the current left menu selector.
func (c *amContext) LeftMenu() string {
rc, ok := c.session.Get("leftMenu")
if ok {
return rc.(string)
} else {
return "top"
}
}
// OutputType returns the MIME output type set for the current operation.
func (c *amContext) OutputType() string {
return c.outputType
}
/* Parameter returns the value of a parameter (query parameter or form field) from the request.
* Parameters:
* name - The name of the field to retrieve.
* Returns:
* The value given to that named field.
*/
func (c *amContext) Parameter(name string) string {
rc := c.echoContext.QueryParam(name)
if rc == "" {
rc = c.echoContext.FormValue(name)
}
return rc
}
// QueryParamInt returns the value of a query parameter as an integer, with a default.
func (c *amContext) QueryParamInt(name string, defval int) int {
s := c.echoContext.QueryParam(name)
if s == "" {
return defval
}
rc, err := strconv.Atoi(s)
if err != nil {
return defval
}
return rc
}
// RemoteIP returns the remote IP address.
func (c *amContext) RemoteIP() string {
return c.echoContext.RealIP()
}
/* ReplaceUser replaces the current user in the context.
* Parameters:
* u - New user to associate with the context.
*/
func (c *amContext) ReplaceUser(u *database.User) {
c.session.SetUser(u)
c.user = u
c.effectiveLevel = u.BaseLevel
}
// SaveSession saves the session link to cookies.
func (c *amContext) SaveSession() error {
return c.session.Save(c.echoContext.Request(), c.echoContext.Response())
}
/* 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(c.echoContext.Request().Context(), param)
if err != nil {
return err
}
if c.community == nil || c.community.Id != comm.Id {
mbr, lock, level, err := comm.Membership(c.echoContext.Request().Context(), c.CurrentUser())
if err != nil {
return err
}
c.community = comm
c.isMember = mbr
c.isMemberLocked = lock
if level > c.effectiveLevel {
c.effectiveLevel = level
}
if mbr {
c.session.Set("lastCommunity", comm.Id)
}
}
return nil
}
// SetFrameTitle sets the frame title for the output.
func (c *amContext) SetFrameTitle(s string) {
c.frameTitle = s
}
// SetHeader sets a header on the output.
func (c *amContext) SetHeader(key, value string) {
c.echoContext.Response().Header().Set(key, value)
}
// SetLeftMenu sets the current topmost left menu name value.
func (c *amContext) SetLeftMenu(name string) {
c.session.Set("leftMenu", name)
}
/* SetLoginCookie adds the login cookie to the result output.
* Parameters:
* auth - The auth string to set.
*/
func (c *amContext) SetLoginCookie(auth string) {
cookie := new(http.Cookie)
cookie.Name = config.GlobalConfig.Site.LoginCookieName
cookie.Value = auth
cookie.Path = "/"
cookie.Expires = time.Now().AddDate(0, 0, config.GlobalConfig.Site.LoginCookieAge)
c.echoContext.SetCookie(cookie)
}
// SetOutputType sets the MIME output type for the current operation.
func (c *amContext) SetOutputType(typ string) {
c.outputType = typ
}
// GetScratch returns a value in the per-request scratchpad.
func (c *amContext) GetScratch(name string) any {
return c.echoContext.Get("am." + name)
}
// SetScratch sets a value in the per-request scratchpad.
func (c *amContext) SetScratch(name string, val any) {
c.echoContext.Set("am."+name, val)
}
// GetSession returns a session variable.
func (c *amContext) GetSession(name string) any {
rc, _ := c.session.Get("x." + name)
return rc
}
// SetSession sets a session variable.
func (c *amContext) SetSession(name string, value any) {
c.session.Set("x."+name, value)
}
// IsSession tests to see whether a session value is set.
func (c *amContext) IsSession(name string) bool {
_, ok := c.session.Get("x." + name)
return ok
}
// 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)
}
// URLParamINt returns the value of a URL parameter parsed as an integer.
func (c *amContext) URLParamInt(name string) (int, error) {
return strconv.Atoi(c.echoContext.Param(name))
}
// URLPath returns the path component of the request URL.
func (c *amContext) URLPath() string {
return c.echoContext.Request().URL.Path
}
// VarMap provides access to the Jet variable map for setting variable data.
func (c *amContext) VarMap() jet.VarMap {
return c.rendervars
}
// Verb returns the HTTP method (verb) for this request.
func (c *amContext) Verb() string {
rc := c.echoContext.Request().Method
if rc == "" {
rc = "GET"
}
return rc
}
// defoptions is the default options for the HTTP session.
var defoptions *AmSessionOptions = &AmSessionOptions{
Path: "/",
MaxAge: 86400,
HttpOnly: true,
}
// 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
/* newContext creates a new AmContext wrapping the Echo context.
* Parameters:
* ctxt - The Echo context to be wrapped.
* Returns:
* Internal Amsterdam context structure pointer, or nil.
* Standard Go error status.
*/
func newContext(ctxt echo.Context) (*amContext, error) {
rc := freeContext.Get()
if rc == nil {
rc = &amContext{
rendervars: make(jet.VarMap),
frameTitle: "",
frameMeta: make(map[int]map[string]string),
outputType: "",
}
}
var err error
if rc.globals, err = database.AmGlobals(ctxt.Request().Context()); err != nil {
amContextRecycleBin <- rc
return nil, err
}
if rc.globalFlags, err = rc.globals.Flags(ctxt.Request().Context()); err != nil {
amContextRecycleBin <- rc
return nil, err
}
rc.echoContext = ctxt
ctxt.Set("__amsterdam_context", rc)
store := ctxt.Get("AmSessionStore").(AmSessionStore)
sess, err := store.Get(ctxt.Request(), "AMSTERDAM_SESSION")
if err == nil {
rc.session = sess
sess.SetOptions(defoptions)
if sess.IsNew() {
sess.FirstTime(ctxt.Request().Context())
} else {
sess.Hit()
}
}
id, ok := sess.Uid()
if ok {
rc.user, err = database.AmGetUser(ctxt.Request().Context(), id)
if err == nil {
rc.effectiveLevel = rc.user.BaseLevel
} else {
rc.user = nil
rc.effectiveLevel = database.AmRole("NotInList").Level()
}
} else {
rc.user = nil
rc.effectiveLevel = database.AmRole("NotInList").Level()
}
if rc.user != nil && !rc.user.IsAnon {
cp, ok := sess.Get("lastCommunity")
if ok {
rc.SetCommunityContext(fmt.Sprintf("%d", cp))
}
}
return rc, err
}
/* AmContextFromEchoContext returns the AmContext associated with an Echo context.
* Parameters:
* ctxt - The Echo context to have the AmContext extracted.
* Returns:
* The associated AmContext.
*/
func AmContextFromEchoContext(ctxt echo.Context) AmContext {
myctxt := ctxt.Get("__amsterdam_context")
if myctxt != nil {
rc, ok := myctxt.(*amContext)
if ok {
if rc.echoContext == nil {
rc.echoContext = ctxt
}
return rc
}
}
panic("Failed to find AmContext when required")
}
// contextRecycler is the task that recycles context blocks.
func contextRecycler(incoming chan *amContext, done chan bool) {
for c := range incoming {
c.echoContext = nil
c.rendervars = make(jet.VarMap)
c.frameTitle = ""
c.frameMeta = make(map[int]map[string]string)
c.outputType = ""
c.session = nil
c.globals = nil
c.globalFlags = nil
c.user = nil
c.effectiveLevel = 0
c.community = nil
c.isMember = false
c.isMemberLocked = 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
}
}
// ContextCreator is middleware that creates and recycles the AmContext.
func ContextCreator(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
myctxt, err := newContext(c)
if err == nil {
err = next(c)
amContextRecycleBin <- myctxt
}
return err
}
}