about summary refs log tree commit diff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/devise/ldap_authenticatable.rb49
-rw-r--r--lib/mastodon/premailer_webpack_strategy.rb19
-rw-r--r--lib/mastodon/version.rb4
-rw-r--r--lib/paperclip/gif_transcoder.rb2
-rw-r--r--lib/paperclip/lazy_thumbnail.rb28
-rw-r--r--lib/tasks/assets.rake8
-rw-r--r--lib/tasks/mastodon.rake432
7 files changed, 501 insertions, 41 deletions
diff --git a/lib/devise/ldap_authenticatable.rb b/lib/devise/ldap_authenticatable.rb
new file mode 100644
index 000000000..531abdbbe
--- /dev/null
+++ b/lib/devise/ldap_authenticatable.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+if ENV['LDAP_ENABLED'] == 'true'
+  require 'net/ldap'
+  require 'devise/strategies/authenticatable'
+
+  module Devise
+    module Strategies
+      class LdapAuthenticatable < Authenticatable
+        def authenticate!
+          if params[:user]
+            ldap = Net::LDAP.new(
+              host: Devise.ldap_host,
+              port: Devise.ldap_port,
+              base: Devise.ldap_base,
+              encryption: {
+                method: Devise.ldap_method,
+                tls_options: OpenSSL::SSL::SSLContext::DEFAULT_PARAMS,
+              },
+              auth: {
+                method: :simple,
+                username: Devise.ldap_bind_dn,
+                password: Devise.ldap_password,
+              },
+              connect_timeout: 10
+            )
+
+            if (user_info = ldap.bind_as(base: Devise.ldap_base, filter: "(#{Devise.ldap_uid}=#{email})", password: password))
+              user = User.ldap_get_user(user_info.first)
+              success!(user)
+            else
+              return fail(:invalid_login)
+            end
+          end
+        end
+
+        def email
+          params[:user][:email]
+        end
+
+        def password
+          params[:user][:password]
+        end
+      end
+    end
+  end
+
+  Warden::Strategies.add(:ldap_authenticatable, Devise::Strategies::LdapAuthenticatable)
+end
diff --git a/lib/mastodon/premailer_webpack_strategy.rb b/lib/mastodon/premailer_webpack_strategy.rb
index 84d83cc66..56ef09c1a 100644
--- a/lib/mastodon/premailer_webpack_strategy.rb
+++ b/lib/mastodon/premailer_webpack_strategy.rb
@@ -2,16 +2,21 @@
 
 module PremailerWebpackStrategy
   def load(url)
-    public_path_host = ENV['ASSET_HOST'] || ENV['LOCAL_DOMAIN']
-    url = url.gsub(/\A\/\/#{public_path_host}/, '')
+    asset_host = ENV['CDN_HOST'] || ENV['WEB_DOMAIN'] || ENV['LOCAL_DOMAIN']
 
     if Webpacker.dev_server.running?
-      url = File.join("#{Webpacker.dev_server.protocol}://#{Webpacker.dev_server.host_with_port}", url)
-      HTTP.get(url).to_s
-    else
-      url = url[1..-1] if url.start_with?('/')
-      File.read(Rails.root.join('public', url))
+      asset_host = "#{Webpacker.dev_server.protocol}://#{Webpacker.dev_server.host_with_port}"
+      url        = File.join(asset_host, url)
     end
+
+    css = if url.start_with?('http')
+            HTTP.get(url).to_s
+          else
+            url = url[1..-1] if url.start_with?('/')
+            File.read(Rails.root.join('public', url))
+          end
+
+    css.gsub(/url\(\//, "url(#{asset_host}/")
   end
 
   module_function :load
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index 098ab46ac..3ba96731d 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -9,7 +9,7 @@ module Mastodon
     end
 
     def minor
-      2
+      3
     end
 
     def patch
@@ -21,7 +21,7 @@ module Mastodon
     end
 
     def flags
-      'rc1'
+      ''
     end
 
     def to_a
diff --git a/lib/paperclip/gif_transcoder.rb b/lib/paperclip/gif_transcoder.rb
index 629e37581..62787983c 100644
--- a/lib/paperclip/gif_transcoder.rb
+++ b/lib/paperclip/gif_transcoder.rb
@@ -16,7 +16,7 @@ module Paperclip
 
       final_file = Paperclip::Transcoder.make(file, options, attachment)
 
-      attachment.instance.file_file_name    = 'media.mp4'
+      attachment.instance.file_file_name    = File.basename(attachment.instance.file_file_name, '.*') + '.mp4'
       attachment.instance.file_content_type = 'video/mp4'
       attachment.instance.type              = MediaAttachment.types[:gifv]
 
diff --git a/lib/paperclip/lazy_thumbnail.rb b/lib/paperclip/lazy_thumbnail.rb
new file mode 100644
index 000000000..aafa21343
--- /dev/null
+++ b/lib/paperclip/lazy_thumbnail.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Paperclip
+  class LazyThumbnail < Paperclip::Thumbnail
+    def make
+      return File.open(@file.path) unless needs_convert?
+
+      min_side = [@current_geometry.width, @current_geometry.height].min
+      options[:geometry] = "#{min_side.to_i}x#{min_side.to_i}#" if @target_geometry.square? && min_side < @target_geometry.width
+
+      Paperclip::Thumbnail.make(file, options, attachment)
+    end
+
+    private
+
+    def needs_convert?
+      needs_different_geometry? || needs_different_format?
+    end
+
+    def needs_different_geometry?
+      !@target_geometry.nil? && @current_geometry.width != @target_geometry.width && @current_geometry.height != @target_geometry.height
+    end
+
+    def needs_different_format?
+      @format.present? && @current_format != @format
+    end
+  end
+end
diff --git a/lib/tasks/assets.rake b/lib/tasks/assets.rake
index f60c1b9f2..b642510a1 100644
--- a/lib/tasks/assets.rake
+++ b/lib/tasks/assets.rake
@@ -1,15 +1,13 @@
 # frozen_string_literal: true
 
 def render_static_page(action, dest:, **opts)
-  I18n.with_locale(ENV['DEFAULT_LOCALE'] || I18n.default_locale) do
-    html = ApplicationController.render(action, opts)
-    File.write(dest, html)
-  end
+  html = ApplicationController.render(action, opts)
+  File.write(dest, html)
 end
 
 namespace :assets do
   desc 'Generate static pages'
-  task :generate_static_pages do
+  task generate_static_pages: :environment do
     render_static_page 'errors/500', layout: 'error', dest: Rails.root.join('public', 'assets', '500.html')
   end
 end
diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake
index 486c035de..d6c9e2d01 100644
--- a/lib/tasks/mastodon.rake
+++ b/lib/tasks/mastodon.rake
@@ -4,6 +4,370 @@ require 'optparse'
 require 'colorize'
 
 namespace :mastodon do
+  desc 'Configure the instance for production use'
+  task :setup do
+    prompt = TTY::Prompt.new
+    env    = {}
+
+    begin
+      prompt.say('Your instance is identified by its domain name. Changing it afterward will break things.')
+      env['LOCAL_DOMAIN'] = prompt.ask('Domain name:') do |q|
+        q.required true
+        q.modify :strip
+        q.validate(/\A[a-z0-9\.\-]+\z/i)
+        q.messages[:valid?] = 'Invalid domain. If you intend to use unicode characters, enter punycode here'
+      end
+
+      prompt.say "\n"
+
+      prompt.say('Single user mode disables registrations and redirects the landing page to your public profile.')
+      env['SINGLE_USER_MODE'] = prompt.yes?('Do you want to enable single user mode?', default: false)
+
+      %w(SECRET_KEY_BASE OTP_SECRET).each do |key|
+        env[key] = SecureRandom.hex(64)
+      end
+
+      vapid_key = Webpush.generate_key
+
+      env['VAPID_PRIVATE_KEY'] = vapid_key.private_key
+      env['VAPID_PUBLIC_KEY']  = vapid_key.public_key
+
+      prompt.say "\n"
+
+      using_docker        = prompt.yes?('Are you using Docker to run Mastodon?')
+      db_connection_works = false
+
+      prompt.say "\n"
+
+      loop do
+        env['DB_HOST'] = prompt.ask('PostgreSQL host:') do |q|
+          q.required true
+          q.default using_docker ? 'db' : '/var/run/postgresql'
+          q.modify :strip
+        end
+
+        env['DB_PORT'] = prompt.ask('PostgreSQL port:') do |q|
+          q.required true
+          q.default 5432
+          q.convert :int
+        end
+
+        env['DB_NAME'] = prompt.ask('Name of PostgreSQL database:') do |q|
+          q.required true
+          q.default using_docker ? 'postgres' : 'mastodon_production'
+          q.modify :strip
+        end
+
+        env['DB_USER'] = prompt.ask('Name of PostgreSQL user:') do |q|
+          q.required true
+          q.default using_docker ? 'postgres' : 'mastodon'
+          q.modify :strip
+        end
+
+        env['DB_PASS'] = prompt.ask('Password of PostgreSQL user:') do |q|
+          q.echo false
+        end
+
+        # The chosen database may not exist yet. Connect to default database
+        # to avoid "database does not exist" error.
+        db_options = {
+          adapter: :postgresql,
+          database: 'postgres',
+          host: env['DB_HOST'],
+          port: env['DB_PORT'],
+          user: env['DB_USER'],
+          password: env['DB_PASS'],
+        }
+
+        begin
+          ActiveRecord::Base.establish_connection(db_options)
+          ActiveRecord::Base.connection
+          prompt.ok 'Database configuration works! 🎆'
+          db_connection_works = true
+          break
+        rescue StandardError => e
+          prompt.error 'Database connection could not be established with this configuration, try again.'
+          prompt.error e.message
+          break unless prompt.yes?('Try again?')
+        end
+      end
+
+      prompt.say "\n"
+
+      loop do
+        env['REDIS_HOST'] = prompt.ask('Redis host:') do |q|
+          q.required true
+          q.default using_docker ? 'redis' : 'localhost'
+          q.modify :strip
+        end
+
+        env['REDIS_PORT'] = prompt.ask('Redis port:') do |q|
+          q.required true
+          q.default 6379
+          q.convert :int
+        end
+
+        redis_options = {
+          host: env['REDIS_HOST'],
+          port: env['REDIS_PORT'],
+          driver: :hiredis,
+        }
+
+        begin
+          redis = Redis.new(redis_options)
+          redis.ping
+          prompt.ok 'Redis configuration works! 🎆'
+          break
+        rescue StandardError => e
+          prompt.error 'Redis connection could not be established with this configuration, try again.'
+          prompt.error e.message
+          break unless prompt.yes?('Try again?')
+        end
+      end
+
+      prompt.say "\n"
+
+      if prompt.yes?('Do you want to store uploaded files on the cloud?', default: false)
+        case prompt.select('Provider', ['Amazon S3', 'Wasabi', 'Minio'])
+        when 'Amazon S3'
+          env['S3_ENABLED']  = 'true'
+          env['S3_PROTOCOL'] = 'https'
+
+          env['S3_BUCKET'] = prompt.ask('S3 bucket name:') do |q|
+            q.required true
+            q.default "files.#{env['LOCAL_DOMAIN']}"
+            q.modify :strip
+          end
+
+          env['S3_REGION'] = prompt.ask('S3 region:') do |q|
+            q.required true
+            q.default 'us-east-1'
+            q.modify :strip
+          end
+
+          env['S3_HOSTNAME'] = prompt.ask('S3 hostname:') do |q|
+            q.required true
+            q.default 's3-us-east-1.amazonaws.com'
+            q.modify :strip
+          end
+
+          env['AWS_ACCESS_KEY_ID'] = prompt.ask('S3 access key:') do |q|
+            q.required true
+            q.modify :strip
+          end
+
+          env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('S3 secret key:') do |q|
+            q.required true
+            q.modify :strip
+          end
+        when 'Wasabi'
+          env['S3_ENABLED']  = 'true'
+          env['S3_PROTOCOL'] = 'https'
+          env['S3_REGION']   = 'us-east-1'
+          env['S3_HOSTNAME'] = 's3.wasabisys.com'
+          env['S3_ENDPOINT'] = 'https://s3.wasabisys.com/'
+
+          env['S3_BUCKET'] = prompt.ask('Wasabi bucket name:') do |q|
+            q.required true
+            q.default "files.#{env['LOCAL_DOMAIN']}"
+            q.modify :strip
+          end
+
+          env['AWS_ACCESS_KEY_ID'] = prompt.ask('Wasabi access key:') do |q|
+            q.required true
+            q.modify :strip
+          end
+
+          env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Wasabi secret key:') do |q|
+            q.required true
+            q.modify :strip
+          end
+        when 'Minio'
+          env['S3_ENABLED']  = 'true'
+          env['S3_PROTOCOL'] = 'https'
+          env['S3_REGION']   = 'us-east-1'
+
+          env['S3_ENDPOINT'] = prompt.ask('Minio endpoint URL:') do |q|
+            q.required true
+            q.modify :strip
+          end
+
+          env['S3_PROTOCOL'] = env['S3_ENDPOINT'].start_with?('https') ? 'https' : 'http'
+          env['S3_HOSTNAME'] = env['S3_ENDPOINT'].gsub(/\Ahttps?:\/\//, '')
+
+          env['S3_BUCKET'] = prompt.ask('Minio bucket name:') do |q|
+            q.required true
+            q.default "files.#{env['LOCAL_DOMAIN']}"
+            q.modify :strip
+          end
+
+          env['AWS_ACCESS_KEY_ID'] = prompt.ask('Minio access key:') do |q|
+            q.required true
+            q.modify :strip
+          end
+
+          env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Minio secret key:') do |q|
+            q.required true
+            q.modify :strip
+          end
+        end
+
+        if prompt.yes?('Do you want to access the uploaded files from your own domain?')
+          env['S3_CLOUDFRONT_HOST'] = prompt.ask('Domain for uploaded files:') do |q|
+            q.required true
+            q.default "files.#{env['LOCAL_DOMAIN']}"
+            q.modify :strip
+          end
+        end
+      end
+
+      prompt.say "\n"
+
+      loop do
+        env['SMTP_SERVER'] = prompt.ask('SMTP server:') do |q|
+          q.required true
+          q.default 'smtp.mailgun.org'
+          q.modify :strip
+        end
+
+        env['SMTP_PORT'] = prompt.ask('SMTP port:') do |q|
+          q.required true
+          q.default 587
+          q.convert :int
+        end
+
+        env['SMTP_LOGIN'] = prompt.ask('SMTP username:') do |q|
+          q.modify :strip
+        end
+
+        env['SMTP_PASSWORD'] = prompt.ask('SMTP password:') do |q|
+          q.echo false
+        end
+
+        env['SMTP_FROM_ADDRESS'] = prompt.ask('E-mail address to send e-mails "from":') do |q|
+          q.required true
+          q.default "Mastodon <notifications@#{env['LOCAL_DOMAIN']}>"
+          q.modify :strip
+        end
+
+        break unless prompt.yes?('Send a test e-mail with this configuration right now?')
+
+        send_to = prompt.ask('Send test e-mail to:', required: true)
+
+        begin
+          ActionMailer::Base.smtp_settings = {
+            :port                 => env['SMTP_PORT'],
+            :address              => env['SMTP_SERVER'],
+            :user_name            => env['SMTP_LOGIN'].presence,
+            :password             => env['SMTP_PASSWORD'].presence,
+            :domain               => env['LOCAL_DOMAIN'],
+            :authentication       => :plain,
+            :enable_starttls_auto => true,
+          }
+
+          ActionMailer::Base.default_options = {
+            from: env['SMTP_FROM_ADDRESS'],
+          }
+
+          mail = ActionMailer::Base.new.mail to: send_to, subject: 'Test', body: 'Mastodon SMTP configuration works!'
+          mail.deliver
+        rescue StandardError => e
+          prompt.error 'E-mail could not be sent with this configuration, try again.'
+          prompt.error e.message
+          break unless prompt.yes?('Try again?')
+        end
+      end
+
+      prompt.say "\n"
+      prompt.say 'This configuration will be written to .env.production'
+
+      if prompt.yes?('Save configuration?')
+        cmd = TTY::Command.new(printer: :quiet)
+
+        File.write(Rails.root.join('.env.production'), "# Generated with mastodon:setup on #{Time.now.utc}\n\n" + env.each_pair.map { |key, value| "#{key}=#{value}" }.join("\n") + "\n")
+
+        if using_docker
+          prompt.ok 'Below is your configuration, save it to an .env.production file outside Docker:'
+          prompt.say "\n"
+          prompt.say File.read(Rails.root.join('.env.production'))
+          prompt.say "\n"
+          prompt.ok 'It is also saved within this container so you can proceed with this wizard.'
+        end
+
+        prompt.say "\n"
+        prompt.say 'Now that configuration is saved, the database schema must be loaded.'
+        prompt.warn 'If the database already exists, this will erase its contents.'
+
+        if prompt.yes?('Prepare the database now?')
+          prompt.say 'Running `RAILS_ENV=production rails db:setup` ...'
+          prompt.say "\n"
+
+          if cmd.run!({ RAILS_ENV: 'production' }, :rails, 'db:setup').failure?
+            prompt.say "\n"
+            prompt.error 'That failed! Perhaps your configuration is not right'
+          else
+            prompt.say "\n"
+            prompt.ok 'Done!'
+          end
+        end
+
+        prompt.say "\n"
+        prompt.say 'The final step is compiling CSS/JS assets.'
+        prompt.say 'This may take a while and consume a lot of RAM.'
+
+        if prompt.yes?('Compile the assets now?')
+          prompt.say 'Running `RAILS_ENV=production rails assets:precompile` ...'
+          prompt.say "\n"
+
+          if cmd.run!({ RAILS_ENV: 'production' }, :rails, 'assets:precompile').failure?
+            prompt.say "\n"
+            prompt.error 'That failed! Maybe you need swap space?'
+          else
+            prompt.say "\n"
+            prompt.say 'Done!'
+          end
+        end
+
+        prompt.say "\n"
+        prompt.ok 'All done! You can now power on the Mastodon server 🐘'
+        prompt.say "\n"
+
+        if db_connection_works && prompt.yes?('Do you want to create an admin user straight away?')
+          env.each_pair do |key, value|
+            ENV[key] = value.to_s
+          end
+
+          require_relative '../../config/environment'
+          disable_log_stdout!
+
+          username = prompt.ask('Username:') do |q|
+            q.required true
+            q.default 'admin'
+            q.validate(/\A[a-z0-9_]+\z/i)
+            q.modify :strip
+          end
+
+          email = prompt.ask('E-mail:') do |q|
+            q.required true
+            q.modify :strip
+          end
+
+          password = SecureRandom.hex(16)
+
+          user = User.new(admin: true, email: email, password: password, confirmed_at: Time.now.utc, account_attributes: { username: username })
+          user.save(validate: false)
+
+          prompt.ok "You can login with the password: #{password}"
+          prompt.warn 'You can change your password once you login.'
+        end
+      else
+        prompt.warn 'Nothing saved. Bye!'
+      end
+    rescue TTY::Reader::InputInterrupt
+      prompt.ok 'Aborting. Bye!'
+    end
+  end
+
   desc 'Execute daily tasks (deprecated)'
   task :daily do
     # No-op
@@ -67,32 +431,40 @@ namespace :mastodon do
   desc 'Add a user by providing their email, username and initial password.' \
        'The user will receive a confirmation email, then they must reset their password before logging in.'
   task add_user: :environment do
-    print 'Enter email: '
-    email = STDIN.gets.chomp
-
-    print 'Enter username: '
-    username = STDIN.gets.chomp
-
-    print 'Create user and send them confirmation mail [y/N]: '
-    confirm = STDIN.gets.chomp
-    puts
-
-    if confirm.casecmp('y').zero?
-      password = SecureRandom.hex
-      user = User.new(email: email, password: password, account_attributes: { username: username })
-      if user.save
-        puts 'User added and confirmation mail sent to user\'s email address.'
-        puts "Here is the random password generated for the user: #{password}"
-      else
-        puts 'Following errors occured while creating new user:'
-        user.errors.each do |key, val|
-          puts "#{key}: #{val}"
+    disable_log_stdout!
+
+    prompt = TTY::Prompt.new
+
+    begin
+      email = prompt.ask('E-mail:', required: true) do |q|
+        q.modify :strip
+      end
+
+      username = prompt.ask('Username:', required: true) do |q|
+        q.modify :strip
+      end
+
+      role = prompt.select('Role:', %w(user moderator admin))
+
+      if prompt.yes?('Proceed to create the user?')
+        user = User.new(email: email, password: SecureRandom.hex, admin: role == 'admin', moderator: role == 'moderator', account_attributes: { username: username })
+
+        if user.save
+          prompt.ok 'User created and confirmation mail sent to the user\'s email address.'
+          prompt.ok "Here is the random password generated for the user: #{password}"
+        else
+          prompt.warn 'User was not created because of the following errors:'
+
+          user.errors.each do |key, val|
+            prompt.error "#{key}: #{val}"
+          end
         end
+      else
+        prompt.ok 'Aborting. Bye!'
       end
-    else
-      puts 'Aborted by user.'
+    rescue TTY::Reader::InputInterrupt
+      prompt.ok 'Aborting. Bye!'
     end
-    puts
   end
 
   namespace :media do
@@ -112,6 +484,8 @@ namespace :mastodon do
       time_ago = ENV.fetch('NUM_DAYS') { 7 }.to_i.days.ago
 
       MediaAttachment.where.not(remote_url: '').where.not(file_file_name: nil).where('created_at < ?', time_ago).find_each do |media|
+        next unless media.file.exists?
+
         media.file.destroy
         media.save
       end
@@ -130,9 +504,13 @@ namespace :mastodon do
       accounts = accounts.where(domain: ENV['DOMAIN']) if ENV['DOMAIN'].present?
 
       accounts.find_each do |account|
-        account.reset_avatar!
-        account.reset_header!
-        account.save
+        begin
+          account.reset_avatar!
+          account.reset_header!
+          account.save
+        rescue Paperclip::Error
+          puts "Error resetting avatar and header for account #{username}@#{domain}"
+        end
       end
     end
   end
@@ -388,6 +766,7 @@ namespace :mastodon do
 
         if [404, 410].include?(res.code)
           if options[:force]
+            SuspendAccountService.new.call(account)
             account.destroy
           else
             progress_bar.pause
@@ -400,6 +779,7 @@ namespace :mastodon do
             if confirm.casecmp('n').zero?
               next
             else
+              SuspendAccountService.new.call(account)
               account.destroy
             end
           end