From d17b45c5c4d7df3542a03f6ab112c6b4ac23ecee Mon Sep 17 00:00:00 2001
From: Riccardo Graziosi <31478034+riggraz@users.noreply.github.com>
Date: Sun, 24 Mar 2024 12:54:02 +0100
Subject: [PATCH] Add custom domains (#314)
---
app/controllers/application_controller.rb | 25 ++++------
app/controllers/o_auths_controller.rb | 4 +-
app/controllers/tenants_controller.rb | 19 ++++++++
app/helpers/application_helper.rb | 40 ++++++++++++++--
app/javascript/actions/Tenant/updateTenant.ts | 5 +-
.../General/GeneralSiteSettingsP.tsx | 34 ++++++++++++-
.../containers/GeneralSiteSettings.tsx | 2 +
app/javascript/interfaces/ITenant.ts | 1 +
app/javascript/interfaces/json/ITenant.ts | 1 +
app/models/o_auth.rb | 4 +-
app/models/tenant.rb | 1 +
app/policies/tenant_policy.rb | 2 +-
.../mailer/confirmation_instructions.html.erb | 2 +-
.../reset_password_instructions.html.erb | 2 +-
.../mailer/unlock_instructions.html.erb | 2 +-
app/views/layouts/mailer.html.erb | 2 +-
app/views/site_settings/general.html.erb | 1 +
.../_unsubscribe_from_post.html.erb | 2 +-
.../_unsubscribe_from_site.html.erb | 2 +-
.../user_mailer/notify_comment_owner.html.erb | 2 +-
...fy_follower_of_post_status_change.html.erb | 2 +-
.../notify_follower_of_post_update.html.erb | 2 +-
.../user_mailer/notify_post_owner.html.erb | 2 +-
app/workflows/add_custom_domain_workflow.rb | 48 +++++++++++++++++++
config/environments/development.rb | 2 +
config/locales/backend/backend.en.yml | 1 +
config/locales/en.yml | 2 +
...1171022_change_tenant_custom_url_column.rb | 6 +++
db/schema.rb | 5 +-
spec/factories/tenants.rb | 3 +-
spec/models/tenant_spec.rb | 34 +++++++++++++
31 files changed, 221 insertions(+), 39 deletions(-)
create mode 100644 app/workflows/add_custom_domain_workflow.rb
create mode 100644 db/migrate/20240321171022_change_tenant_custom_url_column.rb
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 560a01ab..54b50ca9 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,4 +1,7 @@
+require 'uri'
+
class ApplicationController < ActionController::Base
+ include ApplicationHelper
include Pundit::Authorization
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
@@ -16,24 +19,14 @@ class ApplicationController < ActionController::Base
end
def load_tenant_data
- if Rails.application.multi_tenancy?
- return if request.subdomain.blank? or RESERVED_SUBDOMAINS.include?(request.subdomain)
+ current_tenant = get_tenant_from_request(request)
- # Load the current tenant based on subdomain
- current_tenant = Tenant.find_by(subdomain: request.subdomain)
+ if current_tenant.status == "pending" and controller_name != "confirmation" and action_name != "show"
+ redirect_to pending_tenant_path; return
+ end
- if current_tenant.status == "pending" and controller_name != "confirmation" and action_name != "show"
- redirect_to pending_tenant_path; return
- end
-
- if current_tenant.status == "blocked"
- redirect_to blocked_tenant_path; return
- end
-
- redirect_to showcase_url unless current_tenant
- else
- # Load the one and only tenant
- current_tenant = Tenant.first
+ if current_tenant.status == "blocked"
+ redirect_to blocked_tenant_path; return
end
return unless current_tenant
diff --git a/app/controllers/o_auths_controller.rb b/app/controllers/o_auths_controller.rb
index 678bc53a..160189d1 100644
--- a/app/controllers/o_auths_controller.rb
+++ b/app/controllers/o_auths_controller.rb
@@ -61,10 +61,10 @@ class OAuthsController < ApplicationController
if user
oauth_token = user.generate_oauth_token
- redirect_to add_subdomain_to(method(:o_auth_sign_in_from_oauth_token_url), nil, {user_id: user.id, token: oauth_token})
+ redirect_to get_url_for(method(:o_auth_sign_in_from_oauth_token_url), options: { user_id: user.id, token: oauth_token })
else
flash[:alert] = I18n.t('errors.o_auth_login_error', name: @o_auth.name)
- redirect_to add_subdomain_to(method(:new_user_session_url))
+ redirect_to get_url_for(method(:new_user_session_url))
end
elsif reason == 'test'
diff --git a/app/controllers/tenants_controller.rb b/app/controllers/tenants_controller.rb
index ec47586c..f6c10573 100644
--- a/app/controllers/tenants_controller.rb
+++ b/app/controllers/tenants_controller.rb
@@ -1,3 +1,5 @@
+require 'httparty'
+
class TenantsController < ApplicationController
include ApplicationHelper
@@ -66,6 +68,23 @@ class TenantsController < ApplicationController
@tenant = Current.tenant_or_raise!
authorize @tenant
+ # If the custom domain has changed, we need to provision SSL certificate
+ custom_domain_response = AddCustomDomainWorkflow.new(
+ new_custom_domain: params[:tenant][:custom_domain],
+ current_custom_domain: @tenant.custom_domain
+ ).run
+
+ if custom_domain_response == false
+ render json: {
+ error: "Error adding custom domain"
+ }, status: :unprocessable_entity
+ return
+ end
+
+ # Since custom_domain is unique at db level, we need to set it to nil if it is blank
+ # to avoid unique constraint violation
+ params[:tenant][:custom_domain] = nil if params[:tenant][:custom_domain].blank?
+
if @tenant.update(tenant_update_params)
render json: @tenant
else
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index f1d9c38e..f33b8885 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -28,10 +28,44 @@ module ApplicationHelper
end
end
- def add_subdomain_to(url_helper, resource=nil, options={})
- options[:subdomain] = Current.tenant_or_raise!.subdomain if Rails.application.multi_tenancy?
- options[:host] = Rails.application.base_url
+ def get_url_for(url_helper, resource: nil, disallow_custom_domain: false, options: {})
+ custom_domain = Current.tenant.custom_domain
+
+ if Rails.application.multi_tenancy? && (custom_domain.blank? || disallow_custom_domain)
+ options[:subdomain] = Current.tenant.subdomain
+ end
+
+ if custom_domain.blank? || disallow_custom_domain
+ options[:host] = Rails.application.base_url
+ else
+ options[:host] = custom_domain
+ end
+
+ if Rails.application.base_url.include?('https')
+ options[:protocol] = 'https'
+ else
+ options[:protocol] = 'http'
+ end
resource ? url_helper.call(resource, options) : url_helper.call(options)
end
+
+ def get_tenant_from_request(request)
+ if Rails.application.multi_tenancy?
+ request_host_splitted = request.host.split('.')
+ app_host_splitted = URI.parse(Rails.application.base_url).host.split('.')
+
+ if app_host_splitted.join('.') == request_host_splitted.last(app_host_splitted.length).join('.')
+ return if request.subdomain.blank? or RESERVED_SUBDOMAINS.include?(request.subdomain)
+
+ tenant = Tenant.find_by(subdomain: request.subdomain)
+ else
+ tenant = Tenant.find_by(custom_domain: request.host)
+ end
+ else
+ tenant = Tenant.first
+ end
+
+ tenant
+ end
end
diff --git a/app/javascript/actions/Tenant/updateTenant.ts b/app/javascript/actions/Tenant/updateTenant.ts
index 79202a1c..164694b1 100644
--- a/app/javascript/actions/Tenant/updateTenant.ts
+++ b/app/javascript/actions/Tenant/updateTenant.ts
@@ -50,6 +50,7 @@ interface UpdateTenantParams {
siteLogo?: string;
tenantSetting?: ITenantSetting;
locale?: string;
+ customDomain?: string;
authenticityToken: string;
}
@@ -58,6 +59,7 @@ export const updateTenant = ({
siteLogo = null,
tenantSetting = null,
locale = null,
+ customDomain = null,
authenticityToken,
}: UpdateTenantParams): ThunkAction
<%= link_to t('mailers.devise.confirmation_instructions.action'),
- add_subdomain_to(method(:confirmation_url), @resource, { confirmation_token: @token })
+ get_url_for(method(:confirmation_url), resource: @resource, options: { confirmation_token: @token })
%>
<%= link_to t('mailers.devise.reset_password.action'),
- add_subdomain_to(method(:edit_password_url), @resource, { reset_password_token: @token })
+ get_url_for(method(:edit_password_url), resource: @resource, options: { reset_password_token: @token })
%>
<%= link_to 'Unlock my account',
- add_subdomain_to(method(:unlock_url), @resource, { unlock_token: @token })
+ get_url_for(method(:unlock_url), resource: @resource, options: { unlock_token: @token })
%>
{ I18n.t('site_settings.general.subtitle_header') }
diff --git a/app/javascript/containers/GeneralSiteSettings.tsx b/app/javascript/containers/GeneralSiteSettings.tsx
index 681eef6d..408aa84f 100644
--- a/app/javascript/containers/GeneralSiteSettings.tsx
+++ b/app/javascript/containers/GeneralSiteSettings.tsx
@@ -17,6 +17,7 @@ const mapDispatchToProps = (dispatch: any) => ({
brandDisplaySetting: TenantSettingBrandDisplay,
locale: string,
rootBoardId: number,
+ customDomain: string,
showRoadmapInHeader: boolean,
collapseBoardsInHeader: TenantSettingCollapseBoardsInHeader,
showVoteCount: boolean,
@@ -37,6 +38,7 @@ const mapDispatchToProps = (dispatch: any) => ({
collapse_boards_in_header: collapseBoardsInHeader,
},
locale,
+ customDomain,
authenticityToken,
}));
},
diff --git a/app/javascript/interfaces/ITenant.ts b/app/javascript/interfaces/ITenant.ts
index 9ddf6b11..441d97fe 100644
--- a/app/javascript/interfaces/ITenant.ts
+++ b/app/javascript/interfaces/ITenant.ts
@@ -3,6 +3,7 @@ interface ITenant {
siteName: string;
siteLogo: string;
locale: string;
+ customDomain?: string;
}
export default ITenant;
\ No newline at end of file
diff --git a/app/javascript/interfaces/json/ITenant.ts b/app/javascript/interfaces/json/ITenant.ts
index d39dd2ba..8501feaf 100644
--- a/app/javascript/interfaces/json/ITenant.ts
+++ b/app/javascript/interfaces/json/ITenant.ts
@@ -4,6 +4,7 @@ interface ITenantJSON {
site_logo: string;
brand_display_setting: string;
locale: string;
+ custom_domain?: string;
}
export default ITenantJSON;
\ No newline at end of file
diff --git a/app/models/o_auth.rb b/app/models/o_auth.rb
index dc05a9e1..cfb969e1 100644
--- a/app/models/o_auth.rb
+++ b/app/models/o_auth.rb
@@ -27,9 +27,9 @@ class OAuth < ApplicationRecord
# for this reason, we don't preprend tenant subdomain
# but rather use the "login" subdomain
if self.is_default?
- o_auth_callback_url(id, host: Rails.application.base_url, subdomain: "login")
+ get_url_for(method(:o_auth_callback_url), resource: id, disallow_custom_domain: true, options: { subdomain: "login", host: Rails.application.base_url })
else
- add_subdomain_to(method(:o_auth_callback_url), id)
+ get_url_for(method(:o_auth_callback_url), resource: id, disallow_custom_domain: true)
end
end
diff --git a/app/models/tenant.rb b/app/models/tenant.rb
index 41eeecd3..b1ee7d4b 100644
--- a/app/models/tenant.rb
+++ b/app/models/tenant.rb
@@ -19,6 +19,7 @@ class Tenant < ApplicationRecord
validates :site_name, presence: true
validates :subdomain, presence: true, uniqueness: true
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
+ validates :custom_domain, format: { with: /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}\z/ }, uniqueness: true, allow_blank: true, allow_nil: true
accepts_nested_attributes_for :tenant_setting, update_only: true
diff --git a/app/policies/tenant_policy.rb b/app/policies/tenant_policy.rb
index 9a3b81a0..98faef4f 100644
--- a/app/policies/tenant_policy.rb
+++ b/app/policies/tenant_policy.rb
@@ -5,7 +5,7 @@ class TenantPolicy < ApplicationPolicy
def permitted_attributes_for_update
if user.admin?
- [:site_name, :site_logo, :locale]
+ [:site_name, :site_logo, :locale, :custom_domain]
else
[]
end
diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb
index bc93448a..0ff0fc36 100644
--- a/app/views/devise/mailer/confirmation_instructions.html.erb
+++ b/app/views/devise/mailer/confirmation_instructions.html.erb
@@ -4,6 +4,6 @@
- <%= link_to t('mailers.user.learn_more'), add_subdomain_to(method(:post_url), @comment.post) %> + <%= link_to t('mailers.user.learn_more'), get_url_for(method(:post_url), resource: @comment.post) %>
<%= render 'user_mailer/closing' %> diff --git a/app/views/user_mailer/notify_follower_of_post_status_change.html.erb b/app/views/user_mailer/notify_follower_of_post_status_change.html.erb index 1b109144..cba7ea3e 100644 --- a/app/views/user_mailer/notify_follower_of_post_status_change.html.erb +++ b/app/views/user_mailer/notify_follower_of_post_status_change.html.erb @@ -9,7 +9,7 @@- <%= link_to t('mailers.user.learn_more'), add_subdomain_to(method(:post_url), @post) %> + <%= link_to t('mailers.user.learn_more'), get_url_for(method(:post_url), resource: @post) %>
<%= render 'user_mailer/closing' %> diff --git a/app/views/user_mailer/notify_follower_of_post_update.html.erb b/app/views/user_mailer/notify_follower_of_post_update.html.erb index 30027911..9af3d499 100644 --- a/app/views/user_mailer/notify_follower_of_post_update.html.erb +++ b/app/views/user_mailer/notify_follower_of_post_update.html.erb @@ -7,7 +7,7 @@ <%= render 'user_mailer/quoted_text', text: @comment.body %>- <%= link_to t('mailers.user.learn_more'), add_subdomain_to(method(:post_url), @comment.post) %> + <%= link_to t('mailers.user.learn_more'), get_url_for(method(:post_url), resource: @comment.post) %>
<%= render 'user_mailer/closing' %> diff --git a/app/views/user_mailer/notify_post_owner.html.erb b/app/views/user_mailer/notify_post_owner.html.erb index a2481d53..cdfc72fa 100644 --- a/app/views/user_mailer/notify_post_owner.html.erb +++ b/app/views/user_mailer/notify_post_owner.html.erb @@ -7,7 +7,7 @@ <%= render 'user_mailer/quoted_text', text: @comment.body %>- <%= link_to t('mailers.user.learn_more'), add_subdomain_to(method(:post_url), @comment.post) %> + <%= link_to t('mailers.user.learn_more'), get_url_for(method(:post_url), resource: @comment.post) %>
<%= render 'user_mailer/closing' %> diff --git a/app/workflows/add_custom_domain_workflow.rb b/app/workflows/add_custom_domain_workflow.rb new file mode 100644 index 00000000..a4cf2922 --- /dev/null +++ b/app/workflows/add_custom_domain_workflow.rb @@ -0,0 +1,48 @@ +class AddCustomDomainWorkflow + include HTTParty + + attr_accessor :new_custom_domain, :current_custom_domain + + def initialize(new_custom_domain: "", current_custom_domain: "") + @new_custom_domain = new_custom_domain + @current_custom_domain = current_custom_domain + end + + def make_request(method, domain) + return unless method == "POST" || method == "DELETE" + + HTTParty.send( + method.downcase, + ENV["ASTUTO_CNAME_API_URL"], + headers: { + "api_key" => ENV["ASTUTO_CNAME_API_KEY"], + "Accept" => "application/json", + }, + query: { "domain" => domain.downcase } + ) + end + + def run + return true unless Rails.application.multi_tenancy? + return true if @new_custom_domain == @current_custom_domain + return true if Tenant.exists?(custom_domain: @new_custom_domain) + + begin + # Add new custom domain... + if @new_custom_domain.present? + response = make_request("POST", @new_custom_domain) + + return false unless response.success? + end + + # ... and remove the current one + if @current_custom_domain.present? + make_request("DELETE", @current_custom_domain) + end + + return true + rescue => e + return false + end + end +end \ No newline at end of file diff --git a/config/environments/development.rb b/config/environments/development.rb index b9a94f4a..7fefa969 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -3,6 +3,8 @@ Rails.application.configure do config.hosts << ".localhost:3000" config.hosts << ".lvh.me:3000" # used to test oauth strategies in development + config.hosts << ".ngrok-free.app" + config.hosts << ".riggraz.dev" # 0 if using localhost, 1 if using lvh.me config.action_dispatch.tld_length = 0 diff --git a/config/locales/backend/backend.en.yml b/config/locales/backend/backend.en.yml index aaf68fed..347d9d7b 100644 --- a/config/locales/backend/backend.en.yml +++ b/config/locales/backend/backend.en.yml @@ -116,6 +116,7 @@ en: site_logo: 'Site logo' subdomain: 'Subdomain' locale: 'Language' + custom_domain: 'Custom domain' tenant_setting: brand_display: 'Display' show_vote_count: 'Show vote count to users' diff --git a/config/locales/en.yml b/config/locales/en.yml index c492f477..0b4ab362 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -158,6 +158,8 @@ en: collapse_boards_in_header_no_collapse: 'Never' collapse_boards_in_header_always_collapse: 'Always' subtitle_visibility: 'Visibility' + custom_domain_help: 'In your DNS settings, add a CNAME record pointing "%{domain}" to "cname.astuto.io"' + custom_domain_learn_more: 'Learn how to configure a custom domain' show_vote_count_help: 'If you enable this setting, users will be able to see the vote count of posts. This may incentivize users to vote on already popular posts, leading to a snowball effect.' show_vote_button_in_board_help: 'If you enable this setting, users will be able to vote posts from the board page. This may incentivize users to vote on more posts, leading to a higher number of votes but of lower significance.' boards: diff --git a/db/migrate/20240321171022_change_tenant_custom_url_column.rb b/db/migrate/20240321171022_change_tenant_custom_url_column.rb new file mode 100644 index 00000000..f567e285 --- /dev/null +++ b/db/migrate/20240321171022_change_tenant_custom_url_column.rb @@ -0,0 +1,6 @@ +class ChangeTenantCustomUrlColumn < ActiveRecord::Migration[6.1] + def change + rename_column :tenants, :custom_url, :custom_domain + add_index :tenants, :custom_domain, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index d9915808..56a2c401 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2024_03_03_103945) do +ActiveRecord::Schema.define(version: 2024_03_21_171022) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -153,10 +153,11 @@ ActiveRecord::Schema.define(version: 2024_03_03_103945) do t.string "site_logo" t.string "subdomain", null: false t.string "locale", default: "en" - t.string "custom_url" + t.string "custom_domain" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.integer "status" + t.index ["custom_domain"], name: "index_tenants_on_custom_domain", unique: true end create_table "users", force: :cascade do |t| diff --git a/spec/factories/tenants.rb b/spec/factories/tenants.rb index 91c39917..9440a4f8 100644 --- a/spec/factories/tenants.rb +++ b/spec/factories/tenants.rb @@ -4,6 +4,7 @@ FactoryBot.define do site_logo { "" } sequence(:subdomain) { |n| "mysubdomain#{n}" } locale { "en" } - custom_url { "" } + custom_domain { nil } + status { "active" } end end diff --git a/spec/models/tenant_spec.rb b/spec/models/tenant_spec.rb index bf79994c..b460c2b1 100644 --- a/spec/models/tenant_spec.rb +++ b/spec/models/tenant_spec.rb @@ -35,4 +35,38 @@ RSpec.describe Tenant, type: :model do tenant.subdomain = tenant2.subdomain expect(tenant).to be_invalid end + + it 'may have a valid custom domain' do + expect(tenant.custom_domain).to be_nil + + tenant.custom_domain = '' + expect(tenant).to be_valid + + tenant.custom_domain = 'example.com' + expect(tenant).to be_valid + + tenant.custom_domain = 'subdomain.example.com' + expect(tenant).to be_valid + + tenant.custom_domain = 'sub.subdomain.example.com' + expect(tenant).to be_valid + + tenant.custom_domain = 'com' + expect(tenant).to be_invalid + + tenant.custom_domain = 'https://example.com' + expect(tenant).to be_invalid + + tenant.custom_domain = 'example.com/sub' + expect(tenant).to be_invalid + + tenant.custom_domain = 'example.com.' + expect(tenant).to be_invalid + + tenant.custom_domain = 'example..com' + expect(tenant).to be_invalid + + tenant.custom_domain = '.example.com' + expect(tenant).to be_invalid + end end