Files
gochat/backend/internal/handler/api/v1/inbox_serializer.go
T
Rogeeandrogee f719529d66 fix(security): harden auth and secret handling (HH-444) (#101)
* fix(security): harden auth and credential handling (HH-444)

* fix(security): address HH-444 review blockers

* fix(security): close remaining HH-444 review blockers

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-22 15:45:06 +08:00

420 lines
15 KiB
Go

package v1
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/gochat/gochat/internal/model"
"gorm.io/gorm"
)
func inboxListResponse(inboxes []model.Inbox, db *gorm.DB, isAdmin bool) map[string]any {
payload := make([]any, 0, len(inboxes))
for i := range inboxes {
payload = append(payload, serializeInbox(&inboxes[i], db, isAdmin))
}
return map[string]any{"payload": payload}
}
func serializeInbox(inbox *model.Inbox, db *gorm.DB, isAdmin bool) map[string]any {
config := parseInboxChannelConfig(inbox.ChannelConfig)
payload := map[string]any{
"id": inbox.ID,
"avatar_url": inbox.AvatarURL,
"channel_id": inbox.ChannelID,
"name": inbox.Name,
"channel_type": chatwootChannelType(inbox.ChannelType),
"greeting_enabled": inbox.GreetingEnabled,
"greeting_message": inbox.GreetingMessage,
"working_hours_enabled": inbox.WorkingHoursEnabled,
"enable_email_collect": inbox.EnableEmailCollect,
"csat_survey_enabled": inbox.CsatSurveyEnabled,
"csat_config": jsonStringObject(inbox.CsatConfig),
"enable_auto_assignment": inbox.EnableAutoAssignment,
"auto_assignment_config": configValue(config, "auto_assignment_config"),
"out_of_office_message": inbox.OutOfOfficeMessage,
"working_hours": serializeInboxWorkingHours(inbox, config),
"timezone": inbox.Timezone,
"callback_webhook_url": callbackWebhookURL(inbox, config),
"allow_messages_after_resolved": inbox.AllowMessagesAfterResolved,
"lock_to_single_conversation": inbox.LockToSingleConversation,
"sender_name_type": chatwootSenderNameType(inbox.SenderNameType),
"business_name": inbox.BusinessName,
"allowed_domains": configValue(config, "allowed_domains"),
"widget_color": configValue(config, "widget_color"),
"website_url": configValue(config, "website_url"),
"hmac_mandatory": configValue(config, "hmac_mandatory"),
"welcome_title": configValue(config, "welcome_title"),
"welcome_tagline": configValue(config, "welcome_tagline"),
"web_widget_script": webWidgetScript(inbox, config),
"website_token": configValue(config, "website_token"),
"selected_feature_flags": configValue(config, "selected_feature_flags"),
"reply_time": configValue(config, "reply_time"),
"provider": configValue(config, "provider"),
}
switch chatwootChannelType(inbox.ChannelType) {
case "Channel::WebWidget":
if isAdmin {
payload["hmac_token"] = configValue(config, "hmac_token")
}
payload["pre_chat_form_enabled"] = configValue(config, "pre_chat_form_enabled")
payload["pre_chat_form_options"] = configValue(config, "pre_chat_form_options")
payload["continuity_via_email"] = configValue(config, "continuity_via_email")
case "Channel::Api":
if isAdmin {
payload["hmac_token"] = configValue(config, "hmac_token")
payload["secret"] = inbox.Secret
}
payload["webhook_url"] = inbox.WebhookURL
payload["inbox_identifier"] = firstConfigValue(config, "inbox_identifier", "identifier")
payload["additional_attributes"] = configValue(config, "additional_attributes")
case "Channel::Shangwutong":
payload["webhook_url"] = inbox.WebhookURL
if db != nil {
var channelConfig model.ChannelShangwutongConfig
if err := db.Where("inbox_id = ?", inbox.ID).First(&channelConfig).Error; err == nil {
payload["password_configured"] = channelConfig.Password != ""
payload["config_version"] = channelConfig.ConfigVersion
payload["desired_presence"] = channelConfig.DesiredPresence
payload["actual_presence"] = channelConfig.ActualPresence
payload["connection_status"] = channelConfig.ConnectionStatus
payload["credential_status"] = channelConfig.CredentialStatus
payload["last_heartbeat_at"] = channelConfig.LastHeartbeatAt
payload["last_error_code"] = channelConfig.LastErrorCode
}
}
case "Channel::Telegram":
payload["bot_name"] = configValue(config, "bot_name")
case "Channel::FacebookPage":
payload["page_id"] = configValue(config, "page_id")
payload["reauthorization_required"] = configValue(config, "reauthorization_required")
if isAdmin {
payload["fb_app_id"] = configValue(config, "fb_app_id")
}
case "Channel::Instagram":
payload["instagram_id"] = configValue(config, "instagram_id")
payload["reauthorization_required"] = configValue(config, "reauthorization_required")
case "Channel::TwitterProfile":
payload["tweets_enabled"] = configBoolDefault(config, "tweets_enabled", true)
case "Channel::Tiktok":
payload["reauthorization_required"] = configValue(config, "reauthorization_required")
case "Channel::TwilioSms":
payload["messaging_service_sid"] = configValue(config, "messaging_service_sid")
payload["phone_number"] = configValue(config, "phone_number")
payload["medium"] = configValue(config, "medium")
payload["content_templates"] = configValue(config, "content_templates")
if isAdmin {
payload["auth_token"] = configValue(config, "auth_token")
payload["account_sid"] = configValue(config, "account_sid")
payload["api_key_sid"] = configValue(config, "api_key_sid")
}
payload["voice_enabled"] = configValue(config, "voice_enabled")
payload["inbound_calls_enabled"] = configBoolDefault(config, "inbound_calls_enabled", true)
voiceConfigured := configStringPresent(config, "twiml_app_sid")
payload["voice_configured"] = voiceConfigured
payload["has_api_key_secret"] = configStringPresent(config, "api_key_secret")
if voiceConfigured {
payload["voice_call_webhook_url"] = configValue(config, "voice_call_webhook_url")
payload["voice_status_webhook_url"] = configValue(config, "voice_status_webhook_url")
}
case "Channel::Email":
payload["email"] = configValue(config, "email")
forwardingEnabled := strings.TrimSpace(os.Getenv("MAILER_INBOUND_EMAIL_DOMAIN")) != ""
payload["forwarding_enabled"] = forwardingEnabled
if forwardingEnabled {
payload["forward_to_email"] = configValue(config, "forward_to_email")
}
if isAdmin {
payload["imap_login"] = configValue(config, "imap_login")
payload["imap_password"] = maskedSecret(configValue(config, "imap_password"))
payload["imap_password_configured"] = configStringPresent(config, "imap_password")
payload["imap_address"] = configValue(config, "imap_address")
payload["imap_port"] = configValue(config, "imap_port")
payload["imap_enabled"] = configValue(config, "imap_enabled")
payload["imap_enable_ssl"] = configValue(config, "imap_enable_ssl")
payload["imap_authentication"] = configValue(config, "imap_authentication")
payload["smtp_login"] = configValue(config, "smtp_login")
payload["smtp_password"] = maskedSecret(configValue(config, "smtp_password"))
payload["smtp_password_configured"] = configStringPresent(config, "smtp_password")
payload["smtp_address"] = configValue(config, "smtp_address")
payload["smtp_port"] = configValue(config, "smtp_port")
payload["smtp_enabled"] = configValue(config, "smtp_enabled")
payload["smtp_domain"] = configValue(config, "smtp_domain")
payload["smtp_enable_ssl_tls"] = configValue(config, "smtp_enable_ssl_tls")
payload["smtp_enable_starttls_auto"] = configValue(config, "smtp_enable_starttls_auto")
payload["smtp_openssl_verify_mode"] = configValue(config, "smtp_openssl_verify_mode")
payload["smtp_authentication"] = configValue(config, "smtp_authentication")
if emailOauthProvider(config) {
payload["reauthorization_required"] = configObjectEmpty(config, "provider_config") || configBoolDefault(config, "reauthorization_required", false)
}
}
case "Channel::Whatsapp":
payload["phone_number"] = configValue(config, "phone_number")
payload["message_templates"] = configValue(config, "message_templates")
if isAdmin {
payload["provider_config"] = maskedProviderConfig(configValue(config, "provider_config"))
}
payload["reauthorization_required"] = configValue(config, "reauthorization_required")
payload["voice_enabled"] = configValue(config, "voice_enabled")
payload["inbound_calls_enabled"] = configBoolDefault(config, "inbound_calls_enabled", true)
case "Channel::Line":
payload["line_channel_id"] = configValue(config, "line_channel_id")
payload["line_channel_secret"] = configValue(config, "line_channel_secret")
payload["line_channel_token"] = configValue(config, "line_channel_token")
case "Channel::Sms":
payload["phone_number"] = configValue(config, "phone_number")
payload["provider_config"] = configValue(config, "provider_config")
}
if db != nil && inbox.PortalID != nil && *inbox.PortalID != 0 {
var portal model.Portal
if err := db.Where("id = ?", *inbox.PortalID).First(&portal).Error; err == nil {
payload["help_center"] = map[string]any{
"name": portal.Name,
"slug": portal.Slug,
}
}
}
return payload
}
func maskedSecret(value any) any {
if !secretConfigured(value) {
return value
}
return "***"
}
func secretConfigured(value any) bool {
if value == nil {
return false
}
if text, ok := value.(string); ok {
return strings.TrimSpace(text) != ""
}
return true
}
func maskedProviderConfig(value any) any {
config, ok := value.(map[string]any)
if !ok {
return value
}
masked := make(map[string]any, len(config))
for key, item := range config {
switch strings.ToLower(key) {
case "api_key", "api_secret", "access_token", "refresh_token", "webhook_verify_token", "verification_pin", "app_secret", "app_secret_key", "client_secret":
masked[key] = maskedSecret(item)
masked[key+"_configured"] = secretConfigured(item)
default:
masked[key] = item
}
}
return masked
}
func serializeInboxWorkingHours(inbox *model.Inbox, config map[string]any) any {
if len(inbox.WorkingHours) == 0 {
return configArray(config, "working_hours")
}
payload := make([]any, 0, len(inbox.WorkingHours))
for _, wh := range inbox.WorkingHours {
payload = append(payload, map[string]any{
"day_of_week": wh.DayOfWeek,
"closed_all_day": wh.ClosedAllDay,
"open_hour": wh.OpenHour,
"open_minutes": wh.OpenMinutes,
"close_hour": wh.CloseHour,
"close_minutes": wh.CloseMinutes,
"open_all_day": wh.OpenAllDay,
})
}
return payload
}
func chatwootChannelType(channelType string) string {
if strings.HasPrefix(channelType, "Channel::") {
return channelType
}
aliases := map[string]string{
"web_widget": "Channel::WebWidget",
"facebook": "Channel::FacebookPage",
"instagram": "Channel::Instagram",
"twitter": "Channel::TwitterProfile",
"twilio_sms": "Channel::TwilioSms",
"whatsapp": "Channel::Whatsapp",
"api": "Channel::Api",
"shangwutong": "Channel::Shangwutong",
"email": "Channel::Email",
"telegram": "Channel::Telegram",
"line": "Channel::Line",
"sms": "Channel::Sms",
"tiktok": "Channel::Tiktok",
}
if mapped, ok := aliases[channelType]; ok {
return mapped
}
return channelType
}
func chatwootSenderNameType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "friendly", "friendly_name":
return "friendly"
case "professional", "business_name":
return "professional"
default:
return value
}
}
func callbackWebhookURL(inbox *model.Inbox, config map[string]any) any {
baseURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/")
switch chatwootChannelType(inbox.ChannelType) {
case "Channel::TwilioSms":
return baseURL + "/twilio/callback"
case "Channel::Sms":
phoneNumber := strings.TrimPrefix(inboxConfigString(config, "phone_number"), "+")
return baseURL + "/webhooks/sms/" + phoneNumber
case "Channel::Line":
return baseURL + "/webhooks/line/" + inboxConfigString(config, "line_channel_id")
case "Channel::Whatsapp":
return baseURL + "/webhooks/whatsapp/" + inboxConfigString(config, "phone_number")
default:
return nil
}
}
func webWidgetScript(inbox *model.Inbox, config map[string]any) any {
if chatwootChannelType(inbox.ChannelType) != "Channel::WebWidget" {
return nil
}
websiteToken := strings.TrimSpace(inboxConfigString(config, "website_token"))
if websiteToken == "" {
return nil
}
baseURL := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/")
return fmt.Sprintf(`
<script>
(function(d,t) {
var BASE_URL=%q;
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+"/sdk/js/sdk.js";
g.async = true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
window.chatwootSDK.run({
websiteToken: %q,
baseUrl: BASE_URL
})
}
})(document,"script");
</script>
`, baseURL, websiteToken)
}
func parseInboxChannelConfig(raw string) map[string]any {
if raw == "" {
return map[string]any{}
}
var config map[string]any
if err := json.Unmarshal([]byte(raw), &config); err != nil {
return map[string]any{}
}
return config
}
func configValue(config map[string]any, key string) any {
if value, ok := config[key]; ok {
return value
}
return nil
}
func inboxConfigString(config map[string]any, key string) string {
value, ok := config[key]
if !ok || value == nil {
return ""
}
if s, ok := value.(string); ok {
return s
}
return ""
}
func firstConfigValue(config map[string]any, keys ...string) any {
for _, key := range keys {
if value, ok := config[key]; ok {
return value
}
}
return nil
}
func configBoolDefault(config map[string]any, key string, fallback bool) bool {
value, ok := config[key]
if !ok || value == nil {
return fallback
}
if parsed, ok := value.(bool); ok {
return parsed
}
return fallback
}
func configStringPresent(config map[string]any, key string) bool {
value, ok := config[key]
if !ok || value == nil {
return false
}
if s, ok := value.(string); ok {
return strings.TrimSpace(s) != ""
}
return true
}
func configObjectEmpty(config map[string]any, key string) bool {
value, ok := config[key]
if !ok || value == nil {
return true
}
if object, ok := value.(map[string]any); ok {
return len(object) == 0
}
return false
}
func emailOauthProvider(config map[string]any) bool {
provider, _ := config["provider"].(string)
switch strings.ToLower(strings.TrimSpace(provider)) {
case "microsoft", "google", "legacy_google":
return true
default:
return false
}
}
func configArray(config map[string]any, key string) any {
if value, ok := config[key]; ok {
return value
}
return []any{}
}
func jsonStringObject(raw string) any {
if raw == "" {
return map[string]any{}
}
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return map[string]any{}
}
return value
}