From b1eea7f7d14839996c5a605df249c1ee1820900d Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 9 Dec 2021 12:07:48 +0530 Subject: [PATCH] chore: Introduce enterprise edition license (#3209) - Initialize an "enterprise" folder that is copyrighted. - You can remove this folder and the system will continue functioning normally, in case you want a purely MIT licensed product. - Enable limit on the number of user accounts in enterprise code. - Use enterprise edition injector methods (inspired from Gitlab). - SaaS software would run enterprise edition software always. Co-authored-by: Pranav Raj S --- LICENSE | 8 +- .../api/v1/accounts/agents_controller.rb | 5 ++ .../concerns/request_exception_handler.rb | 4 + app/models/account.rb | 9 ++ config/application.rb | 5 +- .../01_inject_enterprise_edition_module.rb | 87 +++++++++++++++++++ .../20211012135050_add_limits_to_accounts.rb | 5 ++ db/schema.rb | 1 + enterprise/LICENSE | 36 ++++++++ enterprise/app/models/enterprise/account.rb | 15 ++++ enterprise/lib/enterprise.rb | 2 + lib/chatwoot_app.rb | 33 +++++++ lib/custom_exceptions/account.rb | 6 ++ spec/enterprise/models/account_spec.rb | 32 +++++++ spec/models/account_spec.rb | 8 ++ 15 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 config/initializers/01_inject_enterprise_edition_module.rb create mode 100644 db/migrate/20211012135050_add_limits_to_accounts.rb create mode 100644 enterprise/LICENSE create mode 100644 enterprise/app/models/enterprise/account.rb create mode 100644 enterprise/lib/enterprise.rb create mode 100644 lib/chatwoot_app.rb create mode 100644 spec/enterprise/models/account_spec.rb diff --git a/LICENSE b/LICENSE index 037ad7885..f36fc7c53 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,11 @@ -The MIT License (MIT) - Copyright (c) 2017-2021 Chatwoot Inc. +Portions of this software are licensed as follows: + +* All content that resides under the "enterprise/" directory of this repository, if that directory exists, is licensed under the license defined in "enterprise/LICENSE". +* All third party components incorporated into the Chatwoot Software are licensed under the original license provided by the owner of the applicable component. +* Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below. + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index a406e0cf3..a666d1a67 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -2,6 +2,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController before_action :fetch_agent, except: [:create, :index] before_action :check_authorization before_action :find_user, only: [:create] + before_action :validate_limit, only: [:create] before_action :create_user, only: [:create] before_action :save_account_user, only: [:create] @@ -69,4 +70,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController def agents @agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] }) end + + def validate_limit + render_payment_required('Account limit exceeded. Upgrade to a higher plan') if agents.count >= Current.account.usage_limits[:agents] + end end diff --git a/app/controllers/concerns/request_exception_handler.rb b/app/controllers/concerns/request_exception_handler.rb index 1f96de737..51061017e 100644 --- a/app/controllers/concerns/request_exception_handler.rb +++ b/app/controllers/concerns/request_exception_handler.rb @@ -31,6 +31,10 @@ module RequestExceptionHandler render json: { error: message }, status: :unprocessable_entity end + def render_payment_required(message) + render json: { error: message }, status: :payment_required + end + def render_internal_server_error(message) render json: { error: message }, status: :internal_server_error end diff --git a/app/models/account.rb b/app/models/account.rb index e23723611..657517c8d 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -6,6 +6,7 @@ # auto_resolve_duration :integer # domain :string(100) # feature_flags :integer default(0), not null +# limits :jsonb # locale :integer default("en") # name :string not null # settings_flags :integer default(0), not null @@ -19,6 +20,7 @@ class Account < ApplicationRecord include FlagShihTzu include Reportable include Featurable + prepend_mod_with('Account') DEFAULT_QUERY_SETTING = { flag_query_mode: :bit_operator, @@ -107,6 +109,13 @@ class Account < ApplicationRecord super || GlobalConfig.get('MAILER_SUPPORT_EMAIL')['MAILER_SUPPORT_EMAIL'] || ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot ') end + def usage_limits + { + agents: ChatwootApp.max_limit, + inboxes: ChatwootApp.max_limit + } + end + private def notify_creation diff --git a/config/application.rb b/config/application.rb index 074a2f744..0c7490dd7 100644 --- a/config/application.rb +++ b/config/application.rb @@ -13,8 +13,11 @@ module Chatwoot # Initialize configuration defaults for originally generated Rails version. config.load_defaults 6.0 - config.autoload_paths << Rails.root.join('lib') config.eager_load_paths << Rails.root.join('lib') + config.eager_load_paths << Rails.root.join('enterprise/lib') + # rubocop:disable Rails/FilePath + config.eager_load_paths += Dir["#{Rails.root}/enterprise/app/**"] + # rubocop:enable Rails/FilePath # Settings in config/environments/* take precedence over those specified here. # Application configuration can go into files in config/initializers diff --git a/config/initializers/01_inject_enterprise_edition_module.rb b/config/initializers/01_inject_enterprise_edition_module.rb new file mode 100644 index 000000000..ecbcbe491 --- /dev/null +++ b/config/initializers/01_inject_enterprise_edition_module.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# original Authors: Gitlab +# https://gitlab.com/gitlab-org/gitlab/-/blob/master/config/initializers/0_inject_enterprise_edition_module.rb +# + +### Ref: https://medium.com/@leo_hetsch/ruby-modules-include-vs-prepend-vs-extend-f09837a5b073 +# Ancestors chain : it holds a list of constant names which are its ancestors +# example, by calling ancestors on the String class, +# String.ancestors => [String, Comparable, Object, PP::ObjectMixin, Kernel, BasicObject] +# +# Include: Ruby will insert the module into the ancestors chain of the class, just after its superclass +# ancestor chain : [OriginalClass, IncludedModule, ...] +# +# Extend: class will actually import the module methods as class methods +# +# Prepend: Ruby will look into the module methods before looking into the class. +# ancestor chain : [PrependedModule, OriginalClass, ...] +######## + +require 'active_support/inflector' + +module InjectEnterpriseEditionModule + def prepend_mod_with(constant_name, namespace: Object, with_descendants: false) + each_extension_for(constant_name, namespace) do |constant| + prepend_module(constant, with_descendants) + end + end + + def extend_mod_with(constant_name, namespace: Object) + # rubocop:disable Performance/MethodObjectAsBlock + each_extension_for( + constant_name, + namespace, + &method(:extend) + ) + # rubocop:enable Performance/MethodObjectAsBlock + end + + def include_mod_with(constant_name, namespace: Object) + # rubocop:disable Performance/MethodObjectAsBlock + each_extension_for( + constant_name, + namespace, + &method(:include) + ) + # rubocop:enable Performance/MethodObjectAsBlock + end + + def prepend_mod(with_descendants: false) + prepend_mod_with(name, with_descendants: with_descendants) + end + + def extend_mod + extend_mod_with(name) + end + + def include_mod + include_mod_with(name) + end + + private + + def prepend_module(mod, with_descendants) + prepend(mod) + + descendants.each { |descendant| descendant.prepend(mod) } if with_descendants + end + + def each_extension_for(constant_name, namespace) + ChatwootApp.extensions.each do |extension_name| + extension_namespace = + const_get_maybe_false(namespace, extension_name.camelize) + + extension_module = + const_get_maybe_false(extension_namespace, constant_name) + + yield(extension_module) if extension_module + end + end + + def const_get_maybe_false(mod, name) + mod&.const_defined?(name, false) && mod&.const_get(name, false) + end +end + +Module.prepend(InjectEnterpriseEditionModule) diff --git a/db/migrate/20211012135050_add_limits_to_accounts.rb b/db/migrate/20211012135050_add_limits_to_accounts.rb new file mode 100644 index 000000000..8f5b28c25 --- /dev/null +++ b/db/migrate/20211012135050_add_limits_to_accounts.rb @@ -0,0 +1,5 @@ +class AddLimitsToAccounts < ActiveRecord::Migration[6.1] + def change + add_column :accounts, :limits, :jsonb, default: {} + end +end diff --git a/db/schema.rb b/db/schema.rb index 10756d5c0..80e526555 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -52,6 +52,7 @@ ActiveRecord::Schema.define(version: 2021_12_08_085931) do t.integer "settings_flags", default: 0, null: false t.integer "feature_flags", default: 0, null: false t.integer "auto_resolve_duration" + t.jsonb "limits", default: {} end create_table "action_mailbox_inbound_emails", force: :cascade do |t| diff --git a/enterprise/LICENSE b/enterprise/LICENSE new file mode 100644 index 000000000..16d5eb94e --- /dev/null +++ b/enterprise/LICENSE @@ -0,0 +1,36 @@ +The Chatwoot Enterprise license (the “Enterprise License”) +Copyright (c) 2017-2021 Chatwoot Inc + +With regard to the Chatwoot Software: + +This software and associated documentation files (the "Software") may only be +used in production, if you (and any entity that you represent) have agreed to, +and are in compliance with, the Chatwoot Subscription Terms of Service, available +at https://www.chatwoot.com/terms-of-service/ (the “Enterprise Terms”), or other +agreement governing the use of the Software, as agreed by you and Chatwoot, +and otherwise have a valid Chatwoot Enterprise License for for the +correct number of user seats. Subject to the foregoing sentence, you are free to +modify this Software and publish patches to the Software. You agree that Chatwoot +and/or its licensors (as applicable) retain all right, title and interest in and +to all such modifications and/or patches, and all such modifications and/or +patches may only be used, copied, modified, displayed, distributed, or otherwise +exploited with a valid Chatwoot Enterprise subscription for the correct +number of user seats. Notwithstanding the foregoing, you may copy and modify +the Software for development and testing purposes, without requiring a +subscription. You agree that Chatwoot and/or its licensors (as applicable) retain +all right, title and interest in and to all such modifications. You are not +granted any other rights beyond what is expressly stated herein. Subject to the +foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, +and/or sell the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +For all third party components incorporated into the Chatwoot Software, those +components are licensed under the original license provided by the owner of the +applicable component. diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb new file mode 100644 index 000000000..84d72e0b3 --- /dev/null +++ b/enterprise/app/models/enterprise/account.rb @@ -0,0 +1,15 @@ +module Enterprise::Account + def usage_limits + { + agents: get_limits(:agents), + inboxes: get_limits(:inboxes) + } + end + + private + + def get_limits(limit_name) + config_name = "ACCOUNT_#{limit_name.to_s.upcase}_LIMIT" + self[:limits][limit_name.to_s] || GlobalConfig.get(config_name)[config_name] || ChatwootApp.max_limit + end +end diff --git a/enterprise/lib/enterprise.rb b/enterprise/lib/enterprise.rb new file mode 100644 index 000000000..50fc8f63c --- /dev/null +++ b/enterprise/lib/enterprise.rb @@ -0,0 +1,2 @@ +module Enterprise +end diff --git a/lib/chatwoot_app.rb b/lib/chatwoot_app.rb new file mode 100644 index 000000000..842ecc479 --- /dev/null +++ b/lib/chatwoot_app.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'pathname' + +module ChatwootApp + def self.root + Pathname.new(File.expand_path('..', __dir__)) + end + + def self.max_limit + 100_000 + end + + def self.enterprise? + return if ENV.fetch('DISABLE_ENTERPRISE', false) + + @enterprise ||= root.join('enterprise').exist? + end + + def self.custom? + @custom ||= root.join('custom').exist? + end + + def self.extensions + if custom? + %w[enterprise custom] + elsif enterprise? + %w[enterprise] + else + %w[] + end + end +end diff --git a/lib/custom_exceptions/account.rb b/lib/custom_exceptions/account.rb index 84d57cd20..bd77c5a78 100644 --- a/lib/custom_exceptions/account.rb +++ b/lib/custom_exceptions/account.rb @@ -28,4 +28,10 @@ module CustomExceptions::Account I18n.t 'errors.signup.failed' end end + + class PlanUpgradeRequired < CustomExceptions::Base + def message + I18n.t 'errors.plan_upgrade_required.failed' + end + end end diff --git a/spec/enterprise/models/account_spec.rb b/spec/enterprise/models/account_spec.rb new file mode 100644 index 000000000..6f0fa5aa7 --- /dev/null +++ b/spec/enterprise/models/account_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Account do + describe 'usage_limits' do + before do + create(:installation_config, name: 'ACCOUNT_AGENTS_LIMIT', value: 20) + end + + let!(:account) { create(:account) } + + it 'returns max limits from global config when enterprise version' do + expect(account.usage_limits).to eq( + { + agents: 20, + inboxes: ChatwootApp.max_limit + } + ) + end + + it 'returns max limits from account when enterprise version' do + account.update(limits: { agents: 10 }) + expect(account.usage_limits).to eq( + { + agents: 10, + inboxes: ChatwootApp.max_limit + } + ) + end + end +end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 20ebf4bfd..03d8c8937 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -21,4 +21,12 @@ RSpec.describe Account do it { is_expected.to have_many(:kbase_portals).dependent(:destroy_async) } it { is_expected.to have_many(:kbase_categories).dependent(:destroy_async) } it { is_expected.to have_many(:teams).dependent(:destroy_async) } + + describe 'usage_limits' do + let(:account) { create(:account) } + + it 'returns ChatwootApp.max limits' do + expect(account.usage_limits).to eq({ agents: ChatwootApp.max_limit, inboxes: ChatwootApp.max_limit }) + end + end end