/* * 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/. * * SPDX-License-Identifier: MPL-2.0 */ // Package main contains the high-level Amsterdam logic. package main import ( "fmt" "net/http" "strconv" "strings" "git.erbosoft.com/amy/amsterdam/config" "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/ui" "git.erbosoft.com/amy/amsterdam/util" "github.com/biter777/countries" "github.com/labstack/echo/v4" ) // ENOJOIN is an error for not being permitted to join a community. var ENOJOIN *echo.HTTPError = echo.NewHTTPError(http.StatusForbidden, "you are not permitted to join this community") // ENOUNJOIN is an error for not being permitted to unjoin a community. var ENOUNJOIN *echo.HTTPError = echo.NewHTTPError(http.StatusForbidden, "you are not permitted to unjoin this community") /* ShowCommunity renders the community profile display. * Parameters: * ctxt - The AmContext for the request. * Returns: * Command string dictating what to be rendered. * Data as a parameter for the command string. */ func ShowCommunity(ctxt ui.AmContext) (string, any) { me := ctxt.CurrentUser() prefs, err := me.Prefs(ctxt.Ctx()) if err != nil { return "error", err } comm := ctxt.CurrentCommunity() // set by middleware ci, err := comm.ContactInfo(ctxt.Ctx()) if err != nil { return "error", err } host, err := comm.Host(ctxt.Ctx()) if err != nil { return "error", err } var cats []*database.Category if !ctxt.GlobalFlags().Get(database.GlobalFlagNoCategories) { cats, err = database.AmGetCategoryHierarchy(ctxt.Ctx(), comm.CategoryId) if err != nil { return "error", err } } var pvtAddr bool if ctxt.TestPermission("Global.SeeHiddenContactInfo") { pvtAddr = false } else { pvtAddr = ci.PrivateAddr } ctxt.VarMap().Set("commName", comm.Name) ctxt.VarMap().Set("commAlias", comm.Alias) if ci.PhotoURL != nil && *ci.PhotoURL != "" { ctxt.VarMap().Set("logoURL", *ci.PhotoURL) } else { ctxt.VarMap().Set("logoURL", config.GlobalConfig.Site.DefaultCommunityLogo) } tz := prefs.Location() loc := prefs.Localizer() ctxt.VarMap().Set("dateCreated", loc.Strftime("%x %X", comm.CreateDate.In(tz))) if comm.LastAccess != nil { ctxt.VarMap().Set("dateLastAccess", loc.Strftime("%x %X", (*comm.LastAccess).In(tz))) } if comm.LastUpdate != nil { ctxt.VarMap().Set("dateLastUpdate", loc.Strftime("%x %X", (*comm.LastUpdate).In(tz))) } if !ctxt.IsMember() && ctxt.EffectiveLevel() >= comm.JoinLevel { ctxt.VarMap().Set("canJoin", true) } if ctxt.IsMember() && !me.IsAnon { ctxt.VarMap().Set("canInvite", true) } ctxt.VarMap().Set("public", comm.Public()) if !ctxt.GlobalFlags().Get(database.GlobalFlagNoCategories) { ctxt.VarMap().Set("categories", cats) } if comm.Synopsis != nil && *comm.Synopsis != "" { ctxt.VarMap().Set("description", *comm.Synopsis) } ctxt.VarMap().Set("hostName", host.Username) if ci.Company != nil && *ci.Company != "" { ctxt.VarMap().Set("company", *ci.Company) } if !pvtAddr && ci.Addr1 != nil && *ci.Addr1 != "" { ctxt.VarMap().Set("addr1", *ci.Addr1) } if !pvtAddr && ci.Addr2 != nil && *ci.Addr2 != "" { ctxt.VarMap().Set("addr2", *ci.Addr2) } var b strings.Builder if ci.Locality != nil { b.WriteString(*ci.Locality) if ci.Region != nil { b.WriteString(", ") } } if ci.Region != nil { b.WriteString(*ci.Region) } if ci.PostalCode != nil { b.WriteString(" " + *ci.PostalCode) } ctxt.VarMap().Set("addrLast", b.String()) if ci.Country != nil && *ci.Country != "" { country := countries.ByName(*ci.Country) ctxt.VarMap().Set("country", country.Emoji()+" "+country.String()) } tag, err := comm.LanguageTag() if err == nil && tag != nil { ctxt.VarMap().Set("language", util.AmLanguageInLanguage(*tag, *prefs.LanguageTag())) } if comm.Rules != nil && *comm.Rules != "" { ctxt.VarMap().Set("rules", *comm.Rules) } if ci.URL != nil && *ci.URL != "" { ctxt.VarMap().Set("homePage", *ci.URL) } ctxt.SetFrameTitle("Community Profile: " + comm.Name) return "framed", "comprofile.jet" } /* JoinCommunity joins a public community, or starts the process of joining a private one. * Parameters: * ctxt - The AmContext for the request. * Returns: * Command string dictating what to be rendered. * Data as a parameter for the command string. */ func JoinCommunity(ctxt ui.AmContext) (string, any) { me := ctxt.CurrentUser() comm := ctxt.CurrentCommunity() // set by middleware mbr, _, _, err := comm.Membership(ctxt.Ctx(), me) if err != nil { return "error", err } if mbr { // already member, this is a no-op return "redirect", fmt.Sprintf("/comm/%s/profile", comm.Alias) } if comm.TestPermission("Community.Join", me.BaseLevel) { if comm.JoinKey != nil && *comm.JoinKey != "" { dlg, err := ui.AmLoadDialog("join") if err != nil { return "error", err } dlg.SetCommunity(comm) dlg.Field("cc").Value = comm.Alias return dlg.Render(ctxt) } // if get here, this is a public community, and we can join err = comm.SetMembership(ctxt.Ctx(), me, database.AmDefaultRole("Community.NewUser").Level(), false, me.Uid, ctxt.RemoteIP()) if err != nil { return "error", err } } else { return "error", ENOJOIN } return "redirect", fmt.Sprintf("/comm/%s/profile", comm.Alias) } /* JoinCommunityWithKey joins a private community with a properly specified join key. * Parameters: * ctxt - The AmContext for the request. * Returns: * Command string dictating what to be rendered. * Data as a parameter for the command string. */ func JoinCommunityWithKey(ctxt ui.AmContext) (string, any) { me := ctxt.CurrentUser() comm := ctxt.CurrentCommunity() // set by middleware mbr, _, _, err := comm.Membership(ctxt.Ctx(), me) if err != nil { return "error", err } if mbr { // already member, this is a no-op return "redirect", fmt.Sprintf("/comm/%s/profile", comm.Alias) } if comm.TestPermission("Community.Join", me.BaseLevel) { dlg, err := ui.AmLoadDialog("join") if err != nil { return "error", err } dlg.SetCommunity(comm) dlg.LoadFromForm(ctxt) action := dlg.WhichButton(ctxt) if action == "cancel" { return "redirect", fmt.Sprintf("/comm/%s/profile", comm.Alias) } if action == "join_now" { key := dlg.Field("key").Value dlg.Field("key").Value = "" // clear it in case we redisplay the form if key == "" { return dlg.RenderError(ctxt, "No join key specified. Please try again.") } if comm.JoinKey != nil && key != *comm.JoinKey { return dlg.RenderError(ctxt, "The join key does not match the community. Please try again.") } err = comm.SetMembership(ctxt.Ctx(), me, database.AmDefaultRole("Community.NewUser").Level(), false, me.Uid, ctxt.RemoteIP()) if err != nil { return dlg.RenderError(ctxt, fmt.Sprintf("Error joining: %v", err)) } return "redirect", fmt.Sprintf("/comm/%s/profile", comm.Alias) } return dlg.RenderError(ctxt, "Unknown button pressed on join form.") } return "redirect", fmt.Sprintf("/comm/%s/profile", comm.Alias) } /* ManageCommunities displays the current list of communities with unjoin buttons. * Parameters: * ctxt - The AmContext for the request. * Returns: * Command string dictating what to be rendered. * Data as a parameter for the command string. */ func ManageCommunities(ctxt ui.AmContext) (string, any) { if ctxt.CurrentUser().IsAnon { return "redirect", "/" } comms, err := database.AmGetCommunitiesForUser(ctxt.Ctx(), ctxt.CurrentUserId()) if err != nil { return "error", err } canUnjoin := make([]bool, len(comms)) for i, c := range comms { _, lock, _, err := c.Membership(ctxt.Ctx(), ctxt.CurrentUser()) if err != nil { return "error", err } canUnjoin[i] = !lock } ctxt.VarMap().Set("communities", comms) ctxt.VarMap().Set("canUnjoin", canUnjoin) ctxt.SetFrameTitle("Your Communities") ctxt.SetLeftMenu("top") return "framed", "comlist.jet" } /* UnjoinCommunity starts the process of unjoining a community. This is a meta-function which sets the return URL * based on its parameter. * Parameters: * uctx - Use context for the function: * prof - Return URL is to the community profile page. * manage - Return URL is to the "manage communities" page. * Returns: * Page function for unjoining a community. */ func UnjoinCommunity(uctx string) ui.AmPageFunc { var returnTemplate string switch uctx { case "prof": returnTemplate = "/comm/[A]/profile" case "manage": returnTemplate = "/manage_comm" default: panic(fmt.Sprintf("config error! uctx = %s", uctx)) } return func(ctxt ui.AmContext) (string, any) { comm := ctxt.CurrentCommunity() returnURL := strings.ReplaceAll(returnTemplate, "[A]", comm.Alias) mbr, lock, _, err := comm.Membership(ctxt.Ctx(), ctxt.CurrentUser()) if err != nil { return "error", err } if !mbr { // not a member, just redirect to profile return "redirect", returnURL } if lock { return "error", ENOUNJOIN } ctxt.VarMap().Set("comm", comm) ctxt.VarMap().Set("returnURL", returnURL) ctxt.SetFrameTitle("Unjoin Community") return "framed", "unjoin.jet" } } /* UnjoinCommunityConfirm finishes the process of unjoining a community. * Parameters: * ctxt - The AmContext for the request. * Returns: * Command string dictating what to be rendered. * Data as a parameter for the command string. */ func UnjoinCommunityConfirm(ctxt ui.AmContext) (string, any) { me := ctxt.CurrentUser() comm := ctxt.CurrentCommunity() // set by middleware returnURL := ctxt.FormField("returnURL") if returnURL == "" { returnURL = fmt.Sprintf("/comm/%s/profile", comm.Alias) } mbr, lock, _, err := comm.Membership(ctxt.Ctx(), me) if err != nil { return "error", err } if !mbr { // not a member, just redirect to profile return "redirect", returnURL } if lock { return "error", ENOUNJOIN } if ctxt.FormFieldIsSet("cancel") { return "redirect", returnURL } if ctxt.FormFieldIsSet("unjoin") { err = comm.SetMembership(ctxt.Ctx(), me, 0, false, me.Uid, ctxt.RemoteIP()) if err != nil { return "error", err } ctxt.ClearCommunityContext() return "redirect", returnURL } return "error", EBUTTON } /* MemberList lists the members of the community. * Parameters: * ctxt - The AmContext for the request. * Returns: * Command string dictating what to be rendered. * Data as a parameter for the command string. */ func MemberList(ctxt ui.AmContext) (string, any) { comm := ctxt.CurrentCommunity() // set by middleware ofs := 0 p := ctxt.Parameter("ofs") if p != "" { v, err := strconv.Atoi(p) if err == nil { ofs = v } } ctxt.VarMap().Set("comm", comm) ctxt.VarMap().Set("hostUid", *comm.HostUid) showHidden := ctxt.TestPermission("Community.ShowHiddenMembers") ctxt.VarMap().Set("canExport", showHidden) ctxt.VarMap().Set("field", "name") ctxt.VarMap().Set("oper", "st") ctxt.VarMap().Set("term", "") ctxt.VarMap().Set("ofs", ofs) ctxt.SetFrameTitle("Members of Community " + comm.Name) listMax := int(ctxt.Globals().MaxCommunityMemberPage) results, total, err := comm.ListMembers(ctxt.Ctx(), database.ListMembersFieldNone, database.ListMembersOperNone, "", ofs*listMax, listMax, showHidden) if err != nil { return "error", err } if total == 0 { ctxt.VarMap().Set("headerLine", "Community Members: (None)") } else { ctxt.VarMap().Set("headerLine", fmt.Sprintf("Community Members: (Displaying %d-%d of %d)", ofs*listMax+1, ofs*listMax+len(results), total)) } if len(results) > 0 { ctxt.VarMap().Set("resultList", results) if ofs > 0 { ctxt.VarMap().Set("resultShowPrev", true) } if ofs*listMax+len(results) < total { ctxt.VarMap().Set("resultShowNext", true) } } return "framed", "memberlist.jet" } /* MemberSearch searches for members of the community. * Parameters: * ctxt - The AmContext for the request. * Returns: * Command string dictating what to be rendered. * Data as a parameter for the command string. */ func MemberSearch(ctxt ui.AmContext) (string, any) { comm := ctxt.CurrentCommunity() // set by middleware ofs, _ := ctxt.FormFieldInt("ofs") field := ctxt.FormField("field") oper := ctxt.FormField("oper") term := ctxt.FormField("term") ctxt.VarMap().Set("comm", comm) ctxt.VarMap().Set("hostUid", comm.HostUid) showHidden := ctxt.TestPermission("Community.ShowHiddenMembers") ctxt.VarMap().Set("canExport", showHidden) ctxt.VarMap().Set("field", field) ctxt.VarMap().Set("oper", oper) ctxt.VarMap().Set("term", term) ctxt.VarMap().Set("ofs", ofs) ctxt.SetFrameTitle("Search for Members") var iField, iOper int switch field { case "name": iField = database.ListMembersFieldName case "descr": iField = database.ListMembersFieldDescription case "first": iField = database.ListMembersFieldFirstName case "last": iField = database.ListMembersFieldLastName default: ctxt.VarMap().Set("errorMessage", "invalid parameter to find") return "framed", "memberlist.jet" } switch oper { case "st": iOper = database.ListMembersOperPrefix case "in": iOper = database.ListMembersOperSubstring case "re": iOper = database.ListMembersOperRegex default: ctxt.VarMap().Set("errorMessage", "invalid parameter to find") return "framed", "memberlist.jet" } listMax := int(ctxt.Globals().MaxCommunityMemberPage) results, total, err := comm.ListMembers(ctxt.Ctx(), iField, iOper, term, ofs*listMax, listMax, showHidden) if err != nil { return "error", err } if total == 0 { ctxt.VarMap().Set("headerLine", "Search Results: (None)") } else { ctxt.VarMap().Set("headerLine", fmt.Sprintf("Search Results: (Displaying %d-%d of %d)", ofs*listMax+1, ofs*listMax+len(results), total)) } if len(results) > 0 { ctxt.VarMap().Set("resultList", results) if ofs > 0 { ctxt.VarMap().Set("resultShowPrev", true) } if ofs*listMax+len(results) < total { ctxt.VarMap().Set("resultShowNext", true) } } return "framed", "memberlist.jet" }