/* * 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 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/gorilla/sessions" "github.com/labstack/echo-contrib/session" "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 { 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 RC() int OutputType() string Parameter(string) string QueryParamInt(string, int) int RemoteIP() string ReplaceUser(*database.User) SaveSession() error SetCommunityContext(string) error SetLeftMenu(string) SetLoginCookie(string) SetOutputType(string) SetRC(int) 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 } /*---------------------------------------------------------------------------- * AmContext implementation *---------------------------------------------------------------------------- */ // amContext is the internal structure that implements AmContext. type amContext struct { echoContext echo.Context httprc int rendervars jet.VarMap outputType string session *sessions.Session globals *database.Globals globalFlags *util.OptionSet user *database.User effectiveLevel uint16 community *database.Community isMember bool isMemberLocked bool } // 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() { AmResetSession(c.echoContext.Request().Context(), c.session) 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 := AmSessionGet(c.session, "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 := AmSessionUid(c.session) 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 { rc, ok := AmSessionUid(c.session) if 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 := AmSessionGet(c.session, "leftMenu") if ok { return rc.(string) } else { return "top" } } // RC returns the HTTP result code for the current operation. func (c *amContext) RC() int { return c.httprc } // 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) { AmSetSessionUser(c.session, 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 { AmSessionPut(c.session, "lastCommunity", comm.Id) } } return nil } // SetLeftMenu sets the current topmost left menu name value. func (c *amContext) SetLeftMenu(name string) { AmSessionPut(c.session, "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 } // SetRC sets the HTTP result code for the current operation. func (c *amContext) SetRC(rc int) { c.httprc = rc } // 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, _ := AmSessionGet(c.session, "x."+name) return rc } // SetSession sets a session variable. func (c *amContext) SetSession(name string, value any) { AmSessionPut(c.session, "x."+name, value) } // IsSession tests to see whether a session value is set. func (c *amContext) IsSession(name string) bool { _, ok := AmSessionGet(c.session, "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 } // defoptions is the default options for the HTTP session. var defoptions *sessions.Options = &sessions.Options{ 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{ httprc: http.StatusOK, 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) sess, err := session.Get("AMSTERDAM_SESSION", ctxt) if err == nil { rc.session = sess sess.Options = defoptions if sess.IsNew { AmSessionFirstTime(ctxt.Request().Context(), sess) } else { AmHitSession(sess) } } id, ok := AmSessionUid(sess) 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 := AmSessionGet(sess, "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.httprc = http.StatusOK 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 } }