feat(help-center): expose public sitemap
This commit is contained in:
@@ -2,7 +2,9 @@ package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -43,6 +45,36 @@ func (h *PortalHandler) PublicGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, publicPortalPayload(portal))
|
||||
}
|
||||
|
||||
// PublicSitemap returns the public help-center XML sitemap.
|
||||
// GET /hc/:slug/sitemap.xml
|
||||
func (h *PortalHandler) PublicSitemap(c *gin.Context) {
|
||||
portal, ok := h.resolvePublicPortal(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
baseURL := publicHelpCenterBaseURL(c, portal)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
|
||||
b.WriteString("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n")
|
||||
for i := range portal.Articles {
|
||||
article := &portal.Articles[i]
|
||||
if article.Status != string(model.ArticleStatusPublished) {
|
||||
continue
|
||||
}
|
||||
b.WriteString(" <url>\n")
|
||||
b.WriteString(" <loc>")
|
||||
b.WriteString(html.EscapeString(baseURL + "/hc/" + portal.Slug + "/articles/" + article.Slug))
|
||||
b.WriteString("</loc>\n")
|
||||
b.WriteString(" <lastmod>")
|
||||
b.WriteString(article.UpdatedAt.Format("2006-01-02"))
|
||||
b.WriteString("</lastmod>\n")
|
||||
b.WriteString(" </url>\n")
|
||||
}
|
||||
b.WriteString("</urlset>\n")
|
||||
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String()))
|
||||
}
|
||||
|
||||
// Create creates a new portal.
|
||||
// POST /api/v1/accounts/:account_id/portals
|
||||
func (h *PortalHandler) Create(c *gin.Context) {
|
||||
@@ -270,6 +302,27 @@ func (h *PortalHandler) resolvePublicPortal(c *gin.Context) (*model.Portal, bool
|
||||
return portal, true
|
||||
}
|
||||
|
||||
func publicHelpCenterBaseURL(c *gin.Context, portal *model.Portal) string {
|
||||
baseURL := ""
|
||||
if portal != nil {
|
||||
baseURL = strings.TrimSpace(portal.CustomDomain)
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimSpace(os.Getenv("FRONTEND_URL"))
|
||||
}
|
||||
if baseURL == "" && c != nil && c.Request != nil {
|
||||
baseURL = c.Request.Host
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "localhost:3000"
|
||||
}
|
||||
if !strings.Contains(baseURL, "://") {
|
||||
baseURL = "https://" + baseURL
|
||||
}
|
||||
return baseURL
|
||||
}
|
||||
|
||||
func portalDefaultLocale(portal *model.Portal) string {
|
||||
if portal == nil {
|
||||
return "en"
|
||||
|
||||
@@ -191,6 +191,29 @@ func (s *PortalHandlerTestSuite) TestPublicGet_NotFoundForArchivedPortal() {
|
||||
assert.Equal(s.T(), http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestPublicSitemap_ReturnsPublishedArticleURLs() {
|
||||
portal := &model.Portal{AccountID: s.account.ID, Name: "Sitemap", Slug: "sitemap", CustomDomain: "help.example.com"}
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Published", Slug: "published", Status: "published", Locale: "en"}).Error)
|
||||
s.Require().NoError(s.db.Create(&model.Article{AccountID: s.account.ID, PortalID: portal.ID, Title: "Draft", Slug: "draft", Status: "draft", Locale: "en"}).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.GET("/hc/:slug/sitemap.xml", s.handler.PublicSitemap)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/hc/sitemap/sitemap.xml", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
assert.Equal(s.T(), "application/xml; charset=utf-8", w.Header().Get("Content-Type"))
|
||||
body := w.Body.String()
|
||||
assert.Contains(s.T(), body, `<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
assert.Contains(s.T(), body, `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
|
||||
assert.Contains(s.T(), body, `<loc>https://help.example.com/hc/sitemap/articles/published</loc>`)
|
||||
assert.Contains(s.T(), body, `<lastmod>`)
|
||||
assert.NotContains(s.T(), body, "draft")
|
||||
}
|
||||
|
||||
func (s *PortalHandlerTestSuite) TestUpdate_Success() {
|
||||
portal := &model.Portal{AccountID: s.account.ID, Name: "update-portal", Slug: "update-slug"}
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
|
||||
@@ -284,6 +284,7 @@ func RegisterRoutes(
|
||||
helpCenter.Use(middleware.CORS(corsCfg))
|
||||
{
|
||||
helpCenter.GET("/:slug", handlers.Portal.PublicRedirectDefaultLocale)
|
||||
helpCenter.GET("/:slug/sitemap.xml", handlers.Portal.PublicSitemap)
|
||||
helpCenter.GET("/:slug/articles/:article_slug", handlers.Article.PublicArticle)
|
||||
helpCenter.GET("/:slug/:locale", handlers.Portal.PublicGet)
|
||||
helpCenter.GET("/:slug/:locale/search", handlers.Article.PublicSearch)
|
||||
|
||||
@@ -37,6 +37,7 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
|
||||
"GET /api/v1/widget/conversations/toggle_status",
|
||||
"PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id",
|
||||
"GET /hc/:slug",
|
||||
"GET /hc/:slug/sitemap.xml",
|
||||
"GET /hc/:slug/:locale",
|
||||
"GET /hc/:slug/:locale/search",
|
||||
"GET /hc/:slug/:locale/articles.json",
|
||||
|
||||
Reference in New Issue
Block a user