Merge branch 'develop' into feat/voice-as-twilio-capability
This commit is contained in:
@@ -40,6 +40,8 @@ gem 'json_refs'
|
||||
gem 'rack-attack', '>= 6.7.0'
|
||||
# a utility tool for streaming, flexible and safe downloading of remote files
|
||||
gem 'down'
|
||||
# SSRF-safe URL fetching
|
||||
gem 'ssrf_filter', '~> 1.5'
|
||||
# authentication type to fetch and send mail over oauth2.0
|
||||
gem 'gmail_xoauth'
|
||||
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
|
||||
|
||||
@@ -942,6 +942,7 @@ GEM
|
||||
activesupport (>= 5.2)
|
||||
sprockets (>= 3.0.0)
|
||||
squasher (0.7.2)
|
||||
ssrf_filter (1.5.0)
|
||||
stackprof (0.2.25)
|
||||
statsd-ruby (1.5.0)
|
||||
stripe (18.0.1)
|
||||
@@ -1158,6 +1159,7 @@ DEPENDENCIES
|
||||
spring
|
||||
spring-watcher-listen
|
||||
squasher
|
||||
ssrf_filter (~> 1.5)
|
||||
stackprof
|
||||
stripe (~> 18.0)
|
||||
telephone_number
|
||||
|
||||
@@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||
elsif params[:external_url].present?
|
||||
create_from_url
|
||||
else
|
||||
render_error('No file or URL provided', :unprocessable_entity)
|
||||
render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity)
|
||||
end
|
||||
|
||||
render_success(result) if result.is_a?(ActiveStorage::Blob)
|
||||
@@ -19,35 +19,21 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def create_from_url
|
||||
uri = parse_uri(params[:external_url])
|
||||
return if performed?
|
||||
|
||||
fetch_and_process_file_from_uri(uri)
|
||||
end
|
||||
|
||||
def parse_uri(url)
|
||||
uri = URI.parse(url)
|
||||
validate_uri(uri)
|
||||
uri
|
||||
rescue URI::InvalidURIError, SocketError
|
||||
render_error('Invalid URL provided', :unprocessable_entity)
|
||||
nil
|
||||
end
|
||||
|
||||
def validate_uri(uri)
|
||||
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
end
|
||||
|
||||
def fetch_and_process_file_from_uri(uri)
|
||||
uri.open do |file|
|
||||
create_and_save_blob(file, File.basename(uri.path), file.content_type)
|
||||
SafeFetch.fetch(params[:external_url].to_s) do |result|
|
||||
create_and_save_blob(result.tempfile, result.filename, result.content_type)
|
||||
end
|
||||
rescue OpenURI::HTTPError => e
|
||||
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
|
||||
rescue SocketError
|
||||
render_error('Invalid URL provided', :unprocessable_entity)
|
||||
rescue SafeFetch::HttpError => e
|
||||
render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity)
|
||||
rescue SafeFetch::FetchError
|
||||
render_error(I18n.t('errors.upload.fetch_failed'), :unprocessable_entity)
|
||||
rescue SafeFetch::FileTooLargeError
|
||||
render_error(I18n.t('errors.upload.file_too_large'), :unprocessable_entity)
|
||||
rescue SafeFetch::UnsupportedContentTypeError
|
||||
render_error(I18n.t('errors.upload.unsupported_content_type'), :unprocessable_entity)
|
||||
rescue SafeFetch::Error
|
||||
render_error(I18n.t('errors.upload.invalid_url'), :unprocessable_entity)
|
||||
rescue StandardError
|
||||
render_error('An unexpected error occurred', :internal_server_error)
|
||||
render_error(I18n.t('errors.upload.unexpected'), :internal_server_error)
|
||||
end
|
||||
|
||||
def create_and_save_blob(io, filename, content_type)
|
||||
|
||||
@@ -30,9 +30,20 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
locale: account_params[:locale],
|
||||
user: current_user
|
||||
).perform
|
||||
enqueue_branding_enrichment
|
||||
if @user
|
||||
send_auth_headers(@user)
|
||||
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
|
||||
# Authenticated users (dashboard "add account") and api_only signups
|
||||
# need the full response with account_id. API-only deployments have no
|
||||
# frontend to handle the email confirmation flow, so they need auth
|
||||
# tokens to proceed.
|
||||
# Unauthenticated web signup returns only the email — no session is
|
||||
# created until the user confirms via the email link.
|
||||
if current_user || api_only_signup?
|
||||
send_auth_headers(@user)
|
||||
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
|
||||
else
|
||||
render json: { email: @user.email }
|
||||
end
|
||||
else
|
||||
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
|
||||
end
|
||||
@@ -59,6 +70,16 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
|
||||
private
|
||||
|
||||
def enqueue_branding_enrichment
|
||||
return if account_params[:email].blank?
|
||||
|
||||
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
|
||||
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
|
||||
rescue StandardError => e
|
||||
# Enrichment is optional — never let queue/Redis failures abort signup
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
|
||||
def ensure_account_name
|
||||
# ensure that account_name and user_full_name is present
|
||||
# this is becuase the account builder and the models validations are not triggered
|
||||
@@ -103,6 +124,15 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
||||
end
|
||||
|
||||
def api_only_signup?
|
||||
# CW_API_ONLY_SERVER is the canonical flag for API-only deployments.
|
||||
# ENABLE_ACCOUNT_SIGNUP='api_only' is a legacy sentinel for the same purpose.
|
||||
# Read ENABLE_ACCOUNT_SIGNUP raw from InstallationConfig because GlobalConfig.get
|
||||
# typecasts it to boolean, coercing 'api_only' to true.
|
||||
ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) ||
|
||||
InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value.to_s == 'api_only'
|
||||
end
|
||||
|
||||
def validate_captcha
|
||||
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
|
||||
end
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Unauthenticated endpoint for resending confirmation emails during signup.
|
||||
# This is a standalone controller (not on DeviseOverrides::ConfirmationsController)
|
||||
# because OmniAuth middleware intercepts all POST /auth/* routes as provider
|
||||
# callbacks, and Devise controller filters cause 307 redirects for custom actions.
|
||||
# Inherits from ActionController::API to avoid both issues entirely.
|
||||
# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha.
|
||||
class Auth::ResendConfirmationsController < ActionController::API
|
||||
def create
|
||||
return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
|
||||
|
||||
email = params[:email]
|
||||
return head(:ok) unless email.is_a?(String)
|
||||
|
||||
user = User.from_email(email.strip.downcase)
|
||||
user&.send_confirmation_instructions unless user&.confirmed?
|
||||
head :ok
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,13 @@ import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
|
||||
|
||||
defineProps({
|
||||
featurePrefix: {
|
||||
type: String,
|
||||
default: 'CAPTAIN',
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
@@ -31,7 +38,7 @@ const openBilling = () => {
|
||||
>
|
||||
<BasePaywallModal
|
||||
class="mx-auto"
|
||||
feature-prefix="CAPTAIN"
|
||||
:feature-prefix="featurePrefix"
|
||||
:i18n-key="i18nKey"
|
||||
:is-super-admin="isSuperAdmin"
|
||||
:is-on-chatwoot-cloud="isOnChatwootCloud"
|
||||
|
||||
@@ -63,16 +63,6 @@ const hasAdvancedAssignment = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasCustomTools = computed(() => {
|
||||
return (
|
||||
isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS
|
||||
) ||
|
||||
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.CAPTAIN_V2)
|
||||
);
|
||||
});
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -374,18 +364,14 @@ const menuItems = computed(() => {
|
||||
navigationPath: 'captain_assistants_inboxes_index',
|
||||
}),
|
||||
},
|
||||
...(hasCustomTools.value
|
||||
? [
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
|
||||
|
||||
@@ -313,7 +313,12 @@ const plugins = computed(() => {
|
||||
const sendWithSignature = computed(() => {
|
||||
// this is considered the source of truth, we watch this property
|
||||
// on change, we toggle the signature in the editor
|
||||
if (props.allowSignature && !props.isPrivate && props.channelType) {
|
||||
if (
|
||||
props.allowSignature &&
|
||||
!props.isPrivate &&
|
||||
props.channelType &&
|
||||
!props.disabled
|
||||
) {
|
||||
return fetchSignatureFlagFromUISettings(props.channelType);
|
||||
}
|
||||
|
||||
@@ -436,6 +441,7 @@ function reloadState(content = props.modelValue) {
|
||||
}
|
||||
|
||||
function addSignature() {
|
||||
if (props.disabled) return;
|
||||
let content = props.modelValue;
|
||||
// see if the content is empty, if it is before appending the signature
|
||||
// we need to add a paragraph node and move the cursor at the start of the editor
|
||||
@@ -454,6 +460,7 @@ function addSignature() {
|
||||
}
|
||||
|
||||
function removeSignature() {
|
||||
if (props.disabled) return;
|
||||
if (!props.signature) return;
|
||||
let content = props.modelValue;
|
||||
content = removeSignatureHelper(
|
||||
@@ -806,7 +813,7 @@ watch(
|
||||
|
||||
watch(sendWithSignature, newValue => {
|
||||
// see if the allowSignature flag is true
|
||||
if (props.allowSignature) {
|
||||
if (props.allowSignature && !props.disabled) {
|
||||
toggleSignatureInEditor(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -128,7 +128,6 @@ export default {
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'replaceText',
|
||||
'toggleInsertArticle',
|
||||
'selectWhatsappTemplate',
|
||||
'selectContentTemplate',
|
||||
@@ -277,9 +276,6 @@ export default {
|
||||
toggleMessageSignature() {
|
||||
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
|
||||
},
|
||||
replaceText(text) {
|
||||
this.$emit('replaceText', text);
|
||||
},
|
||||
toggleInsertArticle() {
|
||||
this.$emit('toggleInsertArticle');
|
||||
},
|
||||
|
||||
@@ -27,7 +27,6 @@ import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
||||
import {
|
||||
getMessageVariables,
|
||||
getUndefinedVariablesInMessage,
|
||||
replaceVariablesInMessage,
|
||||
} from '@chatwoot/utils';
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
|
||||
@@ -636,10 +635,17 @@ export default {
|
||||
return message;
|
||||
}
|
||||
|
||||
// Even when editor is disabled (e.g. WhatsApp/API can't reply), we must
|
||||
// still normalize stale signatures out of drafts when signature is off.
|
||||
if (this.isEditorDisabled && this.sendWithSignature) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.messageSignature, effectiveChannelType)
|
||||
: removeSignature(message, this.messageSignature, effectiveChannelType);
|
||||
@@ -911,32 +917,6 @@ export default {
|
||||
});
|
||||
this.hideContentTemplatesModal();
|
||||
},
|
||||
replaceText(message) {
|
||||
if (this.sendWithSignature && !this.private) {
|
||||
// if signature is enabled, append it to the message
|
||||
// appendSignature ensures that the signature is not duplicated
|
||||
// so we don't need to check if the signature is already present
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
message = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
message,
|
||||
variables: this.messageVariables,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
useTrack(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
|
||||
this.message = updatedMessage;
|
||||
}, 100);
|
||||
},
|
||||
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
||||
// Clear attachments when switching between private note and reply modes
|
||||
// This is to prevent from breaking the upload rules
|
||||
@@ -1435,7 +1415,6 @@ export default {
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@toggle-quoted-reply="toggleQuotedReply"
|
||||
/>
|
||||
|
||||
@@ -51,6 +51,7 @@ export const FEATURE_FLAGS = {
|
||||
export const PREMIUM_FEATURES = [
|
||||
FEATURE_FLAGS.SLA,
|
||||
FEATURE_FLAGS.CAPTAIN,
|
||||
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS,
|
||||
FEATURE_FLAGS.CUSTOM_ROLES,
|
||||
FEATURE_FLAGS.AUDIT_LOGS,
|
||||
FEATURE_FLAGS.HELP_CENTER,
|
||||
|
||||
@@ -32,6 +32,25 @@ export function extractTextFromMarkdown(markdown) {
|
||||
.trim(); // Trim any extra space
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes inline base64 markdown images from signature content.
|
||||
*
|
||||
* @param {string} content
|
||||
* @returns {{ sanitizedContent: string, hasInlineImages: boolean }}
|
||||
*/
|
||||
export function stripInlineBase64Images(content) {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return { sanitizedContent: content || '', hasInlineImages: false };
|
||||
}
|
||||
|
||||
const markdownInlineBase64ImageRegex =
|
||||
/!\[[^\]]*]\(\s*data:image\/[a-zA-Z0-9.+-]+;base64,[^)]+\s*\)/gi;
|
||||
const sanitizedContent = content.replace(markdownInlineBase64ImageRegex, '');
|
||||
const hasInlineImages = sanitizedContent !== content;
|
||||
|
||||
return { sanitizedContent, hasInlineImages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unsupported markdown formatting based on channel capabilities.
|
||||
* Uses MARKDOWN_PATTERNS from editor constants.
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getMenuAnchor,
|
||||
calculateMenuPosition,
|
||||
stripUnsupportedFormatting,
|
||||
stripInlineBase64Images,
|
||||
} from '../editorHelper';
|
||||
import { FORMATTING } from 'dashboard/constants/editor';
|
||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||
@@ -423,6 +424,36 @@ describe('extractTextFromMarkdown', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripInlineBase64Images', () => {
|
||||
it('removes markdown data:image base64 images and sets hasInlineImages', () => {
|
||||
const content =
|
||||
'Hello\n\nWorld';
|
||||
const { sanitizedContent, hasInlineImages } =
|
||||
stripInlineBase64Images(content);
|
||||
|
||||
expect(hasInlineImages).toBe(true);
|
||||
expect(sanitizedContent).not.toContain('data:image/png;base64');
|
||||
expect(sanitizedContent).toContain('Hello');
|
||||
expect(sanitizedContent).toContain('World');
|
||||
});
|
||||
|
||||
it('leaves hosted image markdown unchanged', () => {
|
||||
const content = '';
|
||||
const { sanitizedContent, hasInlineImages } =
|
||||
stripInlineBase64Images(content);
|
||||
|
||||
expect(hasInlineImages).toBe(false);
|
||||
expect(sanitizedContent).toBe(content);
|
||||
});
|
||||
|
||||
it('returns empty hasInlineImages for empty input', () => {
|
||||
expect(stripInlineBase64Images('')).toEqual({
|
||||
sanitizedContent: '',
|
||||
hasInlineImages: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertAtCursor', () => {
|
||||
it('should return undefined if editorView is not provided', () => {
|
||||
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
|
||||
|
||||
@@ -838,6 +838,18 @@
|
||||
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete custom tool"
|
||||
},
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to use tools with Captain",
|
||||
"AVAILABLE_ON": "Captain Tools are only available in Business and Enterprise plans. Please upgrade to Business plan to use the feature.",
|
||||
"UPGRADE_PROMPT": "",
|
||||
"UPGRADE_NOW": "Open billing",
|
||||
"CANCEL_ANYTIME": ""
|
||||
},
|
||||
"ENTERPRISE_PAYWALL": {
|
||||
"AVAILABLE_ON": "Captain Tools are only available in the paid plans.",
|
||||
"UPGRADE_PROMPT": "Please upgrade to a paid plan to use this feature.",
|
||||
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
||||
},
|
||||
"TEST": {
|
||||
"BUTTON": "Test connection",
|
||||
"SUCCESS": "Endpoint returned HTTP {status}",
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
"API_SUCCESS": "Signature saved successfully",
|
||||
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
|
||||
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
|
||||
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
|
||||
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB",
|
||||
"INLINE_IMAGE_WARNING": "Pasted inline images were removed. Please use the image upload button to add images to your signature."
|
||||
},
|
||||
"MESSAGE_SIGNATURE": {
|
||||
"LABEL": "Message Signature",
|
||||
|
||||
@@ -45,6 +45,13 @@
|
||||
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
|
||||
},
|
||||
"SUBMIT": "Create account",
|
||||
"HAVE_AN_ACCOUNT": "Already have an account?"
|
||||
"HAVE_AN_ACCOUNT": "Already have an account?",
|
||||
"VERIFY_EMAIL": {
|
||||
"TITLE": "Check your inbox",
|
||||
"DESCRIPTION": "We sent a verification link to {email}. Click the link to verify your email and get started.",
|
||||
"RESEND": "Resend verification email",
|
||||
"RESEND_SUCCESS": "Verification email sent. Please check your inbox.",
|
||||
"RESEND_ERROR": "Could not send verification email. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ const meta = {
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
|
||||
};
|
||||
|
||||
const metaCustomTools = {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS,
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
|
||||
};
|
||||
|
||||
const metaV2 = {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
|
||||
@@ -46,7 +52,7 @@ const assistantRoutes = [
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
|
||||
component: CustomToolsIndex,
|
||||
name: 'captain_tools_index',
|
||||
meta,
|
||||
meta: metaCustomTools,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'),
|
||||
|
||||
@@ -5,13 +5,14 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
|
||||
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
|
||||
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { isFeatureFlagEnabled } = usePolicy();
|
||||
const { isFeatureFlagEnabled, shouldShowPaywall } = usePolicy();
|
||||
|
||||
const SOFT_LIMIT = 10;
|
||||
const isV2 = computed(() => isFeatureFlagEnabled(FEATURE_FLAGS.CAPTAIN_V2));
|
||||
@@ -80,7 +81,9 @@ const onDeleteSuccess = () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchCustomTools();
|
||||
if (!shouldShowPaywall(FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS)) {
|
||||
fetchCustomTools();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -89,6 +92,7 @@ onMounted(() => {
|
||||
:header-title="$t('CAPTAIN.CUSTOM_TOOLS.HEADER')"
|
||||
:button-label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')"
|
||||
:button-policy="['administrator']"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS"
|
||||
:total-count="customToolsMeta.totalCount"
|
||||
:current-page="customToolsMeta.page"
|
||||
:show-pagination-footer="!isFetching && !!customTools.length"
|
||||
@@ -98,6 +102,10 @@ onMounted(() => {
|
||||
@update:current-page="onPageChange"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
<template #paywall>
|
||||
<CaptainPaywall feature-prefix="CAPTAIN.CUSTOM_TOOLS" />
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<CustomToolsPageEmptyState @click="openCreateDialog" />
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
|
||||
@@ -22,6 +23,11 @@ defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['upgrade']);
|
||||
|
||||
// Cloud agents land on this modal too, but billing is admin-only — they need
|
||||
// the escalation message instead of a button they cannot use. Mirrors the
|
||||
// pattern in UpgradePage.vue.
|
||||
const { isAdmin } = useAdmin();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -47,11 +53,14 @@ const emit = defineEmits(['upgrade']);
|
||||
/>
|
||||
<p class="text-sm font-normal text-n-slate-11">
|
||||
{{ $t(`${featurePrefix}.${i18nKey}.UPGRADE_PROMPT`) }}
|
||||
<span v-if="!isOnChatwootCloud && !isSuperAdmin">
|
||||
<span v-if="isOnChatwootCloud && !isAdmin">
|
||||
{{ $t('GENERAL_SETTINGS.LIMIT_MESSAGES.NON_ADMIN') }}
|
||||
</span>
|
||||
<span v-else-if="!isOnChatwootCloud && !isSuperAdmin">
|
||||
{{ $t(`${featurePrefix}.ENTERPRISE_PAYWALL.ASK_ADMIN`) }}
|
||||
</span>
|
||||
</p>
|
||||
<template v-if="isOnChatwootCloud">
|
||||
<template v-if="isOnChatwootCloud && isAdmin">
|
||||
<ButtonV4 blue solid md @click="emit('upgrade')">
|
||||
{{ $t(`${featurePrefix}.PAYWALL.UPGRADE_NOW`) }}
|
||||
</ButtonV4>
|
||||
@@ -59,7 +68,7 @@ const emit = defineEmits(['upgrade']);
|
||||
{{ $t(`${featurePrefix}.PAYWALL.CANCEL_ANYTIME`) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="isSuperAdmin">
|
||||
<template v-else-if="!isOnChatwootCloud && isSuperAdmin">
|
||||
<a href="/super_admin" class="block w-full">
|
||||
<ButtonV4 solid blue md class="w-full">
|
||||
{{ $t(`${featurePrefix}.PAYWALL.UPGRADE_NOW`) }}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { stripInlineBase64Images } from 'dashboard/helper/editorHelper';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -11,7 +14,9 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updateSignature']);
|
||||
const signature = ref(props.messageSignature);
|
||||
|
||||
const { t } = useI18n();
|
||||
const signature = ref(props.messageSignature ?? '');
|
||||
watch(
|
||||
() => props.messageSignature ?? '',
|
||||
newValue => {
|
||||
@@ -20,6 +25,15 @@ watch(
|
||||
);
|
||||
|
||||
const updateSignature = () => {
|
||||
const { sanitizedContent, hasInlineImages } = stripInlineBase64Images(
|
||||
signature.value || ''
|
||||
);
|
||||
signature.value = sanitizedContent.trim();
|
||||
if (hasInlineImages) {
|
||||
useAlert(
|
||||
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.INLINE_IMAGE_WARNING')
|
||||
);
|
||||
}
|
||||
emit('updateSignature', signature.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -57,7 +57,6 @@ export const register = async creds => {
|
||||
password: creds.password,
|
||||
h_captcha_client_response: creds.hCaptchaClientResponse,
|
||||
});
|
||||
setAuthCredentials(response);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
@@ -65,6 +64,13 @@ export const register = async creds => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resendConfirmation = async ({ email, hCaptchaClientResponse }) => {
|
||||
return wootAPI.post('resend_confirmation', {
|
||||
email,
|
||||
h_captcha_client_response: hCaptchaClientResponse,
|
||||
});
|
||||
};
|
||||
|
||||
export const verifyPasswordToken = async ({ confirmationToken }) => {
|
||||
try {
|
||||
const response = await wootAPI.post('auth/confirmation', {
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, email } from '@vuelidate/validators';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
|
||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import FormInput from '../../../../../components/Form/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -19,6 +19,7 @@ const MIN_PASSWORD_LENGTH = 6;
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const hCaptcha = ref(null);
|
||||
const isPasswordFocused = ref(false);
|
||||
@@ -76,7 +77,10 @@ const performRegistration = async () => {
|
||||
isSignupInProgress.value = true;
|
||||
try {
|
||||
await register(credentials);
|
||||
window.location = DEFAULT_REDIRECT_URL;
|
||||
router.push({
|
||||
name: 'auth_verify_email',
|
||||
state: { email: credentials.email },
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error?.message || t('REGISTER.API.ERROR_MESSAGE');
|
||||
if (globalConfig.value.hCaptchaSiteKey) {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { resendConfirmation } from '../../../api/auth';
|
||||
|
||||
const props = defineProps({
|
||||
email: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
if (!props.email) {
|
||||
router.push({ name: 'login' });
|
||||
}
|
||||
|
||||
const globalConfig = computed(() => store.getters['globalConfig/get']);
|
||||
const isResendingEmail = ref(false);
|
||||
const hCaptcha = ref(null);
|
||||
let captchaToken = '';
|
||||
|
||||
const performResend = async () => {
|
||||
isResendingEmail.value = true;
|
||||
try {
|
||||
await resendConfirmation({
|
||||
email: props.email,
|
||||
hCaptchaClientResponse: captchaToken,
|
||||
});
|
||||
useAlert(t('REGISTER.VERIFY_EMAIL.RESEND_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('REGISTER.VERIFY_EMAIL.RESEND_ERROR'));
|
||||
} finally {
|
||||
isResendingEmail.value = false;
|
||||
captchaToken = '';
|
||||
if (globalConfig.value.hCaptchaSiteKey) {
|
||||
hCaptcha.value.reset();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendEmail = () => {
|
||||
if (isResendingEmail.value) return;
|
||||
if (globalConfig.value.hCaptchaSiteKey) {
|
||||
hCaptcha.value.execute();
|
||||
} else {
|
||||
performResend();
|
||||
}
|
||||
};
|
||||
|
||||
const onCaptchaVerified = token => {
|
||||
captchaToken = token;
|
||||
performResend();
|
||||
};
|
||||
|
||||
const onCaptchaError = () => {
|
||||
isResendingEmail.value = false;
|
||||
captchaToken = '';
|
||||
hCaptcha.value.reset();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="flex flex-col w-full min-h-screen py-20 bg-n-brand/5 dark:bg-n-background sm:px-6 lg:px-8"
|
||||
>
|
||||
<section
|
||||
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-semibold text-n-slate-12">
|
||||
{{ $t('REGISTER.VERIFY_EMAIL.TITLE') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-n-slate-11">
|
||||
{{ $t('REGISTER.VERIFY_EMAIL.DESCRIPTION', { email }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<VueHcaptcha
|
||||
v-if="globalConfig.hCaptchaSiteKey"
|
||||
ref="hCaptcha"
|
||||
size="invisible"
|
||||
:sitekey="globalConfig.hCaptchaSiteKey"
|
||||
@verify="onCaptchaVerified"
|
||||
@error="onCaptchaError"
|
||||
@expired="onCaptchaError"
|
||||
@challenge-expired="onCaptchaError"
|
||||
@closed="onCaptchaError"
|
||||
/>
|
||||
<NextButton
|
||||
lg
|
||||
type="button"
|
||||
data-testid="resend_email_button"
|
||||
class="w-full"
|
||||
:label="$t('REGISTER.VERIFY_EMAIL.RESEND')"
|
||||
:is-loading="isResendingEmail"
|
||||
@click="handleResendEmail"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ import SamlLogin from './login/Saml.vue';
|
||||
import Signup from './auth/signup/Index.vue';
|
||||
import ResetPassword from './auth/reset/password/Index.vue';
|
||||
import Confirmation from './auth/confirmation/Index.vue';
|
||||
import VerifyEmail from './auth/verify-email/Index.vue';
|
||||
import PasswordEdit from './auth/password/Edit.vue';
|
||||
|
||||
export default [
|
||||
@@ -48,6 +49,15 @@ export default [
|
||||
redirectUrl: route.query.route_url,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: frontendURL('auth/verify-email'),
|
||||
name: 'auth_verify_email',
|
||||
component: VerifyEmail,
|
||||
meta: { ignoreSession: true },
|
||||
props: () => ({
|
||||
email: window.history.state?.email || '',
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: frontendURL('auth/password/edit'),
|
||||
name: 'auth_password_edit',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
class Account::BrandingEnrichmentJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account_id, email)
|
||||
result = WebsiteBrandingService.new(email).perform
|
||||
return if result.blank?
|
||||
|
||||
account = Account.find(account_id)
|
||||
account.name = result[:title] if result[:title].present?
|
||||
account.custom_attributes['brand_info'] = result if account.custom_attributes['brand_info'].blank?
|
||||
account.save! if account.changed?
|
||||
ensure
|
||||
finish_enrichment(account_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def finish_enrichment(account_id)
|
||||
Redis::Alfred.delete(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: account_id))
|
||||
|
||||
account = Account.find(account_id)
|
||||
if account.custom_attributes['onboarding_step'] == 'enrichment'
|
||||
account.custom_attributes['onboarding_step'] = 'account_details'
|
||||
account.save!
|
||||
end
|
||||
|
||||
user = account.administrators.first
|
||||
return unless user
|
||||
|
||||
ActionCableBroadcastJob.perform_later([user.pubsub_token], 'account.enrichment_completed', { account_id: account_id })
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,15 @@
|
||||
class WebsiteBrandingService
|
||||
include SocialLinkParser
|
||||
|
||||
def initialize(url)
|
||||
@url = normalize_url(url)
|
||||
attr_reader :http_status
|
||||
|
||||
DATA_DEFAULTS = { description: nil, slogan: nil, phone: nil, address: nil, links: nil, stock: nil, industries: [], is_nsfw: false }.freeze
|
||||
|
||||
def initialize(email)
|
||||
@email = email
|
||||
@domain = email.split('@').last&.downcase&.strip
|
||||
@url = "https://#{@domain}"
|
||||
@http_status = nil
|
||||
end
|
||||
|
||||
def perform
|
||||
@@ -11,13 +18,14 @@ class WebsiteBrandingService
|
||||
|
||||
links = extract_links(doc)
|
||||
|
||||
{
|
||||
business_name: extract_business_name(doc),
|
||||
language: extract_language(doc),
|
||||
industry_category: nil,
|
||||
social_handles: extract_social_from_links(links),
|
||||
branding: extract_branding(doc)
|
||||
}
|
||||
DATA_DEFAULTS.merge({
|
||||
domain: @domain,
|
||||
title: extract_title(doc),
|
||||
colors: extract_colors(doc),
|
||||
logos: extract_logos(doc),
|
||||
socials: build_socials(links),
|
||||
email: @email
|
||||
})
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WebsiteBranding] #{e.message}"
|
||||
nil
|
||||
@@ -25,12 +33,9 @@ class WebsiteBrandingService
|
||||
|
||||
private
|
||||
|
||||
def normalize_url(url)
|
||||
url.match?(%r{\Ahttps?://}) ? url : "https://#{url}"
|
||||
end
|
||||
|
||||
def fetch_page
|
||||
response = HTTParty.get(@url, follow_redirects: true, timeout: 15)
|
||||
@http_status = response.code
|
||||
return nil unless response.success?
|
||||
|
||||
Nokogiri::HTML(response.body)
|
||||
@@ -39,7 +44,7 @@ class WebsiteBrandingService
|
||||
nil
|
||||
end
|
||||
|
||||
def extract_business_name(doc)
|
||||
def extract_title(doc)
|
||||
og_site_name = doc.at_css('meta[property="og:site_name"]')&.[]('content')
|
||||
return og_site_name.strip if og_site_name.present?
|
||||
|
||||
@@ -47,8 +52,37 @@ class WebsiteBrandingService
|
||||
title&.strip&.split(/\s*[|\-–—·:]+\s*/)&.first
|
||||
end
|
||||
|
||||
def extract_language(doc)
|
||||
doc.at_css('html')&.[]('lang')&.split('-')&.first&.downcase
|
||||
def extract_colors(doc)
|
||||
color = doc.at_css('meta[name="theme-color"]')&.[]('content')
|
||||
return [] if color.blank?
|
||||
|
||||
[{ hex: color, name: nil }]
|
||||
end
|
||||
|
||||
def extract_logos(doc)
|
||||
favicon = doc.at_css('link[rel*="icon"]')&.[]('href')
|
||||
return [] if favicon.blank?
|
||||
|
||||
url = resolve_url(favicon)
|
||||
return [] if url.blank?
|
||||
|
||||
[{ url: url, type: nil, mode: nil, colors: [], resolution: { aspect_ratio: 1 } }]
|
||||
end
|
||||
|
||||
def build_socials(links)
|
||||
handles = extract_social_from_links(links)
|
||||
handles.filter_map do |platform, handle|
|
||||
next if handle.blank?
|
||||
|
||||
url = reconstruct_social_url(platform, handle)
|
||||
{ type: platform.to_s, url: url }
|
||||
end
|
||||
end
|
||||
|
||||
def reconstruct_social_url(platform, handle)
|
||||
base_urls = { whatsapp: 'https://wa.me/', line: 'https://line.me/', facebook: 'https://facebook.com/',
|
||||
instagram: 'https://instagram.com/', telegram: 'https://t.me/', tiktok: 'https://tiktok.com/' }
|
||||
"#{base_urls[platform]}#{handle}"
|
||||
end
|
||||
|
||||
def extract_links(doc)
|
||||
@@ -62,24 +96,6 @@ class WebsiteBrandingService
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def extract_branding(doc)
|
||||
{
|
||||
favicon: extract_favicon(doc),
|
||||
primary_color: extract_theme_color(doc)
|
||||
}
|
||||
end
|
||||
|
||||
def extract_favicon(doc)
|
||||
favicon = doc.at_css('link[rel*="icon"]')&.[]('href')
|
||||
return nil if favicon.blank?
|
||||
|
||||
resolve_url(favicon)
|
||||
end
|
||||
|
||||
def extract_theme_color(doc)
|
||||
doc.at_css('meta[name="theme-color"]')&.[]('content')
|
||||
end
|
||||
|
||||
def resolve_url(url)
|
||||
return nil if url.blank?
|
||||
return url if url.start_with?('http')
|
||||
|
||||
@@ -120,8 +120,20 @@ class Rack::Attack
|
||||
end
|
||||
end
|
||||
|
||||
## Resend confirmation throttling
|
||||
## Resend confirmation throttling (unauthenticated)
|
||||
throttle('resend_confirmation/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/resend_confirmation' && req.post?
|
||||
end
|
||||
|
||||
throttle('resend_confirmation/email', limit: 5, period: 1.hour) do |req|
|
||||
if req.path_without_extentions == '/resend_confirmation' && req.post?
|
||||
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
|
||||
email.to_s.downcase.gsub(/\s+/, '')
|
||||
end
|
||||
end
|
||||
|
||||
## Resend confirmation throttling (authenticated)
|
||||
throttle('resend_confirmation_auth/ip', limit: 5, period: 30.minutes) do |req|
|
||||
req.ip if req.path_without_extentions == '/api/v1/profile/resend_confirmation' && req.post?
|
||||
end
|
||||
|
||||
|
||||
@@ -211,6 +211,13 @@
|
||||
type: code
|
||||
# End of Captain Config
|
||||
|
||||
# ------- Context.dev Config ------- #
|
||||
- name: CONTEXT_DEV_API_KEY
|
||||
display_title: 'Context.dev API Key'
|
||||
description: 'API key for Context.dev branding service used during account onboarding'
|
||||
type: secret
|
||||
# ------- End of Context.dev Config ------- #
|
||||
|
||||
# ------- Chatwoot Internal Config for Cloud ----#
|
||||
- name: CHATWOOT_INBOX_TOKEN
|
||||
value:
|
||||
|
||||
@@ -66,6 +66,14 @@ en:
|
||||
not_found: Assignment policy not found
|
||||
attachments:
|
||||
invalid: Invalid attachment
|
||||
upload:
|
||||
missing_input: 'No file or URL provided'
|
||||
invalid_url: 'Invalid URL provided'
|
||||
fetch_failed: 'Failed to fetch file from URL'
|
||||
fetch_failed_with_message: 'Failed to fetch file from URL: %{message}'
|
||||
file_too_large: 'File exceeds the maximum allowed size'
|
||||
unsupported_content_type: 'File type not supported (only images and videos are allowed)'
|
||||
unexpected: 'An unexpected error occurred'
|
||||
saml:
|
||||
feature_not_enabled: SAML feature not enabled for this account
|
||||
sso_not_enabled: SAML SSO is not enabled for this installation
|
||||
|
||||
@@ -8,6 +8,8 @@ Rails.application.routes.draw do
|
||||
omniauth_callbacks: 'devise_overrides/omniauth_callbacks'
|
||||
}, via: [:get, :post]
|
||||
|
||||
post 'resend_confirmation', to: 'auth/resend_confirmations#create'
|
||||
|
||||
## renders the frontend paths only if its not an api only server
|
||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false))
|
||||
root to: 'api#index'
|
||||
|
||||
@@ -34,9 +34,9 @@ module Enterprise::SuperAdmin::AppConfigsController
|
||||
end
|
||||
|
||||
def internal_config_options
|
||||
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY CLOUD_ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS
|
||||
SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL
|
||||
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID BLOCKED_EMAIL_DOMAINS
|
||||
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY CLOUD_ANALYTICS_TOKEN CLEARBIT_API_KEY CONTEXT_DEV_API_KEY DASHBOARD_SCRIPTS
|
||||
INACTIVE_WHATSAPP_NUMBERS SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL
|
||||
CHATWOOT_INSTANCE_ADMIN_EMAIL OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID BLOCKED_EMAIL_DOMAINS
|
||||
OTEL_PROVIDER LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL]
|
||||
end
|
||||
|
||||
|
||||
@@ -1,112 +1,63 @@
|
||||
module Enterprise::WebsiteBrandingService
|
||||
FIRECRAWL_SCRAPE_ENDPOINT = 'https://api.firecrawl.dev/v2/scrape'.freeze
|
||||
|
||||
INDUSTRY_CATEGORIES = [
|
||||
'Technology',
|
||||
'E-commerce',
|
||||
'Healthcare',
|
||||
'Education',
|
||||
'Finance',
|
||||
'Real Estate',
|
||||
'Marketing',
|
||||
'Travel & Hospitality',
|
||||
'Food & Beverage',
|
||||
'Media & Entertainment',
|
||||
'Professional Services',
|
||||
'Non-profit',
|
||||
'Other'
|
||||
].freeze
|
||||
CONTEXT_DEV_ENDPOINT = 'https://api.context.dev/v1/brand/retrieve-by-email'.freeze
|
||||
|
||||
def perform
|
||||
return super unless firecrawl_enabled?
|
||||
return super unless context_dev_enabled?
|
||||
|
||||
response = perform_firecrawl_request
|
||||
process_firecrawl_response(response)
|
||||
response = fetch_brand
|
||||
process_response(response)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WebsiteBranding] Firecrawl failed: #{e.message}, falling back to basic scrape"
|
||||
super
|
||||
Rails.logger.error "[WebsiteBranding] Context.dev failed: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def firecrawl_enabled?
|
||||
firecrawl_api_key.present?
|
||||
def context_dev_enabled?
|
||||
context_dev_api_key.present?
|
||||
end
|
||||
|
||||
def firecrawl_api_key
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value
|
||||
def context_dev_api_key
|
||||
InstallationConfig.find_by(name: 'CONTEXT_DEV_API_KEY')&.value
|
||||
end
|
||||
|
||||
def perform_firecrawl_request
|
||||
HTTParty.post(
|
||||
FIRECRAWL_SCRAPE_ENDPOINT,
|
||||
body: scrape_payload.to_json,
|
||||
def fetch_brand
|
||||
HTTParty.get(
|
||||
CONTEXT_DEV_ENDPOINT,
|
||||
query: { email: @email },
|
||||
headers: {
|
||||
'Authorization' => "Bearer #{firecrawl_api_key}",
|
||||
'Authorization' => "Bearer #{context_dev_api_key}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def scrape_payload
|
||||
{
|
||||
url: @url,
|
||||
onlyMainContent: false,
|
||||
formats: [
|
||||
{
|
||||
type: 'json',
|
||||
schema: extract_schema,
|
||||
prompt: 'Extract the business name, primary language, and industry category from this website.'
|
||||
},
|
||||
'branding',
|
||||
'links'
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def extract_schema
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
business_name: { type: 'string', description: 'The name of the business or company' },
|
||||
language: { type: 'string', description: 'Primary language as ISO 639-1 code (e.g., en, es, fr)' },
|
||||
industry_category: { type: 'string', enum: INDUSTRY_CATEGORIES, description: 'Industry category for this business' }
|
||||
},
|
||||
required: %w[business_name]
|
||||
}
|
||||
end
|
||||
|
||||
def process_firecrawl_response(response)
|
||||
def process_response(response)
|
||||
@http_status = response.code
|
||||
raise "API Error: #{response.message} (Status: #{response.code})" unless response.success?
|
||||
|
||||
format_firecrawl_response(response)
|
||||
brand = response.parsed_response&.dig('brand')
|
||||
return nil if brand.blank?
|
||||
|
||||
format_brand(brand)
|
||||
end
|
||||
|
||||
def format_firecrawl_response(response)
|
||||
data = response.parsed_response
|
||||
extract = data.dig('data', 'json') || {}
|
||||
brand = data.dig('data', 'branding') || {}
|
||||
links = data.dig('data', 'links') || []
|
||||
|
||||
def format_brand(brand)
|
||||
{
|
||||
business_name: extract['business_name'],
|
||||
language: extract['language'],
|
||||
industry_category: extract['industry_category'],
|
||||
social_handles: extract_social_from_links(links),
|
||||
branding: extract_firecrawl_branding(brand)
|
||||
}
|
||||
end
|
||||
|
||||
def extract_firecrawl_branding(brand)
|
||||
{
|
||||
favicon: url_or_nil(brand.dig('images', 'favicon')),
|
||||
primary_color: brand.dig('colors', 'primary')
|
||||
}
|
||||
end
|
||||
|
||||
def url_or_nil(value)
|
||||
return nil if value.blank? || !value.start_with?('http')
|
||||
|
||||
value
|
||||
domain: brand['domain'],
|
||||
title: brand['title'],
|
||||
description: brand['description'],
|
||||
slogan: brand['slogan'],
|
||||
phone: brand['phone'],
|
||||
address: brand['address'],
|
||||
colors: brand['colors'] || [],
|
||||
logos: brand['logos'] || [],
|
||||
socials: brand['socials'] || [],
|
||||
links: brand['links'],
|
||||
email: @email,
|
||||
industries: brand.dig('industries', 'eic') || [],
|
||||
stock: brand['stock'],
|
||||
is_nsfw: brand['is_nsfw'] || false
|
||||
}.deep_symbolize_keys
|
||||
end
|
||||
end
|
||||
|
||||
@@ -50,6 +50,9 @@ module Redis::RedisKeys
|
||||
ASSIGNMENT_KEY = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::CONVERSATION::%<conversation_id>d'.freeze
|
||||
ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::*'.freeze
|
||||
|
||||
## Account Onboarding
|
||||
ACCOUNT_ONBOARDING_ENRICHMENT = 'ONBOARDING_ENRICHMENT::%<account_id>d'.freeze
|
||||
|
||||
## Account Email Rate Limiting
|
||||
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
|
||||
end
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
require 'ssrf_filter'
|
||||
|
||||
module SafeFetch
|
||||
DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES = %w[image/ video/].freeze
|
||||
DEFAULT_OPEN_TIMEOUT = 2
|
||||
DEFAULT_READ_TIMEOUT = 20
|
||||
DEFAULT_MAX_BYTES_FALLBACK_MB = 40
|
||||
|
||||
Result = Data.define(:tempfile, :filename, :content_type)
|
||||
|
||||
class Error < StandardError; end
|
||||
class InvalidUrlError < Error; end
|
||||
class UnsafeUrlError < Error; end
|
||||
class FetchError < Error; end
|
||||
class HttpError < Error; end
|
||||
class FileTooLargeError < Error; end
|
||||
class UnsupportedContentTypeError < Error; end
|
||||
|
||||
def self.fetch(url,
|
||||
max_bytes: nil,
|
||||
allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES)
|
||||
raise ArgumentError, 'block required' unless block_given?
|
||||
|
||||
effective_max_bytes = max_bytes || default_max_bytes
|
||||
uri = parse_and_validate_url!(url)
|
||||
filename = filename_for(uri)
|
||||
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
|
||||
|
||||
response = stream_to_tempfile(url, tempfile, effective_max_bytes, allowed_content_type_prefixes)
|
||||
raise HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
tempfile.rewind
|
||||
yield Result.new(tempfile: tempfile, filename: filename, content_type: response['content-type'])
|
||||
rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e
|
||||
raise InvalidUrlError, e.message
|
||||
rescue SsrfFilter::Error, Resolv::ResolvError => e
|
||||
raise UnsafeUrlError, e.message
|
||||
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => e
|
||||
raise FetchError, e.message
|
||||
ensure
|
||||
tempfile&.close!
|
||||
end
|
||||
|
||||
class << self
|
||||
private
|
||||
|
||||
def stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes)
|
||||
response = nil
|
||||
bytes_written = 0
|
||||
|
||||
SsrfFilter.get(
|
||||
url,
|
||||
http_options: { open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT }
|
||||
) do |res|
|
||||
response = res
|
||||
next unless res.is_a?(Net::HTTPSuccess)
|
||||
|
||||
unless allowed_content_type?(res['content-type'], allowed_content_type_prefixes)
|
||||
raise UnsupportedContentTypeError, "content-type not allowed: #{res['content-type']}"
|
||||
end
|
||||
|
||||
res.read_body do |chunk|
|
||||
bytes_written += chunk.bytesize
|
||||
raise FileTooLargeError, "exceeded #{max_bytes} bytes" if bytes_written > max_bytes
|
||||
|
||||
tempfile.write(chunk)
|
||||
end
|
||||
end
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
def filename_for(uri)
|
||||
File.basename(uri.path).presence || "download-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
|
||||
end
|
||||
|
||||
def default_max_bytes
|
||||
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', DEFAULT_MAX_BYTES_FALLBACK_MB).to_i
|
||||
limit_mb = DEFAULT_MAX_BYTES_FALLBACK_MB if limit_mb <= 0
|
||||
limit_mb.megabytes
|
||||
end
|
||||
|
||||
def parse_and_validate_url!(url)
|
||||
uri = URI.parse(url)
|
||||
raise InvalidUrlError, 'scheme must be http or https' unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
raise InvalidUrlError, 'missing host' if uri.host.blank?
|
||||
|
||||
uri
|
||||
end
|
||||
|
||||
def allowed_content_type?(value, prefixes)
|
||||
mime = value.to_s.split(';').first&.strip&.downcase
|
||||
return false if mime.blank?
|
||||
|
||||
prefixes.any? { |prefix| mime.start_with?(prefix) }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -26,8 +26,8 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
|
||||
expect(AccountBuilder).to have_received(:new).with(params.except(:password).merge(user_password: params[:password]))
|
||||
expect(account_builder).to have_received(:perform)
|
||||
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||
expect(response.body).to include('en')
|
||||
expect(response.headers.keys).not_to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||
expect(response.parsed_body['email']).to eq(email)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -46,8 +46,8 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(ChatwootCaptcha).to have_received(:new).with('123')
|
||||
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||
expect(response.body).to include('en')
|
||||
expect(response.headers.keys).not_to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||
expect(response.parsed_body['email']).to eq(email)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -68,6 +68,23 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an authenticated user creates a second account' do
|
||||
let(:existing_user) { create(:user, password: 'Password1!') }
|
||||
|
||||
it 'returns the full response with account_id' do
|
||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
|
||||
post api_v1_accounts_url,
|
||||
params: { account_name: 'Second Account', email: existing_user.email,
|
||||
user_full_name: existing_user.name, password: 'Password1!' },
|
||||
headers: existing_user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body.dig('data', 'account_id')).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to false' do
|
||||
it 'responds 404 on requests' do
|
||||
params = { account_name: 'test', email: email, user_full_name: user_full_name }
|
||||
@@ -105,7 +122,17 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
end
|
||||
|
||||
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do
|
||||
it 'does not respond 404 on requests' do
|
||||
before do
|
||||
GlobalConfig.clear_cache
|
||||
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
|
||||
end
|
||||
|
||||
after do
|
||||
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
it 'returns auth headers and full response for api_only signup' do
|
||||
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
|
||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'api_only' do
|
||||
post api_v1_accounts_url,
|
||||
@@ -113,6 +140,21 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when CW_API_ONLY_SERVER is true' do
|
||||
it 'returns auth headers and full response' do
|
||||
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
|
||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', CW_API_ONLY_SERVER: 'true' do
|
||||
post api_v1_accounts_url,
|
||||
params: params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -39,6 +39,11 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
||||
let(:valid_external_url) { 'http://example.com/image.jpg' }
|
||||
|
||||
before do
|
||||
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
|
||||
allow(Resolv).to receive(:getaddresses).with('error.example.com').and_return(['93.184.216.34'])
|
||||
allow(Resolv).to receive(:getaddresses).with('nonexistent.example.com').and_return(['93.184.216.34'])
|
||||
|
||||
stub_request(:get, valid_external_url)
|
||||
.to_return(status: 200, body: File.new(Rails.root.join('spec/assets/avatar.png')), headers: { 'Content-Type' => 'image/png' })
|
||||
end
|
||||
@@ -82,7 +87,7 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
||||
params: { external_url: 'http://nonexistent.example.com' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
expect(response.parsed_body['error']).to eq('Failed to fetch file from URL')
|
||||
end
|
||||
|
||||
it 'handles HTTP errors' do
|
||||
@@ -96,6 +101,112 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to start_with('Failed to fetch file from URL')
|
||||
end
|
||||
|
||||
it 'rejects oversized responses with a file-size message' do
|
||||
stub_request(:get, valid_external_url)
|
||||
.to_return(status: 200,
|
||||
body: 'x' * (41 * 1024 * 1024),
|
||||
headers: { 'Content-Type' => 'image/png' })
|
||||
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: valid_external_url }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('File exceeds the maximum allowed size')
|
||||
end
|
||||
|
||||
it 'rejects unsupported content types with a file-type message' do
|
||||
stub_request(:get, valid_external_url)
|
||||
.to_return(status: 200,
|
||||
body: '<html></html>',
|
||||
headers: { 'Content-Type' => 'text/html' })
|
||||
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: valid_external_url }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('File type not supported (only images and videos are allowed)')
|
||||
end
|
||||
|
||||
context 'with SSRF attack vectors' do
|
||||
it 'blocks requests to private IP ranges (10.x.x.x)' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://10.0.0.1/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to private IP ranges (172.16.x.x)' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://172.16.0.1/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to private IP ranges (192.168.x.x)' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://192.168.1.1/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to loopback addresses' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://127.0.0.1/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to AWS metadata service (169.254.169.254)' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to localhost' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://localhost/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to .local domains' do
|
||||
allow(Resolv).to receive(:getaddresses).with('server.local').and_return(['192.168.1.100'])
|
||||
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://server.local/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks DNS rebinding attacks (hostname resolving to private IP)' do
|
||||
allow(Resolv).to receive(:getaddresses).with('evil.attacker.com').and_return(['10.0.0.1'])
|
||||
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://evil.attacker.com/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns an error when no file or URL is provided' do
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Resend Confirmations API', type: :request do
|
||||
describe 'POST /resend_confirmation' do
|
||||
let(:email) { 'unconfirmed@example.com' }
|
||||
|
||||
context 'when the user exists and is unconfirmed' do
|
||||
before { create(:user, email: email, skip_confirmation: false) }
|
||||
|
||||
it 'sends confirmation instructions and returns 200' do
|
||||
expect do
|
||||
post '/resend_confirmation', params: { email: email }, as: :json
|
||||
end.to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user exists and is already confirmed' do
|
||||
before { create(:user, email: email) }
|
||||
|
||||
it 'returns 200 without sending confirmation' do
|
||||
expect do
|
||||
post '/resend_confirmation', params: { email: email }, as: :json
|
||||
end.not_to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the email does not exist' do
|
||||
it 'returns 200 without leaking email existence' do
|
||||
post '/resend_confirmation', params: { email: 'nobody@example.com' }, as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when hCaptcha is configured' do
|
||||
before do
|
||||
create(:user, email: email, skip_confirmation: false)
|
||||
allow(ChatwootCaptcha).to receive(:new).and_return(captcha)
|
||||
end
|
||||
|
||||
context 'with a valid captcha response' do
|
||||
let(:captcha) { instance_double(ChatwootCaptcha, valid?: true) }
|
||||
|
||||
it 'sends confirmation instructions' do
|
||||
expect do
|
||||
post '/resend_confirmation',
|
||||
params: { email: email, h_captcha_client_response: 'valid-token' },
|
||||
as: :json
|
||||
end.to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an invalid captcha response' do
|
||||
let(:captcha) { instance_double(ChatwootCaptcha, valid?: false) }
|
||||
|
||||
it 'returns 200 without sending confirmation' do
|
||||
expect do
|
||||
post '/resend_confirmation',
|
||||
params: { email: email, h_captcha_client_response: 'bad-token' },
|
||||
as: :json
|
||||
end.not_to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,164 +7,111 @@ end
|
||||
|
||||
RSpec.describe Enterprise::WebsiteBrandingService do
|
||||
describe '#perform' do
|
||||
subject(:service) { test_klass.new(url) }
|
||||
subject(:service) { test_klass.new(email) }
|
||||
|
||||
let(:url) { 'https://example.com' }
|
||||
let(:api_key) { 'test-firecrawl-api-key' }
|
||||
let(:scrape_endpoint) { described_class::FIRECRAWL_SCRAPE_ENDPOINT }
|
||||
let(:fallback_html) { '<html lang="en"><head><title>Fallback</title></head><body></body></html>' }
|
||||
let(:email) { 'user@example.com' }
|
||||
let(:api_key) { 'test-context-dev-api-key' }
|
||||
let(:endpoint) { described_class::CONTEXT_DEV_ENDPOINT }
|
||||
let(:fallback_html) { '<html><head><title>Fallback</title></head><body></body></html>' }
|
||||
let(:success_response_body) do
|
||||
{
|
||||
success: true,
|
||||
data: {
|
||||
json: {
|
||||
business_name: 'Acme Corp',
|
||||
language: 'en',
|
||||
industry_category: 'Technology'
|
||||
},
|
||||
branding: {
|
||||
images: { logo: 'https://example.com/logo.png', favicon: 'https://example.com/favicon.png' },
|
||||
colors: { primary: '#FF5733' }
|
||||
},
|
||||
links: [
|
||||
'https://example.com/about',
|
||||
'https://facebook.com/acmecorp',
|
||||
'https://instagram.com/acme_corp',
|
||||
'https://wa.me/1234567890',
|
||||
'https://t.me/acmecorp',
|
||||
'https://tiktok.com/@acmetok'
|
||||
]
|
||||
status: 'ok',
|
||||
code: 200,
|
||||
brand: {
|
||||
domain: 'example.com',
|
||||
title: 'Acme Corp',
|
||||
description: 'Leading tech company',
|
||||
slogan: 'We build things',
|
||||
is_nsfw: false,
|
||||
colors: [{ hex: '#FF5733', name: 'Orange Red' }],
|
||||
logos: [{ url: 'https://media.brand.dev/logo.png', type: 'icon', mode: 'light',
|
||||
colors: [{ hex: '#FF5733', name: 'Orange Red' }],
|
||||
resolution: { width: 256, height: 256, aspect_ratio: 1 } }],
|
||||
socials: [
|
||||
{ type: 'facebook', url: 'https://facebook.com/acmecorp' },
|
||||
{ type: 'instagram', url: 'https://instagram.com/acme_corp' }
|
||||
],
|
||||
industries: {
|
||||
eic: [{ industry: 'Technology', subindustry: 'Software' }]
|
||||
}
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, url).to_return(status: 200, body: fallback_html, headers: { 'content-type' => 'text/html' })
|
||||
stub_request(:get, 'https://example.com').to_return(status: 200, body: fallback_html,
|
||||
headers: { 'content-type' => 'text/html' })
|
||||
end
|
||||
|
||||
context 'when firecrawl is configured and API returns success' do
|
||||
context 'when context.dev is configured and API returns success' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.with(headers: { 'Authorization' => "Bearer #{api_key}", 'Content-Type' => 'application/json' })
|
||||
create(:installation_config, name: 'CONTEXT_DEV_API_KEY', value: api_key)
|
||||
stub_request(:get, endpoint)
|
||||
.with(query: { email: email }, headers: { 'Authorization' => "Bearer #{api_key}" })
|
||||
.to_return(status: 200, body: success_response_body, headers: { 'content-type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'returns business info and branding from firecrawl' do
|
||||
it 'returns basic brand info' do
|
||||
result = service.perform
|
||||
|
||||
expect(result).to eq({
|
||||
business_name: 'Acme Corp',
|
||||
language: 'en',
|
||||
industry_category: 'Technology',
|
||||
social_handles: {
|
||||
whatsapp: '1234567890',
|
||||
line: nil,
|
||||
facebook: 'acmecorp',
|
||||
instagram: 'acme_corp',
|
||||
telegram: 'acmecorp',
|
||||
tiktok: '@acmetok'
|
||||
},
|
||||
branding: {
|
||||
favicon: 'https://example.com/favicon.png',
|
||||
primary_color: '#FF5733'
|
||||
}
|
||||
})
|
||||
expect(result).to include(domain: 'example.com', title: 'Acme Corp', description: 'Leading tech company',
|
||||
slogan: 'We build things', is_nsfw: false, email: email)
|
||||
end
|
||||
|
||||
it 'returns colors, logos, socials, and industries' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:colors]).to eq([{ hex: '#FF5733', name: 'Orange Red' }])
|
||||
expect(result[:logos].first[:url]).to eq('https://media.brand.dev/logo.png')
|
||||
expect(result[:socials]).to eq([{ type: 'facebook', url: 'https://facebook.com/acmecorp' },
|
||||
{ type: 'instagram', url: 'https://instagram.com/acme_corp' }])
|
||||
expect(result[:industries]).to eq([{ industry: 'Technology', subindustry: 'Software' }])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when firecrawl API returns an error' do
|
||||
context 'when context.dev API returns an error' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.to_return(status: 422, body: '{"error": "Invalid URL"}', headers: {})
|
||||
create(:installation_config, name: 'CONTEXT_DEV_API_KEY', value: api_key)
|
||||
stub_request(:get, endpoint)
|
||||
.with(query: { email: email })
|
||||
.to_return(status: 422, body: '{"error": "FREE_EMAIL_DETECTED"}')
|
||||
end
|
||||
|
||||
it 'falls back to basic scrape' do
|
||||
result = service.perform
|
||||
expect(result[:business_name]).to eq('Fallback')
|
||||
expect(result[:industry_category]).to be_nil
|
||||
it 'returns nil' do
|
||||
expect(service.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when firecrawl raises an exception' do
|
||||
context 'when context.dev raises an exception' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
stub_request(:post, scrape_endpoint).to_raise(StandardError.new('connection refused'))
|
||||
create(:installation_config, name: 'CONTEXT_DEV_API_KEY', value: api_key)
|
||||
stub_request(:get, endpoint).with(query: { email: email }).to_raise(StandardError.new('connection refused'))
|
||||
end
|
||||
|
||||
it 'falls back to basic scrape' do
|
||||
result = service.perform
|
||||
expect(result[:business_name]).to eq('Fallback')
|
||||
it 'returns nil' do
|
||||
expect(service.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when firecrawl is not configured' do
|
||||
it 'uses basic scrape' do
|
||||
expect(HTTParty).not_to receive(:post)
|
||||
context 'when context.dev is not configured' do
|
||||
it 'falls back to base scraper' do
|
||||
result = service.perform
|
||||
expect(result[:business_name]).to eq('Fallback')
|
||||
expect(result[:title]).to eq('Fallback')
|
||||
expect(result[:industries]).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when WhatsApp link uses api.whatsapp.com format' do
|
||||
context 'when context.dev returns empty brand' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
response = {
|
||||
success: true,
|
||||
data: {
|
||||
json: { business_name: 'Acme Corp' },
|
||||
links: ['https://api.whatsapp.com/send?phone=5511999999999&text=Hello']
|
||||
}
|
||||
}.to_json
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' })
|
||||
create(:installation_config, name: 'CONTEXT_DEV_API_KEY', value: api_key)
|
||||
stub_request(:get, endpoint)
|
||||
.with(query: { email: email })
|
||||
.to_return(status: 200, body: { status: 'ok', code: 200, brand: nil }.to_json,
|
||||
headers: { 'content-type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'extracts phone number from query param' do
|
||||
result = service.perform
|
||||
expect(result[:social_handles][:whatsapp]).to eq('5511999999999')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when WhatsApp link uses wa.me format' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
response = {
|
||||
success: true,
|
||||
data: {
|
||||
json: { business_name: 'Acme Corp' },
|
||||
links: ['https://wa.me/+5511999999999']
|
||||
}
|
||||
}.to_json
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'extracts phone number from path' do
|
||||
result = service.perform
|
||||
expect(result[:social_handles][:whatsapp]).to eq('5511999999999')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when links contain lookalike domains' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
|
||||
response = {
|
||||
success: true,
|
||||
data: {
|
||||
json: { business_name: 'Acme Corp' },
|
||||
links: ['https://notfacebook.com/page', 'https://fakeinstagram.com/user']
|
||||
}
|
||||
}.to_json
|
||||
stub_request(:post, scrape_endpoint)
|
||||
.to_return(status: 200, body: response, headers: { 'content-type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'does not match lookalike domains' do
|
||||
result = service.perform
|
||||
expect(result[:social_handles][:facebook]).to be_nil
|
||||
expect(result[:social_handles][:instagram]).to be_nil
|
||||
it 'returns nil' do
|
||||
expect(service.perform).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
require 'rails_helper'
|
||||
|
||||
# `SafeFetch.fetch` is a custom method that requires a block (it yields a Result);
|
||||
# it is NOT `Hash#fetch`, so RuboCop's autocorrect to `fetch(url, nil)` would break the API.
|
||||
# rubocop:disable Style/RedundantFetchBlock
|
||||
RSpec.describe SafeFetch do
|
||||
let(:url) { 'http://example.com/image.png' }
|
||||
|
||||
before do
|
||||
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
|
||||
end
|
||||
|
||||
describe '.fetch' do
|
||||
context 'with a valid public URL serving an image' do
|
||||
before do
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: File.new(Rails.root.join('spec/assets/avatar.png')),
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'yields a Result with tempfile, filename, and content_type' do
|
||||
described_class.fetch(url) do |result|
|
||||
expect(result.tempfile).to be_a(Tempfile)
|
||||
expect(result.filename).to eq('image.png')
|
||||
expect(result.content_type).to eq('image/png')
|
||||
expect(result.tempfile.size).to be > 0
|
||||
end
|
||||
end
|
||||
|
||||
it 'closes the tempfile after the block returns' do
|
||||
captured = nil
|
||||
described_class.fetch(url) { |result| captured = result.tempfile }
|
||||
expect(captured.closed?).to be true
|
||||
end
|
||||
|
||||
it 'closes the tempfile even when the block raises' do
|
||||
captured = nil
|
||||
expect do
|
||||
described_class.fetch(url) do |result|
|
||||
captured = result.tempfile
|
||||
raise 'boom'
|
||||
end
|
||||
end.to raise_error('boom')
|
||||
expect(captured.closed?).to be true
|
||||
end
|
||||
|
||||
it 'defaults the filename to a unique "download-<timestamp>-<hex>" when the URL has no path' do
|
||||
bare_url = 'http://example.com'
|
||||
stub_request(:get, bare_url).to_return(
|
||||
status: 200,
|
||||
body: File.new(Rails.root.join('spec/assets/avatar.png')),
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
described_class.fetch(bare_url) do |result|
|
||||
expect(result.filename).to match(/\Adownload-\d+-[a-f0-9]{8}\z/)
|
||||
end
|
||||
end
|
||||
|
||||
it 'requires a block' do
|
||||
expect { described_class.fetch(url) }.to raise_error(ArgumentError, /block required/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with URL validation' do
|
||||
it 'raises InvalidUrlError for javascript: URLs' do
|
||||
expect { described_class.fetch('javascript:alert(1)') { nil } }
|
||||
.to raise_error(SafeFetch::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for mailto: URLs' do
|
||||
expect { described_class.fetch('mailto:test@example.com') { nil } }
|
||||
.to raise_error(SafeFetch::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for data: URLs' do
|
||||
expect { described_class.fetch('data:text/html,<x>') { nil } }
|
||||
.to raise_error(SafeFetch::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for ftp: URLs' do
|
||||
expect { described_class.fetch('ftp://example.com/file') { nil } }
|
||||
.to raise_error(SafeFetch::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError for malformed URLs' do
|
||||
expect { described_class.fetch('not_a_url') { nil } }
|
||||
.to raise_error(SafeFetch::InvalidUrlError)
|
||||
end
|
||||
|
||||
it 'raises InvalidUrlError when host is missing' do
|
||||
expect { described_class.fetch('http:///path') { nil } }
|
||||
.to raise_error(SafeFetch::InvalidUrlError, /missing host/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with SSRF protection (integration with ssrf_filter)' do
|
||||
it 'raises UnsafeUrlError for private IP literals (10.x.x.x)' do
|
||||
expect { described_class.fetch('http://10.0.0.1/secret') { nil } }
|
||||
.to raise_error(SafeFetch::UnsafeUrlError)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for loopback addresses' do
|
||||
expect { described_class.fetch('http://127.0.0.1/secret') { nil } }
|
||||
.to raise_error(SafeFetch::UnsafeUrlError)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for AWS metadata IP (169.254.169.254)' do
|
||||
expect { described_class.fetch('http://169.254.169.254/latest/meta-data/') { nil } }
|
||||
.to raise_error(SafeFetch::UnsafeUrlError)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError when hostname resolves to a private IP (DNS rebinding)' do
|
||||
allow(Resolv).to receive(:getaddresses).with('evil.example.com').and_return(['10.0.0.1'])
|
||||
expect { described_class.fetch('http://evil.example.com/secret') { nil } }
|
||||
.to raise_error(SafeFetch::UnsafeUrlError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with content-type allowlist' do
|
||||
it 'rejects text/html responses' do
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: '<html></html>',
|
||||
headers: { 'Content-Type' => 'text/html' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(SafeFetch::UnsupportedContentTypeError)
|
||||
end
|
||||
|
||||
it 'rejects application/octet-stream responses' do
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: 'x',
|
||||
headers: { 'Content-Type' => 'application/octet-stream' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(SafeFetch::UnsupportedContentTypeError)
|
||||
end
|
||||
|
||||
it 'allows video/mp4 responses' do
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: File.new(Rails.root.join('spec/assets/avatar.png')),
|
||||
headers: { 'Content-Type' => 'video/mp4' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'strips charset/boundary parameters before comparing' do
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: 'x',
|
||||
headers: { 'Content-Type' => 'image/png; charset=binary' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'rejects when the content-type header is missing' do
|
||||
stub_request(:get, url).to_return(status: 200, body: 'x', headers: {})
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(SafeFetch::UnsupportedContentTypeError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with body size cap' do
|
||||
it 'honours a custom max_bytes argument' do
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: 'xxxxx',
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url, max_bytes: 2) { nil } }
|
||||
.to raise_error(SafeFetch::FileTooLargeError)
|
||||
end
|
||||
|
||||
it 'reads the default cap from GlobalConfigService MAXIMUM_FILE_UPLOAD_SIZE (matching Attachment#validate_file_size)' do
|
||||
allow(GlobalConfigService).to receive(:load).and_call_original
|
||||
allow(GlobalConfigService).to receive(:load).with('MAXIMUM_FILE_UPLOAD_SIZE', 40).and_return('1')
|
||||
|
||||
oversize = 'x' * (1.megabyte + 1)
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: oversize,
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(SafeFetch::FileTooLargeError)
|
||||
end
|
||||
|
||||
it 'falls back to 40 MB when GlobalConfigService returns a non-positive value' do
|
||||
allow(GlobalConfigService).to receive(:load).and_call_original
|
||||
allow(GlobalConfigService).to receive(:load).with('MAXIMUM_FILE_UPLOAD_SIZE', 40).and_return('-10')
|
||||
|
||||
# 1 MB body should pass under the 40 MB fallback
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: 'x' * 1.megabyte,
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'allows uploads between the old hardcoded 10 MB and the configured limit (regression check)' do
|
||||
# Default config is 40 MB; a 15 MB upload must succeed.
|
||||
# This is the exact regression scenario: with the old hardcoded 10 MB cap,
|
||||
# this would have failed even though direct file uploads of the same size succeed.
|
||||
allow(GlobalConfigService).to receive(:load).and_call_original
|
||||
allow(GlobalConfigService).to receive(:load).with('MAXIMUM_FILE_UPLOAD_SIZE', 40).and_return('40')
|
||||
|
||||
stub_request(:get, url).to_return(
|
||||
status: 200,
|
||||
body: 'x' * (15 * 1024 * 1024),
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'with network failures' do
|
||||
it 'maps Net::ReadTimeout to FetchError' do
|
||||
stub_request(:get, url).to_raise(Net::ReadTimeout)
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(SafeFetch::FetchError)
|
||||
end
|
||||
|
||||
it 'maps SocketError to FetchError' do
|
||||
stub_request(:get, url).to_raise(SocketError.new('connection refused'))
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(SafeFetch::FetchError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-2xx upstream responses' do
|
||||
it 'raises HttpError with the status code in the message' do
|
||||
stub_request(:get, url).to_return(status: 404, body: '', headers: {})
|
||||
|
||||
expect { described_class.fetch(url) { nil } }
|
||||
.to raise_error(SafeFetch::HttpError, /404/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Style/RedundantFetchBlock
|
||||
@@ -2,6 +2,7 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe WebsiteBrandingService do
|
||||
describe '#perform' do
|
||||
let(:email) { 'user@example.com' }
|
||||
let(:url) { 'https://example.com' }
|
||||
let(:html_body) do
|
||||
<<~HTML
|
||||
@@ -9,12 +10,21 @@ RSpec.describe WebsiteBrandingService do
|
||||
<head>
|
||||
<title>Acme Corp | Home</title>
|
||||
<meta property="og:site_name" content="Acme Corp" />
|
||||
<meta property="og:image" content="https://example.com/og-image.png" />
|
||||
<meta name="theme-color" content="#FF5733" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="shortcut icon" href="/favicon-32.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<header><a href="/">Home</a></header>
|
||||
<header>
|
||||
<a href="https://facebook.com/acmecorp">Facebook</a>
|
||||
<a href="https://instagram.com/acme_corp">Instagram</a>
|
||||
</header>
|
||||
<nav>
|
||||
<a href="https://facebook.com/acmecorp">FB</a>
|
||||
<a href="https://t.me/acmecorp">TG</a>
|
||||
</nav>
|
||||
<footer>
|
||||
<a href="https://facebook.com/acmecorp">Facebook</a>
|
||||
<a href="https://instagram.com/acme_corp">Instagram</a>
|
||||
@@ -31,26 +41,19 @@ RSpec.describe WebsiteBrandingService do
|
||||
stub_request(:get, url).to_return(status: 200, body: html_body, headers: { 'content-type' => 'text/html' })
|
||||
end
|
||||
|
||||
it 'extracts business info, branding, and social handles' do
|
||||
result = described_class.new(url).perform
|
||||
it 'extracts basic brand info' do
|
||||
result = described_class.new(email).perform
|
||||
|
||||
expect(result).to eq({
|
||||
business_name: 'Acme Corp',
|
||||
language: 'en',
|
||||
industry_category: nil,
|
||||
social_handles: {
|
||||
whatsapp: '1234567890',
|
||||
line: nil,
|
||||
facebook: 'acmecorp',
|
||||
instagram: 'acme_corp',
|
||||
telegram: 'acmecorp',
|
||||
tiktok: '@acmetok'
|
||||
},
|
||||
branding: {
|
||||
favicon: 'https://example.com/favicon.ico',
|
||||
primary_color: '#FF5733'
|
||||
}
|
||||
})
|
||||
expect(result).to include(domain: 'example.com', title: 'Acme Corp', email: email,
|
||||
description: nil, slogan: nil, is_nsfw: false, industries: [])
|
||||
end
|
||||
|
||||
it 'extracts colors, logos, and socials' do
|
||||
result = described_class.new(email).perform
|
||||
|
||||
expect(result[:colors]).to eq([{ hex: '#FF5733', name: nil }])
|
||||
expect(result[:logos].first[:url]).to eq('https://example.com/favicon.ico')
|
||||
expect(result[:socials].map { |s| s[:type] }).to contain_exactly('facebook', 'instagram', 'whatsapp', 'telegram', 'tiktok')
|
||||
end
|
||||
|
||||
context 'when og:site_name is missing' do
|
||||
@@ -64,17 +67,18 @@ RSpec.describe WebsiteBrandingService do
|
||||
end
|
||||
|
||||
it 'falls back to the first segment of the title' do
|
||||
result = described_class.new(url).perform
|
||||
expect(result[:business_name]).to eq('Mon Entreprise')
|
||||
expect(result[:language]).to eq('fr')
|
||||
result = described_class.new(email).perform
|
||||
expect(result[:title]).to eq('Mon Entreprise')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the page fails to load' do
|
||||
before { stub_request(:get, url).to_return(status: 500, body: '') }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(described_class.new(url).perform).to be_nil
|
||||
it 'returns nil and sets http_status' do
|
||||
service = described_class.new(email)
|
||||
expect(service.perform).to be_nil
|
||||
expect(service.http_status).to eq(500)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -83,18 +87,7 @@ RSpec.describe WebsiteBrandingService do
|
||||
|
||||
it 'logs the error and returns nil' do
|
||||
expect(Rails.logger).to receive(:error).with(/connection refused/)
|
||||
expect(described_class.new(url).perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when URL has no scheme' do
|
||||
before do
|
||||
stub_request(:get, 'https://example.com').to_return(status: 200, body: html_body, headers: { 'content-type' => 'text/html' })
|
||||
end
|
||||
|
||||
it 'prepends https://' do
|
||||
result = described_class.new('example.com').perform
|
||||
expect(result[:business_name]).to eq('Acme Corp')
|
||||
expect(described_class.new(email).perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -109,8 +102,9 @@ RSpec.describe WebsiteBrandingService do
|
||||
end
|
||||
|
||||
it 'extracts phone from query param' do
|
||||
result = described_class.new(url).perform
|
||||
expect(result[:social_handles][:whatsapp]).to eq('5511999999999')
|
||||
result = described_class.new(email).perform
|
||||
whatsapp = result[:socials].find { |s| s[:type] == 'whatsapp' }
|
||||
expect(whatsapp[:url]).to eq('https://wa.me/5511999999999')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -128,9 +122,10 @@ RSpec.describe WebsiteBrandingService do
|
||||
end
|
||||
|
||||
it 'does not match lookalike domains' do
|
||||
result = described_class.new(url).perform
|
||||
expect(result[:social_handles][:facebook]).to be_nil
|
||||
expect(result[:social_handles][:instagram]).to be_nil
|
||||
result = described_class.new(email).perform
|
||||
types = result[:socials].map { |s| s[:type] }
|
||||
expect(types).not_to include('facebook')
|
||||
expect(types).not_to include('instagram')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -148,8 +143,8 @@ RSpec.describe WebsiteBrandingService do
|
||||
end
|
||||
|
||||
it 'resolves the relative favicon URL' do
|
||||
result = described_class.new(url).perform
|
||||
expect(result[:branding][:favicon]).to eq('https://example.com/favicon.ico')
|
||||
result = described_class.new(email).perform
|
||||
expect(result[:logos].first[:url]).to eq('https://example.com/favicon.ico')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user