/* * 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 *---------------------------------------------------------------------------- */ // AmContext is the interface for Amsterdam's wrapper context that exposes the required functionality. type AmContext interface { 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) 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 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 outputType string session AmSession globals *database.Globals globalFlags *util.OptionSet user *database.User effectiveLevel uint16 community *database.Community isMember bool isMemberLocked bool } // 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) } // 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 } // 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), 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.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 } }