diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml
index 760a916e..b62b5acf 100644
--- a/.github/workflows/frontend-ci.yml
+++ b/.github/workflows/frontend-ci.yml
@@ -60,6 +60,8 @@ jobs:
cp config.sample.toml config.toml
sed -i 's/host = "db"/host = "127.0.0.1"/' config.toml
sed -i 's/address = "redis:6379"/address = "localhost:6379"/' config.toml
+ # upload.fs.upload_path is not created by the app, so attachment uploads 500 without it
+ mkdir -p uploads
- name: Run unit tests for frontend
run: cd frontend && pnpm test:run
@@ -70,12 +72,21 @@ jobs:
# E2E runs over plain HTTP, so the Secure session/csrf cookies must be off
# or real login + writes would be rejected.
LIBREDESK_APP__SERVER__DISABLE_SECURE_COOKIES: "true"
+ # The livechat widget suite makes far more than 100 widget requests a minute.
+ LIBREDESK_RATE_LIMIT__WIDGET__REQUESTS_PER_MINUTE: "100000"
+ LIBREDESK_RATE_LIMIT__PUBLIC__REQUESTS_PER_MINUTE: "100000"
CYPRESS_SYSTEM_PASSWORD: "StrongPass!123"
CYPRESS_MAILHOG_URL: "http://localhost:8025"
run: |
./libredesk --install --idempotent-install --yes --config ./config.toml
./libredesk --upgrade --yes --config ./config.toml
./libredesk --config ./config.toml &
- sleep 10
+ for i in $(seq 1 60); do
+ if [ "$(curl --connect-timeout 1 --max-time 2 -s -o /dev/null -w '%{http_code}' http://localhost:9000/ || true)" = "200" ]; then
+ echo "app is up"; break
+ fi
+ if [ "$i" = "60" ]; then echo "app did not start"; exit 1; fi
+ sleep 1
+ done
cd frontend
pnpm run test:e2e:ci
\ No newline at end of file
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index ab32da70..53f28de9 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -6,6 +6,24 @@ jobs:
test:
runs-on: ubuntu-latest
+ services:
+ db:
+ image: postgres:17-alpine
+ ports:
+ - 5432:5432
+ env:
+ POSTGRES_USER: libredesk
+ POSTGRES_PASSWORD: libredesk
+ POSTGRES_DB: libredesk
+ options: >-
+ --health-cmd="pg_isready -U libredesk"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
+
+ env:
+ LIBREDESK_TEST_DB_DSN: "postgres://libredesk:libredesk@127.0.0.1:5432/libredesk?sslmode=disable&connect_timeout=3"
+
steps:
- name: Checkout code
uses: actions/checkout@v4
diff --git a/.github/workflows/pr-welcome.yml b/.github/workflows/pr-welcome.yml
new file mode 100644
index 00000000..14cb59f9
--- /dev/null
+++ b/.github/workflows/pr-welcome.yml
@@ -0,0 +1,23 @@
+name: "welcome-first-time-contributors"
+on:
+ pull_request_target:
+ types: [opened]
+
+permissions:
+ pull-requests: write
+ issues: write
+
+jobs:
+ welcome:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0
+ with:
+ repo_token: ${{ secrets.GITHUB_TOKEN }}
+ # v3 requires this input even though this workflow only runs on pull_request_target.
+ issue_message: |
+ Thanks for opening your first issue in Libredesk. A maintainer will take a look soon.
+ pr_message: |
+ Thanks for opening your first pull request in Libredesk.
+
+ Please give [CONTRIBUTING.md](https://github.com/abhinavxd/libredesk/blob/main/CONTRIBUTING.md) a read if you haven't already, it covers the setup, code style and the checks this PR needs to pass. A maintainer will review this soon.
diff --git a/README.md b/README.md
index 1ee451d2..eb2afc63 100644
--- a/README.md
+++ b/README.md
@@ -4,19 +4,19 @@
Modern, open source, self-hosted omnichannel customer support desk. Live chat, email, and more in a single binary.
-
+
Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**live demo**](https://demo.libredesk.io/).
@@ -27,6 +27,8 @@ Visit [libredesk.io](https://libredesk.io) for more info. Check out the [**live
Live chat and email in one inbox. Every conversation lands in the same place, whichever channel it came from.
- **Live chat widget**
Embed a real-time chat widget on your website. Replies go out from the same inbox your team already works in.
+- **Help center**
+ Publish a searchable knowledge base with collections, articles in multiple languages, and customize it however you want.
- **AI assistant**
Answer live chat conversations automatically with an AI assistant grounded in your knowledge base. Hands off to a human when it can't help.
- **Agent copilot**
@@ -61,6 +63,14 @@ And more — checkout [libredesk.io](https://libredesk.io) or try the [live demo
## Installation
+### Railway (1-click deploy)
+
+The fastest way to get a libredesk instance running. Railway provisions the app, Postgres, and Redis for you.
+
+[](https://railway.com/deploy/libredesk?referralCode=4gdm5b&utm_medium=integration&utm_source=template&utm_campaign=generic)
+
+__________________
+
### Docker
The latest image is available on DockerHub at [`libredesk/libredesk:latest`](https://hub.docker.com/r/libredesk/libredesk/tags?page=1&ordering=last_updated&name=latest)
diff --git a/cmd/chat.go b/cmd/chat.go
index 2ab7e4ad..113a0dd0 100644
--- a/cmd/chat.go
+++ b/cmd/chat.go
@@ -225,8 +225,7 @@ func handleChatInit(r *fastglue.Request) error {
isVisitor = true
visitor, newSessionToken, conversationAttrs, err = createVisitorContact(app, req.FormData, config, inbox)
if err != nil {
- app.lo.Error("error creating visitor contact", "error", err)
- return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
+ return sendErrorEnvelope(r, err)
}
contactID = visitor.ID
}
@@ -334,7 +333,7 @@ func handleChatUpdateLastSeen(r *fastglue.Request) error {
_, conversation, err := getContactConversation(r, conversationUUID)
if err != nil {
- return err
+ return sendErrorEnvelope(r, err)
}
if err := app.conversation.UpdateConversationContactLastSeen(conversation.UUID); err != nil {
@@ -384,10 +383,10 @@ func handleAuthExchange(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "first_name"), nil, envelope.InputError)
}
if len(claims.LastName) > maxNameLength {
- return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxNameLength)), nil, envelope.InputError)
+ return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.name}", "max", strconv.Itoa(maxNameLength)), nil, envelope.InputError)
}
if len(claims.PhoneNumber) > maxPhoneNumberLength {
- return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxPhoneNumberLength)), nil, envelope.InputError)
+ return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.phoneNumber}", "max", strconv.Itoa(maxPhoneNumberLength)), nil, envelope.InputError)
}
// Country code is cosmetic - drop an invalid one instead of failing the whole exchange.
if len(claims.PhoneNumberCountryCode) > maxPhoneCountryCodeLength {
@@ -476,7 +475,7 @@ func handleChatGetConversation(r *fastglue.Request) error {
_, conversation, err := getContactConversation(r, conversationUUID)
if err != nil {
- return err
+ return sendErrorEnvelope(r, err)
}
// Build conversation response with messages and attachments.
@@ -540,7 +539,7 @@ func handleChatSendMessage(r *fastglue.Request) error {
senderID, conversation, err := getContactConversation(r, conversationUUID)
if err != nil {
- return err
+ return sendErrorEnvelope(r, err)
}
if err := canReply(r, conversation); err != nil {
@@ -586,7 +585,7 @@ func handleWidgetMediaUpload(r *fastglue.Request) error {
senderID, conversation, err := getContactConversation(r, conversationUUID)
if err != nil {
- return err
+ return sendErrorEnvelope(r, err)
}
if err := canReply(r, conversation); err != nil {
@@ -714,24 +713,24 @@ func getContactConversation(r *fastglue.Request, conversationUUID string) (int,
contactID, err := getWidgetContactID(r)
if err != nil {
app.lo.Error("error getting contact ID from middleware context", "error", err)
- return 0, cmodels.Conversation{}, r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
+ return 0, cmodels.Conversation{}, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
}
inbox, err := getWidgetInbox(r)
if err != nil {
app.lo.Error("error getting inbox from middleware context", "error", err)
- return 0, cmodels.Conversation{}, r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
+ return 0, cmodels.Conversation{}, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
}
conversation, err := app.conversation.GetConversation(0, conversationUUID, "")
if err != nil {
app.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err)
- return 0, cmodels.Conversation{}, sendErrorEnvelope(r, err)
+ return 0, cmodels.Conversation{}, err
}
if conversation.ContactID != contactID || conversation.InboxID != inbox.ID {
app.lo.Error("unauthorized access to conversation", "conversation_uuid", conversationUUID, "contact_id", contactID, "conversation_contact_id", conversation.ContactID, "session_inbox_id", inbox.ID, "conversation_inbox_id", conversation.InboxID)
- return 0, cmodels.Conversation{}, r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("status.deniedPermission"), nil, envelope.PermissionError)
+ return 0, cmodels.Conversation{}, envelope.NewError(envelope.PermissionError, app.i18n.T("status.deniedPermission"), nil)
}
return contactID, conversation, nil
@@ -807,7 +806,7 @@ func resolveOrCreateExternalContact(app *App, claims Claims) (int, error) {
// createVisitorContact creates a new visitor contact from form data.
func createVisitorContact(app *App, formData map[string]any, config livechat.Config, inbox imodels.Inbox) (umodels.User, string, map[string]any, error) {
// Validate form data and get final name/email/phone for new visitor.
- finalName, finalEmail, finalPhone, finalPhoneCountryCode, err := validateFormData(formData, config, nil)
+ finalName, finalEmail, finalPhone, finalPhoneCountryCode, err := validateFormData(app, formData, config, nil)
if err != nil {
return umodels.User{}, "", nil, err
}
@@ -825,13 +824,13 @@ func createVisitorContact(app *App, formData map[string]any, config livechat.Con
if err := app.user.CreateVisitor(&visitor); err != nil {
app.lo.Error("error creating visitor contact", "error", err)
- return umodels.User{}, "", nil, err
+ return umodels.User{}, "", nil, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
}
token, err := generateSessionToken(app, visitor.ID, inbox.ID, true, "", defaultSessionTTL)
if err != nil {
app.lo.Error("error generating session token for visitor", "error", err)
- return umodels.User{}, "", nil, err
+ return umodels.User{}, "", nil, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
}
return visitor, token, formConvoAttrs, nil
@@ -1149,7 +1148,7 @@ func validateAttributeValue(key string, value any, app *App) any {
}
// validateFormData returns the final name/email/phone/phone country code to persist from the pre-chat form.
-func validateFormData(formData map[string]any, config livechat.Config, existingUser *umodels.User) (string, string, string, string, error) {
+func validateFormData(app *App, formData map[string]any, config livechat.Config, existingUser *umodels.User) (string, string, string, string, error) {
var finalName, finalEmail, finalPhone, finalPhoneCountryCode string
if !config.PreChatForm.Enabled {
@@ -1173,35 +1172,35 @@ func validateFormData(formData map[string]any, config livechat.Config, existingU
case "name":
finalName = resolveFormField(formData, field.Key, exName)
if field.Required && finalName == "" {
- return "", "", "", "", fmt.Errorf("name is required")
+ return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.name}"), nil)
}
if len(finalName) > maxNameLength {
- return "", "", "", "", fmt.Errorf("name too long")
+ return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.name}", "max", strconv.Itoa(maxNameLength)), nil)
}
case "email":
finalEmail = resolveFormField(formData, field.Key, exEmail)
if field.Required && finalEmail == "" {
- return "", "", "", "", fmt.Errorf("email is required")
+ return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.email}"), nil)
}
if len(finalEmail) > maxEmailLength {
- return "", "", "", "", fmt.Errorf("email too long")
+ return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.email}", "max", strconv.Itoa(maxEmailLength)), nil)
}
if finalEmail != "" && !stringutil.ValidEmail(finalEmail) {
- return "", "", "", "", fmt.Errorf("invalid email format")
+ return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidEmail"), nil)
}
case fieldTypePhone:
finalPhone = resolveFormField(formData, field.Key, exPhone)
finalPhoneCountryCode = resolveFormField(formData, field.Key+phoneCountryCodeSuffix, exPhoneCountryCode)
if field.Required && (finalPhone == "" || finalPhoneCountryCode == "") {
- return "", "", "", "", fmt.Errorf("phone is required")
+ return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.phoneNumber}"), nil)
}
if len(finalPhone) > maxPhoneNumberLength {
- return "", "", "", "", fmt.Errorf("phone too long")
+ return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.phoneNumber}", "max", strconv.Itoa(maxPhoneNumberLength)), nil)
}
if len(finalPhoneCountryCode) > maxPhoneCountryCodeLength {
- return "", "", "", "", fmt.Errorf("phone country code too long")
+ return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.countryCode}", "max", strconv.Itoa(maxPhoneCountryCodeLength)), nil)
}
if finalPhone == "" {
finalPhoneCountryCode = ""
diff --git a/cmd/context_links.go b/cmd/context_links.go
index 76bc90f0..ca5cc6ad 100644
--- a/cmd/context_links.go
+++ b/cmd/context_links.go
@@ -53,7 +53,7 @@ func handleCreateContextLink(r *fastglue.Request) error {
if err := r.Decode(&contextLink, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), err.Error(), envelope.InputError)
}
- if err := validateContextLink(app, contextLink); err != nil {
+ if err := validateContextLink(app, &contextLink); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -77,7 +77,7 @@ func handleUpdateContextLink(r *fastglue.Request) error {
if err := r.Decode(&contextLink, "json"); err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), err.Error(), envelope.InputError)
}
- if err := validateContextLink(app, contextLink); err != nil {
+ if err := validateContextLink(app, &contextLink); err != nil {
return sendErrorEnvelope(r, err)
}
@@ -163,7 +163,7 @@ func handleGetContextLinkURL(r *fastglue.Request) error {
return r.SendEnvelope(url)
}
-func validateContextLink(app *App, contextLink models.ContextLink) error {
+func validateContextLink(app *App, contextLink *models.ContextLink) error {
if contextLink.Name == "" {
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
diff --git a/cmd/handlers.go b/cmd/handlers.go
index 7d205c9c..b3c41138 100644
--- a/cmd/handlers.go
+++ b/cmd/handlers.go
@@ -37,7 +37,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Settings.
g.GET("/api/v1/settings/general", auth(handleGetGeneralSettings))
- g.PUT("/api/v1/settings/general", perm(handleUpdateGeneralSettings, "general_settings:manage"))
+ g.PUT("/api/v1/settings/general", perm(clearsHCCache(handleUpdateGeneralSettings), "general_settings:manage"))
g.GET("/api/v1/settings/notifications/email", perm(handleGetEmailNotificationSettings, "notification_settings:manage"))
g.PUT("/api/v1/settings/notifications/email", perm(handleUpdateEmailNotificationSettings, "notification_settings:manage"))
@@ -125,18 +125,18 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Agents.
g.GET("/api/v1/agents/me", auth(handleGetCurrentAgent))
- g.PUT("/api/v1/agents/me", auth(handleUpdateCurrentAgent))
+ g.PUT("/api/v1/agents/me", auth(clearsHCCache(handleUpdateCurrentAgent)))
g.GET("/api/v1/agents/me/teams", auth(handleGetCurrentAgentTeams))
g.PUT("/api/v1/agents/me/availability", auth(handleUpdateAgentAvailability))
- g.DELETE("/api/v1/agents/me/avatar", auth(handleDeleteCurrentAgentAvatar))
+ g.DELETE("/api/v1/agents/me/avatar", auth(clearsHCCache(handleDeleteCurrentAgentAvatar)))
g.GET("/api/v1/agents/compact", auth(handleGetAgentsCompact))
g.GET("/api/v1/agents", perm(handleGetAgents, "users:manage"))
g.GET("/api/v1/agents/{id}", perm(handleGetAgent, "users:manage"))
g.POST("/api/v1/agents", perm(handleCreateAgent, "users:manage"))
- g.PUT("/api/v1/agents/{id}", perm(handleUpdateAgent, "users:manage"))
- g.DELETE("/api/v1/agents/{id}", perm(handleDeleteAgent, "users:manage"))
- g.POST("/api/v1/agents/import", perm(handleImportAgents, "users:manage"))
+ g.PUT("/api/v1/agents/{id}", perm(clearsHCCache(handleUpdateAgent), "users:manage"))
+ g.DELETE("/api/v1/agents/{id}", perm(clearsHCCache(handleDeleteAgent), "users:manage"))
+ g.POST("/api/v1/agents/import", perm(clearsHCCache(handleImportAgents), "users:manage"))
g.GET("/api/v1/agents/import/status", perm(handleGetAgentImportStatus, "users:manage"))
g.POST("/api/v1/agents/{id}/api-key", perm(handleGenerateAPIKey, "users:manage"))
g.DELETE("/api/v1/agents/{id}/api-key", perm(handleRevokeAPIKey, "users:manage"))
@@ -278,8 +278,8 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/ai/assistants", perm(handleGetAIAssistants, "ai:manage"))
g.GET("/api/v1/ai/assistants/{id}", perm(handleGetAIAssistant, "ai:manage"))
g.POST("/api/v1/ai/assistants", perm(handleCreateAIAssistant, "ai:manage"))
- g.PUT("/api/v1/ai/assistants/{id}", perm(handleUpdateAIAssistant, "ai:manage"))
- g.DELETE("/api/v1/ai/assistants/{id}", perm(handleDeleteAIAssistant, "ai:manage"))
+ g.PUT("/api/v1/ai/assistants/{id}", perm(clearsHCCache(handleUpdateAIAssistant), "ai:manage"))
+ g.DELETE("/api/v1/ai/assistants/{id}", perm(clearsHCCache(handleDeleteAIAssistant), "ai:manage"))
g.POST("/api/v1/ai/assistants/{id}/preview", perm(handleAIAssistantPreview, "ai:manage"))
g.GET("/api/v1/ai/assistants/{id}/stats", perm(handleGetAIAssistantStats, "ai:manage"))
@@ -295,31 +295,31 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/help-centers/locales", perm(handleGetHelpCenterLocales, "help_center:manage"))
g.GET("/api/v1/help-centers/{id}", perm(handleGetHelpCenter, "help_center:manage"))
g.GET("/api/v1/help-centers/{id}/tree", perm(handleGetHelpCenterTree, "help_center:manage"))
- g.POST("/api/v1/help-centers", perm(handleCreateHelpCenter, "help_center:manage"))
- g.PUT("/api/v1/help-centers/{id}", perm(handleUpdateHelpCenter, "help_center:manage"))
+ g.POST("/api/v1/help-centers", perm(clearsHCCache(handleCreateHelpCenter), "help_center:manage"))
+ g.PUT("/api/v1/help-centers/{id}", perm(clearsHCCache(handleUpdateHelpCenter), "help_center:manage"))
g.POST("/api/v1/help-centers/{id}/preview", perm(handleHelpCenterPreview, "help_center:manage"))
- g.PUT("/api/v1/help-centers/{id}/toggle", perm(handleToggleHelpCenterActive, "help_center:manage"))
- g.DELETE("/api/v1/help-centers/{id}", perm(handleDeleteHelpCenter, "help_center:manage"))
+ g.PUT("/api/v1/help-centers/{id}/toggle", perm(clearsHCCache(handleToggleHelpCenterActive), "help_center:manage"))
+ g.DELETE("/api/v1/help-centers/{id}", perm(clearsHCCache(handleDeleteHelpCenter), "help_center:manage"))
g.GET("/api/v1/help-centers/{hc_id}/collections", perm(handleGetCollections, "help_center:manage"))
- g.POST("/api/v1/help-centers/{hc_id}/collections", perm(handleCreateCollection, "help_center:manage"))
- g.PUT("/api/v1/help-centers/{hc_id}/collections/{id}", perm(handleUpdateCollection, "help_center:manage"))
- g.DELETE("/api/v1/help-centers/{hc_id}/collections/{id}", perm(handleDeleteCollection, "help_center:manage"))
- g.PUT("/api/v1/help-centers/{hc_id}/collection-sort-order", perm(handleUpdateCollectionSortOrders, "help_center:manage"))
- g.PUT("/api/v1/collections/{id}/toggle", perm(handleToggleCollection, "help_center:manage"))
- g.PUT("/api/v1/collections/{col_id}/article-sort-order", perm(handleUpdateArticleSortOrders, "help_center:manage"))
+ g.POST("/api/v1/help-centers/{hc_id}/collections", perm(clearsHCCache(handleCreateCollection), "help_center:manage"))
+ g.PUT("/api/v1/help-centers/{hc_id}/collections/{id}", perm(clearsHCCache(handleUpdateCollection), "help_center:manage"))
+ g.DELETE("/api/v1/help-centers/{hc_id}/collections/{id}", perm(clearsHCCache(handleDeleteCollection), "help_center:manage"))
+ g.PUT("/api/v1/help-centers/{hc_id}/collection-sort-order", perm(clearsHCCache(handleUpdateCollectionSortOrders), "help_center:manage"))
+ g.PUT("/api/v1/collections/{id}/toggle", perm(clearsHCCache(handleToggleCollection), "help_center:manage"))
+ g.PUT("/api/v1/collections/{col_id}/article-sort-order", perm(clearsHCCache(handleUpdateArticleSortOrders), "help_center:manage"))
g.GET("/api/v1/collections/{col_id}/articles/{id}", perm(handleGetArticle, "help_center:manage"))
- g.POST("/api/v1/collections/{col_id}/articles", perm(handleCreateArticle, "help_center:manage"))
- g.DELETE("/api/v1/collections/{col_id}/articles/{id}", perm(handleDeleteArticle, "help_center:manage"))
- g.PUT("/api/v1/articles/{id}", perm(handleUpdateArticle, "help_center:manage"))
- g.PUT("/api/v1/articles/{id}/collection", perm(handleMoveArticle, "help_center:manage"))
- g.PUT("/api/v1/articles/{id}/status", perm(handleUpdateArticleStatus, "help_center:manage"))
+ g.POST("/api/v1/collections/{col_id}/articles", perm(clearsHCCache(handleCreateArticle), "help_center:manage"))
+ g.DELETE("/api/v1/collections/{col_id}/articles/{id}", perm(clearsHCCache(handleDeleteArticle), "help_center:manage"))
+ g.PUT("/api/v1/articles/{id}", perm(clearsHCCache(handleUpdateArticle), "help_center:manage"))
+ g.PUT("/api/v1/articles/{id}/collection", perm(clearsHCCache(handleMoveArticle), "help_center:manage"))
+ g.PUT("/api/v1/articles/{id}/status", perm(clearsHCCache(handleUpdateArticleStatus), "help_center:manage"))
g.GET("/api/v1/help-centers/{id}/insights", perm(handleGetHelpCenterInsights, "help_center:manage"))
// Public help center JSON API.
- g.GET("/api/public/help-centers/{slug}/tree", rateLimit(handleGetPublicHelpCenterTree, "public"))
- g.GET("/api/public/help-centers/{slug}/articles/{article_slug}", rateLimit(handleGetPublicHelpCenterArticle, "public"))
- g.GET("/api/public/help-centers/{slug}/search", rateLimit(handlePublicHelpCenterSearch, "public"))
- g.POST("/api/public/help-centers/{slug}/articles/{article_slug}/feedback", rateLimit(handleHelpCenterArticleFeedback, "public"))
+ g.GET("/api/v1/public/help-centers/{slug}/tree", rateLimit(handleGetPublicHelpCenterTree, "public"))
+ g.GET("/api/v1/public/help-centers/{slug}/articles/{article_slug}", rateLimit(handleGetPublicHelpCenterArticle, "public"))
+ g.GET("/api/v1/public/help-centers/{slug}/search", rateLimit(handlePublicHelpCenterSearch, "public"))
+ g.POST("/api/v1/public/help-centers/{slug}/articles/{article_slug}/feedback", rateLimit(handleHelpCenterArticleFeedback, "public"))
// Custom attributes.
g.GET("/api/v1/custom-attributes", auth(handleGetCustomAttributes))
@@ -404,11 +404,11 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
getAndHead("/robots.txt", rateLimit(handleRobotsTxt, "public"))
getAndHead("/sitemap.xml", rateLimit(handleSitemapIndex, "public"))
getAndHead("/hc/{slug}", rateLimit(handleRedirectHelpCenterHome, "public"))
- getAndHead("/hc/{slug}/{locale}", rateLimit(handleShowHelpCenterHome, "public"))
+ getAndHead("/hc/{slug}/{locale}", rateLimit(cachedHCPage(handleShowHelpCenterHome), "public"))
getAndHead("/hc/{slug}/{locale}/sitemap.xml", rateLimit(handleHelpCenterSitemap, "public"))
- getAndHead("/hc/{slug}/{locale}/search", rateLimit(handleHelpCenterSearch, "public"))
- getAndHead("/hc/{slug}/{locale}/collections/{collection_slug}", rateLimit(handleShowHelpCenterCollection, "public"))
- getAndHead("/hc/{slug}/{locale}/articles/{article_slug}", rateLimit(handleShowHelpCenterArticle, "public"))
+ getAndHead("/hc/{slug}/{locale}/search", rateLimit(cachedHCNoIndexPage(handleHelpCenterSearch), "public"))
+ getAndHead("/hc/{slug}/{locale}/collections/{collection_slug}", rateLimit(cachedHCPage(handleShowHelpCenterCollection), "public"))
+ getAndHead("/hc/{slug}/{locale}/articles/{article_slug}", rateLimit(countArticleView(cachedHCPage(handleShowHelpCenterArticle)), "public"))
g.GET("/csat/{uuid}", rateLimit(handleShowCSAT, "public"))
g.GET("/csat/{uuid}/widget", rateLimit(handleShowCSATWidget, "public"))
@@ -433,7 +433,7 @@ func serveIndexPage(r *fastglue.Request) error {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError)
}
r.RequestCtx.Response.Header.Set("Content-Type", "text/html")
- r.RequestCtx.SetBody(file.ReadBytes())
+ r.RequestCtx.Response.SetBodyRaw(file.ReadBytes())
// Set CSRF cookie if not already set.
if err := app.auth.SetCSRFCookie(r); err != nil {
@@ -464,7 +464,7 @@ func serveWidgetIndexPage(r *fastglue.Request) error {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError)
}
r.RequestCtx.Response.Header.Set("Content-Type", "text/html")
- r.RequestCtx.SetBody(file.ReadBytes())
+ r.RequestCtx.Response.SetBodyRaw(file.ReadBytes())
return nil
}
@@ -480,14 +480,15 @@ func serveStaticFiles(r *fastglue.Request) error {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError)
}
+ body := file.ReadBytes()
ext := filepath.Ext(filePath)
contentType := mime.TypeByExtension(ext)
if contentType == "" {
- contentType = http.DetectContentType(file.ReadBytes())
+ contentType = http.DetectContentType(body)
}
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
r.RequestCtx.Response.Header.Set("Cache-Control", "public, max-age=86400")
- r.RequestCtx.SetBody(file.ReadBytes())
+ r.RequestCtx.Response.SetBodyRaw(body)
return nil
}
@@ -505,14 +506,14 @@ func serveFrontendStaticFiles(r *fastglue.Request) error {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError)
}
- // Set the appropriate Content-Type based on the file extension.
+ body := file.ReadBytes()
ext := filepath.Ext(filePath)
contentType := mime.TypeByExtension(ext)
if contentType == "" {
- contentType = http.DetectContentType(file.ReadBytes())
+ contentType = http.DetectContentType(body)
}
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
- r.RequestCtx.SetBody(file.ReadBytes())
+ r.RequestCtx.Response.SetBodyRaw(body)
return nil
}
@@ -528,14 +529,14 @@ func serveWidgetStaticFiles(r *fastglue.Request) error {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError)
}
- // Set the appropriate Content-Type based on the file extension.
+ body := file.ReadBytes()
ext := filepath.Ext(filePath)
contentType := mime.TypeByExtension(ext)
if contentType == "" {
- contentType = http.DetectContentType(file.ReadBytes())
+ contentType = http.DetectContentType(body)
}
r.RequestCtx.Response.Header.Set("Content-Type", contentType)
- r.RequestCtx.SetBody(file.ReadBytes())
+ r.RequestCtx.Response.SetBodyRaw(body)
return nil
}
@@ -551,7 +552,7 @@ func serveWidgetJS(r *fastglue.Request) error {
return r.SendErrorEnvelope(http.StatusNotFound, app.i18n.T("validation.notFoundFile"), nil, envelope.NotFoundError)
}
- r.RequestCtx.SetBody(file.ReadBytes())
+ r.RequestCtx.Response.SetBodyRaw(file.ReadBytes())
return nil
}
diff --git a/cmd/helpcenter.go b/cmd/helpcenter.go
index aec7e385..b5391334 100644
--- a/cmd/helpcenter.go
+++ b/cmd/helpcenter.go
@@ -6,6 +6,7 @@ import (
"fmt"
"hash/fnv"
"html/template"
+ "io"
"log"
"math"
"net"
@@ -25,7 +26,9 @@ import (
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 (
@@ -43,7 +46,18 @@ const (
sitemapDate = "2006-01-02"
schemaOrgContext = "https://schema.org"
- helpCenterCacheControl = "public, max-age=300, stale-while-revalidate=3600"
+ 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
@@ -60,6 +74,15 @@ const (
)
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/`)
@@ -83,6 +106,11 @@ var (
}
)
+// 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"`
@@ -115,6 +143,11 @@ type previewTOCItem struct {
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())
@@ -155,8 +188,8 @@ func handleCreateHelpCenter(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
- if err := validateHelpCenter(r, &req); err != nil {
- return err
+ if err := validateHelpCenter(app, &req); err != nil {
+ return sendErrorEnvelope(r, err)
}
helpCenter, err := app.helpcenter.CreateHelpCenter(req)
if err != nil {
@@ -178,8 +211,8 @@ func handleUpdateHelpCenter(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
- if err := validateHelpCenter(r, &req); err != nil {
- return err
+ if err := validateHelpCenter(app, &req); err != nil {
+ return sendErrorEnvelope(r, err)
}
helpCenter, err := app.helpcenter.UpdateHelpCenter(id, req)
if err != nil {
@@ -310,8 +343,8 @@ func handleCreateCollection(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
- if err := validateCollection(r, &req); err != nil {
- return err
+ if err := validateCollection(app, &req); err != nil {
+ return sendErrorEnvelope(r, err)
}
req.Slug = helpcenter.GenerateSlug(req.Name)
collection, err := app.helpcenter.CreateCollection(helpCenterID, req)
@@ -334,8 +367,8 @@ func handleUpdateCollection(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
- if err := validateCollection(r, &req); err != nil {
- return err
+ if err := validateCollection(app, &req); err != nil {
+ return sendErrorEnvelope(r, err)
}
existing, err := app.helpcenter.GetCollectionByID(id)
if err != nil {
@@ -428,8 +461,8 @@ func handleCreateArticle(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
- if err := validateArticle(r, &req); err != nil {
- return err
+ if err := validateArticle(app, &req); err != nil {
+ return sendErrorEnvelope(r, err)
}
req.Slug = helpcenter.GenerateSlug(req.Title)
req.CollectionID = nil
@@ -689,12 +722,8 @@ func handleShowHelpCenterArticle(r *fastglue.Request) error {
}
// The JSON-LD embeds the author too, not just the byline.
hideArticleAuthor(helpCenterTheme(helpCenter), &article)
- if !isCrawler(r) {
- app.helpcenter.IncrementArticleViewCount(article.ID)
- }
if markdown {
- r.RequestCtx.Response.Header.Set("X-Robots-Tag", noIndexHeader)
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
@@ -778,8 +807,7 @@ func handleHelpCenterSearch(r *fastglue.Request) error {
lcl = localeI18n(app, locale)
pathFor = func(l string) string { return searchPath(helpCenter, l) }
)
- r.RequestCtx.Response.Header.Set("X-Robots-Tag", noIndexHeader)
- return app.tmpl.RenderWebPage(r.RequestCtx, hcPageName(helpCenter, "help-search"), map[string]interface{}{
+ 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),
@@ -1017,8 +1045,8 @@ func handleUpdateArticle(r *fastglue.Request) error {
if err := r.Decode(&req, "json"); err != nil {
return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.T("errors.parsingRequest"), nil))
}
- if err := validateArticle(r, &req); err != nil {
- return err
+ if err := validateArticle(app, &req); err != nil {
+ return sendErrorEnvelope(r, err)
}
existing, err := app.helpcenter.GetArticleByID(id)
if err != nil {
@@ -1055,11 +1083,13 @@ func handleUpdateArticle(r *fastglue.Request) error {
// 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)
- err := app.tmpl.RenderWebPage(r.RequestCtx, name, data)
+ 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 err
+ return nil
}
// sendXML writes v as an XML document.
@@ -1069,12 +1099,88 @@ func sendXML(r *fastglue.Request, v any) error {
return sendErrorEnvelope(r, err)
}
r.RequestCtx.SetContentType("application/xml; charset=utf-8")
- r.RequestCtx.Response.Header.Set("Cache-Control", helpCenterCacheControl)
+ 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())
@@ -1711,36 +1817,39 @@ func renderHelpCenterStatusPage(r *fastglue.Request, hc *hcmodels.HelpCenter, st
return rerr
}
-func validateHelpCenter(r *fastglue.Request, req *helpcenter.HelpCenterRequest) error {
- app := r.Context.(*App)
+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 r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
+ return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
if req.Slug == "" {
- return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`slug`"), nil, envelope.InputError)
+ return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`slug`"), nil)
}
if req.PageTitle == "" {
- return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`page_title`"), nil, envelope.InputError)
+ 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(r *fastglue.Request, req *helpcenter.CollectionRequest) error {
- app := r.Context.(*App)
+func validateCollection(app *App, req *helpcenter.CollectionRequest) error {
+ req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
- return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError)
+ return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
}
return nil
}
-func validateArticle(r *fastglue.Request, req *helpcenter.ArticleRequest) error {
- app := r.Context.(*App)
+func validateArticle(app *App, req *helpcenter.ArticleRequest) error {
+ req.Title = strings.TrimSpace(req.Title)
if req.Title == "" {
- return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`title`"), nil, envelope.InputError)
+ return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`title`"), nil)
}
- if req.Content == "" {
- return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`content`"), nil, envelope.InputError)
+ 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)
diff --git a/cmd/helpcenter_cache_test.go b/cmd/helpcenter_cache_test.go
new file mode 100644
index 00000000..e7acbb1e
--- /dev/null
+++ b/cmd/helpcenter_cache_test.go
@@ -0,0 +1,216 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/alicebob/miniredis/v2"
+ "github.com/redis/go-redis/v9"
+ "github.com/valyala/fasthttp"
+ goredis "github.com/zerodha/fastcache/stores/goredis/v9"
+ "github.com/zerodha/fastcache/v4"
+ "github.com/zerodha/fastglue"
+)
+
+func TestHelpCenterPageCacheServesAndInvalidates(t *testing.T) {
+ fc := newTestFastCache(t)
+
+ renders := 0
+ body := "
How refunds work
"}, false}, + {"empty title", helpcenter.ArticleRequest{Content: "body
"}, true}, + {"whitespace title", helpcenter.ArticleRequest{Title: " ", Content: "body
"}, true}, + {"empty content", helpcenter.ArticleRequest{Title: "Refunds"}, true}, + {"whitespace content", helpcenter.ArticleRequest{Title: "Refunds", Content: " \n\t "}, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertValidation(t, validateArticle(app, &tc.req), tc.wantErr) + }) + } +} + +func TestValidateArticleStripsBaseURLFromAssets(t *testing.T) { + app := newValidatorTestApp(t) + req := helpcenter.ArticleRequest{ + Title: " Refunds ", + Content: `
`,
+ MetaImageURL: testAppBaseURL + "/uploads/meta.png",
+ }
+
+ if err := validateArticle(app, &req); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if req.Title != "Refunds" {
+ t.Fatalf("got title %q, want Refunds", req.Title)
+ }
+ if got, want := req.Content, `
`; got != want {
+ t.Fatalf("got content %q, want %q", got, want)
+ }
+ if got, want := req.MetaImageURL, "/uploads/meta.png"; got != want {
+ t.Fatalf("got meta image url %q, want %q", got, want)
+ }
+}
+
+func TestValidateMacro(t *testing.T) {
+ app := newValidatorTestApp(t)
+
+ actions := func(a ...autoModels.RuleAction) json.RawMessage {
+ b, err := json.Marshal(a)
+ if err != nil {
+ t.Fatalf("marshalling actions: %v", err)
+ }
+ return b
+ }
+
+ tests := []struct {
+ name string
+ macro macromodels.Macro
+ wantErr bool
+ }{
+ {"valid", macromodels.Macro{Name: "Close", VisibleWhen: []string{"replying"}, Actions: actions(autoModels.RuleAction{Type: autoModels.ActionSetStatus, Value: []string{"Closed"}})}, false},
+ {"valid no actions", macromodels.Macro{Name: "Close", VisibleWhen: []string{"replying"}, Actions: json.RawMessage(`[]`)}, false},
+ {"empty name", macromodels.Macro{VisibleWhen: []string{"replying"}, Actions: json.RawMessage(`[]`)}, true},
+ {"nil visible when", macromodels.Macro{Name: "Close", Actions: json.RawMessage(`[]`)}, true},
+ {"empty visible when", macromodels.Macro{Name: "Close", VisibleWhen: []string{}, Actions: json.RawMessage(`[]`)}, true},
+ {"malformed actions json", macromodels.Macro{Name: "Close", VisibleWhen: []string{"replying"}, Actions: json.RawMessage(`{`)}, true},
+ {"action with no value", macromodels.Macro{Name: "Close", VisibleWhen: []string{"replying"}, Actions: actions(autoModels.RuleAction{Type: autoModels.ActionSetStatus})}, true},
+ {"second action with no value", macromodels.Macro{Name: "Close", VisibleWhen: []string{"replying"}, Actions: actions(
+ autoModels.RuleAction{Type: autoModels.ActionSetStatus, Value: []string{"Closed"}},
+ autoModels.RuleAction{Type: autoModels.ActionAddTags, Value: []string{}},
+ )}, true},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ assertValidation(t, validateMacro(app, tc.macro), tc.wantErr)
+ })
+ }
+}
+
+func newValidatorTestApp(t *testing.T) *App {
+ t.Helper()
+ lo := logf.New(logf.Opts{Writer: io.Discard})
+ app := &App{
+ i18n: testutil.NewI18n(t),
+ lo: &lo,
+ }
+ app.consts.Store(&constants{AppBaseURL: testAppBaseURL})
+ return app
+}
+
+func assertValidation(t *testing.T, err error, wantErr bool) {
+ t.Helper()
+ if !wantErr {
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ return
+ }
+ if err == nil {
+ t.Fatal("expected a validation error, got nil")
+ }
+ envErr, ok := err.(envelope.Error)
+ if !ok {
+ t.Fatalf("got %T, want envelope.Error", err)
+ }
+ if envErr.ErrorType != envelope.InputError {
+ t.Fatalf("got error type %q, want %q", envErr.ErrorType, envelope.InputError)
+ }
+ if envErr.Message == "" {
+ t.Fatal("expected a non-empty error message")
+ }
+}
diff --git a/cmd/webhooks.go b/cmd/webhooks.go
index 7bd48c29..87b3d81d 100644
--- a/cmd/webhooks.go
+++ b/cmd/webhooks.go
@@ -76,7 +76,7 @@ func handleCreateWebhook(r *fastglue.Request) error {
// Validate webhook fields
if err := validateWebhook(app, webhook); err != nil {
- return r.SendEnvelope(err)
+ return sendErrorEnvelope(r, err)
}
webhook, err := app.webhook.Create(webhook)
@@ -108,7 +108,7 @@ func handleUpdateWebhook(r *fastglue.Request) error {
// Validate webhook fields
if err := validateWebhook(app, webhook); err != nil {
- return r.SendEnvelope(err)
+ return sendErrorEnvelope(r, err)
}
updatedWebhook, err := app.webhook.Update(id, webhook)
diff --git a/cmd/websocket.go b/cmd/websocket.go
index 90d44401..967acc76 100644
--- a/cmd/websocket.go
+++ b/cmd/websocket.go
@@ -1,9 +1,9 @@
package main
import (
- "fmt"
"net/url"
"strings"
+ "sync"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
"github.com/abhinavxd/libredesk/internal/ws"
@@ -13,15 +13,13 @@ import (
"github.com/zerodha/fastglue"
)
-// ErrHandler is a custom error handler.
-func ErrHandler(ctx *fasthttp.RequestCtx, status int, reason error) {
- fmt.Printf("error status %d: %s", status, reason)
-}
+var wsWriteBufferPool = &sync.Pool{}
// agentUpgrader: same-origin only, with loopback allowed for dev.
var agentUpgrader = websocket.FastHTTPUpgrader{
ReadBufferSize: 8192,
WriteBufferSize: 8192,
+ WriteBufferPool: wsWriteBufferPool,
CheckOrigin: func(ctx *fasthttp.RequestCtx) bool {
origin := string(ctx.Request.Header.Peek("Origin"))
if origin == "" {
@@ -47,12 +45,18 @@ var agentUpgrader = websocket.FastHTTPUpgrader{
var widgetUpgrader = websocket.FastHTTPUpgrader{
ReadBufferSize: 8192,
WriteBufferSize: 8192,
+ WriteBufferPool: wsWriteBufferPool,
CheckOrigin: func(ctx *fasthttp.RequestCtx) bool {
return true
},
Error: ErrHandler,
}
+// ErrHandler writes the handshake failure response.
+func ErrHandler(ctx *fasthttp.RequestCtx, status int, reason error) {
+ ctx.Error(reason.Error(), status)
+}
+
// handleWS handles the websocket connection.
func handleWS(r *fastglue.Request, hub *ws.Hub) error {
var (
diff --git a/cmd/widget_ws.go b/cmd/widget_ws.go
index 340e133d..83088114 100644
--- a/cmd/widget_ws.go
+++ b/cmd/widget_ws.go
@@ -29,6 +29,7 @@ const (
maxPageVisits = 20
pageVisitTTL = 24 * time.Hour
wsReadDeadline = 20 * time.Second
+ wsWriteDeadline = 10 * time.Second
wsReadLimitBytes = 64 * 1024
// Per-connection minimum intervals between inbound frames of each kind.
@@ -71,16 +72,27 @@ type safeConn struct {
lastAt map[string]time.Time
}
+// WriteJSON and WriteMessage set a deadline first; a peer that stops reading would otherwise block the caller while holding sc.mu.
func (sc *safeConn) WriteJSON(v any) error {
sc.mu.Lock()
defer sc.mu.Unlock()
- return sc.conn.WriteJSON(v)
+ sc.conn.SetWriteDeadline(time.Now().Add(wsWriteDeadline))
+ if err := sc.conn.WriteJSON(v); err != nil {
+ sc.conn.Close()
+ return err
+ }
+ return nil
}
func (sc *safeConn) WriteMessage(msgType int, data []byte) error {
sc.mu.Lock()
defer sc.mu.Unlock()
- return sc.conn.WriteMessage(msgType, data)
+ sc.conn.SetWriteDeadline(time.Now().Add(wsWriteDeadline))
+ if err := sc.conn.WriteMessage(msgType, data); err != nil {
+ sc.conn.Close()
+ return err
+ }
+ return nil
}
// allow throttles abusive clients that flood typing/page_visit/ping frames.
@@ -108,8 +120,8 @@ func handleWidgetWS(r *fastglue.Request) error {
sc := &safeConn{conn: conn}
var (
- client *livechat.Client
- liveChat *livechat.LiveChat
+ client *livechat.Client
+ liveChat *livechat.LiveChat
inboxUUID string
userID int
)
@@ -124,6 +136,13 @@ func handleWidgetWS(r *fastglue.Request) error {
for {
conn.SetReadDeadline(time.Now().Add(wsReadDeadline))
+
+ // Checked after the refresh: CloseChannel marks the client before disconnect() expires the deadline, so
+ // either this sees it or the expiry landed after the refresh and ReadJSON returns immediately.
+ if client != nil && client.IsClosed() {
+ break
+ }
+
var msg WidgetMessage
if err := conn.ReadJSON(&msg); err != nil {
app.lo.Debug("widget websocket connection closed", "error", err)
@@ -136,9 +155,8 @@ func handleWidgetWS(r *fastglue.Request) error {
if client != nil && liveChat != nil {
liveChat.RemoveClient(client)
client.CloseChannel()
- client = nil
- liveChat = nil
}
+ client, liveChat, inboxUUID, userID = nil, nil, "", 0
joinedClient, joinedLiveChat, joinedInboxUUID, joinedUserID, err := handleInboxJoin(app, sc, msg.Data, msg.Token, clientIP)
if err != nil {
@@ -235,7 +253,10 @@ func handleInboxJoin(app *App, sc *safeConn, data json.RawMessage, token, client
}
userIDStr := fmt.Sprintf("%d", user.ID)
- client, err := liveChat.AddClient(userIDStr)
+ // fasthttp makes Close a no-op on a hijacked conn; expiring the read deadline is what drops it.
+ client, err := liveChat.AddClient(userIDStr, func() {
+ sc.conn.SetReadDeadline(time.Now())
+ })
if err != nil {
return nil, nil, "", 0, fmt.Errorf("adding client to live chat: %w", err)
}
@@ -258,6 +279,8 @@ func handleInboxJoin(app *App, sc *safeConn, data json.RawMessage, token, client
Type: WidgetMsgTypeJoined,
Data: json.RawMessage(`{"message":"namaste!"}`),
}); err != nil {
+ liveChat.RemoveClient(client)
+ client.CloseChannel()
return nil, nil, "", 0, err
}
diff --git a/config.sample.toml b/config.sample.toml
index 245531c9..50d5bcc7 100644
--- a/config.sample.toml
+++ b/config.sample.toml
@@ -24,7 +24,7 @@ disable_secure_cookies = false
# Session lifetime duration.
session_lifetime = "9h"
# Request read and write timeouts.
-read_timeout = "8s"
+read_timeout = "60s"
write_timeout = "60s"
# Maximum request body size in bytes (100MB)
# If you are using proxy, you may need to configure them to allow larger request bodies.
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index 036e7cfa..06e10dbb 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -92,7 +92,7 @@ In light mode `card` is pure white against a near-white background. In dark mode
## 3. Typography
-Font: Instrument Sans. Sizes: `text-xs` 12 · `text-sm` 14 · `text-base` 16 · `text-lg` 18 ·
+Font: Geist. Sizes: `text-xs` 12 · `text-sm` 14 · `text-base` 16 · `text-lg` 18 ·
`text-xl` 20 · `text-2xl` 24 · `text-3xl` 30. Weights: 400 body · 500 labels · 600 headings ·
700 rare emphasis.
diff --git a/frontend/apps/main/index.html b/frontend/apps/main/index.html
index 535cb700..781f17db 100644
--- a/frontend/apps/main/index.html
+++ b/frontend/apps/main/index.html
@@ -4,11 +4,9 @@
-
-
-
-
+
+
+
diff --git a/frontend/apps/main/src/App.vue b/frontend/apps/main/src/App.vue
index 60f08949..90436594 100644
--- a/frontend/apps/main/src/App.vue
+++ b/frontend/apps/main/src/App.vue
@@ -1,74 +1,12 @@
- {{ t('globals.terms.inbox', 2) }}
-{{ t('globals.terms.contact', 2) }}
-{{ t('globals.terms.report', 2) }}
-{{ t('globals.terms.admin') }}
-{{ t('globals.messages.smallScreenDescription') }}
- -