mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-10-31 08:40:07 +00:00
a58bdf6579
Keep the chosen article language when widget buttons use another translation. Match regional codes without mixing Traditional and Simplified Chinese.
2205 lines
77 KiB
Go
2205 lines
77 KiB
Go
package main
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"html/template"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"net"
|
|
"net/url"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
|
"github.com/abhinavxd/libredesk/internal/envelope"
|
|
"github.com/abhinavxd/libredesk/internal/helpcenter"
|
|
hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models"
|
|
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
|
|
"github.com/abhinavxd/libredesk/internal/media"
|
|
"github.com/abhinavxd/libredesk/internal/stringutil"
|
|
realip "github.com/ferluci/fast-realip"
|
|
"github.com/knadh/stuffbin"
|
|
"github.com/valyala/fasthttp"
|
|
"github.com/zerodha/fastcache/v4"
|
|
"github.com/zerodha/fastglue"
|
|
"github.com/zerodha/logf"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
const (
|
|
publicSearchLimit = 20
|
|
popularArticlesLimit = 6
|
|
relatedArticlesLimit = 5
|
|
insightsTermLimit = 20
|
|
markdownSlugExtension = ".md"
|
|
previewPageArticle = "article"
|
|
|
|
// sitemapURLLimit is the per-sitemap URL cap set by the sitemap protocol.
|
|
sitemapURLLimit = 50000
|
|
|
|
sitemapNamespace = "http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
sitemapDate = "2006-01-02"
|
|
schemaOrgContext = "https://schema.org"
|
|
|
|
helpCenterCacheControl = "public, no-cache"
|
|
|
|
fastCachePrefix = "libredesk:cache:"
|
|
|
|
helpCenterCacheTTL = 30 * time.Minute
|
|
|
|
helpCenterCacheGroup = "helpcenter"
|
|
|
|
helpCenterCacheNamespaceKey = "hc_cache_ns"
|
|
helpCenterCacheNamespace = "hc"
|
|
|
|
helpCenterXMLCacheControl = "public, max-age=300, stale-while-revalidate=3600"
|
|
|
|
// articleFeedbackDedupTTL is how long a reader IP's vote on an article suppresses further votes.
|
|
articleFeedbackDedupTTL = 24 * time.Hour
|
|
|
|
noIndexHeader = "noindex"
|
|
|
|
// hcHostRewriteKey marks a request already rewritten to its /hc/{slug} form.
|
|
hcHostRewriteKey = "hc_host_rewrite"
|
|
|
|
lucideSpritePath = "/static/public/static/lucide-sprite.svg"
|
|
|
|
headerTextDark = "#16181d"
|
|
headerTextLight = "#ffffff"
|
|
)
|
|
|
|
var (
|
|
// Logger is set because fastcache writes to the field when it finds it nil, racing across requests.
|
|
helpCenterCacheOpts = &fastcache.Options{
|
|
NamespaceKey: helpCenterCacheNamespaceKey,
|
|
ETag: true,
|
|
IncludeQueryString: true,
|
|
TTL: helpCenterCacheTTL,
|
|
Logger: log.New(io.Discard, "", 0),
|
|
}
|
|
|
|
// crawlerUARe matches search and preview bots, whose hits must not count as reader views.
|
|
crawlerUARe = regexp.MustCompile(`(?i)bot\b|bot/|crawler|spider|crawling|slurp|facebookexternalhit|preview|headlesschrome|lighthouse|feedfetcher|python-requests|curl/|wget/`)
|
|
|
|
lucideSymbolRe = regexp.MustCompile(`(?s)<symbol id="([a-z0-9-]+)"[^>]*>(.*?)</symbol>`)
|
|
|
|
// rtlLanguages covers the right-to-left base languages an operator can put in AllowedLocales.
|
|
rtlLanguages = []string{"ar", "he", "fa", "ur", "ps", "sd", "yi", "dv", "ckb"}
|
|
|
|
// crawlerDisallowedPaths are the non-public parts of the app served on the same host as
|
|
// the help center. /uploads is deliberately absent: article images live there and have
|
|
// to stay crawlable for image search and social previews.
|
|
crawlerDisallowedPaths = []string{
|
|
"/api/",
|
|
"/assets/",
|
|
"/login",
|
|
"/logout",
|
|
"/reset-password",
|
|
"/set-password",
|
|
"/csat/",
|
|
"/hc/*/*/search",
|
|
}
|
|
)
|
|
|
|
// helpCenterCacheLogWriter routes fastcache's log lines into the app logger.
|
|
type helpCenterCacheLogWriter struct {
|
|
lo *logf.Logger
|
|
}
|
|
|
|
type sitemapURL struct {
|
|
Loc string `xml:"loc"`
|
|
LastMod string `xml:"lastmod,omitempty"`
|
|
}
|
|
|
|
type urlset struct {
|
|
XMLName xml.Name `xml:"urlset"`
|
|
Xmlns string `xml:"xmlns,attr"`
|
|
URLs []sitemapURL `xml:"url"`
|
|
}
|
|
|
|
type sitemapRef struct {
|
|
Loc string `xml:"loc"`
|
|
}
|
|
|
|
type sitemapIndex struct {
|
|
XMLName xml.Name `xml:"sitemapindex"`
|
|
Xmlns string `xml:"xmlns,attr"`
|
|
Sitemaps []sitemapRef `xml:"sitemap"`
|
|
}
|
|
|
|
// localeLink is one entry in the language switcher or in the hreflang set.
|
|
type localeLink struct {
|
|
Locale string
|
|
Path string
|
|
}
|
|
|
|
type helpArticleResponse struct {
|
|
hcmodels.Article
|
|
Translations []hcmodels.ArticleTranslation `json:"translations"`
|
|
}
|
|
|
|
type previewTOCItem struct {
|
|
ID string
|
|
Title string
|
|
}
|
|
|
|
type colorSchemeResult struct {
|
|
dark bool
|
|
showToggle bool
|
|
followSystem bool
|
|
}
|
|
|
|
func (w helpCenterCacheLogWriter) Write(p []byte) (int, error) {
|
|
w.lo.Error("help center page cache: " + strings.TrimSpace(string(p)))
|
|
return len(p), nil
|
|
}
|
|
|
|
// handleGetHelpCenterLocales returns the locales a help center can be authored in.
|
|
func handleGetHelpCenterLocales(r *fastglue.Request) error {
|
|
return r.SendEnvelope(helpcenter.SupportedLocales())
|
|
}
|
|
|
|
// handleGetHelpCenters returns all help centers.
|
|
func handleGetHelpCenters(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
helpCenters, err := app.helpcenter.GetAllHelpCenters()
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(helpCenters)
|
|
}
|
|
|
|
// handleGetHelpCenter returns a help center by ID.
|
|
func handleGetHelpCenter(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
helpCenter, err := app.helpcenter.GetHelpCenterByID(id)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(helpCenter)
|
|
}
|
|
|
|
// handleCreateHelpCenter creates a new help center.
|
|
func handleCreateHelpCenter(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req = helpcenter.HelpCenterRequest{}
|
|
)
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if err := validateHelpCenter(app, &req); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
helpCenter, err := app.helpcenter.CreateHelpCenter(req)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(helpCenter)
|
|
}
|
|
|
|
// handleUpdateHelpCenter updates a help center.
|
|
func handleUpdateHelpCenter(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req = helpcenter.HelpCenterRequest{}
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if err := validateHelpCenter(app, &req); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
helpCenter, err := app.helpcenter.UpdateHelpCenter(id, req)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(helpCenter)
|
|
}
|
|
|
|
// handleHelpCenterPreview renders the home page from unsaved settings so the admin can preview edits.
|
|
func handleHelpCenterPreview(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req = helpcenter.HelpCenterRequest{}
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
helpCenter, err := app.helpcenter.DraftHelpCenter(id, req)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
locale := helpCenter.DefaultLocale
|
|
if string(r.RequestCtx.QueryArgs().Peek("page")) == previewPageArticle {
|
|
return renderHelpCenterArticlePreview(r, helpCenter, locale)
|
|
}
|
|
popular, err := app.helpcenter.GetPopularArticles(helpCenter.Slug, locale, popularArticlesLimit)
|
|
if err != nil {
|
|
popular = nil
|
|
}
|
|
tree, err := app.helpcenter.GetPublicTree(helpCenter, locale)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
if err := app.tmpl.RenderWebPage(r.RequestCtx, hcPageName(helpCenter, "help-center"), map[string]interface{}{
|
|
"L": localeI18n(app, locale),
|
|
"Data": map[string]interface{}{
|
|
"Title": helpCenter.PageTitle,
|
|
"LandingHero": true,
|
|
"HelpCenter": helpCenterTemplateData(app, r, helpCenter, locale),
|
|
"Tree": tree.Tree,
|
|
"Popular": popular,
|
|
},
|
|
}); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
r.RequestCtx.Response.Header.Set("Cache-Control", "no-store")
|
|
return nil
|
|
}
|
|
|
|
// handleToggleHelpCenterActive toggles whether a help center is live or paused.
|
|
func handleToggleHelpCenterActive(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
hc, err := app.helpcenter.ToggleHelpCenterActive(id)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(hc)
|
|
}
|
|
|
|
// handleDeleteHelpCenter deletes a help center.
|
|
func handleDeleteHelpCenter(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
if err := app.helpcenter.DeleteHelpCenter(id); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(true)
|
|
}
|
|
|
|
// handleGetHelpCenterTree returns the full collection/article tree for a help center.
|
|
func handleGetHelpCenterTree(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
locale = strings.TrimSpace(string(r.RequestCtx.QueryArgs().Peek("locale")))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
tree, err := app.helpcenter.GetHelpCenterTree(id, locale)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(tree)
|
|
}
|
|
|
|
// handleGetCollections returns all collections for a help center.
|
|
func handleGetCollections(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
helpCenterID, _ = strconv.Atoi(r.RequestCtx.UserValue("hc_id").(string))
|
|
)
|
|
if helpCenterID <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`help_center_id`"), nil, envelope.InputError)
|
|
}
|
|
collections, err := app.helpcenter.GetCollectionsByHelpCenter(helpCenterID)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(collections)
|
|
}
|
|
|
|
// handleCreateCollection creates a new collection.
|
|
func handleCreateCollection(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req = helpcenter.CollectionRequest{}
|
|
helpCenterID, _ = strconv.Atoi(r.RequestCtx.UserValue("hc_id").(string))
|
|
)
|
|
if helpCenterID <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`help_center_id`"), nil, envelope.InputError)
|
|
}
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if err := validateCollection(app, &req); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
req.Slug = helpcenter.GenerateSlug(req.Name)
|
|
collection, err := app.helpcenter.CreateCollection(helpCenterID, req)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(collection)
|
|
}
|
|
|
|
// handleUpdateCollection updates a collection, keeping its existing slug.
|
|
func handleUpdateCollection(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req = helpcenter.CollectionRequest{}
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if err := validateCollection(app, &req); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
existing, err := app.helpcenter.GetCollectionByID(id)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
req.Slug = existing.Slug
|
|
collection, err := app.helpcenter.UpdateCollection(id, req)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(collection)
|
|
}
|
|
|
|
// handleUpdateCollectionSortOrders reorders the collections of a help center.
|
|
func handleUpdateCollectionSortOrders(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
helpCenterID, _ = strconv.Atoi(r.RequestCtx.UserValue("hc_id").(string))
|
|
orders = make(map[int]int)
|
|
)
|
|
if helpCenterID <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`help_center_id`"), nil, envelope.InputError)
|
|
}
|
|
if err := r.Decode(&orders, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if err := app.helpcenter.UpdateCollectionSortOrders(helpCenterID, orders); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(true)
|
|
}
|
|
|
|
// handleToggleCollection toggles the published status of a collection.
|
|
func handleToggleCollection(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
collection, err := app.helpcenter.ToggleCollectionPublished(id)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(collection)
|
|
}
|
|
|
|
// handleDeleteCollection deletes a collection.
|
|
func handleDeleteCollection(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
if err := app.helpcenter.DeleteCollection(id); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(true)
|
|
}
|
|
|
|
// handleGetArticle returns an article by ID.
|
|
func handleGetArticle(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
article, err := app.helpcenter.GetArticleByID(id)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
translations, err := app.helpcenter.GetArticleTranslations(id)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(helpArticleResponse{Article: article, Translations: translations})
|
|
}
|
|
|
|
// handleCreateArticle creates a new article.
|
|
func handleCreateArticle(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req = helpcenter.ArticleRequest{}
|
|
collectionID, _ = strconv.Atoi(r.RequestCtx.UserValue("col_id").(string))
|
|
)
|
|
if collectionID <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`collection_id`"), nil, envelope.InputError)
|
|
}
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if err := validateArticle(app, &req); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
req.Slug = helpcenter.GenerateSlug(req.Title)
|
|
req.CollectionID = nil
|
|
if req.Status == "" {
|
|
req.Status = hcmodels.ArticleStatusDraft
|
|
}
|
|
if auser, ok := r.RequestCtx.UserValue("user").(amodels.User); ok && auser.ID > 0 {
|
|
userID := int64(auser.ID)
|
|
req.CreatedBy = &userID
|
|
if req.AuthorID == nil {
|
|
req.AuthorID = &userID
|
|
}
|
|
}
|
|
article, err := app.helpcenter.CreateArticle(collectionID, req)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
if err := app.media.LinkHelpArticleMedia(article.ID, article.Content); err != nil {
|
|
app.lo.Error("error linking help article media", "article_id", article.ID, "error", err)
|
|
}
|
|
return r.SendEnvelope(article)
|
|
}
|
|
|
|
// handleDeleteArticle deletes an article.
|
|
func handleDeleteArticle(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
if err := app.helpcenter.DeleteArticle(id); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(true)
|
|
}
|
|
|
|
// handleMoveArticle moves an article to another collection.
|
|
func handleMoveArticle(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req = struct {
|
|
CollectionID int `json:"collection_id"`
|
|
}{}
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if req.CollectionID <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`collection_id`"), nil, envelope.InputError)
|
|
}
|
|
article, err := app.helpcenter.MoveArticle(id, req.CollectionID)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(article)
|
|
}
|
|
|
|
// handleUpdateArticleSortOrders reorders the articles of a collection.
|
|
func handleUpdateArticleSortOrders(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
collectionID, _ = strconv.Atoi(r.RequestCtx.UserValue("col_id").(string))
|
|
orders = make(map[int]int)
|
|
)
|
|
if collectionID <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`collection_id`"), nil, envelope.InputError)
|
|
}
|
|
if err := r.Decode(&orders, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if err := app.helpcenter.UpdateArticleSortOrders(collectionID, orders); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(true)
|
|
}
|
|
|
|
// handleUpdateArticleStatus updates the status of an article.
|
|
func handleUpdateArticleStatus(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req = struct {
|
|
Status string `json:"status"`
|
|
}{}
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if req.Status == "" {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`status`"), nil, envelope.InputError)
|
|
}
|
|
article, err := app.helpcenter.UpdateArticleStatus(id, req.Status)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(article)
|
|
}
|
|
|
|
// handleGetLinkableArticles lists articles in the other locales that can still be linked as a translation.
|
|
func handleGetLinkableArticles(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
excludeLocale = strings.TrimSpace(string(r.RequestCtx.QueryArgs().Peek("exclude_locale")))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
if excludeLocale == "" {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`exclude_locale`"), nil, envelope.InputError)
|
|
}
|
|
articles, err := app.helpcenter.GetLinkableArticles(id, excludeLocale)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(articles)
|
|
}
|
|
|
|
// handleLinkArticleTranslation moves an existing article into this article's translation group.
|
|
func handleLinkArticleTranslation(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req = struct {
|
|
TranslationOfID int `json:"translation_of_id"`
|
|
}{}
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if req.TranslationOfID <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`translation_of_id`"), nil, envelope.InputError)
|
|
}
|
|
article, err := app.helpcenter.LinkArticleTranslation(id, req.TranslationOfID)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(article)
|
|
}
|
|
|
|
// handleUnlinkArticleTranslation detaches an article from its translation group.
|
|
func handleUnlinkArticleTranslation(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
article, err := app.helpcenter.UnlinkArticleTranslation(id)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(article)
|
|
}
|
|
|
|
// handleRedirectHelpCenterHome redirects bare /hc/{slug} to the default-locale home so the locale is always in the path.
|
|
func handleRedirectHelpCenterHome(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
slug = r.RequestCtx.UserValue("slug").(string)
|
|
)
|
|
helpCenter, err := app.helpcenter.GetHelpCenterBySlug(slug)
|
|
if err != nil {
|
|
return renderHelpCenterPageError(r, nil, err)
|
|
}
|
|
if redirectHelpCenterCanonicalHost(r, helpCenter) {
|
|
return nil
|
|
}
|
|
uri := helpCenterHomePath(helpCenter, helpCenter.DefaultLocale)
|
|
if qs := r.RequestCtx.URI().QueryString(); len(qs) > 0 {
|
|
uri += "?" + string(qs)
|
|
}
|
|
// 302, not 301: the default locale is mutable and browsers cache permanent redirects.
|
|
redirectPath(r.RequestCtx, uri, fasthttp.StatusFound)
|
|
return nil
|
|
}
|
|
|
|
// handleShowHelpCenterHome renders the public help center home page.
|
|
func handleShowHelpCenterHome(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
slug = r.RequestCtx.UserValue("slug").(string)
|
|
)
|
|
helpCenter, err := app.helpcenter.GetHelpCenterBySlug(slug)
|
|
if err != nil {
|
|
return renderHelpCenterPageError(r, nil, err)
|
|
}
|
|
if redirectHelpCenterCanonicalHost(r, helpCenter) {
|
|
return nil
|
|
}
|
|
locale, ok := resolveLocale(r, helpCenter)
|
|
if !ok {
|
|
return renderHelpCenterNotFound(r, &helpCenter)
|
|
}
|
|
tree, err := app.helpcenter.GetPublicTree(helpCenter, locale)
|
|
if err != nil {
|
|
return renderHelpCenterPageError(r, &helpCenter, err)
|
|
}
|
|
popular, err := app.helpcenter.GetPopularArticles(slug, locale, popularArticlesLimit)
|
|
if err != nil {
|
|
popular = nil
|
|
}
|
|
var (
|
|
root = helpCenterBaseURL(app, helpCenter)
|
|
locales = helpCenterLocales(helpCenter)
|
|
pathFor = func(l string) string { return helpCenterHomePath(helpCenter, l) }
|
|
theme = helpCenterTheme(tree.HelpCenter)
|
|
metaDescription = firstNonEmpty(tree.HelpCenter.MetaDescription, theme.Header.Heading)
|
|
)
|
|
data := helpCenterTemplateData(app, r, tree.HelpCenter, locale)
|
|
return renderHelpCenterPage(r, hcPageName(helpCenter, "help-center"), map[string]interface{}{
|
|
"L": localeI18n(app, locale),
|
|
"Data": map[string]interface{}{
|
|
"Title": tree.HelpCenter.PageTitle,
|
|
"MetaDescription": metaDescription,
|
|
"CanonicalPath": pathFor(locale),
|
|
"LandingHero": true,
|
|
"OGImage": absoluteURL(root, publicAssetPaths(app, theme.LogoURL)),
|
|
"Alternates": helpCenterAlternates(helpCenter, locales, pathFor),
|
|
"XDefaultPath": defaultLocalePath(helpCenter, locales, pathFor),
|
|
"LocaleLinks": helpCenterLocaleLinks(helpCenter, locales, pathFor),
|
|
"JSONLD": homeJSONLD(root, tree.HelpCenter, locale),
|
|
"HelpCenter": data,
|
|
"Tree": tree.Tree,
|
|
"Popular": popular,
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleShowHelpCenterCollection renders a single collection's page: its sub-collections and articles.
|
|
func handleShowHelpCenterCollection(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
slug = r.RequestCtx.UserValue("slug").(string)
|
|
collectionSlug = r.RequestCtx.UserValue("collection_slug").(string)
|
|
)
|
|
helpCenter, err := app.helpcenter.GetHelpCenterBySlug(slug)
|
|
if err != nil {
|
|
return renderHelpCenterPageError(r, nil, err)
|
|
}
|
|
if redirectHelpCenterCanonicalHost(r, helpCenter) {
|
|
return nil
|
|
}
|
|
locale, ok := resolveLocale(r, helpCenter)
|
|
if !ok {
|
|
return renderHelpCenterNotFound(r, &helpCenter)
|
|
}
|
|
tree, err := app.helpcenter.GetPublicTree(helpCenter, locale)
|
|
if err != nil {
|
|
return renderHelpCenterPageError(r, &helpCenter, err)
|
|
}
|
|
collection := findCollectionNode(tree.Tree, collectionSlug)
|
|
if collection == nil {
|
|
return renderHelpCenterNotFound(r, &helpCenter)
|
|
}
|
|
translated, err := app.helpcenter.GetPublishedCollectionLocales(slug, collection.Slug)
|
|
if err != nil {
|
|
translated = []string{locale}
|
|
}
|
|
var (
|
|
root = helpCenterBaseURL(app, helpCenter)
|
|
pathFor = func(l string) string { return collectionPath(helpCenter, l, collection.Slug) }
|
|
)
|
|
data := helpCenterTemplateData(app, r, helpCenter, locale)
|
|
return renderHelpCenterPage(r, hcPageName(helpCenter, "help-collection"), map[string]interface{}{
|
|
"L": localeI18n(app, locale),
|
|
"Data": map[string]interface{}{
|
|
"Title": fmt.Sprintf("%s - %s", collection.Name, helpCenter.Name),
|
|
"MetaDescription": collection.Description,
|
|
"CanonicalPath": pathFor(locale),
|
|
"OGImage": absoluteURL(root, publicAssetPaths(app, helpCenterTheme(helpCenter).LogoURL)),
|
|
"Alternates": helpCenterAlternates(helpCenter, translated, pathFor),
|
|
"XDefaultPath": defaultLocalePath(helpCenter, translated, pathFor),
|
|
"LocaleLinks": helpCenterLocaleLinks(helpCenter, translated, pathFor),
|
|
"JSONLD": collectionJSONLD(root, helpCenter, *collection, locale, pathFor(locale)),
|
|
"HelpCenter": data,
|
|
"Collection": collection,
|
|
"Tree": tree.Tree,
|
|
"ActiveCollection": collection.Slug,
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleShowHelpCenterArticle renders a published article, or raw text for a `.md` slug.
|
|
func handleShowHelpCenterArticle(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
slug = r.RequestCtx.UserValue("slug").(string)
|
|
articleSlug = r.RequestCtx.UserValue("article_slug").(string)
|
|
markdown = strings.HasSuffix(articleSlug, markdownSlugExtension)
|
|
)
|
|
if markdown {
|
|
articleSlug = strings.TrimSuffix(articleSlug, markdownSlugExtension)
|
|
}
|
|
helpCenter, err := app.helpcenter.GetHelpCenterBySlug(slug)
|
|
if err != nil {
|
|
return renderHelpCenterPageError(r, nil, err)
|
|
}
|
|
if redirectHelpCenterCanonicalHost(r, helpCenter) {
|
|
return nil
|
|
}
|
|
locale, ok := resolveLocale(r, helpCenter)
|
|
if !ok {
|
|
return renderHelpCenterNotFound(r, &helpCenter)
|
|
}
|
|
article, err := app.helpcenter.GetPublishedArticle(slug, articleSlug, locale)
|
|
if err != nil {
|
|
return renderHelpCenterPageError(r, &helpCenter, err)
|
|
}
|
|
// The JSON-LD embeds the author too, not just the byline.
|
|
hideArticleAuthor(helpCenterTheme(helpCenter), &article)
|
|
|
|
if markdown {
|
|
r.RequestCtx.SetContentType("text/markdown; charset=utf-8")
|
|
fmt.Fprintf(r.RequestCtx, "# %s\n\n%s\n", article.Title, stringutil.HTML2Text(article.Content))
|
|
return nil
|
|
}
|
|
collection, err := app.helpcenter.GetCollectionByID(article.CollectionID)
|
|
if err != nil {
|
|
collection = hcmodels.Collection{}
|
|
}
|
|
related, err := app.helpcenter.GetPublishedArticlesByCollection(article.CollectionID, article.ID, article.Locale, relatedArticlesLimit)
|
|
if err != nil {
|
|
related = nil
|
|
}
|
|
translations, err := app.helpcenter.GetPublishedArticleTranslations(slug, article.ID)
|
|
if err != nil {
|
|
translations = nil
|
|
}
|
|
translated := make([]string, 0, len(translations)+1)
|
|
slugByLocale := map[string]string{locale: article.Slug}
|
|
for _, t := range translations {
|
|
translated = append(translated, t.Locale)
|
|
slugByLocale[t.Locale] = t.Slug
|
|
}
|
|
if !slices.Contains(translated, locale) {
|
|
translated = append(translated, locale)
|
|
}
|
|
var (
|
|
root = helpCenterBaseURL(app, helpCenter)
|
|
pathFor = func(l string) string { return articlePath(helpCenter, l, slugByLocale[l]) }
|
|
metaDescription = firstNonEmpty(article.MetaDescription, article.Excerpt)
|
|
metaTitle = firstNonEmpty(article.MetaTitle, fmt.Sprintf("%s - %s", article.Title, helpCenter.Name))
|
|
ogImage = absoluteURL(root, publicAssetPaths(app, firstNonEmpty(article.MetaImageURL, helpCenterTheme(helpCenter).LogoURL)))
|
|
)
|
|
data := helpCenterTemplateData(app, r, helpCenter, locale)
|
|
return renderHelpCenterPage(r, hcPageName(helpCenter, "help-article"), map[string]interface{}{
|
|
"L": localeI18n(app, locale),
|
|
"Data": map[string]interface{}{
|
|
"Title": metaTitle,
|
|
"MetaDescription": metaDescription,
|
|
"OGImage": ogImage,
|
|
"CanonicalPath": pathFor(locale),
|
|
"OGType": "article",
|
|
"PublishedTime": article.CreatedAt.Format(time.RFC3339),
|
|
"ModifiedTime": article.UpdatedAt.Format(time.RFC3339),
|
|
"Alternates": helpCenterAlternates(helpCenter, translated, pathFor),
|
|
"XDefaultPath": defaultLocalePath(helpCenter, translated, pathFor),
|
|
"LocaleLinks": helpCenterLocaleLinks(helpCenter, translated, pathFor),
|
|
"JSONLD": articleJSONLD(root, helpCenter, collection, article, locale, pathFor(locale), ogImage),
|
|
"HelpCenter": data,
|
|
"Article": article,
|
|
"AuthorInitial": authorInitial(article),
|
|
"Collection": collection,
|
|
"Related": related,
|
|
"Tree": sidebarTree(app, helpCenter, locale),
|
|
"ActiveCollection": collection.Slug,
|
|
"ActiveArticle": article.Slug,
|
|
"NoIndex": isEmbedRequest(r),
|
|
"Content": template.HTML(publicAssetPaths(app, stringutil.DeferOffscreenImages(article.Content))),
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleHelpCenterSearch renders the public article search results page.
|
|
func handleHelpCenterSearch(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
slug = r.RequestCtx.UserValue("slug").(string)
|
|
query = strings.TrimSpace(string(r.RequestCtx.QueryArgs().Peek("q")))
|
|
)
|
|
helpCenter, err := app.helpcenter.GetHelpCenterBySlug(slug)
|
|
if err != nil {
|
|
return renderHelpCenterPageError(r, nil, err)
|
|
}
|
|
if redirectHelpCenterCanonicalHost(r, helpCenter) {
|
|
return nil
|
|
}
|
|
locale, ok := resolveLocale(r, helpCenter)
|
|
if !ok {
|
|
return renderHelpCenterNotFound(r, &helpCenter)
|
|
}
|
|
var articles []hcmodels.Article
|
|
if query != "" {
|
|
articles, err = app.helpcenter.SearchPublishedArticles(slug, query, locale, publicSearchLimit)
|
|
if err != nil {
|
|
articles = nil
|
|
} else if !isCrawler(r) {
|
|
app.helpcenter.LogSearch(helpCenter.ID, query, len(articles))
|
|
}
|
|
}
|
|
var (
|
|
data = helpCenterTemplateData(app, r, helpCenter, locale)
|
|
lcl = localeI18n(app, locale)
|
|
pathFor = func(l string) string { return searchPath(helpCenter, l) }
|
|
)
|
|
return renderHelpCenterPage(r, hcPageName(helpCenter, "help-search"), map[string]interface{}{
|
|
"L": lcl,
|
|
"Data": map[string]interface{}{
|
|
"Title": fmt.Sprintf("%s - %s", lcl.T("globals.terms.search"), helpCenter.Name),
|
|
"NoIndex": true,
|
|
"LocaleLinks": helpCenterLocaleLinks(helpCenter, helpCenterLocales(helpCenter), pathFor),
|
|
"HelpCenter": data,
|
|
"Query": query,
|
|
"Results": articles,
|
|
"Tree": sidebarTree(app, helpCenter, locale),
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleHelpCenterSitemap serves a sitemap of a help center's published pages in one locale.
|
|
func handleHelpCenterSitemap(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
slug = r.RequestCtx.UserValue("slug").(string)
|
|
)
|
|
helpCenter, err := app.helpcenter.GetHelpCenterBySlug(slug)
|
|
if err != nil {
|
|
return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.T("globals.messages.notFound"), nil, envelope.NotFoundError)
|
|
}
|
|
if redirectHelpCenterCanonicalHost(r, helpCenter) {
|
|
return nil
|
|
}
|
|
locale, ok := resolveLocale(r, helpCenter)
|
|
if !ok {
|
|
return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.T("globals.messages.notFound"), nil, envelope.NotFoundError)
|
|
}
|
|
articles, err := app.helpcenter.GetPopularArticles(slug, locale, sitemapURLLimit)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
if len(articles) == sitemapURLLimit {
|
|
app.lo.Warn("help center sitemap hit the URL limit, some articles are missing", "help_center_slug", slug, "locale", locale, "limit", sitemapURLLimit)
|
|
}
|
|
tree, err := app.helpcenter.GetPublicTree(helpCenter, locale)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
|
|
root := helpCenterBaseURL(app, helpCenter)
|
|
set := urlset{Xmlns: sitemapNamespace}
|
|
set.URLs = append(set.URLs, sitemapURL{
|
|
Loc: root + helpCenterHomePath(helpCenter, locale),
|
|
LastMod: helpCenter.UpdatedAt.Format(sitemapDate),
|
|
})
|
|
for _, c := range flattenCollections(tree.Tree, nil) {
|
|
set.URLs = append(set.URLs, sitemapURL{
|
|
Loc: root + collectionPath(helpCenter, locale, c.Slug),
|
|
LastMod: c.UpdatedAt.Format(sitemapDate),
|
|
})
|
|
}
|
|
for _, a := range articles {
|
|
set.URLs = append(set.URLs, sitemapURL{
|
|
Loc: root + articlePath(helpCenter, locale, a.Slug),
|
|
LastMod: a.UpdatedAt.Format(sitemapDate),
|
|
})
|
|
}
|
|
if len(set.URLs) > sitemapURLLimit {
|
|
app.lo.Warn("help center sitemap truncated to the URL limit", "help_center_slug", slug, "locale", locale, "limit", sitemapURLLimit)
|
|
set.URLs = set.URLs[:sitemapURLLimit]
|
|
}
|
|
return sendXML(r, set)
|
|
}
|
|
|
|
// handleSitemapIndex lists the per-locale sitemaps of the help centers served on this host.
|
|
func handleSitemapIndex(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
helpCenters, err := app.helpcenter.GetActiveHelpCenters()
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
index := sitemapIndex{Xmlns: sitemapNamespace}
|
|
for _, hc := range helpCentersForHost(r, helpCenters) {
|
|
root := helpCenterBaseURL(app, hc)
|
|
for _, locale := range helpCenterLocales(hc) {
|
|
index.Sitemaps = append(index.Sitemaps, sitemapRef{Loc: fmt.Sprintf("%s%s/sitemap.xml", root, helpCenterHomePath(hc, locale))})
|
|
}
|
|
}
|
|
return sendXML(r, index)
|
|
}
|
|
|
|
// handleRobotsTxt keeps crawlers out of the agent app and points them at this host's sitemap index.
|
|
func handleRobotsTxt(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
r.RequestCtx.SetContentType("text/plain; charset=utf-8")
|
|
fmt.Fprint(r.RequestCtx, "User-agent: *\n")
|
|
for _, path := range crawlerDisallowedPaths {
|
|
fmt.Fprintf(r.RequestCtx, "Disallow: %s\n", path)
|
|
}
|
|
helpCenters, err := app.helpcenter.GetActiveHelpCenters()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
// Crawlers ignore a Sitemap directive on another host, and listing one publishes the other hosts.
|
|
hosted := helpCentersForHost(r, helpCenters)
|
|
if len(hosted) > 0 && hosted[0].CustomDomain != "" {
|
|
fmt.Fprint(r.RequestCtx, "Disallow: /*/search\n")
|
|
}
|
|
if len(hosted) > 0 {
|
|
fmt.Fprintf(r.RequestCtx, "\nSitemap: %s/sitemap.xml\n", helpCenterHostRootURL(app, hosted))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleGetPublicHelpCenterTree returns the published-only tree as JSON.
|
|
func handleGetPublicHelpCenterTree(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
slug = r.RequestCtx.UserValue("slug").(string)
|
|
)
|
|
helpCenter, err := app.helpcenter.GetHelpCenterBySlug(slug)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
locale := resolveQueryLocale(r, helpCenter)
|
|
tree, err := app.helpcenter.GetPublicTree(helpCenter, locale)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
hideTreeAuthors(helpCenterTheme(helpCenter), tree.Tree)
|
|
return r.SendEnvelope(tree)
|
|
}
|
|
|
|
// handleGetPublicHelpCenterArticle returns a published article as JSON.
|
|
func handleGetPublicHelpCenterArticle(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
slug = r.RequestCtx.UserValue("slug").(string)
|
|
articleSlug = r.RequestCtx.UserValue("article_slug").(string)
|
|
)
|
|
helpCenter, err := app.helpcenter.GetHelpCenterBySlug(slug)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
locale := resolveQueryLocale(r, helpCenter)
|
|
article, err := app.helpcenter.GetPublishedArticle(slug, articleSlug, locale)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
hideArticleAuthor(helpCenterTheme(helpCenter), &article)
|
|
if !isCrawler(r) {
|
|
app.helpcenter.IncrementArticleViewCount(article.ID)
|
|
}
|
|
return r.SendEnvelope(article)
|
|
}
|
|
|
|
// handlePublicHelpCenterSearch returns published article search results as JSON.
|
|
func handlePublicHelpCenterSearch(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
slug = r.RequestCtx.UserValue("slug").(string)
|
|
query = strings.TrimSpace(string(r.RequestCtx.QueryArgs().Peek("q")))
|
|
)
|
|
helpCenter, err := app.helpcenter.GetHelpCenterBySlug(slug)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
if query == "" {
|
|
return r.SendEnvelope([]hcmodels.Article{})
|
|
}
|
|
locale := resolveQueryLocale(r, helpCenter)
|
|
articles, err := app.helpcenter.SearchPublishedArticles(slug, query, locale, publicSearchLimit)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
// The typeahead passes log=0 per keystroke and logs the settled term with a later log=1 call.
|
|
if string(r.RequestCtx.QueryArgs().Peek("log")) != "0" {
|
|
app.helpcenter.LogSearch(helpCenter.ID, query, len(articles))
|
|
}
|
|
return r.SendEnvelope(articles)
|
|
}
|
|
|
|
// handleHelpCenterArticleFeedback records a reader's helpful/not-helpful vote for a published article.
|
|
func handleHelpCenterArticleFeedback(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
slug = r.RequestCtx.UserValue("slug").(string)
|
|
articleSlug = r.RequestCtx.UserValue("article_slug").(string)
|
|
req = struct {
|
|
Helpful bool `json:"helpful"`
|
|
}{}
|
|
)
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
helpCenter, err := app.helpcenter.GetHelpCenterBySlug(slug)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
article, err := app.helpcenter.GetPublishedArticle(slug, articleSlug, resolveQueryLocale(r, helpCenter))
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
dedupKey := fmt.Sprintf("help_center:feedback:%d:%s", article.ID, realip.FromRequest(r.RequestCtx))
|
|
if ok, err := app.redis.SetNX(r.RequestCtx, dedupKey, "1", articleFeedbackDedupTTL).Result(); err == nil && !ok {
|
|
return r.SendEnvelope(true)
|
|
}
|
|
if err := app.helpcenter.RecordArticleFeedback(article.ID, req.Helpful); err != nil {
|
|
// A failed insert must not consume the reader's vote for the dedup window.
|
|
app.redis.Del(r.RequestCtx, dedupKey)
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(true)
|
|
}
|
|
|
|
// handleGetHelpCenterInsights returns search analytics for a help center.
|
|
func handleGetHelpCenterInsights(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
insights, err := app.helpcenter.GetInsights(id, insightsTermLimit)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
return r.SendEnvelope(insights)
|
|
}
|
|
|
|
// handleUpdateArticle updates an article, keeping its existing slug.
|
|
func handleUpdateArticle(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req = helpcenter.ArticleRequest{}
|
|
id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string))
|
|
)
|
|
if id <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`id`"), nil, envelope.InputError)
|
|
}
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
|
|
}
|
|
if err := validateArticle(app, &req); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
existing, err := app.helpcenter.GetArticleByID(id)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
req.Slug = existing.Slug
|
|
if req.CollectionID != nil && *req.CollectionID != existing.CollectionID {
|
|
from, err := app.helpcenter.GetCollectionByID(existing.CollectionID)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
to, err := app.helpcenter.GetCollectionByID(*req.CollectionID)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
if from.HelpCenterID != to.HelpCenterID {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("helpCenter.invalidParent"), nil, envelope.InputError)
|
|
}
|
|
}
|
|
if req.Status == "" {
|
|
req.Status = hcmodels.ArticleStatusDraft
|
|
}
|
|
article, err := app.helpcenter.UpdateArticle(id, req)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
if err := app.media.LinkHelpArticleMedia(article.ID, article.Content); err != nil {
|
|
app.lo.Error("error linking help article media", "article_id", article.ID, "error", err)
|
|
}
|
|
return r.SendEnvelope(article)
|
|
}
|
|
|
|
// renderHelpCenterPage renders a public help center page as cacheable, overriding the
|
|
// no-store default that RenderWebPage applies for the app's authenticated pages.
|
|
func renderHelpCenterPage(r *fastglue.Request, name string, data map[string]interface{}) error {
|
|
app := r.Context.(*App)
|
|
if err := app.tmpl.RenderWebPage(r.RequestCtx, name, data); err != nil {
|
|
return err
|
|
}
|
|
r.RequestCtx.Response.Header.Set("Cache-Control", helpCenterCacheControl)
|
|
r.RequestCtx.Response.Header.Del("Pragma")
|
|
r.RequestCtx.Response.Header.Del("Expires")
|
|
return nil
|
|
}
|
|
|
|
// sendXML writes v as an XML document.
|
|
func sendXML(r *fastglue.Request, v any) error {
|
|
out, err := xml.Marshal(v)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
r.RequestCtx.SetContentType("application/xml; charset=utf-8")
|
|
r.RequestCtx.Response.Header.Set("Cache-Control", helpCenterXMLCacheControl)
|
|
fmt.Fprint(r.RequestCtx, xml.Header)
|
|
r.RequestCtx.Write(out)
|
|
return nil
|
|
}
|
|
|
|
// countArticleView records a view after the page is served, counting cache hits too.
|
|
func countArticleView(h fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
|
return func(r *fastglue.Request) error {
|
|
err := h(r)
|
|
switch r.RequestCtx.Response.StatusCode() {
|
|
case fasthttp.StatusOK, fasthttp.StatusNotModified:
|
|
if !isCrawler(r) {
|
|
app := r.Context.(*App)
|
|
slug, _ := r.RequestCtx.UserValue("slug").(string)
|
|
articleSlug, _ := r.RequestCtx.UserValue("article_slug").(string)
|
|
locale, _ := r.RequestCtx.UserValue("locale").(string)
|
|
app.helpcenter.IncrementPublishedArticleView(slug, strings.TrimSuffix(articleSlug, markdownSlugExtension), locale)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
func cachedHCPage(h fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
|
return cacheHCPage(h, false)
|
|
}
|
|
|
|
func cachedHCNoIndexPage(h fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
|
return cacheHCPage(h, true)
|
|
}
|
|
|
|
// cacheHCPage owns every response header: the cache restores only body and content type.
|
|
func cacheHCPage(h fastglue.FastRequestHandler, noIndex bool) fastglue.FastRequestHandler {
|
|
return func(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
r.RequestCtx.SetUserValue(helpCenterCacheNamespaceKey, helpCenterCacheNamespace)
|
|
miss := false
|
|
rendered := func(r *fastglue.Request) error {
|
|
miss = true
|
|
return h(r)
|
|
}
|
|
err := app.fc.Cached(rendered, helpCenterCacheOpts, helpCenterCacheGroup)(r)
|
|
switch r.RequestCtx.Response.StatusCode() {
|
|
case fasthttp.StatusOK, fasthttp.StatusNotModified:
|
|
r.RequestCtx.Response.Header.Set("Cache-Control", helpCenterCacheControl)
|
|
r.RequestCtx.Response.Header.Del("Pragma")
|
|
r.RequestCtx.Response.Header.Del("Expires")
|
|
status := "HIT"
|
|
if miss {
|
|
status = "MISS"
|
|
}
|
|
r.RequestCtx.Response.Header.Set("X-Cache", status)
|
|
r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff")
|
|
if !isEmbedRequest(r) {
|
|
r.RequestCtx.Response.Header.Set("X-Frame-Options", "SAMEORIGIN")
|
|
}
|
|
r.RequestCtx.Response.Header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
if noIndex || isEmbedRequest(r) || isMarkdownRequest(r) {
|
|
r.RequestCtx.Response.Header.Set("X-Robots-Tag", noIndexHeader)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
// isEmbedRequest reports whether the page is framed by the chat widget.
|
|
func isEmbedRequest(r *fastglue.Request) bool {
|
|
return r.RequestCtx.QueryArgs().GetBool("embed")
|
|
}
|
|
|
|
func isMarkdownRequest(r *fastglue.Request) bool {
|
|
slug, _ := r.RequestCtx.UserValue("article_slug").(string)
|
|
return strings.HasSuffix(slug, markdownSlugExtension)
|
|
}
|
|
|
|
func clearsHCCache(h fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
|
return func(r *fastglue.Request) error {
|
|
err := h(r)
|
|
if r.RequestCtx.Response.StatusCode() < fasthttp.StatusMultipleChoices {
|
|
app := r.Context.(*App)
|
|
if err := app.fc.DelGroup(helpCenterCacheNamespace, helpCenterCacheGroup); err != nil {
|
|
app.lo.Error("error clearing help center cache", "error", err)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
// isCrawler reports whether the request came from a bot rather than a reader; HEAD probes never count as readers.
|
|
func isCrawler(r *fastglue.Request) bool {
|
|
return r.RequestCtx.IsHead() || crawlerUARe.Match(r.RequestCtx.Request.Header.UserAgent())
|
|
}
|
|
|
|
// helpCenterRootURL returns the app root URL without its trailing slash. Canonical URLs,
|
|
// sitemaps and robots.txt all read it from here so they can never disagree.
|
|
func helpCenterRootURL(app *App) string {
|
|
return strings.TrimRight(app.consts.Load().(*constants).AppBaseURL, "/")
|
|
}
|
|
|
|
// helpCenterBaseURL returns the origin a reader sees this help center on: its custom domain when set, else the app root URL.
|
|
func helpCenterBaseURL(app *App, hc hcmodels.HelpCenter) string {
|
|
if origin := helpCenterCustomOrigin(hc); origin != "" {
|
|
return origin
|
|
}
|
|
return helpCenterRootURL(app)
|
|
}
|
|
|
|
// helpCenterCustomOrigin returns the origin of hc's custom domain, empty when unset or unparseable.
|
|
func helpCenterCustomOrigin(hc hcmodels.HelpCenter) string {
|
|
if hc.CustomDomain == "" {
|
|
return ""
|
|
}
|
|
return urlOrigin(hc.CustomDomain)
|
|
}
|
|
|
|
// isRootHost reports whether host is the app root URL's hostname.
|
|
func isRootHost(app *App, host string) bool {
|
|
return strings.EqualFold(urlHostname(helpCenterRootURL(app)), host)
|
|
}
|
|
|
|
// helpCenterByHost returns the active help center whose custom domain hostname is host.
|
|
func helpCenterByHost(app *App, host string) (hcmodels.HelpCenter, bool) {
|
|
if isRootHost(app, host) {
|
|
return hcmodels.HelpCenter{}, false
|
|
}
|
|
helpCenters, err := app.helpcenter.GetActiveHelpCenters()
|
|
if err != nil {
|
|
return hcmodels.HelpCenter{}, false
|
|
}
|
|
for _, hc := range helpCenters {
|
|
if helpCenterCustomOrigin(hc) != "" && strings.EqualFold(urlHostname(hc.CustomDomain), host) {
|
|
return hc, true
|
|
}
|
|
}
|
|
return hcmodels.HelpCenter{}, false
|
|
}
|
|
|
|
// helpCenterHostNotFound serves unmatched paths on a help center's custom domain by prepending /hc/{slug} and re-running the router.
|
|
func helpCenterHostNotFound(app *App, g *fastglue.Fastglue) fasthttp.RequestHandler {
|
|
return func(ctx *fasthttp.RequestCtx) {
|
|
if (ctx.IsGet() || ctx.IsHead()) && ctx.UserValue(hcHostRewriteKey) == nil {
|
|
host := hostWithoutPort(string(ctx.Host()))
|
|
// The custom-domain lookup below hits the DB; throttle it like every registered public route.
|
|
if !isRootHost(app, host) {
|
|
if app.rateLimit.Check(ctx, "public") != nil {
|
|
return
|
|
}
|
|
ctx.SetUserValue(rateLimitPaidKey, "public")
|
|
}
|
|
if hc, ok := helpCenterByHost(app, host); ok {
|
|
// Strip the trailing slash here: the router's own trailing-slash redirect would otherwise expose the rewritten /hc/{slug} path in its Location.
|
|
if path := string(ctx.Path()); len(path) > 1 && strings.HasSuffix(path, "/") {
|
|
uri := strings.TrimRight(path, "/")
|
|
if uri == "" {
|
|
uri = "/"
|
|
}
|
|
if qs := ctx.URI().QueryString(); len(qs) > 0 {
|
|
uri += "?" + string(qs)
|
|
}
|
|
redirectPath(ctx, uri, fasthttp.StatusMovedPermanently)
|
|
return
|
|
}
|
|
ctx.SetUserValue(hcHostRewriteKey, true)
|
|
ctx.Request.SetRequestURI("/hc/" + hc.Slug + string(ctx.RequestURI()))
|
|
g.Router.Handler(ctx)
|
|
return
|
|
}
|
|
}
|
|
fastglue.NotFoundHandler(ctx)
|
|
}
|
|
}
|
|
|
|
// helpCenterHostHome redirects the custom-domain root to the help center's default-locale home.
|
|
func helpCenterHostHome(h fastglue.FastRequestHandler) fastglue.FastRequestHandler {
|
|
return func(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
host := hostWithoutPort(string(r.RequestCtx.Host()))
|
|
if !isRootHost(app, host) {
|
|
// The custom-domain lookup below hits the DB; throttle it like every registered public route.
|
|
if app.rateLimit.Check(r.RequestCtx, "public") != nil {
|
|
return nil
|
|
}
|
|
if hc, ok := helpCenterByHost(app, host); ok {
|
|
uri := helpCenterHomePath(hc, hc.DefaultLocale)
|
|
if qs := r.RequestCtx.URI().QueryString(); len(qs) > 0 {
|
|
uri += "?" + string(qs)
|
|
}
|
|
redirectPath(r.RequestCtx, uri, fasthttp.StatusFound)
|
|
return nil
|
|
}
|
|
}
|
|
return h(r)
|
|
}
|
|
}
|
|
|
|
// redirectHelpCenterCanonicalHost 301s a /hc/{slug} path to its custom-domain equivalent, unless the request arrived via the host rewrite.
|
|
func redirectHelpCenterCanonicalHost(r *fastglue.Request, hc hcmodels.HelpCenter) bool {
|
|
origin := helpCenterCustomOrigin(hc)
|
|
if origin == "" || r.RequestCtx.UserValue(hcHostRewriteKey) != nil {
|
|
return false
|
|
}
|
|
uri := origin + strings.TrimPrefix(string(r.RequestCtx.Path()), "/hc/"+hc.Slug)
|
|
if qs := r.RequestCtx.URI().QueryString(); len(qs) > 0 {
|
|
uri += "?" + string(qs)
|
|
}
|
|
r.RequestCtx.Redirect(uri, fasthttp.StatusMovedPermanently)
|
|
return true
|
|
}
|
|
|
|
// helpCentersForHost returns the help centers whose custom domain is this request's host, else the app-root ones.
|
|
func helpCentersForHost(r *fastglue.Request, helpCenters []hcmodels.HelpCenter) []hcmodels.HelpCenter {
|
|
var (
|
|
host = hostWithoutPort(string(r.RequestCtx.Host()))
|
|
matched []hcmodels.HelpCenter
|
|
rootHosted []hcmodels.HelpCenter
|
|
)
|
|
for _, hc := range helpCenters {
|
|
if hc.CustomDomain == "" {
|
|
rootHosted = append(rootHosted, hc)
|
|
continue
|
|
}
|
|
if strings.EqualFold(urlHostname(hc.CustomDomain), host) {
|
|
matched = append(matched, hc)
|
|
}
|
|
}
|
|
if len(matched) > 0 {
|
|
return matched
|
|
}
|
|
return rootHosted
|
|
}
|
|
|
|
// helpCenterHostRootURL returns the origin these help centers are served on.
|
|
func helpCenterHostRootURL(app *App, helpCenters []hcmodels.HelpCenter) string {
|
|
for _, hc := range helpCenters {
|
|
if hc.CustomDomain != "" {
|
|
return urlOrigin(hc.CustomDomain)
|
|
}
|
|
}
|
|
return helpCenterRootURL(app)
|
|
}
|
|
|
|
// urlHostname returns the URL's host without any port, so a public URL saved with an
|
|
// explicit port still matches the Host header and vice versa.
|
|
func urlHostname(raw string) string {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return u.Hostname()
|
|
}
|
|
|
|
func hostWithoutPort(h string) string {
|
|
if host, _, err := net.SplitHostPort(h); err == nil {
|
|
return host
|
|
}
|
|
return h
|
|
}
|
|
|
|
func urlOrigin(raw string) string {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Host == "" {
|
|
return ""
|
|
}
|
|
return u.Scheme + "://" + u.Host
|
|
}
|
|
|
|
// publicAssetPaths rewrites stored absolute media URLs to root-relative ones so uploads resolve
|
|
// on whichever host serves the page.
|
|
func publicAssetPaths(app *App, s string) string {
|
|
root := helpCenterRootURL(app)
|
|
if root == "" {
|
|
return s
|
|
}
|
|
return strings.ReplaceAll(s, root+media.PublicURI, media.PublicURI)
|
|
}
|
|
|
|
// absoluteURL resolves a root-relative URL against the app root URL, since social and
|
|
// structured-data consumers reject relative image URLs.
|
|
func absoluteURL(root, u string) string {
|
|
if u == "" || strings.Contains(u, "://") || strings.HasPrefix(u, "//") {
|
|
return u
|
|
}
|
|
return root + "/" + strings.TrimLeft(u, "/")
|
|
}
|
|
|
|
// helpCenterPathPrefix returns the path prefix hc's pages live under: none on a custom domain, /hc/{slug} on the app host.
|
|
func helpCenterPathPrefix(hc hcmodels.HelpCenter) string {
|
|
if helpCenterCustomOrigin(hc) != "" {
|
|
return ""
|
|
}
|
|
return "/hc/" + hc.Slug
|
|
}
|
|
|
|
func helpCenterHomePath(hc hcmodels.HelpCenter, locale string) string {
|
|
if locale == "" {
|
|
locale = hc.DefaultLocale
|
|
}
|
|
return fmt.Sprintf("%s/%s", helpCenterPathPrefix(hc), locale)
|
|
}
|
|
|
|
func collectionPath(hc hcmodels.HelpCenter, locale, collectionSlug string) string {
|
|
return fmt.Sprintf("%s/%s/collections/%s", helpCenterPathPrefix(hc), locale, collectionSlug)
|
|
}
|
|
|
|
func articlePath(hc hcmodels.HelpCenter, locale, articleSlug string) string {
|
|
return fmt.Sprintf("%s/%s/articles/%s", helpCenterPathPrefix(hc), locale, articleSlug)
|
|
}
|
|
|
|
func searchPath(hc hcmodels.HelpCenter, locale string) string {
|
|
return fmt.Sprintf("%s/%s/search", helpCenterPathPrefix(hc), locale)
|
|
}
|
|
|
|
// helpCenterAlternates returns the hreflang set for a page: only the locales the page
|
|
// actually exists in, so no alternate points at a 404. Empty for single-locale help centers.
|
|
func helpCenterAlternates(hc hcmodels.HelpCenter, translated []string, pathFor func(string) string) []localeLink {
|
|
locales := helpCenterLocales(hc)
|
|
if len(locales) < 2 {
|
|
return nil
|
|
}
|
|
links := make([]localeLink, 0, len(locales))
|
|
for _, loc := range locales {
|
|
if slices.Contains(translated, loc) {
|
|
links = append(links, localeLink{Locale: loc, Path: pathFor(loc)})
|
|
}
|
|
}
|
|
return links
|
|
}
|
|
|
|
// helpCenterLocaleLinks returns where the language switcher sends the reader for each locale:
|
|
// the same page when it exists there, the locale home otherwise.
|
|
func helpCenterLocaleLinks(hc hcmodels.HelpCenter, translated []string, pathFor func(string) string) []localeLink {
|
|
locales := helpCenterLocales(hc)
|
|
links := make([]localeLink, 0, len(locales))
|
|
for _, loc := range locales {
|
|
path := helpCenterHomePath(hc, loc)
|
|
if slices.Contains(translated, loc) {
|
|
path = pathFor(loc)
|
|
}
|
|
links = append(links, localeLink{Locale: loc, Path: path})
|
|
}
|
|
return links
|
|
}
|
|
|
|
// defaultLocalePath returns the x-default hreflang target, empty when the page has no
|
|
// default-locale version.
|
|
func defaultLocalePath(hc hcmodels.HelpCenter, translated []string, pathFor func(string) string) string {
|
|
if len(helpCenterLocales(hc)) < 2 || !slices.Contains(translated, hc.DefaultLocale) {
|
|
return ""
|
|
}
|
|
return pathFor(hc.DefaultLocale)
|
|
}
|
|
|
|
// homeJSONLD returns the WebSite structured data for a help center home page, including the
|
|
// sitelinks search box target.
|
|
func homeJSONLD(root string, hc hcmodels.HelpCenter, locale string) template.JS {
|
|
home := root + helpCenterHomePath(hc, locale)
|
|
site := map[string]any{
|
|
"@context": schemaOrgContext,
|
|
"@type": "WebSite",
|
|
"name": hc.Name,
|
|
"url": home,
|
|
"inLanguage": locale,
|
|
"potentialAction": map[string]any{
|
|
"@type": "SearchAction",
|
|
"target": map[string]any{
|
|
"@type": "EntryPoint",
|
|
"urlTemplate": root + searchPath(hc, locale) + "?q={search_term_string}",
|
|
},
|
|
"query-input": "required name=search_term_string",
|
|
},
|
|
}
|
|
if d := firstNonEmpty(hc.MetaDescription, helpCenterTheme(hc).Header.Heading); d != "" {
|
|
site["description"] = d
|
|
}
|
|
return jsonLD([]any{site})
|
|
}
|
|
|
|
// collectionJSONLD returns the CollectionPage and breadcrumb structured data for a collection page.
|
|
func collectionJSONLD(root string, hc hcmodels.HelpCenter, collection hcmodels.TreeCollection, locale, canonicalPath string) template.JS {
|
|
page := map[string]any{
|
|
"@context": schemaOrgContext,
|
|
"@type": "CollectionPage",
|
|
"name": collection.Name,
|
|
"url": root + canonicalPath,
|
|
"inLanguage": locale,
|
|
"isPartOf": map[string]any{"@type": "WebSite", "name": hc.Name, "url": root + helpCenterHomePath(hc, locale)},
|
|
}
|
|
if collection.Description != "" {
|
|
page["description"] = collection.Description
|
|
}
|
|
crumbs := breadcrumbJSONLD(root, hc, locale, []localeLink{{Locale: collection.Name, Path: canonicalPath}})
|
|
return jsonLD([]any{page, crumbs})
|
|
}
|
|
|
|
// articleJSONLD returns the Article and breadcrumb structured data for an article page.
|
|
func articleJSONLD(root string, hc hcmodels.HelpCenter, collection hcmodels.Collection, article hcmodels.Article, locale, canonicalPath, image string) template.JS {
|
|
art := map[string]any{
|
|
"@context": schemaOrgContext,
|
|
"@type": "Article",
|
|
"headline": article.Title,
|
|
"url": root + canonicalPath,
|
|
"inLanguage": locale,
|
|
"datePublished": article.CreatedAt.Format(time.RFC3339),
|
|
"dateModified": article.UpdatedAt.Format(time.RFC3339),
|
|
"mainEntityOfPage": map[string]any{"@type": "WebPage", "@id": root + canonicalPath},
|
|
"publisher": map[string]any{"@type": "Organization", "name": hc.Name},
|
|
}
|
|
if d := firstNonEmpty(article.MetaDescription, article.Excerpt); d != "" {
|
|
art["description"] = d
|
|
}
|
|
if image != "" {
|
|
art["image"] = image
|
|
}
|
|
if article.AuthorName != nil && strings.TrimSpace(*article.AuthorName) != "" {
|
|
art["author"] = map[string]any{"@type": "Person", "name": strings.TrimSpace(*article.AuthorName)}
|
|
} else {
|
|
art["author"] = map[string]any{"@type": "Organization", "name": hc.Name}
|
|
}
|
|
|
|
trail := []localeLink{}
|
|
if collection.Name != "" {
|
|
trail = append(trail, localeLink{Locale: collection.Name, Path: collectionPath(hc, locale, collection.Slug)})
|
|
}
|
|
trail = append(trail, localeLink{Locale: article.Title, Path: canonicalPath})
|
|
return jsonLD([]any{art, breadcrumbJSONLD(root, hc, locale, trail)})
|
|
}
|
|
|
|
// breadcrumbJSONLD builds a BreadcrumbList rooted at the help center home, where each trail
|
|
// entry carries its label in Locale and its path in Path.
|
|
func breadcrumbJSONLD(root string, hc hcmodels.HelpCenter, locale string, trail []localeLink) map[string]any {
|
|
items := []any{map[string]any{
|
|
"@type": "ListItem",
|
|
"position": 1,
|
|
"name": hc.Name,
|
|
"item": root + helpCenterHomePath(hc, locale),
|
|
}}
|
|
for i, t := range trail {
|
|
items = append(items, map[string]any{
|
|
"@type": "ListItem",
|
|
"position": i + 2,
|
|
"name": t.Locale,
|
|
"item": root + t.Path,
|
|
})
|
|
}
|
|
return map[string]any{
|
|
"@context": schemaOrgContext,
|
|
"@type": "BreadcrumbList",
|
|
"itemListElement": items,
|
|
}
|
|
}
|
|
|
|
// jsonLD marshals structured data for a ld+json script tag. encoding/json escapes the HTML
|
|
// delimiters, so a title can't close the tag.
|
|
func jsonLD(v any) template.JS {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return template.JS("")
|
|
}
|
|
return template.JS(b)
|
|
}
|
|
|
|
// authorInitial returns the first letter of the article author's name for the avatar fallback.
|
|
func authorInitial(article hcmodels.Article) string {
|
|
if article.AuthorName == nil {
|
|
return ""
|
|
}
|
|
runes := []rune(strings.TrimSpace(*article.AuthorName))
|
|
if len(runes) == 0 {
|
|
return ""
|
|
}
|
|
return string(runes[0])
|
|
}
|
|
|
|
// firstNonEmpty returns the first value that isn't blank.
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
if strings.TrimSpace(v) != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// flattenCollections returns every collection in the tree, depth first.
|
|
func flattenCollections(cols []hcmodels.TreeCollection, out []hcmodels.TreeCollection) []hcmodels.TreeCollection {
|
|
for _, c := range cols {
|
|
out = append(out, c)
|
|
out = flattenCollections(c.Children, out)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// findCollectionNode returns the collection with the given slug from the tree, searching descendants.
|
|
func findCollectionNode(cols []hcmodels.TreeCollection, slug string) *hcmodels.TreeCollection {
|
|
for i := range cols {
|
|
if cols[i].Slug == slug {
|
|
return &cols[i]
|
|
}
|
|
if found := findCollectionNode(cols[i].Children, slug); found != nil {
|
|
return found
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveLocale returns the locale path segment and whether the help center serves it,
|
|
// falling back to the help center's default when the segment is absent.
|
|
func resolveLocale(r *fastglue.Request, hc hcmodels.HelpCenter) (string, bool) {
|
|
v, _ := r.RequestCtx.UserValue("locale").(string)
|
|
loc := strings.TrimSpace(v)
|
|
if loc == "" || loc == hc.DefaultLocale {
|
|
return hc.DefaultLocale, true
|
|
}
|
|
return loc, slices.Contains(helpCenterLocales(hc), loc)
|
|
}
|
|
|
|
func resolveQueryLocale(r *fastglue.Request, hc hcmodels.HelpCenter) string {
|
|
locales := helpCenterLocales(hc)
|
|
for _, key := range []string{"locale", "fallback_locale"} {
|
|
requested := string(r.RequestCtx.QueryArgs().Peek(key))
|
|
if matched := matchHelpCenterLocale(requested, locales); matched != "" {
|
|
return matched
|
|
}
|
|
}
|
|
return hc.DefaultLocale
|
|
}
|
|
|
|
func matchHelpCenterLocale(requested string, locales []string) string {
|
|
wanted, err := language.All.Parse(strings.TrimSpace(requested))
|
|
if err != nil || wanted.IsRoot() {
|
|
return ""
|
|
}
|
|
base, _ := wanted.Base()
|
|
script, _ := wanted.Script()
|
|
var baseMatch, regionMatch string
|
|
for _, available := range locales {
|
|
candidate, err := language.All.Parse(available)
|
|
if err != nil || candidate.IsRoot() {
|
|
continue
|
|
}
|
|
if candidate.String() == wanted.String() {
|
|
return available
|
|
}
|
|
candidateBase, _ := candidate.Base()
|
|
candidateScript, _ := candidate.Script()
|
|
if candidateBase != base || candidateScript != script {
|
|
continue
|
|
}
|
|
if candidate.String() == base.String() || candidate.String() == base.String()+"-"+script.String() {
|
|
baseMatch = cmp.Or(baseMatch, available)
|
|
} else {
|
|
regionMatch = cmp.Or(regionMatch, available)
|
|
}
|
|
}
|
|
return cmp.Or(baseMatch, regionMatch)
|
|
}
|
|
|
|
// helpCenterLocales returns the help center's configured locale codes.
|
|
func helpCenterLocales(hc hcmodels.HelpCenter) []string {
|
|
locales := []string{}
|
|
if len(hc.AllowedLocales) > 0 {
|
|
if err := json.Unmarshal(hc.AllowedLocales, &locales); err != nil {
|
|
return nil
|
|
}
|
|
}
|
|
return locales
|
|
}
|
|
|
|
func helpCenterTheme(hc hcmodels.HelpCenter) hcmodels.Theme {
|
|
theme := hcmodels.DefaultTheme()
|
|
if len(hc.Theme) > 0 {
|
|
if err := json.Unmarshal(hc.Theme, &theme); err != nil {
|
|
return hcmodels.DefaultTheme()
|
|
}
|
|
}
|
|
return theme
|
|
}
|
|
|
|
func hideArticleAuthor(theme hcmodels.Theme, article *hcmodels.Article) {
|
|
if theme.Article.ShowAuthor {
|
|
return
|
|
}
|
|
article.AuthorName = nil
|
|
article.AuthorAvatar = nil
|
|
}
|
|
|
|
func hideTreeAuthors(theme hcmodels.Theme, cols []hcmodels.TreeCollection) {
|
|
for i := range cols {
|
|
if !theme.Cards.ShowAuthors {
|
|
cols[i].Authors = []hcmodels.ArticleAuthor{}
|
|
cols[i].AuthorCount = 0
|
|
}
|
|
for j := range cols[i].Articles {
|
|
hideArticleAuthor(theme, &cols[i].Articles[j])
|
|
}
|
|
hideTreeAuthors(theme, cols[i].Children)
|
|
}
|
|
}
|
|
|
|
// hcPageName returns the page's template name under the help center's chosen page template.
|
|
func hcPageName(hc hcmodels.HelpCenter, page string) string {
|
|
return firstNonEmpty(hc.Template, hcmodels.TemplateClassic) + "-" + page
|
|
}
|
|
|
|
// sidebarTree returns the published tree for page templates that render a navigation sidebar on every page.
|
|
func sidebarTree(app *App, hc hcmodels.HelpCenter, locale string) []hcmodels.TreeCollection {
|
|
if hc.Template != hcmodels.TemplateDocs {
|
|
return nil
|
|
}
|
|
tree, err := app.helpcenter.GetPublicTree(hc, locale)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return tree.Tree
|
|
}
|
|
|
|
// helpCenterTemplateData shapes a help center row for the public templates.
|
|
func helpCenterTemplateData(app *App, r *fastglue.Request, hc hcmodels.HelpCenter, locale string) map[string]interface{} {
|
|
theme := helpCenterTheme(hc)
|
|
theme.Favicon = publicAssetPaths(app, theme.Favicon)
|
|
theme.Header.BackgroundImage = publicAssetPaths(app, theme.Header.BackgroundImage)
|
|
pageTemplate := hc.Template
|
|
if pageTemplate == "" {
|
|
pageTemplate = hcmodels.TemplateClassic
|
|
}
|
|
embed := isEmbedRequest(r)
|
|
scheme := resolveColorScheme(theme.ColorScheme, string(r.RequestCtx.QueryArgs().Peek("theme")), embed)
|
|
return map[string]interface{}{
|
|
"Slug": hc.Slug,
|
|
"Name": hc.Name,
|
|
"Template": pageTemplate,
|
|
"BaseURL": helpCenterBaseURL(app, hc),
|
|
"BasePath": helpCenterHomePath(hc, locale),
|
|
"PageTitle": hc.PageTitle,
|
|
"HeaderText": theme.Header.Heading,
|
|
"LogoURL": publicAssetPaths(app, theme.LogoURL),
|
|
"LogoURLDark": publicAssetPaths(app, cmp.Or(theme.LogoURLDark, theme.LogoURL)),
|
|
"Color": theme.Color,
|
|
"ColorDark": cmp.Or(theme.ColorDark, theme.Color),
|
|
"ShowThemeToggle": scheme.showToggle,
|
|
"FollowSystemTheme": scheme.followSystem,
|
|
"DefaultLocale": hc.DefaultLocale,
|
|
"CurrentLocale": locale,
|
|
"OGLocale": strings.ReplaceAll(locale, "-", "_"),
|
|
"Dir": localeDir(locale),
|
|
"AvailableLocales": helpCenterLocales(hc),
|
|
"NavLinks": theme.NavLinks,
|
|
"Theme": theme,
|
|
"ThemeCSS": buildThemeCSSVars(theme),
|
|
"ThemeCSSDark": buildDarkThemeCSSVars(theme),
|
|
"AnnouncementKey": announcementKey(hc.Slug, theme.Announcement),
|
|
"TaglineHTML": template.HTML(helpcenter.RenderInlineMarkdown(theme.Tagline)),
|
|
"FooterTaglineHTML": template.HTML(helpcenter.RenderInlineMarkdown(theme.Footer.Tagline)),
|
|
"AnnouncementHTML": template.HTML(helpcenter.RenderInlineMarkdown(theme.Announcement.Text)),
|
|
"CustomCSS": template.CSS(hc.CustomCSS),
|
|
"CustomJS": template.JS(hc.CustomJS),
|
|
"WidgetInboxUUID": livechatWidgetInboxUUID(app, hc),
|
|
"WidgetRootURL": helpCenterRootURL(app),
|
|
"Embed": embed,
|
|
"Dark": scheme.dark,
|
|
}
|
|
}
|
|
|
|
func livechatWidgetInboxUUID(app *App, hc hcmodels.HelpCenter) string {
|
|
if !hc.LivechatInboxID.Valid {
|
|
return ""
|
|
}
|
|
inb, err := app.inbox.GetDBRecord(hc.LivechatInboxID.Int)
|
|
if err != nil || !inb.Enabled || inb.Channel != livechat.ChannelLiveChat {
|
|
return ""
|
|
}
|
|
return inb.UUID
|
|
}
|
|
|
|
// announcementKey keys the dismissal on help center and content, so an edited announcement reappears for visitors who dismissed the old one.
|
|
func announcementKey(hcSlug string, a hcmodels.AnnouncementTheme) string {
|
|
if a.Text == "" {
|
|
return ""
|
|
}
|
|
h := fnv.New32a()
|
|
h.Write([]byte(hcSlug + "|" + a.Text + "|" + a.LinkLabel + "|" + a.LinkURL))
|
|
return fmt.Sprintf("%x", h.Sum32())
|
|
}
|
|
|
|
// buildThemeCSSVars emits the theme's CSS custom-property overrides.
|
|
func buildThemeCSSVars(t hcmodels.Theme) template.CSS {
|
|
var b strings.Builder
|
|
switch t.Header.BackgroundType {
|
|
case "image":
|
|
if t.Header.BackgroundImage != "" {
|
|
fmt.Fprintf(&b, "--hc-header-img:url(%s);", t.Header.BackgroundImage)
|
|
b.WriteString("--hc-hero-text-shadow:0 1px 2px rgba(0,0,0,.35),0 2px 16px rgba(0,0,0,.25);")
|
|
if t.Header.TextColor == "" {
|
|
b.WriteString("--hc-header-text:#ffffff;--hc-header-scrim:linear-gradient(rgba(10,12,18,.45),rgba(10,12,18,.45));")
|
|
}
|
|
}
|
|
case "gradient":
|
|
if t.Header.GradientFrom != "" && t.Header.GradientTo != "" {
|
|
fmt.Fprintf(&b, "--hc-header-bg:linear-gradient(180deg,%s,%s);", t.Header.GradientFrom, t.Header.GradientTo)
|
|
if t.Header.TextColor == "" {
|
|
fmt.Fprintf(&b, "--hc-header-text:%s;", readableOn(t.Header.GradientFrom, t.Header.GradientTo))
|
|
}
|
|
}
|
|
case "solid":
|
|
if t.Header.BackgroundColor != "" {
|
|
fmt.Fprintf(&b, "--hc-header-bg:%s;", t.Header.BackgroundColor)
|
|
if t.Header.TextColor == "" {
|
|
fmt.Fprintf(&b, "--hc-header-text:%s;", readableOn(t.Header.BackgroundColor))
|
|
}
|
|
}
|
|
}
|
|
if t.Header.TextColor != "" {
|
|
fmt.Fprintf(&b, "--hc-header-text:%s;", t.Header.TextColor)
|
|
}
|
|
if t.Footer.BackgroundColor != "" {
|
|
fmt.Fprintf(&b, "--hc-footer-bg:%s;", t.Footer.BackgroundColor)
|
|
}
|
|
if t.Footer.TextColor != "" {
|
|
fmt.Fprintf(&b, "--hc-footer-text:%s;", t.Footer.TextColor)
|
|
}
|
|
return template.CSS(b.String())
|
|
}
|
|
|
|
// buildDarkThemeCSSVars swaps in the dark footer colors and resets light-only colors to the dark defaults.
|
|
func buildDarkThemeCSSVars(t hcmodels.Theme) template.CSS {
|
|
var b strings.Builder
|
|
if t.Header.TextColor != "" && !hasHeaderBackground(t.Header) {
|
|
b.WriteString("--hc-header-text:initial;")
|
|
}
|
|
writeDarkVar(&b, "--hc-footer-bg", t.Footer.BackgroundColorDark, t.Footer.BackgroundColor)
|
|
writeDarkVar(&b, "--hc-footer-text", t.Footer.TextColorDark, t.Footer.TextColor)
|
|
return template.CSS(b.String())
|
|
}
|
|
|
|
func writeDarkVar(b *strings.Builder, name, dark, light string) {
|
|
switch {
|
|
case dark != "":
|
|
fmt.Fprintf(b, "%s:%s;", name, dark)
|
|
case light != "":
|
|
fmt.Fprintf(b, "%s:initial;", name)
|
|
}
|
|
}
|
|
|
|
func hasHeaderBackground(h hcmodels.HeaderTheme) bool {
|
|
switch h.BackgroundType {
|
|
case "image":
|
|
return h.BackgroundImage != ""
|
|
case "gradient":
|
|
return h.GradientFrom != "" && h.GradientTo != ""
|
|
case "solid":
|
|
return h.BackgroundColor != ""
|
|
}
|
|
return false
|
|
}
|
|
|
|
// renderHelpCenterNotFound renders the help center's themed 404, falling back to the
|
|
// generic error page when the help center is nil.
|
|
func renderHelpCenterNotFound(r *fastglue.Request, hc *hcmodels.HelpCenter) error {
|
|
return renderHelpCenterStatusPage(r, hc, fasthttp.StatusNotFound)
|
|
}
|
|
|
|
// renderHelpCenterPageError renders the themed 404 for missing pages and a themed 500 for everything else.
|
|
func renderHelpCenterPageError(r *fastglue.Request, hc *hcmodels.HelpCenter, err error) error {
|
|
if e, ok := err.(envelope.Error); ok && e.ErrorType == envelope.NotFoundError {
|
|
return renderHelpCenterNotFound(r, hc)
|
|
}
|
|
return renderHelpCenterStatusPage(r, hc, fasthttp.StatusInternalServerError)
|
|
}
|
|
|
|
func renderHelpCenterStatusPage(r *fastglue.Request, hc *hcmodels.HelpCenter, status int) error {
|
|
app := r.Context.(*App)
|
|
headingKey, textKey := "globals.messages.pageNotFound", "helpCenter.notFoundText"
|
|
if status == fasthttp.StatusInternalServerError {
|
|
headingKey, textKey = "globals.messages.somethingWentWrong", "helpCenter.errorText"
|
|
}
|
|
if hc != nil {
|
|
helpCenter := *hc
|
|
locale, ok := resolveLocale(r, helpCenter)
|
|
if !ok {
|
|
locale = helpCenter.DefaultLocale
|
|
}
|
|
data := helpCenterTemplateData(app, r, helpCenter, locale)
|
|
lcl := localeI18n(app, locale)
|
|
r.RequestCtx.Response.Header.Set("X-Robots-Tag", noIndexHeader)
|
|
rerr := app.tmpl.RenderWebPage(r.RequestCtx, hcPageName(helpCenter, "help-notfound"), map[string]interface{}{
|
|
"L": lcl,
|
|
"Data": map[string]interface{}{
|
|
"Title": lcl.T(headingKey),
|
|
"NoIndex": true,
|
|
"ErrorCode": strconv.Itoa(status),
|
|
"ErrorTitle": lcl.T(headingKey),
|
|
"ErrorText": lcl.T(textKey),
|
|
"LocaleLinks": helpCenterLocaleLinks(helpCenter, nil, func(l string) string { return helpCenterHomePath(helpCenter, l) }),
|
|
"HelpCenter": data,
|
|
"Tree": sidebarTree(app, helpCenter, locale),
|
|
},
|
|
})
|
|
r.RequestCtx.SetStatusCode(status)
|
|
return rerr
|
|
}
|
|
rerr := app.tmpl.RenderWebPage(r.RequestCtx, "error", map[string]interface{}{
|
|
"Data": map[string]interface{}{
|
|
"Title": app.i18n.T(headingKey),
|
|
"ErrorMessage": app.i18n.T(headingKey),
|
|
},
|
|
})
|
|
r.RequestCtx.SetStatusCode(status)
|
|
return rerr
|
|
}
|
|
|
|
func validateHelpCenter(app *App, req *helpcenter.HelpCenterRequest) error {
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
req.Slug = strings.TrimSpace(req.Slug)
|
|
req.PageTitle = strings.TrimSpace(req.PageTitle)
|
|
req.MetaDescription = strings.TrimSpace(req.MetaDescription)
|
|
if req.Name == "" {
|
|
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
|
|
}
|
|
if req.Slug == "" {
|
|
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`slug`"), nil)
|
|
}
|
|
if req.PageTitle == "" {
|
|
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`page_title`"), nil)
|
|
}
|
|
req.Theme = json.RawMessage(publicAssetPaths(app, string(req.Theme)))
|
|
if req.LivechatInboxID.Valid {
|
|
inb, err := app.inbox.GetDBRecord(req.LivechatInboxID.Int)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if inb.Channel != livechat.ChannelLiveChat {
|
|
return envelope.NewError(envelope.InputError, app.i18n.T("helpCenter.invalidLivechatInbox"), nil)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCollection(app *App, req *helpcenter.CollectionRequest) error {
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if req.Name == "" {
|
|
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateArticle(app *App, req *helpcenter.ArticleRequest) error {
|
|
req.Title = strings.TrimSpace(req.Title)
|
|
if req.Title == "" {
|
|
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`title`"), nil)
|
|
}
|
|
if strings.TrimSpace(req.Content) == "" {
|
|
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`content`"), nil)
|
|
}
|
|
req.Content = publicAssetPaths(app, req.Content)
|
|
req.MetaImageURL = publicAssetPaths(app, req.MetaImageURL)
|
|
return nil
|
|
}
|
|
|
|
// loadLucideIcons parses the vendored lucide sprite into ready-to-inline SVG elements keyed by icon name.
|
|
func loadLucideIcons(fs stuffbin.FileSystem) map[string]template.HTML {
|
|
icons := map[string]template.HTML{}
|
|
b, err := fs.Read(lucideSpritePath)
|
|
if err != nil {
|
|
log.Printf("error reading lucide sprite: %v", err)
|
|
return icons
|
|
}
|
|
for _, m := range lucideSymbolRe.FindAllSubmatch(b, -1) {
|
|
icons[string(m[1])] = template.HTML(`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` + string(m[2]) + `</svg>`)
|
|
}
|
|
return icons
|
|
}
|
|
|
|
// readableOn returns the dark or light header text color, whichever holds the higher worst-case
|
|
// contrast across the given hex backgrounds. A gradient passes both of its endpoints.
|
|
func readableOn(hexColors ...string) string {
|
|
darkL, _ := relativeLuminance(headerTextDark)
|
|
darkWorst, lightWorst := math.Inf(1), math.Inf(1)
|
|
for _, hexColor := range hexColors {
|
|
bg, ok := relativeLuminance(hexColor)
|
|
if !ok {
|
|
return headerTextDark
|
|
}
|
|
darkWorst = min(darkWorst, contrastRatio(bg, darkL))
|
|
lightWorst = min(lightWorst, contrastRatio(bg, 1))
|
|
}
|
|
if lightWorst > darkWorst {
|
|
return headerTextLight
|
|
}
|
|
return headerTextDark
|
|
}
|
|
|
|
// relativeLuminance returns the WCAG relative luminance of any hex color hexColorRe accepts; alpha is dropped.
|
|
func relativeLuminance(hexColor string) (float64, bool) {
|
|
c := strings.TrimPrefix(strings.TrimSpace(hexColor), "#")
|
|
if len(c) == 3 || len(c) == 4 {
|
|
c = string([]byte{c[0], c[0], c[1], c[1], c[2], c[2]})
|
|
}
|
|
if len(c) == 8 {
|
|
c = c[:6]
|
|
}
|
|
if len(c) != 6 {
|
|
return 0, false
|
|
}
|
|
v, err := strconv.ParseUint(c, 16, 32)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
toLinear := func(ch float64) float64 {
|
|
ch /= 255
|
|
if ch <= 0.03928 {
|
|
return ch / 12.92
|
|
}
|
|
return math.Pow((ch+0.055)/1.055, 2.4)
|
|
}
|
|
r, g, b := float64((v>>16)&0xff), float64((v>>8)&0xff), float64(v&0xff)
|
|
return 0.2126*toLinear(r) + 0.7152*toLinear(g) + 0.0722*toLinear(b), true
|
|
}
|
|
|
|
func contrastRatio(a, b float64) float64 {
|
|
return (max(a, b) + 0.05) / (min(a, b) + 0.05)
|
|
}
|
|
|
|
// localeDir returns the text direction for a locale tag such as "ar" or "ar-EG".
|
|
func localeDir(locale string) string {
|
|
base, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(locale)), "-")
|
|
if slices.Contains(rtlLanguages, base) {
|
|
return "rtl"
|
|
}
|
|
return "ltr"
|
|
}
|
|
|
|
// renderHelpCenterArticlePreview renders a sample article page from unsaved settings; TOC and related list are built server-side because the preview iframe is sandboxed without scripts.
|
|
func renderHelpCenterArticlePreview(r *fastglue.Request, helpCenter hcmodels.HelpCenter, locale string) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
i18n = localeI18n(app, locale)
|
|
body = template.HTMLEscapeString(i18n.T("helpCenter.preview.sampleArticleBody"))
|
|
item = template.HTMLEscapeString(i18n.T("helpCenter.preview.sampleArticleListItem"))
|
|
toc = []previewTOCItem{
|
|
{ID: "sample-1", Title: i18n.T("helpCenter.preview.sampleArticleHeading1")},
|
|
{ID: "sample-2", Title: i18n.T("helpCenter.preview.sampleArticleHeading2")},
|
|
{ID: "sample-3", Title: i18n.T("helpCenter.preview.sampleArticleHeading3")},
|
|
}
|
|
author = i18n.T("helpCenter.preview.sampleAuthor")
|
|
article = hcmodels.Article{
|
|
Slug: "sample-article",
|
|
Locale: locale,
|
|
AuthorName: &author,
|
|
Title: i18n.T("helpCenter.preview.sampleArticleTitle"),
|
|
Excerpt: i18n.T("helpCenter.preview.sampleArticleExcerpt"),
|
|
UpdatedAt: time.Now(),
|
|
Content: fmt.Sprintf(
|
|
`<h2 id="%s">%s</h2><p>%s</p><h2 id="%s">%s</h2><p>%s</p><ul><li>%s</li><li>%s</li></ul><h2 id="%s">%s</h2><p>%s</p>`,
|
|
toc[0].ID, template.HTMLEscapeString(toc[0].Title), body,
|
|
toc[1].ID, template.HTMLEscapeString(toc[1].Title), body, item, item,
|
|
toc[2].ID, template.HTMLEscapeString(toc[2].Title), body),
|
|
}
|
|
collection = hcmodels.Collection{
|
|
Slug: "sample-collection",
|
|
Name: i18n.T("helpCenter.preview.sampleCollection"),
|
|
}
|
|
related = []hcmodels.Article{
|
|
{Slug: "sample-1", Title: toc[0].Title},
|
|
{Slug: "sample-2", Title: toc[1].Title},
|
|
{Slug: "sample-3", Title: toc[2].Title},
|
|
}
|
|
)
|
|
if err := app.tmpl.RenderWebPage(r.RequestCtx, hcPageName(helpCenter, "help-article"), map[string]interface{}{
|
|
"L": i18n,
|
|
"Data": map[string]interface{}{
|
|
"Title": article.Title,
|
|
"ModifiedTime": article.UpdatedAt.Format(time.RFC3339),
|
|
"HelpCenter": helpCenterTemplateData(app, r, helpCenter, locale),
|
|
"Article": article,
|
|
"AuthorInitial": authorInitial(article),
|
|
"Collection": collection,
|
|
"Related": related,
|
|
"TOC": toc,
|
|
"Tree": sidebarTree(app, helpCenter, locale),
|
|
"Content": template.HTML(article.Content),
|
|
},
|
|
}); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
r.RequestCtx.Response.Header.Set("Cache-Control", "no-store")
|
|
return nil
|
|
}
|
|
|
|
// redirectPath sends a path-only Location; ctx.Redirect would absolutize it to http:// behind a TLS-terminating proxy.
|
|
// The URI round-trip is what escapes the path: a bare "/\host" Location is read as an authority by browsers.
|
|
func redirectPath(ctx *fasthttp.RequestCtx, uri string, statusCode int) {
|
|
u := fasthttp.AcquireURI()
|
|
defer fasthttp.ReleaseURI(u)
|
|
u.Update(uri)
|
|
ctx.Response.Header.SetCanonical([]byte(fasthttp.HeaderLocation), u.RequestURI())
|
|
ctx.SetStatusCode(statusCode)
|
|
ctx.Response.SetBodyString("")
|
|
}
|
|
|
|
// resolveColorScheme lets the widget embed and the admin preview override the admin's scheme.
|
|
func resolveColorScheme(scheme, requested string, embed bool) colorSchemeResult {
|
|
res := colorSchemeResult{dark: scheme == hcmodels.ColorSchemeDark}
|
|
if requested != "" || embed {
|
|
res.dark = requested == hcmodels.ColorSchemeDark
|
|
}
|
|
res.showToggle = scheme == hcmodels.ColorSchemeSystem && !embed
|
|
res.followSystem = res.showToggle && requested == ""
|
|
return res
|
|
}
|