mastodon.rake 20 KB

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