You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
docuseal/lib/whitelabel.rb

617 lines
19 KiB

# frozen_string_literal: true
# =============================================================================
# Whitelabel — Centralised brand config + licence enforcement
# =============================================================================
#
# Config loading priority:
# 1. Local YAML file → if present, used as-is (dev / custom deploys)
# 2. Remote API fetch → if no file, calls Intebec Dashboard (production)
# 3. Empty defaults → test environment only
#
# All accessors use dig() with safe fallbacks so the app never crashes on
# missing keys. Without a valid config source the fallbacks return plain
# upstream DocuSeal values — your branding only appears with YOUR config.
#
# Env vars:
# INTEBEC_CONFIG_PATH — override local file path (default: /run/secrets/config.yml)
# INTEBEC_LICENCE_KEY — licence UUID (required for API mode)
# INTEBEC_SECRET_KEY — HMAC shared secret (required for API mode)
# INTEBEC_DASHBOARD_URL — override Dashboard URL (default: https://dashboard.intebec.ca)
# =============================================================================
require 'yaml'
require 'uri'
require 'json'
require 'openssl'
require 'net/http'
require 'securerandom'
module Whitelabel
class ConfigError < StandardError; end
class LicenceRevokedError < ConfigError; end
CONFIG_PATH = Pathname.new(
ENV.fetch('INTEBEC_CONFIG_PATH', '/run/secrets/config.yml')
).freeze
DASHBOARD_URL = ENV.fetch('INTEBEC_DASHBOARD_URL', 'https://dashboard.intebec.ca').freeze
CONFIG_ENDPOINT = '/api/licences/config'
API_TIMEOUT = 10
API_MAX_RETRIES = 3
API_RETRY_DELAY = 2 # seconds, doubles each retry
REFRESH_INTERVAL = 24 * 3600 # 24 h
REFRESH_ON_ERROR = 5 * 60 # 5 min retry on transient failure
THEME_DEFAULTS = {
'primary' => '216 77% 52%',
'primary_focus' => '216 77% 44%',
'primary_content' => '0 0% 100%',
'secondary' => '220 12% 45%',
'secondary_focus' => '220 14% 36%',
'secondary_content' => '0 0% 100%',
'accent' => '160 50% 40%',
'accent_focus' => '160 50% 34%',
'accent_content' => '0 0% 100%',
'neutral' => '220 16% 12%',
'neutral_focus' => '220 16% 8%',
'neutral_content' => '0 0% 100%',
'base_100' => '0 0% 100%',
'base_200' => '220 14% 96%',
'base_300' => '220 12% 93%',
'base_content' => '220 14% 10%',
'info' => '205 80% 50%',
'success' => '154 55% 38%',
'warning' => '38 88% 48%',
'error' => '0 72% 50%',
'rounded_btn' => '1.9rem',
'tab_border' => '2px',
'tab_radius' => '.5rem'
}.freeze
DEFAULT_STYLING_VARIABLES = {
'ib-bg' => '220 14% 98%',
'ib-surface' => '0 0% 100%',
'ib-surface-2' => '220 14% 96%',
'ib-border' => '220 10% 88%',
'ib-text' => '220 14% 10%',
'ib-text-secondary' => '220 8% 40%',
'ib-muted' => '220 6% 55%'
}.freeze
# ── Mutable state (thread-safe) ─────────────────────────────────────────
@mutex = Mutex.new
@config = nil
@api_sourced = false
@next_refresh = Time.at(0).utc
class << self
# =====================================================================
# Core
# =====================================================================
def config
@config || load_config!
end
def reload!
@mutex.synchronize { @config = nil }
load_config!
end
def config_source
return :api if @api_sourced
return :test if @config && !CONFIG_PATH.file?
:file
end
# Called per-request from ApplicationController.
# For API-sourced configs, periodically re-fetches to confirm the
# licence is still active and pick up any Dashboard changes.
def ensure_valid!
return true unless @api_sourced
return true unless Time.now.utc >= @next_refresh
@mutex.synchronize do
return true unless Time.now.utc >= @next_refresh
@config = fetch_remote_config
@next_refresh = Time.now.utc + REFRESH_INTERVAL
rescue LicenceRevokedError
# Licence actively revoked → propagate, controller returns 503
@config = {}
raise
rescue ConfigError => e
# Transient error (network, timeout) → keep existing config, retry sooner
Rails.logger.error("[Whitelabel] Revalidation failed: #{e.message}")
@next_refresh = Time.now.utc + REFRESH_ON_ERROR
end
true
end
# =====================================================================
# Brand
# =====================================================================
def brand_name
config.dig('brand', 'name') || 'DocuSeal'
end
def brand_short_name
config.dig('brand', 'short_name') || brand_name
end
def tagline
config.dig('brand', 'tagline') || ''
end
def description
config.dig('brand', 'description') || ''
end
def page_title(signed_in: false)
key = signed_in ? 'page_title_signed_in' : 'page_title_signed_out'
config.dig('brand', key) || brand_name
end
# =====================================================================
# URLs
# =====================================================================
def website_url
config.dig('urls', 'website') || 'https://www.docuseal.com'
end
def support_email
config.dig('urls', 'support_email') || 'support@docuseal.com'
end
def privacy_policy_url
config.dig('urls', 'privacy_policy')
end
def terms_url
config.dig('urls', 'terms_of_service')
end
def twitter_url
config.dig('urls', 'twitter_url')
end
def twitter_handle
config.dig('urls', 'twitter_handle')
end
def github_url
config.dig('urls', 'github_url')
end
def discord_url
config.dig('urls', 'discord_url')
end
# =====================================================================
# Email
# =====================================================================
def email_from
name = config.dig('email', 'from_name') || brand_name
addr = config.dig('email', 'from_address') || support_email
"#{name} <#{addr}>"
end
def email_attribution_html
raw = config.dig('email', 'attribution_html') ||
'Sent with <a href="%{website}">%{brand}</a>.'
raw.gsub('%{brand}', brand_name).gsub('%{website}', website_url)
end
# =====================================================================
# Assets
# =====================================================================
def logo_path
config.dig('assets', 'logo_path') || '/logo.svg'
end
def logo_width
config.dig('assets', 'logo_width') || 37
end
def logo_height
config.dig('assets', 'logo_height') || 37
end
def favicon_svg
config.dig('assets', 'favicon_svg') || '/favicon.svg'
end
def favicon_ico
config.dig('assets', 'favicon_ico') || '/favicon.ico'
end
def favicon_16
config.dig('assets', 'favicon_16') || '/favicon-16x16.png'
end
def favicon_32
config.dig('assets', 'favicon_32') || '/favicon-32x32.png'
end
def favicon_96
config.dig('assets', 'favicon_96') || '/favicon-96x96.png'
end
def apple_touch_icon
config.dig('assets', 'apple_touch_icon') || '/apple-icon-180x180.png'
end
def preview_image
config.dig('assets', 'preview_image') || '/preview.png'
end
# =====================================================================
# Theme — HSL triplets for DaisyUI / CSS custom properties
# =====================================================================
def theme(key)
config.dig('theme', key.to_s) || THEME_DEFAULTS[key.to_s] || '0 0% 50%'
end
# =====================================================================
# PDF / Audit trail
# =====================================================================
def sign_reason(name)
template = config.dig('pdf', 'sign_reason') || 'Signed by %{name}'
template.gsub('%{name}', name.to_s)
end
def audit_trail_footer
config.dig('pdf', 'audit_trail_footer') || "Signed with #{brand_name}"
end
def pdf_creator
creator = config.dig('pdf', 'creator') || brand_name
"#{creator} (#{website_url})"
end
def cert_name
config.dig('pdf', 'cert_name') || 'docuseal_aatl'
end
# =====================================================================
# PWA
# =====================================================================
def pwa_description
config.dig('pwa', 'description') || description
end
def pwa_theme_color
config.dig('pwa', 'theme_color') || '#FFFFFF'
end
def pwa_background_color
config.dig('pwa', 'background_color') || '#FFFFFF'
end
# =====================================================================
# Webhooks
# =====================================================================
def webhook_user_agent
config.dig('webhooks', 'user_agent') || "#{brand_name} Webhook"
end
# =====================================================================
# Feature flags
# =====================================================================
def show_github_button?
dig_bool('features', 'show_github_button', false)
end
def show_powered_by?
dig_bool('features', 'show_powered_by', false)
end
def powered_by_text
config.dig('features', 'powered_by_text') || brand_name
end
def show_ai_link?
dig_bool('features', 'show_ai_link', false)
end
def show_discord_link?
dig_bool('features', 'show_discord_link', false)
end
def show_pro_upsells?
dig_bool('features', 'show_pro_upsells', false)
end
# =====================================================================
# Internal
# =====================================================================
def temp_email_domain
config.dig('internal', 'temp_email_domain') || 'docuseal.com'
end
# =====================================================================
# Locale / Translations
# =====================================================================
def default_locale
config.dig('locale', 'default') || 'en'
end
def available_locales
config.dig('locale', 'available') || %w[en]
end
def fallback_locale
config.dig('locale', 'fallback') || 'en'
end
def translation_overrides
config.dig('text', 'translations') || {}
end
# =====================================================================
# Styling
# =====================================================================
def styling_variables
DEFAULT_STYLING_VARIABLES.merge(config.dig('styling', 'css_variables') || {})
end
def inline_css_variables
vars = {
'wl-ib-primary' => theme(:primary),
'wl-ib-primary-strong' => theme(:primary_focus),
'wl-ib-primary-soft' => "#{theme(:primary)} / 0.12",
'wl-ib-neutral' => theme(:neutral),
'wl-ib-neutral-soft' => theme(:base_200),
'wl-p' => theme(:primary),
'wl-pf' => theme(:primary_focus),
'wl-pc' => theme(:primary_content),
'wl-s' => theme(:secondary),
'wl-sf' => theme(:secondary_focus),
'wl-sc' => theme(:secondary_content),
'wl-a' => theme(:accent),
'wl-af' => theme(:accent_focus),
'wl-ac' => theme(:accent_content),
'wl-n' => theme(:neutral),
'wl-nf' => theme(:neutral_focus),
'wl-nc' => theme(:neutral_content),
'wl-b1' => theme(:base_100),
'wl-b2' => theme(:base_200),
'wl-b3' => theme(:base_300),
'wl-bc' => theme(:base_content),
'wl-in' => theme(:info),
'wl-su' => theme(:success),
'wl-wa' => theme(:warning),
'wl-er' => theme(:error),
'wl-rounded-btn' => theme(:rounded_btn)
}
styling_variables.each { |key, value| vars["wl-#{key}"] = value }
declarations = vars.map { |k, v| "--#{k}: #{v};" }.join(' ')
":root { #{declarations} }"
end
# =====================================================================
# Config signature (file-based only)
# =====================================================================
def enforce_config_signature?
dig_bool('security', 'enforce_config_signature', false)
end
def config_signature
config.dig('security', 'config_signature') || ''
end
def signature_payload
canonical_payload(config)
end
def generate_config_signature(secret)
raise ConfigError, 'Secret required' if secret.to_s.empty?
OpenSSL::HMAC.hexdigest('SHA256', secret, signature_payload).downcase
end
private
# =====================================================================
# Config loading
# =====================================================================
def load_config!
@mutex.synchronize do
return @config if @config # another thread beat us
if CONFIG_PATH.file?
load_from_file!
elsif Rails.env.test?
load_test_defaults!
else
load_from_api!
end
end
@config
end
def load_from_file!
raw = YAML.safe_load_file(
CONFIG_PATH,
permitted_classes: [], permitted_symbols: [], aliases: false
)
raise ConfigError, '[Whitelabel] Config must be a YAML mapping' unless raw.is_a?(Hash)
verify_file_signature!(raw)
@config = raw
@api_sourced = false
Rails.logger.info("[Whitelabel] Loaded config from file: #{CONFIG_PATH}")
rescue Psych::SyntaxError => e
raise ConfigError, "[Whitelabel] YAML parse error in #{CONFIG_PATH}: #{e.message}"
rescue Errno::EISDIR
raise ConfigError, "[Whitelabel] #{CONFIG_PATH} is a directory, not a file."
end
def load_from_api!
licence_key = ENV['INTEBEC_LICENCE_KEY'].to_s
secret_key = ENV['INTEBEC_SECRET_KEY'].to_s
if licence_key.empty? || secret_key.empty?
raise ConfigError,
'[Whitelabel] No config file found and INTEBEC_LICENCE_KEY / INTEBEC_SECRET_KEY ' \
'env vars are missing. Cannot start without a config source.'
end
@config = fetch_remote_config
@api_sourced = true
@next_refresh = Time.now.utc + REFRESH_INTERVAL
Rails.logger.info('[Whitelabel] Loaded config from Intebec Dashboard API')
end
def load_test_defaults!
@config = {}
@api_sourced = false
Rails.logger.info('[Whitelabel] Test mode — all accessors return safe fallbacks')
end
# =====================================================================
# Remote config fetch (with retry)
# =====================================================================
def fetch_remote_config
licence_key = ENV.fetch('INTEBEC_LICENCE_KEY')
secret_key = ENV.fetch('INTEBEC_SECRET_KEY')
last_error = nil
API_MAX_RETRIES.times do |attempt|
uri = URI.join(DASHBOARD_URL, CONFIG_ENDPOINT)
timestamp = Time.now.utc.to_i.to_s
nonce = SecureRandom.hex(12)
instance_id = stable_instance_id
payload = [licence_key, timestamp, nonce, instance_id].join('.')
signature = OpenSSL::HMAC.hexdigest('SHA256', secret_key, payload)
uri.query = URI.encode_www_form(licence_key: licence_key, instance_id: instance_id)
req = Net::HTTP::Get.new(uri)
req['Accept'] = 'application/json'
req['X-Licence-Key'] = licence_key
req['X-Licence-Timestamp'] = timestamp
req['X-Licence-Nonce'] = nonce
req['X-Licence-Signature'] = signature
req['X-Licence-Instance'] = instance_id
req['User-Agent'] = 'Intebec-DocuSeal'
resp = Net::HTTP.start(
uri.host, uri.port,
use_ssl: uri.scheme == 'https',
open_timeout: API_TIMEOUT,
read_timeout: API_TIMEOUT
) { |http| http.request(req) }
unless [200, 201].include?(resp.code.to_i)
raise ConfigError, "HTTP #{resp.code}"
end
parsed = JSON.parse(resp.body)
status = parsed['status'].to_s
unless %w[active trial].include?(status)
raise LicenceRevokedError, "Licence status: #{status}"
end
remote_cfg = parsed['config']
raise ConfigError, 'API returned no config payload' unless remote_cfg.is_a?(Hash)
return remote_cfg
rescue LicenceRevokedError
raise # don't retry revocations
rescue StandardError => e
last_error = e.message
delay = API_RETRY_DELAY * (2**attempt)
if attempt < API_MAX_RETRIES - 1
Rails.logger.warn(
"[Whitelabel] API attempt #{attempt + 1}/#{API_MAX_RETRIES} " \
"failed: #{e.message}, retry in #{delay}s"
)
sleep(delay)
end
end
raise ConfigError,
"[Whitelabel] Dashboard unreachable after #{API_MAX_RETRIES} attempts: #{last_error}"
end
def stable_instance_id
@stable_instance_id ||= begin
raw = [ENV.fetch('INTEBEC_LICENCE_KEY', ''), ENV.fetch('HOST', 'localhost')].join(':')
OpenSSL::Digest::SHA256.hexdigest(raw)
end
end
# =====================================================================
# File signature verification (optional, for file-based configs)
# =====================================================================
def verify_file_signature!(raw)
return unless raw.dig('security', 'enforce_config_signature') == true
secret = ENV['INTEBEC_SECRET_KEY'].to_s
raise ConfigError, '[Whitelabel] INTEBEC_SECRET_KEY required for config signature verification' if secret.empty?
expected = raw.dig('security', 'config_signature').to_s.downcase
actual = OpenSSL::HMAC.hexdigest('SHA256', secret, canonical_payload(raw)).downcase
unless expected.length == 64 && secure_compare(actual, expected)
raise ConfigError, '[Whitelabel] Config signature mismatch — refusing to boot.'
end
end
def canonical_payload(loaded)
copy = Marshal.load(Marshal.dump(loaded))
copy['security']&.delete('config_signature')
JSON.generate(deep_sort_hash(copy))
end
def deep_sort_hash(value)
case value
when Hash
value.keys.sort.each_with_object({}) { |k, h| h[k] = deep_sort_hash(value[k]) }
when Array
value.map { |v| deep_sort_hash(v) }
else
value
end
end
def secure_compare(a, b)
return false unless a.bytesize == b.bytesize
ActiveSupport::SecurityUtils.secure_compare(a, b)
end
# =====================================================================
# Helpers
# =====================================================================
def dig_bool(section, key, default = false)
value = config.dig(section, key)
value.nil? ? default : value
end
end
end