diff --git a/.annotaterb.yml b/.annotaterb.yml index 5b79a7fba..07162a22d 100644 --- a/.annotaterb.yml +++ b/.annotaterb.yml @@ -1,45 +1,65 @@ -additional_file_patterns: [] -routes: false -models: true -position_in_routes: before -position_in_class: before -position_in_test: before -position_in_fixture: before -position_in_factory: before -position_in_serializer: before -show_foreign_keys: true -show_complete_foreign_keys: false -show_indexes: true -simple_indexes: false -model_dir: - - app/models - - enterprise/app/models -root_dir: '' -include_version: false -require: '' -exclude_tests: true -exclude_fixtures: true -exclude_factories: true -exclude_serializers: true -exclude_scaffolds: true -exclude_controllers: true -exclude_helpers: true -exclude_sti_subclasses: false -ignore_model_sub_dir: false -ignore_columns: null -ignore_routes: null -ignore_unknown_models: false -hide_limit_column_types: integer,bigint,boolean -hide_default_column_types: json,jsonb,hstore -skip_on_db_migrate: false -format_bare: true -format_rdoc: false -format_markdown: false -sort: false -force: false -frozen: false -classified_sort: true -trace: false -wrapper_open: null -wrapper_close: null -with_comment: true +--- +: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/.claude/skills/merge-upstream/SKILL.md b/.claude/skills/merge-upstream/SKILL.md new file mode 100644 index 000000000..119dc9ab5 --- /dev/null +++ b/.claude/skills/merge-upstream/SKILL.md @@ -0,0 +1,188 @@ +--- +name: merge-upstream +description: Use this skill when pulling chatwoot upstream (chatwoot/chatwoot) into the fazer-ai fork, resolving merge conflicts, and validating the result. Covers direction choice, per-file decision framework (KC/AI/CO/delete), recurring patterns (SaveBang, signature architecture, schema.rb regen, WhatsApp service, installation_config), validation flow, and pre-commit/CI pitfalls specific to this repo. Trigger when the user asks to merge develop/main from chatwoot upstream, resolve merge conflicts on a merge branch, or bump the fork to a new chatwoot version. +allowed-tools: Bash, Read, Edit, Write, Grep, Glob +--- + +# Merge upstream (chatwoot → fazer-ai fork) + +The fazer-ai fork diverges from chatwoot upstream on real features (Baileys, Zapi, per-inbox signatures, scheduled messages, group conversations, internal chat). Every few releases we pull upstream in to stay current. This skill captures the recurring patterns and footguns so the next merge doesn't rediscover them from scratch. + +## Direction of merge + +Prefer **branch from our fork's `main`, merge `upstream/develop` into it**, not the other way around. + +- Same number of conflicts either way — git is symmetric. +- What differs: the `--first-parent` chain. Merging upstream into a fork-based branch keeps our main's first-parent history "our work", with upstream as a side merge. Easier to answer "what's ours" later with `git log --first-parent`. +- If the current in-progress merge already went the other direction, finish it as-is. Standardize on next merge. + +## Pre-flight + +After `git merge upstream/develop` (or whatever ref), before touching anything: + +```bash +# list conflicted files +git diff --name-only --diff-filter=U + +# confirm direction — who is HEAD (ours) vs MERGE_HEAD (theirs) +cat .git/MERGE_HEAD +head -5 .git/MERGE_MSG +git log --oneline HEAD -3 +git log --oneline MERGE_HEAD -3 +``` + +Terminology used in this skill: +- **HEAD / current / ours** = the branch you're sitting on (the one receiving the merge). +- **MERGE_HEAD / incoming / theirs** = the branch being merged in. + +If you're on a fork-based branch pulling upstream in: `HEAD` = fork, `MERGE_HEAD` = upstream. +If you're on an upstream-based branch pulling fork in (the less-preferred direction): `HEAD` = upstream, `MERGE_HEAD` = fork. + +Read carefully which side is which before labeling decisions. + +## Per-file decision framework + +For each conflicted file, pick one of: + +| Code | Meaning | +|------|---------| +| **KC** | Keep current (HEAD) — drop the incoming side | +| **AI** | Accept incoming (MERGE_HEAD) — drop the HEAD side | +| **CO** | Combination — merge both sides manually | +| **DEL** | Accept deletion — `git rm` (modify/delete conflict where one side deleted) | + +Process: + +1. Read the conflict markers to see what each side does. +2. `git log --oneline HEAD -5 -- ` and `git log --oneline MERGE_HEAD -5 -- ` — understand WHY each side changed it. +3. For modify/delete: `git ls-files -u ` shows which stages are present (1=base, 2=ours, 3=theirs). +4. For complex hunks: `git show HEAD:` and `git show MERGE_HEAD:` to see each full file. +5. Decide KC/AI/CO/DEL based on intent, not just diff. + +## Recurring patterns in this repo + +### Style/SaveBang noise + +Our fork has `Rails/SaveBang: Enabled: true` in `.rubocop.yml`. Upstream doesn't enforce it as strictly. Consequence: when upstream touches any line near a persistence call, we see a conflict where our side says `save!`/`update!`/`destroy!`/`create!` and theirs says the non-bang version. + +The cop flags more than just `save`. Full list it tries to add `!` to: `save`, `update`, `update_attributes`, `destroy`, `create`, `create_or_find_by`, `find_or_create_by`, `find_or_initialize_by`, `first_or_create`, `first_or_initialize`. Any of these can appear in a conflict. + +- Most are **trivial** style churn from our fork's rubocop autofix, no semantic change. +- **Never blindly accept the bang rewrite (or run `rubocop -A`) without evaluating each offense individually.** The cop doesn't check the receiver's class — it matches by method name alone. Non-ActiveRecord receivers (POROs, service objects with their own `save`/`update`/`destroy` method, third-party libraries like Stripe, Kredis, OpenStruct wrappers, CSV/IO objects with `update`, filesystem objects with `destroy`) will raise `NoMethodError` at runtime. Caught by CI if there's a spec, silently broken in prod if not. +- For each SaveBang offense, read the surrounding code: what class is the receiver? If it's an ActiveRecord model, the autocorrect is safe. If it's anything else, either add the receiver to `.rubocop.yml`'s `Rails/SaveBang.AllowedReceivers` list (currently Stripe::Subscription, Stripe::Customer, Stripe::Invoice) or add a targeted `rubocop:disable Rails/SaveBang` comment. +- Safe workflow: run `bundle exec rubocop ` (without `-A`) first to see the offenses listed, evaluate each individually, then apply `-A` only once you've confirmed every receiver is an ActiveRecord object. Always review the diff before committing. + +### Signature architecture (PR #79) + +We deliberately removed upstream's editor-side signature manipulation (`addSignature`, `removeSignature`, `toggleSignatureInEditor`, signature-in-draft logic) and moved signature application to **send-time** (`getMessagePayload`). This prevents signature duplication, persistence in drafts, and position-toggle bugs. + +When upstream adds or tweaks any signature-related code in: +- `app/javascript/dashboard/components/widgets/WootWriter/Editor.vue` +- `app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue` +- `app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue` + +→ Usually **AI (accept incoming = our fork)**, preserving the send-time architecture. Upstream's "fixes" may be rebuilding exactly what we tore out. + +One exception worth porting as follow-up (NOT during merge): upstream's inline-image sanitization (`stripInlineBase64Images` + `INLINE_IMAGE_WARNING` i18n key) is orthogonal to architecture and would be a nice safety net in our send-time code. + +### WhatsApp incoming message service + +`app/services/whatsapp/incoming_message_base_service.rb` is the other frequent conflict zone. Our fork has two-layer locking (source_id lock + contact phone lock) plus a contact-level re-check for slow networks. Upstream evolves its simpler dedup logic. + +Decision: **CO (combination)**. Keep the fork's `acquire_message_processing_lock` + `with_contact_lock` + explicit `clear_message_source_id_from_redis` in `ensure`. Layer upstream's improvements in (e.g., the `@contact.blocked? && !outgoing_echo` check) at the equivalent point inside the contact lock. + +Adjacent file that may need follow-up: `app/services/whatsapp/incoming_message_service_helpers.rb` typically auto-merges to our version. That's correct. If upstream's `Whatsapp::MessageDedupLock` class becomes orphaned after a merge, `git rm` it (and its spec). + +**Known regression hiding here:** `acquire_message_processing_lock` in our fork checks `@processed_params.try(:[], :messages).blank?`, which skips `:message_echoes` payloads. Echoes from WhatsApp Cloud native-app sends were being silently dropped. Fixed in the 4.13.0 merge by changing to `messages_data.blank?` and picking `:to` vs `:from` for the contact phone based on `outgoing_echo`. Keep that fix on future merges. + +### db/schema.rb + +Always conflicts because both sides have different migration versions. Resolution is mechanical but has traps: + +1. Resolve the version-number conflict first so Ruby can parse the file (`ActiveRecord::Schema[7.1].define(version: ...)`). Pick the later timestamp. +2. Resolve every other Ruby conflict file (`installation_config.rb`, any model conflicts) so Rails can boot. +3. `bundle exec rails db:migrate` to apply pending migrations. +4. `bundle exec rails db:schema:dump` to regenerate. + +**Traps to remember:** + +- **Local dev DB may have tables from other branches** (kanban, features in progress). After `db:schema:dump`, diff against `git show HEAD:db/schema.rb` and `git show MERGE_HEAD:db/schema.rb` to find extras. Manually delete stray `create_table` blocks + any foreign-key references + column references in shared tables (`conversations.kanban_task_id`, etc.). + +- **Custom SQL functions aren't dumpable.** `db:schema:dump` strips our `execute <<~SQL CREATE OR REPLACE FUNCTION f_unaccent(text)` block. Automated re-injection is wired via the `Rakefile` + `lib/tasks/internal_chat_search.rake` (`db:internal_chat:inject_schema_functions` runs as an `enhance` hook after `db:schema:dump`). If you see the block missing after a dump, the hook didn't run — check the Rakefile wiring and the task for a warning line like `Could not find insertion point ...`. The function itself is created by migration `20260410170003_add_unaccent_search_to_internal_chat.rb`. + +- **Schema version may be stamped with a migration from another branch.** `db:schema:dump` uses `MAX(schema_migrations.version)`. If the dev DB has a kanban/other-branch migration with a higher timestamp, that version ends up in `schema.rb`. Manually set the version to the highest timestamp among migrations *present in this merge's `db/migrate/`*. + +- **Quick integrity diff** (in Python — sed-free): parse HEAD's schema + MERGE_HEAD's schema + merged schema, compare column/index sets per table. Any table with columns outside HEAD∪MERGE_HEAD is a stray from another branch. + +### annotate_rb vs auto_annotate_models + +Upstream migrated `.annotaterb.yml` + `lib/tasks/annotate_rb.rake` and deleted the old custom `lib/tasks/auto_annotate_models.rake`. Our fork did a similar migration earlier with different config style. + +- `.annotaterb.yml`: **KC** (upstream's format is more complete, symbol-key style). +- `lib/tasks/auto_annotate_models.rake`: **DEL** (`git rm`). Replacement is `lib/tasks/annotate_rb.rake` from upstream. + +### InstallationConfig serialize + +Upstream simplified to `serialize :serialized_value, coder: YAML, type: ActiveSupport::HashWithIndifferentAccess, default: {}.with_indifferent_access`. Our fork had a custom `SerializedValueCoder` handling both YAML strings and native jsonb hashes. + +Test before choosing: create a legacy `InstallationConfig` where `serialized_value` is a YAML string inside the jsonb column, then confirm upstream's simpler version can still load it. If it works (it did in 4.13.0 merge with all 3 legacy formats: tagged YAML, symbol-key YAML, native hash), go **KC**. Otherwise keep the custom coder. + +### i18n files + +`config/locales/en.yml` / `pt_BR.yml` and `app/javascript/dashboard/i18n/locale/en/settings.json` / `pt_BR/settings.json` conflict because both sides add keys. Almost always **CO**: merge both key sets under the right parent. + +When upstream only adds `en.yml` keys and not `pt_BR.yml`, match upstream's scope — do not invent pt_BR translations as part of the merge. Those come in as community PRs or a separate translation pass. + +### New features from both sides + +Controllers (`inboxes_controller`, `conversations_controller`), policies, routes, store modules, automation_rule action whitelist, spec describe-blocks — when both sides added net-new methods/endpoints/actions, the resolution is always **CO**. Keep both additions ordered sensibly. + +## Validation flow + +After staging all resolved files and before commit: + +```bash +# parse sanity +ruby -c app/models/installation_config.rb +ruby -c db/schema.rb + +# rails boots +bundle exec rails runner 'puts "ok"' + +# migrations all apply +bundle exec rails db:migrate + +# specs for each changed area at minimum +bundle exec rspec spec/models spec/policies +bundle exec rspec spec/services/whatsapp +bundle exec rspec spec/controllers/api/v1/accounts/inboxes_controller_spec.rb \ + spec/controllers/api/v1/accounts/conversations_controller_spec.rb \ + spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb + +# targeted specs we touched +bundle exec rspec spec/services/action_service_spec.rb \ + spec/services/automation_rules/action_service_spec.rb + +# smoke: load real installation configs / other records touched by the merge +bundle exec rails runner 'InstallationConfig.find_each { |c| c.value }' +``` + +## Pre-commit pitfalls + +1. **Husky rubocop check only inspects files with staged diff.** Upstream files merged as-is don't appear in the diff, so their offenses slip past the hook and blow up in CI. Before commit: + ```bash + bundle exec rubocop --parallel + ``` + Run the full thing. Fix anything that comes up (most are `Rails/SaveBang` in upstream migrations/specs — safe to `rubocop -A` after receiver check). + +2. **Frontend lint error vs warning.** `pnpm-lint-staged` eslint runs with `--max-warnings=0` in some configs; a warning appears as an error in the hook. Check the actual error line in the hook output, not the warning count. + +3. **Missing imports after removing conflict hunks.** When resolving AI (accept incoming) conflicts in JS/Vue files, you can accidentally delete imports you still need. Example from 4.13.0: `replaceVariablesInMessage` in `ReplyBox.vue` — the `replaceText` method came in from main but its import was above the conflict. After keeping `replaceText`, add the import. + +4. **Duplicate `defineExpose` / `setup()` returns.** Same category: when combining both sides of a Vue component, watch for duplicate `defineExpose({ ... })` calls or duplicate keys in the `setup()` return object. Consolidate. + +## What this skill deliberately does NOT cover + +- CI flakiness from shard redistribution (pre-existing test pollution involving `perform_enqueued_jobs` in `before_all`, test-prof `let_it_be`, and rspec-mocks interaction). Track separately. +- Frontend build pipeline issues unrelated to the merge. +- Upstream feature rollouts that need product decisions (e.g., adopting a new captain model in our UI). diff --git a/Gemfile b/Gemfile index 44db9dc87..69d6f6ed2 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 c5f2ac99e..b2f1f4007 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -166,7 +166,7 @@ GEM multi_json (~> 1) statsd-ruby (~> 1.1) base64 (0.3.0) - bcrypt (3.1.20) + bcrypt (3.1.22) benchmark (0.4.1) bigdecimal (3.2.2) bindex (0.8.1) @@ -465,7 +465,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.18.1) + json (2.19.2) json_refs (0.1.8) hana json_schemer (0.2.24) @@ -944,6 +944,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) streamio-ffmpeg (3.0.2) @@ -1163,6 +1164,7 @@ DEPENDENCIES spring spring-watcher-listen squasher + ssrf_filter (~> 1.5) stackprof streamio-ffmpeg (~> 3.0) stripe (~> 18.0) diff --git a/Rakefile b/Rakefile index 99dd20aa6..cf80b84af 100644 --- a/Rakefile +++ b/Rakefile @@ -16,3 +16,14 @@ if Rake::Task.task_defined?('db:schema:load') && Rake::Task.task_defined?('db:internal_chat:ensure_search_functions') Rake::Task['db:schema:load'].enhance(['db:internal_chat:ensure_search_functions']) end + +# Re-inject the f_unaccent `execute <<~SQL ...` block into db/schema.rb after +# db:schema:dump rewrites the file. The schema dumper can't capture CREATE +# FUNCTION statements, so without this hook every dump would silently drop the +# block and break db:schema:load downstream. +if Rake::Task.task_defined?('db:schema:dump') && + Rake::Task.task_defined?('db:internal_chat:inject_schema_functions') + Rake::Task['db:schema:dump'].enhance do + Rake::Task['db:internal_chat:inject_schema_functions'].invoke + end +end 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..6f79d6018 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 @@ -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/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 e6a4bdbc0..224bd876a 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -126,6 +126,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 has_unread = assignee? ? @conversation.assignee_unread_messages.any? : @conversation.unread_messages.any? # No unread messages - apply throttling to limit DB writes diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index ceecce222..979108307 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -1,4 +1,4 @@ -class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength include Api::V1::InboxesHelper before_action :fetch_inbox, except: [:index, :create] before_action :fetch_agent_bot, only: [:set_agent_bot] @@ -68,6 +68,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 setup_channel_provider channel = @inbox.channel 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/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/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index af759af54..f0f6d5394 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/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 aeaea41ab..522324cb2 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -49,6 +49,10 @@ class Inboxes extends CacheEnabledApiClient { }); } + resetSecret(inboxId) { + return axios.post(`${this.url}/${inboxId}/reset_secret`); + } + linkCSATTemplate(inboxId, template) { return axios.post(`${this.url}/${inboxId}/csat_template/link`, { template, 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/ContactsListLayout.vue b/app/javascript/dashboard/components-next/Contacts/ContactsListLayout.vue index b38f44018..7f7b8392d 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsListLayout.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsListLayout.vue @@ -103,6 +103,7 @@ const showPagination = computed(() => { diff --git a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue index d51764ef5..c7453219f 100644 --- a/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue +++ b/app/javascript/dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue @@ -1,207 +1,63 @@ - diff --git a/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue b/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue index 5e96df3c6..779165caa 100644 --- a/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue +++ b/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue @@ -57,7 +57,7 @@ useKeyboardEvents(keyboardEvents);