mastodon.rake 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  1. # frozen_string_literal: true
  2. require 'tty-prompt'
  3. namespace :mastodon do
  4. desc 'Configure the instance for production use'
  5. task :setup do
  6. prompt = TTY::Prompt.new
  7. env = {}
  8. # When the application code gets loaded, it runs `lib/mastodon/redis_configuration.rb`.
  9. # This happens before application environment configuration and sets REDIS_URL etc.
  10. # These variables are then used even when REDIS_HOST etc. are changed, so clear them
  11. # out so they don't interfere with our new configuration.
  12. ENV.delete('REDIS_URL')
  13. ENV.delete('CACHE_REDIS_URL')
  14. ENV.delete('SIDEKIQ_REDIS_URL')
  15. begin
  16. errors = false
  17. prompt.say('Your instance is identified by its domain name. Changing it afterward will break things.')
  18. env['LOCAL_DOMAIN'] = prompt.ask('Domain name:') do |q|
  19. q.required true
  20. q.modify :strip
  21. q.validate(/\A[a-z0-9.-]+\z/i)
  22. q.messages[:valid?] = 'Invalid domain. If you intend to use unicode characters, enter punycode here'
  23. end
  24. prompt.say "\n"
  25. prompt.say('Single user mode disables registrations and redirects the landing page to your public profile.')
  26. env['SINGLE_USER_MODE'] = prompt.yes?('Do you want to enable single user mode?', default: false)
  27. %w(SECRET_KEY_BASE OTP_SECRET).each do |key|
  28. env[key] = SecureRandom.hex(64)
  29. end
  30. # Required by ActiveRecord encryption feature
  31. %w(
  32. ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
  33. ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
  34. ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
  35. ).each do |key|
  36. env[key] = SecureRandom.alphanumeric(32)
  37. end
  38. vapid_key = Webpush.generate_key
  39. env['VAPID_PRIVATE_KEY'] = vapid_key.private_key
  40. env['VAPID_PUBLIC_KEY'] = vapid_key.public_key
  41. prompt.say "\n"
  42. using_docker = prompt.yes?('Are you using Docker to run Mastodon?')
  43. db_connection_works = false
  44. prompt.say "\n"
  45. loop do
  46. env['DB_HOST'] = prompt.ask('PostgreSQL host:') do |q|
  47. q.required true
  48. q.default using_docker ? 'db' : '/var/run/postgresql'
  49. q.modify :strip
  50. end
  51. env['DB_PORT'] = prompt.ask('PostgreSQL port:') do |q|
  52. q.required true
  53. q.default 5432
  54. q.convert :int
  55. end
  56. env['DB_NAME'] = prompt.ask('Name of PostgreSQL database:') do |q|
  57. q.required true
  58. q.default using_docker ? 'postgres' : 'mastodon_production'
  59. q.modify :strip
  60. end
  61. env['DB_USER'] = prompt.ask('Name of PostgreSQL user:') do |q|
  62. q.required true
  63. q.default using_docker ? 'postgres' : 'mastodon'
  64. q.modify :strip
  65. end
  66. env['DB_PASS'] = prompt.ask('Password of PostgreSQL user:') do |q|
  67. q.echo false
  68. end
  69. # The chosen database may not exist yet. Connect to default database
  70. # to avoid "database does not exist" error.
  71. db_options = {
  72. adapter: :postgresql,
  73. database: 'postgres',
  74. host: env['DB_HOST'],
  75. port: env['DB_PORT'],
  76. user: env['DB_USER'],
  77. password: env['DB_PASS'],
  78. }
  79. begin
  80. ActiveRecord::Base.establish_connection(db_options)
  81. ActiveRecord::Base.connection
  82. prompt.ok 'Database configuration works! 🎆'
  83. db_connection_works = true
  84. break
  85. rescue => e
  86. prompt.error 'Database connection could not be established with this configuration, try again.'
  87. prompt.error e.message
  88. unless prompt.yes?('Try again?')
  89. return prompt.warn 'Nothing saved. Bye!' unless prompt.yes?('Continue anyway?')
  90. errors = true
  91. break
  92. end
  93. end
  94. end
  95. prompt.say "\n"
  96. loop do
  97. env['REDIS_HOST'] = prompt.ask('Redis host:') do |q|
  98. q.required true
  99. q.default using_docker ? 'redis' : 'localhost'
  100. q.modify :strip
  101. end
  102. env['REDIS_PORT'] = prompt.ask('Redis port:') do |q|
  103. q.required true
  104. q.default 6379
  105. q.convert :int
  106. end
  107. env['REDIS_PASSWORD'] = prompt.ask('Redis password:') do |q|
  108. q.required false
  109. q.default nil
  110. q.modify :strip
  111. end
  112. redis_options = {
  113. host: env['REDIS_HOST'],
  114. port: env['REDIS_PORT'],
  115. password: env['REDIS_PASSWORD'],
  116. driver: :hiredis,
  117. }
  118. begin
  119. redis = Redis.new(redis_options)
  120. redis.ping
  121. prompt.ok 'Redis configuration works! 🎆'
  122. break
  123. rescue => e
  124. prompt.error 'Redis connection could not be established with this configuration, try again.'
  125. prompt.error e.message
  126. unless prompt.yes?('Try again?')
  127. return prompt.warn 'Nothing saved. Bye!' unless prompt.yes?('Continue anyway?')
  128. errors = true
  129. break
  130. end
  131. end
  132. end
  133. prompt.say "\n"
  134. if prompt.yes?('Do you want to store uploaded files on the cloud?', default: false)
  135. case prompt.select('Provider', ['DigitalOcean Spaces', 'Amazon S3', 'Wasabi', 'Minio', 'Google Cloud Storage', 'Storj DCS'])
  136. when 'DigitalOcean Spaces'
  137. env['S3_ENABLED'] = 'true'
  138. env['S3_PROTOCOL'] = 'https'
  139. env['S3_BUCKET'] = prompt.ask('Space name:') do |q|
  140. q.required true
  141. q.default "files.#{env['LOCAL_DOMAIN']}"
  142. q.modify :strip
  143. end
  144. env['S3_REGION'] = prompt.ask('Space region:') do |q|
  145. q.required true
  146. q.default 'nyc3'
  147. q.modify :strip
  148. end
  149. env['S3_HOSTNAME'] = prompt.ask('Space endpoint:') do |q|
  150. q.required true
  151. q.default 'nyc3.digitaloceanspaces.com'
  152. q.modify :strip
  153. end
  154. env['S3_ENDPOINT'] = "https://#{env['S3_HOSTNAME']}"
  155. env['AWS_ACCESS_KEY_ID'] = prompt.ask('Space access key:') do |q|
  156. q.required true
  157. q.modify :strip
  158. end
  159. env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Space secret key:') do |q|
  160. q.required true
  161. q.modify :strip
  162. end
  163. when 'Amazon S3'
  164. env['S3_ENABLED'] = 'true'
  165. env['S3_PROTOCOL'] = 'https'
  166. env['S3_BUCKET'] = prompt.ask('S3 bucket name:') do |q|
  167. q.required true
  168. q.default "files.#{env['LOCAL_DOMAIN']}"
  169. q.modify :strip
  170. end
  171. env['S3_REGION'] = prompt.ask('S3 region:') do |q|
  172. q.required true
  173. q.default 'us-east-1'
  174. q.modify :strip
  175. end
  176. env['S3_HOSTNAME'] = prompt.ask('S3 hostname:') do |q|
  177. q.required true
  178. q.default 's3.us-east-1.amazonaws.com'
  179. q.modify :strip
  180. end
  181. env['AWS_ACCESS_KEY_ID'] = prompt.ask('S3 access key:') do |q|
  182. q.required true
  183. q.modify :strip
  184. end
  185. env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('S3 secret key:') do |q|
  186. q.required true
  187. q.modify :strip
  188. end
  189. when 'Wasabi'
  190. env['S3_ENABLED'] = 'true'
  191. env['S3_PROTOCOL'] = 'https'
  192. env['S3_REGION'] = 'us-east-1'
  193. env['S3_HOSTNAME'] = 's3.wasabisys.com'
  194. env['S3_ENDPOINT'] = 'https://s3.wasabisys.com/'
  195. env['S3_BUCKET'] = prompt.ask('Wasabi bucket name:') do |q|
  196. q.required true
  197. q.default "files.#{env['LOCAL_DOMAIN']}"
  198. q.modify :strip
  199. end
  200. env['AWS_ACCESS_KEY_ID'] = prompt.ask('Wasabi access key:') do |q|
  201. q.required true
  202. q.modify :strip
  203. end
  204. env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Wasabi secret key:') do |q|
  205. q.required true
  206. q.modify :strip
  207. end
  208. when 'Minio'
  209. env['S3_ENABLED'] = 'true'
  210. env['S3_PROTOCOL'] = 'https'
  211. env['S3_REGION'] = 'us-east-1'
  212. env['S3_ENDPOINT'] = prompt.ask('Minio endpoint URL:') do |q|
  213. q.required true
  214. q.modify :strip
  215. end
  216. env['S3_PROTOCOL'] = env['S3_ENDPOINT'].start_with?('https') ? 'https' : 'http'
  217. env['S3_HOSTNAME'] = env['S3_ENDPOINT'].gsub(%r{\Ahttps?://}, '')
  218. env['S3_BUCKET'] = prompt.ask('Minio bucket name:') do |q|
  219. q.required true
  220. q.default "files.#{env['LOCAL_DOMAIN']}"
  221. q.modify :strip
  222. end
  223. env['AWS_ACCESS_KEY_ID'] = prompt.ask('Minio access key:') do |q|
  224. q.required true
  225. q.modify :strip
  226. end
  227. env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Minio secret key:') do |q|
  228. q.required true
  229. q.modify :strip
  230. end
  231. when 'Storj DCS'
  232. env['S3_ENABLED'] = 'true'
  233. env['S3_PROTOCOL'] = 'https'
  234. env['S3_REGION'] = 'global'
  235. env['S3_ENDPOINT'] = prompt.ask('Storj DCS endpoint URL:') do |q|
  236. q.required true
  237. q.default 'https://gateway.storjshare.io'
  238. q.modify :strip
  239. end
  240. env['S3_PROTOCOL'] = env['S3_ENDPOINT'].start_with?('https') ? 'https' : 'http'
  241. env['S3_HOSTNAME'] = env['S3_ENDPOINT'].gsub(%r{\Ahttps?://}, '')
  242. env['S3_BUCKET'] = prompt.ask('Storj DCS bucket name:') do |q|
  243. q.required true
  244. q.default "files.#{env['LOCAL_DOMAIN']}"
  245. q.modify :strip
  246. end
  247. env['AWS_ACCESS_KEY_ID'] = prompt.ask('Storj Gateway access key (uplink share --register --readonly=false --not-after=none sj://bucket):') do |q|
  248. q.required true
  249. q.modify :strip
  250. end
  251. env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Storj Gateway secret key:') do |q|
  252. q.required true
  253. q.modify :strip
  254. end
  255. linksharing_access_key = prompt.ask('Storj Linksharing access key (uplink share --register --public --readonly=true --disallow-lists --not-after=none sj://bucket):') do |q|
  256. q.required true
  257. q.modify :strip
  258. end
  259. env['S3_ALIAS_HOST'] = "link.storjshare.io/raw/#{linksharing_access_key}/#{env['S3_BUCKET']}"
  260. when 'Google Cloud Storage'
  261. env['S3_ENABLED'] = 'true'
  262. env['S3_PROTOCOL'] = 'https'
  263. env['S3_HOSTNAME'] = 'storage.googleapis.com'
  264. env['S3_ENDPOINT'] = 'https://storage.googleapis.com'
  265. env['S3_MULTIPART_THRESHOLD'] = 50.megabytes
  266. env['S3_BUCKET'] = prompt.ask('GCS bucket name:') do |q|
  267. q.required true
  268. q.default "files.#{env['LOCAL_DOMAIN']}"
  269. q.modify :strip
  270. end
  271. env['S3_REGION'] = prompt.ask('GCS region:') do |q|
  272. q.required true
  273. q.default 'us-west1'
  274. q.modify :strip
  275. end
  276. env['AWS_ACCESS_KEY_ID'] = prompt.ask('GCS access key:') do |q|
  277. q.required true
  278. q.modify :strip
  279. end
  280. env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('GCS secret key:') do |q|
  281. q.required true
  282. q.modify :strip
  283. end
  284. end
  285. if prompt.yes?('Do you want to access the uploaded files from your own domain?')
  286. env['S3_ALIAS_HOST'] = prompt.ask('Domain for uploaded files:') do |q|
  287. q.required true
  288. q.default "files.#{env['LOCAL_DOMAIN']}"
  289. q.modify :strip
  290. end
  291. end
  292. end
  293. prompt.say "\n"
  294. loop do
  295. if prompt.yes?('Do you want to send e-mails from localhost?', default: false)
  296. env['SMTP_SERVER'] = 'localhost'
  297. env['SMTP_PORT'] = 25
  298. env['SMTP_AUTH_METHOD'] = 'none'
  299. env['SMTP_OPENSSL_VERIFY_MODE'] = 'none'
  300. env['SMTP_ENABLE_STARTTLS'] = 'auto'
  301. else
  302. env['SMTP_SERVER'] = prompt.ask('SMTP server:') do |q|
  303. q.required true
  304. q.default 'smtp.mailgun.org'
  305. q.modify :strip
  306. end
  307. env['SMTP_PORT'] = prompt.ask('SMTP port:') do |q|
  308. q.required true
  309. q.default 587
  310. q.convert :int
  311. end
  312. env['SMTP_LOGIN'] = prompt.ask('SMTP username:') do |q|
  313. q.modify :strip
  314. end
  315. env['SMTP_PASSWORD'] = prompt.ask('SMTP password:') do |q|
  316. q.echo false
  317. end
  318. env['SMTP_AUTH_METHOD'] = prompt.ask('SMTP authentication:') do |q|
  319. q.required
  320. q.default 'plain'
  321. q.modify :strip
  322. end
  323. env['SMTP_OPENSSL_VERIFY_MODE'] = prompt.select('SMTP OpenSSL verify mode:', %w(none peer client_once fail_if_no_peer_cert))
  324. env['SMTP_ENABLE_STARTTLS'] = prompt.select('Enable STARTTLS:', %w(auto always never))
  325. end
  326. env['SMTP_FROM_ADDRESS'] = prompt.ask('E-mail address to send e-mails "from":') do |q|
  327. q.required true
  328. q.default "Mastodon <notifications@#{env['LOCAL_DOMAIN']}>"
  329. q.modify :strip
  330. end
  331. break unless prompt.yes?('Send a test e-mail with this configuration right now?')
  332. send_to = prompt.ask('Send test e-mail to:', required: true)
  333. begin
  334. enable_starttls = nil
  335. enable_starttls_auto = nil
  336. case env['SMTP_ENABLE_STARTTLS']
  337. when 'always'
  338. enable_starttls = true
  339. when 'never'
  340. enable_starttls = false
  341. when 'auto'
  342. enable_starttls_auto = true
  343. else
  344. enable_starttls_auto = env['SMTP_ENABLE_STARTTLS_AUTO'] != 'false'
  345. end
  346. ActionMailer::Base.smtp_settings = {
  347. port: env['SMTP_PORT'],
  348. address: env['SMTP_SERVER'],
  349. user_name: env['SMTP_LOGIN'].presence,
  350. password: env['SMTP_PASSWORD'].presence,
  351. domain: env['LOCAL_DOMAIN'],
  352. authentication: env['SMTP_AUTH_METHOD'] == 'none' ? nil : env['SMTP_AUTH_METHOD'] || :plain,
  353. openssl_verify_mode: env['SMTP_OPENSSL_VERIFY_MODE'],
  354. enable_starttls: enable_starttls,
  355. enable_starttls_auto: enable_starttls_auto,
  356. }
  357. ActionMailer::Base.default_options = {
  358. from: env['SMTP_FROM_ADDRESS'],
  359. }
  360. mail = ActionMailer::Base.new.mail(
  361. to: send_to,
  362. subject: 'Test', # rubocop:disable Rails/I18nLocaleTexts
  363. body: 'Mastodon SMTP configuration works!'
  364. )
  365. mail.deliver
  366. break
  367. rescue => e
  368. prompt.error 'E-mail could not be sent with this configuration, try again.'
  369. prompt.error e.message
  370. unless prompt.yes?('Try again?')
  371. return prompt.warn 'Nothing saved. Bye!' unless prompt.yes?('Continue anyway?')
  372. errors = true
  373. break
  374. end
  375. end
  376. end
  377. prompt.say "\n"
  378. env['UPDATE_CHECK_URL'] = '' unless prompt.yes?('Do you want Mastodon to periodically check for important updates and notify you? (Recommended)', default: true)
  379. prompt.say "\n"
  380. prompt.say 'This configuration will be written to .env.production'
  381. if prompt.yes?('Save configuration?')
  382. incompatible_syntax = false
  383. env_contents = env.each_pair.map do |key, value|
  384. value = value.to_s
  385. escaped = dotenv_escape(value)
  386. incompatible_syntax = true if value != escaped
  387. "#{key}=#{escaped}"
  388. end.join("\n")
  389. generated_header = generate_header(incompatible_syntax)
  390. Rails.root.join('.env.production').write("#{generated_header}#{env_contents}\n")
  391. if using_docker
  392. prompt.ok 'Below is your configuration, save it to an .env.production file outside Docker:'
  393. prompt.say "\n"
  394. prompt.say "#{generated_header}#{env.each_pair.map { |key, value| "#{key}=#{value}" }.join("\n")}"
  395. prompt.say "\n"
  396. prompt.ok 'It is also saved within this container so you can proceed with this wizard.'
  397. end
  398. prompt.say "\n"
  399. prompt.say 'Now that configuration is saved, the database schema must be loaded.'
  400. prompt.warn 'If the database already exists, this will erase its contents.'
  401. if prompt.yes?('Prepare the database now?')
  402. prompt.say 'Running `RAILS_ENV=production rails db:setup` ...'
  403. prompt.say "\n\n"
  404. if system(env.transform_values(&:to_s).merge({ 'RAILS_ENV' => 'production', 'SAFETY_ASSURED' => '1' }), 'rails db:setup')
  405. prompt.ok 'Done!'
  406. else
  407. prompt.error 'That failed! Perhaps your configuration is not right'
  408. errors = true
  409. end
  410. end
  411. unless using_docker
  412. prompt.say "\n"
  413. prompt.say 'The final step is compiling CSS/JS assets.'
  414. prompt.say 'This may take a while and consume a lot of RAM.'
  415. if prompt.yes?('Compile the assets now?')
  416. prompt.say 'Running `RAILS_ENV=production rails assets:precompile` ...'
  417. prompt.say "\n\n"
  418. if system(env.transform_values(&:to_s).merge({ 'RAILS_ENV' => 'production' }), 'rails assets:precompile')
  419. prompt.say 'Done!'
  420. else
  421. prompt.error 'That failed! Maybe you need swap space?'
  422. errors = true
  423. end
  424. end
  425. end
  426. prompt.say "\n"
  427. if errors
  428. prompt.warn 'Your Mastodon server is set up, but there were some errors along the way, you may have to fix them.'
  429. else
  430. prompt.ok 'All done! You can now power on the Mastodon server 🐘'
  431. end
  432. prompt.say "\n"
  433. if db_connection_works && prompt.yes?('Do you want to create an admin user straight away?')
  434. env.each_pair do |key, value|
  435. ENV[key] = value.to_s
  436. end
  437. require_relative '../../config/environment'
  438. disable_log_stdout!
  439. username = prompt.ask('Username:') do |q|
  440. q.required true
  441. q.default 'admin'
  442. q.validate(/\A[a-z0-9_]+\z/i)
  443. q.modify :strip
  444. end
  445. email = prompt.ask('E-mail:') do |q|
  446. q.required true
  447. q.modify :strip
  448. end
  449. password = SecureRandom.hex(16)
  450. owner_role = UserRole.find_by(name: 'Owner')
  451. user = User.new(email: email, password: password, confirmed_at: Time.now.utc, account_attributes: { username: username }, bypass_invite_request_check: true, role: owner_role)
  452. user.save(validate: false)
  453. user.approve!
  454. Setting.site_contact_username = username
  455. prompt.ok "You can login with the password: #{password}"
  456. prompt.warn 'You can change your password once you login.'
  457. end
  458. else
  459. prompt.warn 'Nothing saved. Bye!'
  460. end
  461. rescue TTY::Reader::InputInterrupt
  462. prompt.ok 'Aborting. Bye!'
  463. end
  464. end
  465. namespace :webpush do
  466. desc 'Generate VAPID key'
  467. task :generate_vapid_key do
  468. vapid_key = Webpush.generate_key
  469. puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
  470. puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
  471. end
  472. end
  473. private
  474. def generate_header(include_warning)
  475. default_message = "# Generated with mastodon:setup on #{Time.now.utc}\n\n"
  476. default_message.tap do |string|
  477. if include_warning
  478. string << "# Some variables in this file will be interpreted differently whether you are\n"
  479. string << "# using docker-compose or not.\n\n"
  480. end
  481. end
  482. end
  483. end
  484. def disable_log_stdout!
  485. dev_null = Logger.new('/dev/null')
  486. Rails.logger = dev_null
  487. ActiveRecord::Base.logger = dev_null
  488. HttpLog.configuration.logger = dev_null
  489. Paperclip.options[:log] = false
  490. end
  491. def dotenv_escape(value)
  492. # Dotenv has its own parser, which unfortunately deviates somewhat from
  493. # what shells actually do.
  494. #
  495. # In particular, we can't use Shellwords::escape because it outputs a
  496. # non-quotable string, while Dotenv requires `#` to always be in quoted
  497. # strings.
  498. #
  499. # Therefore, we need to write our own escape code…
  500. # Dotenv's parser has a *lot* of edge cases, and I think not every
  501. # ASCII string can even be represented into something Dotenv can parse,
  502. # so this is a best effort thing.
  503. #
  504. # In particular, strings with all the following probably cannot be
  505. # escaped:
  506. # - `#`, or ends with spaces, which requires some form of quoting (simply escaping won't work)
  507. # - `'` (single quote), preventing us from single-quoting
  508. # - `\` followed by either `r` or `n`
  509. # No character that would cause Dotenv trouble
  510. return value unless /[\s\#\\"'$]/.match?(value)
  511. # As long as the value doesn't include single quotes, we can safely
  512. # rely on single quotes
  513. return "'#{value}'" unless value.include?("'")
  514. # If the value contains the string '\n' or '\r' we simply can't use
  515. # a double-quoted string, because Dotenv will expand \n or \r no
  516. # matter how much escaping we add.
  517. double_quoting_disallowed = /\\[rn]/.match?(value)
  518. value = value.gsub(double_quoting_disallowed ? /[\\"'\s]/ : /[\\"']/) { |x| "\\#{x}" }
  519. # Dotenv is especially tricky with `$` as unbalanced
  520. # parenthesis will make it not unescape `\$` as `$`…
  521. # Variables
  522. value = value.gsub(/\$(?!\()/) { |x| "\\#{x}" }
  523. # Commands
  524. value = value.gsub(/\$(?<cmd>\((?:[^()]|\g<cmd>)+\))/) { |x| "\\#{x}" }
  525. value = "\"#{value}\"" unless double_quoting_disallowed
  526. value
  527. end