diff --git a/Changes.md b/Changes.md index f5e6ab52..52fdbcee 100644 --- a/Changes.md +++ b/Changes.md @@ -1,10 +1,15 @@ # Sidekiq Changes -HEAD +4.2.0 ----------- -- Enable development-mode code reloading. With Rails 5.0+, you don't need - to restart Sidekiq to pick up your Sidekiq::Worker changes anymore! [#2457] +- Enable development-mode code reloading. **With Rails 5.0+, you don't need + to restart Sidekiq to pick up your Sidekiq::Worker changes anymore!** [#2457] +- **Remove Sinatra dependency**. Sidekiq's Web UI now uses Rack directly. + Thank you to Sidekiq's newest committer, **badosu**, for writing the code + and doing a lot of testing to ensure compatibility with many different + 3rd party plugins. If your Web UI works with 4.1.4 but fails with + 4.2.0, please open an issue. [#3075] - Allow tuning of concurrency with the `RAILS_MAX_THREADS` env var. [#2985] This is the same var used by Puma so you can tune all of your systems the same way: diff --git a/examples/web/Gemfile b/examples/web/Gemfile new file mode 100644 index 00000000..4971813e --- /dev/null +++ b/examples/web/Gemfile @@ -0,0 +1,5 @@ +source 'https://rubygems.org' + +gem 'sidekiq', path: '../../' +gem 'thin' +gem 'pry' diff --git a/examples/web/config.ru b/examples/web/config.ru new file mode 100644 index 00000000..2c008b53 --- /dev/null +++ b/examples/web/config.ru @@ -0,0 +1,14 @@ +require 'sidekiq/web' +require 'redis' + +$redis = Redis.new + +class SinatraWorker + include Sidekiq::Worker + + def perform(msg="lulz you forgot a msg!") + $redis.lpush("sinkiq-example-messages", msg) + end +end + +run Sidekiq::Web diff --git a/lib/sidekiq/web.rb b/lib/sidekiq/web.rb index 22a4837c..6d761fc2 100644 --- a/lib/sidekiq/web.rb +++ b/lib/sidekiq/web.rb @@ -1,26 +1,28 @@ # frozen_string_literal: true require 'erb' -require 'yaml' -require 'sinatra/base' require 'sidekiq' require 'sidekiq/api' require 'sidekiq/paginator' -require 'sidekiq/web_helpers' +require 'sidekiq/web/helpers' + +require 'sidekiq/web/router' +require 'sidekiq/web/action' +require 'sidekiq/web/application' + +require 'rack/protection' + +require 'rack/builder' +require 'rack/file' +require 'rack/session/cookie' module Sidekiq - class Web < Sinatra::Base - include Sidekiq::Paginator - - enable :sessions - use ::Rack::Protection, :use => :authenticity_token unless ENV['RACK_ENV'] == 'test' - - set :root, File.expand_path(File.dirname(__FILE__) + "/../../web") - set :public_folder, proc { "#{root}/assets" } - set :views, proc { "#{root}/views" } - set :locales, ["#{root}/locales"] - - helpers WebHelpers + class Web + ROOT = File.expand_path("#{File.dirname(__FILE__)}/../../web") + VIEWS = "#{ROOT}/views".freeze + LOCALES = ["#{ROOT}/locales".freeze] + LAYOUT = "#{VIEWS}/layout.erb".freeze + ASSETS = "#{ROOT}/assets".freeze DEFAULT_TABS = { "Dashboard" => '', @@ -32,6 +34,18 @@ module Sidekiq } class << self + def settings + self + end + + def middlewares + @middlewares ||= [] + end + + def use(*middleware_args, &block) + middlewares << [middleware_args, block] + end + def default_tabs DEFAULT_TABS end @@ -41,227 +55,94 @@ module Sidekiq end alias_method :tabs, :custom_tabs - attr_accessor :app_url - end - - get "/busy" do - erb :busy - end - - post "/busy" do - if params['identity'] - p = Sidekiq::Process.new('identity' => params['identity']) - p.quiet! if params[:quiet] - p.stop! if params[:stop] - else - processes.each do |pro| - pro.quiet! if params[:quiet] - pro.stop! if params[:stop] - end + def locales + @locales ||= LOCALES end - redirect "#{root_path}busy" - end - get "/queues" do - @queues = Sidekiq::Queue.all - erb :queues - end - - get "/queues/:name" do - halt 404 unless params[:name] - @count = (params[:count] || 25).to_i - @name = params[:name] - @queue = Sidekiq::Queue.new(@name) - (@current_page, @total_size, @messages) = page("queue:#{@name}", params[:page], @count) - @messages = @messages.map { |msg| Sidekiq::Job.new(msg, @name) } - erb :queue - end - - post "/queues/:name" do - Sidekiq::Queue.new(params[:name]).clear - redirect "#{root_path}queues" - end - - post "/queues/:name/delete" do - Sidekiq::Job.new(params[:key_val], params[:name]).delete - redirect_with_query("#{root_path}queues/#{params[:name]}") - end - - get '/morgue' do - @count = (params[:count] || 25).to_i - (@current_page, @total_size, @dead) = page("dead", params[:page], @count, reverse: true) - @dead = @dead.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) } - erb :morgue - end - - get "/morgue/:key" do - halt 404 unless params['key'] - @dead = Sidekiq::DeadSet.new.fetch(*parse_params(params['key'])).first - redirect "#{root_path}morgue" if @dead.nil? - erb :dead - end - - post '/morgue' do - redirect request.path unless params['key'] - - params['key'].each do |key| - job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first - retry_or_delete_or_kill job, params if job + def views + @views ||= VIEWS end - redirect_with_query("#{root_path}morgue") - end - post "/morgue/all/delete" do - Sidekiq::DeadSet.new.clear - redirect "#{root_path}morgue" - end - - post "/morgue/all/retry" do - Sidekiq::DeadSet.new.retry_all - redirect "#{root_path}morgue" - end - - post "/morgue/:key" do - halt 404 unless params['key'] - job = Sidekiq::DeadSet.new.fetch(*parse_params(params['key'])).first - retry_or_delete_or_kill job, params if job - redirect_with_query("#{root_path}morgue") - end - - - get '/retries' do - @count = (params[:count] || 25).to_i - (@current_page, @total_size, @retries) = page("retry", params[:page], @count) - @retries = @retries.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) } - erb :retries - end - - get "/retries/:key" do - @retry = Sidekiq::RetrySet.new.fetch(*parse_params(params['key'])).first - redirect "#{root_path}retries" if @retry.nil? - erb :retry - end - - post '/retries' do - redirect request.path unless params['key'] - - params['key'].each do |key| - job = Sidekiq::RetrySet.new.fetch(*parse_params(key)).first - retry_or_delete_or_kill job, params if job + def session_secret=(secret) + @session_secret = secret end - redirect_with_query("#{root_path}retries") + + attr_accessor :app_url, :session_secret, :redis_pool + attr_writer :locales, :views end - post "/retries/all/delete" do - Sidekiq::RetrySet.new.clear - redirect "#{root_path}retries" + def settings + self.class.settings end - post "/retries/all/retry" do - Sidekiq::RetrySet.new.retry_all - redirect "#{root_path}retries" + def use(*middleware_args, &block) + middlewares << [middleware_args, block] end - post "/retries/:key" do - job = Sidekiq::RetrySet.new.fetch(*parse_params(params['key'])).first - retry_or_delete_or_kill job, params if job - redirect_with_query("#{root_path}retries") + def middlewares + @middlewares ||= Web.middlewares.dup end - get '/scheduled' do - @count = (params[:count] || 25).to_i - (@current_page, @total_size, @scheduled) = page("schedule", params[:page], @count) - @scheduled = @scheduled.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) } - erb :scheduled + def call(env) + app.call(env) end - get "/scheduled/:key" do - @job = Sidekiq::ScheduledSet.new.fetch(*parse_params(params['key'])).first - redirect "#{root_path}scheduled" if @job.nil? - erb :scheduled_job_info + def self.call(env) + @app ||= new + @app.call(env) end - post '/scheduled' do - redirect request.path unless params['key'] - - params['key'].each do |key| - job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first - delete_or_add_queue job, params if job - end - redirect_with_query("#{root_path}scheduled") + def app + @app ||= build end - post "/scheduled/:key" do - halt 404 unless params['key'] - job = Sidekiq::ScheduledSet.new.fetch(*parse_params(params['key'])).first - delete_or_add_queue job, params if job - redirect_with_query("#{root_path}scheduled") - end - - get '/' do - @redis_info = redis_info.select{ |k, v| REDIS_KEYS.include? k } - stats_history = Sidekiq::Stats::History.new((params[:days] || 30).to_i) - @processed_history = stats_history.processed - @failed_history = stats_history.failed - erb :dashboard - end - - REDIS_KEYS = %w(redis_version uptime_in_days connected_clients used_memory_human used_memory_peak_human) - - get '/dashboard/stats' do - redirect "#{root_path}stats" - end - - get '/stats' do - sidekiq_stats = Sidekiq::Stats.new - redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k } - - content_type :json - Sidekiq.dump_json( - sidekiq: { - processed: sidekiq_stats.processed, - failed: sidekiq_stats.failed, - busy: sidekiq_stats.workers_size, - processes: sidekiq_stats.processes_size, - enqueued: sidekiq_stats.enqueued, - scheduled: sidekiq_stats.scheduled_size, - retries: sidekiq_stats.retry_size, - dead: sidekiq_stats.dead_size, - default_latency: sidekiq_stats.default_queue_latency - }, - redis: redis_stats - ) - end - - get '/stats/queues' do - queue_stats = Sidekiq::Stats::Queues.new - - content_type :json - Sidekiq.dump_json( - queue_stats.lengths - ) + def self.register(extension) + extension.registered(WebApplication) end private - def retry_or_delete_or_kill job, params - if params['retry'] - job.retry - elsif params['delete'] - job.delete - elsif params['kill'] - job.kill + def using?(middleware) + middlewares.any? do |(m,_)| + m.kind_of?(Array) && (m[0] == middleware || m[0].kind_of?(middleware)) end end - def delete_or_add_queue job, params - if params['delete'] - job.delete - elsif params['add_to_queue'] - job.add_to_queue + def build + middlewares = self.middlewares + klass = self.class + + unless using?(::Rack::Protection) || ENV['RACK_ENV'] == 'test' + middlewares.unshift [[::Rack::Protection, { use: :authenticity_token }], nil] + end + + unless using? ::Rack::Session::Cookie + unless secret = Web.session_secret + require 'securerandom' + secret = SecureRandom.hex(64) + end + + middlewares.unshift [[::Rack::Session::Cookie, { secret: secret }], nil] + end + + ::Rack::Builder.new do + %w(stylesheets javascripts images).each do |asset_dir| + map "/#{asset_dir}" do + run ::Rack::File.new("#{ASSETS}/#{asset_dir}") + end + end + + middlewares.each {|middleware, block| use *middleware, &block } + + run WebApplication.new(klass) end end end + + Sidekiq::WebApplication.helpers WebHelpers + Sidekiq::WebApplication.helpers Sidekiq::Paginator + + Sidekiq::WebAction.class_eval "def _render\n#{ERB.new(File.read(Web::LAYOUT)).src}\nend" end if defined?(::ActionDispatch::Request::Session) && diff --git a/lib/sidekiq/web/action.rb b/lib/sidekiq/web/action.rb new file mode 100644 index 00000000..e9bec31f --- /dev/null +++ b/lib/sidekiq/web/action.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Sidekiq + class WebAction + RACK_SESSION = 'rack.session'.freeze + + LOCATION = "Location".freeze + + CONTENT_TYPE = "Content-Type".freeze + TEXT_HTML = { CONTENT_TYPE => "text/html".freeze } + APPLICATION_JSON = { CONTENT_TYPE => "application/json".freeze } + + attr_accessor :env, :block, :type + + def settings + Web.settings + end + + def request + @request ||= ::Rack::Request.new(env) + end + + def halt(res) + throw :halt, res + end + + def redirect(location) + throw :halt, [302, { LOCATION => "#{request.base_url}#{location}" }, []] + end + + def params + indifferent_hash = Hash.new {|hash,key| hash[key.to_s] if Symbol === key } + + indifferent_hash.merge! request.params + route_params.each {|k,v| indifferent_hash[k.to_s] = v } + + indifferent_hash + end + + def route_params + env[WebRouter::ROUTE_PARAMS] + end + + def session + env[RACK_SESSION] + end + + def content_type(type) + @type = type + end + + def erb(content, options = {}) + if content.kind_of? Symbol + unless respond_to?(:"_erb_#{content}") + src = ERB.new(File.read("#{Web.settings.views}/#{content}.erb")).src + WebAction.class_eval("def _erb_#{content}\n#{src}\n end") + end + end + + if @_erb + _erb(content, options[:locals]) + else + @_erb = true + content = _erb(content, options[:locals]) + + _render { content } + end + end + + def render(engine, content, options = {}) + raise "Only erb templates are supported" if engine != :erb + + erb(content, options) + end + + def json(payload) + [200, APPLICATION_JSON, [Sidekiq.dump_json(payload)]] + end + + def initialize(env, block) + @env = env + @block = block + @@files ||= {} + end + + private + + def _erb(file, locals) + locals.each {|k, v| define_singleton_method(k){ v } } if locals + + if file.kind_of?(String) + ERB.new(file).result(binding) + else + send(:"_erb_#{file}") + end + end + end +end diff --git a/lib/sidekiq/web/application.rb b/lib/sidekiq/web/application.rb new file mode 100644 index 00000000..ba399bd0 --- /dev/null +++ b/lib/sidekiq/web/application.rb @@ -0,0 +1,335 @@ +# frozen_string_literal: true + +module Sidekiq + class WebApplication + extend WebRouter + + CONTENT_LENGTH = "Content-Length".freeze + CONTENT_TYPE = "Content-Type".freeze + REDIS_KEYS = %w(redis_version uptime_in_days connected_clients used_memory_human used_memory_peak_human) + NOT_FOUND = [404, {"Content-Type" => "text/plain", "X-Cascade" => "pass" }, ["Not Found"]] + + def initialize(klass) + @klass = klass + end + + def settings + @klass.settings + end + + def self.settings + Sidekiq::Web.settings + end + + def self.tabs + Sidekiq::Web.tabs + end + + def self.set(key, val) + # nothing, backwards compatibility + end + + get "/" do + @redis_info = redis_info.select{ |k, v| REDIS_KEYS.include? k } + stats_history = Sidekiq::Stats::History.new((params['days'] || 30).to_i) + @processed_history = stats_history.processed + @failed_history = stats_history.failed + + erb(:dashboard) + end + + get "/busy" do + erb(:busy) + end + + post "/busy" do + if params['identity'] + p = Sidekiq::Process.new('identity' => params['identity']) + p.quiet! if params['quiet'] + p.stop! if params['stop'] + else + processes.each do |pro| + pro.quiet! if params['quiet'] + pro.stop! if params['stop'] + end + end + + redirect "#{root_path}busy" + end + + get "/queues" do + @queues = Sidekiq::Queue.all + + erb(:queues) + end + + get "/queues/:name" do + @name = route_params[:name] + + halt(404) unless @name + + @count = (params['count'] || 25).to_i + @queue = Sidekiq::Queue.new(@name) + (@current_page, @total_size, @messages) = page("queue:#{@name}", params['page'], @count) + @messages = @messages.map { |msg| Sidekiq::Job.new(msg, @name) } + + erb(:queue) + end + + post "/queues/:name" do + Sidekiq::Queue.new(route_params[:name]).clear + + redirect "#{root_path}queues" + end + + post "/queues/:name/delete" do + name = route_params[:name] + Sidekiq::Job.new(params['key_val'], name).delete + + redirect_with_query("#{root_path}queues/#{name}") + end + + get '/morgue' do + @count = (params['count'] || 25).to_i + (@current_page, @total_size, @dead) = page("dead", params['page'], @count, reverse: true) + @dead = @dead.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) } + + erb(:morgue) + end + + get "/morgue/:key" do + halt(404) unless key = route_params[:key] + + @dead = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first + + if @dead.nil? + redirect "#{root_path}morgue" + else + erb(:dead) + end + end + + post '/morgue' do + redirect(request.path) unless params['key'] + + params['key'].each do |key| + job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first + retry_or_delete_or_kill job, params if job + end + + redirect_with_query("#{root_path}morgue") + end + + post "/morgue/all/delete" do + Sidekiq::DeadSet.new.clear + + redirect "#{root_path}morgue" + end + + post "/morgue/all/retry" do + Sidekiq::DeadSet.new.retry_all + + redirect "#{root_path}morgue" + end + + post "/morgue/:key" do + halt(404) unless key = route_params[:key] + + job = Sidekiq::DeadSet.new.fetch(*parse_params(key)).first + retry_or_delete_or_kill job, params if job + + redirect_with_query("#{root_path}morgue") + end + + get '/retries' do + @count = (params['count'] || 25).to_i + (@current_page, @total_size, @retries) = page("retry", params['page'], @count) + @retries = @retries.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) } + + erb(:retries) + end + + get "/retries/:key" do + @retry = Sidekiq::RetrySet.new.fetch(*parse_params(route_params[:key])).first + + if @retry.nil? + redirect "#{root_path}retries" + else + erb(:retry) + end + end + + post '/retries' do + redirect(request.path) unless params['key'] + + params['key'].each do |key| + job = Sidekiq::RetrySet.new.fetch(*parse_params(key)).first + retry_or_delete_or_kill job, params if job + end + + redirect_with_query("#{root_path}retries") + end + + post "/retries/all/delete" do + Sidekiq::RetrySet.new.clear + + redirect "#{root_path}retries" + end + + post "/retries/all/retry" do + Sidekiq::RetrySet.new.retry_all + + redirect "#{root_path}retries" + end + + post "/retries/:key" do + job = Sidekiq::RetrySet.new.fetch(*parse_params(route_params[:key])).first + + retry_or_delete_or_kill job, params if job + + redirect_with_query("#{root_path}retries") + end + + get '/scheduled' do + @count = (params['count'] || 25).to_i + (@current_page, @total_size, @scheduled) = page("schedule", params['page'], @count) + @scheduled = @scheduled.map { |msg, score| Sidekiq::SortedEntry.new(nil, score, msg) } + + erb(:scheduled) + end + + get "/scheduled/:key" do + @job = Sidekiq::ScheduledSet.new.fetch(*parse_params(route_params[:key])).first + + if @job.nil? + redirect "#{root_path}scheduled" + else + erb(:scheduled_job_info) + end + end + + post '/scheduled' do + redirect(request.path) unless params['key'] + + params['key'].each do |key| + job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first + delete_or_add_queue job, params if job + end + + redirect_with_query("#{root_path}scheduled") + end + + post "/scheduled/:key" do + halt(404) unless key = route_params[:key] + + job = Sidekiq::ScheduledSet.new.fetch(*parse_params(key)).first + delete_or_add_queue job, params if job + + redirect_with_query("#{root_path}scheduled") + end + + get '/dashboard/stats' do + redirect "#{root_path}stats" + end + + get '/stats' do + sidekiq_stats = Sidekiq::Stats.new + redis_stats = redis_info.select { |k, v| REDIS_KEYS.include? k } + + json( + sidekiq: { + processed: sidekiq_stats.processed, + failed: sidekiq_stats.failed, + busy: sidekiq_stats.workers_size, + processes: sidekiq_stats.processes_size, + enqueued: sidekiq_stats.enqueued, + scheduled: sidekiq_stats.scheduled_size, + retries: sidekiq_stats.retry_size, + dead: sidekiq_stats.dead_size, + default_latency: sidekiq_stats.default_queue_latency + }, + redis: redis_stats + ) + end + + get '/stats/queues' do + json Sidekiq::Stats::Queues.new.lengths + end + + def call(env) + action = self.class.match(env) + return NOT_FOUND unless action + + resp = catch(:halt) do + app = @klass + self.class.run_befores(app, action) + begin + resp = action.instance_exec env, &action.block + ensure + self.class.run_afters(app, action) + end + + resp + end + + resp = case resp + when Array + resp + when Fixnum + [resp, {}, []] + else + type_header = case action.type + when :json + WebAction::APPLICATION_JSON + when String + { WebAction::CONTENT_TYPE => action.type } + else + WebAction::TEXT_HTML + end + + [200, type_header, [resp]] + end + + resp[1][CONTENT_LENGTH] = resp[2].inject(0) { |l, p| l + p.bytesize }.to_s + + resp + end + + def self.helpers(mod=nil, &block) + if block_given? + WebAction.class_eval(&block) + else + WebAction.send(:include, mod) + end + end + + def self.before(path=nil, &block) + befores << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block] + end + + def self.after(path=nil, &block) + afters << [path && Regexp.new("\\A#{path.gsub("*", ".*")}\\z"), block] + end + + def self.run_befores(app, action) + run_hooks(befores, app, action) + end + + def self.run_afters(app, action) + run_hooks(afters, app, action) + end + + def self.run_hooks(hooks, app, action) + hooks.select { |p,_| !p || p =~ action.env[WebRouter::PATH_INFO] }. + each {|_,b| action.instance_exec(action.env, app, &b) } + end + + def self.befores + @befores ||= [] + end + + def self.afters + @afters ||= [] + end + end +end diff --git a/lib/sidekiq/web_helpers.rb b/lib/sidekiq/web/helpers.rb similarity index 91% rename from lib/sidekiq/web_helpers.rb rename to lib/sidekiq/web/helpers.rb index 0f89cdd7..398740b8 100644 --- a/lib/sidekiq/web_helpers.rb +++ b/lib/sidekiq/web/helpers.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true require 'uri' +require 'yaml' module Sidekiq # This is not a public API @@ -45,21 +46,13 @@ module Sidekiq # # <% end %> # - def add_to_head(&block) + def add_to_head @head_html ||= [] - @head_html << block if block_given? + @head_html << yield.dup if block_given? end def display_custom_head - return unless defined?(@head_html) - @head_html.map { |block| capture(&block) }.join - end - - # Simple capture method for erb templates. The origin was - # capture method from sinatra-contrib library. - def capture(&block) - block.call - eval('', block.binding) + @head_html.join if @head_html end # Given a browser request Accept-Language header like @@ -69,7 +62,7 @@ module Sidekiq def locale @locale ||= begin locale = 'en'.freeze - languages = request.env['HTTP_ACCEPT_LANGUAGE'.freeze] || 'en'.freeze + languages = env['HTTP_ACCEPT_LANGUAGE'.freeze] || 'en'.freeze languages.downcase.split(','.freeze).each do |lang| next if lang == '*'.freeze lang = lang.split(';'.freeze)[0] @@ -251,5 +244,23 @@ module Sidekiq "#{redis_connection}#{namespace_suffix}" end end + + def retry_or_delete_or_kill(job, params) + if params['retry'] + job.retry + elsif params['delete'] + job.delete + elsif params['kill'] + job.kill + end + end + + def delete_or_add_queue(job, params) + if params['delete'] + job.delete + elsif params['add_to_queue'] + job.add_to_queue + end + end end end diff --git a/lib/sidekiq/web/router.rb b/lib/sidekiq/web/router.rb new file mode 100644 index 00000000..d34c4342 --- /dev/null +++ b/lib/sidekiq/web/router.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true +require 'rack' + +module Sidekiq + module WebRouter + GET = 'GET'.freeze + DELETE = 'DELETE'.freeze + POST = 'POST'.freeze + PUT = 'PUT'.freeze + PATCH = 'PATCH'.freeze + HEAD = 'HEAD'.freeze + + ROUTE_PARAMS = 'rack.route_params'.freeze + REQUEST_METHOD = 'REQUEST_METHOD'.freeze + PATH_INFO = 'PATH_INFO'.freeze + + def get(path, &block) + route(GET, path, &block) + end + + def post(path, &block) + route(POST, path, &block) + end + + def put(path, &block) + route(PUT, path, &block) + end + + def patch(path, &block) + route(PATCH, path, &block) + end + + def delete(path, &block) + route(DELETE, path, &block) + end + + def route(method, path, &block) + @routes ||= { GET => [], POST => [], PUT => [], PATCH => [], DELETE => [], HEAD => [] } + + @routes[method] << WebRoute.new(method, path, block) + @routes[HEAD] << WebRoute.new(method, path, block) if method == GET + end + + def match(env) + request_method = env[REQUEST_METHOD] + path_info = ::Rack::Utils.unescape env[PATH_INFO] + + @routes[request_method].each do |route| + if params = route.match(request_method, path_info) + env[ROUTE_PARAMS] = params + + return WebAction.new(env, route.block) + end + end + + nil + end + end + + class WebRoute + attr_accessor :request_method, :pattern, :block, :name + + NAMED_SEGMENTS_PATTERN = /\/([^\/]*):([^\.:$\/]+)/.freeze + + def initialize(request_method, pattern, block) + @request_method = request_method + @pattern = pattern + @block = block + end + + def matcher + @matcher ||= compile + end + + def compile + if pattern.match(NAMED_SEGMENTS_PATTERN) + p = pattern.gsub(NAMED_SEGMENTS_PATTERN, '/\1(?<\2>[^$/]+)') + + Regexp.new("\\A#{p}\\Z") + else + pattern + end + end + + def match(request_method, path) + case matcher + when String + {} if path == matcher + else + if path_match = path.match(matcher) + params = Hash[path_match.names.map(&:to_sym).zip(path_match.captures)] + end + end + end + end +end diff --git a/myapp/Gemfile b/myapp/Gemfile index 615a1228..495a0c87 100644 --- a/myapp/Gemfile +++ b/myapp/Gemfile @@ -3,7 +3,6 @@ source 'https://rubygems.org' gem 'pry' gem 'sidekiq', :path => '..' gem 'rails', '5.0.0' -gem 'sinatra', github: 'sinatra/sinatra' platforms :ruby do gem 'sqlite3' diff --git a/sidekiq.gemspec b/sidekiq.gemspec index 9a91478d..624a9c36 100644 --- a/sidekiq.gemspec +++ b/sidekiq.gemspec @@ -18,7 +18,7 @@ Gem::Specification.new do |gem| gem.add_dependency 'redis', '~> 3.2', '>= 3.2.1' gem.add_dependency 'connection_pool', '~> 2.2', '>= 2.2.0' gem.add_dependency 'concurrent-ruby', '~> 1.0' - gem.add_dependency 'sinatra', '>= 1.4.7' + gem.add_dependency 'rack-protection', '~> 1.5' gem.add_development_dependency 'redis-namespace', '~> 1.5', '>= 1.5.2' gem.add_development_dependency 'minitest', '~> 5.7', '>= 5.7.0' gem.add_development_dependency 'rake', '~> 10.0' diff --git a/test/test_web.rb b/test/test_web.rb index 152b2cce..1e7e2e6e 100644 --- a/test/test_web.rb +++ b/test/test_web.rb @@ -3,10 +3,8 @@ require_relative 'helper' require 'sidekiq/web' require 'rack/test' -require 'tilt/erubis' class TestWeb < Sidekiq::Test - describe 'sidekiq web' do include Rack::Test::Methods @@ -341,7 +339,6 @@ class TestWeb < Sidekiq::Test assert last_response.body.include?( "<a>hello</a>" ) assert !last_response.body.include?( "hello" ) - # on /queues page params = add_xss_retry # sorry, don't know how to easily make this show up on queues page otherwise. post "/retries/#{job_params(*params)}", 'retry' => 'Retry' @@ -374,7 +371,7 @@ class TestWeb < Sidekiq::Test before do Sidekiq::Web.settings.locales << File.join(File.dirname(__FILE__), "fixtures") Sidekiq::Web.tabs['Custom Tab'] = '/custom' - Sidekiq::Web.get('/custom') do + Sidekiq::WebApplication.get('/custom') do clear_caches # ugly hack since I can't figure out how to access WebHelpers outside of this context t('translated_text') end @@ -387,6 +384,7 @@ class TestWeb < Sidekiq::Test it 'can show user defined tab with custom locales' do get '/custom' + assert_match(/Changed text/, last_response.body) end end @@ -564,6 +562,7 @@ class TestWeb < Sidekiq::Test Sidekiq.redis do |conn| conn.zadd('retry', score, Sidekiq.dump_json(msg)) end + [msg, score] end @@ -596,6 +595,7 @@ class TestWeb < Sidekiq::Test Sidekiq.redis do |conn| conn.zadd('retry', score, Sidekiq.dump_json(msg)) end + [msg, score] end @@ -611,4 +611,51 @@ class TestWeb < Sidekiq::Test end end end + + describe 'sidekiq web with basic auth' do + include Rack::Test::Methods + + def app + app = Sidekiq::Web.new + app.use(Rack::Auth::Basic) { |user, pass| user == "a" && pass == "b" } + + app + end + + it 'requires basic authentication' do + get '/' + + assert_equal 401, last_response.status + refute_nil last_response.header["WWW-Authenticate"] + end + + it 'authenticates successfuly' do + basic_authorize 'a', 'b' + + get '/' + + assert_equal 200, last_response.status + end + end + + describe 'sidekiq web with custom session' do + include Rack::Test::Methods + + def app + app = Sidekiq::Web.new + + app.use Rack::Session::Cookie, secret: 'v3rys3cr31', host: 'nicehost.org' + + app + end + + it 'requires basic authentication' do + get '/' + + session_options = last_request.env['rack.session'].options + + assert_equal 'v3rys3cr31', session_options[:secret] + assert_equal 'nicehost.org', session_options[:host] + end + end end diff --git a/web/views/dashboard.erb b/web/views/dashboard.erb index 36601eb3..8e3ab142 100644 --- a/web/views/dashboard.erb +++ b/web/views/dashboard.erb @@ -58,14 +58,14 @@ <% end %> - <% if @redis_info.fetch("used_memory_human", nil) %> + <% if @redis_info.fetch("used_memory_human", nil) %>

<%= @redis_info.fetch("used_memory_human") %>

<%= t('MemoryUsage') %>

<% end %> - <% if @redis_info.fetch("used_memory_peak_human", nil) %> + <% if @redis_info.fetch("used_memory_peak_human", nil) %>

<%= @redis_info.fetch("used_memory_peak_human") %>

<%= t('PeakMemoryUsage') %>

diff --git a/web/views/dead.erb b/web/views/dead.erb index a987fde5..8eb151bd 100644 --- a/web/views/dead.erb +++ b/web/views/dead.erb @@ -1,4 +1,4 @@ -<%= erb :_job_info, :locals => {:job => @dead, :type => :dead} %> +<%= erb :_job_info, locals: { job: @dead, type: :dead } %>

<%= t('Error') %>

diff --git a/web/views/layout.erb b/web/views/layout.erb index b4d82dfc..65175099 100644 --- a/web/views/layout.erb +++ b/web/views/layout.erb @@ -2,6 +2,7 @@ <%= environment_title_prefix %><%= Sidekiq::NAME %> + @@ -26,7 +27,7 @@
- <%= erb :_footer %> + <%= erb :_footer %> <%= erb :_poll_js %> diff --git a/web/views/morgue.erb b/web/views/morgue.erb index 1f1cd482..dce046bc 100644 --- a/web/views/morgue.erb +++ b/web/views/morgue.erb @@ -4,7 +4,7 @@ <% if @dead.size > 0 && @total_size > @count %>
- <%= erb :_paging, :locals => { :url => "#{root_path}morgue" } %> + <%= erb :_paging, locals: { url: "#{root_path}morgue" } %>
<% end %> <%= filtering('dead') %> diff --git a/web/views/queue.erb b/web/views/queue.erb index 3e041ee3..78b906de 100644 --- a/web/views/queue.erb +++ b/web/views/queue.erb @@ -8,7 +8,7 @@
- <%= erb :_paging, :locals => { :url => "#{root_path}queues/#{@name}" } %> + <%= erb :_paging, locals: { url: "#{root_path}queues/#{@name}" } %>
@@ -42,4 +42,4 @@ <% end %>
-<%= erb :_paging, :locals => { :url => "#{root_path}queues/#{@name}" } %> +<%= erb :_paging, locals: { url: "#{root_path}queues/#{@name}" } %> diff --git a/web/views/retries.erb b/web/views/retries.erb index 3deac5a0..8f761baf 100644 --- a/web/views/retries.erb +++ b/web/views/retries.erb @@ -4,7 +4,7 @@ <% if @retries.size > 0 && @total_size > @count %>
- <%= erb :_paging, :locals => { :url => "#{root_path}retries" } %> + <%= erb :_paging, locals: { url: "#{root_path}retries" } %>
<% end %> <%= filtering('retries') %> diff --git a/web/views/retry.erb b/web/views/retry.erb index 3e34bf98..c9d763b6 100644 --- a/web/views/retry.erb +++ b/web/views/retry.erb @@ -1,4 +1,4 @@ -<%= erb :_job_info, :locals => {:job => @retry, :type => :retry} %> +<%= erb :_job_info, locals: { job: @retry, type: :retry } %>

<%= t('Error') %>

diff --git a/web/views/scheduled.erb b/web/views/scheduled.erb index 9d3f008e..2eb089b5 100644 --- a/web/views/scheduled.erb +++ b/web/views/scheduled.erb @@ -4,7 +4,7 @@
<% if @scheduled.size > 0 && @total_size > @count %>
- <%= erb :_paging, :locals => { :url => "#{root_path}scheduled" } %> + <%= erb :_paging, locals: { url: "#{root_path}scheduled" } %>
<% end %> <%= filtering('scheduled') %> diff --git a/web/views/scheduled_job_info.erb b/web/views/scheduled_job_info.erb index fdbb8bbc..4f532616 100644 --- a/web/views/scheduled_job_info.erb +++ b/web/views/scheduled_job_info.erb @@ -1,4 +1,4 @@ -<%= erb :_job_info, :locals => {:job => @job, :type => :scheduled} %> +<%= erb :_job_info, locals: { job: @job, type: :scheduled } %>
<%= csrf_tag %>