405 lines
13 KiB
Go
405 lines
13 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/.
|
|
*
|
|
* SPDX-License-Identifier: MPL-2.0
|
|
*/
|
|
// Package main contains the high-level Amsterdam logic.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
|
|
"git.erbosoft.com/amy/amsterdam/config"
|
|
"git.erbosoft.com/amy/amsterdam/database"
|
|
"git.erbosoft.com/amy/amsterdam/ui"
|
|
"github.com/CloudyKit/jet/v6"
|
|
"github.com/labstack/echo/v4"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
/*----------------------------------------------------------------------------
|
|
* Sidebox rendering
|
|
*----------------------------------------------------------------------------
|
|
*/
|
|
|
|
// SideboxRendering is a wrapper interface used to handle rendering a sidebox's variables.
|
|
type SideboxRendering interface {
|
|
SetVar(string, any)
|
|
}
|
|
|
|
// SideboxRenderFunc "renders" a sidebox by outputing variables through an adapter.
|
|
type SideboxRenderFunc func(context.Context, *database.User, *DisplaySidebox, *string, SideboxRendering) error
|
|
|
|
// DisplaySidebox is the structure used to display a sidebox.
|
|
type DisplaySidebox struct {
|
|
BoxId int32 // box ID
|
|
Title string // title to display
|
|
TitleAnon string // title to display if user is anon
|
|
TemplateName string // name of template to render
|
|
Renderer SideboxRenderFunc // rendering function
|
|
}
|
|
|
|
// renderSBCommunities renders the Communities sidebox.
|
|
func renderSBCommunities(ctx context.Context, u *database.User, sb *DisplaySidebox, param *string, rx SideboxRendering) error {
|
|
g, err := database.AmGlobals(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
l, err := database.AmGetCommunitiesForUser(ctx, u.Uid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rx.SetVar("communities", l)
|
|
isAdmin := make([]bool, len(l))
|
|
for i, c := range l {
|
|
isAdmin[i] = false
|
|
level, err := database.AmGetCommunityAccessLevel(ctx, u.Uid, c.Id)
|
|
if err == nil && database.AmTestPermission("Community.ShowAdmin", level) {
|
|
isAdmin[i] = true
|
|
}
|
|
}
|
|
rx.SetVar("isAdmin", isAdmin)
|
|
rx.SetVar("canManage", !(u.IsAnon))
|
|
rx.SetVar("canCreate", !(u.IsAnon) && u.BaseLevel >= uint16(g.CommunityCreateLevel))
|
|
return nil
|
|
}
|
|
|
|
// renderSBConferences renders the Conferences sidebox.
|
|
func renderSBConferences(ctx context.Context, u *database.User, sb *DisplaySidebox, param *string, rx SideboxRendering) error {
|
|
hl, err := database.AmGetConferenceHotlist(ctx, u)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
comm := make([]*database.Community, len(hl))
|
|
conf := make([]*database.Conference, len(hl))
|
|
alias := make([]string, len(hl))
|
|
newFlag := make([]bool, len(hl))
|
|
for i, h := range hl {
|
|
if comm[i], err = h.Community(ctx); err != nil {
|
|
return err
|
|
}
|
|
if conf[i], err = h.Conference(ctx); err != nil {
|
|
return err
|
|
}
|
|
var a []string
|
|
if a, err = conf[i].Aliases(ctx); err != nil {
|
|
return err
|
|
}
|
|
alias[i] = a[0]
|
|
newFlag[i] = false
|
|
if !u.IsAnon {
|
|
nnew, err := conf[i].UnreadMessages(ctx, u)
|
|
if err == nil {
|
|
newFlag[i] = (nnew > 0)
|
|
}
|
|
}
|
|
}
|
|
rx.SetVar("comm", comm)
|
|
rx.SetVar("conf", conf)
|
|
rx.SetVar("alias", alias)
|
|
rx.SetVar("newFlag", newFlag)
|
|
rx.SetVar("canManage", !(u.IsAnon))
|
|
return nil
|
|
}
|
|
|
|
// renderSBOnlineUsers renders the Online Users sidebox.
|
|
func renderSBOnlineUsers(ctx context.Context, u *database.User, sb *DisplaySidebox, param *string, rx SideboxRendering) error {
|
|
anons, users, maxUsers := ui.AmSessions()
|
|
rx.SetVar("total", len(users)+anons)
|
|
rx.SetVar("maxUsers", maxUsers)
|
|
rx.SetVar("anons", anons)
|
|
rx.SetVar("users", users)
|
|
return nil
|
|
}
|
|
|
|
// sideboxRegistry contains a registry of all known sideboxes.
|
|
var sideboxRegistry map[int32]*DisplaySidebox
|
|
|
|
// init sets up the sidebox registry.
|
|
func init() {
|
|
sideboxRegistry = make(map[int32]*DisplaySidebox)
|
|
sb1 := DisplaySidebox{
|
|
BoxId: database.SideboxIDCommunities,
|
|
Title: "Your Communities",
|
|
TitleAnon: "Featured Communities",
|
|
TemplateName: "sb_comm.jet",
|
|
Renderer: renderSBCommunities,
|
|
}
|
|
sideboxRegistry[database.SideboxIDCommunities] = &sb1
|
|
sb2 := DisplaySidebox{
|
|
BoxId: database.SideboxIDConferences,
|
|
Title: "Your Conference Hotlist",
|
|
TitleAnon: "Featured Conferences",
|
|
TemplateName: "sb_conf.jet",
|
|
Renderer: renderSBConferences,
|
|
}
|
|
sideboxRegistry[database.SideboxIDConferences] = &sb2
|
|
sb3 := DisplaySidebox{
|
|
BoxId: database.SideboxIDOnlineUsers,
|
|
Title: "Users Online",
|
|
TitleAnon: "Users Online",
|
|
TemplateName: "sb_online.jet",
|
|
Renderer: renderSBOnlineUsers,
|
|
}
|
|
sideboxRegistry[database.SideboxIDOnlineUsers] = &sb3
|
|
}
|
|
|
|
// sbRender is a context used for controlling adding variables for sideboxes.
|
|
type sbRender struct {
|
|
ctxt ui.AmContext // the UI context
|
|
id int32 // ID of sidebox being rendered
|
|
}
|
|
|
|
// SetVar sets a sidebox rendering value into the context.
|
|
func (rx *sbRender) SetVar(name string, value any) {
|
|
rx.ctxt.VarMap().Set(fmt.Sprintf("sb%d_%s", rx.id, name), value)
|
|
}
|
|
|
|
// templateGetTopic returns the pointer to the topic.
|
|
func templateGetTopic(args jet.Arguments) reflect.Value {
|
|
post := args.Get(0).Convert(reflect.TypeFor[*database.PostHeader]()).Interface().(*database.PostHeader)
|
|
ctxt := args.Get(1).Convert(reflect.TypeFor[ui.AmContext]()).Interface().(ui.AmContext)
|
|
topic, _ := database.AmGetTopic(ctxt.Ctx(), post.TopicId)
|
|
return reflect.ValueOf(topic)
|
|
}
|
|
|
|
// templateTopicLink returns the link string for the given topic.
|
|
func templateTopicLink(args jet.Arguments) reflect.Value {
|
|
topic := args.Get(0).Convert(reflect.TypeFor[*database.Topic]()).Interface().(*database.Topic)
|
|
ctxt := args.Get(1).Convert(reflect.TypeFor[ui.AmContext]()).Interface().(ui.AmContext)
|
|
link, _ := topic.Link(ctxt.Ctx(), "global")
|
|
return reflect.ValueOf(link)
|
|
}
|
|
|
|
/* TopPage renders the "top level" Amsterdam page (the "home page").
|
|
* Parameters:
|
|
* ctxt - The AmContext for the request.
|
|
* Returns:
|
|
* Command string dictating what to be rendered.
|
|
* Data as a parameter for the command string.
|
|
*/
|
|
func TopPage(ctxt ui.AmContext) (string, any) {
|
|
// Set the page title.
|
|
ctxt.SetFrameTitle("My Front Page")
|
|
|
|
// Retrieve the published posts.
|
|
hdrs, err := database.AmGetPublishedPosts(ctxt.Ctx())
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
|
|
ctxt.VarMap().Set("posts", hdrs)
|
|
ctxt.VarMap().SetFunc("post_getText", templatePostText)
|
|
ctxt.VarMap().SetFunc("post_getUserName", templateExtractUserName)
|
|
ctxt.VarMap().SetFunc("post_topic", templateGetTopic)
|
|
ctxt.VarMap().SetFunc("post_topicLink", templateTopicLink)
|
|
|
|
// Retrieve the sideboxes and create the data to be presented.
|
|
sboxes, err := database.AmGetSideboxes(ctxt.Ctx(), ctxt.CurrentUserId())
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
disp := make([]*DisplaySidebox, 0, len(sboxes))
|
|
rx := sbRender{ctxt: ctxt, id: 0}
|
|
for _, sb := range sboxes {
|
|
if dsb, ok := sideboxRegistry[sb.Boxid]; ok {
|
|
rx.id = sb.Boxid
|
|
err := dsb.Renderer(ctxt.Ctx(), ctxt.CurrentUser(), dsb, sb.Param, &rx)
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
disp = append(disp, dsb)
|
|
} else {
|
|
log.Errorf("TopPage: unknown sidebox ID %d", sb.Boxid)
|
|
}
|
|
}
|
|
ctxt.VarMap().Set("sideboxes", disp)
|
|
|
|
// Final data set.
|
|
ctxt.SetLeftMenu("top")
|
|
if config.GlobalConfig.Site.TopRefresh > 0 {
|
|
ctxt.AddFrameMetadata(ui.FrameMetaHttpEquiv, "refresh", fmt.Sprintf("%d", config.GlobalConfig.Site.TopRefresh))
|
|
}
|
|
return "framed", "top.jet"
|
|
}
|
|
|
|
/* AboutPage renders the "About Amsterdam" page.
|
|
* Parameters:
|
|
* ctxt - The AmContext for the request.
|
|
* Returns:
|
|
* Command string dictating what to be rendered.
|
|
* Data as a parameter for the command string.
|
|
*/
|
|
func AboutPage(ctxt ui.AmContext) (string, any) {
|
|
// Set the database version.
|
|
g, err := database.AmGlobals(ctxt.Ctx())
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
ctxt.VarMap().Set("dbVersion", g.Version)
|
|
|
|
// Set the page title.
|
|
ctxt.SetFrameTitle("About Amsterdam")
|
|
return "framed", "about.jet"
|
|
}
|
|
|
|
/* PolicyPage renders the policy page.
|
|
* Parameters:
|
|
* ctxt - The AmContext for the request.
|
|
* Returns:
|
|
* Command string dictating what to be rendered.
|
|
* Data as a parameter for the command string.
|
|
*/
|
|
func PolicyPage(ctxt ui.AmContext) (string, any) {
|
|
title, body, err := ui.AmLoadHTMLResource(config.GlobalConfig.Site.PolicyResource)
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
|
|
ctxt.VarMap().Set("title", title)
|
|
ctxt.VarMap().Set("body", body)
|
|
ctxt.SetFrameTitle(title)
|
|
return "framed", "policy.jet"
|
|
}
|
|
|
|
/* JumpToShortcut resolves "/go" links by redirecting them to the appropriate page.
|
|
* Parameters:
|
|
* ctxt - The AmContext for the request.
|
|
* Returns:
|
|
* Command string dictating what to be rendered.
|
|
* Data as a parameter for the command string.
|
|
*/
|
|
func JumpToShortcut(ctxt ui.AmContext) (string, any) {
|
|
link, err := database.AmDecodePostLink(ctxt.URLParam("postlink"))
|
|
if err != nil {
|
|
return "error", echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found: %s", ctxt.URLParam("postlink"))).SetInternal(err)
|
|
}
|
|
scope, target := link.Classify()
|
|
if scope != "global" {
|
|
return "error", echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found: %s", ctxt.URLParam("postlink")))
|
|
}
|
|
if err = link.VerifyNames(ctxt.Ctx()); err != nil {
|
|
return "error", echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("not found: %s", ctxt.URLParam("postlink"))).SetInternal(err)
|
|
}
|
|
targetURL := ""
|
|
switch target {
|
|
case "community":
|
|
targetURL = fmt.Sprintf("/comm/%s", link.Community)
|
|
case "conference":
|
|
targetURL = fmt.Sprintf("/comm/%s/conf/%s", link.Community, link.Conference)
|
|
case "topic":
|
|
targetURL = fmt.Sprintf("/comm/%s/conf/%s/r/%d", link.Community, link.Conference, link.Topic)
|
|
case "post", "postrange", "postopenrange":
|
|
targetURL = fmt.Sprintf("/comm/%s/conf/%s/r/%d?r=%d,%d", link.Community, link.Conference, link.Topic, link.FirstPost, link.LastPost)
|
|
default:
|
|
return "error", fmt.Sprintf("invalid target '%s' for link: %s", target, ctxt.URLParam("postlink"))
|
|
}
|
|
return "redirect", targetURL
|
|
}
|
|
|
|
/* ManageSideboxes displays the "sidebox management" page.
|
|
* Parameters:
|
|
* ctxt - The AmContext for the request.
|
|
* Returns:
|
|
* Command string dictating what to be rendered.
|
|
* Data as a parameter for the command string.
|
|
*/
|
|
func ManageSideboxes(ctxt ui.AmContext) (string, any) {
|
|
if ctxt.CurrentUser().IsAnon {
|
|
return "error", ELOGIN
|
|
}
|
|
|
|
// Retrieve the sideboxes from the database.
|
|
sboxes, err := database.AmGetSideboxes(ctxt.Ctx(), ctxt.CurrentUserId())
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
|
|
if ctxt.HasParameter("m") {
|
|
index := ctxt.QueryParamInt("m", -1)
|
|
if index == -1 {
|
|
return "error", EINVAL
|
|
}
|
|
delta := ctxt.QueryParamInt("n", 0)
|
|
if delta == 0 {
|
|
return "error", EINVAL
|
|
}
|
|
err = database.AmReorderSideboxes(ctxt.Ctx(), ctxt.CurrentUserId(), sboxes[index].Sequence, sboxes[index+delta].Sequence)
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
tmp := sboxes[index]
|
|
sboxes[index] = sboxes[index+delta]
|
|
sboxes[index+delta] = tmp
|
|
} else if ctxt.HasParameter("d") {
|
|
index := ctxt.QueryParamInt("d", -1)
|
|
if index == -1 {
|
|
return "error", EINVAL
|
|
}
|
|
err = database.AmRemoveSidebox(ctxt.Ctx(), ctxt.CurrentUserId(), sboxes[index].Boxid)
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
sboxes = append(sboxes[:index], sboxes[index+1:]...)
|
|
}
|
|
|
|
// Create the display list.
|
|
avail := maps.Clone(sideboxRegistry)
|
|
disp := make([]*DisplaySidebox, 0, len(sboxes))
|
|
for _, sb := range sboxes {
|
|
if dsb, ok := avail[sb.Boxid]; ok {
|
|
disp = append(disp, dsb)
|
|
delete(avail, sb.Boxid)
|
|
} else {
|
|
log.Errorf("TopPage: unknown sidebox ID %d", sb.Boxid)
|
|
}
|
|
}
|
|
ctxt.VarMap().Set("sideboxes", disp)
|
|
|
|
// Create the list of available sideboxes to add.
|
|
addList := slices.Collect(maps.Values(avail))
|
|
if len(addList) > 0 {
|
|
slices.SortFunc(addList, func(a, b *DisplaySidebox) int {
|
|
return strings.Compare(strings.ToLower(a.Title), strings.ToLower(b.Title))
|
|
})
|
|
}
|
|
ctxt.VarMap().Set("addList", addList)
|
|
|
|
ctxt.SetFrameTitle("Your Front Page Configuration")
|
|
return "framed", "sideboxes.jet"
|
|
}
|
|
|
|
/* AddSidebox performs the "add sidebox" operation.
|
|
* Parameters:
|
|
* ctxt - The AmContext for the request.
|
|
* Returns:
|
|
* Command string dictating what to be rendered.
|
|
* Data as a parameter for the command string.
|
|
*/
|
|
func AddSidebox(ctxt ui.AmContext) (string, any) {
|
|
if ctxt.CurrentUser().IsAnon {
|
|
return "error", ELOGIN
|
|
}
|
|
|
|
newId, err := ctxt.FormFieldInt("sbid")
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
err = database.AmAppendSidebox(ctxt.Ctx(), ctxt.CurrentUserId(), int32(newId), nil)
|
|
if err != nil {
|
|
return "error", err
|
|
}
|
|
return "redirect", "/sideboxes"
|
|
}
|