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 @@ + srcset="https://s3.ap-south-1.amazonaws.com/libredesk.io/libredesk_white.png?v=2"> + srcset="https://s3.ap-south-1.amazonaws.com/libredesk.io/libredesk_black.png?v=3"> LibreDesk
Modern, open source, self-hosted omnichannel customer support desk. Live chat, email, and more in a single binary. -![image](https://libredesk.io/hero-dark-v2.png?q=3) +![image](https://libredesk.io/hero-dark.png?q=5) 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. + +[![Deploy on Railway](https://railway.com/button.svg)](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 := "

original

" + handler := func(r *fastglue.Request) error { + renders++ + r.RequestCtx.SetContentType("text/html; charset=utf-8") + r.RequestCtx.Response.Header.Set("Cache-Control", helpCenterCacheControl) + r.RequestCtx.Response.SetBodyString(body) + return nil + } + cached := fc.Cached(handler, helpCenterCacheOpts, helpCenterCacheGroup) + + const uri = "/hc/support/en/articles/refunds" + + first := request(uri, "") + if err := cached(first); err != nil { + t.Fatalf("first request: %v", err) + } + if renders != 1 { + t.Fatalf("first request should render, renders = %d", renders) + } + etag := string(first.RequestCtx.Response.Header.Peek("ETag")) + if etag == "" { + t.Fatal("expected an ETag on the rendered page") + } + + second := request(uri, "") + if err := cached(second); err != nil { + t.Fatalf("second request: %v", err) + } + if renders != 1 { + t.Errorf("second request re-rendered instead of using the cache, renders = %d", renders) + } + if got := string(second.RequestCtx.Response.Body()); got != body { + t.Errorf("cached body = %q, want %q", got, body) + } + + revalidate := request(uri, etag) + if err := cached(revalidate); err != nil { + t.Fatalf("revalidation: %v", err) + } + if got := revalidate.RequestCtx.Response.StatusCode(); got != fasthttp.StatusNotModified { + t.Errorf("matching ETag returned %d, want 304", got) + } + if got := revalidate.RequestCtx.Response.Body(); len(got) != 0 { + t.Errorf("304 carried a body of %d bytes", len(got)) + } + + if err := fc.DelGroup(helpCenterCacheNamespace, helpCenterCacheGroup); err != nil { + t.Fatalf("clearing the group: %v", err) + } + body = "

edited

" + afterEdit := request(uri, "") + if err := cached(afterEdit); err != nil { + t.Fatalf("request after edit: %v", err) + } + if renders != 2 { + t.Errorf("cleared cache did not re-render, renders = %d", renders) + } + if got := string(afterEdit.RequestCtx.Response.Body()); got != body { + t.Errorf("stale body served after an edit: got %q, want %q", got, body) + } + if newETag := string(afterEdit.RequestCtx.Response.Header.Peek("ETag")); newETag == etag { + t.Error("ETag survived an edit, readers holding the old copy would never refetch") + } +} + +func TestHelpCenterPageCacheKeysPerQueryAndSurvivesRename(t *testing.T) { + fc := newTestFastCache(t) + + served := map[string]int{} + handler := func(r *fastglue.Request) error { + uri := string(r.RequestCtx.URI().RequestURI()) + served[uri]++ + r.RequestCtx.Response.SetBodyString(uri) + return nil + } + cached := fc.Cached(handler, helpCenterCacheOpts, helpCenterCacheGroup) + + for _, uri := range []string{ + "/hc/support/en/search?q=refund", + "/hc/support/en/search?q=billing", + "/hc/docs/en/search?q=refund", + } { + if err := cached(request(uri, "")); err != nil { + t.Fatalf("request: %v", err) + } + } + if len(served) != 3 { + t.Fatalf("expected 3 distinct cache entries, got %d: %v", len(served), served) + } + + clearAll(t, fc) + for uri, before := range served { + if err := cached(request(uri, "")); err != nil { + t.Fatalf("request: %v", err) + } + if served[uri] == before { + t.Errorf("%s still served from cache after a clear", uri) + } + } +} + +func TestHelpCenterPageCacheReportsHitOrMiss(t *testing.T) { + app := &App{fc: newTestFastCache(t)} + handler := cachedHCPage(func(r *fastglue.Request) error { + r.RequestCtx.Response.SetBodyString("

page

") + return nil + }) + + const uri = "/hc/support/en/articles/refunds" + run := func(ifNoneMatch string) *fasthttp.RequestCtx { + req := request(uri, ifNoneMatch) + req.Context = app + if err := handler(req); err != nil { + t.Fatalf("request: %v", err) + } + return req.RequestCtx + } + + first := run("") + if got := string(first.Response.Header.Peek("X-Cache")); got != "MISS" { + t.Errorf("first request X-Cache = %q, want MISS", got) + } + + second := run("") + if got := string(second.Response.Header.Peek("X-Cache")); got != "HIT" { + t.Errorf("second request X-Cache = %q, want HIT", got) + } + + revalidated := run(string(first.Response.Header.Peek("ETag"))) + if got := revalidated.Response.StatusCode(); got != fasthttp.StatusNotModified { + t.Fatalf("revalidation returned %d, want 304", got) + } + if got := string(revalidated.Response.Header.Peek("X-Cache")); got != "HIT" { + t.Errorf("304 X-Cache = %q, want HIT", got) + } +} + +func TestHelpCenterPageCacheKeepsNoIndexOnHits(t *testing.T) { + app := &App{fc: newTestFastCache(t)} + body := func(r *fastglue.Request) error { + r.RequestCtx.Response.SetBodyString("raw markdown") + return nil + } + + cases := []struct { + name string + handler fastglue.FastRequestHandler + uri string + slug string + }{ + {"search results", cachedHCNoIndexPage(body), "/hc/support/en/search?q=refund", ""}, + {"markdown article", cachedHCPage(body), "/hc/support/en/articles/refunds.md", "refunds.md"}, + {"html article", cachedHCPage(body), "/hc/support/en/articles/refunds", "refunds"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + want := tc.slug != "refunds" + for _, pass := range []string{"miss", "hit"} { + req := request(tc.uri, "") + req.Context = app + if tc.slug != "" { + req.RequestCtx.SetUserValue("article_slug", tc.slug) + } + if err := tc.handler(req); err != nil { + t.Fatalf("%s: %v", pass, err) + } + got := string(req.RequestCtx.Response.Header.Peek("X-Robots-Tag")) == noIndexHeader + if got != want { + t.Errorf("%s: noindex = %v, want %v", pass, got, want) + } + } + }) + } +} + +func newTestFastCache(t *testing.T) *fastcache.FastCache { + t.Helper() + mr := miniredis.RunT(t) + return fastcache.New(goredis.New(goredis.Config{Prefix: fastCachePrefix}, redis.NewClient(&redis.Options{Addr: mr.Addr()}))) +} + +func request(uri, ifNoneMatch string) *fastglue.Request { + ctx := &fasthttp.RequestCtx{} + ctx.Request.Header.SetMethod("GET") + ctx.Request.SetRequestURI(uri) + ctx.SetUserValue(helpCenterCacheNamespaceKey, helpCenterCacheNamespace) + if ifNoneMatch != "" { + ctx.Request.Header.Set("If-None-Match", ifNoneMatch) + } + return &fastglue.Request{RequestCtx: ctx} +} + +func clearAll(t *testing.T, fc *fastcache.FastCache) { + t.Helper() + if err := fc.DelGroup(helpCenterCacheNamespace, helpCenterCacheGroup); err != nil { + t.Fatalf("clearing the group: %v", err) + } +} diff --git a/cmd/inboxes.go b/cmd/inboxes.go index 9ead99f1..01c8c497 100644 --- a/cmd/inboxes.go +++ b/cmd/inboxes.go @@ -38,7 +38,7 @@ func handleGetInboxes(r *fastglue.Request) error { for i := range inboxes { if err := inboxes[i].ClearPasswords(); err != nil { app.lo.Error("error clearing inbox passwords from response", "error", err) - return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } setComputedInboxFieldsWithRoot(app, &inboxes[i], rootURL) } @@ -57,7 +57,7 @@ func handleGetInbox(r *fastglue.Request) error { } if err := inbox.ClearPasswords(); err != nil { app.lo.Error("error clearing inbox passwords from response", "error", err) - return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } setComputedInboxFields(app, &inbox) return r.SendEnvelope(inbox) @@ -234,7 +234,7 @@ func handleCreateInbox(r *fastglue.Request) error { // Clear passwords before returning. if err := createdInbox.ClearPasswords(); err != nil { app.lo.Error("error clearing inbox passwords from response", "error", err) - return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } setComputedInboxFields(app, &createdInbox) @@ -302,7 +302,7 @@ func handleUpdateInbox(r *fastglue.Request) error { // Clear passwords before returning. if err := updatedInbox.ClearPasswords(); err != nil { app.lo.Error("error clearing inbox passwords from response", "error", err) - return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } setComputedInboxFields(app, &updatedInbox) @@ -322,7 +322,7 @@ func handleToggleInbox(r *fastglue.Request) error { toggledInbox, err := app.inbox.Toggle(id) if err != nil { - return err + return sendErrorEnvelope(r, err) } if err := reloadInbox(app, id); err != nil { @@ -333,7 +333,7 @@ func handleToggleInbox(r *fastglue.Request) error { // Clear passwords before returning if err := toggledInbox.ClearPasswords(); err != nil { app.lo.Error("error clearing inbox passwords from response", "error", err) - return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } setComputedInboxFields(app, &toggledInbox) diff --git a/cmd/init.go b/cmd/init.go index c60030cb..ed13fc2e 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -73,6 +73,8 @@ import ( "github.com/redis/go-redis/v9" flag "github.com/spf13/pflag" "github.com/volatiletech/null/v9" + goredis "github.com/zerodha/fastcache/stores/goredis/v9" + "github.com/zerodha/fastcache/v4" "github.com/zerodha/logf" ) @@ -1010,6 +1012,10 @@ func initRedis() *redis.Client { }) } +func initFastCache(rdb *redis.Client) *fastcache.FastCache { + return fastcache.New(goredis.New(goredis.Config{Prefix: fastCachePrefix}, rdb)) +} + // initRedis inits postgres DB. func initDB() *sqlx.DB { dsn := fmt.Sprintf( diff --git a/cmd/main.go b/cmd/main.go index 532648d4..e46a2551 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -3,6 +3,7 @@ package main import ( "cmp" "context" + "errors" "fmt" "hash/fnv" "log" @@ -59,6 +60,7 @@ import ( "github.com/knadh/koanf/v2" "github.com/knadh/stuffbin" "github.com/valyala/fasthttp" + "github.com/zerodha/fastcache/v4" "github.com/zerodha/fastglue" "github.com/zerodha/logf" ) @@ -88,6 +90,8 @@ var ( const ( sampleEncKey = "your-32-char-random-string-here!" + + serverShutdownTimeout = 8 * time.Second ) // App is the global app context which is passed and injected in the http handlers. @@ -130,6 +134,7 @@ type App struct { contextLink *contextlink.Manager rateLimit *ratelimit.Limiter redis *redis.Client + fc *fastcache.FastCache importer *importer.Importer whatsappTemplate *whatsappTemplate.Manager whatsappClient *whatsappapi.Client @@ -210,6 +215,8 @@ func main() { validateConfig(ko) + startPprof() + // Fallback for config typo. Logs a warning but continues to work with the incorrect key. // Uses 'message.message_outgoing_scan_interval' (correct key) as default key, falls back to the common typo. msgOutgoingScanIntervalKey := "message.message_outgoing_scan_interval" @@ -329,12 +336,15 @@ func main() { contextLink: initContextLink(db, i18n), rateLimit: rateLimiter, redis: rdb, + fc: initFastCache(rdb), userNotification: userNotification, whatsappClient: waClient, whatsappTemplate: waTemplates, wsHub: wsHub, } app.consts.Store(constants) + helpCenterCacheOpts.Logger = log.New(helpCenterCacheLogWriter{lo: app.lo}, "", 0) + whatsappIngester, err := newWhatsAppIngester(app) if err != nil { log.Fatalf("error initializing whatsapp ingester: %v", err) @@ -352,6 +362,9 @@ func main() { initHandlers(g, wsHub) g.Router.NotFound = helpCenterHostNotFound(app, g) + // Buffers above this are dropped rather than reused, and the ones we keep stay with the connection until it closes. + fasthttp.SetBodySizePoolLimit(64<<10, 1<<20) // request: 64 KiB, response: 1 MiB + s := &fasthttp.Server{ Name: appName, ReadTimeout: ko.MustDuration("app.server.read_timeout"), @@ -378,8 +391,19 @@ func main() { // Wait for shutdown signal. <-ctx.Done() + closedAgentConns := wsHub.CloseAll() + closedLiveChatInboxes := inbox.CloseLiveChatClients() + colorlog.Red("Closed %d agent websocket connections and %d livechat inboxes.", closedAgentConns, closedLiveChatInboxes) colorlog.Red("Shutting down HTTP server...") - s.Shutdown() + shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), serverShutdownTimeout) + if err := s.ShutdownWithContext(shutdownCtx); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + colorlog.Red("HTTP server drain timed out after %s, exiting with connections still open: %v", serverShutdownTimeout, err) + } else { + colorlog.Red("error shutting down HTTP server: %v", err) + } + } + cancelShutdown() colorlog.Red("Shutting down AI agent...") aiAgent.Close() colorlog.Red("Shutting down AI...") diff --git a/cmd/messages_delete.go b/cmd/messages_delete.go index 7214e528..b65ff81b 100644 --- a/cmd/messages_delete.go +++ b/cmd/messages_delete.go @@ -23,7 +23,13 @@ func handleDeleteMessage(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - content, err := app.conversation.DeletePrivateMessage(cuuid, uuid) + // Agents may delete only their own private notes, admins may delete any. + senderID := user.ID + if user.HasAdminRole() { + senderID = 0 + } + + content, err := app.conversation.DeletePrivateMessage(cuuid, uuid, senderID) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/pprof.go b/cmd/pprof.go new file mode 100644 index 00000000..9406dc9f --- /dev/null +++ b/cmd/pprof.go @@ -0,0 +1,49 @@ +package main + +import ( + "net" + "net/http" + "net/http/pprof" + "runtime" + + "github.com/abhinavxd/libredesk/internal/colorlog" +) + +// startPprof starts the net/http/pprof server on its own address if enabled in config. +func startPprof() { + if !ko.Bool("app.pprof.enabled") { + return + } + + addr := ko.String("app.pprof.address") + if addr == "" { + addr = "127.0.0.1:6060" + } + + if rate := ko.Int("app.pprof.block_profile_rate"); rate > 0 { + runtime.SetBlockProfileRate(rate) + } + if frac := ko.Int("app.pprof.mutex_profile_fraction"); frac > 0 { + runtime.SetMutexProfileFraction(frac) + } + + mux := http.NewServeMux() + mux.HandleFunc("/debug/pprof/", pprof.Index) + mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + mux.HandleFunc("/debug/pprof/profile", pprof.Profile) + mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + mux.HandleFunc("/debug/pprof/trace", pprof.Trace) + + ln, err := net.Listen("tcp", addr) + if err != nil { + colorlog.Red("error starting pprof server: %v", err) + return + } + colorlog.Green("pprof server started at %s", addr) + + go func() { + if err := http.Serve(ln, mux); err != nil { + colorlog.Red("pprof server stopped: %v", err) + } + }() +} diff --git a/cmd/settings.go b/cmd/settings.go index f7cd2dd3..72e78efd 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -69,7 +69,7 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error { // Reload the settings and templates. if err := reloadSettings(app); err != nil { app.lo.Error("error reloading settings", "error", err) - return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } // Check if language changed and reload i18n if needed. @@ -84,7 +84,7 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error { if err := reloadTemplates(app); err != nil { app.lo.Error("error reloading templates", "error", err) - return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil) + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) } if strings.TrimRight(oldRootURL, "/") != req.RootURL { diff --git a/cmd/updates.go b/cmd/updates.go index a0756ce6..2d5787ef 100644 --- a/cmd/updates.go +++ b/cmd/updates.go @@ -6,7 +6,7 @@ package main import ( "encoding/json" - "io" + "fmt" "net/http" "regexp" "time" @@ -16,6 +16,8 @@ import ( const updateCheckURL = "https://updates.libredesk.io/updates.json" +const updateCheckTimeout = 10 * time.Second + type AppUpdate struct { Update struct { ReleaseVersion string `json:"release_version"` @@ -44,31 +46,15 @@ func checkUpdates(curVersion string, interval time.Duration, app *App) { // Strip -* suffix. curVersion = reSemver.ReplaceAllString(curVersion, "") + client := &http.Client{Timeout: updateCheckTimeout} + fnCheck := func() { - resp, err := http.Get(updateCheckURL) + out, err := fetchAppUpdate(client, updateCheckURL) if err != nil { app.lo.Error("error checking for app updates", "err", err) return } - if resp.StatusCode != 200 { - app.lo.Error("non-ok status code checking for app updates", "status", resp.StatusCode) - return - } - - b, err := io.ReadAll(resp.Body) - if err != nil { - app.lo.Error("error reading response body", "err", err) - return - } - resp.Body.Close() - - var out AppUpdate - if err := json.Unmarshal(b, &out); err != nil { - app.lo.Error("error unmarshalling response body", "err", err) - return - } - // There is an update. Set it on the global app state. if semver.IsValid(out.Update.ReleaseVersion) { v := reSemver.ReplaceAllString(out.Update.ReleaseVersion, "") @@ -79,7 +65,7 @@ func checkUpdates(curVersion string, interval time.Duration, app *App) { } app.Lock() - app.update = &out + app.update = out app.Unlock() } @@ -96,3 +82,22 @@ func checkUpdates(curVersion string, interval time.Duration, app *App) { fnCheck() } } + +func fetchAppUpdate(client *http.Client, url string) (*AppUpdate, error) { + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var out AppUpdate + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + + return &out, nil +} diff --git a/cmd/updates_test.go b/cmd/updates_test.go new file mode 100644 index 00000000..0d5c3396 --- /dev/null +++ b/cmd/updates_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +const updatesPayload = `{ + "update": { + "release_version": "v2.7.1", + "release_date": "2026-08-12", + "url": "https://github.com/abhinavxd/libredesk/releases/tag/v2.7.1", + "description": "Bug fixes and improvements." + }, + "messages": [] +}` + +func TestFetchAppUpdateSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(updatesPayload)) + })) + defer srv.Close() + + out, err := fetchAppUpdate(srv.Client(), srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.Update.ReleaseVersion != "v2.7.1" { + t.Fatalf("got release version %q, want v2.7.1", out.Update.ReleaseVersion) + } + if out.Update.ReleaseDate != "2026-08-12" { + t.Fatalf("got release date %q, want 2026-08-12", out.Update.ReleaseDate) + } + if out.Update.URL != "https://github.com/abhinavxd/libredesk/releases/tag/v2.7.1" { + t.Fatalf("got url %q", out.Update.URL) + } + if out.Update.Description != "Bug fixes and improvements." { + t.Fatalf("got description %q", out.Update.Description) + } + if len(out.Messages) != 0 { + t.Fatalf("got %d messages, want 0", len(out.Messages)) + } +} + +func TestFetchAppUpdateNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + out, err := fetchAppUpdate(srv.Client(), srv.URL) + if err == nil { + t.Fatal("expected an error for a non-200 status") + } + if out != nil { + t.Fatalf("expected nil result on error, got %+v", out) + } +} diff --git a/cmd/users.go b/cmd/users.go index ca460a19..8278d403 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -23,7 +23,8 @@ import ( ) const ( - maxAvatarSizeMB = 2 + // Bounds the request body only; the stored file is capped by image.AvatarMaxDim regardless. + maxAvatarSizeMB = 10 ) type resetPasswordRequest struct { @@ -196,9 +197,8 @@ func handleCreateAgent(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError) } - // Validate agent request - if err := validateAgentRequest(r, &req); err != nil { - return err + if err := validateAgentRequest(app, &req); err != nil { + return sendErrorEnvelope(r, err) } agent, err := app.user.CreateAgent(req.FirstName, req.LastName, req.Email, req.Roles) @@ -263,9 +263,8 @@ func handleUpdateAgent(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError) } - // Validate agent request - if err := validateAgentRequest(r, &req); err != nil { - return err + if err := validateAgentRequest(app, &req); err != nil { + return sendErrorEnvelope(r, err) } agent, err := app.user.GetAgent(id, "") @@ -514,10 +513,14 @@ func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart. srcFileName := stringutil.SanitizeFilename(fileHeader.Filename) srcContentType := fileHeader.Header.Get("Content-Type") - srcFileSize := fileHeader.Size - // Reset ptr. - file.Seek(0, 0) + resized, err := image.Downscale(image.AvatarMaxDim, file) + if err != nil { + app.lo.Error("error downscaling avatar", "user_id", user.ID, "error", err) + return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.fileTypeisNotAnImage"), nil) + } + srcFileSize := resized.Len() + linkedModel := null.StringFrom(mmodels.ModelUser) linkedID := null.IntFrom(user.ID) disposition := null.NewString("", false) @@ -525,7 +528,7 @@ func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart. meta := []byte("{}") // Agent and AI assistant avatars are public: both appear as authors on the help center. private := user.Type != models.UserTypeAgent && user.Type != models.UserTypeAIAssistant - media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, file, int(srcFileSize), disposition, meta, private) + media, err := app.media.UploadAndInsert(srcFileName, srcContentType, contentID, linkedModel, linkedID, resized, srcFileSize, disposition, meta, private) if err != nil { app.lo.Error("error uploading file", "user_id", user.ID, "error", err) return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.errorUploadingFile"), nil) @@ -612,26 +615,30 @@ func handleRevokeAPIKey(r *fastglue.Request) error { return r.SendEnvelope(true) } -// validateAgentRequest validates common agent request fields and normalizes the email -func validateAgentRequest(r *fastglue.Request, req *agentReq) error { - var app = r.Context.(*App) - - // Normalize email +func validateAgentRequest(app *App, req *agentReq) error { req.Email = strings.TrimSpace(strings.ToLower(req.Email)) + req.FirstName = strings.TrimSpace(req.FirstName) + req.LastName = strings.TrimSpace(req.LastName) if req.Email == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil, envelope.InputError) + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`email`"), nil) } if !stringutil.ValidEmail(req.Email) { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("validation.invalidEmail"), nil, envelope.InputError) + return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidEmail"), nil) } if req.Roles == nil { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil, envelope.InputError) + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`role`"), nil) } if req.FirstName == "" { - return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil, envelope.InputError) + return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`first_name`"), nil) + } + + switch req.AvailabilityStatus { + case "", models.Online, models.Offline, models.Away, models.AwayManual, models.AwayAndReassigning: + default: + return envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidAvailabilityStatus"), nil) } return nil diff --git a/cmd/validators_test.go b/cmd/validators_test.go new file mode 100644 index 00000000..d5554dc4 --- /dev/null +++ b/cmd/validators_test.go @@ -0,0 +1,469 @@ +package main + +import ( + "encoding/json" + "io" + "strings" + "testing" + + autoModels "github.com/abhinavxd/libredesk/internal/automation/models" + clmodels "github.com/abhinavxd/libredesk/internal/context_link/models" + cmodels "github.com/abhinavxd/libredesk/internal/custom_attribute/models" + "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/helpcenter" + imodels "github.com/abhinavxd/libredesk/internal/inbox/models" + macromodels "github.com/abhinavxd/libredesk/internal/macro/models" + smodels "github.com/abhinavxd/libredesk/internal/sla/models" + "github.com/abhinavxd/libredesk/internal/stringutil" + "github.com/abhinavxd/libredesk/internal/testutil" + umodels "github.com/abhinavxd/libredesk/internal/user/models" + wmodels "github.com/abhinavxd/libredesk/internal/webhook/models" + "github.com/volatiletech/null/v9" + "github.com/zerodha/logf" +) + +const testAppBaseURL = "https://desk.example.com" + +func TestValidateAgentRequest(t *testing.T) { + app := newValidatorTestApp(t) + + tests := []struct { + name string + req agentReq + wantErr bool + }{ + {"valid", agentReq{Email: "agent@example.com", FirstName: "Ada", Roles: []string{"Agent"}}, false}, + {"valid with availability status", agentReq{Email: "agent@example.com", FirstName: "Ada", Roles: []string{"Agent"}, AvailabilityStatus: umodels.AwayAndReassigning}, false}, + {"empty email", agentReq{FirstName: "Ada", Roles: []string{"Agent"}}, true}, + {"whitespace email", agentReq{Email: " ", FirstName: "Ada", Roles: []string{"Agent"}}, true}, + {"malformed email", agentReq{Email: "not-an-email", FirstName: "Ada", Roles: []string{"Agent"}}, true}, + {"email with display name", agentReq{Email: "Ada ", FirstName: "Ada", Roles: []string{"Agent"}}, true}, + {"nil roles", agentReq{Email: "agent@example.com", FirstName: "Ada"}, true}, + {"empty first name", agentReq{Email: "agent@example.com", Roles: []string{"Agent"}}, true}, + {"whitespace first name", agentReq{Email: "agent@example.com", FirstName: " ", Roles: []string{"Agent"}}, true}, + {"unknown availability status", agentReq{Email: "agent@example.com", FirstName: "Ada", Roles: []string{"Agent"}, AvailabilityStatus: "vacation"}, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertValidation(t, validateAgentRequest(app, &tc.req), tc.wantErr) + }) + } +} + +func TestValidateAgentRequestNormalizesFields(t *testing.T) { + app := newValidatorTestApp(t) + req := agentReq{Email: " Ada@Example.COM ", FirstName: " Ada ", LastName: " Lovelace ", Roles: []string{"Agent"}} + + if err := validateAgentRequest(app, &req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Email != "ada@example.com" { + t.Fatalf("got email %q, want ada@example.com", req.Email) + } + if req.FirstName != "Ada" || req.LastName != "Lovelace" { + t.Fatalf("got names %q %q, want Ada Lovelace", req.FirstName, req.LastName) + } +} + +func TestValidateAgentRequestEmptyRolesSlice(t *testing.T) { + t.Skip("validateAgentRequest checks Roles == nil, so a JSON body with \"roles\": [] passes and creates an agent with no roles") + + app := newValidatorTestApp(t) + req := agentReq{Email: "agent@example.com", FirstName: "Ada", Roles: []string{}} + assertValidation(t, validateAgentRequest(app, &req), true) +} + +func TestValidateWebhook(t *testing.T) { + app := newValidatorTestApp(t) + + tests := []struct { + name string + webhook wmodels.Webhook + wantErr bool + }{ + {"valid", wmodels.Webhook{Name: "hook", URL: "https://example.com/hook", Events: []string{"conversation.created"}}, false}, + {"empty name", wmodels.Webhook{URL: "https://example.com/hook", Events: []string{"conversation.created"}}, true}, + {"empty url", wmodels.Webhook{Name: "hook", Events: []string{"conversation.created"}}, true}, + {"nil events", wmodels.Webhook{Name: "hook", URL: "https://example.com/hook"}, true}, + {"empty events", wmodels.Webhook{Name: "hook", URL: "https://example.com/hook", Events: []string{}}, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertValidation(t, validateWebhook(app, tc.webhook), tc.wantErr) + }) + } +} + +func TestValidateContextLink(t *testing.T) { + app := newValidatorTestApp(t) + masked := strings.Repeat(stringutil.PasswordDummy, 10) + + tests := []struct { + name string + link clmodels.ContextLink + wantErr bool + }{ + {"valid", clmodels.ContextLink{Name: "CRM", URLTemplate: "https://crm.example.com/{{email}}"}, false}, + {"valid with 32 char secret", clmodels.ContextLink{Name: "CRM", URLTemplate: "https://crm.example.com", Secret: strings.Repeat("a", 32)}, false}, + {"valid with masked secret", clmodels.ContextLink{Name: "CRM", URLTemplate: "https://crm.example.com", Secret: masked}, false}, + {"empty name", clmodels.ContextLink{URLTemplate: "https://crm.example.com"}, true}, + {"empty url template", clmodels.ContextLink{Name: "CRM"}, true}, + {"short secret", clmodels.ContextLink{Name: "CRM", URLTemplate: "https://crm.example.com", Secret: strings.Repeat("a", 31)}, true}, + {"long secret", clmodels.ContextLink{Name: "CRM", URLTemplate: "https://crm.example.com", Secret: strings.Repeat("a", 33)}, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertValidation(t, validateContextLink(app, &tc.link), tc.wantErr) + }) + } +} + +func TestValidateContextLinkDefaultsTokenExpiry(t *testing.T) { + app := newValidatorTestApp(t) + + tests := []struct { + name string + given int + want int + }{ + {"zero defaults", 0, 1200}, + {"negative defaults", -5, 1200}, + {"positive kept", 60, 60}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + link := clmodels.ContextLink{Name: "CRM", URLTemplate: "https://crm.example.com", TokenExpirySeconds: tc.given} + if err := validateContextLink(app, &link); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if link.TokenExpirySeconds != tc.want { + t.Fatalf("got token expiry %d, want %d", link.TokenExpirySeconds, tc.want) + } + }) + } +} + +func TestValidateCustomAttribute(t *testing.T) { + app := newValidatorTestApp(t) + valid := cmodels.CustomAttribute{Name: "Plan", AppliesTo: "conversation", DataType: "text", Description: "Customer plan", Key: "plan"} + + tests := []struct { + name string + attr cmodels.CustomAttribute + wantErr bool + }{ + {"valid", valid, false}, + {"empty name", cmodels.CustomAttribute{AppliesTo: "conversation", DataType: "text", Description: "d", Key: "plan"}, true}, + {"empty applies to", cmodels.CustomAttribute{Name: "Plan", DataType: "text", Description: "d", Key: "plan"}, true}, + {"empty data type", cmodels.CustomAttribute{Name: "Plan", AppliesTo: "conversation", Description: "d", Key: "plan"}, true}, + {"empty description", cmodels.CustomAttribute{Name: "Plan", AppliesTo: "conversation", DataType: "text", Key: "plan"}, true}, + {"empty key", cmodels.CustomAttribute{Name: "Plan", AppliesTo: "conversation", DataType: "text", Description: "d"}, true}, + {"reserved key status", cmodels.CustomAttribute{Name: "Plan", AppliesTo: "conversation", DataType: "text", Description: "d", Key: "status"}, true}, + {"reserved key inbox", cmodels.CustomAttribute{Name: "Plan", AppliesTo: "conversation", DataType: "text", Description: "d", Key: "inbox"}, true}, + {"key near reserved", cmodels.CustomAttribute{Name: "Plan", AppliesTo: "conversation", DataType: "text", Description: "d", Key: "status_code"}, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertValidation(t, validateCustomAttribute(app, tc.attr), tc.wantErr) + }) + } +} + +func TestValidateSLA(t *testing.T) { + app := newValidatorTestApp(t) + notification := smodels.SlaNotification{Type: "warning", TimeDelayType: "after", Metric: "first_response", TimeDelay: "30m", Recipients: []string{"assigned_user"}} + + withNotification := func(n smodels.SlaNotification) *smodels.SLAPolicy { + return &smodels.SLAPolicy{Name: "Gold", FirstResponseTime: null.StringFrom("1h"), Notifications: smodels.SlaNotifications{n}} + } + + tests := []struct { + name string + sla *smodels.SLAPolicy + wantErr bool + }{ + {"valid first response only", &smodels.SLAPolicy{Name: "Gold", FirstResponseTime: null.StringFrom("1h")}, false}, + {"valid all durations", &smodels.SLAPolicy{Name: "Gold", FirstResponseTime: null.StringFrom("1h"), NextResponseTime: null.StringFrom("2h"), ResolutionTime: null.StringFrom("24h")}, false}, + {"empty name", &smodels.SLAPolicy{FirstResponseTime: null.StringFrom("1h")}, true}, + {"no durations", &smodels.SLAPolicy{Name: "Gold"}, true}, + + {"first response unparseable", &smodels.SLAPolicy{Name: "Gold", FirstResponseTime: null.StringFrom("one hour")}, true}, + {"first response below one minute", &smodels.SLAPolicy{Name: "Gold", FirstResponseTime: null.StringFrom("30s")}, true}, + {"first response exactly one minute", &smodels.SLAPolicy{Name: "Gold", FirstResponseTime: null.StringFrom("1m")}, false}, + {"next response unparseable", &smodels.SLAPolicy{Name: "Gold", NextResponseTime: null.StringFrom("soon")}, true}, + {"next response below one minute", &smodels.SLAPolicy{Name: "Gold", NextResponseTime: null.StringFrom("59s")}, true}, + {"resolution unparseable", &smodels.SLAPolicy{Name: "Gold", ResolutionTime: null.StringFrom("later")}, true}, + {"resolution below one minute", &smodels.SLAPolicy{Name: "Gold", ResolutionTime: null.StringFrom("10s")}, true}, + {"first response after resolution", &smodels.SLAPolicy{Name: "Gold", FirstResponseTime: null.StringFrom("5h"), ResolutionTime: null.StringFrom("1h")}, true}, + {"first response equals resolution", &smodels.SLAPolicy{Name: "Gold", FirstResponseTime: null.StringFrom("1h"), ResolutionTime: null.StringFrom("1h")}, false}, + + {"notification valid", withNotification(notification), false}, + {"notification empty type", withNotification(smodels.SlaNotification{TimeDelayType: "after", Metric: "first_response", TimeDelay: "30m", Recipients: []string{"assigned_user"}}), true}, + {"notification empty time delay type", withNotification(smodels.SlaNotification{Type: "warning", Metric: "first_response", TimeDelay: "30m", Recipients: []string{"assigned_user"}}), true}, + {"notification empty metric", withNotification(smodels.SlaNotification{Type: "warning", TimeDelayType: "after", TimeDelay: "30m", Recipients: []string{"assigned_user"}}), true}, + {"notification empty time delay", withNotification(smodels.SlaNotification{Type: "warning", TimeDelayType: "after", Metric: "first_response", Recipients: []string{"assigned_user"}}), true}, + {"notification unparseable time delay", withNotification(smodels.SlaNotification{Type: "warning", TimeDelayType: "after", Metric: "first_response", TimeDelay: "half an hour", Recipients: []string{"assigned_user"}}), true}, + {"notification time delay below one minute", withNotification(smodels.SlaNotification{Type: "warning", TimeDelayType: "after", Metric: "first_response", TimeDelay: "45s", Recipients: []string{"assigned_user"}}), true}, + {"notification immediately skips delay", withNotification(smodels.SlaNotification{Type: "warning", TimeDelayType: "immediately", Metric: "first_response", Recipients: []string{"assigned_user"}}), false}, + {"notification no recipients", withNotification(smodels.SlaNotification{Type: "warning", TimeDelayType: "after", Metric: "first_response", TimeDelay: "30m"}), true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertValidation(t, validateSLA(app, tc.sla), tc.wantErr) + }) + } +} + +func TestValidateEmailConfig(t *testing.T) { + app := newValidatorTestApp(t) + + tests := []struct { + name string + config string + wantErr bool + }{ + {"valid password auth", `{"auth_type":"password","smtp":[{"host":"smtp.example.com","port":587,"auth_protocol":"plain"}],"imap":[{"host":"imap.example.com","port":993,"mailbox":"INBOX","tls_type":"tls"}]}`, false}, + {"valid empty auth type", `{"smtp":[{"host":"smtp.example.com","port":587}]}`, false}, + {"valid oauth2", `{"auth_type":"oauth2","oauth":{"provider":"google","client_id":"abc"}}`, false}, + {"malformed json", `{"auth_type":`, true}, + {"unknown auth type", `{"auth_type":"kerberos"}`, true}, + {"oauth2 without oauth block", `{"auth_type":"oauth2"}`, true}, + {"oauth2 unknown provider", `{"auth_type":"oauth2","oauth":{"provider":"yahoo","client_id":"abc"}}`, true}, + {"oauth2 empty client id", `{"auth_type":"oauth2","oauth":{"provider":"microsoft"}}`, true}, + + {"smtp empty host", `{"smtp":[{"port":587}]}`, true}, + {"smtp zero port", `{"smtp":[{"host":"smtp.example.com","port":0}]}`, true}, + {"smtp negative port", `{"smtp":[{"host":"smtp.example.com","port":-1}]}`, true}, + {"smtp unknown auth protocol", `{"smtp":[{"host":"smtp.example.com","port":587,"auth_protocol":"ntlm"}]}`, true}, + {"smtp auth protocol ignored for oauth2", `{"auth_type":"oauth2","oauth":{"provider":"google","client_id":"abc"},"smtp":[{"host":"smtp.example.com","port":587,"auth_protocol":"ntlm"}]}`, false}, + {"smtp second entry invalid", `{"smtp":[{"host":"smtp.example.com","port":587},{"host":"","port":587}]}`, true}, + + {"imap empty host", `{"imap":[{"port":993,"mailbox":"INBOX","tls_type":"tls"}]}`, true}, + {"imap zero port", `{"imap":[{"host":"imap.example.com","port":0,"mailbox":"INBOX","tls_type":"tls"}]}`, true}, + {"imap empty mailbox", `{"imap":[{"host":"imap.example.com","port":993,"tls_type":"tls"}]}`, true}, + {"imap unknown tls type", `{"imap":[{"host":"imap.example.com","port":993,"mailbox":"INBOX","tls_type":"ssl"}]}`, true}, + {"imap empty tls type", `{"imap":[{"host":"imap.example.com","port":993,"mailbox":"INBOX"}]}`, true}, + {"imap starttls", `{"imap":[{"host":"imap.example.com","port":143,"mailbox":"INBOX","tls_type":"starttls"}]}`, false}, + {"imap none", `{"imap":[{"host":"imap.example.com","port":143,"mailbox":"INBOX","tls_type":"none"}]}`, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertValidation(t, validateEmailConfig(app, json.RawMessage(tc.config)), tc.wantErr) + }) + } +} + +func TestValidateEmailConfigAuthTypeConstants(t *testing.T) { + app := newValidatorTestApp(t) + + for _, authType := range []string{imodels.AuthTypePassword, imodels.AuthTypeOAuth2} { + cfg := imodels.Config{AuthType: authType} + if authType == imodels.AuthTypeOAuth2 { + cfg.OAuth = &imodels.OAuthConfig{Provider: "google", ClientID: "abc"} + } + b, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshalling config: %v", err) + } + if err := validateEmailConfig(app, b); err != nil { + t.Fatalf("auth type %q: unexpected error: %v", authType, err) + } + } +} + +func TestValidateHelpCenter(t *testing.T) { + app := newValidatorTestApp(t) + + tests := []struct { + name string + req helpcenter.HelpCenterRequest + wantErr bool + }{ + {"valid", helpcenter.HelpCenterRequest{Name: "Docs", Slug: "docs", PageTitle: "Help"}, false}, + {"empty name", helpcenter.HelpCenterRequest{Slug: "docs", PageTitle: "Help"}, true}, + {"whitespace name", helpcenter.HelpCenterRequest{Name: " ", Slug: "docs", PageTitle: "Help"}, true}, + {"empty slug", helpcenter.HelpCenterRequest{Name: "Docs", PageTitle: "Help"}, true}, + {"whitespace slug", helpcenter.HelpCenterRequest{Name: "Docs", Slug: " \t ", PageTitle: "Help"}, true}, + {"empty page title", helpcenter.HelpCenterRequest{Name: "Docs", Slug: "docs"}, true}, + {"whitespace page title", helpcenter.HelpCenterRequest{Name: "Docs", Slug: "docs", PageTitle: " "}, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertValidation(t, validateHelpCenter(app, &tc.req), tc.wantErr) + }) + } +} + +func TestValidateHelpCenterStripsBaseURLFromTheme(t *testing.T) { + app := newValidatorTestApp(t) + req := helpcenter.HelpCenterRequest{ + Name: " Docs ", + Slug: " docs ", + PageTitle: " Help ", + Theme: json.RawMessage(`{"logo_url":"` + testAppBaseURL + `/uploads/abc"}`), + } + + if err := validateHelpCenter(app, &req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Name != "Docs" || req.Slug != "docs" || req.PageTitle != "Help" { + t.Fatalf("fields not trimmed: %q %q %q", req.Name, req.Slug, req.PageTitle) + } + if got, want := string(req.Theme), `{"logo_url":"/uploads/abc"}`; got != want { + t.Fatalf("got theme %s, want %s", got, want) + } +} + +func TestValidateCollection(t *testing.T) { + app := newValidatorTestApp(t) + + tests := []struct { + name string + req helpcenter.CollectionRequest + wantErr bool + }{ + {"valid", helpcenter.CollectionRequest{Name: "Billing"}, false}, + {"empty name", helpcenter.CollectionRequest{}, true}, + {"whitespace name", helpcenter.CollectionRequest{Name: " "}, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assertValidation(t, validateCollection(app, &tc.req), tc.wantErr) + }) + } +} + +func TestValidateCollectionTrimsName(t *testing.T) { + app := newValidatorTestApp(t) + req := helpcenter.CollectionRequest{Name: " Billing "} + + if err := validateCollection(app, &req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.Name != "Billing" { + t.Fatalf("got name %q, want Billing", req.Name) + } +} + +func TestValidateArticle(t *testing.T) { + app := newValidatorTestApp(t) + + tests := []struct { + name string + req helpcenter.ArticleRequest + wantErr bool + }{ + {"valid", helpcenter.ArticleRequest{Title: "Refunds", Content: "

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 @@ @@ -344,6 +353,7 @@ const viewToDelete = ref(null) + @@ -395,6 +405,7 @@ const viewToDelete = ref(null) + @@ -413,6 +424,7 @@ const viewToDelete = ref(null) + @@ -429,6 +441,7 @@ const viewToDelete = ref(null) + @@ -453,6 +466,7 @@ const viewToDelete = ref(null) + @@ -541,7 +555,7 @@ const viewToDelete = ref(null) {{ view.name }} - - - + + + - - - - {{ t('globals.messages.edit') }} - - - {{ t('globals.messages.delete') }} - - - - + + + + + {{ t('globals.messages.edit') }} + + + {{ t('globals.messages.delete') }} + + + @@ -632,6 +641,7 @@ const viewToDelete = ref(null) + diff --git a/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue b/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue index 7bbe8e8d..cc205320 100644 --- a/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue +++ b/frontend/apps/main/src/components/sidebar/SidebarNavUser.vue @@ -27,10 +27,10 @@ @@ -127,13 +127,15 @@ import { Avatar, AvatarFallback, AvatarImage } from '@shared-ui/components/ui/av import StatusDot from '@shared-ui/components/StatusDot.vue' import { Switch } from '@shared-ui/components/ui/switch' import { ChevronsUpDown, CircleUserRound, Keyboard, LogOut } from 'lucide-vue-next' -import { useUserStore } from '../../stores/user' +import { useUserStore } from '@main/stores/user' +import { useIsMobile } from '@shared-ui/composables' import { useRouter } from 'vue-router' -import KeyboardShortcutsDialog from '../KeyboardShortcutsDialog.vue' +import KeyboardShortcutsDialog from '@main/components/KeyboardShortcutsDialog.vue' import { useColorMode } from '@vueuse/core' import { ref } from 'vue' +const isMobile = useIsMobile() const mode = useColorMode() const userStore = useUserStore() const router = useRouter() diff --git a/frontend/apps/main/src/composables/useIsComposerCramped.js b/frontend/apps/main/src/composables/useIsComposerCramped.js new file mode 100644 index 00000000..ac531e13 --- /dev/null +++ b/frontend/apps/main/src/composables/useIsComposerCramped.js @@ -0,0 +1,6 @@ +import { useMediaQuery } from '@vueuse/core' + +// The inline composer needs ~280px of height. +export function useIsComposerCramped () { + return useMediaQuery('(max-width: 767px), (max-height: 500px) and (pointer: coarse)') +} diff --git a/frontend/apps/main/src/composables/useVisualViewportHeight.js b/frontend/apps/main/src/composables/useVisualViewportHeight.js new file mode 100644 index 00000000..5ec01da2 --- /dev/null +++ b/frontend/apps/main/src/composables/useVisualViewportHeight.js @@ -0,0 +1,18 @@ +import { onMounted, onUnmounted } from 'vue' + +// iOS Safari and Chrome Android keep dvh at full height while the soft keyboard is up; visualViewport tracks the space actually left. +export function useVisualViewportHeight () { + const viewport = window.visualViewport + const update = () => + document.documentElement.style.setProperty( + '--visual-viewport-height', + `${viewport?.height || window.innerHeight}px` + ) + + onMounted(() => { + update() + ;(viewport || window).addEventListener('resize', update) + }) + + onUnmounted(() => (viewport || window).removeEventListener('resize', update)) +} diff --git a/frontend/apps/main/src/features/admin/agents/schema.test.js b/frontend/apps/main/src/features/admin/agents/schema.test.js index 01889c38..53bf8cf5 100644 --- a/frontend/apps/main/src/features/admin/agents/schema.test.js +++ b/frontend/apps/main/src/features/admin/agents/schema.test.js @@ -12,7 +12,6 @@ const validForm = { } describe('Form Schema', () => { - // Valid cases test('valid complete form', () => { expect(() => schema.parse(validForm)).not.toThrow() }) @@ -25,7 +24,6 @@ describe('Form Schema', () => { })).not.toThrow() }) - // First name tests test('first_name too short', () => { expect(() => schema.parse({ ...validForm, first_name: 'J' })).toThrow() }) @@ -47,7 +45,6 @@ describe('Form Schema', () => { expect(() => schema.parse({ ...validForm, first_name: null })).toThrow() }) - // Email tests test('invalid email format', () => { expect(() => schema.parse({ ...validForm, email: 'invalid' })).toThrow() }) @@ -69,7 +66,6 @@ describe('Form Schema', () => { expect(() => schema.parse(form)).toThrow() }) - // Roles tests test('roles empty array', () => { expect(() => schema.parse({ ...validForm, roles: [] })).toThrow() }) @@ -83,7 +79,6 @@ describe('Form Schema', () => { expect(() => schema.parse({ ...validForm, roles: ['admin', 'user', 'moderator'] })).not.toThrow() }) - // Password tests test('password too short', () => { expect(() => schema.parse({ ...validForm, new_password: 'Pass1!' })).toThrow() }) @@ -128,7 +123,6 @@ describe('Form Schema', () => { expect(() => schema.parse({ ...validForm, new_password: 'P'.repeat(69) + 'a1!' })).not.toThrow() }) - // Optional fields test('last_name optional', () => { expect(() => schema.parse(validForm)).not.toThrow() expect(() => schema.parse({ ...validForm, last_name: 'Doe' })).not.toThrow() @@ -159,7 +153,6 @@ describe('Form Schema', () => { expect(() => schema.parse({ ...validForm, teams: ['team1', 'team2'] })).not.toThrow() }) - // Edge cases test('undefined values', () => { expect(() => schema.parse({ first_name: undefined, @@ -203,7 +196,6 @@ describe('Form Schema', () => { // Password regex validation tests describe('Password Regex Validation', () => { - // Lowercase tests test('lowercase - single letter', () => { expect(() => schema.parse({ ...validForm, new_password: 'PASSWORD123!a' })).not.toThrow() }) @@ -220,7 +212,6 @@ describe('Password Regex Validation', () => { expect(() => schema.parse({ ...validForm, new_password: 'PASSWORD123!' })).toThrow() }) - // Uppercase tests test('uppercase - single letter', () => { expect(() => schema.parse({ ...validForm, new_password: 'passwordA123!' })).not.toThrow() }) @@ -237,7 +228,6 @@ describe('Password Regex Validation', () => { expect(() => schema.parse({ ...validForm, new_password: 'password123!' })).toThrow() }) - // Digit tests test('digit - single number', () => { expect(() => schema.parse({ ...validForm, new_password: 'Password1!' })).not.toThrow() }) @@ -254,7 +244,6 @@ describe('Password Regex Validation', () => { expect(() => schema.parse({ ...validForm, new_password: 'Password!' })).toThrow() }) - // Special character tests test('special - common symbols', () => { expect(() => schema.parse({ ...validForm, new_password: 'Password123!' })).not.toThrow() expect(() => schema.parse({ ...validForm, new_password: 'Password123@' })).not.toThrow() @@ -311,7 +300,6 @@ describe('Password Regex Validation', () => { expect(() => schema.parse({ ...validForm, new_password: 'Password123' })).toThrow() }) - // Combination edge cases test('only uppercase and special', () => { expect(() => schema.parse({ ...validForm, new_password: 'PASSWORD!@#$%^&*()' })).toThrow() }) diff --git a/frontend/apps/main/src/features/admin/automation/formSchema.test.js b/frontend/apps/main/src/features/admin/automation/formSchema.test.js new file mode 100644 index 00000000..62eeb7a1 --- /dev/null +++ b/frontend/apps/main/src/features/admin/automation/formSchema.test.js @@ -0,0 +1,85 @@ +import { describe, test, expect } from 'vitest' +import { createFormSchema } from './formSchema' + +const mockT = (key, params) => `${key} ${JSON.stringify(params || {})}` +const schema = createFormSchema(mockT) + +const validForm = { + name: 'Auto assign', + type: 'new_conversation' +} + +describe('Automation Form Schema', () => { + test('valid minimal form', () => { + expect(() => schema.parse(validForm)).not.toThrow() + }) + + test('valid complete form', () => { + expect(() => schema.parse({ + name: 'Auto assign', + description: 'Assigns new conversations', + enabled: false, + type: 'conversation_update', + events: ['status_change'] + })).not.toThrow() + }) + + test('name missing', () => { + const { name, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('name null', () => { + expect(() => schema.parse({ ...validForm, name: null })).toThrow() + }) + + test('name not a string', () => { + expect(() => schema.parse({ ...validForm, name: 123 })).toThrow() + }) + + test('type missing', () => { + const { type, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('type not a string', () => { + expect(() => schema.parse({ ...validForm, type: 1 })).toThrow() + }) + + test('conversation_update without events', () => { + expect(() => schema.parse({ ...validForm, type: 'conversation_update' })).toThrow() + }) + + test('conversation_update with empty events', () => { + expect(() => schema.parse({ ...validForm, type: 'conversation_update', events: [] })).toThrow() + }) + + test('conversation_update with one event', () => { + expect(() => schema.parse({ ...validForm, type: 'conversation_update', events: ['status_change'] })).not.toThrow() + }) + + test('events optional for other types', () => { + expect(() => schema.parse({ ...validForm, type: 'new_conversation' })).not.toThrow() + expect(() => schema.parse({ ...validForm, type: 'new_conversation', events: [] })).not.toThrow() + }) + + test('events not an array', () => { + expect(() => schema.parse({ ...validForm, events: 'status_change' })).toThrow() + }) + + test('description defaults to empty string', () => { + expect(schema.parse(validForm).description).toBe('') + }) + + test('enabled defaults to true', () => { + expect(schema.parse(validForm).enabled).toBe(true) + }) + + test('enabled must be boolean', () => { + expect(() => schema.parse({ ...validForm, enabled: 'true' })).toThrow() + }) + + test('empty object', () => { + expect(() => schema.parse({})).toThrow() + }) +}) diff --git a/frontend/apps/main/src/features/admin/business-hours/BusinessHoursForm.vue b/frontend/apps/main/src/features/admin/business-hours/BusinessHoursForm.vue index 55243275..d0b53f6c 100644 --- a/frontend/apps/main/src/features/admin/business-hours/BusinessHoursForm.vue +++ b/frontend/apps/main/src/features/admin/business-hours/BusinessHoursForm.vue @@ -307,7 +307,10 @@ const initializeFromValues = (values) => { // Set hours and selected days if (values.hours && typeof values.hours === 'object') { - hours.value = { ...values.hours } + // Copy each day: mutating a shared day object retriggers the watcher and resets the form. + hours.value = Object.fromEntries( + Object.entries(values.hours).map(([day, hour]) => [day, { ...hour }]) + ) selectedDays.value = Object.keys(values.hours).reduce((acc, day) => { acc[day] = true return acc @@ -316,7 +319,7 @@ const initializeFromValues = (values) => { // Set holidays if (values.holidays) { - holidays.push(...values.holidays) + holidays.push(...values.holidays.map((holiday) => ({ ...holiday }))) } // Update form diff --git a/frontend/apps/main/src/features/admin/business-hours/formSchema.test.js b/frontend/apps/main/src/features/admin/business-hours/formSchema.test.js new file mode 100644 index 00000000..378aa2b0 --- /dev/null +++ b/frontend/apps/main/src/features/admin/business-hours/formSchema.test.js @@ -0,0 +1,100 @@ +import { describe, test, expect } from 'vitest' +import { createFormSchema } from './formSchema' + +const mockT = (key, params) => `${key} ${JSON.stringify(params || {})}` +const schema = createFormSchema(mockT) + +const validForm = { + name: 'Weekdays', + is_always_open: false, + hours: { + monday: { open: '09:00', close: '17:30' } + } +} + +describe('Business Hours Form Schema', () => { + test('valid complete form', () => { + expect(() => schema.parse(validForm)).not.toThrow() + }) + + test('valid always open form without hours', () => { + expect(() => schema.parse({ name: 'Always', is_always_open: true })).not.toThrow() + }) + + test('name missing', () => { + const { name, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('name empty string', () => { + expect(() => schema.parse({ ...validForm, name: '' })).toThrow() + }) + + test('name single character accepted', () => { + expect(() => schema.parse({ ...validForm, name: 'a' })).not.toThrow() + }) + + // is_always_open + test('is_always_open missing', () => { + const { is_always_open, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('is_always_open not a boolean', () => { + expect(() => schema.parse({ ...validForm, is_always_open: 'false' })).toThrow() + }) + + test('hours required when not always open', () => { + expect(() => schema.parse({ name: 'Weekdays', is_always_open: false })).toThrow() + }) + + test('hours empty object when not always open', () => { + expect(() => schema.parse({ ...validForm, hours: {} })).toThrow() + }) + + test('hours with multiple days', () => { + expect(() => schema.parse({ + ...validForm, + hours: { + monday: { open: '09:00', close: '17:00' }, + tuesday: { open: '10:15', close: '18:45' } + } + })).not.toThrow() + }) + + test('open time invalid format', () => { + expect(() => schema.parse({ ...validForm, hours: { monday: { open: '9:00', close: '17:00' } } })).toThrow() + }) + + test('close time invalid hour', () => { + expect(() => schema.parse({ ...validForm, hours: { monday: { open: '09:00', close: '24:00' } } })).toThrow() + }) + + test('close time invalid minute', () => { + expect(() => schema.parse({ ...validForm, hours: { monday: { open: '09:00', close: '17:60' } } })).toThrow() + }) + + test('boundary times accepted', () => { + expect(() => schema.parse({ ...validForm, hours: { monday: { open: '00:00', close: '23:59' } } })).not.toThrow() + }) + + test('close time missing', () => { + expect(() => schema.parse({ ...validForm, hours: { monday: { open: '09:00' } } })).toThrow() + }) + + test('description optional', () => { + expect(() => schema.parse({ ...validForm, description: 'Office hours' })).not.toThrow() + }) + + test('description null becomes empty string', () => { + expect(schema.parse({ ...validForm, description: null }).description).toBe('') + }) + + test('description omitted becomes empty string', () => { + expect(schema.parse(validForm).description).toBe('') + }) + + test('empty object', () => { + expect(() => schema.parse({})).toThrow() + }) +}) diff --git a/frontend/apps/main/src/features/admin/context-links/formSchema.js b/frontend/apps/main/src/features/admin/context-links/formSchema.js index 6bdfe815..119e98bb 100644 --- a/frontend/apps/main/src/features/admin/context-links/formSchema.js +++ b/frontend/apps/main/src/features/admin/context-links/formSchema.js @@ -18,5 +18,5 @@ export const createFormSchema = (t) => }), secret: z.string().optional(), token_expiry_seconds: z.coerce.number().int().min(1).default(1200), - is_active: z.boolean().default(true).optional() + is_active: z.boolean().optional().default(true) }) diff --git a/frontend/apps/main/src/features/admin/context-links/formSchema.test.js b/frontend/apps/main/src/features/admin/context-links/formSchema.test.js new file mode 100644 index 00000000..d20b2fe2 --- /dev/null +++ b/frontend/apps/main/src/features/admin/context-links/formSchema.test.js @@ -0,0 +1,83 @@ +import { describe, test, expect } from 'vitest' +import { createFormSchema } from './formSchema' + +const mockT = (key, params) => `${key} ${JSON.stringify(params || {})}` +const schema = createFormSchema(mockT) + +const validForm = { + name: 'CRM', + url_template: 'https://crm.example.com/{{.Contact.Email}}' +} + +describe('Context Links Form Schema', () => { + test('valid minimal form', () => { + expect(() => schema.parse(validForm)).not.toThrow() + }) + + test('valid complete form', () => { + expect(() => schema.parse({ + ...validForm, + secret: 's3cret', + token_expiry_seconds: 60, + is_active: false + })).not.toThrow() + }) + + test('name missing', () => { + const { name, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('name empty string', () => { + expect(() => schema.parse({ ...validForm, name: '' })).toThrow() + }) + + test('name single character accepted', () => { + expect(() => schema.parse({ ...validForm, name: 'a' })).not.toThrow() + }) + + test('url_template missing', () => { + const { url_template, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('url_template empty string', () => { + expect(() => schema.parse({ ...validForm, url_template: '' })).toThrow() + }) + + test('token_expiry_seconds defaults to 1200', () => { + expect(schema.parse(validForm).token_expiry_seconds).toBe(1200) + }) + + test('token_expiry_seconds below minimum', () => { + expect(() => schema.parse({ ...validForm, token_expiry_seconds: 0 })).toThrow() + }) + + test('token_expiry_seconds at minimum', () => { + expect(() => schema.parse({ ...validForm, token_expiry_seconds: 1 })).not.toThrow() + }) + + test('token_expiry_seconds not an integer', () => { + expect(() => schema.parse({ ...validForm, token_expiry_seconds: 1.5 })).toThrow() + }) + + test('token_expiry_seconds coerced from string', () => { + expect(schema.parse({ ...validForm, token_expiry_seconds: '300' }).token_expiry_seconds).toBe(300) + }) + + test('secret optional', () => { + expect(() => schema.parse({ ...validForm, secret: '' })).not.toThrow() + }) + + test('is_active defaults to true', () => { + expect(schema.parse(validForm).is_active).toBe(true) + }) + + test('is_active must be boolean', () => { + expect(() => schema.parse({ ...validForm, is_active: 'yes' })).toThrow() + }) + + test('empty object', () => { + expect(() => schema.parse({})).toThrow() + }) +}) diff --git a/frontend/apps/main/src/features/admin/custom-attributes/formSchema.test.js b/frontend/apps/main/src/features/admin/custom-attributes/formSchema.test.js new file mode 100644 index 00000000..03775449 --- /dev/null +++ b/frontend/apps/main/src/features/admin/custom-attributes/formSchema.test.js @@ -0,0 +1,156 @@ +import { describe, test, expect } from 'vitest' +import { createFormSchema } from './formSchema' + +const mockT = (key, params) => `${key} ${JSON.stringify(params || {})}` +const schema = createFormSchema(mockT) + +const validForm = { + applies_to: 'contact', + name: 'Account tier', + key: 'account_tier', + description: 'Tier of the customer account', + data_type: 'text' +} + +describe('Custom Attributes Form Schema', () => { + test('valid minimal form', () => { + expect(() => schema.parse(validForm)).not.toThrow() + }) + + test('valid list form', () => { + expect(() => schema.parse({ ...validForm, data_type: 'list', values: ['gold'] })).not.toThrow() + }) + + // applies_to + test('applies_to missing', () => { + const { applies_to, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('applies_to invalid value', () => { + expect(() => schema.parse({ ...validForm, applies_to: 'team' })).toThrow() + }) + + test('applies_to conversation accepted', () => { + expect(() => schema.parse({ ...validForm, applies_to: 'conversation' })).not.toThrow() + }) + + test('name missing', () => { + const { name, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('name too short', () => { + expect(() => schema.parse({ ...validForm, name: 'ab' })).toThrow() + }) + + test('name at minimum length', () => { + expect(() => schema.parse({ ...validForm, name: 'abc' })).not.toThrow() + }) + + test('name too long', () => { + expect(() => schema.parse({ ...validForm, name: 'a'.repeat(141) })).toThrow() + }) + + test('name at maximum length', () => { + expect(() => schema.parse({ ...validForm, name: 'a'.repeat(140) })).not.toThrow() + }) + + test('key missing', () => { + const { key, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('key too short', () => { + expect(() => schema.parse({ ...validForm, key: 'ab' })).toThrow() + }) + + test('key at minimum length', () => { + expect(() => schema.parse({ ...validForm, key: 'abc' })).not.toThrow() + }) + + test('key too long', () => { + expect(() => schema.parse({ ...validForm, key: 'a'.repeat(141) })).toThrow() + }) + + test('key with uppercase rejected', () => { + expect(() => schema.parse({ ...validForm, key: 'Account_tier' })).toThrow() + }) + + test('key with dash rejected', () => { + expect(() => schema.parse({ ...validForm, key: 'account-tier' })).toThrow() + }) + + test('key with space rejected', () => { + expect(() => schema.parse({ ...validForm, key: 'account tier' })).toThrow() + }) + + test('key with digits and underscores accepted', () => { + expect(() => schema.parse({ ...validForm, key: 'account_tier_2' })).not.toThrow() + }) + + test('description missing', () => { + const { description, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('description too short', () => { + expect(() => schema.parse({ ...validForm, description: 'ab' })).toThrow() + }) + + test('description at minimum length', () => { + expect(() => schema.parse({ ...validForm, description: 'abc' })).not.toThrow() + }) + + test('description too long', () => { + expect(() => schema.parse({ ...validForm, description: 'a'.repeat(301) })).toThrow() + }) + + test('description at maximum length', () => { + expect(() => schema.parse({ ...validForm, description: 'a'.repeat(300) })).not.toThrow() + }) + + // data_type + test('data_type missing', () => { + const { data_type, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('data_type invalid value', () => { + expect(() => schema.parse({ ...validForm, data_type: 'json' })).toThrow() + }) + + test('all data_type values accepted', () => { + for (const dt of ['text', 'number', 'checkbox', 'date', 'link']) { + expect(() => schema.parse({ ...validForm, data_type: dt })).not.toThrow() + } + }) + + test('values required for list type', () => { + expect(() => schema.parse({ ...validForm, data_type: 'list' })).toThrow() + }) + + test('values empty for list type', () => { + expect(() => schema.parse({ ...validForm, data_type: 'list', values: [] })).toThrow() + }) + + test('values defaults to empty array', () => { + expect(schema.parse(validForm).values).toEqual([]) + }) + + test('id optional', () => { + expect(() => schema.parse({ ...validForm, id: 3 })).not.toThrow() + }) + + test('id must be a number', () => { + expect(() => schema.parse({ ...validForm, id: '3' })).toThrow() + }) + + test('regex and regex_hint optional', () => { + expect(() => schema.parse({ ...validForm, regex: '^a+$', regex_hint: 'letters' })).not.toThrow() + }) + + test('empty object', () => { + expect(() => schema.parse({})).toThrow() + }) +}) diff --git a/frontend/apps/main/src/features/admin/general/formSchema.js b/frontend/apps/main/src/features/admin/general/formSchema.js index 6b7afb47..50895b47 100644 --- a/frontend/apps/main/src/features/admin/general/formSchema.js +++ b/frontend/apps/main/src/features/admin/general/formSchema.js @@ -39,6 +39,6 @@ export const createFormSchema = (t) => z.object({ .max(500, { message: t('admin.general.maxAllowedFileUploadSize.valid') }), - allowed_file_upload_extensions: z.array(z.string()).nullable().default([]).optional(), + allowed_file_upload_extensions: z.array(z.string()).nullable().optional().default([]), show_conversation_subject: z.boolean().optional() }) diff --git a/frontend/apps/main/src/features/admin/general/formSchema.test.js b/frontend/apps/main/src/features/admin/general/formSchema.test.js new file mode 100644 index 00000000..e83dc243 --- /dev/null +++ b/frontend/apps/main/src/features/admin/general/formSchema.test.js @@ -0,0 +1,123 @@ +import { describe, test, expect } from 'vitest' +import { createFormSchema } from './formSchema' + +const mockT = (key, params) => `${key} ${JSON.stringify(params || {})}` +const schema = createFormSchema(mockT) + +const validForm = { + site_name: 'Support', + root_url: 'https://support.example.com', + favicon_url: 'https://support.example.com/favicon.ico', + max_file_upload_size: 20 +} + +describe('General Form Schema', () => { + test('valid minimal form', () => { + expect(() => schema.parse(validForm)).not.toThrow() + }) + + test('valid complete form', () => { + expect(() => schema.parse({ + ...validForm, + lang: 'en', + timezone: 'Asia/Kolkata', + business_hours_id: '1', + logo_url: 'https://support.example.com/logo.png', + allowed_file_upload_extensions: ['png', 'pdf'], + show_conversation_subject: true + })).not.toThrow() + }) + + test('site_name missing', () => { + const { site_name, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('site_name empty string', () => { + expect(() => schema.parse({ ...validForm, site_name: '' })).toThrow() + }) + + test('site_name single character accepted', () => { + expect(() => schema.parse({ ...validForm, site_name: 'a' })).not.toThrow() + }) + + test('root_url missing', () => { + const { root_url, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('root_url not a url', () => { + expect(() => schema.parse({ ...validForm, root_url: 'support.example.com' })).toThrow() + }) + + test('root_url empty string', () => { + expect(() => schema.parse({ ...validForm, root_url: '' })).toThrow() + }) + + test('root_url with port and path accepted', () => { + expect(() => schema.parse({ ...validForm, root_url: 'http://localhost:9000/desk' })).not.toThrow() + }) + + test('favicon_url missing', () => { + const { favicon_url, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('favicon_url not a url', () => { + expect(() => schema.parse({ ...validForm, favicon_url: '/favicon.ico' })).toThrow() + }) + + test('logo_url optional', () => { + expect(() => schema.parse(validForm)).not.toThrow() + }) + + test('logo_url empty string accepted', () => { + expect(() => schema.parse({ ...validForm, logo_url: '' })).not.toThrow() + }) + + test('logo_url invalid url', () => { + expect(() => schema.parse({ ...validForm, logo_url: 'not-a-url' })).toThrow() + }) + + test('max_file_upload_size missing', () => { + const { max_file_upload_size, ...form } = validForm + expect(() => schema.parse(form)).toThrow() + }) + + test('max_file_upload_size below minimum', () => { + expect(() => schema.parse({ ...validForm, max_file_upload_size: 0 })).toThrow() + }) + + test('max_file_upload_size at minimum', () => { + expect(() => schema.parse({ ...validForm, max_file_upload_size: 1 })).not.toThrow() + }) + + test('max_file_upload_size above maximum', () => { + expect(() => schema.parse({ ...validForm, max_file_upload_size: 501 })).toThrow() + }) + + test('max_file_upload_size at maximum', () => { + expect(() => schema.parse({ ...validForm, max_file_upload_size: 500 })).not.toThrow() + }) + + test('max_file_upload_size as string', () => { + expect(() => schema.parse({ ...validForm, max_file_upload_size: '20' })).toThrow() + }) + + // Optional fields + test('allowed_file_upload_extensions defaults to empty array', () => { + expect(schema.parse(validForm).allowed_file_upload_extensions).toEqual([]) + }) + + test('allowed_file_upload_extensions accepts null', () => { + expect(() => schema.parse({ ...validForm, allowed_file_upload_extensions: null })).not.toThrow() + }) + + test('show_conversation_subject must be boolean', () => { + expect(() => schema.parse({ ...validForm, show_conversation_subject: 'true' })).toThrow() + }) + + test('empty object', () => { + expect(() => schema.parse({})).toThrow() + }) +}) diff --git a/frontend/apps/main/src/features/admin/help-center/HelpCenterBasicsForm.vue b/frontend/apps/main/src/features/admin/help-center/HelpCenterBasicsForm.vue index cbd8c4cf..31344c2c 100644 --- a/frontend/apps/main/src/features/admin/help-center/HelpCenterBasicsForm.vue +++ b/frontend/apps/main/src/features/admin/help-center/HelpCenterBasicsForm.vue @@ -124,6 +124,8 @@ const generateSlug = () => { form.setFieldValue( 'slug', form.values.name + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') .trim() .toLowerCase() .replace(/\s+/g, '-') diff --git a/frontend/apps/main/src/features/admin/help-center/HelpCenterForm.vue b/frontend/apps/main/src/features/admin/help-center/HelpCenterForm.vue index 30c55b11..db5e9db6 100644 --- a/frontend/apps/main/src/features/admin/help-center/HelpCenterForm.vue +++ b/frontend/apps/main/src/features/admin/help-center/HelpCenterForm.vue @@ -101,7 +101,7 @@ @@ -215,7 +215,7 @@
- {{ t('helpCenter.styling.headerBackground') }} + {{ t('globals.terms.background') }} - {{ t('helpCenter.styling.footerTagline') }} + {{ t('helpCenter.styling.tagline') }}