651 lines
17 KiB
Go
651 lines
17 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"
|
|
"errors"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"sync"
|
|
"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
|
|
FormFieldValues(string) ([]string, error)
|
|
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
|
|
Locator() 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)
|
|
}
|
|
|
|
// FormFieldValues returns all values for a specified parameter name.
|
|
func (c *amContext) FormFieldValues(name string) ([]string, error) {
|
|
vals, err := c.echoContext.FormParams()
|
|
if err != nil {
|
|
return make([]string, 0), err
|
|
}
|
|
if rc, ok := vals[name]; ok {
|
|
return rc, nil
|
|
}
|
|
return make([]string, 0), errors.New("parameter not found")
|
|
}
|
|
|
|
// FormFile returns a "file" parameter from a multipart upload form.
|
|
func (c *amContext) FormFile(name string) (*multipart.FileHeader, error) {
|
|
return c.echoContext.FormFile(name)
|
|
}
|
|
|
|
// emptyMap is the return from FrameMetadata if the specified selector is not present.
|
|
var emptyMap map[string]string = make(map[string]string)
|
|
|
|
// 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 = emptyMap
|
|
}
|
|
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"
|
|
}
|
|
}
|
|
|
|
// Locator returns the current URL path minus the scheme and host, so it's a site-relative locator.
|
|
func (c *amContext) Locator() string {
|
|
tmp := url.URL{
|
|
Path: c.echoContext.Request().URL.Path,
|
|
RawPath: c.echoContext.Request().URL.RawPath,
|
|
RawQuery: c.echoContext.Request().URL.RawQuery,
|
|
Fragment: c.echoContext.Request().URL.Fragment,
|
|
RawFragment: c.echoContext.Request().URL.RawFragment,
|
|
}
|
|
return tmp.String()
|
|
}
|
|
|
|
// 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 sync.Pool
|
|
|
|
// 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) {
|
|
var rc *amContext
|
|
tmp := freeContext.Get()
|
|
if tmp == nil {
|
|
rc = &amContext{
|
|
rendervars: make(jet.VarMap),
|
|
frameTitle: "",
|
|
frameMeta: make(map[int]map[string]string),
|
|
outputType: "",
|
|
}
|
|
} else {
|
|
rc = tmp.(*amContext)
|
|
}
|
|
|
|
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)
|
|
stmp := ctxt.Get("AmSessionStore")
|
|
if stmp != nil {
|
|
store := stmp.(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
|
|
}
|
|
|
|
// setupContext starts the recycler for contexts.
|
|
func setupContext() func() {
|
|
amContextRecycleBin = make(chan *amContext, config.GlobalConfig.Tuning.Queues.ContextRecycle)
|
|
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
|
|
}
|
|
}
|