mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-10 14:15:42 +00:00
1999 lines
70 KiB
Go
1999 lines
70 KiB
Go
package main
|
|
|
|
import (
|
|
"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/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"
|
|
)
|
|
|
|
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 previewTOCItem struct {
|
|
ID string
|
|
Title string
|
|
}
|
|
|
|
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, 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)
|
|
}
|
|
return r.SendEnvelope(article)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// 302, not 301: the default locale is mutable and browsers cache permanent redirects.
|
|
redirectPath(r.RequestCtx, helpCenterHomePath(helpCenter, helpCenter.DefaultLocale), 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, 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, 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
|
|
}
|
|
translated, err := app.helpcenter.GetPublishedArticleLocales(slug, article.Slug)
|
|
if err != nil {
|
|
translated = []string{locale}
|
|
}
|
|
var (
|
|
root = helpCenterBaseURL(app, helpCenter)
|
|
pathFor = func(l string) string { return articlePath(helpCenter, l, article.Slug) }
|
|
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, 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,
|
|
"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, 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")
|
|
r.RequestCtx.Response.Header.Set("X-Frame-Options", "SAMEORIGIN")
|
|
r.RequestCtx.Response.Header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
if noIndex || isMarkdownRequest(r) {
|
|
r.RequestCtx.Response.Header.Set("X-Robots-Tag", noIndexHeader)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// resolveQueryLocale returns the "locale" query arg when the help center serves it, else its default.
|
|
func resolveQueryLocale(r *fastglue.Request, hc hcmodels.HelpCenter) string {
|
|
loc := strings.TrimSpace(string(r.RequestCtx.QueryArgs().Peek("locale")))
|
|
if loc == "" || !slices.Contains(helpCenterLocales(hc), loc) {
|
|
return hc.DefaultLocale
|
|
}
|
|
return loc
|
|
}
|
|
|
|
// 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, 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
|
|
}
|
|
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),
|
|
"Color": theme.Color,
|
|
"DefaultLocale": hc.DefaultLocale,
|
|
"CurrentLocale": locale,
|
|
"OGLocale": strings.ReplaceAll(locale, "-", "_"),
|
|
"Dir": localeDir(locale),
|
|
"AvailableLocales": helpCenterLocales(hc),
|
|
"NavLinks": theme.NavLinks,
|
|
"Theme": theme,
|
|
"ThemeCSS": buildThemeCSSVars(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),
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
|
|
// 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, 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)))
|
|
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, 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("")
|
|
}
|