diff --git a/.annotaterb.yml b/.annotaterb.yml new file mode 100644 index 000000000..07162a22d --- /dev/null +++ b/.annotaterb.yml @@ -0,0 +1,65 @@ +--- +:position: before +:position_in_additional_file_patterns: before +:position_in_class: before +:position_in_factory: before +:position_in_fixture: before +:position_in_routes: before +:position_in_serializer: before +:position_in_test: before +:classified_sort: true +:exclude_controllers: true +:exclude_factories: true +:exclude_fixtures: true +:exclude_helpers: true +:exclude_scaffolds: true +:exclude_serializers: true +:exclude_sti_subclasses: false +:exclude_tests: true +:force: false +:format_markdown: false +:format_rdoc: false +:format_yard: false +:frozen: false +:grouped_polymorphic: false +:ignore_model_sub_dir: false +:ignore_unknown_models: false +:include_version: false +:show_check_constraints: false +:show_complete_foreign_keys: false +:show_foreign_keys: true +:show_indexes: true +:show_indexes_include: false +:simple_indexes: false +:sort: false +:timestamp: false +:trace: false +:with_comment: true +:with_column_comments: true +:with_table_comments: true +:position_of_column_comment: :with_name +:active_admin: false +:command: +:debug: false +:hide_default_column_types: json,jsonb,hstore +:hide_limit_column_types: integer,bigint,boolean +:timestamp_columns: +- created_at +- updated_at +:ignore_columns: +:ignore_routes: +:models: true +:routes: false +:skip_on_db_migrate: false +:target_action: :do_annotations +:wrapper: +:wrapper_close: +:wrapper_open: +:classes_default_to_s: [] +:additional_file_patterns: [] +:model_dir: +- app/models +- enterprise/app/models +:require: [] +:root_dir: +- '' diff --git a/.bundler-audit.yml b/.bundler-audit.yml index 7cb453c01..908d97175 100644 --- a/.bundler-audit.yml +++ b/.bundler-audit.yml @@ -2,3 +2,8 @@ ignore: - CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated) - GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+) + # Chatwoot defaults to Active Storage redirect-style URLs, and its recommended + # storage setup uses local/cloud storage with optional direct uploads to the + # storage provider rather than Rails proxy mode. Revisit if we enable + # rails_storage_proxy or other app-served Active Storage proxy routes. + - CVE-2026-33658 diff --git a/Gemfile b/Gemfile index 01c7a9f83..a5068e765 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 74ea4d82d..b77e5880f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/VERSION_CW b/VERSION_CW index 815588ef1..813b83b65 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.12.0 +4.13.0 diff --git a/app/builders/email/base_builder.rb b/app/builders/email/base_builder.rb index 731b1b0f5..a6da58792 100644 --- a/app/builders/email/base_builder.rb +++ b/app/builders/email/base_builder.rb @@ -1,4 +1,6 @@ class Email::BaseBuilder + include EmailAddressParseable + pattr_initialize [:inbox!] private @@ -39,7 +41,7 @@ class Email::BaseBuilder end def business_name - inbox.business_name || inbox.sanitized_name + inbox.sanitized_business_name end def account_support_email @@ -47,8 +49,4 @@ class Email::BaseBuilder # can save it in the format "Name " parse_email(account.support_email) end - - def parse_email(email_string) - Mail::Address.new(email_string).address - end end diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index fb986d335..b935bd520 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -1,6 +1,7 @@ class V2::ReportBuilder include DateRangeHelper include ReportHelper + attr_reader :account, :params DEFAULT_GROUP_BY = 'day'.freeze diff --git a/app/builders/v2/reports/agent_summary_builder.rb b/app/builders/v2/reports/agent_summary_builder.rb index 9b4541aaa..58689aaad 100644 --- a/app/builders/v2/reports/agent_summary_builder.rb +++ b/app/builders/v2/reports/agent_summary_builder.rb @@ -11,10 +11,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder attr_reader :conversations_count, :resolved_count, :avg_resolution_time, :avg_first_response_time, :avg_reply_time - def fetch_conversations_count - account.conversations.where(created_at: range).group('assignee_id').count - end - def prepare_report account.account_users.map do |account_user| build_agent_stats(account_user) diff --git a/app/builders/v2/reports/base_summary_builder.rb b/app/builders/v2/reports/base_summary_builder.rb index d4a9e7c0b..bd8ed5f56 100644 --- a/app/builders/v2/reports/base_summary_builder.rb +++ b/app/builders/v2/reports/base_summary_builder.rb @@ -9,37 +9,13 @@ class V2::Reports::BaseSummaryBuilder private def load_data - @conversations_count = fetch_conversations_count - load_reporting_events_data - end + results = data_source.summary - def load_reporting_events_data - # Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id') - index_key = group_by_key.to_s.split('.').last - - results = reporting_events - .select( - "#{group_by_key} as #{index_key}", - "COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count", - "AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time", - "AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time", - "AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time" - ) - .group(group_by_key) - .index_by { |record| record.public_send(index_key) } - - @resolved_count = results.transform_values(&:resolved_count) - @avg_resolution_time = results.transform_values(&:avg_resolution_time) - @avg_first_response_time = results.transform_values(&:avg_first_response_time) - @avg_reply_time = results.transform_values(&:avg_reply_time) - end - - def reporting_events - @reporting_events ||= account.reporting_events.where(created_at: range) - end - - def fetch_conversations_count - # Override this method + @conversations_count = results.transform_values { |data| data[:conversations_count] } + @resolved_count = results.transform_values { |data| data[:resolved_conversations_count] } + @avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] } + @avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] } + @avg_reply_time = results.transform_values { |data| data[:avg_reply_time] } end def group_by_key @@ -50,7 +26,26 @@ class V2::Reports::BaseSummaryBuilder # Override this method end - def average_value_key - ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value + def data_source + @data_source ||= Reports::DataSource.for( + account: account, + metric: nil, + dimension_type: summary_dimension_type, + dimension_id: nil, + scope: nil, + range: range, + group_by: 'day', + timezone_offset: params[:timezone_offset], + business_hours: params[:business_hours] + ) + end + + def summary_dimension_type + { + 'account_id' => 'account', + 'user_id' => 'agent', + 'inbox_id' => 'inbox', + 'conversations.team_id' => 'team' + }.fetch(group_by_key.to_s) end end diff --git a/app/builders/v2/reports/conversations/base_report_builder.rb b/app/builders/v2/reports/conversations/base_report_builder.rb index a7961b0d6..ee0155150 100644 --- a/app/builders/v2/reports/conversations/base_report_builder.rb +++ b/app/builders/v2/reports/conversations/base_report_builder.rb @@ -3,23 +3,10 @@ class V2::Reports::Conversations::BaseReportBuilder private - AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze - COUNT_METRICS = %w[ - conversations_count - incoming_messages_count - outgoing_messages_count - resolutions_count - bot_resolutions_count - bot_handoffs_count - ].freeze - def builder_class(metric) - case metric - when *AVG_METRICS - V2::Reports::Timeseries::AverageReportBuilder - when *COUNT_METRICS - V2::Reports::Timeseries::CountReportBuilder - end + return unless Reports::ReportMetricRegistry.supported?(metric) + + V2::Reports::Timeseries::ReportBuilder end def log_invalid_metric diff --git a/app/builders/v2/reports/inbox_summary_builder.rb b/app/builders/v2/reports/inbox_summary_builder.rb index 935afeb82..fcfabc599 100644 --- a/app/builders/v2/reports/inbox_summary_builder.rb +++ b/app/builders/v2/reports/inbox_summary_builder.rb @@ -11,15 +11,6 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder attr_reader :conversations_count, :resolved_count, :avg_resolution_time, :avg_first_response_time, :avg_reply_time - def load_data - @conversations_count = fetch_conversations_count - load_reporting_events_data - end - - def fetch_conversations_count - account.conversations.where(created_at: range).group(group_by_key).count - end - def prepare_report account.inboxes.map do |inbox| build_inbox_stats(inbox) @@ -40,8 +31,4 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder def group_by_key :inbox_id end - - def average_value_key - ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value - end end diff --git a/app/builders/v2/reports/team_summary_builder.rb b/app/builders/v2/reports/team_summary_builder.rb index b98151bc6..3bcf51816 100644 --- a/app/builders/v2/reports/team_summary_builder.rb +++ b/app/builders/v2/reports/team_summary_builder.rb @@ -6,14 +6,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder attr_reader :conversations_count, :resolved_count, :avg_resolution_time, :avg_first_response_time, :avg_reply_time - def fetch_conversations_count - account.conversations.where(created_at: range).group(:team_id).count - end - - def reporting_events - @reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation) - end - def prepare_report account.teams.map do |team| build_team_stats(team) diff --git a/app/builders/v2/reports/timeseries/average_report_builder.rb b/app/builders/v2/reports/timeseries/average_report_builder.rb deleted file mode 100644 index 5df718b6a..000000000 --- a/app/builders/v2/reports/timeseries/average_report_builder.rb +++ /dev/null @@ -1,48 +0,0 @@ -class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder - def timeseries - grouped_average_time = reporting_events.average(average_value_key) - grouped_event_count = reporting_events.count - grouped_average_time.each_with_object([]) do |element, arr| - event_date, average_time = element - arr << { - value: average_time, - timestamp: event_date.in_time_zone(timezone).to_i, - count: grouped_event_count[event_date] - } - end - end - - def aggregate_value - object_scope.average(average_value_key) - end - - private - - def event_name - metric_to_event_name = { - avg_first_response_time: :first_response, - avg_resolution_time: :conversation_resolved, - reply_time: :reply_time - } - metric_to_event_name[params[:metric].to_sym] - end - - def object_scope - scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id) - end - - def reporting_events - @grouped_values = object_scope.group_by_period( - group_by, - :created_at, - default_value: 0, - range: range, - permit: %w[day week month year hour], - time_zone: timezone - ) - end - - def average_value_key - @average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value - end -end diff --git a/app/builders/v2/reports/timeseries/base_timeseries_builder.rb b/app/builders/v2/reports/timeseries/base_timeseries_builder.rb index 50699417d..86fddba07 100644 --- a/app/builders/v2/reports/timeseries/base_timeseries_builder.rb +++ b/app/builders/v2/reports/timeseries/base_timeseries_builder.rb @@ -1,12 +1,13 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder include TimezoneHelper include DateRangeHelper + DEFAULT_GROUP_BY = 'day'.freeze pattr_initialize :account, :params def scope - case params[:type].to_sym + case dimension_type.to_sym when :account account when :inbox @@ -20,6 +21,20 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder end end + def data_source + @data_source ||= Reports::DataSource.for( + account: account, + metric: params[:metric], + dimension_type: dimension_type, + dimension_id: params[:id], + scope: scope, + range: range, + group_by: group_by, + timezone_offset: params[:timezone_offset], + business_hours: params[:business_hours] + ) + end + def inbox @inbox ||= account.inboxes.find(params[:id]) end @@ -43,4 +58,10 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder def timezone @timezone ||= timezone_name_from_offset(params[:timezone_offset]) end + + private + + def dimension_type + (params[:type].presence || 'account').to_s + end end diff --git a/app/builders/v2/reports/timeseries/count_report_builder.rb b/app/builders/v2/reports/timeseries/count_report_builder.rb deleted file mode 100644 index bb3b1250c..000000000 --- a/app/builders/v2/reports/timeseries/count_report_builder.rb +++ /dev/null @@ -1,78 +0,0 @@ -class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder - def timeseries - grouped_count.each_with_object([]) do |element, arr| - event_date, event_count = element - - # The `event_date` is in Date format (without time), such as "Wed, 15 May 2024". - # We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i` - # because it converts the date to 12:00 AM server timezone. - # The desired output should be 12:00 AM in the specified timezone. - arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i } - end - end - - def aggregate_value - object_scope.count - end - - private - - def metric - @metric ||= params[:metric] - end - - def object_scope - send("scope_for_#{metric}") - end - - def scope_for_conversations_count - scope.conversations.where(account_id: account.id, created_at: range) - end - - def scope_for_incoming_messages_count - scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order) - end - - def scope_for_outgoing_messages_count - scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order) - end - - def scope_for_resolutions_count - scope.reporting_events.where( - name: :conversation_resolved, - account_id: account.id, - created_at: range - ) - end - - def scope_for_bot_resolutions_count - scope.reporting_events.where( - name: :conversation_bot_resolved, - account_id: account.id, - created_at: range - ) - end - - def scope_for_bot_handoffs_count - scope.reporting_events.joins(:conversation).select(:conversation_id).where( - name: :conversation_bot_handoff, - account_id: account.id, - created_at: range - ).distinct - end - - def grouped_count - # IMPORTANT: time_zone parameter affects both data grouping AND output timestamps - # It converts timestamps to the target timezone before grouping, which means - # the same event can fall into different day buckets depending on timezone - # Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day) - @grouped_values = object_scope.group_by_period( - group_by, - :created_at, - default_value: 0, - range: range, - permit: %w[day week month year hour], - time_zone: timezone - ).count - end -end diff --git a/app/builders/v2/reports/timeseries/report_builder.rb b/app/builders/v2/reports/timeseries/report_builder.rb new file mode 100644 index 000000000..a3e83c78a --- /dev/null +++ b/app/builders/v2/reports/timeseries/report_builder.rb @@ -0,0 +1,9 @@ +class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder + def timeseries + data_source.timeseries + end + + def aggregate_value + data_source.aggregate + end +end diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index c2f919659..de3d10081 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -34,6 +34,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController @agent_bot.reload end + def reset_secret + @agent_bot.reset_secret! + end + private def agent_bot diff --git a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb b/app/controllers/api/v1/accounts/captain/tasks_controller.rb similarity index 93% rename from enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb rename to app/controllers/api/v1/accounts/captain/tasks_controller.rb index d7208d678..9ba197a3c 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb +++ b/app/controllers/api/v1/accounts/captain/tasks_controller.rb @@ -57,7 +57,7 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr if result.nil? render json: { message: nil } elsif result[:error] - render json: { error: result[:error] }, status: :unprocessable_entity + render json: { error: result[:error] }, status: :unprocessable_content else response_data = { message: result[:message] } response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context] @@ -69,3 +69,5 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr authorize(:'captain/tasks') end end + +Api::V1::Accounts::Captain::TasksController.prepend_mod_with('Api::V1::Accounts::Captain::TasksController') diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 52d829441..6159f804d 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -116,6 +116,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro # High-traffic accounts generate excessive DB writes when agents frequently switch between conversations. # Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load. # Always update immediately if there are unread messages to maintain accurate read/unread state. + # Visiting a conversation should clear any unread inbox notifications for this conversation. + Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any? return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any? diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 4ca9a6af8..c7d3e2737 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -66,6 +66,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController head :ok end + def reset_secret + return head :not_found unless @inbox.api? + + @inbox.channel.reset_secret! + end + def destroy ::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present? render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } diff --git a/app/controllers/api/v1/accounts/upload_controller.rb b/app/controllers/api/v1/accounts/upload_controller.rb index 479d8ae1b..bf20bc6ff 100644 --- a/app/controllers/api/v1/accounts/upload_controller.rb +++ b/app/controllers/api/v1/accounts/upload_controller.rb @@ -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) diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 3e513a4b2..7176d6e1b 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -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 diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index a51b4c2d6..83b3dc8b1 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -43,7 +43,15 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController end def set_conversation - @conversation = create_conversation if conversation.nil? + return unless conversation.nil? + + @conversation = create_conversation + apply_labels if permitted_params[:labels].present? + end + + def apply_labels + valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title) + @conversation.update_labels(valid_labels) if valid_labels.present? end def message_finder_params @@ -64,7 +72,14 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController def permitted_params # timestamp parameter is used in create conversation method - params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to]) + # custom_attributes and labels are applied when a new conversation is created alongside the first message + params.permit( + :id, :before, :after, :website_token, + contact: [:name, :email], + message: [:content, :referer_url, :timestamp, :echo_id, :reply_to], + custom_attributes: {}, + labels: [] + ) end def set_message diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb index 98b3f05d7..40d5947b9 100644 --- a/app/controllers/api/v2/accounts/summary_reports_controller.rb +++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb @@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel] def agent - render_report_with(V2::Reports::AgentSummaryBuilder) + render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent) end def team - render_report_with(V2::Reports::TeamSummaryBuilder) + render_report_with(V2::Reports::TeamSummaryBuilder, type: :team) end def inbox - render_report_with(V2::Reports::InboxSummaryBuilder) + render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox) end def label @@ -38,8 +38,9 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr } end - def render_report_with(builder_class) - builder = builder_class.new(account: Current.account, params: @builder_params) + def render_report_with(builder_class, type: nil) + builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params + builder = builder_class.new(account: Current.account, params: builder_params) render json: builder.build end diff --git a/app/controllers/auth/resend_confirmations_controller.rb b/app/controllers/auth/resend_confirmations_controller.rb new file mode 100644 index 000000000..b2c778c46 --- /dev/null +++ b/app/controllers/auth/resend_confirmations_controller.rb @@ -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 diff --git a/app/controllers/concerns/twilio_signature_verify_concern.rb b/app/controllers/concerns/twilio_signature_verify_concern.rb new file mode 100644 index 000000000..b7a4754a4 --- /dev/null +++ b/app/controllers/concerns/twilio_signature_verify_concern.rb @@ -0,0 +1,88 @@ +module TwilioSignatureVerifyConcern + extend ActiveSupport::Concern + + included do + before_action :verify_twilio_signature! + end + + private + + def verify_twilio_signature! + channel = find_twilio_channel + return log_and_reject_missing_channel if channel.blank? + return if channel.api_key_sid.present? && log_api_key_skip(channel) + + head :forbidden unless valid_signature?(channel) + end + + def log_and_reject_missing_channel + Rails.logger.warn( + '[TWILIO] Channel not found for webhook ' \ + "account_sid=#{params[:AccountSid]} messaging_service_sid=#{params[:MessagingServiceSid]} " \ + "to=#{params[:To]} from=#{params[:From]}" + ) + head :forbidden + end + + def log_api_key_skip(channel) + Rails.logger.warn( + '[TWILIO] Signature validation skipped: channel uses API key authentication. ' \ + "account_sid=#{params[:AccountSid]} channel_id=#{channel.id}" + ) + end + + def valid_signature?(channel) + signature = request.headers['X-Twilio-Signature'] + if signature.blank? + Rails.logger.warn("[TWILIO] Missing X-Twilio-Signature header account_sid=#{params[:AccountSid]}") + return false + end + + validator = Twilio::Security::RequestValidator.new(channel.auth_token) + request_url = reconstruct_url + return true if validator.validate(request_url, request.request_parameters, signature) + + Rails.logger.warn( + '[TWILIO] Signature validation failed ' \ + "account_sid=#{params[:AccountSid]} channel_id=#{channel.id} url=#{request_url} ip=#{request.remote_ip}" + ) + false + end + + def find_twilio_channel + if params[:MessagingServiceSid].present? + channel = ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) + return channel if channel.present? && (params[:AccountSid].blank? || channel.account_sid == params[:AccountSid]) + + return nil + end + return if params[:AccountSid].blank? + + find_channel_by_phone_number + end + + def find_channel_by_phone_number + channel_lookup_phone_numbers.each do |phone| + channel = ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: phone) + return channel if channel + end + nil + end + + def channel_lookup_phone_numbers + [params[:To], params[:From]].compact_blank + end + + def reconstruct_url + url = request.original_url + url = url.sub('http://', 'https://') if url.start_with?('http://') && https_request? + url + end + + def https_request? + return true if request.ssl? + + forwarded_proto = request.headers['X-Forwarded-Proto'].to_s.split(',').map(&:strip).find(&:present?) + forwarded_proto&.casecmp?('https') + end +end diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index af759af54..2c8387142 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -10,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa private def sign_in_user + # Capture before skip_confirmation! sets confirmed_at, which would + # make oauth_user_needs_password_reset? return false and skip the + # password reset for persisted unconfirmed users. + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def sign_in_user_on_mobile + # See comment in sign_in_user for why this is captured before skip_confirmation! + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? create_account_for_user + set_random_password_if_oauth_user token = @resource.send(:set_reset_password_token) frontend_url = ENV.fetch('FRONTEND_URL', nil) redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}" @@ -81,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image']) end + def oauth_user_needs_password_reset? + @resource.present? && (@resource.new_record? || !@resource.confirmed?) + end + + def set_random_password_if_oauth_user + # Password must satisfy secure_password requirements (uppercase, lowercase, number, special char) + @resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted? + end + def default_devise_mapping 'user' end diff --git a/app/controllers/platform/api/v1/email_channel_migrations_controller.rb b/app/controllers/platform/api/v1/email_channel_migrations_controller.rb new file mode 100644 index 000000000..3e9e8defd --- /dev/null +++ b/app/controllers/platform/api/v1/email_channel_migrations_controller.rb @@ -0,0 +1,101 @@ +class Platform::Api::V1::EmailChannelMigrationsController < PlatformController + before_action :set_account + before_action :validate_account_permissible + before_action :validate_feature_flag + before_action :validate_params + + def create + results = migrate_email_channels + render json: { results: results }, status: :ok + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def validate_account_permissible + return if @platform_app.platform_app_permissibles.find_by(permissible: @account) + + render json: { error: 'Non permissible resource' }, status: :unauthorized + end + + def validate_feature_flag + return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false)) + + render json: { error: 'Email channel migration is not enabled' }, status: :forbidden + end + + def validate_params + return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank? + + return unless migration_params.size > MAX_MIGRATIONS + + return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" }, + status: :unprocessable_entity + end + + def migrate_email_channels + migration_params.map { |entry| migrate_single(entry) } + end + + MAX_MIGRATIONS = 25 + SUPPORTED_PROVIDERS = %w[google microsoft].freeze + + def migrate_single(entry) + validate_provider!(entry[:provider]) + + ActiveRecord::Base.transaction do + channel = create_channel(entry) + inbox = create_inbox(channel, entry) + + { email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' } + end + rescue StandardError => e + { email: entry[:email], status: 'error', message: e.message } + end + + def create_channel(entry) + Channel::Email.create!( + account_id: @account.id, + email: entry[:email], + provider: entry[:provider], + provider_config: entry[:provider_config]&.to_h, + imap_enabled: entry.fetch(:imap_enabled, true), + imap_address: entry[:imap_address] || default_imap_address(entry[:provider]), + imap_port: entry[:imap_port] || 993, + imap_login: entry[:imap_login] || entry[:email], + imap_enable_ssl: entry.fetch(:imap_enable_ssl, true) + ) + end + + def create_inbox(channel, entry) + @account.inboxes.create!( + name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}", + channel: channel + ) + end + + def validate_provider!(provider) + return if SUPPORTED_PROVIDERS.include?(provider) + + raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}" + end + + def default_imap_address(provider) + case provider + when 'google' then 'imap.gmail.com' + when 'microsoft' then 'outlook.office365.com' + else '' + end + end + + def migration_params + params.permit(migrations: [ + :email, :provider, :inbox_name, + :imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl, + { provider_config: {} } + ])[:migrations] + end +end diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index 664a1964f..2bbfafcc7 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -1,6 +1,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show, :index] before_action :portal + before_action :ensure_portal_feature_enabled before_action :set_category, except: [:index, :show, :tracking_pixel] before_action :set_article, only: [:show] layout 'portal' @@ -61,7 +62,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def set_article @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) - @parsed_content = render_article_content(@article.content) + @parsed_content = render_article_content(@article.content.to_s) end def set_category diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb index ebfcb310a..3fb200269 100644 --- a/app/controllers/public/api/v1/portals/categories_controller.rb +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -1,6 +1,7 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show, :index] before_action :portal + before_action :ensure_portal_feature_enabled before_action :set_category, only: [:show] layout 'portal' diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index df4552432..a187ca8a8 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -1,7 +1,8 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show] - before_action :portal before_action :redirect_to_portal_with_locale, only: [:show] + before_action :portal + before_action :ensure_portal_feature_enabled layout 'portal' def show @@ -24,6 +25,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl def redirect_to_portal_with_locale return if params[:locale].present? + portal redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" end end diff --git a/app/controllers/public_controller.rb b/app/controllers/public_controller.rb index 3b83a2210..b266b725b 100644 --- a/app/controllers/public_controller.rb +++ b/app/controllers/public_controller.rb @@ -18,4 +18,11 @@ class PublicController < ActionController::Base Please send us an email at support@chatwoot.com with the custom domain name and account API key" }, status: :unauthorized and return end + + def ensure_portal_feature_enabled + return unless ChatwootApp.chatwoot_cloud? + return if @portal.account.feature_enabled?('help_center') + + render 'public/api/v1/portals/not_active', status: :payment_required + end end diff --git a/app/controllers/twilio/callback_controller.rb b/app/controllers/twilio/callback_controller.rb index d607ba151..9b42cd034 100644 --- a/app/controllers/twilio/callback_controller.rb +++ b/app/controllers/twilio/callback_controller.rb @@ -1,4 +1,6 @@ class Twilio::CallbackController < ApplicationController + include TwilioSignatureVerifyConcern + def create Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash) diff --git a/app/controllers/twilio/delivery_status_controller.rb b/app/controllers/twilio/delivery_status_controller.rb index 1c756a1c2..8e846a737 100644 --- a/app/controllers/twilio/delivery_status_controller.rb +++ b/app/controllers/twilio/delivery_status_controller.rb @@ -1,4 +1,6 @@ class Twilio::DeliveryStatusController < ApplicationController + include TwilioSignatureVerifyConcern + def create Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash) @@ -18,4 +20,8 @@ class Twilio::DeliveryStatusController < ApplicationController :ErrorMessage ) end + + def channel_lookup_phone_numbers + [params[:From]].compact_blank + end end diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 8912c03d1..a706e2df5 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -98,7 +98,9 @@ export default { mql.onchange = e => setColorTheme(e.matches); }, setLocale(locale) { - this.$root.$i18n.locale = locale; + if (locale) { + this.$root.$i18n.locale = locale; + } }, async initializeAccount() { await this.$store.dispatch('accounts/get'); diff --git a/app/javascript/dashboard/api/agentBots.js b/app/javascript/dashboard/api/agentBots.js index de887f415..a16b252de 100644 --- a/app/javascript/dashboard/api/agentBots.js +++ b/app/javascript/dashboard/api/agentBots.js @@ -25,6 +25,10 @@ class AgentBotsAPI extends ApiClient { resetAccessToken(botId) { return axios.post(`${this.url}/${botId}/reset_access_token`); } + + resetSecret(botId) { + return axios.post(`${this.url}/${botId}/reset_secret`); + } } export default new AgentBotsAPI(); diff --git a/app/javascript/dashboard/api/captain/customTools.js b/app/javascript/dashboard/api/captain/customTools.js index d0818d941..471c2846b 100644 --- a/app/javascript/dashboard/api/captain/customTools.js +++ b/app/javascript/dashboard/api/captain/customTools.js @@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient { delete(id) { return axios.delete(`${this.url}/${id}`); } + + test(data = {}) { + return axios.post(`${this.url}/test`, { + custom_tool: data, + }); + } } export default new CaptainCustomTools(); diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 079f21815..cc564fe96 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -48,6 +48,10 @@ class Inboxes extends CacheEnabledApiClient { template, }); } + + resetSecret(inboxId) { + return axios.post(`${this.url}/${inboxId}/reset_secret`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/assets/scss/_base.scss b/app/javascript/dashboard/assets/scss/_base.scss index 84c8a4b0f..108da3889 100644 --- a/app/javascript/dashboard/assets/scss/_base.scss +++ b/app/javascript/dashboard/assets/scss/_base.scss @@ -106,6 +106,10 @@ select { &[disabled] { @apply field-disabled; } + + option:not(:disabled) { + @apply bg-n-solid-2 text-n-slate-12; + } } // Textarea diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue index 3ac7dfd5f..2aafec45b 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue @@ -20,11 +20,11 @@ const excludedLabels = defineModel('excludedLabels', { const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', { type: Number, - default: 10, + default: null, }); -// Duration limits: 10 minutes to 999 days (in minutes) -const MIN_DURATION_MINUTES = 10; +// Duration limits: 1 minute to 999 days (in minutes) +const MIN_DURATION_MINUTES = 1; const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes const { t } = useI18n(); diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue index b31248653..7b79e5280 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue @@ -27,7 +27,7 @@ const { t } = useI18n(); const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY'; const DEFAULT_CONVERSATION_LIMIT = 10; -const MIN_CONVERSATION_LIMIT = 1; +const MIN_CONVERSATION_LIMIT = 0; const MAX_CONVERSATION_LIMIT = 100000; const selectedInboxIds = computed( @@ -42,6 +42,7 @@ const availableInboxes = computed(() => const isLimitValid = limit => { return ( + Number.isInteger(limit.conversationLimit) && limit.conversationLimit >= MIN_CONVERSATION_LIMIT && limit.conversationLimit <= MAX_CONVERSATION_LIMIT ); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue index 0bc1e376c..351cc7071 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue @@ -107,11 +107,10 @@ const closeMobileSidebar = () => { size="sm" /> -