mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-11 13:28:57 +00:00
add a public URL per help center and fix code review issues
Each help center gets an optional public_url. When set, canonical links, hreflang alternates, og:url, the sitemap and robots.txt point at that host instead of the app root URL. Stored media URLs are rewritten to root-relative paths so logos, favicons, header images and article images resolve on whichever host serves the page. The URL is validated on both the form and the backend, and trailing slashes are trimmed. Also fixes issues found in code review: - the help center template used a locale link as the data root inside the alternates loop, which broke every public page on a help center with two or more locales - creating an article posted the clicked collection id, so changing the collection in the sheet filed the article in the wrong place - the collection field's error message was hidden when the locale had no collections, so Create did nothing with no error shown - switching the header type or unticking popular articles dropped those theme values on save, because vee-validate unsets fields that unmount - closing a new collection sheet with Esc left the parent id set, so the next collection you edited got re-parented - a table row with th labels in the first column was treated as a header row when preparing article content for embeddings - the img tag regex stopped at a > inside an attribute value and injected loading/decoding in the middle of an attribute
This commit is contained in:
+54
-18
@@ -18,6 +18,7 @@ import (
|
||||
"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"
|
||||
@@ -211,7 +212,7 @@ func handleHelpCenterPreview(r *fastglue.Request) error {
|
||||
"Data": map[string]interface{}{
|
||||
"Title": helpCenter.PageTitle,
|
||||
"LandingHero": true,
|
||||
"HelpCenter": helpCenterTemplateData(helpCenter, locale),
|
||||
"HelpCenter": helpCenterTemplateData(app, helpCenter, locale),
|
||||
"Tree": tree.Tree,
|
||||
"Popular": popular,
|
||||
},
|
||||
@@ -593,12 +594,12 @@ func handleShowHelpCenterHome(r *fastglue.Request) error {
|
||||
popular = nil
|
||||
}
|
||||
var (
|
||||
root = helpCenterRootURL(app)
|
||||
root = helpCenterBaseURL(app, helpCenter)
|
||||
locales = helpCenterLocales(helpCenter)
|
||||
pathFor = func(l string) string { return helpCenterHomePath(helpCenter.Slug, l) }
|
||||
metaDescription = firstNonEmpty(tree.HelpCenter.MetaDescription, tree.HelpCenter.HeaderText)
|
||||
)
|
||||
data := helpCenterTemplateData(tree.HelpCenter, locale)
|
||||
data := helpCenterTemplateData(app, tree.HelpCenter, locale)
|
||||
return renderHelpCenterPage(r, "help-center", map[string]interface{}{
|
||||
"L": localeI18n(app, locale),
|
||||
"Data": map[string]interface{}{
|
||||
@@ -606,7 +607,7 @@ func handleShowHelpCenterHome(r *fastglue.Request) error {
|
||||
"MetaDescription": metaDescription,
|
||||
"CanonicalPath": pathFor(locale),
|
||||
"LandingHero": true,
|
||||
"OGImage": absoluteURL(root, tree.HelpCenter.LogoURL),
|
||||
"OGImage": absoluteURL(root, publicAssetPaths(app, tree.HelpCenter.LogoURL)),
|
||||
"Alternates": helpCenterAlternates(helpCenter, locales, pathFor),
|
||||
"XDefaultPath": defaultLocalePath(helpCenter, locales, pathFor),
|
||||
"LocaleLinks": helpCenterLocaleLinks(helpCenter, locales, pathFor),
|
||||
@@ -646,17 +647,17 @@ func handleShowHelpCenterCollection(r *fastglue.Request) error {
|
||||
translated = []string{locale}
|
||||
}
|
||||
var (
|
||||
root = helpCenterRootURL(app)
|
||||
root = helpCenterBaseURL(app, helpCenter)
|
||||
pathFor = func(l string) string { return collectionPath(helpCenter.Slug, l, collection.Slug) }
|
||||
)
|
||||
data := helpCenterTemplateData(helpCenter, locale)
|
||||
data := helpCenterTemplateData(app, helpCenter, locale)
|
||||
return renderHelpCenterPage(r, "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, helpCenter.LogoURL),
|
||||
"OGImage": absoluteURL(root, publicAssetPaths(app, helpCenter.LogoURL)),
|
||||
"Alternates": helpCenterAlternates(helpCenter, translated, pathFor),
|
||||
"XDefaultPath": defaultLocalePath(helpCenter, translated, pathFor),
|
||||
"LocaleLinks": helpCenterLocaleLinks(helpCenter, translated, pathFor),
|
||||
@@ -713,13 +714,13 @@ func handleShowHelpCenterArticle(r *fastglue.Request) error {
|
||||
translated = []string{locale}
|
||||
}
|
||||
var (
|
||||
root = helpCenterRootURL(app)
|
||||
root = helpCenterBaseURL(app, helpCenter)
|
||||
pathFor = func(l string) string { return articlePath(helpCenter.Slug, l, article.Slug) }
|
||||
metaDescription = firstNonEmpty(article.MetaDescription, article.Excerpt)
|
||||
metaTitle = firstNonEmpty(article.MetaTitle, fmt.Sprintf("%s - %s", article.Title, helpCenter.Name))
|
||||
ogImage = absoluteURL(root, firstNonEmpty(article.MetaImageURL, helpCenter.LogoURL))
|
||||
ogImage = absoluteURL(root, publicAssetPaths(app, firstNonEmpty(article.MetaImageURL, helpCenter.LogoURL)))
|
||||
)
|
||||
data := helpCenterTemplateData(helpCenter, locale)
|
||||
data := helpCenterTemplateData(app, helpCenter, locale)
|
||||
return renderHelpCenterPage(r, "help-article", map[string]interface{}{
|
||||
"L": localeI18n(app, locale),
|
||||
"Data": map[string]interface{}{
|
||||
@@ -739,7 +740,7 @@ func handleShowHelpCenterArticle(r *fastglue.Request) error {
|
||||
"AuthorInitial": authorInitial(article),
|
||||
"Collection": collection,
|
||||
"Related": related,
|
||||
"Content": template.HTML(stringutil.DeferOffscreenImages(article.Content)),
|
||||
"Content": template.HTML(publicAssetPaths(app, stringutil.DeferOffscreenImages(article.Content))),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -769,7 +770,7 @@ func handleHelpCenterSearch(r *fastglue.Request) error {
|
||||
}
|
||||
}
|
||||
var (
|
||||
data = helpCenterTemplateData(helpCenter, locale)
|
||||
data = helpCenterTemplateData(app, helpCenter, locale)
|
||||
lcl = localeI18n(app, locale)
|
||||
pathFor = func(l string) string { return searchPath(helpCenter.Slug, l) }
|
||||
)
|
||||
@@ -813,7 +814,7 @@ func handleHelpCenterSitemap(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
root := helpCenterRootURL(app)
|
||||
root := helpCenterBaseURL(app, helpCenter)
|
||||
set := urlset{Xmlns: sitemapNamespace}
|
||||
set.URLs = append(set.URLs, sitemapURL{
|
||||
Loc: root + helpCenterHomePath(helpCenter.Slug, locale),
|
||||
@@ -841,9 +842,9 @@ func handleSitemapIndex(r *fastglue.Request) error {
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
root := helpCenterRootURL(app)
|
||||
index := sitemapIndex{Xmlns: sitemapNamespace}
|
||||
for _, hc := range 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.Slug, locale))})
|
||||
}
|
||||
@@ -860,6 +861,19 @@ func handleRobotsTxt(r *fastglue.Request) error {
|
||||
fmt.Fprintf(r.RequestCtx, "Disallow: %s\n", path)
|
||||
}
|
||||
fmt.Fprintf(r.RequestCtx, "\nSitemap: %s/sitemap.xml\n", helpCenterRootURL(app))
|
||||
// A help center on its own host can't be crawled through the app root's sitemap index.
|
||||
helpCenters, err := app.helpcenter.GetActiveHelpCenters()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, hc := range helpCenters {
|
||||
if hc.PublicURL == "" {
|
||||
continue
|
||||
}
|
||||
for _, locale := range helpCenterLocales(hc) {
|
||||
fmt.Fprintf(r.RequestCtx, "Sitemap: %s%s/sitemap.xml\n", hc.PublicURL, helpCenterHomePath(hc.Slug, locale))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1055,6 +1069,25 @@ func helpCenterRootURL(app *App) string {
|
||||
return strings.TrimRight(app.consts.Load().(*constants).AppBaseURL, "/")
|
||||
}
|
||||
|
||||
// helpCenterBaseURL returns the host a reader sees this help center on: its own public URL when
|
||||
// set, else the app root URL.
|
||||
func helpCenterBaseURL(app *App, hc hcmodels.HelpCenter) string {
|
||||
if hc.PublicURL != "" {
|
||||
return hc.PublicURL
|
||||
}
|
||||
return helpCenterRootURL(app)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -1305,7 +1338,7 @@ func helpCenterLocales(hc hcmodels.HelpCenter) []string {
|
||||
}
|
||||
|
||||
// helpCenterTemplateData shapes a help center row for the public templates.
|
||||
func helpCenterTemplateData(hc hcmodels.HelpCenter, locale string) map[string]interface{} {
|
||||
func helpCenterTemplateData(app *App, hc hcmodels.HelpCenter, locale string) map[string]interface{} {
|
||||
navLinks := []hcmodels.NavLink{}
|
||||
if len(hc.NavLinks) > 0 {
|
||||
if err := json.Unmarshal(hc.NavLinks, &navLinks); err != nil {
|
||||
@@ -1318,12 +1351,15 @@ func helpCenterTemplateData(hc hcmodels.HelpCenter, locale string) map[string]in
|
||||
theme = hcmodels.DefaultTheme()
|
||||
}
|
||||
}
|
||||
theme.Favicon = publicAssetPaths(app, theme.Favicon)
|
||||
theme.Header.BackgroundImage = publicAssetPaths(app, theme.Header.BackgroundImage)
|
||||
return map[string]interface{}{
|
||||
"Slug": hc.Slug,
|
||||
"Name": hc.Name,
|
||||
"BaseURL": helpCenterBaseURL(app, hc),
|
||||
"PageTitle": hc.PageTitle,
|
||||
"HeaderText": hc.HeaderText,
|
||||
"LogoURL": hc.LogoURL,
|
||||
"LogoURL": publicAssetPaths(app, hc.LogoURL),
|
||||
"Color": hc.Color,
|
||||
"DefaultLocale": hc.DefaultLocale,
|
||||
"CurrentLocale": locale,
|
||||
@@ -1401,7 +1437,7 @@ func renderHelpCenterNotFound(r *fastglue.Request, hc *hcmodels.HelpCenter) erro
|
||||
if !ok {
|
||||
locale = helpCenter.DefaultLocale
|
||||
}
|
||||
data := helpCenterTemplateData(helpCenter, locale)
|
||||
data := helpCenterTemplateData(app, helpCenter, locale)
|
||||
lcl := localeI18n(app, locale)
|
||||
r.RequestCtx.Response.Header.Set("X-Robots-Tag", noIndexHeader)
|
||||
rerr := app.tmpl.RenderWebPage(r.RequestCtx, "help-notfound", map[string]interface{}{
|
||||
@@ -1570,7 +1606,7 @@ func renderHelpCenterArticlePreview(r *fastglue.Request, helpCenter hcmodels.Hel
|
||||
"Data": map[string]interface{}{
|
||||
"Title": article.Title,
|
||||
"ModifiedTime": article.UpdatedAt.Format(time.RFC3339),
|
||||
"HelpCenter": helpCenterTemplateData(helpCenter, locale),
|
||||
"HelpCenter": helpCenterTemplateData(app, helpCenter, locale),
|
||||
"Article": article,
|
||||
"AuthorInitial": authorInitial(article),
|
||||
"Collection": collection,
|
||||
|
||||
@@ -123,13 +123,9 @@
|
||||
{{ t('helpCenter.noCollectionsInLanguage') }}
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
v-if="localeCollections.length > 0"
|
||||
v-slot="{ componentField }"
|
||||
name="collection_id"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="collection_id">
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<FormControl v-if="localeCollections.length > 0">
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue>{{ collectionLabel }}</SelectValue>
|
||||
|
||||
@@ -29,6 +29,17 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="public_url">
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('helpCenter.publicURL') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="https://help.example.com" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>{{ t('helpCenter.publicURLHint') }}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="page_title">
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('helpCenter.pageTitle') }}</FormLabel>
|
||||
@@ -209,21 +220,19 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
v-if="form.values.theme?.header?.background_type === 'solid'"
|
||||
v-slot="{ componentField }"
|
||||
name="theme.header.background_color"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('globals.messages.backgroundColor') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="color" v-bind="componentField" />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<div v-show="form.values.theme?.header?.background_type === 'solid'">
|
||||
<FormField v-slot="{ componentField }" name="theme.header.background_color">
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('globals.messages.backgroundColor') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="color" v-bind="componentField" />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.values.theme?.header?.background_type === 'gradient'"
|
||||
v-show="form.values.theme?.header?.background_type === 'gradient'"
|
||||
class="flex gap-4"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="theme.header.gradient_from">
|
||||
@@ -244,24 +253,22 @@
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
v-if="form.values.theme?.header?.background_type === 'image'"
|
||||
v-slot="{ componentField }"
|
||||
name="theme.header.background_image"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('helpCenter.styling.headerImage') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="https://example.com/header.jpg"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{{ t('helpCenter.styling.headerImageHint') }}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<div v-show="form.values.theme?.header?.background_type === 'image'">
|
||||
<FormField v-slot="{ componentField }" name="theme.header.background_image">
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('helpCenter.styling.headerImage') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="https://example.com/header.jpg"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{{ t('helpCenter.styling.headerImageHint') }}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="theme.header.text_color">
|
||||
<FormItem>
|
||||
@@ -338,26 +345,24 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
v-if="form.values.theme?.layout?.collections !== 'list'"
|
||||
v-slot="{ componentField }"
|
||||
name="theme.layout.columns"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('helpCenter.styling.cardsPerRow') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<div v-show="form.values.theme?.layout?.collections !== 'list'">
|
||||
<FormField v-slot="{ componentField }" name="theme.layout.columns">
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('helpCenter.styling.cardsPerRow') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="theme.cards.icon_position">
|
||||
<FormItem>
|
||||
@@ -393,23 +398,21 @@
|
||||
}}</FormLabel>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField
|
||||
v-if="form.values.theme?.layout?.show_popular_articles"
|
||||
v-slot="{ componentField }"
|
||||
name="theme.layout.popular_articles_label"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('helpCenter.styling.popularArticlesLabel') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
:placeholder="t('helpCenter.popularArticles')"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<div v-show="form.values.theme?.layout?.show_popular_articles">
|
||||
<FormField v-slot="{ componentField }" name="theme.layout.popular_articles_label">
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('helpCenter.styling.popularArticlesLabel') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
:placeholder="t('helpCenter.popularArticles')"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField v-slot="{ value, handleChange }" name="theme.cards.hide_description">
|
||||
<FormItem class="flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
@@ -698,6 +701,7 @@ const socialPlatforms = [
|
||||
const toFormValues = (hc) => ({
|
||||
name: hc?.name || '',
|
||||
slug: hc?.slug || '',
|
||||
public_url: hc?.public_url || '',
|
||||
page_title: hc?.page_title || '',
|
||||
header_text: hc?.header_text || '',
|
||||
meta_description: hc?.meta_description || '',
|
||||
|
||||
@@ -39,6 +39,10 @@ export const createHelpCenterFormSchema = (t) => {
|
||||
logo_url: optionalURL,
|
||||
color: z.string().optional(),
|
||||
nav_links: linkArray,
|
||||
public_url: z
|
||||
.string()
|
||||
.refine((v) => !v || /^https?:\/\/[^"'()\s\\<>;{}]+$/.test(v), t('helpCenter.invalidPublicURL'))
|
||||
.optional(),
|
||||
custom_css: z.string().optional(),
|
||||
custom_js: z.string().optional(),
|
||||
default_locale: z
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
|
||||
<ArticleEditSheet
|
||||
:is-open="showArticleEditSheet"
|
||||
@update:open="showArticleEditSheet = $event"
|
||||
@update:open="$event ? (showArticleEditSheet = true) : closeEditSheet()"
|
||||
:article="editingArticle"
|
||||
:collection-id="editingArticle?.collection_id || createArticleCollectionId"
|
||||
:help-center-id="parseInt(id)"
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
<CollectionEditSheet
|
||||
:is-open="showCollectionEditSheet"
|
||||
@update:open="showCollectionEditSheet = $event"
|
||||
@update:open="$event ? (showCollectionEditSheet = true) : closeEditSheet()"
|
||||
:collection="editingCollection"
|
||||
:help-center-id="parseInt(id)"
|
||||
:parent-id="createCollectionParentId"
|
||||
@@ -420,9 +420,7 @@ const closeEditSheet = () => {
|
||||
const visitSite = () => {
|
||||
const rootUrl = appSettingsStore.settings?.['app.root_url'] || window.location.origin
|
||||
const locale = props.locale || helpCenter.value?.default_locale || ''
|
||||
const path = locale
|
||||
? `/hc/${helpCenter.value?.slug}/${locale}`
|
||||
: `/hc/${helpCenter.value?.slug}`
|
||||
const path = locale ? `/hc/${helpCenter.value?.slug}/${locale}` : `/hc/${helpCenter.value?.slug}`
|
||||
window.open(`${rootUrl.replace(/\/$/, '')}${path}`, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
@@ -507,7 +505,7 @@ const handleArticleSave = async (formData) => {
|
||||
if (editingArticle.value) {
|
||||
await api.updateArticle(editingArticle.value.id, formData)
|
||||
} else {
|
||||
await api.createArticle(createArticleCollectionId.value, formData)
|
||||
await api.createArticle(formData.collection_id || createArticleCollectionId.value, formData)
|
||||
}
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
description: t('globals.messages.savedSuccessfully')
|
||||
|
||||
@@ -1261,6 +1261,9 @@
|
||||
"helpCenter.searchPlaceholder": "Search articles...",
|
||||
"helpCenter.searchResults": "Results for",
|
||||
"helpCenter.slugHint": "Used in the help center URL.",
|
||||
"helpCenter.publicURL": "Public URL (optional)",
|
||||
"helpCenter.publicURLHint": "If you want to serve this help center on its own domain, enter it here so canonical links, sitemaps and share previews point there instead of the app's root URL. That domain also needs to point at Libredesk in your DNS and reverse proxy.",
|
||||
"helpCenter.invalidPublicURL": "Enter a full URL starting with https://, with no trailing slash.",
|
||||
"imageLightbox.next": "Next image",
|
||||
"imageLightbox.previous": "Previous image",
|
||||
"imageLightbox.resetZoom": "Reset zoom",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -97,6 +98,7 @@ type HelpCenterRequest struct {
|
||||
DefaultLocale string `json:"default_locale"`
|
||||
AllowedLocales json.RawMessage `json:"allowed_locales"`
|
||||
Theme json.RawMessage `json:"theme"`
|
||||
PublicURL string `json:"public_url"`
|
||||
}
|
||||
|
||||
type CollectionRequest struct {
|
||||
@@ -271,7 +273,10 @@ func (m *Manager) CreateHelpCenter(req HelpCenterRequest) (models.HelpCenter, er
|
||||
if err := m.validateColor(req.Color); err != nil {
|
||||
return hc, err
|
||||
}
|
||||
if err := m.q.InsertHelpCenter.Get(&hc, req.Name, req.Slug, req.PageTitle, req.HeaderText, req.MetaDescription, req.LogoURL, req.Color, req.NavLinks, req.CustomCSS, req.CustomJS, req.DefaultLocale, req.AllowedLocales, req.Theme); err != nil {
|
||||
if err := m.validatePublicURL(req.PublicURL); err != nil {
|
||||
return hc, err
|
||||
}
|
||||
if err := m.q.InsertHelpCenter.Get(&hc, req.Name, req.Slug, req.PageTitle, req.HeaderText, req.MetaDescription, req.LogoURL, req.Color, req.NavLinks, req.CustomCSS, req.CustomJS, req.DefaultLocale, req.AllowedLocales, req.Theme, req.PublicURL); err != nil {
|
||||
if dbutil.IsUniqueViolationError(err) {
|
||||
return hc, envelope.NewError(envelope.ConflictError, m.i18n.T("globals.messages.errorAlreadyExists"), nil)
|
||||
}
|
||||
@@ -300,6 +305,7 @@ func (m *Manager) DraftHelpCenter(id int, req HelpCenterRequest) (models.HelpCen
|
||||
hc.DefaultLocale = req.DefaultLocale
|
||||
hc.AllowedLocales = req.AllowedLocales
|
||||
hc.Theme = req.Theme
|
||||
hc.PublicURL = req.PublicURL
|
||||
if err := m.validateColor(hc.Color); err != nil {
|
||||
return hc, err
|
||||
}
|
||||
@@ -319,7 +325,10 @@ func (m *Manager) UpdateHelpCenter(id int, req HelpCenterRequest) (models.HelpCe
|
||||
if err := m.validateColor(req.Color); err != nil {
|
||||
return hc, err
|
||||
}
|
||||
if err := m.q.UpdateHelpCenter.Get(&hc, id, req.Name, req.Slug, req.PageTitle, req.HeaderText, req.MetaDescription, req.LogoURL, req.Color, req.NavLinks, req.CustomCSS, req.CustomJS, req.DefaultLocale, req.AllowedLocales, req.Theme); err != nil {
|
||||
if err := m.validatePublicURL(req.PublicURL); err != nil {
|
||||
return hc, err
|
||||
}
|
||||
if err := m.q.UpdateHelpCenter.Get(&hc, id, req.Name, req.Slug, req.PageTitle, req.HeaderText, req.MetaDescription, req.LogoURL, req.Color, req.NavLinks, req.CustomCSS, req.CustomJS, req.DefaultLocale, req.AllowedLocales, req.Theme, req.PublicURL); err != nil {
|
||||
if dbutil.IsUniqueViolationError(err) {
|
||||
return hc, envelope.NewError(envelope.ConflictError, m.i18n.T("globals.messages.errorAlreadyExists"), nil)
|
||||
}
|
||||
@@ -1208,6 +1217,17 @@ func (m *Manager) validateLocales(defaultLocale string, allowed json.RawMessage)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) validatePublicURL(publicURL string) error {
|
||||
if publicURL == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(publicURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return envelope.NewError(envelope.InputError, m.i18n.T("helpCenter.invalidPublicURL"), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) validateColor(color string) error {
|
||||
if !hexColorRe.MatchString(color) {
|
||||
return envelope.NewError(envelope.InputError, m.i18n.T("helpCenter.invalidColor"), nil)
|
||||
@@ -1243,6 +1263,7 @@ func normalizeHelpCenterRequest(req HelpCenterRequest) HelpCenterRequest {
|
||||
if req.Color == "" {
|
||||
req.Color = defaultAccentColor
|
||||
}
|
||||
req.PublicURL = strings.TrimRight(strings.TrimSpace(req.PublicURL), "/")
|
||||
req.LogoURL = sanitizeAssetURL(req.LogoURL)
|
||||
req.NavLinks = normalizeNavLinks(req.NavLinks)
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ type HelpCenter struct {
|
||||
AllowedLocales json.RawMessage `db:"allowed_locales" json:"allowed_locales"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
Theme json.RawMessage `db:"theme" json:"theme"`
|
||||
PublicURL string `db:"public_url" json:"public_url"`
|
||||
}
|
||||
|
||||
// Theme holds the customizable branding for a help center's public pages.
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
-- name: get-all-help-centers
|
||||
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme
|
||||
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme, public_url
|
||||
FROM help_centers
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: get-active-help-centers
|
||||
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme
|
||||
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme, public_url
|
||||
FROM help_centers
|
||||
WHERE is_active = true
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: get-help-center-by-id
|
||||
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme
|
||||
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme, public_url
|
||||
FROM help_centers
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: get-help-center-by-slug
|
||||
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme
|
||||
SELECT id, created_at, updated_at, name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, is_active, theme, public_url
|
||||
FROM help_centers
|
||||
WHERE slug = $1 AND is_active = true;
|
||||
|
||||
-- name: insert-help-center
|
||||
INSERT INTO help_centers (name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, theme)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
INSERT INTO help_centers (name, slug, page_title, header_text, meta_description, logo_url, color, nav_links, custom_css, custom_js, default_locale, allowed_locales, theme, public_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *;
|
||||
|
||||
-- name: update-help-center
|
||||
UPDATE help_centers
|
||||
SET name = $2, slug = $3, page_title = $4, header_text = $5, meta_description = $6, logo_url = $7, color = $8, nav_links = $9, custom_css = $10, custom_js = $11, default_locale = $12, allowed_locales = $13, theme = $14, updated_at = NOW()
|
||||
SET name = $2, slug = $3, page_title = $4, header_text = $5, meta_description = $6, logo_url = $7, color = $8, nav_links = $9, custom_css = $10, custom_js = $11, default_locale = $12, allowed_locales = $13, theme = $14, public_url = $15, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ func V2_7_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
default_locale TEXT NOT NULL DEFAULT 'en',
|
||||
allowed_locales JSONB NOT NULL DEFAULT '["en"]',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
theme JSONB NOT NULL DEFAULT '{}'
|
||||
theme JSONB NOT NULL DEFAULT '{}',
|
||||
public_url TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`); err != nil {
|
||||
return err
|
||||
@@ -42,6 +43,9 @@ func V2_7_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
if _, err := db.Exec(`ALTER TABLE help_centers ADD COLUMN IF NOT EXISTS meta_description TEXT NOT NULL DEFAULT '';`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`ALTER TABLE help_centers ADD COLUMN IF NOT EXISTS public_url TEXT NOT NULL DEFAULT '';`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS article_collections (
|
||||
|
||||
@@ -138,10 +138,17 @@ func captionText(table *html.Node) string {
|
||||
}
|
||||
|
||||
func isHeaderRow(row *html.Node) bool {
|
||||
seen := false
|
||||
for c := row.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.ElementNode && c.Data == "th" {
|
||||
return true
|
||||
if c.Type != html.ElementNode {
|
||||
continue
|
||||
}
|
||||
switch c.Data {
|
||||
case "th":
|
||||
seen = true
|
||||
case "td":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
return seen
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
imgTagRe = regexp.MustCompile(`(?i)<img\b[^>]*>`)
|
||||
imgTagRe = regexp.MustCompile(`(?is)<img\b(?:"[^"]*"|'[^']*'|[^>"'])*>`)
|
||||
|
||||
imgLoadingAttrRe = regexp.MustCompile(`(?i)\bloading\s*=`)
|
||||
|
||||
|
||||
+2
-1
@@ -650,7 +650,8 @@ CREATE TABLE help_centers (
|
||||
default_locale TEXT NOT NULL DEFAULT 'en',
|
||||
allowed_locales JSONB NOT NULL DEFAULT '["en"]',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
theme JSONB NOT NULL DEFAULT '{}'
|
||||
theme JSONB NOT NULL DEFAULT '{}',
|
||||
public_url TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS article_collections CASCADE;
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
<title>{{ .Data.Title }}</title>
|
||||
{{ if .Data.MetaDescription }}<meta name="description" content="{{ .Data.MetaDescription }}" />{{ end }}
|
||||
{{ if .Data.NoIndex }}<meta name="robots" content="noindex" />{{ end }}
|
||||
{{ if .Data.CanonicalPath }}<link rel="canonical" href="{{ RootURL }}{{ .Data.CanonicalPath }}" />{{ end }}
|
||||
{{ range .Data.Alternates }}<link rel="alternate" hreflang="{{ .Locale }}" href="{{ RootURL }}{{ .Path }}" />
|
||||
{{ end }}{{ if .Data.XDefaultPath }}<link rel="alternate" hreflang="x-default" href="{{ RootURL }}{{ .Data.XDefaultPath }}" />{{ end }}
|
||||
{{ if .Data.CanonicalPath }}<link rel="canonical" href="{{ .Data.HelpCenter.BaseURL }}{{ .Data.CanonicalPath }}" />{{ end }}
|
||||
{{ range .Data.Alternates }}<link rel="alternate" hreflang="{{ .Locale }}" href="{{ $.Data.HelpCenter.BaseURL }}{{ .Path }}" />
|
||||
{{ end }}{{ if .Data.XDefaultPath }}<link rel="alternate" hreflang="x-default" href="{{ .Data.HelpCenter.BaseURL }}{{ .Data.XDefaultPath }}" />{{ end }}
|
||||
<meta property="og:title" content="{{ .Data.Title }}" />
|
||||
{{ if .Data.MetaDescription }}<meta property="og:description" content="{{ .Data.MetaDescription }}" />{{ end }}
|
||||
<meta property="og:type" content="{{ if .Data.OGType }}{{ .Data.OGType }}{{ else }}website{{ end }}" />
|
||||
<meta property="og:site_name" content="{{ .Data.HelpCenter.Name }}" />
|
||||
<meta property="og:locale" content="{{ .Data.HelpCenter.OGLocale }}" />
|
||||
{{ if .Data.CanonicalPath }}<meta property="og:url" content="{{ RootURL }}{{ .Data.CanonicalPath }}" />{{ end }}
|
||||
{{ if .Data.CanonicalPath }}<meta property="og:url" content="{{ .Data.HelpCenter.BaseURL }}{{ .Data.CanonicalPath }}" />{{ end }}
|
||||
{{ if .Data.OGImage }}<meta property="og:image" content="{{ .Data.OGImage }}" />{{ end }}
|
||||
{{ if .Data.PublishedTime }}<meta property="article:published_time" content="{{ .Data.PublishedTime }}" />{{ end }}
|
||||
{{ if .Data.ModifiedTime }}<meta property="article:modified_time" content="{{ .Data.ModifiedTime }}" />{{ end }}
|
||||
|
||||
Reference in New Issue
Block a user