From 8e49033ed106f0dd6b3c5f6e8d4d30d5bb9698fc Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Thu, 8 Feb 2018 12:16:13 -0800 Subject: [PATCH 001/299] tab index composer actions add styling for :focus --- .../discourse/templates/components/composer-action-title.hbs | 3 ++- app/assets/javascripts/discourse/templates/composer.hbs | 2 +- app/assets/stylesheets/common/select-kit/composer-actions.scss | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/components/composer-action-title.hbs b/app/assets/javascripts/discourse/templates/components/composer-action-title.hbs index 6075400d90..0019b1d179 100644 --- a/app/assets/javascripts/discourse/templates/components/composer-action-title.hbs +++ b/app/assets/javascripts/discourse/templates/components/composer-action-title.hbs @@ -5,7 +5,8 @@ composerModel=model options=model.replyOptions canWhisper=canWhisper - action=model.action}} + action=model.action + tabindex=tabindex}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/composer.hbs b/app/assets/javascripts/discourse/templates/composer.hbs index beb60b375a..17813a59c5 100644 --- a/app/assets/javascripts/discourse/templates/composer.hbs +++ b/app/assets/javascripts/discourse/templates/composer.hbs @@ -16,7 +16,7 @@ {{plugin-outlet name="composer-open" args=(hash model=model)}}
- {{composer-action-title model=model canWhisper=canWhisper}} + {{composer-action-title model=model canWhisper=canWhisper tabindex=8}} {{#unless site.mobileView}} {{#if whisperOrUnlistTopicText}} diff --git a/app/assets/stylesheets/common/select-kit/composer-actions.scss b/app/assets/stylesheets/common/select-kit/composer-actions.scss index 70c37dae9a..c205aa11dd 100644 --- a/app/assets/stylesheets/common/select-kit/composer-actions.scss +++ b/app/assets/stylesheets/common/select-kit/composer-actions.scss @@ -14,7 +14,7 @@ margin: 0!important; } - &:hover { + &:hover, &:focus { background: $primary-low; } } From 925d1a78697dfb8e9549e0b9ba4f7e7f18235cfe Mon Sep 17 00:00:00 2001 From: Blake Erickson Date: Thu, 15 Feb 2018 16:20:48 -0700 Subject: [PATCH 002/299] FEATURE: add rake task for import/export of site settings --- lib/tasks/site_settings.rake | 63 ++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 lib/tasks/site_settings.rake diff --git a/lib/tasks/site_settings.rake b/lib/tasks/site_settings.rake new file mode 100644 index 0000000000..2d1d19ac03 --- /dev/null +++ b/lib/tasks/site_settings.rake @@ -0,0 +1,63 @@ +require 'yaml' + +class SiteSettingsTask + def self.export_to_hash + site_settings = SiteSetting.all_settings + h = {} + site_settings.each do |site_setting| + h.store(site_setting[:setting].to_s, site_setting[:value]) + end + h + end +end + +desc "Exports site settings" +task "site_settings:export" => :environment do + h = SiteSettingsTask.export_to_hash + puts h.to_yaml +end + +desc "Imports site settings" +task "site_settings:import" => :environment do + yml = (STDIN.tty?) ? '' : STDIN.read + if yml == '' + puts "" + puts "Please specify a settings yml file" + puts "Example: rake site_settings:import < settings.yml" + exit 1 + end + + puts "" + puts "starting import..." + puts "" + + + h = SiteSettingsTask.export_to_hash + counts = { updated: 0, not_found: 0, errors: 0 } + + site_settings = YAML::load(yml) + site_settings.each do |site_setting| + key = site_setting[0] + val = site_setting[1] + if h.has_key?(key) + if val != h[key] #only update if different + begin + result = SiteSetting.set_and_log(key, val) + puts "Changed #{key} FROM: #{result.previous_value} TO: #{result.new_value}" + counts[:updated] += 1 + rescue => e + puts "ERROR: #{e.message}" + counts[:errors] += 1 + end + end + else + puts "NOT FOUND: existing site setting not found for #{key}" + counts[:not_found] += 1 + end + end + puts "" + puts "Results:" + puts " Updated: #{counts[:updated]}" + puts " Not Found: #{counts[:not_found]}" + puts " Errors: #{counts[:errors]}" +end From 49ad98305010093971df767d12bfb4b07742f986 Mon Sep 17 00:00:00 2001 From: Blake Erickson Date: Thu, 15 Feb 2018 16:54:22 -0700 Subject: [PATCH 003/299] fix extra blank line --- lib/tasks/site_settings.rake | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/tasks/site_settings.rake b/lib/tasks/site_settings.rake index 2d1d19ac03..710c9b1e07 100644 --- a/lib/tasks/site_settings.rake +++ b/lib/tasks/site_settings.rake @@ -31,7 +31,6 @@ task "site_settings:import" => :environment do puts "starting import..." puts "" - h = SiteSettingsTask.export_to_hash counts = { updated: 0, not_found: 0, errors: 0 } From 790c5facc946bd35f569afca3a23241c205c8693 Mon Sep 17 00:00:00 2001 From: SidV Date: Fri, 16 Feb 2018 03:35:37 -0300 Subject: [PATCH 004/299] Mailgun typo (#5593) mailgun = Mailgun --- config/locales/server.en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 27e31c7540..8fbb0f57d2 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -961,7 +961,7 @@ en: email_polling_errored_recently: one: "Email polling has generated an error in the past 24 hours. Look at the logs for more details." other: "Email polling has generated %{count} errors in the past 24 hours. Look at the logs for more details." - missing_mailgun_api_key: "The server is configured to send emails via mailgun but you haven't provided an API key used to verify the webhook messages." + missing_mailgun_api_key: "The server is configured to send emails via Mailgun but you haven't provided an API key used to verify the webhook messages." bad_favicon_url: "The favicon is failing to load. Check your favicon_url setting in Site Settings." poll_pop3_timeout: "Connection to the POP3 server is timing out. Incoming email could not be retrieved. Please check your POP3 settings and service provider." poll_pop3_auth_error: "Connection to the POP3 server is failing with an authentication error. Please check your POP3 settings." From 93b1829f04eb87189b7f30b4cf00a6a886429056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Fri, 16 Feb 2018 11:21:11 +0100 Subject: [PATCH 005/299] tiny refactor --- lib/oneboxer.rb | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/oneboxer.rb b/lib/oneboxer.rb index 88fd33245a..338ec2b2a8 100644 --- a/lib/oneboxer.rb +++ b/lib/oneboxer.rb @@ -179,17 +179,15 @@ module Oneboxer return unless Guardian.new.can_see_topic?(topic) end - post = nil post_number = route[:post_number].to_i - if post_number > 1 - post = topic.posts.where(post_number: route[:post_number].to_i).first - else - post = topic.ordered_posts.first - end + + post = post_number > 1 ? + topic.posts.where(post_number: post_number).first : + topic.ordered_posts.first return if !post || post.hidden || post.post_type != Post.types[:regular] - if route[:post_number].to_i > 1 + if post_number > 1 excerpt = post.excerpt(SiteSetting.post_onebox_maxlength) excerpt.gsub!(/[\r\n]+/, " ") excerpt.gsub!("[/quote]", "[quote]") # don't break my quote From 9bb7c3dcf0db32861413f981fac9431ca5f1f15e Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Fri, 16 Feb 2018 21:32:25 +0530 Subject: [PATCH 006/299] bump onebox version --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index cf25046564..04ad8b67b1 100644 --- a/Gemfile +++ b/Gemfile @@ -36,7 +36,7 @@ gem 'redis-namespace' gem 'active_model_serializers', '~> 0.8.3' -gem 'onebox', '1.8.38' +gem 'onebox', '1.8.39' gem 'http_accept_language', '~>2.0.5', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 09d9ff3bb3..76775fb758 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -228,7 +228,7 @@ GEM omniauth-twitter (1.3.0) omniauth-oauth (~> 1.1) rack - onebox (1.8.38) + onebox (1.8.39) fast_blank (>= 1.0.0) htmlentities (~> 4.3) moneta (~> 1.0) @@ -461,7 +461,7 @@ DEPENDENCIES omniauth-oauth2 omniauth-openid omniauth-twitter - onebox (= 1.8.38) + onebox (= 1.8.39) openid-redis-store pg (~> 0.21.0) pry-nav From 61930e092a6edce634f9b2148c0db2d4691f9583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Fri, 16 Feb 2018 18:14:56 +0100 Subject: [PATCH 007/299] FIX: support incoming emails with just an attachment --- lib/email/receiver.rb | 16 +- spec/components/email/receiver_spec.rb | 7 +- spec/fixtures/emails/attached_pdf_file.eml | 1158 ++++++++++++++++++++ 3 files changed, 1176 insertions(+), 5 deletions(-) create mode 100644 spec/fixtures/emails/attached_pdf_file.eml diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb index 19389398ca..bb5576a5ad 100644 --- a/lib/email/receiver.rb +++ b/lib/email/receiver.rb @@ -238,11 +238,13 @@ module Email text_content_type = @mail.text_part&.content_type elsif @mail.content_type.to_s["text/html"] html = fix_charset(@mail) - else + elsif @mail.content_type.blank? || @mail.content_type["text/plain"] text = fix_charset(@mail) text_content_type = @mail.content_type end + return unless text.present? || html.present? + if text.present? text = trim_discourse_markers(text) text, elided_text = trim_reply_and_extract_elided(text) @@ -690,11 +692,17 @@ module Email raise InvalidPostAction.new(e) end + def is_whitelisted_attachment?(attachment) + attachment.content_type !~ SiteSetting.attachment_content_type_blacklist_regex && + attachment.filename !~ SiteSetting.attachment_filename_blacklist_regex + end + def attachments # strip blacklisted attachments (mostly signatures) - @attachments ||= @mail.attachments.select do |attachment| - attachment.content_type !~ SiteSetting.attachment_content_type_blacklist_regex && - attachment.filename !~ SiteSetting.attachment_filename_blacklist_regex + @attachments ||= begin + attachments = @mail.attachments.select { |attachment| is_whitelisted_attachment?(attachment) } + attachments << @mail if @mail.attachment? && is_whitelisted_attachment?(@mail) + attachments end end diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb index 3c5017aec3..485dd6add4 100644 --- a/spec/components/email/receiver_spec.rb +++ b/spec/components/email/receiver_spec.rb @@ -210,7 +210,6 @@ describe Email::Receiver do expect { process(:reply_with_8bit_encoding) }.to change { topic.posts.count } expect(topic.posts.last.raw).to eq("hab vergessen kritische zeichen einzufügen:\näöüÄÖÜß") - end it "prefers text over html" do @@ -390,6 +389,12 @@ describe Email::Receiver do expect(topic.posts.last.raw).to_not match(/text\.txt/) end + it "supports emails with just an attachment" do + SiteSetting.authorized_extensions = "pdf" + expect { process(:attached_pdf_file) }.to change { topic.posts.count } + expect(topic.posts.last.raw).to match(/discourse\.pdf/) + end + it "supports liking via email" do expect { process(:like) }.to change(PostAction, :count) end diff --git a/spec/fixtures/emails/attached_pdf_file.eml b/spec/fixtures/emails/attached_pdf_file.eml new file mode 100644 index 0000000000..91c2a5e13d --- /dev/null +++ b/spec/fixtures/emails/attached_pdf_file.eml @@ -0,0 +1,1158 @@ +Date: Fri, 16 Feb 2018 17:45:22 +0100 +From: Foo Bar +To: reply+4f97315cc828096c9cb34c6f1a0d6fe8@bar.com +Message-ID: <100@foo.bar.mail> +Content-Type: application/pdf; name="discourse.pdf" +Content-Disposition: attachment; filename="discourse.pdf" +Content-Transfer-Encoding: base64 + +JVBERi0xLjQKJdPr6eEKMSAwIG9iago8PC9DcmVhdG9yIChNb3ppbGxhLzUuMCBcKFdpbmRvd3Mg +TlQgMTAuMDsgV2luNjQ7IHg2NFwpIEFwcGxlV2ViS2l0LzUzNy4zNiBcKEtIVE1MLCBsaWtlIEdl +Y2tvXCkgQ2hyb21lLzY0LjAuMzI4Mi4xNjcgU2FmYXJpLzUzNy4zNikKL1Byb2R1Y2VyIChTa2lh +L1BERiBtNjQpCi9DcmVhdGlvbkRhdGUgKEQ6MjAxODAyMTYxNjQ0NDMrMDAnMDAnKQovTW9kRGF0 +ZSAoRDoyMDE4MDIxNjE2NDQ0MyswMCcwMCcpPj4KZW5kb2JqCjIgMCBvYmoKPDwvVHlwZSAvWE9i +amVjdAovU3VidHlwZSAvSW1hZ2UKL1dpZHRoIDExODgKL0hlaWdodCA1NzUKL0NvbG9yU3BhY2Ug +L0RldmljZVJHQgovQml0c1BlckNvbXBvbmVudCA4Ci9GaWx0ZXIgL0RDVERlY29kZQovQ29sb3JU +cmFuc2Zvcm0gMAovTGVuZ3RoIDIyMDQ3Pj4gc3RyZWFtCv/Y/+AAEEpGSUYAAQEAAEgASAAA/9sA +hAALCwsLCwsUCwsUHBQUFBwmHBwcHCYwJiYmJiYwOTAwMDAwMDk5OTk5OTk5RUVFRUVFUFBQUFBa +WlpaWlpaWlpaAQ4PDxcVFycVFSdeQDRAXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5e +Xl5eXl5eXl5eXl5eXl5eXl7/wgARCAI/BKQDASIAAhEBAxEB/8QAGwABAAIDAQEAAAAAAAAAAAAA +AAIDAQQFBgf/2gAIAQEAAAAA+uAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAADS+dAeht8yB6z01env5UxnaAB5L419F+l+B09/t38X13zD02n1+Vu6e/s6 +dFPJ9V6OYAAAAAAAAAAAAAc75F6bTlp7nM9Jd5D0HK2tXe5HtfZ8fo6XUcijY6wAHnvn/G+u7/zf +mT3Op7z5ft8bq73jPQ9Lzmp6bZ0vPfcgAAAAAAAAAAAAAc75F296jm9Xjehu873uN0NfW0/Zez5d +bsOVRsdQAAqzYjIQnGQYyVeb9SAAAAAAAAAAAAADT+fAd+3zYHqfSGtsgAAAAAAAAAAAAAAAAAAA +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 +yrUBcwgLstVdlqrstVdlqrstVdlqrstVbPRfTcS5bRSfUItXZaq7LVXZaq7LVXZaq7LVXZaq2em6 +nN3cLQTP/EchSFIUhSFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFc +FcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFc +FcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFc +FcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFcFIUhSFIUhSFI +/wCdnbSMmYpu3n42/wAJj21G3NTnBokrtB0CZWDsDhuc4NbcU3aKbv5/6nc5wYLnJ+3wYAVLb2vM +OEeX7UJpgDCXBcRzrGu8TXwV2v4sLZy1VEvNWoHZAp5ezaHVRk2JHouNYajs8RC7UQHZEgTgqtRw +NjflJTKtaGMABJbKftLmOxtziNVdzn/yf9KnVqN8eReQqby+7oDCZSbUfUv+ZNqvZTIzh1slDaTb +dhg6DCY+97ho3BGtV5nMAtYmEv2m/S0R91tThApzFxzVOv8AgDV02qq/aAwzAxGIXFLS6BzSAnV6 +lO4PAkCcEypU4nDqAYicPOalQtMBU6heYKftNOm4tM4LtNOSMcF2gGLfmgyhtVIycYCp1m1ZjTru +2h1tP6oINjNbHc15GhVYy/6bqnjVM3MBW21ahqcFiFHaMQAcFslY1qMnNba83hipsZwpe2ZTqdr8 +MFsbi6jB09jUcQIbmmGWg9+oS1shNLnSJVz7ZnVU3Ez3mP0PUoOlw+6N04IXOYDMKncRJ9wqM4gA +6GU+gH1G1ciENncOUO5Z+6bTtqOfPiQpw9z/AJkNkAaQDrI9FwHOa4Pdn0Qouuve6cIVOiWFpJm0 +QuyugsDsJnJcDGZ+K5VKdlJ7c7jh9VSZw6YYuDUDnFj4u9F2aA2w4txxTqfDpvvN1/pqqDOHTDTn +qjQdzBroa7NNpBj7h0A/hcIGrxHY4QnbNJJaYmD9wjQe5pudif8ApGhMmcZBR2cuuL3YnBWfiCp0 +Eec1KZcZCp0ywyU+jUqVX42tcAjs3K9oPiTdlt1+K7Jdizl2Y0VGjwp9fSN1Zl7ICa19xnJcMLZ6 +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 +j+lI/pSP6Uj+lItySQkXlp/Skf0pH9KR/Skf0pH9KR/SkMl7J6JaE9w36tLjZ1BNRP8AzJHNl8GS +F7j1ipLQmkqymZ0jLYg0lQU5eR51S5hgW5EeMhBD4XwW34xcitSRv7CEE31S+fzCMNtInWBWrFq2 +EiewciaKqI1zSalhhlgcOepLKNxJM070NCZjByMN4w1RMzTYTlfReTIF7YpMY9x7k3GJbGqOT9qf +kPR0VyH/AKC0TEkOt5FeNo7N7Y9tlpUkqbrWGfXAmvTPwGSPIog/IvjGnBD5JcUQKSPGB1GhKjfY +a9iPyF6Sy0CaQdxvx65PMvc97/zQwq3t8DFmXlRbJtnGPCGA3AtOpseP0/A6ossHLYl97uldKcTi +tSiLzr/s7ivuHRoXMbI2TafI0nRnB8BqGx+D+iZ+B/0fj/5Qq+r/AKH9D3L1E4m8egKlLx9A3V2P +po3eA9v/AL/P7V/IelM8dpz7jhNLG+hcrqanZhCGNFhhZVbr8kxpRJRLr0z8BiEKsGtzPkpZNYnT +EKOr2YzYbeM7HdcgwLKfkL0+/YfIQqnx6k3Tv0UCp+Q44skwxdesVsz8L2FfSK2Z+Lv4FZtYwzkg +tyftXac0dK+50r7nSvudK+50r7nSvudK+5opaHLxp0r7nSvudK+50r7nSvudK+50r7i9efwJzbX/ +ABH3Hcdx3Hcdp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2na +dp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nad +p2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp2nadp +2nadp2nadp2ncdx3Hcdx3HcRERERERERERERERERERERERERERERERERERERERERERERERERERER +ERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERER +EREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREREX7hVbRh +ybqIEB9T9PLpgGGx6OWhI0D8PQbWRI/n3/6PrDF+nucnAqIMFT7Lz9Tx8TLXVg2FQT7DxW1e6c/Q +ardk08ZREOaRvU8wYVKuj2N7AjbGBYOZ94oW84JZ0OOuhuv/AEPWmgfmbHfTkLLXBaKrrii8jtEy +Q7SPlDUayZeXyOWzGnXlzcHbDI2wgk/rnUiWxqcsX1WKRGLIOOsaNVlc4baEDUsnBpuGBTAr8frM +eLQ6IWjMGhuKpJjTnucRfq0kzoX4CLWsOEpaeySPPo5s6D1GQtkVXTfqNYgtipNBrAztetec/wDk +Q8js4hm0xhN4Spy8G6U4jweUGf4edD+BjTbXxtITDWZhJtThOceRTTse/iY3uv4Bs2v9GRZ6TFHy +U5Gpbde3yGUJD7BdHTns0PzSe6UdmChyciGjsJT2Gb1V9podImswiX0G83vFTZlAnWDwRfsW3LOX +x6eFIOoPOVLKbJ0Es+/JjVtWsholkHSWt7plZ1ZYDe0vc9m65qSvO4xwdhyh9EBNaEZYkRa5BS3h +MYW2h+pdFzTSgjpLVJYSTo7t2R7/AKzXi0OkNETgDxaaI2n7QuT8WEaUEnaFhPuZxtN+Caejm7Fk +zpLKMY8SwO8MIDyclNiaW0aZpoa60Lt0RFDY4xyi3meBaH9TWl5f4cwmZsUpePj3acibWAhRxHqO +div4UVvGhUMCuzyVJ2y/n08vqixj91OIyE9qJJKIiiod8iMysvz6NuHSUdKPaeiG4hiXUx95q+p9 +95r/AJDhERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERER +ERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERER +ERERERERERERERERERERERERERERERERERERF+6HVsr2KbEvImmqvR2TzUvv/wDBDs+FtJoSPtyT +PNV49UyZZWsP2/TGbbVWlYuWU4U42XsKHje7Nh+SPYW0s5VZgxYNNtQbGlUpOExaxJ+SoKbBjK5/ +IhuFY+BEbjYvBguVVXw1wyWrDqPYva+pdGlMg3LOicXFf1P+SFDlYXykKBajY+hbNtMEc1A5ESjZ +iC2A+50JW55A5VjciRV7ionKHwYUu2m5we2aL8ieaj2xuiGonDwfRXE1Nca8CUrGVOPwzNEKq8ju +WqM50NbpWJEKk7TiciGQOxwJriJWXFOo6+mUVZUS6EZx949hqtTK4jvI0Ohjl92Muwyn2/RVYlES +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 +P6RP6RP6RP6RP6RP6RCSCiU3myCAIQ21rU/pE/pE/pE/pE/pE/pE/pEGgFiU3pf0GFSanX/yjbbV +f8M8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8 +wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzD +PMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8 +wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzD +PMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8wzzDPMM8w/4NtttxTinFOKcU +4pxTinFOKcU4pxTinFOKcU4pxTinFOKcU4pxTinFOKcU4pxTinFOKcU4pxTinFOKcU4pxTinFOKc +U4pxTinFOKcU4pxTinFOKcU4pxTinFOKcU4pxTinFOKcU4pxTinFOKcU4pxTinFOKcU4pxTinFOK +cU4pxTinFOKcU4pxTinFOKcU4pxTinFOKcU4pxTinFOKcU4pxTinFOKcU4pxTinFOKcU4pxTinFO +KcU4pxTinFOKcU4pxTinFOKcU4pxTinFOKcX+waTIgKVg+N5QtLdVJ8Kz9OluHmWKo6HV9ppQOXM +PnZps+mqqn3ouJNNYW/k9qmuYVDVLDqQc7eyx3gfKYusPccn3giWZH8uaqWZSW1FjUOMXoewZis4 +LdVuBzW0XQAArVhovpWXmCwH1RFsG9LiPgxsudK9DqyhrIESEPUS5TyFSlNDp7xt8cxgVlxao94F +AI1aFrAsN6ekQy3vkZFvGvtpCBCtCVq1V7sEpcH9d1HfEs4rgqFHFJGC7FMzCtV6Yl7b3LgVdygw +4AUdVF9lhMywBDm8GCul6xmCN02sdNLesIxnDqJW/B8yr8k5xbQrsmblZc0gAKOo3h4Ye4eWored +ANpeYIzohnIjKZKro1SCrNc6n5yTYG2b5gkAWxe8ClCldAKmhDBYbapkEyb3iVy7QyBacVo/aWFQ +3tCpp9+msSHX4QMhrZ9BB0oubOv2liVVNSgUEfgrEI4aQOAbHtHbpgH6zQ94QCijT2mYFqn4xGO0 +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 +22xRFldhyk0tgizQBtdTjKnWCtuWSjQNGpiEKGZNVLYKHoTFMQp6LbaQaAZZKB1ShMYiDp6gNCzj +EVXz0N0JYldKl1AG3eRtCYhwNZattUcS4D8robDtHyC3qDramVeJYTJFKCm0LF5JdlULoiFSmxq5 +UCBE9qSUBsauDyOStFLoGzOkeBY29SSsQsUWoVt0kpUw3LruAqolo6VNNalZ2VDsmT7xFn5CeYBR +VsrKtaa6Df5KarqHTdlCI06bwpg/cUbzg3LQC6w0AjY0tcwzRybaohFQtuNJMBY2omRYxnLBTBQN +oOq18Y9FZLSg2LH7MfTXR9qiuG0yNl31slgKqlChXpArA4yHTp+9RJ0UQovNi9SLM+QgewDmC0DV +9ZU4HPkhIuBEFA+0vlw2qaVedGJQLFWMYR+8xeNYphkekMDkre8RoTZ0Ye0Hi72ot3ZKelXrmMKb +Jk1p+priFaGesQm0ta0z9Y1Zu1tbYsGa8UEWhWKxrBM6qL1emmkHUVK3oepw/TvmnJvKUBCkuKUc +ZVy3HSGIMKgENAr8NV7CoNd/rUCCzR6/5W16ioF9vTiVQE+8BAAaB+eIBEsZVAvtWnwwrrXGP1MQ +2FB92PAgNTUejCeDlYU0w9JdkzDqDLv7ehQGwbzKNEGws0VrrKNWRGRRpQV29CzoCF0vWASGu6bt +W1tD/wBApKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95 +TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc9 +5TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc +95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvK +c95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95Tnv +Kc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95TnvKc95Tn +vKc95TnvKc95TnvKc95TnvKc95Tnv/tAIxdL6yja9mz9fzpoqFV4IWLQR1O1K1euClVOhUzhUdAL +oLWH3iitQKlLaWhR8xg2gsKHq0NRES3omi6tDREhwOPwN8zHkN1FUusFR3gLWmi6NKv5htjWrLYL +joeGkul6y3AsAQfDtEUfCNosapZk9phWjac4v9IBsIpTZNQpSw3YRFTYahSmGLiKU2TUKUw/kkWm +w6FKWDcsJTYahSl/PrLI1ekeKqF6Sw1lkfFnAovPNaQRaHSCJZn0R9TQ94KCudUclfEptRwcb1Wk +TUKfh0+34Q2G80bE0qfrXoPY/wCwOuKlYbXQx1jUhLgwzezpDAsikfpphSq9+Y1wWoylqbLKekxE +AuOIMO6L9sXEQyWjjP3/AABxtGL3V/aCrVxNxYDAw62yHa6EaBYc3pb7RWsYIA3kxXWMvQh1NBva +/aBCi+W0VaxrpxKTEBii7D0DPtHoWI9EMdoICpqBjazVn63Ax7Q8rSHlhBroIcS6MTbi3CljlGMR +G0DW1+0soBSpQ3wzk5l5hgAxDk6r0iHAUAwCWdVmXoVgb0QzwRe5lUAu1HJxXWU+y9UC21MnFfnw +hBcNPdjgE5e8RxgJYbq5O8tWBNuSixSX+8GgwInUC0LWYouE6gt1Rq29THhoALDKAv8A30+JZoQA +HgP63CHqo97F1qsvByBJV1q/hG3kSDXMKKslPf62qFoYJZg0DNXeBTzBbXTKEsRYg1aZ+kClbv6k +cNQJELQoChiWUFA9T2wtcAp/eOI6jiyY70HudX/YwhCoaZa0t+pkViUxVRAV3hdjfviXqlqkUYaw +a5jWh1L5R+TCmLo4/wDJ6d5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvK +d5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd +5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5 +TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5T +vKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd5TvKd/9oAtAczQZ9n86w69A +BrKqWvRVwF2xpA3xsZFYa1lO59SV3gYJ6JK7wMAm5r7RvWCmnOixAhmwz7Sgzulgua5ImcOoS+0E +IsaFlxBATQWXc6iYXXWJBgDZOrtE7EtLriAGAuwYrGEf7g4KEEFTWnWAukkF4UW3ftMpWMgHqyRo +iNjVG3p6HQMr8Ri0Q1BrxrCFxdMLdvzleiHlitEvD6sAkqLdXY+gn6UsVf6EzJmhsir3Dc/CUdqv +Q1hl7HR+tw9BBXiNVjeeIK9AacN37S3F1h+l+lBBADRz6IjqUTGhs11mWtbq6RPwDENBn2BLpKpC +A5PViBfrIlY2O0EwQCDrYo6CXUb0bR6qZroJpGBEUKwKl8CxIAhMmpg7S7uOZJZwjI7xGAjLmqyM +6/eETYqCU10KzUuVUFXd71M3+sWSgwOCjsEKOTMLT+wygFhdrNe/vBcW20cJlpoyU5zWC+sCAGoc +AMA0b7wUaWDGM1T95k6gHKtsNjAI5J0CjhGpmEVaJTjTWDY8LFA7VUfU0VVGl0pPaOyeVzYp7TQ9 +vznR9n7zyOYkVfTK6aQYEoNAQW6dPtHWEQvUXVBSEBZSy0G+qMg0i5c8FVprOUP+ehUVsv0/TQOZ +0G/wgdfBfMABWr9/rso5Lds6/EebNo6P/INJ00a8V9ozX4U70Z+kkYgtemxBAihFtVY+ID9u5aY+ +0pKXWkJhQDT2gTqHjgafgkHDKNJQ4fVB1lDr6UTN9BW3XLf7zW5vd7vpQQAZ0A6HoAaelF3+foWC +8kTsD2IP2IF7afUj0WMOHcI3Nl00m5tT8bRpKNPrQutq+f8AyjP/AMVgAAAAAAAAAAAAAAAAAAAA +AAUNxsX9I0KoW7VjXMAhol/4q2k6OxLePXkoPTLEuGxv9oRFDRZT9N4ylAF6avt6dbYr4L+lAVbR +dMY5/O1LobZdUmgaA3XrAabKaX7jrAQPRjR5Pwg9sPeCJZ9agWwcoibwehK3ggtWcfSBZRz6oEF1 +0iF2mNYOoF4fwPiN2WXLKlbSxj3lVNAWJz1MP6isxEQ1dFhfMCvIBbhLHAsKiQMRbOoGBLSxthRz +rxLYbVhTIc4joWsqm9UtzNlnLV3csuOxbUGNVtBO61VlwVAlVmG82yz8VFQWKCjVCywJpQq17m5F +zd4CzA6DX51into1OuYqQuoLXV16QulY2KOgtj0zp1WCwxCXW0FXNpeYq4SBdWuEZm6jbkWHzWZM +WTTVoYLiQzDVsrqa1KDSxFMVdK6ShgQATIl0zRNwLarzrxBQk0rFubcRAqttxAPOIoNXzTSrt+Yg +7gKpsWqqNVJpN21ZdPb8wTStonJtKicAZt01lG+iARWwVKtVO1V1OaUlupuFYw1hFslcjgiUOdwX +0scRi6+ddMQ2Llxaq9nPxAasYaz4Dxq7xGJeGjN/On4QM6ZNdLl83P16puh94hnFqjA8TVgLcdNI +QPrGm9Z+nCBGgbR4pC7mIHXVFmqEFp6rhl5LNc9IMQLxTY/n8COECvFYzrLFUvse0aMHUMyk5OjP +0csQOMGwrrvFlIxrDU5gd0Czo6HSB4D2tPaUOrXdGs6Fyuqxd3+sTLC2E67y3ItUhoXeeZZtUBXm +w6R+JqFaxWouqQBEwUY6be0DAYFmkDwBpw09oqkKVpWrvOnFir61FRVNLJkoWKMaHEqkGzmCgzaN +L2hisK2zV3miDTQOmSZFFil3InoGEaVcDRBhRjQ2jRw9QlJqssKwJAqsm7rN/mC9NsVs0sT2MFCX +VbREiNrX3lPUpvAanWOhHBoM3BUJdaK9MPtMOz0ildl4DnprcDLXQLGZ82p12PwhVEfeABRgPrAU +WMBRFOpKsThBaaOPp6Bl6w+gxalawpUCtPQBUNdYAooP9qqQGyXFLZ4A/ObdpbtLdpbtLdpbtLdp +btLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpb +tLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbt +LdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtL +dpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLdpbtLd +pbtLdpbtLdpbtLdpbtLdv/IxbYW1n1RJQarNcnrn1GmpOslpdcf4yVADTW/+jKBwBCNCr0uBbDgk +0KJqbfnPjN5fsEAvS2rjSpqiFifsxL9SxeWnaLVqwANUhgwkUGV9+kTq1dvzDYkbb92GOi3F8OMw +QMDcLO4mKiRm6aoZ4rpANKrWGELshfnKPdhY5F1AAYB6bsVhAOocaPETYqWJBvbEp4EKQC+u6CVc +0pqpd56RKNBqb7woLJoXTj/sHjR3Quq6m8KCWDXf88qSoqNxXiVogEusMfnAWtTERxsBU4bL6kwO +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 +IFBwIjBCoSNAQWGQkbHw/9oACAECAQE/ANHR1bZGNc0yF8LKFFLMAINpEwAEWN49sYItphwaW5/Z +W0syAopZwFtDGCmS0ebPVtkqNa03FE0j6vdB1ICJTXUmmQ5Go3j3TgnGi5wcTiiaJ+vug6kBFy2i +ow0y1p7CgSiQBJTdqpuNoO8kBAz8wOX/AH86HXvt8GaoX2+PPkDYEBbbPDwUQz9KnNouz3WmbkAb +p0MROBTaFNpkDVR6LWc4NlmaolxbLs+aPNk3JpfOPcyq8tbIVJ5c2To+OyNR9olU33Cd7jAlZiVT +MjHRXCCAgQOyD3WiUx1wnRRAOaAjLsq50CU10id7RJhVKbvSoIwOhSAc0BGW8GMVxRCJkz2QJhAz +ooiUBHZqUNCkIDkhD8OX/8QALhEAAgIBAgMFBwUAAAAAAAAAAQIAEQMSIQQTYBBQYXChFDFBQoCR +4SAwQHGQ/9oACAEDAQE/APITj8jKw0moefQOo/eas4NEmM+ZRZY1/cGRvZdV7/mK+ZlLhjQ8Zqz3 +QJgbOSRZ+84TJkOUKxPQfHYndgVFxRnHy+kZc5OrTHTOw0lfSDE/s2it/wAxBnVSgXY+EUcQPl9I +y5ySQtThcWQZQzjyF1CEgCzF4nGxoHtAJhFfyD3SO1vdtBjIWhOL1cuVSRL0i/fAYGFVGIqh0LUI +vYxcCKbA7VYqbHU3x7nH6r/doaYQlbeVQ7n+HnAos1PCZBR26K5phN7n6dWNC5w+RSCWmoHdeiSL +2nKNUDEUKKH07UIRXRVwm/8AHL//2QplbmRzdHJlYW0KZW5kb2JqCjMgMCBvYmoKPDwvVHlwZSAv +WE9iamVjdAovU3VidHlwZSAvSW1hZ2UKL1dpZHRoIDM3NQovSGVpZ2h0IDUxMgovQ29sb3JTcGFj +ZSAvRGV2aWNlUkdCCi9CaXRzUGVyQ29tcG9uZW50IDgKL0ZpbHRlciAvRENURGVjb2RlCi9Db2xv +clRyYW5zZm9ybSAwCi9MZW5ndGggMTQ0MzM+PiBzdHJlYW0K/9j/4AAQSkZJRgABAQAASABIAAD/ +2wCEAAsLCwsLCxQLCxQcFBQUHCYcHBwcJjAmJiYmJjA5MDAwMDAwOTk5OTk5OTlFRUVFRUVQUFBQ +UFpaWlpaWlpaWloBDg8PFxUXJxUVJ15ANEBeXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5e +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 +w/SH2fkuRVsBLPFnn8P+1w+gLkNfi/dHoDJQZZZYx5YfsHT6uAYF0T/Z/wDJ/s/+R1aq1T6mj0VW +/wAThmvm7nXlzcKm++maWjD2WprpoHgafhlNoHnSpb4PmnmJHope2pva/mOWW1BRct4qaKIJgat8 +45l0YD+wvLWVxKUt4mBLWnb+0UazJBbIQ2fxmFZsgSmxW1/P3G8Q1yWGj3XzqYF1PlkinxdOoNDo +3Jg0e6+dTAup8skU+Lp1DAJ7RYonO/MQDXGU0uq/7jdfjMgte2PMD9BP8FOKqvPMwyhIG1/avPmL +SY8AoNHvm/r1/wBrh9Nody4TFwsRGxnjUpNRC+Xn+o0YAA1XwcZeYxHL+EuhEralvHip+4PT6N9X +E+nn38zB8sS9PKs+jYFgwTr/AO+Z1/8AfMfOQ/b1NHobbIMQHgHB8Q+WAowD/jFS0VC0+6I8ub9D +QqHUw0u2/gTD1GKM3Rb/AHDEV2hQp0UpyxFzDkjYsMrfaZz2XV0q5YIJI4BH/wC+0ZTCg2lUDXj6 +Jg8pEXA21/sQi/qoAu7zrgn8rs5Wvk5m4lPSAXd51wT+V2crXycwyLxDYMYvTQeIRuA28SsP5X8T +32hLyzljfEMncDDTjHk1zEMIu3MJrAZJrZN+aMvzv1/2uH0xkWiwcSndDGGFCxqtbx8xlfa5NK8P +MzQpqnkhhdYsdp5Z+4PRAOQYoNQ+GKMUyEs6VgfQoHVMM6H++J0P98RgBZ449TR+qQ5Bkm84n+o/ +qf6j+p/qP6n+o/qf6j+p/qP6n+o/qCBMXt/X4QDRV/kBhY+51Pudb7nW+51vudb7nW+51vudb7nW ++51vudb7nW+51vudb7nW+51vudb7nW+51vudb7nW+51vudb7nW+51vudb7nW+51vudb7nW+51vud +b7nW+51vudb7nW+51vudb7nW+51vudb7nW+51vudb7nU+4PgRl4KS/YnWnWnWnWnWnWnWnWnWnWn +WnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWnWgehELCGj1zN +HnyNB9o+wGD/ABqk+o0NqPtX8eo1LpBdgopOZaDXVazVpwdkxxgLEBooKL7mkkLAJpQNHvAdto7g +mkDQ9woJQQ8tob1KNlLMU6QKvct1KG4C0qUPVyiIoKlR0AUfLGblwKjtrNe0wZgLE8CwUX3K5rBa +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 +w9sWHmWksd7t5r8APWj1jgplbmRzdHJlYW0KZW5kb2JqCjUgMCBvYmoKPDwvVHlwZSAvQ2F0YWxv +ZwovUGFnZXMgNiAwIFI+PgplbmRvYmoKNiAwIG9iago8PC9UeXBlIC9QYWdlcwovQ291bnQgMQov +S2lkcyBbNyAwIFJdPj4KZW5kb2JqCjcgMCBvYmoKPDwvVHlwZSAvUGFnZQovUmVzb3VyY2VzIDw8 +L1Byb2NTZXRzIFsvUERGIC9UZXh0IC9JbWFnZUIgL0ltYWdlQyAvSW1hZ2VJXQovRXh0R1N0YXRl +IDw8L0cwIDggMCBSPj4KL1hPYmplY3QgPDwvWDAgMiAwIFIKL1gxIDMgMCBSPj4KL0ZvbnQgPDwv +RjAgOSAwIFIKL0YxIDE0IDAgUgovRjIgMTkgMCBSPj4+PgovTWVkaWFCb3ggWzAgMCA2MTMgNzkz +XQovQW5ub3RzIFs8PC9UeXBlIC9Bbm5vdAovU3VidHlwZSAvTGluawovRiA0Ci9Cb3JkZXIgWzAg +MCAwXQovUmVjdCBbMzcuMDA1NzY4IDc0MS41ODM4NiA5Ny4wNDkwMjYgNzU5Ljk5NzEzXQovQSA8 +PC9UeXBlIC9BY3Rpb24KL1MgL1VSSQovVVJJIChodHRwczovL3d3dy5kaXNjb3Vyc2Uub3JnLyk+ +Pj4+IDw8L1R5cGUgL0Fubm90Ci9TdWJ0eXBlIC9MaW5rCi9GIDQKL0JvcmRlciBbMCAwIDBdCi9S +ZWN0IFs0NTEuMzA0MjYgNzQ2LjM4NzMzIDQ3Ny4zMjMgNzU1LjE5MzY2XQovQSA8PC9UeXBlIC9B +Y3Rpb24KL1MgL1VSSQovVVJJIChodHRwczovL3d3dy5kaXNjb3Vyc2Uub3JnL2ZlYXR1cmVzKT4+ +Pj4gPDwvVHlwZSAvQW5ub3QKL1N1YnR5cGUgL0xpbmsKL0YgNAovQm9yZGVyIFswIDAgMF0KL1Jl +Y3QgWzQ4NS4zMjg3NyA3NDYuMzg3MzMgNTIwLjE1Mzg3IDc1NS4xOTM2Nl0KL0EgPDwvVHlwZSAv +QWN0aW9uCi9TIC9VUkkKL1VSSSAoaHR0cDovL21ldGEuZGlzY291cnNlLm9yZy8pPj4+PiA8PC9U +eXBlIC9Bbm5vdAovU3VidHlwZSAvTGluawovRiA0Ci9Cb3JkZXIgWzAgMCAwXQovUmVjdCBbNTI4 +LjE1OTY3IDc0Ni4zODczMyA1NDYuMTcyNjEgNzU1LjE5MzY2XQovQSA8PC9UeXBlIC9BY3Rpb24K +L1MgL1VSSQovVVJJIChodHRwOi8vdHJ5LmRpc2NvdXJzZS5vcmcvKT4+Pj4gPDwvVHlwZSAvQW5u +b3QKL1N1YnR5cGUgL0xpbmsKL0YgNAovQm9yZGVyIFswIDAgMF0KL1JlY3QgWzU1NC4xNzg0MSA3 +NDYuMzg3MzMgNTc2LjE5NDI3IDc1NS4xOTM2Nl0KL0EgPDwvVHlwZSAvQWN0aW9uCi9TIC9VUkkK +L1VSSSAoaHR0cHM6Ly9kaXNjb3Vyc2Uub3JnL2J1eSk+Pj4+IDw8L1R5cGUgL0Fubm90Ci9TdWJ0 +eXBlIC9MaW5rCi9GIDQKL0JvcmRlciBbMCAwIDBdCi9SZWN0IFsyNzEuMTc0NSA1OTMuMDc2ODQg +MzQyLjAyNTU0IDYxMy4wOTEzMV0KL0EgPDwvVHlwZSAvQWN0aW9uCi9TIC9VUkkKL1VSSSAoaHR0 +cHM6Ly9kaXNjb3Vyc2Uub3JnL2J1eSk+Pj4+XQovQ29udGVudHMgNCAwIFIKL1BhcmVudCA2IDAg +Uj4+CmVuZG9iago4IDAgb2JqCjw8L2NhIDEKL0JNIC9Ob3JtYWw+PgplbmRvYmoKOSAwIG9iago8 +PC9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMAovQmFzZUZvbnQgL09wZW4jMjBTYW5zCi9FbmNv +ZGluZyAvSWRlbnRpdHktSAovRGVzY2VuZGFudEZvbnRzIFsxMCAwIFJdCi9Ub1VuaWNvZGUgMTMg +MCBSPj4KZW5kb2JqCjEwIDAgb2JqCjw8L1R5cGUgL0ZvbnQKL0ZvbnREZXNjcmlwdG9yIDExIDAg +UgovQmFzZUZvbnQgL09wZW4jMjBTYW5zCi9TdWJ0eXBlIC9DSURGb250VHlwZTIKL0NJRFRvR0lE +TWFwIC9JZGVudGl0eQovQ0lEU3lzdGVtSW5mbyA8PC9SZWdpc3RyeSAoQWRvYmUpCi9PcmRlcmlu +ZyAoSWRlbnRpdHkpCi9TdXBwbGVtZW50IDA+PgovVyBbMCBbNjAwLjA5NzY2IDAgMCAyNTkuNzY1 +NjNdIDE1IFsyNDUuMTE3MTkgMCAyNjYuMTEzMjhdIDE4IDM1IDU3MS43NzczNCAzNiBbNjMyLjgx +MjUgMCA2MzAuODU5MzggNzI5LjAwMzkxIDU1Ni4xNTIzNCA1MTYuMTEzMjhdIDQ2IFs1MTkuMDQy +OTcgMCAwIDAgNjAyLjA1MDc4IDAgNjE4LjE2NDA2IDU0OC44MjgxMyA1NTMuMjIyNjYgMCAwIDky +NS43ODEyNV0gNjcgWzU1Ni4xNTIzNCA2MTIuNzkyOTcgNDc2LjA3NDIyIDYxMi43OTI5NyA1NjEu +MDM1MTYgMzM4Ljg2NzE5IDU0Ny44NTE1NiA2MTMuNzY5NTNdIDc1IDc4IDI1Mi45Mjk2OSA3OSBb +OTMwLjE3NTc4IDYxMy43Njk1MyA2MDQuMDAzOTEgNjEyLjc5Mjk3IDAgNDA4LjIwMzEzIDQ3Ny4w +NTA3OCAzNTMuMDI3MzQgNjEzLjc2OTUzIDUwMC45NzY1NiA3NzcuODMyMDMgMCA1MDMuOTA2MjUg +NDY3Ljc3MzQ0XSA5NyBbMjU5Ljc2NTYzXSAxOTUgWzUwMCAwIDAgMTY5LjkyMTg4XSAyMTAgWzU5 +MS43OTY4OF1dCi9EVyAwPj4KZW5kb2JqCjExIDAgb2JqCjw8L1R5cGUgL0ZvbnREZXNjcmlwdG9y +Ci9Gb250TmFtZSAvT3BlbiMyMFNhbnMKL0ZsYWdzIDQKL0FzY2VudCAxMDY4Ljg0NzY2Ci9EZXNj +ZW50IDI5Mi45Njg3NQovU3RlbVYgNDUuODk4NDM4Ci9DYXBIZWlnaHQgNzEzLjg2NzE5Ci9JdGFs +aWNBbmdsZSAwCi9Gb250QkJveCBbLTU0OS44MDQ2OSAtMjcwLjk5NjA5IDEyMDQuMTAxNTYgMTA0 +Ny44NTE1Nl0KL0ZvbnRGaWxlMiAxMiAwIFI+PgplbmRvYmoKMTIgMCBvYmoKPDwvRmlsdGVyIC9G +bGF0ZURlY29kZQovTGVuZ3RoIDYzOTQKL0xlbmd0aDEgOTY4OD4+IHN0cmVhbQp4nN06aXhURban +7tJbet+ThuR2LglLAiF0QhK2NEm6kxCUEAjTHRhMIGBAVsMywiCgIhBQBH0MizqCTAYQ5QYxBgYR +RgWjIIMODDKO8lxAfIjLuMyD5Oadut2dBNR53/v7bqX6Vp2qOufU2erUBSAAEAcrgIWB8+6dNq/w +5c27AAy9AIh2Ts3saalDRp4HENuwf3F2zW/mMYsYHbbduEqYNXdqzSdff34AwPhXhB2pm73gNwff +Dq/Bdi4AO6uublqN4ZRqD879Dmsv7NbqDk+dh+0jWAffPeu+6V+9kj4SwPwYgG3W9Hl3z76/ptkC +wOMc9cGpixYIpjvcyI9xIM7fOXV2zbz9hhefAuAQBqeA8k7i//6d+eK2u0zDvtdoNUCf1uzMdfR9 +Lu3UuZvb2s9pd2qmYFcLDEQeXKd5pH03gvbd3CYv1e5UMHV/3lcg/4AGcMIE4HGlGTJgEi41wr9Q +VoQ7DcfpRM4OwNAqwhY2G1bxaeDjHocHVZuhjv8bzCdvwCpmMlRgHc7NhAk4Vkd+gHzmcRjPeGEL +8zXYETYd6xGstVgnY03Dugrrwmi/DutMZb4X8qP9xfTNzgWPOhPu480otQxo5fWwjD8HrVw9Vi/2 +38P+FWhlxI5q7irCekOrOg9aVRqsQ2EZdzb6/hbHamEmNxusuOYw9xpqoA483NOg4ZbiPjfiHnZC +I/LrwrePmwCZ7OaONu5pshZpTeaugMSegXp813PLoJ5php7cFOiNNCVGBTsZVcdGzqe0JfV8kCic +O6fMl+gatgjXn8U9vgdJOLaLQ12p8sDFZSIODTDsUahgNSjDOnId3yV07zG5Y/soViqXpViT6Bzc ++1LkLVu1G2qZcxBkb0CFsgblTmEcdNxgZ8NyBXYSMrF6lb18BxI/HOZTWZOzkILw0SxAAa4vVw2H +MqwDsCag3H2KzH+mqto6ZKoHRQfdKn8SMmJyv71SfdM3lX33qsj+E1x/A+VE5fwzVfUPmKzIftmt +FWV+EeW9B98vYr3GvQHzO+V+e6U2Rd9U9t0ryl7REb4V3U2BZeq1iOcI0aO85uJbwwFUoOdVkEkQ +r1Rq36chnlZ2OFYGdqmCaLtz0YbmQjnRyfdxLVE7PoJ2ghXlbFcvhJ608rOxPxRa0e9e7DiJv9Rj +WcUj9cDBMXyng4AtPZRAKdwPb8MZ6CCZZBxzgrnIfCDYhQQhUUgWUoUhQpGwQNiXbO3ooBELZ4+C +ZzBy/AVnV0Rn2wS30EOZndc1u+MT2NDxccdxMHS80rG+Y1b7jfYr7ZcvSZeev7Tv0p5LjZeeuLTu +UvWlXh+++pPY8W8ff/HEqnCocvy4irHlY+68Y3TZqNKS4mCgqLBgpD9/xPBhQ4fk5eYMzs4cmDGg +f3qf3qkpvcRkb5LbbjGbjIY4nVajVvEcyxBIFyRSHZDYFMESrBEDYk1J/3Qh4K4r6p8eEIPVklAj +SPjiUsWSEgUk1khCtSCl4qumG7ha8uPM6bfN9Edm+jtnErMwDIZREqIgnS4ShRZSNTaE7UeKxLAg +fam071DaXKrSMWDH68UVCleUWyEgBRfVNQSqkUfSFKcrFAun6fqnQ5MuDptx2JL6iPOaSJ8RRGkw +fQJDmhjQGChZ3GmgplYqHxsKFHm83nD/9FLJKBYpQ1CooJRUhZJaQSnMoKzDOqEp/VjD+hYzTKlO +09eKtTWTQhJbg2sb2EBDw2rJkib1FYukvks+dePOp0npYlFASqNYyyo66ZR1kSQSn2IWhYbvAbcj +fnntVkhNFKJKMX8PtCkxhRKpCHnp4wmirBsagqIQbKhuqGnpWDFFFMxiQ5Ne3zAvgOKG8hCiaOk4 +vM4jBdeHJXN1HRkSjm49WFEm2cZODElMSlCoq0EI/uWL3lyP19I5p/yXhtFtC1E4KGGvl4phXYsf +pmBHWjE2FOkLMMVzAPwZaWGJqaYjx2Ijjko6siI20rm8WkTdlo0LNUhcSmmtGECJr6uRVkxB65pJ +FSOaJeMPHq/YYLUIeRlhZa6AXJXWzhAkPhWFhKu6L0C7oUsazErH+EPk9aUHCaRarEKeiGgonoAY +qI7+LapzIwIBBV2SFjGE8SHJX4QNf01UY4GmgRm4oqYaFTajSFGmlCHOk+xiQad2KVuBGeNCypLo +MsleKEH11OgqKSOg+JUQaKguirBAcYljQ4fA13GpKUvwvOiDLAgX0cnOQrSy1EBDqHa6lFTtqUW/ +my6EPF7JH0YNh8XQtDA1O5RQ30sexTjCiq2MD5WNE8vGVoVyo4xEBig6LiVwGxox5ImgQQOUNCka +IcR42DBONCNACGJDLBiGv5I6RYPVjAJXoNRwC4YJIeKB2GxkQ+orBKYVRefR/i1IeWpOhSUxbCra +RTyFJR5v2Bt5+qczOCxECeMKDRVqSWwIwxQOaNA+C0sUEJWlmxq9EBKniWGxTpD85SG6NyoeRcpR +YSgyj+pq/C29bsJCMYEXh2MdKkwpmObpLlypWOl3dktuGy6NDQsNGrFsXANFLkYRYuKSUioBNWF/ +rsWjxALq0CLGXsGMLq04dEOT30+duW4IRSKW1jaI40LDlNkYT5Z5llBaVigjZeML+qdjaCtoEsma +sU1+smZcVegQpg7CmvGhAwxhCqsLwk29cCx0SMBDQ4EyFEqBtCPQDsVUgR2NMt9zyA+wQhnlFIDS +n9pCQIFpYjACU1uYCMwcIZSqEPJjlju1hYuM+GOzOYRpIrAVCkx5moCKzK/j/Rq/1q9nDIyniVDQ +AYQcxhNRS+BFPTEQTxOuqlDALWRFk9bvicxYgTP8EQ7XVHaRrqwKvagHXKb8IqEC+qC5uOtQ2Xis +BIRaaii/Ddc1VIeps4ETVYN/RCLiCFSTOAIZUeklnTitQIoTCyg8n8LzI3AVhavRRImT4PIVqPty +iVALmBjyoksKCa2eBvOXVFNhDCoN5s/6+7dqvSVCpUASK5NK2MqkRGJKzE/cn8jeUZaaNLrMl1QW +TElKzTJXpvh6VcbbOpLUXEeSiu1IGlXqSyrFMZvPWskTtpLz4WqWmNh8dj/LFgfjk/4rSERfcmUP +n6fS6XNUWoip0uwzVZpMY0xMkumMiTGZOkyMCs/7SuKDyrmwHPbDV8CZgaxwEp60kMeaxo9LSytr +UXfgEaEtnyiRNVLKOPrrH1slqdZIUFk1MdREyKPhVY88AgU9y6RB40KS0DNcJtViw9yzyQkF4fr6 +tLTJ9QsWptFnQVr9grTuj9J1T8b8RWUHF9zJN+PvQr6ZP31rdsOVgB02AnRco72uX9lOf3GVre2J +ji/lVR1fyHsQ4paP/F9yp64ncs+DdbAS72hbYAM8ClthDawkJryxgZLp/bsyAkt1tOzsLK//tBAO +SwG5nxwkH8cKA9EyAst65hLLKaXkZ8ou9gY3mGvgLvBufh//hcqtmoBljuqEGtQF6i1YTmkSNZM1 +T2pe15qxjNIu+n9V1itl7y+U5p8rKFi8V3M6tBUW1OB4mWN5hmPVABmDMnwkw5fhyxxo81q8OViP +s6VtL9cx97Wv5ptvjKrjLis3hXz5X2Q2XEcjsbzEg1aH94eM0xYrycvLHJjiUjFqcbA1h0w2JUzJ +XuYZabjuqpV/vLeODJqEa7d0fELWwrcQB25/nArvGwYtOyasdUJ+GsVgQeI5WYN9g5wOu0pMTt0y +PG/IyII8X+HMwkCgcGQwn9KvRf5T+LchHtL8LjdR28BmJFyCx6wrC5vVxD06TNyID9wKThcipWj7 +kmwygsnOShWTVereERKMmji8XEpbPlkpzct99P6Jz9aG3rr+zhdPnpePMV9vIA8e2PLYuIVrh42Z +v/u9A+vkr/8in1Q8gwC6KpeGMlRBkt9MeB4v0GoNy4wOsypA4p2UMwd6iTfb6yCkJzO57RP2dPte +vufWVTfORL6V8Gb+CGrBCHn+RMYYpyE81QrLqTm8jjCl4TjeaOR4Fjgr5PvyrXkZ1jxFTsqeLFYX +StzLelmR+LSETe2twqXVze3NB/cxBRuYfHnaXq/o7LuPnJUz+CM3ipiZ5I0Jy6rr5aHKHpbhjfUM +ytEJqX47a7RrjazLbYXSsJWLUyFt261CzBxIksFiBt+gHIdKFMCSZe3lG+RSp7JDv5GvEsN/b319 +y0fyK/KOPST/wuV9JY28T35Vvir/p3wy5z/yyBoy42MyvmX8pjsB996Kew+h/NCGoI/fbuS0eBBa +bbxhVJhnOeOoMN1xjHpeVIXEDH2JxSsAawavaPEJiENeLG+Q7yHHSCVZchBpffbjGTKQDGKuypvl +lXyz/LD8R5JIkm/OI2l0z0iX/RfSjYMh/iSVTkc40BBOb1BpS8MqFWEYvjTMsERXGiZdDECeO+NW +U7J4Hd5IZf/VdpbNaF/KTG7fyazim38n993cHvGTGC0tDPInoK0TVkNYXdxtpKwRa7mdBu4vSoPU +740RQPTtV3/XHTeP9mdk0RhVasICWxqGKNtRsWUOpIbidbTupczd+HRbRPZcncKXFbL8CTqeVxnR +kG12HTcqrNOp1GrrqLCaVd3GWdToqB7sHLKHDArEN2gwulQa4erks/LlfXvJUqZXe9zWj9862nqU +03/4Vftxvrnds2nHxkepuUdon0HaenBAiT/FQAij11rUcTqdWstwTpfWAAYYFTYYGJZ1lIZZNo5R +TL+LD8jrlFInPwovnMPME5GkxFhCuZGNmCER+SJ5pFV+Wj5z9eDu5175B1Pd/gzf/M4Z+cPp7XOZ +6k0bNmxcoehrJvoDg/7QC6XiSbTpAdw2nktJ1SeyTqenNOzkWFQda+tmF3ndNIZSEYXe2YkRmQwg +vQeQbBrK0EMw5DjsicSVSDhG/vxruW191V/r9u4bvmHTqf3y2YsvZ7/03OotuavWXn6erDp+oXBX +avrK+tE1FVmlb+7Y82b5E6MX3D26ZmxmxZFI7LGiDKtQhmro5bcCJiksq9Gi56P2uZg9ga8rAKFw +MAJ5mXV75UyuRM7kk36n2JAHQD0a8cQjHnO8zpPgMBp5rduiY4lWEbrPZ1WUHgntXpHgptDOMNrY +nK7BOcRH6Mvm49/YLb/vyCLOgfLHu+WlB68MdHqyieogsQ302rIvH2TfG3nC/tBTbT6+ue2+o9te +3s/e17Z8+2uPnmJXUZuY3HGNPc1VUT6g2J+SrIlLTIyPt2rYlFTCxCUWh5m4OHA4TMEwajghGOad +aDz53Z0zr9uGo+7TWzQSPECyzSk5SsxX9x5BaMy3pAwaPJyojcRhd7IVXk3Fs0uePcTYDs9dsvZ5 +39jjNa+/Ihu3NzWe2D/7ybtLd28no82qopVLxy9PH/TCsXb7wr1bp6rVs+urJiPfEtrLQszXbJAE +Rf5knctlMul7snpW8BpA77BadBYVqJBhlRPswTCYbzEcnzs/v5tXKRY0yGoxM8h1b1GN4c1iV/sG +u6jpOCM2zbxw4atvzt/TNEwvLm7UaBac2rt5296tmzdzVfL78rdYzo2peERllx9ePm3XuteuXDn5 +0dnz70Zsph5lvI6bFIn1JvQtLet2qQzImxmsyJvz9ljPR2O906UeQETBQnnAuM9Mvy7fJNrvxzzd +35fzwCD5wLPPrH1sjp2kED2xkfRk1yPOnvKEty4MfTxP8Xeky+WgjKwoowK/t6fKBUajRWURvFaH +CRkjelarRRFpzawtGGadvywiRUIpImYFjMVsRbfyZRCEi4o+syIu5mS3aes/+9tXX5//aLFBzTWu +lp/eu3X73k3btz3+R5JKTFjSd465gxz972uLD70jXj35yZl3z3fyaUX52CABhvqT3DpXHMuiJ/Tw +uOKCYZcLVCq7IizjLcLqHrV93cVm7Um8NBLkOIyExYTDi9pb/k/5CuE/eOfrdgN/aHfT86Gnnnzo +KSMzfL2d9CFqoiW58jcfzjj+5qgnUr3sZ/u2PPXHiO56Yqw3qpIw88/z97Tr9TaGUbM863Dq0CV0 +GADUfDBsU5tY6hU+6gpdxyYVnBWdl3gxRorZOdlmbzQgIYPM0/LVxtdfJzW/WphWXTS5irjYk215 +7MmyYcPJE+KqpGUNxUgaest2LhNl0w9yYCTM8Y9Ii89N0ScN5zNtxMYz/ZJ7JKXE6woKe5iyTdnB +sGZocViXrOln0pg0zn79mOJwP1Of/OJwH7NzQHHY6YkKr1PN7owMC8aZtF+I7Q51IlGSwd5UwUMJ +zds4L3p21uAcJcLmxOIsiflJGklW2WgnOws3ymW+3SP9g2OZaTNHVf35wKvyB/Lnf7u6YkG/PH+g +8p4LJyYEZMvm9Wdb52w5Of/+qgcW/POHhfdzJTPc4vziHcc0uZX90zZvaH71mU21mxJs5dnDqvqJ +u2cdfM1+E8KTlt0TDsxih9Uvuvbj/agnCeNyEdq6E3z+eKNeo9GCU+t0uY1aq5ULhq1mHV7bHTSw +5sf2GvN+GrEsESO2RD0/Gq7Yp5c88PzvGxs1usyXFrS2Mm88/NCR8+2voZf3rcwdM/HVv7RnU/vd +iYYynf8ItWUC0W9BShwhZotBXRI2MCZCLeOdaIYdSy98bLdEm0xobDyY26/PkCF9+uVyJaRvXvbg +3NycHMTdsVG2K7j1mNmm+522uDiDRhOf4DSXhJ1+rQnQ+iCq1YRbiNiSe2crKsEdmbtTS7traKDw +ztIuirI9frW98ldc202zfET96xhxRaZkAsqUBSSF4YLjGVB2E7st0I1Q5nEOROar3kZbFSHoFx0W +ixWVIGqtItsrpYfT4bDGc4Z4jHpJ8WabCfMfqg1f1GnQCN0ZPt8tFniLWrr046LaEVwxHalS5i3a +/kTjvMVPbmxc7dFkPDeTkDGazMOLDx9iWh988MCh9ifp+0/n2o9zJZvLqw5PqH31Xaq3qM0gv3bI +9LvBTo3GrnU69FqzGU3GbNaZfslkbrUYV3d72fcMZcPXMv/Em9ReDp9X6I4NK0QjsW4K0qT6xNzb +Bga8LifE65wYTcys+ZYw3D33RsUx2TS4QaenUfeaIn97/fFPf0v01y8TU9sru599ds+ePzzbyKTI +38nvNRDmeTwa0uR35JvvfvD3986+H4m3EsaUhcq+vZDvF+LjOLVak2TVWJNFLg5MJkcwbDJrTRoP +9OgKuPldSVen4ygxFw9MZzcx0FOUBt1uByc9NPWNq10a/945F7/85vruzcy2vY/u2GEfU1E9QR6u +ytpcVS6fl/9JD1H2k8Nvp1w5efmt03+P2RTbG3k1Qz+/3aDV6nSMxRpnMoDOodh+9Fy35uV3l1cs +74uxxewqHeIqylr1SuNam2b4Pm6Sfpvpwo72g1zJ2/csiOWfbD3S6QWD/T0i+Sdv68w/g2GnWcVq +O7WT8XMZKA2TsQw0tfcA5qcJKFv/6TsXl4x5afwD6+fu2Loy/+LRpj1D//Dwot/0r330tbUkbWtj +YFufAeMq/RNH5FXOKnt4e8nqolEj00fkZhc/hjwmdVxjdvNB1AjmyGa7XRuntbKc26WzmW3FYaPf +bFJDMKyOKizh9C2JGZ7fgwbTE1EU4omYnU98DnrHsTsZa3qF2zOjn/znp54KVpMR8p8nLzSolxss +ZAyzvjzwhbyyfenUmVRGu9B285SvYFn+eGJT6/U6m87h1BsMZo3dpPiMMy6WHeLJF81eu24K9lhA +EmMZooWMRpf5feNat9b30oI3T3Il7XkYZM8x/pstj4+dcPQsczpyDtPvbwzSpt8udESnN/BaYlJS +UV9eLEdWUmPfYKvNR8gzcvWBa+VGTdzCUwfkakS76NOibHIHM/Bmi4LPhbFKRHw9MKratZ6eiS6n +0cByBk6TYMWwqnF0z8F9vs48HC/6It70MQ1PINSyMAOnREcQJMpltNqyNH0SzxyTz70wc65GE5dp +bT34eq5dw4mv7pPPMg8OPbv/rvbleA+YKpeX5b2UzSxsX7dvYa/NzAcKW8gXg76pQr4c9H6BjLBO +F4fXMkwTDR4Nni/5g/K7zmqM8l3UqUCVkJ9PmANfj7FpDOMuH5D7Bv+0ZvSonKLnSoejEDacv8v3 +I/Pbm8Kh7ZZV+mNPRmhWoI/NRppa+k0Hr8kYkTjC6eJ4rjjMm4imOEyj/s9901G+rnjZ2W2fMRPa +zzBX2g8wv57PTli5su0wxUveZ19g5uP5YXkJGI5HUEZMkDZcWcFOIu9v2aLwEM8eJ0XKvcrazBON +llezkH8iOpkoH7asOSSbftdaEhT4Zif9rkV6XoCODpqjcT0wR0uFEsSlhjKmOfohdbryv1m02L4X +INqmd7h7o20GjPBQtM1CFmyKtjlIhhPRNo+R+vNoWwUCieExwngiQBHMgLuxLsC6BKZBLQhYa7Bf +g62pMBfmwX1Ij86qQ6gAe7AOgoGQibV/tJWp/Jt/Mc6ei/NmIR4BCrF9L66mvzUK/rkwBwbAGIRN +w5YA4xA+B+pRf9Nw1UJcV4NzM3EOxT0Uf0finEJsxdbEVvS/bc1PcQq3zZiAvXsRHuFC6KTyv2Gm +e16Ac4ZABpbFShmAI/OwTsXRadijO7wbR2ch9qkKtnr8rUfIaChF/gNwJ+IPKNIagDThfwCgDwMh +CmVuZHN0cmVhbQplbmRvYmoKMTMgMCBvYmoKPDwvRmlsdGVyIC9GbGF0ZURlY29kZQovTGVuZ3Ro +IDM0MT4+IHN0cmVhbQp4nF2Sy26DMBBF93yFl+ki4mEgioSQUpJILPpQaT8A7CFFKsYyZMHf1/ZN +UqmWjHQ8c8d3GIdVfazVsLDw3UyioYX1g5KG5ulqBLGOLoMK4oTJQSw38l8xtjoIrbhZ54XGWvVT +UBSMhR82Oi9mZZuDnDp6CsI3I8kM6sI2X1Vjublq/UMjqYVFQVkySb2t9NLq13YkFnrZtpY2Pizr +1mr+Mj5XTSzxHMONmCTNuhVkWnWhoIjsKllxtqsMSMl/8ZhD1vXiuzU+ndv0KEqi0tMZVHmKY9AJ +lHniCegA2nlKUk9pDDqBUMUKHGW4ge9B0OW44YBY5bwkUcxBOWjv6YgqtjvX1s1/fu/m0X2SwzJu +SVGJwx1HLEMDKb+bZDDiD0/3FH+IBjLIM9TcQZ494xDy3f5mC0bcj3cP5DFVcTXGDtS/Ij9JN8NB +0eOh6Uk7ldu/fwOyFwplbmRzdHJlYW0KZW5kb2JqCjE0IDAgb2JqCjw8L1R5cGUgL0ZvbnQKL1N1 +YnR5cGUgL1R5cGUwCi9CYXNlRm9udCAvT3BlbiMyMFNhbnMKL0VuY29kaW5nIC9JZGVudGl0eS1I +Ci9EZXNjZW5kYW50Rm9udHMgWzE1IDAgUl0KL1RvVW5pY29kZSAxOCAwIFI+PgplbmRvYmoKMTUg +MCBvYmoKPDwvVHlwZSAvRm9udAovRm9udERlc2NyaXB0b3IgMTYgMCBSCi9CYXNlRm9udCAvT3Bl +biMyMFNhbnMKL1N1YnR5cGUgL0NJREZvbnRUeXBlMgovQ0lEVG9HSURNYXAgL0lkZW50aXR5Ci9D +SURTeXN0ZW1JbmZvIDw8L1JlZ2lzdHJ5IChBZG9iZSkKL09yZGVyaW5nIChJZGVudGl0eSkKL1N1 +cHBsZW1lbnQgMD4+Ci9XIFswIFs2MDAuMDk3NjYgMCAwIDI1OS43NjU2M10gNDAgWzU2MC4wNTg1 +OV0gNTAgWzYyNy45Mjk2OV0gNjcgWzYwNC4wMDM5MSAwIDUxNC4xNjAxNiAwIDU5MC44MjAzMSAw +IDU2NC45NDE0MV0gNzQgNzggMzA1LjE3NTc4IDc5IFs5ODEuOTMzNTkgNjU3LjIyNjU2IDYxOS4x +NDA2MyA2MzIuODEyNSAwIDQ1NC4xMDE1NiA0OTcuMDcwMzFdXQovRFcgMD4+CmVuZG9iagoxNiAw +IG9iago8PC9UeXBlIC9Gb250RGVzY3JpcHRvcgovRm9udE5hbWUgL09wZW4jMjBTYW5zCi9GbGFn +cyA0Ci9Bc2NlbnQgMTA2OC44NDc2NgovRGVzY2VudCAyOTIuOTY4NzUKL1N0ZW1WIDY4Ljg0NzY1 +NgovQ2FwSGVpZ2h0IDcxMy44NjcxOQovSXRhbGljQW5nbGUgMAovRm9udEJCb3ggWy02MTkuMTQw +NjMgLTI5Mi45Njg3NSAxMzE4Ljg0NzY2IDEwNjguODQ3NjZdCi9Gb250RmlsZTIgMTcgMCBSPj4K +ZW5kb2JqCjE3IDAgb2JqCjw8L0ZpbHRlciAvRmxhdGVEZWNvZGUKL0xlbmd0aCA0MzMyCi9MZW5n +dGgxIDY4NTI+PiBzdHJlYW0KeJztOGt4U9eRc+5LsizZkizLNsL4yteWcfyQbZmHeVmRLfkhA34S +XeO6FtjEEBsMJqFA25ikNFTg0gTS7aa7+dw8HMKy4RqyRLBs82rzTkg2m81maTZu0zTfboDky9ew +WcBXO+dKNiZJ9/F9+3Pv9dwzZ2bOzJyZOXOvDAQAkmEEWCgb2tY39FLStdsATM1I/XJzeLBPumvF +7wGycEqeGwx/Z4jpZ3IQz0SCOLBlfXjx/iUCQMofkXa2f3D7dz45vPJRxBcDsAP9/X3hlELdPpRF +PuThtDc5v+FuxM8iLLx1YOeGc6k1cwHMCoC+ecPQrYN/O/zI7QD8EIBucv0d28W0kjmHUX89yh9d +PxgeOm46+ZcA3CM4fw2o7yTr/G9Xv/Hut1OXfaFP0gO9XvZljNHxnaLX3rlqnDpiGNWPomwSMBC/ +cJ1+dOoIgGH0qlHdaRjVNM2+3tMo70ME7BACHleawQ1ddClJxlgR7nV4lgpyNgCGQiPczr4Ie/ki +KOHuhZBuAVRjYFoZJ+xlTuF4CpZzXVBNeUwrVDOHYDlTj2tWQQrSGhF2IqxKgITQi1CPsCgx+qg8 +XUt1TAP7Fgi6EtjA78Wo9UCUz4Id/AWIcrsRenH+KuwQciDKjMf6+Wak7YWobj9EhT0I3SgrJMZ6 +5PVDN/cQFApGeIL3YAYwS/wa3KOK8BCUM+NwEP014ujhfKBnG2PXuPOkiXsH1vBmGOOyoQvHLu4M +dLFZUIi2BN4HY8xWOMRsjX2Pu6zhY7rzMEbp3B81+TG6hn0IxtgrOO4EN/Ie4EYBhPfAxo2BgeLs +x7CILYAcrp+8iGOrFsdE3BE/iEBpWxEETeZD2IS+ZQhHoJfF3HEXEmsw7pTGQewKO4i+0hjqwY2w +lO4F4zDGL4etNNbkkdh5pHexDqii63VGKE1AJ8Z9uRbzbwDdgzhiHrQcxOE4HTEv7um4fxXQjz0a +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 +bGF0ZURlY29kZQovTGVuZ3RoIDI4OD4+IHN0cmVhbQp4nF2RzW6DMAzH73kKH7tDBQ1QWgkhbWyT +OOxDo3sAmhgWaYQohANvvxB3TJqlRPrF/tuOHVX1Y62Vg+jdjqJBB53S0uI0zlYgXLFXmh04SCXc +jcIthtawyIubZXI41LobWVEARB/eOzm7wO5ejle8Y9GblWiV7mH3WTWem9mYbxxQO4hZWYLEzmd6 +ac1rOyBEQbavpfcrt+y95i/ishgEHvhA3YhR4mRagbbVPbIi9lZC8eytZKjlP/+JVNdOfLU2RCc+ +Oo55XK7ET4HSLFDCA2XkSynyeCDKiBKinIh06ZkoJ3ogOod+bpX5bx9b2+kTFaOaxypos5QeqVjO +bylItP5u3cI2OjFb66cWVhXGtQ5Kady2aUazqtbzAy9dk7UKZW5kc3RyZWFtCmVuZG9iagoxOSAw +IG9iago8PC9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMAovQmFzZUZvbnQgL0ZvbnRBd2Vzb21l +Ci9FbmNvZGluZyAvSWRlbnRpdHktSAovRGVzY2VuZGFudEZvbnRzIFsyMCAwIFJdCi9Ub1VuaWNv +ZGUgMjMgMCBSPj4KZW5kb2JqCjIwIDAgb2JqCjw8L1R5cGUgL0ZvbnQKL0ZvbnREZXNjcmlwdG9y +IDIxIDAgUgovQmFzZUZvbnQgL0ZvbnRBd2Vzb21lCi9TdWJ0eXBlIC9DSURGb250VHlwZTIKL0NJ +RFRvR0lETWFwIC9JZGVudGl0eQovQ0lEU3lzdGVtSW5mbyA8PC9SZWdpc3RyeSAoQWRvYmUpCi9P +cmRlcmluZyAoSWRlbnRpdHkpCi9TdXBwbGVtZW50IDA+PgovVyBbMCBbNTAwXSAxNSBbOTI4LjU3 +MTQxXV0KL0RXIDA+PgplbmRvYmoKMjEgMCBvYmoKPDwvVHlwZSAvRm9udERlc2NyaXB0b3IKL0Zv +bnROYW1lIC9Gb250QXdlc29tZQovRmxhZ3MgNAovQXNjZW50IDg1Ny4xNDI4OAovRGVzY2VudCAx +NDIuODU3MTQ3Ci9TdGVtViAyMDkuMjYzNAovQ2FwSGVpZ2h0IDY5OS43NzY3OQovSXRhbGljQW5n +bGUgMAovRm9udEJCb3ggWy0uNTU4MDM1NzMgLTE0Mi44NTcxNDcgMTI4Ni4yNzIzNCA4NTcuMTQy +ODhdCi9Gb250RmlsZTIgMjIgMCBSPj4KZW5kb2JqCjIyIDAgb2JqCjw8L0ZpbHRlciAvRmxhdGVE +ZWNvZGUKL0xlbmd0aCAyMDgxCi9MZW5ndGgxIDg0MTY+PiBzdHJlYW0KeJztWV1sHFcVPjOzM7Nr +x76TNI5cklpj13ZC5Hid4DoQVdE6ddyXYBtjQqho6o137LXi/dHuJq1DFEZISCChNE994CGK+kQQ +ChZtpD6QygqIl1IrEqUtkFgupRCgCTMDqfoj7XLu7L2zd4ctqQRCgHbsz/fcc849f/fc6x0bJACI +gg0KQDaZsX74tdQAwI6zAJETmeQzeWVA3oEatxHmUm4uCdL6DIDxY4BWI50pPXN4ufObAFIUoMVJ +p60kUVquou5PEb04TXV++509SH8HMbKwtDz/9firLQDbf47zlblMMr9r9yNraOt99HcdaCwR6ZX+ +O88/9xR59F60VQf6vPaZV/fxsVKpQEzSwI9a9lcA/alBBYkYoLwSkxg/eOSrlCP/RH4QdlYZ/iqq +lahqKDbkfcGXULAKQD1/AjxJI9HsGo/SFCqd2/UyxLJqQ5braHwNQq3yPqWxOa7dx3Uaga6LVG0n +qJ7C5r5vu2aXxdCi8jgFnYjg/765slgiUBvFfPV63hlVjF2Qa7hnGMt3EU98XG4NkGB2HqHQa/w2 +1Q5qd0asjVpfd0MN7QXTe8KHLeRRrWVC9xusvt68DoJ/2je1OtT2JMhbHHmsak0+gDV5KqgRz6Xm +j+YtizF/LGzoYHmrob032FhEvsR0IuFeQpC6uG3YrvO6M8SYzQjbS9mGhMzqxnmRUKxI7xL7COsl +8XqJPaML+Qt7lxBrEsjs6v6wvIIY0dYeRBvuwecpMLY9iGDOfUTYeRFy5bWu7+lQvwhnk8sS7Py1 +4XwPWytjjDKvCRtnaZ0QJ3C9SoH6ksZiQf02fj6RVnDsbbTfrC6b+NnnPMmuq/cI56NfheJ+50vY +u2HlGzgqEFH5OQL4rNBjPRTI96FxCPcZYjvtwVAPt4bzYH7H/DOE3xSsnvw8c71gLcYywMbgHtIZ +j+2DqduVininom5Hg7MSnFWqEw3dx/zsx6r6GfGeDs4V1O4H/T34dPg8sTj53eznFRPX1EYjWuOb +fD9i9zvv9djJY2bxm4gHBB9jLJ7PIb1F0BNjPojyzgb3QhgPIlIo/wqLezezE8NxZ4zFHgbqtVOo +dmP70dDI6yvsg09jjK3ivSzug7imjmb7y+YPaIJN1uMy7yvhvpXpGUV9jclkpP1zwc+GsM+BrWgo +hnBPRN+HmRibN6oT720xb02Q6/DPe0Osp1hL/WqdLOhH9Dfvr32pZj/Iv35PtqFeO44PNeoLdo/3 +RRvzp0PxHRZ8mTwWIbfeRnkx2904P4l2dzG+otfbpvQYO/+0HxV+xpn9QF+tQJ/Gfh+poj8biNgj +/HcVk/fF6n11hPrrH3pD4D/E10YaQPhsIYXPRQBZ6HUbhmjNWYzxQMeGfjre51EYdlQ/LUvv4ox+ +SvbwM/gNZOTxs7EKQ2DCZbgCL8LP4M3unp6unlmn3elyDjoTznEn65xzzjsXnSvOj5w1Z92565Td +drfLHXQPuhPucTfrnnPPuxfdK+7L7pq77t51y1671+Ud9Ca8417WO+ed9y56V7yXvTVv3bt7Dz+/ +o2cTvhfyCI7hdDsJZ8qZdfKO7VxwLjkrzqpzw9lwHBdcwzXdITfhTrmzbt613QvuJXfFXXVvuBuu +44FneN1ewpvyZr28Z3sXvEveirfq3fA2qMfK25WvVo5Vvlg5VNm7fvvWnVs/MNqMTUaLETOihm5o +hmpEDMWQyT3yN/JX4hGXOOQv5C65Q94lfyZ/In8kt8kfyO/JO+R35G3yW/IW2SDr5Ba5SX5Dfk1+ +Rd4kb5DXyS/Ja+QXbdfD7yb/iYe+MHG3En33kcMK1Vb4b3g0tUWPbIrGWv/dhptd/X/W1ewZ+Bew +0kQTTTTRRBNNNNFEE/+zkOn/sBRDoy9zOsD+7s3dyubuzRvSajmRly/lVfPDvAYfAn3wDbACmq3a +qNmNcwPfTxDSVu3hnp0S/ujtl40tI71mZNuWjq26tC1ilz8ony9/IOlSQdGPDI/0li+/cPPZ8kfX +crlrkip1Seq13Fnpy30yKkh6VblsDx/pl46erWnkrpU/evbmC+XLfTSKTqk/eHOIAwT/z2vFmcTi +1OEAoxXYDKOMjqDONKNV2AJPMlpDfo7ROnwBzjA6CpvgFUbHoAXeYHQrdMBbjG6HLniP/hUkQv9M +c1zaymgJtkkFRsvQLn2L0Qr0SM8xOoI6LzFahV5pjdEa8j1G6/C8HGV0FDrl7zM6Blvl64xuhd3y +64xuh0cVeCyXXy4sLqRL5ljytGU+nsymls19Q3tHBs3RpSXTFxXNglW0Cqet1OB4Llsafdoq5jLW +tLVwailZGJ+cmDkyeujA5Mz44WNTk9MzgspRq1BczGXN4cH9g0O+VUE4tWQlixaanrcKZilnltKW +WYumaM2V6NL5XMGXzONCs1RIpqxMsnDSTJZKhcUTp3yVbK60OGcVMbZCyWTGa8mkS6X8gXicrk9W +ZYOLuYbM+BKayRatODyGe5yHZSjAIixAGkrYvWOQhNNgIfU4UllIodyEffiuvxdGYBDpUVjCL1NY +VfRnFo4WjnR1CjXH0XoWpaPwtC/LQQbHacQCnEILSdQdh0mYgBk4glqHsEMnkR6Hw3AMppCe9meN +rBz1PRUxAio1YRj97UcMCbE2XjmFni30XfRzpFHP+7ZM1Mz5P9O+pFFt6Jo5pLjXeRwLwpp55pFy +CugjhdyMn+dJ5CWRW/LtncD8a1ayONLZnB9ltW4F30p95I12Ju3bzGPl4vjF/Sfr1g36nj65Zhwr +VI0m62eMN8rfAb/YINsKZW5kc3RyZWFtCmVuZG9iagoyMyAwIG9iago8PC9GaWx0ZXIgL0ZsYXRl +RGVjb2RlCi9MZW5ndGggMjIzPj4gc3RyZWFtCnicXZDPasMwDMbvfgodu0NxmnMIjJZCDvvD0j2A +YyuZYZGN4hzy9pW90EEFNsjf9xOfpc/dpSOfQH9ysD0mGD05xiWsbBEGnDypUw3O27R35baziUoL +3G9LwrmjMaimAdBfoi6JNzi8ujDgi9If7JA9TXD4PvfS92uMvzgjJahU24LDUSa9mfhuZgRdsGPn +RPdpOwrz77htEaEu/ekvjQ0Ol2gssqEJVVNJtdBcpVqF5J70nRpG+2O4uK/ZXVV1ce/vmcv/e4Sy +K7PkKUsoQXIET/jYUwwxU/ncAU0Cb2IKZW5kc3RyZWFtCmVuZG9iagp4cmVmCjAgMjQKMDAwMDAw +MDAwMCA2NTUzNSBmIAowMDAwMDAwMDE1IDAwMDAwIG4gCjAwMDAwMDAyNjUgMDAwMDAgbiAKMDAw +MDAyMjQ5OCAwMDAwMCBuIAowMDAwMDM3MTE2IDAwMDAwIG4gCjAwMDAwNDM4MTAgMDAwMDAgbiAK +MDAwMDA0Mzg1NyAwMDAwMCBuIAowMDAwMDQzOTEyIDAwMDAwIG4gCjAwMDAwNDUxNTUgMDAwMDAg +biAKMDAwMDA0NTE5MiAwMDAwMCBuIAowMDAwMDQ1MzI4IDAwMDAwIG4gCjAwMDAwNDYwMjQgMDAw +MDAgbiAKMDAwMDA0NjI2MyAwMDAwMCBuIAowMDAwMDUyNzQzIDAwMDAwIG4gCjAwMDAwNTMxNTUg +MDAwMDAgbiAKMDAwMDA1MzI5MiAwMDAwMCBuIAowMDAwMDUzNjkwIDAwMDAwIG4gCjAwMDAwNTM5 +MjkgMDAwMDAgbiAKMDAwMDA1ODM0NyAwMDAwMCBuIAowMDAwMDU4NzA2IDAwMDAwIG4gCjAwMDAw +NTg4NDMgMDAwMDAgbiAKMDAwMDA1OTA3MyAwMDAwMCBuIAowMDAwMDU5MzExIDAwMDAwIG4gCjAw +MDAwNjE0NzggMDAwMDAgbiAKdHJhaWxlcgo8PC9TaXplIDI0Ci9Sb290IDUgMCBSCi9JbmZvIDEg +MCBSPj4Kc3RhcnR4cmVmCjYxNzcyCiUlRU9GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== From 35a49a240a6154a6455db03cb9b0a48098f9febf Mon Sep 17 00:00:00 2001 From: Kris Date: Fri, 16 Feb 2018 15:58:10 -0500 Subject: [PATCH 008/299] UX: Improving header scalability for large font themes --- .../stylesheets/common/base/header.scss | 26 +++++++++++-------- .../stylesheets/common/base/menu-panel.scss | 9 +++---- app/assets/stylesheets/desktop/header.scss | 11 ++++---- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/app/assets/stylesheets/common/base/header.scss b/app/assets/stylesheets/common/base/header.scss index b28652565f..faf9ea3165 100644 --- a/app/assets/stylesheets/common/base/header.scss +++ b/app/assets/stylesheets/common/base/header.scss @@ -4,7 +4,7 @@ top: 0; z-index: z("header"); background-color: $header_background; - box-shadow: 0 2px 4px -1px rgba(0,0,0, .25); + box-shadow: 0 2px 4px -1px rgba(0, 0, 0, .25); .docked & { position: fixed; @@ -33,11 +33,11 @@ .panel { float: right; position: relative; + display: flex; + align-items: center; } .login-button, button.sign-up-button { - float: left; - margin-top: 7px; padding: 6px 10px; .fa { margin-right: 3px; } } @@ -62,14 +62,17 @@ margin: 0 0 0 5px; list-style: none; - > li { float: left; } .icon { position: relative; - display: block; - padding: 3px; + display: flex; + align-items: center; + justify-content: center; + width: 2.2857em; + height: 2.2857em; + padding: .2143em; color: dark-light-choose(scale-color($header_primary, $lightness: 50%), $header_primary); text-decoration: none; cursor: pointer; @@ -77,11 +80,13 @@ border-left: 1px solid transparent; border-right: 1px solid transparent; transition: all linear .15s; - - + img.avatar { + width: 2.2857em; + height: 2.2857em; + } &:hover { color: $primary; - background-color: $primary-low; + background-color: $primary-low; border-top: 1px solid transparent; border-left: 1px solid transparent; border-right: 1px solid transparent; @@ -119,8 +124,7 @@ } .d-icon { - width: 32px; - height: 32px; + width: 100%; font-size: $font-up-4; line-height: $line-height-large; display: inline-block; diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss index ad27956a56..4953f8b3f0 100644 --- a/app/assets/stylesheets/common/base/menu-panel.scss +++ b/app/assets/stylesheets/common/base/menu-panel.scss @@ -19,7 +19,7 @@ .menu-panel { border: 1px solid $primary-low; - box-shadow: 0 2px 2px rgba(0,0,0, .25); + box-shadow: 0 2px 2px rgba(0, 0, 0, .25); background-color: $secondary; z-index: z("header"); padding: 0.5em; @@ -44,7 +44,6 @@ overflow-y: auto; overflow-x: hidden; } - } .menu-links.columned { @@ -69,7 +68,6 @@ margin-left: 0.5em; color: dark-light-choose($primary-medium, $secondary-medium); } - } li.category-link { @@ -77,7 +75,7 @@ background-color: transparent; display: inline-flex; margin: 0.25em 0.5em; - width: 44%; + width: 43%; .badge-notification { color: dark-light-choose($primary-medium, $secondary-medium); background-color: transparent; @@ -90,7 +88,6 @@ overflow: hidden; text-overflow: ellipsis; display: inline-block; - max-width: 80%; &.bar, &.bullet { color: $primary; } @@ -100,7 +97,7 @@ padding-top: 2px; } span { - z-index: z("base") * -1; + z-index: z("base") * -1; } } } diff --git a/app/assets/stylesheets/desktop/header.scss b/app/assets/stylesheets/desktop/header.scss index ad83f6d2c2..4a6f406983 100644 --- a/app/assets/stylesheets/desktop/header.scss +++ b/app/assets/stylesheets/desktop/header.scss @@ -5,9 +5,9 @@ .d-header { left: 0; padding-top: 3px; - height: 60px; + height: 4.2857em; .d-icon-home { - padding:8px; + padding: 8px; font-size: $font-up-5; } @@ -17,9 +17,10 @@ } } -@media all -and (max-width : 570px) { - .extra-info-wrapper {display: none;} +@media all and (max-width: 570px) { + .extra-info-wrapper { + display: none; + } } #main { From a2327b4897cd131de4a0aabadde21c425aa7c50e Mon Sep 17 00:00:00 2001 From: Kris Date: Fri, 16 Feb 2018 17:13:10 -0500 Subject: [PATCH 009/299] login button alignment fix --- app/assets/stylesheets/common/base/header.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/stylesheets/common/base/header.scss b/app/assets/stylesheets/common/base/header.scss index faf9ea3165..a8655f2986 100644 --- a/app/assets/stylesheets/common/base/header.scss +++ b/app/assets/stylesheets/common/base/header.scss @@ -37,6 +37,10 @@ align-items: center; } + .header-buttons { + margin-top: .2em; + } + .login-button, button.sign-up-button { padding: 6px 10px; .fa { margin-right: 3px; } From 02093ecbdd72ffaf51e8ece92fc9ef5bb0c8475f Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Fri, 16 Feb 2018 19:11:41 -0500 Subject: [PATCH 010/299] Extensibility: Allow plugins to munge user params --- app/controllers/users_controller.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 4d0726d865..0ccaa73102 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -975,7 +975,12 @@ class UsersController < ApplicationController result.merge!(params.permit(:active, :staged, :approved)) end - result + modify_user_params(result) + end + + # Plugins can use this to modify user parameters + def modify_user_params(attrs) + attrs end def user_locale From 87c0cc2b1ad3787fbcf5daa7dc2bf8a1247e21df Mon Sep 17 00:00:00 2001 From: Jay Pfaffman Date: Fri, 16 Feb 2018 16:25:37 -0800 Subject: [PATCH 011/299] Update INSTALL-cloud.md Add note about being able to enable Let's Encrypt when you run `./discourse-setup`. --- docs/INSTALL-cloud.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/INSTALL-cloud.md b/docs/INSTALL-cloud.md index aaca49a3ad..3cee1f61fa 100644 --- a/docs/INSTALL-cloud.md +++ b/docs/INSTALL-cloud.md @@ -62,14 +62,17 @@ Launch the setup tool at Answer the following questions when prompted: Hostname for your Discourse? [discourse.example.com]: - Email address for admin account? [me@example.com]: + Email address for admin account(s)? [me@example.com,you@example.com]: SMTP server address? [smtp.example.com]: - SMTP user name? [postmaster@discourse.example.com]: - SMTP port [587]: - SMTP password? []: + SMTP port? [587]: + SMTP user name? [user@example.com]: + SMTP password? [pa$$word]: + Let's Encrypt account email? (ENTER to skip) [me@example.com]: This will generate an `app.yml` configuration file on your behalf, and then kicks off bootstrap. Bootstrapping takes between **2-8 minutes** to set up your Discourse. If you need to change these settings after bootstrapping, you can run `./discourse-setup` again (it will read your old values from the file) or edit `/containers/app.yml` with `nano` and then `./launcher rebuild app`, otherwise your changes will not take effect. +**NOTE:** You should not attempt to enable Let's Encrypt unless the DNS record for hostname resolves to your server. You can run `./discourse-setup` again later to make any changes. + ### Start Discourse Once bootstrapping is complete, your Discourse should be accessible in your web browser via the domain name `discourse.example.com` you entered earlier, provided you configured DNS. If not, you can visit the server IP directly, e.g. `http://192.168.1.1`. From 33df2d6a029cb9dd97124cd7866e1a090caf8b4f Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Sun, 18 Feb 2018 23:52:09 +0530 Subject: [PATCH 012/299] FIX: data export should fill missing dates with zero value --- app/jobs/regular/export_csv_file.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/jobs/regular/export_csv_file.rb b/app/jobs/regular/export_csv_file.rb index 5eca7dd91b..0a0899e572 100644 --- a/app/jobs/regular/export_csv_file.rb +++ b/app/jobs/regular/export_csv_file.rb @@ -146,8 +146,14 @@ module Jobs @extra[:end_date] = @extra[:end_date].to_date if @extra[:end_date].is_a?(String) @extra[:category_id] = @extra[:category_id].present? ? @extra[:category_id].to_i : nil @extra[:group_id] = @extra[:group_id].present? ? @extra[:group_id].to_i : nil + + report_hash = {} Report.find(@extra[:name], @extra).data.each do |row| - yield [row[:x].to_s(:db), row[:y].to_s(:db)] + report_hash[row[:x].to_s(:db)] = row[:y].to_s(:db) + end + + (@extra[:start_date]..@extra[:end_date]).each do |date| + yield [date.to_s(:db), report_hash.fetch(date.to_s(:db), 0)] end end From d601a6b23cf270f79d635eadd56590188fa2ea20 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 19 Feb 2018 08:04:15 +0800 Subject: [PATCH 013/299] FIX: Support old Service Worker source file path to avoid routing errors. --- config/nginx.sample.conf | 2 +- config/routes.rb | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/config/nginx.sample.conf b/config/nginx.sample.conf index 6253e04146..f7544fdce1 100644 --- a/config/nginx.sample.conf +++ b/config/nginx.sample.conf @@ -188,7 +188,7 @@ server { # This big block is needed so we can selectively enable # acceleration for backups and avatars # see note about repetition above - location ~ ^/(letter_avatar/|user_avatar|highlight-js|stylesheets|favicon/proxied|service-worker-.*.js) { + location ~ ^/(letter_avatar/|user_avatar|highlight-js|stylesheets|favicon/proxied|service-worker) { proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/config/routes.rb b/config/routes.rb index a7409d3afd..bb1b8c8fed 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -697,6 +697,12 @@ Discourse::Application.routes.draw do delete "draft" => "draft#destroy" if service_worker_asset = Rails.application.assets_manifest.assets['service-worker.js'] + # https://developers.google.com/web/fundamentals/codelabs/debugging-service-workers/ + # Normally the browser will wait until a user closes all tabs that contain the + # current site before updating to a new Service Worker. + # Support the old Service Worker path to avoid routing error filling up the + # logs. + get "/service-worker.js" => redirect(service_worker_asset), format: :js get service_worker_asset => "static#service_worker_asset", format: :js end From 9d8df812dde09c2965d1e9a30b6a47c84a2eac53 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 19 Feb 2018 08:07:33 +1100 Subject: [PATCH 014/299] PERF: upgrade Oj gem --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 76775fb758..5e8564fd0e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -199,7 +199,7 @@ GEM multi_json (~> 1.3) multi_xml (~> 0.5) rack (>= 1.2, < 3) - oj (3.1.0) + oj (3.4.0) omniauth (1.6.1) hashie (>= 3.4.6, < 3.6.0) rack (>= 1.6.2, < 3) From 107eb5d83070efeda647ad8eb79e9d26bc20bdb7 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 19 Feb 2018 08:08:13 +1100 Subject: [PATCH 015/299] FIX: binding_of_caller not working on Ruby 2.5 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 5e8564fd0e..1d856bcced 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -63,7 +63,7 @@ GEM coderay (>= 1.0.0) erubis (>= 2.6.6) rack (>= 0.9.0) - binding_of_caller (0.7.2) + binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) bootsnap (1.0.0) msgpack (~> 1.0) From a3c7ee09b6dacfd2f67cfdacc9f597685548fa4c Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 19 Feb 2018 10:12:51 +1100 Subject: [PATCH 016/299] FIX: ruby bench not working properly - Remove thin which is no longer supported - Bypass admin api rate limiting in profile environment - Admin password was too short - Run by default in concurrency 1 mode - A skip bundle assets flag to speed up local testing --- lib/auth/default_current_user_provider.rb | 23 ++++---- script/bench.rb | 68 +++++++++++++++++++---- script/profile_db_generator.rb | 2 +- 3 files changed, 72 insertions(+), 21 deletions(-) diff --git a/lib/auth/default_current_user_provider.rb b/lib/auth/default_current_user_provider.rb index c46ee178b3..cacfe81962 100644 --- a/lib/auth/default_current_user_provider.rb +++ b/lib/auth/default_current_user_provider.rb @@ -5,14 +5,14 @@ require_dependency "rate_limiter" class Auth::DefaultCurrentUserProvider - CURRENT_USER_KEY ||= "_DISCOURSE_CURRENT_USER".freeze - API_KEY ||= "api_key".freeze - USER_API_KEY ||= "HTTP_USER_API_KEY".freeze - USER_API_CLIENT_ID ||= "HTTP_USER_API_CLIENT_ID".freeze - API_KEY_ENV ||= "_DISCOURSE_API".freeze - USER_API_KEY_ENV ||= "_DISCOURSE_USER_API".freeze - TOKEN_COOKIE ||= "_t".freeze - PATH_INFO ||= "PATH_INFO".freeze + CURRENT_USER_KEY ||= "_DISCOURSE_CURRENT_USER" + API_KEY ||= "api_key" + USER_API_KEY ||= "HTTP_USER_API_KEY" + USER_API_CLIENT_ID ||= "HTTP_USER_API_CLIENT_ID" + API_KEY_ENV ||= "_DISCOURSE_API" + USER_API_KEY_ENV ||= "_DISCOURSE_USER_API" + TOKEN_COOKIE ||= "_t" + PATH_INFO ||= "PATH_INFO" COOKIE_ATTEMPTS_PER_MIN ||= 10 # do all current user initialization here @@ -86,8 +86,11 @@ class Auth::DefaultCurrentUserProvider raise Discourse::InvalidAccess if current_user.suspended? || !current_user.active @env[API_KEY_ENV] = true - limiter_min = RateLimiter.new(nil, "admin_api_min_#{api_key}", GlobalSetting.max_admin_api_reqs_per_key_per_minute, 60) - limiter_min.performed! + # we do not run this rate limiter while profiling + if Rails.env != "profile" + limiter_min = RateLimiter.new(nil, "admin_api_min_#{api_key}", GlobalSetting.max_admin_api_reqs_per_key_per_minute, 60) + limiter_min.performed! + end end # user api key handling diff --git a/script/bench.rb b/script/bench.rb index ac6fe614fc..385fd7e587 100644 --- a/script/bench.rb +++ b/script/bench.rb @@ -11,6 +11,9 @@ require "fileutils" @mem_stats = false @unicorn = false @dump_heap = false +@concurrency = 1 +@skip_asset_bundle = false +@unicorn_workers = 3 opts = OptionParser.new do |o| o.banner = "Usage: ruby bench.rb [options]" @@ -35,9 +38,18 @@ opts = OptionParser.new do |o| o.on("-m", "--memory_stats") do @mem_stats = true end - o.on("-u", "--unicorn", "Use unicorn to serve pages as opposed to thin") do + o.on("-u", "--unicorn", "Use unicorn to serve pages as opposed to puma") do @unicorn = true end + o.on("-c", "--concurrency [NUM]", "Run benchmark with this number of concurrent requests (default: 1)") do |i| + @concurrency = i.to_i + end + o.on("-w", "--unicorn_workers [NUM]", "Run benchmark with this number of unicorn workers (default: 3)") do |i| + @unicorn_workers = i.to_i + end + o.on("-s", "--skip-bundle-assets", "Skip bundling assets") do + @skip_asset_bundle = true + end end opts.parse! @@ -106,19 +118,40 @@ end ENV["RAILS_ENV"] = "profile" -discourse_env_vars = %w(DISCOURSE_DUMP_HEAP RUBY_GC_HEAP_INIT_SLOTS RUBY_GC_HEAP_FREE_SLOTS RUBY_GC_HEAP_GROWTH_FACTOR RUBY_GC_HEAP_GROWTH_MAX_SLOTS RUBY_GC_MALLOC_LIMIT RUBY_GC_OLDMALLOC_LIMIT RUBY_GC_MALLOC_LIMIT_MAX RUBY_GC_OLDMALLOC_LIMIT_MAX RUBY_GC_MALLOC_LIMIT_GROWTH_FACTOR RUBY_GC_OLDMALLOC_LIMIT_GROWTH_FACTOR RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR) +discourse_env_vars = %w( + DISCOURSE_DUMP_HEAP + RUBY_GC_HEAP_INIT_SLOTS + RUBY_GC_HEAP_FREE_SLOTS + RUBY_GC_HEAP_GROWTH_FACTOR + RUBY_GC_HEAP_GROWTH_MAX_SLOTS + RUBY_GC_MALLOC_LIMIT + RUBY_GC_OLDMALLOC_LIMIT + RUBY_GC_MALLOC_LIMIT_MAX + RUBY_GC_OLDMALLOC_LIMIT_MAX + RUBY_GC_MALLOC_LIMIT_GROWTH_FACTOR + RUBY_GC_OLDMALLOC_LIMIT_GROWTH_FACTOR + RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR + RUBY_GLOBAL_METHOD_CACHE_SIZE +) if @include_env puts "Running with tuned environment" - discourse_env_vars - %w(RUBY_GC_MALLOC_LIMIT).each do |v| + discourse_env_vars.each do |v| ENV.delete v end + + ENV['RUBY_GLOBAL_METHOD_CACHE_SIZE'] = '131072' + ENV['RUBY_GC_HEAP_GROWTH_MAX_SLOTS'] = '40000' + ENV['RUBY_GC_HEAP_INIT_SLOTS'] = '400000' + ENV['RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR'] = '1.5' + else # clean env puts "Running with the following custom environment" - discourse_env_vars.each do |w| - puts "#{w}: #{ENV[w]}" - end +end + +discourse_env_vars.each do |w| + puts "#{w}: #{ENV[w]}" if ENV[w].to_s.length > 0 end def port_available?(port) @@ -153,8 +186,9 @@ api_key = `bundle exec rake api_key:get`.split("\n")[-1] def bench(path, name) puts "Running apache bench warmup" add = "" - add = "-c 3 " if @unicorn + add = "-c #{@concurrency} " if @concurrency > 1 `ab #{add} -n 20 -l "http://127.0.0.1:#{@port}#{path}"` + puts "Benchmarking #{name} @ #{path}" `ab #{add} -n #{@iterations} -l -e tmp/ab.csv "http://127.0.0.1:#{@port}#{path}"` @@ -168,16 +202,19 @@ end begin # critical cause cache may be incompatible - puts "precompiling assets" - run("bundle exec rake assets:precompile") + unless @skip_asset_bundle + puts "precompiling assets" + run("bundle exec rake assets:precompile") + end pid = if @unicorn ENV['UNICORN_PORT'] = @port.to_s + ENV['UNICORN_WORKERS'] = @unicorn_workers.to_s FileUtils.mkdir_p(File.join('tmp', 'pids')) spawn("bundle exec unicorn -c config/unicorn.conf.rb") else - spawn("bundle exec thin start -p #{@port}") + spawn("bundle exec puma -p #{@port}") end while port_available? @port @@ -223,6 +260,17 @@ begin puts "Your Results: (note for timings- percentile is first, duration is second in millisecs)" + if @unicorn + puts "Unicorn: (workers: #{@unicorn_workers})" + else + # TODO we want to also bench puma clusters + puts "Puma: (single threaded)" + end + puts "Include env: #{@include_env}" + puts "Iterations: #{@iterations}, Best of: #{@best_of}" + puts "Concurrency: #{@concurrency}" + puts + # Prevent using external facts because it breaks when running in the # discourse/discourse_bench docker container. Facter::Util::Config.external_facts_dirs = [] diff --git a/script/profile_db_generator.rb b/script/profile_db_generator.rb index 18cf34b5f6..37f757177d 100644 --- a/script/profile_db_generator.rb +++ b/script/profile_db_generator.rb @@ -45,7 +45,7 @@ def create_admin(seq) User.new.tap { |admin| admin.email = "admin@localhost#{seq}.fake" admin.username = "admin#{seq}" - admin.password = "password" + admin.password = "password12345abc" admin.save! admin.grant_admin! admin.change_trust_level!(TrustLevel[4]) From b3b6373f777430e6ef4c38af5963efafe3194d2c Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Mon, 19 Feb 2018 09:52:35 +0100 Subject: [PATCH 017/299] FIX: do not show mail-forward icon if not needed --- .../discourse/components/composer-action-title.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/components/composer-action-title.js.es6 b/app/assets/javascripts/discourse/components/composer-action-title.js.es6 index 2bc6927300..5b4a148e4d 100644 --- a/app/assets/javascripts/discourse/components/composer-action-title.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-action-title.js.es6 @@ -38,11 +38,11 @@ export default Ember.Component.extend({ ${postLink.anchor} ${userAvatar} ${userLink.anchor} - ${iconHTML("mail-forward", { class: "reply-to-glyph" })} `; if (originalUser) { editTitle += ` + ${iconHTML("mail-forward", { class: "reply-to-glyph" })} ${originalUser.avatar} ${originalUser.username} `; From 5d9d0fcb4f3829297e8546b10381b58d3699ca55 Mon Sep 17 00:00:00 2001 From: Leo McArdle Date: Mon, 19 Feb 2018 09:20:17 +0000 Subject: [PATCH 018/299] FEATURE: add setting which adds group name to PM email subject (#5475) --- app/mailers/user_notifications.rb | 18 ++++++++ config/locales/server.en.yml | 1 + config/site_settings.yml | 2 + lib/email/message_builder.rb | 2 +- spec/mailers/user_notifications_spec.rb | 61 ++++++++++++++++++++++++- 5 files changed, 82 insertions(+), 2 deletions(-) diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index f5d8e537ec..9d0df12796 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -258,6 +258,7 @@ class UserNotifications < ActionMailer::Base opts[:use_site_subject] = true opts[:add_re_to_subject] = true opts[:show_category_in_subject] = false + opts[:show_group_in_subject] = true if SiteSetting.group_in_subject # We use the 'user_posted' event when you are emailed a post in a PM. opts[:notification_type] = 'posted' @@ -372,6 +373,7 @@ class UserNotifications < ActionMailer::Base use_site_subject: opts[:use_site_subject], add_re_to_subject: opts[:add_re_to_subject], show_category_in_subject: opts[:show_category_in_subject], + show_group_in_subject: opts[:show_group_in_subject], notification_type: notification_type, use_invite_template: opts[:use_invite_template], user: user @@ -422,6 +424,21 @@ class UserNotifications < ActionMailer::Base show_category_in_subject = nil end + if post.topic.private_message? + subject_pm = + if opts[:show_group_in_subject] + if group = post.topic.allowed_groups&.first + if group.full_name + "[#{group.full_name}] " + else + "[#{group.name}] " + end + end + else + I18n.t('subject_pm') + end + end + if SiteSetting.private_email? title = I18n.t("system_messages.private_topic_title", id: post.topic_id) end @@ -523,6 +540,7 @@ class UserNotifications < ActionMailer::Base add_re_to_subject: add_re_to_subject, show_category_in_subject: show_category_in_subject, private_reply: post.topic.private_message?, + subject_pm: subject_pm, include_respond_instructions: !(user.suspended? || user.staged?), template: template, site_description: SiteSetting.site_description, diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 8fbb0f57d2..c9d7029fc4 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1470,6 +1470,7 @@ en: staff_user_custom_fields: "A whitelist of custom fields for a user that can be shown to staff." enable_user_directory: "Provide a directory of users for browsing" enable_group_directory: "Provide a directory of groups for browsing" + group_in_subject: "Set %{optional_pm} in email subject to name of first group in PM, see: https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" allow_anonymous_posting: "Allow users to switch to anonymous mode" anonymous_posting_min_trust_level: "Minimum trust level required to enable anonymous posting" anonymous_account_duration_minutes: "To protect anonymity create a new anonymous account every N minutes for each user. Example: if set to 600, as soon as 600 minutes elapse from last post AND user switches to anon, a new anonymous account is created." diff --git a/config/site_settings.yml b/config/site_settings.yml index ea7d2140aa..ffadb0dabf 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -437,6 +437,8 @@ groups: enable_group_directory: client: true default: true + group_in_subject: + default: false posting: min_post_length: diff --git a/lib/email/message_builder.rb b/lib/email/message_builder.rb index 9bc9cfd5dd..ac95582586 100644 --- a/lib/email/message_builder.rb +++ b/lib/email/message_builder.rb @@ -62,7 +62,7 @@ module Email subject = String.new(SiteSetting.email_subject) subject.gsub!("%{site_name}", @template_args[:email_prefix]) subject.gsub!("%{optional_re}", @opts[:add_re_to_subject] ? I18n.t('subject_re', @template_args) : '') - subject.gsub!("%{optional_pm}", @opts[:private_reply] ? I18n.t('subject_pm', @template_args) : '') + subject.gsub!("%{optional_pm}", @opts[:private_reply] ? @template_args[:subject_pm] : '') subject.gsub!("%{optional_cat}", @template_args[:show_category_in_subject] ? "[#{@template_args[:show_category_in_subject]}] " : '') subject.gsub!("%{topic_title}", @template_args[:topic_title]) if @template_args[:topic_title] # must be last for safety else diff --git a/spec/mailers/user_notifications_spec.rb b/spec/mailers/user_notifications_spec.rb index 81cca74798..a86424b323 100644 --- a/spec/mailers/user_notifications_spec.rb +++ b/spec/mailers/user_notifications_spec.rb @@ -379,7 +379,7 @@ describe UserNotifications do expect(mail[:from].display_names).to eql(['john']) # subject should include "[PM]" - expect(mail.subject).to match("[PM]") + expect(mail.subject).to include("[PM] ") # 1 "visit message" link expect(mail.html_part.to_s.scan(/Visit Message/).count).to eq(1) @@ -409,6 +409,65 @@ describe UserNotifications do expect(mail.text_part.to_s).to_not include(response.raw) expect(mail.text_part.to_s).to_not include(topic.url) end + + it "doesn't include group name in subject" do + group = Fabricate(:group) + topic.allowed_groups = [ group ] + mail = UserNotifications.user_private_message( + response.user, + post: response, + notification_type: notification.notification_type, + notification_data_hash: notification.data_hash + ) + + expect(mail.subject).to include("[PM] ") + end + + context "when SiteSetting.group_name_in_subject is true" do + before do + SiteSetting.group_in_subject = true + end + + let(:group) { Fabricate(:group, name: "my_group") } + let(:mail) { UserNotifications.user_private_message( + response.user, + post: response, + notification_type: notification.notification_type, + notification_data_hash: notification.data_hash + ) } + + shared_examples "includes first group name" do + it "includes first group name in subject" do + expect(mail.subject).to include("[my_group] ") + end + + context "when first group has full name" do + it "includes full name in subject" do + group.full_name = "My Group" + group.save + expect(mail.subject).to include("[My Group] ") + end + end + end + + context "one group in pm" do + before do + topic.allowed_groups = [ group ] + end + + include_examples "includes first group name" + end + + context "multiple groups in pm" do + let(:group2) { Fabricate(:group) } + + before do + topic.allowed_groups = [ group, group2 ] + end + + include_examples "includes first group name" + end + end end it 'adds a warning when mail limit is reached' do From afa2b36842d18b6145808c1ce0e9ad0985c088f5 Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Mon, 19 Feb 2018 04:22:27 -0500 Subject: [PATCH 019/299] Add class to category link for easy styling (#5606) --- .../discourse/templates/components/category-title-link.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/templates/components/category-title-link.hbs b/app/assets/javascripts/discourse/templates/components/category-title-link.hbs index 3403e70d49..49dd92606d 100644 --- a/app/assets/javascripts/discourse/templates/components/category-title-link.hbs +++ b/app/assets/javascripts/discourse/templates/components/category-title-link.hbs @@ -1,4 +1,4 @@ - + {{#if category.read_restricted}} {{d-icon 'lock'}} {{/if}} From f3815cd78502c09e2c405f9d82c771ee922d8815 Mon Sep 17 00:00:00 2001 From: OsamaSayegh Date: Mon, 19 Feb 2018 12:44:24 +0300 Subject: [PATCH 020/299] FEATURE: New site setting for additional allowed filetypes for staff (#5364) * FEATURE: New site setting for additional allowed filetypes for staff * Problematic variable name * feedback * small issues * fix indentation * failing tests * Remove message bus and fix minor issues * Missed this message bus --- .../discourse/lib/utilities.js.es6 | 42 ++++++++++++++----- config/locales/server.en.yml | 1 + config/site_settings.yml | 5 +++ lib/validators/upload_validator.rb | 40 +++++++++++------- spec/controllers/uploads_controller_spec.rb | 30 +++++++++++++ test/javascripts/helpers/site-settings.js | 1 + 6 files changed, 95 insertions(+), 24 deletions(-) diff --git a/app/assets/javascripts/discourse/lib/utilities.js.es6 b/app/assets/javascripts/discourse/lib/utilities.js.es6 index 671e3ca799..987e868247 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js.es6 +++ b/app/assets/javascripts/discourse/lib/utilities.js.es6 @@ -203,7 +203,7 @@ export function validateUploadedFile(file, opts) { // check that the uploaded file is authorized if (opts.allowStaffToUploadAnyFileInPm && opts.isPrivateMessage) { - if (Discourse.User.current("staff")) { + if (Discourse.User.currentProp('staff')) { return true; } } @@ -239,16 +239,28 @@ export function validateUploadedFile(file, opts) { const IMAGES_EXTENSIONS_REGEX = /(png|jpe?g|gif|bmp|tiff?|svg|webp|ico)/i; +function extensionsToArray(exts) { + return exts.toLowerCase() + .replace(/[\s\.]+/g, "") + .split("|") + .filter(ext => ext.indexOf("*") === -1); +} + function extensions() { - return Discourse.SiteSettings.authorized_extensions - .toLowerCase() - .replace(/[\s\.]+/g, "") - .split("|") - .filter(ext => ext.indexOf("*") === -1); + return extensionsToArray(Discourse.SiteSettings.authorized_extensions); +} + +function staffExtensions() { + return extensionsToArray(Discourse.SiteSettings.authorized_extensions_for_staff); } function imagesExtensions() { - return extensions().filter(ext => IMAGES_EXTENSIONS_REGEX.test(ext)); + let exts = extensions().filter(ext => IMAGES_EXTENSIONS_REGEX.test(ext)); + if (Discourse.User.currentProp('staff')) { + const staffExts = staffExtensions().filter(ext => IMAGES_EXTENSIONS_REGEX.test(ext)); + exts = _.union(exts, staffExts); + } + return exts; } function extensionsRegex() { @@ -259,7 +271,14 @@ function imagesExtensionsRegex() { return new RegExp("\\.(" + imagesExtensions().join("|") + ")$", "i"); } +function staffExtensionsRegex() { + return new RegExp("\\.(" + staffExtensions().join("|") + ")$", "i"); +} + function isAuthorizedFile(fileName) { + if (Discourse.User.currentProp('staff') && staffExtensionsRegex().test(fileName)) { + return true; + } return extensionsRegex().test(fileName); } @@ -268,7 +287,8 @@ function isAuthorizedImage(fileName){ } export function authorizedExtensions() { - return authorizesAllExtensions() ? "*" : extensions().join(", "); + const exts = Discourse.User.currentProp('staff') ? [...extensions(), ...staffExtensions()] : extensions(); + return exts.filter(ext => ext.length > 0).join(", "); } export function authorizedImagesExtensions() { @@ -276,7 +296,9 @@ export function authorizedImagesExtensions() { } export function authorizesAllExtensions() { - return Discourse.SiteSettings.authorized_extensions.indexOf("*") >= 0; + return Discourse.SiteSettings.authorized_extensions.indexOf("*") >= 0 || ( + Discourse.SiteSettings.authorized_extensions_for_staff.indexOf("*") >= 0 && + Discourse.User.currentProp('staff')); } export function authorizesOneOrMoreExtensions() { @@ -322,7 +344,7 @@ export function allowsImages() { } export function allowsAttachments() { - return authorizesAllExtensions() || extensions().length > imagesExtensions().length; + return authorizesAllExtensions() || authorizedExtensions().split(", ").length > imagesExtensions().length; } export function uploadLocation(url) { diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index c9d7029fc4..2c8986e6e4 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1334,6 +1334,7 @@ en: max_image_size_kb: "The maximum image upload size in kB. This must be configured in nginx (client_max_body_size) / apache or proxy as well." max_attachment_size_kb: "The maximum attachment files upload size in kB. This must be configured in nginx (client_max_body_size) / apache or proxy as well." authorized_extensions: "A list of file extensions allowed for upload (use '*' to enable all file types)" + authorized_extensions_for_staff: "A list of file extensions allowed for upload for staff users in addition to the list defined in the `authorized_extensions` site setting. (use '*' to enable all file types)" theme_authorized_extensions: "A list of file extensions allowed for theme uploads (use '*' to enable all file types)" max_similar_results: "How many similar topics to show above the editor when composing a new topic. Comparison is based on title and body." diff --git a/config/site_settings.yml b/config/site_settings.yml index ffadb0dabf..2550cfaeb7 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -796,6 +796,11 @@ files: default: 'jpg|jpeg|png|gif' refresh: true type: list + authorized_extensions_for_staff: + client: true + default: '' + refresh: true + type: list crawl_images: default: true max_image_width: diff --git a/lib/validators/upload_validator.rb b/lib/validators/upload_validator.rb index 6c2013565d..8f6c4cede1 100644 --- a/lib/validators/upload_validator.rb +++ b/lib/validators/upload_validator.rb @@ -29,11 +29,11 @@ class Validators::UploadValidator < ActiveModel::Validator end def is_authorized?(upload, extension) - authorized_extensions(upload, extension, authorized_uploads(upload)) + extension_authorized?(upload, extension, authorized_extensions(upload)) end def authorized_image_extension(upload, extension) - authorized_extensions(upload, extension, authorized_images(upload)) + extension_authorized?(upload, extension, authorized_images(upload)) end def maximum_image_file_size(upload) @@ -41,7 +41,7 @@ class Validators::UploadValidator < ActiveModel::Validator end def authorized_attachment_extension(upload, extension) - authorized_extensions(upload, extension, authorized_attachments(upload)) + extension_authorized?(upload, extension, authorized_attachments(upload)) end def maximum_attachment_file_size(upload) @@ -50,38 +50,50 @@ class Validators::UploadValidator < ActiveModel::Validator private - def authorized_uploads(upload) - authorized_uploads = Set.new + def extensions_to_set(exts) + extensions = Set.new - extensions = upload.for_theme ? SiteSetting.theme_authorized_extensions : SiteSetting.authorized_extensions - - extensions + exts .gsub(/[\s\.]+/, "") .downcase .split("|") - .each { |extension| authorized_uploads << extension unless extension.include?("*") } + .each { |extension| extensions << extension unless extension.include?("*") } - authorized_uploads + extensions + end + + def authorized_extensions(upload) + extensions = upload.for_theme ? SiteSetting.theme_authorized_extensions : SiteSetting.authorized_extensions + extensions_to_set(extensions) end def authorized_images(upload) - authorized_uploads(upload) & FileHelper.images + authorized_extensions(upload) & FileHelper.images end def authorized_attachments(upload) - authorized_uploads(upload) - FileHelper.images + authorized_extensions(upload) - FileHelper.images end def authorizes_all_extensions?(upload) + if upload.user&.staff? + return true if SiteSetting.authorized_extensions_for_staff.include?("*") + end extensions = upload.for_theme ? SiteSetting.theme_authorized_extensions : SiteSetting.authorized_extensions extensions.include?("*") end - def authorized_extensions(upload, extension, extensions) + def extension_authorized?(upload, extension, extensions) return true if authorizes_all_extensions?(upload) + staff_extensions = Set.new + if upload.user&.staff? + staff_extensions = extensions_to_set(SiteSetting.authorized_extensions_for_staff) + return true if staff_extensions.include?(extension.downcase) + end + unless authorized = extensions.include?(extension.downcase) - message = I18n.t("upload.unauthorized", authorized_extensions: extensions.to_a.join(", ")) + message = I18n.t("upload.unauthorized", authorized_extensions: (extensions | staff_extensions).to_a.join(", ")) upload.errors.add(:original_filename, message) end diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index 2a91a61196..abf27ceb47 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -148,6 +148,36 @@ describe UploadsController do expect(id).to be end + it 'respects `authorized_extensions_for_staff` setting when staff upload file' do + SiteSetting.authorized_extensions = "" + SiteSetting.authorized_extensions_for_staff = "*" + @user.update_columns(moderator: true) + + post :create, params: { + file: text_file, + type: "composer", + format: :json + } + + expect(response).to be_success + data = JSON.parse(response.body) + expect(data["id"]).to be + end + + it 'ignores `authorized_extensions_for_staff` setting when non-staff upload file' do + SiteSetting.authorized_extensions = "" + SiteSetting.authorized_extensions_for_staff = "*" + + post :create, params: { + file: text_file, + type: "composer", + format: :json + } + + data = JSON.parse(response.body) + expect(data["errors"].first).to eq(I18n.t("upload.unauthorized", authorized_extensions: '')) + end + it 'returns an error when it could not determine the dimensions of an image' do Jobs.expects(:enqueue).with(:create_avatar_thumbnails, anything).never diff --git a/test/javascripts/helpers/site-settings.js b/test/javascripts/helpers/site-settings.js index 8df19c95c9..f84fd111e0 100644 --- a/test/javascripts/helpers/site-settings.js +++ b/test/javascripts/helpers/site-settings.js @@ -59,6 +59,7 @@ Discourse.SiteSettingsOriginal = { "autohighlight_all_code":false, "email_in":false, "authorized_extensions":".jpg|.jpeg|.png|.gif|.svg|.txt|.ico|.yml", + "authorized_extensions_for_staff": "", "max_image_width":690, "max_image_height":500, "allow_profile_backgrounds":true, From b6277e208b11a3ac1d92e2f9c818af84d6c7cf81 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Sun, 18 Feb 2018 16:08:07 +0100 Subject: [PATCH 021/299] FIX: Cookies header didn't have the right format --- lib/final_destination.rb | 28 ++++++----- spec/components/final_destination_spec.rb | 57 +++++++++++++++++++++++ 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/lib/final_destination.rb b/lib/final_destination.rb index c6c989de9a..a1180b4618 100644 --- a/lib/final_destination.rb +++ b/lib/final_destination.rb @@ -73,7 +73,7 @@ class FinalDestination "Host" => @uri.hostname } - result['cookie'] = @cookie if @cookie + result['Cookie'] = @cookie if @cookie result end @@ -164,7 +164,7 @@ class FinalDestination ) location = nil - headers = nil + response_headers = nil response_status = response.status.to_i @@ -181,31 +181,29 @@ class FinalDestination return @uri end - headers = {} + response_headers = {} if cookie_val = get_response.get_fields('set-cookie') - headers['set-cookie'] = cookie_val.join + response_headers[:cookies] = cookie_val end - # TODO this is confusing why grab location for anything not - # between 300-400 ? if location_val = get_response.get_fields('location') - headers['location'] = location_val.join + response_headers[:location] = location_val.join end end - unless headers - headers = {} - response.headers.each do |k, v| - headers[k.to_s.downcase] = v - end + unless response_headers + response_headers = { + cookies: response.data[:cookies] || response.headers[:"set-cookie"], + location: response.headers[:location] + } end if (300..399).include?(response_status) - location = headers["location"] + location = response_headers[:location] end - if set_cookie = headers["set-cookie"] - @cookie = set_cookie + if cookies = response_headers[:cookies] + @cookie = Array.wrap(cookies).map { |c| c.split(';').first.strip }.join('; ') end if location diff --git a/spec/components/final_destination_spec.rb b/spec/components/final_destination_spec.rb index 38cd37e2bf..aa5364c5b1 100644 --- a/spec/components/final_destination_spec.rb +++ b/spec/components/final_destination_spec.rb @@ -204,6 +204,63 @@ describe FinalDestination do expect(final.cookie).to eq('evil=trout') end end + + it "correctly extracts cookies during GET" do + stub_request(:head, "https://eviltrout.com").to_return(status: 405) + + stub_request(:get, "https://eviltrout.com") + .to_return(status: 302, body: "" , headers: { + "Location" => "https://eviltrout.com", + "Set-Cookie" => ["foo=219ffwef9w0f; expires=Mon, 19-Feb-2018 10:44:24 GMT; path=/; domain=eviltrout.com", + "bar=1", + "baz=2; expires=Tue, 19-Feb-2019 10:14:24 GMT; path=/; domain=eviltrout.com"] + }) + + stub_request(:head, "https://eviltrout.com") + .with(headers: { "Cookie" => "bar=1; baz=2; foo=219ffwef9w0f" }) + .to_return(status: 200, body: "") + + final = FinalDestination.new("https://eviltrout.com", opts) + expect(final.resolve.to_s).to eq("https://eviltrout.com") + expect(final.status).to eq(:resolved) + expect(final.cookie).to eq("bar=1; baz=2; foo=219ffwef9w0f") + end + end + + it "should use the correct format for cookies when there is only one cookie" do + stub_request(:head, "https://eviltrout.com") + .to_return(status: 302, body: "" , headers: { + "Location" => "https://eviltrout.com", + "Set-Cookie" => "foo=219ffwef9w0f; expires=Mon, 19-Feb-2018 10:44:24 GMT; path=/; domain=eviltrout.com" + }) + + stub_request(:head, "https://eviltrout.com") + .with(headers: { "Cookie" => "foo=219ffwef9w0f" }) + .to_return(status: 200, body: "") + + final = FinalDestination.new("https://eviltrout.com", opts) + expect(final.resolve.to_s).to eq("https://eviltrout.com") + expect(final.status).to eq(:resolved) + expect(final.cookie).to eq("foo=219ffwef9w0f") + end + + it "should use the correct format for cookies when there are multiple cookies" do + stub_request(:head, "https://eviltrout.com") + .to_return(status: 302, body: "" , headers: { + "Location" => "https://eviltrout.com", + "Set-Cookie" => ["foo=219ffwef9w0f; expires=Mon, 19-Feb-2018 10:44:24 GMT; path=/; domain=eviltrout.com", + "bar=1", + "baz=2; expires=Tue, 19-Feb-2019 10:14:24 GMT; path=/; domain=eviltrout.com"] + }) + + stub_request(:head, "https://eviltrout.com") + .with(headers: { "Cookie" => "bar=1; baz=2; foo=219ffwef9w0f" }) + .to_return(status: 200, body: "") + + final = FinalDestination.new("https://eviltrout.com", opts) + expect(final.resolve.to_s).to eq("https://eviltrout.com") + expect(final.status).to eq(:resolved) + expect(final.cookie).to eq("bar=1; baz=2; foo=219ffwef9w0f") end end From c419c26f560b7f2a2cbd08080e4e3a892c7bc43e Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Sat, 17 Feb 2018 10:40:30 +0530 Subject: [PATCH 022/299] FEATURE: new site setting 'max_emojis_in_title' --- app/models/topic.rb | 1 + config/locales/server.en.yml | 2 + config/site_settings.yml | 1 + lib/validators/max_emojis_validator.rb | 11 ++++ .../validators/max_emojis_validator_spec.rb | 50 +++++++++++++++++++ 5 files changed, 65 insertions(+) create mode 100644 lib/validators/max_emojis_validator.rb create mode 100644 spec/components/validators/max_emojis_validator_spec.rb diff --git a/app/models/topic.rb b/app/models/topic.rb index 3b3c74b956..ac6af15392 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -84,6 +84,7 @@ class Topic < ActiveRecord::Base topic_title_length: true, censored_words: true, quality_title: { unless: :private_message? }, + max_emojis: true, unique_among: { unless: Proc.new { |t| (SiteSetting.allow_duplicate_topic_titles? || t.private_message?) }, message: :has_already_been_used, allow_blank: true, diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 2c8986e6e4..96bdd775b2 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -114,6 +114,7 @@ en: not_an_integer: must be an integer odd: must be odd record_invalid: ! 'Validation failed: %{errors}' + max_emojis: "can't have more than %{max_emojis_count} emoji" restrict_dependent_destroy: one: "Cannot delete record because a dependent %{record} exists" many: "Cannot delete record because dependent %{record} exist" @@ -984,6 +985,7 @@ en: min_topic_title_length: "Minimum allowed topic title length in characters" max_topic_title_length: "Maximum allowed topic title length in characters" min_personal_message_title_length: "Minimum allowed title length for a message in characters" + max_emojis_in_title: "Maximum allowed emojis in topic title" min_search_term_length: "Minimum valid search term length in characters" search_tokenize_chinese_japanese_korean: "Force search to tokenize Chinese/Japanese/Korean even on non CJK sites" search_prefer_recent_posts: "If searching your large forum is slow, this option tries an index of more recent posts first" diff --git a/config/site_settings.yml b/config/site_settings.yml index 2550cfaeb7..c3bff313e5 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -502,6 +502,7 @@ posting: client: true default: 2 min: 1 + max_emojis_in_title: 1 allow_uncategorized_topics: client: true default: true diff --git a/lib/validators/max_emojis_validator.rb b/lib/validators/max_emojis_validator.rb new file mode 100644 index 0000000000..f208f66b1e --- /dev/null +++ b/lib/validators/max_emojis_validator.rb @@ -0,0 +1,11 @@ +class MaxEmojisValidator < ActiveModel::EachValidator + + def validate_each(record, attribute, value) + if Emoji.unicode_unescape(value).scan(/:([\w\-+]*(?::t\d)?):/).size > SiteSetting.max_emojis_in_title + record.errors.add( + attribute, :max_emojis, + max_emojis_count: SiteSetting.max_emojis_in_title + ) + end + end +end diff --git a/spec/components/validators/max_emojis_validator_spec.rb b/spec/components/validators/max_emojis_validator_spec.rb new file mode 100644 index 0000000000..a268e707e2 --- /dev/null +++ b/spec/components/validators/max_emojis_validator_spec.rb @@ -0,0 +1,50 @@ +# encoding: UTF-8 + +require 'rails_helper' +require 'validators/max_emojis_validator' + +describe MaxEmojisValidator do + + # simulate Rails behavior (singleton) + def validate + @validator ||= MaxEmojisValidator.new(attributes: :title) + @validator.validate_each(record, :title, record.title) + end + + shared_examples "validating any topic title" do + it 'adds an error when emoji count is greater than SiteSetting.max_emojis_in_title' do + SiteSetting.max_emojis_in_title = 3 + record.title = '🧐 Lots of emojis here 🎃 :joy: :sunglasses:' + validate + expect(record.errors[:title]).to be_present + end + end + + describe 'topic' do + let(:record) { Fabricate.build(:topic) } + + it 'does not add an error when emoji count is good' do + SiteSetting.max_emojis_in_title = 2 + + record.title = 'To Infinity and beyond! 🚀 :woman:t5:' + validate + expect(record.errors[:title]).to_not be_present + end + + include_examples "validating any topic title" + end + + describe 'private message' do + let(:record) { Fabricate.build(:private_message_topic) } + + it 'does not add an error when emoji count is good' do + SiteSetting.max_emojis_in_title = 1 + + record.title = 'To Infinity and beyond! 🚀' + validate + expect(record.errors[:title]).to_not be_present + end + + include_examples "validating any topic title" + end +end From 614b1c8e68f1098d8f1d0c9ef9856bc1dc4c6e9b Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Tue, 20 Feb 2018 00:36:13 +0530 Subject: [PATCH 023/299] FIX: admin was not able to unblock screened IP address --- .../javascripts/admin/templates/logs/screened-ip-addresses.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/admin/templates/logs/screened-ip-addresses.hbs b/app/assets/javascripts/admin/templates/logs/screened-ip-addresses.hbs index 00e67a9aa5..e8ee4d896d 100644 --- a/app/assets/javascripts/admin/templates/logs/screened-ip-addresses.hbs +++ b/app/assets/javascripts/admin/templates/logs/screened-ip-addresses.hbs @@ -58,7 +58,7 @@ {{#unless item.editing}} {{d-button action="destroy" actionParam=item icon="trash-o" class="btn-danger"}} {{d-button action="edit" actionParam=item icon="pencil"}} - {{#if isBlocked}} + {{#if item.isBlocked}} {{d-button action="allow" actionParam=item icon="check" label="admin.logs.screened_ips.actions.do_nothing"}} {{else}} {{d-button action="block" actionParam=item icon="ban" label="admin.logs.screened_ips.actions.block"}} From 60ec483caae52db3e067292a22f7bdaae7f879ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Mon, 19 Feb 2018 22:40:14 +0100 Subject: [PATCH 024/299] FIX: include title in local onebox when linking to a different topic --- .../components/composer-editor.js.es6 | 8 +++++- .../components/composer-title.js.es6 | 1 + .../javascripts/pretty-text/oneboxer.js.es6 | 15 +++++++---- app/controllers/onebox_controller.rb | 4 ++- lib/oneboxer.rb | 6 ++++- spec/components/oneboxer_spec.rb | 26 +++++++++++-------- 6 files changed, 41 insertions(+), 19 deletions(-) diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index b0623158e4..86a9b02e50 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -372,7 +372,13 @@ export default Ember.Component.extend({ post.set('refreshedPost', true); } - $oneboxes.each((_, o) => load({ elem: o, refresh, ajax, categoryId: this.get('composer.category.id') })); + $oneboxes.each((_, o) => load({ + elem: o, + refresh, + ajax, + categoryId: this.get('composer.category.id'), + topicId: this.get('composer.topic.id') + })); }, _warnMentionedGroups($preview) { diff --git a/app/assets/javascripts/discourse/components/composer-title.js.es6 b/app/assets/javascripts/discourse/components/composer-title.js.es6 index 86acf45c77..23d5443b0c 100644 --- a/app/assets/javascripts/discourse/components/composer-title.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-title.js.es6 @@ -86,6 +86,7 @@ export default Ember.Component.extend({ ajax, synchronous: true, categoryId: this.get('composer.category.id'), + topicId: this.get('composer.topic.id') }); if (loadOnebox && loadOnebox.then) { diff --git a/app/assets/javascripts/pretty-text/oneboxer.js.es6 b/app/assets/javascripts/pretty-text/oneboxer.js.es6 index 90538994b7..aef317865d 100644 --- a/app/assets/javascripts/pretty-text/oneboxer.js.es6 +++ b/app/assets/javascripts/pretty-text/oneboxer.js.es6 @@ -43,12 +43,17 @@ function loadNext(ajax) { let timeoutMs = 150; let removeLoading = true; - const { url, refresh, $elem, categoryId } = loadingQueue.shift(); + const { url, refresh, $elem, categoryId, topicId } = loadingQueue.shift(); // Retrieve the onebox return ajax("/onebox", { dataType: 'html', - data: { url, refresh, category_id: categoryId }, + data: { + url, + refresh, + category_id: categoryId, + topic_id: topicId + }, cache: true }).then(html => { let $html = $(html); @@ -59,7 +64,7 @@ function loadNext(ajax) { if (result && result.jqXHR && result.jqXHR.status === 429) { timeoutMs = 2000; removeLoading = false; - loadingQueue.unshift({ url, refresh, $elem, categoryId }); + loadingQueue.unshift({ url, refresh, $elem, categoryId, topicId }); } else { failedCache[normalize(url)] = true; } @@ -74,7 +79,7 @@ function loadNext(ajax) { // Perform a lookup of a onebox based an anchor $element. // It will insert a loading indicator and remove it when the loading is complete or fails. -export function load({ elem , refresh = true, ajax, synchronous = false, categoryId }) { +export function load({ elem , refresh = true, ajax, synchronous = false, categoryId, topicId }) { const $elem = $(elem); // If the onebox has loaded or is loading, return @@ -98,7 +103,7 @@ export function load({ elem , refresh = true, ajax, synchronous = false, categor $elem.addClass('loading-onebox'); // Add to the loading queue - loadingQueue.push({ url, refresh, $elem, categoryId }); + loadingQueue.push({ url, refresh, $elem, categoryId, topicId }); // Load next url in queue if (synchronous) { diff --git a/app/controllers/onebox_controller.rb b/app/controllers/onebox_controller.rb index dea29ce536..f093342c77 100644 --- a/app/controllers/onebox_controller.rb +++ b/app/controllers/onebox_controller.rb @@ -15,6 +15,7 @@ class OneboxController < ApplicationController user_id = current_user.id category_id = params[:category_id].to_i + topic_id = params[:topic_id].to_i invalidate = params[:refresh] == 'true' url = params[:url] @@ -24,7 +25,8 @@ class OneboxController < ApplicationController preview = Oneboxer.preview(url, invalidate_oneboxes: invalidate, user_id: user_id, - category_id: category_id + category_id: category_id, + topic_id: topic_id ) preview.strip! if preview.present? diff --git a/lib/oneboxer.rb b/lib/oneboxer.rb index 338ec2b2a8..ecf9d14195 100644 --- a/lib/oneboxer.rb +++ b/lib/oneboxer.rb @@ -170,6 +170,10 @@ module Oneboxer return unless Guardian.new(current_user).can_see_category?(current_category) end + if current_topic = Topic.find_by(id: opts[:topic_id]) + return unless Guardian.new(current_user).can_see_topic?(current_topic) + end + topic = Topic.find_by(id: route[:topic_id]) return unless topic @@ -187,7 +191,7 @@ module Oneboxer return if !post || post.hidden || post.post_type != Post.types[:regular] - if post_number > 1 + if post_number > 1 && current_topic&.id == topic.id excerpt = post.excerpt(SiteSetting.post_onebox_maxlength) excerpt.gsub!(/[\r\n]+/, " ") excerpt.gsub!("[/quote]", "[quote]") # don't break my quote diff --git a/spec/components/oneboxer_spec.rb b/spec/components/oneboxer_spec.rb index 5fb6e2ccfc..ee1e4600bb 100644 --- a/spec/components/oneboxer_spec.rb +++ b/spec/components/oneboxer_spec.rb @@ -18,8 +18,11 @@ describe Oneboxer do %{#{url}} end - def preview(url, user, category = Category.first) - Oneboxer.preview("#{Discourse.base_url}#{url}", user_id: user.id, category_id: category.id).to_s + def preview(url, user = nil, category = nil, topic = nil) + Oneboxer.preview("#{Discourse.base_url}#{url}", + user_id: user&.id, + category_id: category&.id, + topic_id: topic&.id).to_s end it "links to a topic/post" do @@ -44,7 +47,8 @@ describe Oneboxer do expect(preview(public_topic.relative_url, user, public_category)).to include(public_topic.title) expect(preview(public_post.url, user, public_category)).to include(public_topic.title) - expect(preview(public_reply.url, user, public_category)).to include(public_reply.cooked) + expect(preview(public_reply.url, user, public_category)).to include(public_reply.excerpt) + expect(preview(public_reply.url, user, public_category, public_topic)).not_to include(public_topic.title) expect(preview(public_hidden.url, user, public_category)).to match_html(link(public_hidden.url)) expect(preview(secured_topic.relative_url, user, public_category)).to match_html(link(secured_topic.relative_url)) expect(preview(secured_post.url, user, public_category)).to match_html(link(secured_post.url)) @@ -57,27 +61,27 @@ describe Oneboxer do expect(preview(public_topic.relative_url, staff, secured_category)).to include(public_topic.title) expect(preview(public_post.url, staff, secured_category)).to include(public_topic.title) - expect(preview(public_reply.url, staff, secured_category)).to include(public_reply.cooked) + expect(preview(public_reply.url, staff, secured_category)).to include(public_reply.excerpt) expect(preview(public_hidden.url, staff, secured_category)).to match_html(link(public_hidden.url)) expect(preview(secured_topic.relative_url, staff, secured_category)).to include(secured_topic.title) expect(preview(secured_post.url, staff, secured_category)).to include(secured_topic.title) - expect(preview(secured_reply.url, staff, secured_category)).to include(secured_reply.cooked) + expect(preview(secured_reply.url, staff, secured_category)).to include(secured_reply.excerpt) + expect(preview(secured_reply.url, staff, secured_category, secured_topic)).not_to include(secured_topic.title) end it "links to an user profile" do user = Fabricate(:user) - expect(preview("/u/does-not-exist", user)).to match_html(link("/u/does-not-exist")) - expect(preview("/u/#{user.username}", user)).to include(user.name) + expect(preview("/u/does-not-exist")).to match_html(link("/u/does-not-exist")) + expect(preview("/u/#{user.username}")).to include(user.name) end it "links to an upload" do - user = Fabricate(:user) path = "/uploads/default/original/3X/e/8/e8fcfa624e4fb6623eea57f54941a58ba797f14d" - expect(preview("#{path}.pdf", user)).to match_html(link("#{path}.pdf")) - expect(preview("#{path}.MP3", user)).to include("
{{/if}} + +
+
{{i18n 'user.second_factor.title'}}
+
+ {{#if model.second_factor_enabled}} + {{i18n "yes_value"}} + {{else}} + {{i18n "no_value"}} + {{/if}} +
+
+ {{#if canDisableSecondFactor}} + {{d-button action="disableSecondFactor" icon="unlock-alt" label="user.second_factor.disable"}} + {{/if}} +
+
{{#if userFields}} diff --git a/app/assets/javascripts/discourse/components/login-modal.js.es6 b/app/assets/javascripts/discourse/components/login-modal.js.es6 index e366392b34..c4d710966a 100644 --- a/app/assets/javascripts/discourse/components/login-modal.js.es6 +++ b/app/assets/javascripts/discourse/components/login-modal.js.es6 @@ -11,7 +11,7 @@ export default Ember.Component.extend({ } Ember.run.schedule('afterRender', () => { - $('#login-account-password, #login-account-name').keydown(e => { + $('#login-account-password, #login-account-name, #login-second-factor').keydown(e => { if (e.keyCode === 13) { this.sendAction(); } diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6 index 6eb266d98f..5103317221 100644 --- a/app/assets/javascripts/discourse/controllers/login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/login.js.es6 @@ -31,6 +31,9 @@ export default Ember.Controller.extend(ModalFunctionality, { this.set('authenticate', null); this.set('loggingIn', false); this.set('loggedIn', false); + this.set('secondFactorRequired', false); + $("#credentials").show(); + $("#second-factor").hide(); }, // Determines whether at least one login button is enabled @@ -67,12 +70,19 @@ export default Ember.Controller.extend(ModalFunctionality, { this.set('loggingIn', true); ajax("/session", { - data: { login: this.get('loginName'), password: this.get('loginPassword') }, + data: { login: this.get('loginName'), password: this.get('loginPassword'), second_factor_token: this.get('loginSecondFactor') }, type: 'POST' }).then(function (result) { // Successful login if (result && result.error) { self.set('loggingIn', false); + if(result.reason === 'invalid_second_factor' && !self.get('secondFactorRequired')) { + $('#modal-alert').hide(); + self.set('secondFactorRequired', true); + $("#credentials").hide(); + $("#second-factor").show(); + return; + } if (result.reason === 'not_activated') { self.send('showNotActivated', { username: self.get('loginName'), diff --git a/app/assets/javascripts/discourse/controllers/password-reset.js.es6 b/app/assets/javascripts/discourse/controllers/password-reset.js.es6 index 91c62436d6..0c1a9e0faf 100644 --- a/app/assets/javascripts/discourse/controllers/password-reset.js.es6 +++ b/app/assets/javascripts/discourse/controllers/password-reset.js.es6 @@ -8,6 +8,7 @@ import { userPath } from 'discourse/lib/url'; export default Ember.Controller.extend(PasswordValidation, { isDeveloper: Ember.computed.alias('model.is_developer'), admin: Ember.computed.alias('model.admin'), + secondFactorRequired: Ember.computed.alias('model.second_factor_required'), passwordRequired: true, errorMessage: null, successMessage: null, @@ -32,7 +33,8 @@ export default Ember.Controller.extend(PasswordValidation, { url: userPath(`password-reset/${this.get('model.token')}.json`), type: 'PUT', data: { - password: this.get('accountPassword') + password: this.get('accountPassword'), + second_factor_token: this.get('secondFactor') } }).then(result => { if (result.success) { @@ -45,7 +47,19 @@ export default Ember.Controller.extend(PasswordValidation, { DiscourseURL.redirectTo(result.redirect_to || '/'); } } else { - if (result.errors && result.errors.password && result.errors.password.length > 0) { + if (result.errors && result.errors.second_factor) { + this.setProperties({ + secondFactorRequired: true, + password: null, + errorMessage: result.message + }); + } + else if (this.get('secondFactorRequired')) { + //ok 2factor + this.set('secondFactorRequired',false); + this.set('errorMessage', null); + } + else if (result.errors && result.errors.password && result.errors.password.length > 0) { this.get('rejectedPasswords').pushObject(this.get('accountPassword')); this.get('rejectedPasswordsMessages').set(this.get('accountPassword'), result.errors.password[0]); } diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 new file mode 100644 index 0000000000..4365bdd168 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 @@ -0,0 +1,72 @@ +import { default as computed } from 'ember-addons/ember-computed-decorators'; +import DiscourseURL from 'discourse/lib/url'; +import { userPath } from 'discourse/lib/url'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +export default Ember.Controller.extend({ + + loading: false, + password: null, + secondFactorImage: null, + secondFactorKey: null, + showSecondFactorKey: false, + + errorMessage: null, + newUsername: null, + + @computed('secondFactorImage','secondFactorKey') + loaded(secondFactorImage, secondFactorKey) { + return secondFactorImage && secondFactorKey; + }, + + @computed('loading') + submitButtonText(loading) { + if (loading) return I18n.t('loading'); + return I18n.t('submit'); + }, + + toggleSecondFactor(enable) { + if(!this.get('second_factor_token')) { + return; + } + this.set('loading', true); + this.get('content').toggleSecondFactor(this.get('second_factor_token'), enable).then((resp) => { + if(resp.error) { + this.set('errorMessage',resp.error); + return; + } + this.set('errorMessage',null); + DiscourseURL.redirectTo(userPath(this.get('content').username.toLowerCase() + "/preferences")); + }) + .catch(popupAjaxError) + .finally(() => this.set('loading', false)); + }, + + actions: { + confirmPassword() { + if(!this.get('password')) { + return; + } + this.set('loading', true); + this.get('content').loadSecondFactorCodes(this.get('password')).then((resp) => { + if(resp.error) { + this.set('errorMessage',resp.error); + return; + } + this.set('errorMessage',null); + this.set('secondFactorKey', resp.key); + this.set('secondFactorImage', resp.qr); + }).catch(popupAjaxError) + .finally(() => this.set('loading', false)); + }, + showSecondFactorKey() { + this.set('showSecondFactorKey', true); + }, + enableSecondFactor() { + this.toggleSecondFactor(true); + }, + disableSecondFactor() { + this.toggleSecondFactor(false); + } + } +}); diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index 9925d4798a..ad06bb36e4 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -304,6 +304,23 @@ const User = RestModel.extend({ }); }, + loadSecondFactorCodes(password) { + return ajax("/second_factor/create", { + dataType: 'json', + data: { login: this.get('username'), + password: password}, + type: 'POST' + }); + }, + + toggleSecondFactor(token, enable) { + return ajax(userPath(`${this.get('username_lower')}/preferences/second-factor`), { + dataType: 'json', + data: { token, enable }, + type: 'POST' + }); + }, + loadUserAction(id) { const stream = this.get('stream'); return ajax(`/user_actions/${id}.json`, { cache: 'false' }).then(result => { diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index 72dca540a1..58f541d736 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -110,6 +110,7 @@ export default function() { this.route('username'); this.route('email'); + this.route('second-factor'); this.route('about', { path: '/about-me' }); this.route('badgeTitle', { path: '/badge_title' }); this.route('card-badge', { path: '/card-badge' }); diff --git a/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 b/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 new file mode 100644 index 0000000000..fdc9a5fac7 --- /dev/null +++ b/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 @@ -0,0 +1,21 @@ +import RestrictedUserRoute from "discourse/routes/restricted-user"; + +export default RestrictedUserRoute.extend({ + model() { + return this.modelFor('user'); + }, + + renderTemplate() { + return this.render({ into: 'user' }); + }, + + // A bit odd, but if we leave to /preferences we need to re-render that outlet + deactivate() { + this._super(); + this.render('preferences', { into: 'user', controller: 'preferences' }); + }, + + setupController(controller, user) { + controller.setProperties({ model: user, newUsername: user.get('username') }); + } +}); diff --git a/app/assets/javascripts/discourse/routes/preferences.js.es6 b/app/assets/javascripts/discourse/routes/preferences.js.es6 index 1eb3d4b3e1..ef42132ddc 100644 --- a/app/assets/javascripts/discourse/routes/preferences.js.es6 +++ b/app/assets/javascripts/discourse/routes/preferences.js.es6 @@ -15,6 +15,9 @@ export default RestrictedUserRoute.extend({ }, actions: { + showTwoFactorModal() { + showModal('second-factor-intro'); + }, showAvatarSelector() { showModal('avatar-selector'); diff --git a/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs b/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs new file mode 100644 index 0000000000..0535a9578c --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs @@ -0,0 +1,15 @@ + diff --git a/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs b/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs index 0e191a6c8d..a9483afdd4 100644 --- a/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs +++ b/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs @@ -1,9 +1,9 @@ -{{#login-modal screenX=lastX screenY=lastY loginName=loginName loginPassword=loginPassword action="login"}} +{{#login-modal screenX=lastX screenY=lastY loginName=loginName loginPassword=loginPassword loginSecondFactor=loginSecondFactor action="login"}} {{#d-modal-body title="login.title" class="login-modal"}} {{login-buttons action="externalLogin"}} {{#if canLoginLocal}}
-
+
@@ -29,8 +29,9 @@
@@ -15,10 +15,10 @@
- + - {{text-field value=loginPassword type="password" id="login-account-password" maxlength="200"}}   + {{text-field value=loginPassword type="password" id="login-account-password" maxlength="200"}}  
- - + {{#second-factor-form}} + {{text-field value=loginSecondFactor id="login-second-factor" autocorrect="off" autocapitalize="off" autofocus="autofocus"}} + {{/second-factor-form}} {{/if}} {{authMessage}} @@ -44,9 +45,9 @@ {{#if canLoginLocal}} {{#if showSignupLink}} diff --git a/app/assets/javascripts/discourse/templates/modal/login.hbs b/app/assets/javascripts/discourse/templates/modal/login.hbs index 9b47c12958..0da7e8ad04 100644 --- a/app/assets/javascripts/discourse/templates/modal/login.hbs +++ b/app/assets/javascripts/discourse/templates/modal/login.hbs @@ -1,9 +1,9 @@ -{{#login-modal screenX=lastX screenY=lastY loginName=loginName loginPassword=loginPassword action="login"}} +{{#login-modal screenX=lastX screenY=lastY loginName=loginName loginPassword=loginPassword loginSecondFactor=loginSecondFactor action="login"}} {{#d-modal-body title="login.title" class="login-modal"}} {{login-buttons action="externalLogin"}} {{#if canLoginLocal}}
-
+
@@ -22,6 +22,9 @@
+ {{#second-factor-form}} + {{text-field value=loginSecondFactor id="login-second-factor" autocorrect="off" autocapitalize="off" autofocus="autofocus"}} + {{/second-factor-form}} {{/if}} {{authMessage}} diff --git a/app/assets/javascripts/discourse/templates/modal/second-factor-intro.hbs b/app/assets/javascripts/discourse/templates/modal/second-factor-intro.hbs new file mode 100644 index 0000000000..f573c45cc9 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/second-factor-intro.hbs @@ -0,0 +1,6 @@ +{{#d-modal-body title="user.second_factor.title"}} +
{{{i18n 'user.second_factor.extended_description'}}}
+{{/d-modal-body}} + + diff --git a/app/assets/javascripts/discourse/templates/password-reset.hbs b/app/assets/javascripts/discourse/templates/password-reset.hbs index e999fedb1e..01d1db9df3 100644 --- a/app/assets/javascripts/discourse/templates/password-reset.hbs +++ b/app/assets/javascripts/discourse/templates/password-reset.hbs @@ -16,20 +16,28 @@ {{/if}} {{else}}
+ {{#if secondFactorRequired}} +

{{i18n 'login.second_factor_title'}}

+

{{i18n 'login.second_factor_description'}}

+
+ {{input value=secondFactor id="second-factor" autofocus="autofocus"}} +
+ {{d-button action="submit" class='btn-primary' label='submit'}} + {{else}} +

{{i18n 'user.change_password.choose'}}

-

{{i18n 'user.change_password.choose'}}

+
+ {{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn autofocus="autofocus"}} +  {{input-tip validation=passwordValidation}} +
-
- {{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn autofocus="autofocus"}} -  {{input-tip validation=passwordValidation}} -
+
+
+ {{d-icon "exclamation-triangle"}} {{i18n 'login.caps_lock_warning'}}
+
-
-
- {{d-icon "exclamation-triangle"}} {{i18n 'login.caps_lock_warning'}}
-
- - + {{d-button action="submit" class='btn-primary' label='user.change_password.set_password'}} + {{/if}} {{#if errorMessage}}

diff --git a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs new file mode 100644 index 0000000000..dfafe0ed8e --- /dev/null +++ b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs @@ -0,0 +1,69 @@ +
+ + +
+ +
+ {{#if model.second_factor_enabled}} +

{{i18n 'user.second_factor.disable_description'}}

+ + {{text-field value=second_factor_token id="second_factor_token" classNames="input-large" autofocus="autofocus"}} +

+ {{#if errorMessage}} + {{errorMessage}} + {{/if}} +

+ + {{else}} + {{#if loaded}} +

{{i18n 'user.second_factor.enable_description'}}

+
+ {{{ secondFactorImage }}} +

+ {{#if showSecondFactorKey}} + {{ secondFactorKey }} + {{else}} + {{i18n 'user.second_factor.show_key_description'}} + {{/if}} +

+
+
+ + {{text-field value=second_factor_token id="second_factor_token" classNames="input-large" autofocus="autofocus"}} +
+

+ {{#if errorMessage}} + {{errorMessage}} + {{/if}} +

+
+ +
+ {{else}} +
+

{{i18n 'user.second_factor.confirm_password_description'}}

+ +
+ {{text-field value=password id="password" type="password" classNames="input-xxlarge" autofocus="autofocus"}} +
+

+ {{#if errorMessage}} + {{errorMessage}} + {{/if}} +

+
+ +
+
+ + {{#if saved}}{{i18n 'saved'}}{{/if}} +
+
+ {{/if}} + {{/if}} +
+
+ + + +
diff --git a/app/assets/javascripts/discourse/templates/preferences/account.hbs b/app/assets/javascripts/discourse/templates/preferences/account.hbs index 38569dd9bd..a37f525e9c 100644 --- a/app/assets/javascripts/discourse/templates/preferences/account.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/account.hbs @@ -14,6 +14,7 @@ {{/if}}
+ {{#if canEditName}}
@@ -66,6 +67,23 @@ {{passwordProgress}}
+
+ +
+ {{#link-to "preferences.second-factor" class="btn"}} + {{#if model.second_factor_enabled}} + {{d-icon "unlock-alt"}} + {{i18n 'user.second_factor.disable'}} + {{else}} + {{d-icon "lock"}} + {{i18n 'user.second_factor.enable'}} + {{/if}} + {{/link-to}} +
+ +
{{/if}}
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index d948d5f5f0..75615c23b0 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -25,7 +25,8 @@ class Admin::UsersController < Admin::AdminController :generate_api_key, :revoke_api_key, :anonymize, - :reset_bounce_score] + :reset_bounce_score, + :disable_second_factor] def index users = ::AdminUserIndexQuery.new(params).find_users @@ -340,6 +341,18 @@ class Admin::UsersController < Admin::AdminController } end + def disable_second_factor + guardian.ensure_can_disable_second_factor! @user + if @user.user_second_factor.try(:delete) + StaffActionLogger.new(current_user).log_disable_second_factor_auth(@user) + end + Jobs.enqueue( + :critical_user_email, + type: :account_second_factor_disabled, + user_id: @user.id + ) + end + def destroy user = User.find_by(id: params[:id].to_i) guardian.ensure_can_delete_user!(user) diff --git a/app/controllers/second_factor_controller.rb b/app/controllers/second_factor_controller.rb new file mode 100644 index 0000000000..ca22f0d25d --- /dev/null +++ b/app/controllers/second_factor_controller.rb @@ -0,0 +1,51 @@ +class SecondFactorController < ApplicationController + + def create + RateLimiter.new(nil, "login-hr-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_hour, 1.hour).performed! + RateLimiter.new(nil, "login-min-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_minute, 1.minute).performed! + if user = User.find_by_username_or_email(params[:login]) + unless user.confirm_password?(params[:password]) + return invalid_credentials + end + qrcode = RQRCode::QRCode.new(SecondFactorHelper.provisioning_uri(user)) + qrcode_svg = qrcode.as_svg( + offset: 0, + color: '000', + shape_rendering: 'crispEdges', + module_size: 4 + ) + render json: { key: user.user_second_factor.data, qr: qrcode_svg } + end + end + + def update + params.require(:token) + user = fetch_user_from_params + unless SecondFactorHelper.authenticate(user, params[:token]) + RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! + render json: { error: I18n.t("login.invalid_second_factor_code") } + return + end + if params[:enable] == "true" + SecondFactorHelper.create_totp(user) + user.user_second_factor.enabled = true + user.user_second_factor.save! + return render json: { result: "ok", action: "enabled" } + else + user.user_second_factor.delete + Jobs.enqueue( + :critical_user_email, + type: :account_second_factor_disabled, + user_id: user.id + ) + return render json: { result: "ok", action: "disabled" } + end + end + + private + + def invalid_credentials + render json: { error: I18n.t("login.incorrect_username_email_or_password") } + end + +end diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 8b0e0f243c..61f93af276 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -188,6 +188,10 @@ class SessionController < ApplicationController end def create + unless params[:second_factor_token].blank? + RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! + end + params.require(:login) params.require(:password) @@ -221,6 +225,12 @@ class SessionController < ApplicationController if payload = login_error_check(user) render json: payload else + + if SecondFactorHelper.totp_enabled?(user) + unless SecondFactorHelper.authenticate(user, params[:second_factor_token]) + return render json: { error: I18n.t("login.invalid_second_factor_code"), reason: "invalid_second_factor" } + end + end (user.active && user.email_confirmed?) ? login(user) : not_activated(user) end end @@ -228,6 +238,14 @@ class SessionController < ApplicationController def email_login raise Discourse::NotFound if !SiteSetting.enable_local_logins_via_email + if params[:second_factor_token].present? + @error = I18n.t("login.invalid_second_factor_code") + RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! + end + unless EmailToken.second_factor_valid(params[:token], params[:second_factor_token]) + @second_factor_required = true + return render layout: 'no_ember' + end if EmailToken.valid_token_format?(params[:token]) && (user = EmailToken.confirm(params[:token])) if login_not_approved_for?(user) @error = login_not_approved[:error] diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 0ccaa73102..4a9ccd31b0 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -470,12 +470,21 @@ class UsersController < ApplicationController end end + if @user && (!SecondFactorHelper.totp_enabled?(@user) || SecondFactorHelper.authenticate(@user, params[:second_factor_token])) + secure_session["second-factor-#{token}"] = "true" + end + @valid_second_factor = secure_session["second-factor-#{token}"] == "true" + if !@user @error = I18n.t('password_reset.no_token') elsif request.put? @invalid_password = params[:password].blank? || params[:password].length > User.max_password_length - if @invalid_password + if !@valid_second_factor + RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! + @user.errors.add(:second_factor, :invalid) + @error = I18n.t('login.invalid_second_factor_code') + elsif @invalid_password @user.errors.add(:password, :invalid) else @user.password = params[:password] @@ -484,6 +493,7 @@ class UsersController < ApplicationController if @user.save Invite.invalidate_for_email(@user.email) # invite link can't be used to log in anymore secure_session["password-#{token}"] = nil + secure_session["second-factor-#{token}"] = nil logon_after_password_reset end end @@ -496,7 +506,7 @@ class UsersController < ApplicationController else store_preloaded( "password_reset", - MultiJson.dump(is_developer: UsernameCheckerService.is_developer?(@user.email), admin: @user.admin?) + MultiJson.dump(is_developer: UsernameCheckerService.is_developer?(@user.email), admin: @user.admin?, second_factor_required: !@valid_second_factor) ) end return redirect_to(wizard_path) if request.put? && Wizard.user_requires_completion?(@user) @@ -521,7 +531,7 @@ class UsersController < ApplicationController } end else - render json: { is_developer: UsernameCheckerService.is_developer?(@user.email), admin: @user.admin? } + render json: { is_developer: UsernameCheckerService.is_developer?(@user.email), admin: @user.admin?, second_factor_required: !@valid_second_factor } end end end @@ -550,7 +560,7 @@ class UsersController < ApplicationController def admin_login return redirect_to(path("/")) if current_user - if request.put? + if request.put? && params[:email].present? RateLimiter.new(nil, "admin-login-hr-#{request.remote_ip}", 6, 1.hour).performed! RateLimiter.new(nil, "admin-login-min-#{request.remote_ip}", 3, 1.minute).performed! @@ -563,13 +573,20 @@ class UsersController < ApplicationController end elsif params[:token].present? if EmailToken.valid_token_format?(params[:token]) - @user = EmailToken.confirm(params[:token]) - - if @user&.admin? - log_on_user(@user) - return redirect_to path("/") + if params[:second_factor_token].present? + RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! + end + if EmailToken.second_factor_valid(params[:token], params[:second_factor_token]) + @user = EmailToken.confirm(params[:token]) + if @user && @user.admin? + log_on_user(@user) + return redirect_to path("/") + else + @message = I18n.t("admin_login.errors.unknown_email_address") + end else - @message = I18n.t("admin_login.errors.unknown_email_address") + @second_factor_required = true + @message = I18n.t("login.second_factor_title") end else @message = I18n.t("admin_login.errors.invalid_token") diff --git a/app/controllers/users_email_controller.rb b/app/controllers/users_email_controller.rb index e408a84f2a..45b7920a9a 100644 --- a/app/controllers/users_email_controller.rb +++ b/app/controllers/users_email_controller.rb @@ -33,6 +33,21 @@ class UsersEmailController < ApplicationController def confirm expires_now + token = EmailToken.confirmable params[:token] + change_req = token&.user&.email_change_requests + &.where('new_email_token_id = :token_id', token_id: token.id) + &.first + if change_req.try(:change_state) == EmailChangeRequest.states[:authorizing_new] && + !EmailToken.second_factor_valid(params[:token], params[:second_factor_token]) + @update_result = :invalid_second_factor + if params[:second_factor_token].present? + RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! + @show_invalid_second_factor_error = true + end + render layout: 'no_ember' + return + end + updater = EmailUpdater.new @update_result = updater.confirm(params[:token]) diff --git a/app/helpers/second_factor_helper.rb b/app/helpers/second_factor_helper.rb new file mode 100644 index 0000000000..0d2feb304a --- /dev/null +++ b/app/helpers/second_factor_helper.rb @@ -0,0 +1,35 @@ +module SecondFactorHelper + + def self.totp(user) + self.create_totp user + ROTP::TOTP.new(user.user_second_factor.data, issuer: SiteSetting.title) + end + + def self.create_totp(user) + if !user.user_second_factor + user.user_second_factor = UserSecondFactor.create(user_id: user.id, method: "totp", data: ROTP::Base32.random_base32) + end + end + + def self.provisioning_uri(user) + self.totp(user).provisioning_uri(user.email) + end + + def self.authenticate(user, token) + totp = self.totp(user) + last_used = 0 + if user.user_second_factor.last_used + last_used = user.user_second_factor.last_used.to_i + end + authenticated = !token.blank? && totp.verify_with_drift_and_prior(token, 0, last_used) + if authenticated + user.user_second_factor.last_used = DateTime.now + user.user_second_factor.save + end + return authenticated + end + + def self.totp_enabled?(user) + !!user.user_second_factor && user.user_second_factor.enabled? + end +end diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index 9d0df12796..c9cd1a032d 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -120,6 +120,15 @@ class UserNotifications < ActionMailer::Base ) end + def account_second_factor_disabled(user, opts = {}) + build_email( + user.email, + template: 'user_notifications.account_second_factor_disabled', + locale: user_locale(user), + email: user.email + ) + end + def short_date(dt) if dt.year == Time.now.year I18n.l(dt, format: :short_no_year) diff --git a/app/models/email_token.rb b/app/models/email_token.rb index 2a10dd4e23..7cfe74dab5 100644 --- a/app/models/email_token.rb +++ b/app/models/email_token.rb @@ -39,6 +39,15 @@ class EmailToken < ActiveRecord::Base token.present? && token =~ /\h{#{token.length / 2}}/i end + def self.second_factor_valid(token, second_factor_token) + # Fail only when token is valid, second factor token is required, and does NOT check out. + return true unless valid_token_format?(token) + email_token = confirmable(token) + return true if email_token.blank? + return true unless SecondFactorHelper.totp_enabled?(email_token.user) + return SecondFactorHelper.authenticate(email_token.user, second_factor_token) + end + def self.atomic_confirm(token) failure = { success: false } return failure unless valid_token_format?(token) diff --git a/app/models/user.rb b/app/models/user.rb index 48b328a06f..0514b7038c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -60,6 +60,7 @@ class User < ActiveRecord::Base has_one :github_user_info, dependent: :destroy has_one :google_user_info, dependent: :destroy has_one :oauth2_user_info, dependent: :destroy + has_one :user_second_factor, dependent: :destroy has_one :user_stat, dependent: :destroy has_one :user_profile, dependent: :destroy, inverse_of: :user has_one :single_sign_on_record, dependent: :destroy @@ -461,6 +462,10 @@ class User < ActiveRecord::Base '' # so that validator doesn't complain that a password attribute doesn't exist end + def second_factor + '' # so that validator doesn't complain that a password attribute doesn't exist + end + # Indicate that this is NOT a passwordless account for the purposes of validation def password_required! @password_required = true diff --git a/app/models/user_history.rb b/app/models/user_history.rb index bf2fe2b6e4..5e809af46e 100644 --- a/app/models/user_history.rb +++ b/app/models/user_history.rb @@ -66,7 +66,8 @@ class UserHistory < ActiveRecord::Base change_name: 48, post_locked: 49, post_unlocked: 50, - check_personal_message: 51) + check_personal_message: 51, + disabled_second_factor: 52) end # Staff actions is a subset of all actions, used to audit actions taken by staff users. @@ -110,7 +111,8 @@ class UserHistory < ActiveRecord::Base :backup_destroy, :post_locked, :post_unlocked, - :check_personal_message] + :check_personal_message, + :disabled_second_factor] end def self.staff_action_ids diff --git a/app/models/user_second_factor.rb b/app/models/user_second_factor.rb new file mode 100644 index 0000000000..da48d2302f --- /dev/null +++ b/app/models/user_second_factor.rb @@ -0,0 +1,3 @@ +class UserSecondFactor < ActiveRecord::Base + belongs_to :user +end diff --git a/app/serializers/admin_detailed_user_serializer.rb b/app/serializers/admin_detailed_user_serializer.rb index 8cc9316cb5..f1a0e1c93f 100644 --- a/app/serializers/admin_detailed_user_serializer.rb +++ b/app/serializers/admin_detailed_user_serializer.rb @@ -25,7 +25,9 @@ class AdminDetailedUserSerializer < AdminUserSerializer :user_fields, :bounce_score, :reset_bounce_score_after, - :can_view_action_logs + :can_view_action_logs, + :second_factor_enabled, + :can_disable_second_factor has_one :approved_by, serializer: BasicUserSerializer, embed: :objects has_one :api_key, serializer: ApiKeySerializer, embed: :objects @@ -34,6 +36,19 @@ class AdminDetailedUserSerializer < AdminUserSerializer has_one :tl3_requirements, serializer: TrustLevel3RequirementsSerializer, embed: :objects has_many :groups, embed: :object, serializer: BasicGroupSerializer + def include_second_factor_enabled? + scope.is_staff? + end + + def can_disable_second_factor + (object.id && object.id != scope.user.try(:id)) && + scope.is_staff? + end + + def second_factor_enabled + SecondFactorHelper.totp_enabled?(object) + end + def can_revoke_admin scope.can_revoke_admin?(object) end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 30bb1038c6..63677173ce 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -1,3 +1,5 @@ +require 'rqrcode' + class UserSerializer < BasicUserSerializer attr_accessor :omit_stats, @@ -72,7 +74,8 @@ class UserSerializer < BasicUserSerializer :primary_group_flair_url, :primary_group_flair_bg_color, :primary_group_flair_color, - :staged + :staged, + :second_factor_enabled has_one :invited_by, embed: :object, serializer: BasicUserSerializer has_many :groups, embed: :object, serializer: BasicGroupSerializer @@ -145,6 +148,15 @@ class UserSerializer < BasicUserSerializer (scope.is_staff? && object.staged?) end + def include_second_factor_enabled? + (object.id && object.id == scope.user.try(:id)) || + scope.is_staff? + end + + def second_factor_enabled + SecondFactorHelper.totp_enabled?(object) + end + def can_change_bio !(SiteSetting.enable_sso && SiteSetting.sso_overrides_bio) end diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb index b5a64c3f2f..6058c1b584 100644 --- a/app/services/staff_action_logger.rb +++ b/app/services/staff_action_logger.rb @@ -305,6 +305,12 @@ class StaffActionLogger target_user_id: user.id)) end + def log_disable_second_factor_auth(user, opts = {}) + raise Discourse::InvalidParameters.new(:user) unless user + UserHistory.create(params(opts).merge(action: UserHistory.actions[:disabled_second_factor], + target_user_id: user.id)) + end + def log_grant_admin(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user UserHistory.create(params(opts).merge(action: UserHistory.actions[:grant_admin], diff --git a/app/views/session/email_login.html.erb b/app/views/session/email_login.html.erb index 7bd6cf03fd..03704e0a44 100644 --- a/app/views/session/email_login.html.erb +++ b/app/views/session/email_login.html.erb @@ -3,6 +3,18 @@ <%= @error %>
<%end%> +<%if @second_factor_required%> +
+
+ <%= form_tag(method: "post") do%> +

<%=t "login.second_factor_title" %>

+ <%= label_tag(:second_factor_token, t("login.second_factor_description")) %> +
<%= text_field_tag(:second_factor_token) %>
+ <%= submit_tag(t("login.submit"), class: "btn btn-large btn-primary") %> + <%end%> +
+
+<%end%> <% content_for :title do %><%=t "email_login.title" %><% end %> diff --git a/app/views/users/admin_login.html.erb b/app/views/users/admin_login.html.erb index 9b40e951ec..b6fc06f972 100644 --- a/app/views/users/admin_login.html.erb +++ b/app/views/users/admin_login.html.erb @@ -5,6 +5,13 @@ <% if @message %> <%= @message %> + <% if @second_factor_required %> + <%=form_tag({}, method: :put) do %> + <%= label_tag(:second_factor_token, t('login.second_factor_description')) %> + <%= text_field_tag(:second_factor_token, nil, autofocus: true) %>

+ <%= submit_tag t('login.submit')%> + <% end %> + <% end %> <% else %> <%=form_tag({}, method: :put) do %> <%= label_tag(:email, t('admin_login.email_input')) %> diff --git a/app/views/users_email/confirm.html.erb b/app/views/users_email/confirm.html.erb index 0538ddfaac..9411b3473c 100644 --- a/app/views/users_email/confirm.html.erb +++ b/app/views/users_email/confirm.html.erb @@ -7,6 +7,17 @@

<%= t 'change_email.confirmed' %>


<%= t('change_email.please_continue', site_name: SiteSetting.title) %> + <% elsif @update_result == :invalid_second_factor%> +

<%= t('login.second_factor_title') %>

+
+ <%=form_tag({}, method: :put) do %> + <%= label_tag(:second_factor_token, t('login.second_factor_description')) %> + <%= text_field_tag(:second_factor_token, nil, autofocus: true) %>
+ <% if @show_invalid_second_factor_error %> +
<%= t('login.invalid_second_factor_code') %>
+ <% end %> + <%= submit_tag t('login.submit'), class: "btn btn-primary" %> + <% end %> <% else %>
<%=t 'change_email.already_done' %> diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 56d9beaefe..835cbfbe4f 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -207,6 +207,7 @@ en: not_implemented: "That feature hasn't been implemented yet, sorry!" no_value: "No" yes_value: "Yes" + submit: "Submit" generic_error: "Sorry, an error has occurred." generic_error_with_reason: "An error occurred: %{error}" sign_up: "Sign Up" @@ -707,6 +708,17 @@ en: choose_new: "Choose a new password" choose: "Choose a password" + second_factor: + title: "Two Factor Authentication" + enable: "Enable 2-Step Verification" + disable: "Disable 2-Step Verification" + confirm_password_description: "Confirm your password to continue enabling 2-Step Verification." + enable_description: "To complete 2-Step Verification setup, scan the following QR code and submit a 2-Step Verification code." + disable_description: "Enter a 2-Step Verification code to disable." + show_key_description: "Or enter the key manually." + info_prompt: "What is Two Factor authentication?" + extended_description: "Two-factor authentication adds an extra security step to logging in by requiring a one-time token in addition to your password. These tokens are generated by compatible apps for iPhone or Android such as Google Authenticator, Authy, and FreeOTP." + change_about: title: "Change About Me" error: "There was an error changing this value." @@ -1097,6 +1109,9 @@ en: title: "Log In" username: "User" password: "Password" + second_factor_title: "2-Step Verification Required" + second_factor_description: "Enter a generated verification code." + second_factor_label: "Code" email_placeholder: "email or username" caps_lock_warning: "Caps Lock is on" error: "Unknown error" @@ -3262,6 +3277,7 @@ en: post_locked: "post locked" post_unlocked: "post unlocked" check_personal_message: "check personal message" + disabled_second_factor: "disable 2-step auth" screened_emails: title: "Screened Emails" description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index c326faf39c..42e61730f4 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1783,6 +1783,10 @@ en: auth_complete: "Authentication is complete." click_to_continue: "Click here to continue." already_logged_in: "Oops, looks like you are attempting to accept an invitation for another user. If you are not %{current_user}, please log out and try again." + second_factor_title: "2-Step Verification Required" + second_factor_description: "Enter a generated verification code." + invalid_second_factor_code: "Invalid 2-Step Verification Code" + submit: "Submit" user: no_accounts_associated: "No accounts associated" @@ -2730,6 +2734,15 @@ en: + account_second_factor_disabled: + title: "2-Step Verification disabled" + subject_template: "[%{email_prefix}] 2-Step Verification disabled" + text_body_template: | + Your account’s 2-Step verification at %{site_name} has been disabled. The account no longer needs a 2-Step Verification code to sign in. + + If you have any questions, [contact our friendly staff](%{base_url}/about). + + digest: why: "A brief summary of %{site_link} since your last visit on %{last_seen_at}" since_last_visit: "Since your last visit" diff --git a/config/routes.rb b/config/routes.rb index bb1b8c8fed..bf838b8ff4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -129,6 +129,7 @@ Discourse::Application.routes.draw do get "tl3_requirements" put "anonymize" post "reset_bounce_score" + put "disable_second_factor" end get "users/:id.json" => 'users#show', defaults: { format: 'json' } get 'users/:id/:username' => 'users#show', constraints: { username: RouteFormat.username } @@ -302,6 +303,8 @@ Discourse::Application.routes.draw do get "session/current" => "session#current" get "session/csrf" => "session#csrf" get "session/email-login/:token" => "session#email_login" + post "session/email-login/:token" => "session#email_login" + post "second_factor/create" => "second_factor#create" get "composer_messages" => "composer_messages#index" post "composer/parse_html" => "composer#parse_html" @@ -335,6 +338,7 @@ Discourse::Application.routes.draw do get "#{root_path}/admin-login" => "users#admin_login" put "#{root_path}/admin-login" => "users#admin_login" get "#{root_path}/admin-login/:token" => "users#admin_login" + put "#{root_path}/admin-login/:token" => "users#admin_login" post "#{root_path}/toggle-anon" => "users#toggle_anon" post "#{root_path}/read-faq" => "users#read_faq" get "#{root_path}/search/users" => "users#search_users" @@ -349,6 +353,7 @@ Discourse::Application.routes.draw do get "#{root_path}/activate-account/:token" => "users#activate_account" put({ "#{root_path}/activate-account/:token" => "users#perform_account_activation" }.merge(index == 1 ? { as: 'perform_activate_account' } : {})) get "#{root_path}/authorize-email/:token" => "users_email#confirm" + put "#{root_path}/authorize-email/:token" => "users_email#confirm" get({ "#{root_path}/confirm-admin/:token" => "users#confirm_admin", constraints: { token: /[0-9a-f]+/ } @@ -380,6 +385,8 @@ Discourse::Application.routes.draw do put "#{root_path}/:username/preferences/badge_title" => "users#badge_title", constraints: { username: RouteFormat.username } get "#{root_path}/:username/preferences/username" => "users#preferences", constraints: { username: RouteFormat.username } put "#{root_path}/:username/preferences/username" => "users#username", constraints: { username: RouteFormat.username } + post "#{root_path}/:username/preferences/second-factor" => "second_factor#update", constraints: { username: RouteFormat.username } + get "#{root_path}/:username/preferences/second-factor" => "users#preferences", constraints: { username: RouteFormat.username } delete "#{root_path}/:username/preferences/user_image" => "users#destroy_user_image", constraints: { username: RouteFormat.username } put "#{root_path}/:username/preferences/avatar/pick" => "users#pick_avatar", constraints: { username: RouteFormat.username } get "#{root_path}/:username/preferences/card-badge" => "users#card_badge", constraints: { username: RouteFormat.username } diff --git a/db/migrate/20180109222722_create_user_second_factors.rb b/db/migrate/20180109222722_create_user_second_factors.rb new file mode 100644 index 0000000000..46abb216d1 --- /dev/null +++ b/db/migrate/20180109222722_create_user_second_factors.rb @@ -0,0 +1,12 @@ +class CreateUserSecondFactors < ActiveRecord::Migration[5.1] + def change + create_table :user_second_factors do |t| + t.integer :user_id, null: false + t.string :method + t.string :data + t.boolean :enabled, null: false, default: false + t.timestamp :last_used + t.timestamps + end + end +end diff --git a/lib/guardian/user_guardian.rb b/lib/guardian/user_guardian.rb index 26a1056262..c9f056b034 100644 --- a/lib/guardian/user_guardian.rb +++ b/lib/guardian/user_guardian.rb @@ -72,4 +72,8 @@ module UserGuardian user == @user || is_staff? end + def can_disable_second_factor?(user) + user && can_administer_user?(user) + end + end diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 703021890f..0c81cde596 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -265,6 +265,19 @@ describe Admin::UsersController do end end + context '#disable_second_factor' do + before do + @another_user = Fabricate(:user) + SecondFactorHelper.create_totp(@another_user) + end + + it 'disables the second factor' do + expect(User.find(@another_user.id).user_second_factor).not_to eq(nil) + put :disable_second_factor, params: { user_id: @another_user.id }, format: :json + expect(User.find(@another_user.id).user_second_factor).to eq(nil) + end + end + context '#add_group' do let(:user) { Fabricate(:user) } let(:group) { Fabricate(:group) } diff --git a/spec/controllers/second_factor_controller_spec.rb b/spec/controllers/second_factor_controller_spec.rb new file mode 100644 index 0000000000..d74ba44ce7 --- /dev/null +++ b/spec/controllers/second_factor_controller_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +RSpec.describe SecondFactorController, type: :controller do + # featheredtoast-todo also write qunit tests. + describe '.create' do + + let(:user) { Fabricate(:user) } + + describe 'create 2fa request' do + it 'fails on incorrect password' do + post :create, params: { + login: user.username, password: 'wrongpassword' + }, format: :json + expect(JSON.parse(response.body)['error']).to eq(I18n.t("login.incorrect_username_email_or_password")) + end + + it 'succeeds on correct password' do + post :create, params: { + login: user.username, password: 'myawesomepassword' + }, format: :json + expect(JSON.parse(response.body).keys).to contain_exactly('key', 'qr') + end + end + end + + describe '.update' do + let(:user) { Fabricate(:user) } + + context 'when user has totp setup' do + second_factor_data = "rcyryaqage3jexfj" + before do + user.user_second_factor = UserSecondFactor.create(user_id: user.id, method: "totp", data: second_factor_data) + end + + it 'errors on incorrect code' do + post :update, params: { + username: user.username, + token: '000000', + enable: 'true' + }, format: :json + expect(JSON.parse(response.body)['error']).to eq(I18n.t("login.invalid_second_factor_code")) + user.reload + end + + it 'can be enabled' do + post :update, params: { + username: user.username, + token: ROTP::TOTP.new(second_factor_data).now, + enable: 'true' + }, format: :json + expect(JSON.parse(response.body)['result']).to eq('ok') + user.reload + expect(user.user_second_factor.enabled).to be true + end + + it 'can be disabled' do + post :update, params: { + username: user.username, + enable: 'false', + token: ROTP::TOTP.new(second_factor_data).now + }, format: :json + expect(JSON.parse(response.body)['result']).to eq('ok') + user.reload + expect(user.user_second_factor).to be_nil + end + end + end + +end diff --git a/spec/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb index 96b58c5483..0b83ab8ab2 100644 --- a/spec/controllers/session_controller_spec.rb +++ b/spec/controllers/session_controller_spec.rb @@ -584,6 +584,39 @@ describe SessionController do end end + context 'when user has 2-factor logins' do + second_factor_data = "rcyryaqage3jexfj" + before do + user.user_second_factor = UserSecondFactor.create(user_id: user.id, method: "totp", data: second_factor_data, enabled: true) + end + + describe 'failure no 2-factor' do + it 'should return an error' do + post :create, params: { + login: user.username, password: 'myawesomepassword' + }, format: :json + expect(JSON.parse(response.body)['error']).to eq(I18n.t('login.invalid_second_factor_code')) + end + end + describe 'successful 2-factor' do + it 'logs in correctly' do + events = DiscourseEvent.track_events do + post :create, params: { + login: user.username, password: 'myawesomepassword', second_factor_token: ROTP::TOTP.new(second_factor_data).now + }, format: :json + end + + expect(events.map { |event| event[:event_name] }).to include(:user_logged_in, :user_first_logged_in) + + user.reload + + expect(session[:current_user_id]).to eq(user.id) + expect(user.user_auth_tokens.count).to eq(1) + expect(UserAuthToken.hash_token(cookies[:_t])).to eq(user.user_auth_tokens.first.auth_token) + end + end + end + describe 'with a blocked IP' do before do screened_ip = Fabricate(:screened_ip_address) @@ -777,6 +810,26 @@ describe SessionController do login: user.username, password: 'myawesomepassword' }, format: :json + expect(response).not_to be_success + json = JSON.parse(response.body) + expect(json["error_type"]).to eq("rate_limit") + end + it 'rate limits second factor attempts' do + RateLimiter.enable + RateLimiter.clear_all! + + 3.times do + post :create, params: { + login: user.username, password: 'myawesomepassword', second_factor_token: '000000' + }, format: :json + + expect(response).to be_success + end + + post :create, params: { + login: user.username, password: 'myawesomepassword', second_factor_token: '000000' + }, format: :json + expect(response).not_to be_success json = JSON.parse(response.body) expect(json["error_type"]).to eq("rate_limit") diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index f9b802a02d..206fd6bcf5 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -343,7 +343,7 @@ describe UsersController do ) expect(response).to be_success - expect(response.body).to include('{"is_developer":false,"admin":false}') + expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":false}') user.reload @@ -406,6 +406,46 @@ describe UsersController do expect(email_token.confirmed).to eq(false) expect(UserAuthToken.where(id: user_token.id).count).to eq(1) end + + context '2-factor required' do + + second_factor_data = "rcyryaqage3jexfj" + let(:user) { Fabricate(:user) } + + before do + user.user_second_factor = UserSecondFactor.create(user_id: user.id, method: "totp", data: second_factor_data, enabled: true) + end + + it 'does not change with an invalid token' do + token = user.email_tokens.create(email: user.email).token + + get :password_reset, params: { token: token } + + expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":true}') + + put :password_reset, + params: { token: token, password: 'hg9ow8yHG32O', second_factor_token: '000000' } + + expect(response.body).to include(I18n.t("login.invalid_second_factor_code")) + + user.reload + expect(user.confirm_password?('hg9ow8yHG32O')).not_to eq(true) + expect(user.user_auth_tokens.count).not_to eq(1) + end + + it 'changes password with valid 2-factor tokens' do + token = user.email_tokens.create(email: user.email).token + + get :password_reset, params: { token: token } + + put :password_reset, + params: { token: token, password: 'hg9ow8yHG32O', second_factor_token: ROTP::TOTP.new(second_factor_data).now } + + user.reload + expect(user.confirm_password?('hg9ow8yHG32O')).to eq(true) + expect(user.user_auth_tokens.count).to eq(1) + end + end end context 'submit change' do @@ -514,6 +554,29 @@ describe UsersController do expect(session[:current_user_id]).to eq(admin.id) end end + + context 'needs 2-factor' do + render_views + second_factor_data = "rcyryaqage3jexfj" + before do + admin.user_second_factor = UserSecondFactor.create(user_id: admin.id, method: "totp", data: second_factor_data, enabled: true) + end + + it 'does not log in when token required' do + token = admin.email_tokens.create(email: admin.email).token + get :admin_login, params: { token: token } + expect(response).not_to redirect_to('/') + expect(session[:current_user_id]).not_to eq(admin.id) + expect(response.body).to include(I18n.t('login.second_factor_description')); + end + + it 'logs in when a valid 2-factor token is given' do + token = admin.email_tokens.create(email: admin.email).token + put :admin_login, params: { token: token, second_factor_token: ROTP::TOTP.new(second_factor_data).now } + expect(response).to redirect_to('/') + expect(session[:current_user_id]).to eq(admin.id) + end + end end end diff --git a/spec/requests/session_controller_spec.rb b/spec/requests/session_controller_spec.rb index 951968984f..65004efe14 100644 --- a/spec/requests/session_controller_spec.rb +++ b/spec/requests/session_controller_spec.rb @@ -136,6 +136,41 @@ RSpec.describe SessionController do date: I18n.l(user.suspended_till, format: :date_only) )) end + + context 'user has 2-factor logins' do + second_factor_data = "rcyryaqage3jexfj" + before do + user.user_second_factor = UserSecondFactor.create(user_id: user.id, method: "totp", data: second_factor_data, enabled: true) + end + + describe 'requires second factor' do + it 'should return a second factor prompt' do + get "/session/email-login/#{email_token.token}" + + expect(response.status).to eq(200) + + expect(CGI.unescapeHTML(response.body)).to include(I18n.t("login.second_factor_title")) + end + end + + describe 'errors on incorrect 2-factor' do + it 'does not log in with incorrect two factor' do + post "/session/email-login/#{email_token.token}", params: { second_factor_token: "0000" } + + expect(response.status).to eq(200) + + expect(CGI.unescapeHTML(response.body)).to include(I18n.t("login.invalid_second_factor_code")) + end + end + + describe 'allows successful 2-factor' do + it 'logs in correctly' do + post "/session/email-login/#{email_token.token}", params: { second_factor_token: ROTP::TOTP.new(second_factor_data).now } + + expect(response).to redirect_to("/") + end + end + end end end end diff --git a/spec/requests/users_email_controller_spec.rb b/spec/requests/users_email_controller_spec.rb index 04912f8f20..e7feb560db 100644 --- a/spec/requests/users_email_controller_spec.rb +++ b/spec/requests/users_email_controller_spec.rb @@ -60,6 +60,35 @@ describe UsersEmailController do expect(user.user_stat.bounce_score).to eq(0) expect(user.user_stat.reset_bounce_score_after).to eq(nil) end + + context 'second factor required' do + second_factor_data = "rcyryaqage3jexfj" + before do + user.user_second_factor = UserSecondFactor.create(user_id: user.id, method: "totp", data: second_factor_data, enabled: true) + end + + it 'requires a second factor token' do + get "/u/authorize-email/#{user.email_tokens.last.token}" + expect(response.body).to include(I18n.t("login.second_factor_title")) + expect(response.body).not_to include(I18n.t("login.invalid_second_factor_code")) + end + + it 'adds an error on a second factor attempt' do + get "/u/authorize-email/#{user.email_tokens.last.token}", params: { + second_factor_token: "000000" + } + expect(response.body).to include(I18n.t("login.invalid_second_factor_code")) + end + + it 'confirms with a correct second token' do + get "/u/authorize-email/#{user.email_tokens.last.token}", params: { + second_factor_token: ROTP::TOTP.new(second_factor_data).now + } + expect(response).to be_success + expect(response.body).not_to include(I18n.t("login.second_factor_title")) + expect(response.body).not_to include(I18n.t("login.invalid_second_factor_code")) + end + end end end diff --git a/test/javascripts/acceptance/password-reset-test.js.es6 b/test/javascripts/acceptance/password-reset-test.js.es6 index 4706b88c29..211ef5f47b 100644 --- a/test/javascripts/acceptance/password-reset-test.js.es6 +++ b/test/javascripts/acceptance/password-reset-test.js.es6 @@ -24,6 +24,20 @@ acceptance("Password Reset", { return response({success: "OK", message: I18n.t('password_reset.success')}); } }); + + server.get('/u/confirm-email-token/requiretwofactor.json', () => { //eslint-disable-line + return response({success: "OK"}); + }); + server.put('/u/password-reset/requiretwofactor.json', request => { //eslint-disable-line + const body = parsePostData(request.requestBody); + if (body.password === "perf3ctly5ecur3" && body.second_factor_token === "123123") { + return response({success: "OK", message: I18n.t('password_reset.success')}); + } else if (body.second_factor_token === "123123") { + return response({success: false, errors: {password: ["invalid"]}}); + } else { + return response({success: false, message: "invalid token", errors: {second_factor: ["invalid token"]}}); + } + }); } }); @@ -58,4 +72,35 @@ QUnit.test("Password Reset Page", assert => { andThen(() => { assert.ok(!exists(".password-reset form"), "form is gone"); }); -}); \ No newline at end of file +}); + +QUnit.test("Password Reset Page With Second Factor", assert => { + PreloadStore.store('password_reset', {is_developer: false, second_factor_required: true}); + + visit("/u/password-reset/requiretwofactor"); + andThen(() => { + assert.notOk(exists("#new-account-password"), "does not show the input"); + assert.ok(exists("#second-factor"), "shows the second factor prompt"); + }); + + fillIn('#second-factor', '0000'); + + click('.password-reset form button'); + andThen(() => { + assert.ok(exists(".alert-error"), "shows 2 factor error"); + assert.ok(find(".alert-error").html().indexOf("invalid token") > -1, "server validation error message shows"); + }); + + fillIn('#second-factor', '123123'); + click('.password-reset form button'); + andThen(() => { + assert.notOk(exists(".alert-error"), "hides error"); + assert.ok(exists("#new-account-password"), "shows the input"); + }); + + fillIn('.password-reset input', 'perf3ctly5ecur3'); + click('.password-reset form button'); + andThen(() => { + assert.ok(!exists(".password-reset form"), "form is gone"); + }); +}); diff --git a/test/javascripts/acceptance/preferences-test.js.es6 b/test/javascripts/acceptance/preferences-test.js.es6 index 0014cefd65..c8fbee7aae 100644 --- a/test/javascripts/acceptance/preferences-test.js.es6 +++ b/test/javascripts/acceptance/preferences-test.js.es6 @@ -1,5 +1,20 @@ import { acceptance } from "helpers/qunit-helpers"; -acceptance("User Preferences", { loggedIn: true }); +acceptance("User Preferences", { + loggedIn: true, + beforeEach() { + const response = (object) => { + return [ + 200, + {"Content-Type": "application/json"}, + object + ]; + }; + + server.post('/second_factor/create', () => { //eslint-disable-line + return response({key: "rcyryaqage3jexfj", qr: '
qr-code
'}); + }); + } +}); QUnit.test("update some fields", assert => { visit("/u/eviltrout/preferences"); @@ -73,3 +88,16 @@ QUnit.test("email", assert => { assert.equal(find('.tip.bad').text().trim(), I18n.t('user.email.invalid'), 'it should display invalid email tip'); }); }); + +QUnit.test("second factor", assert => { + visit("/u/eviltrout/preferences/second-factor"); + andThen(() => { + assert.ok(exists("#password"), "it has a password input"); + }); + fillIn('#password', 'secrets'); + click(".user-content .btn-primary"); + andThen(() => { + assert.ok(exists("#test-qr"), "shows qr code"); + assert.notOk(exists("#password"), "it hides the password input"); + }); +}); diff --git a/test/javascripts/acceptance/sign-in-test.js.es6 b/test/javascripts/acceptance/sign-in-test.js.es6 index 12d77228a5..ffe8c9cab7 100644 --- a/test/javascripts/acceptance/sign-in-test.js.es6 +++ b/test/javascripts/acceptance/sign-in-test.js.es6 @@ -76,6 +76,32 @@ QUnit.test("sign in - not activated - edit email", assert => { }); }); +QUnit.test("second factor", assert => { + visit("/"); + click("header .login-button"); + andThen(() => { + assert.ok(exists('.login-modal'), "it shows the login modal"); + }); + + // Login with username and password only + fillIn('#login-account-name', 'eviltrout'); + fillIn('#login-account-password', 'need-second-factor'); + click('.modal-footer .btn-primary'); + andThen(() => { + assert.not(exists('#modal-alert:visible'), 'it hides the login error'); + assert.not(exists('#credentials:visible'), 'it hides the username and password prompt'); + assert.ok(exists('#second-factor:visible'), 'it displays the second factor prompt'); + assert.not(exists('.modal-footer .btn-primary:disabled'), "enables the login button"); + }); + + // Login with username, password, and token + fillIn('#login-second-factor', '123456'); + click('.modal-footer .btn-primary'); + andThen(() => { + assert.ok(exists('.modal-footer .btn-primary:disabled'), "disables the login button"); + }); +}); + QUnit.test("create account", assert => { visit("/"); click("header .sign-up-button"); @@ -106,4 +132,4 @@ QUnit.test("create account", assert => { andThen(() => { assert.ok(exists('.modal-footer .btn-primary:disabled'), "create account is disabled"); }); -}); \ No newline at end of file +}); diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6 index b0bb47e3e8..db8b8520a1 100644 --- a/test/javascripts/helpers/create-pretender.js.es6 +++ b/test/javascripts/helpers/create-pretender.js.es6 @@ -227,6 +227,16 @@ export default function() { current_email: 'current@example.com' }); } + if (data.password === 'need-second-factor') { + if (data.second_factor_token) { + return response({username: 'eviltrout'}); + } + return response({ error: "Invalid Second Factor", + reason: "invalid_second_factor", + sent_to_email: 'eviltrout@example.com', + current_email: 'current@example.com' }); + } + return response(400, {error: 'invalid login'}); }); From 5c40ae9e63e5071503919c38f4f04e8f0532de69 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Tue, 20 Feb 2018 20:41:27 -0500 Subject: [PATCH 041/299] FIX: Links in quotes should be counted for rate limits --- app/models/post_analyzer.rb | 2 +- spec/models/post_spec.rb | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/models/post_analyzer.rb b/app/models/post_analyzer.rb index ddcfa2fd0c..57e67c5efa 100644 --- a/app/models/post_analyzer.rb +++ b/app/models/post_analyzer.rb @@ -130,7 +130,7 @@ class PostAnalyzer def cooked_stripped @cooked_stripped ||= begin doc = Nokogiri::HTML.fragment(cook(@raw, topic_id: @topic_id)) - doc.css("pre, code, aside.quote, .onebox, .elided").remove + doc.css("pre, code, aside.quote > .title, aside.quote .mention, .onebox, .elided").remove doc end end diff --git a/spec/models/post_spec.rb b/spec/models/post_spec.rb index 9ccf9a80df..b5571fb370 100644 --- a/spec/models/post_spec.rb +++ b/spec/models/post_spec.rb @@ -169,6 +169,7 @@ describe Post do let(:post_two_images) { post_with_body(" ", newuser) } let(:post_with_avatars) { post_with_body('smiley wink', newuser) } let(:post_with_favicon) { post_with_body('', newuser) } + let(:post_image_within_quote) { post_with_body('[quote][/quote]', newuser) } let(:post_with_thumbnail) { post_with_body('', newuser) } let(:post_with_two_classy_images) { post_with_body(" ", newuser) } @@ -198,6 +199,12 @@ describe Post do expect(post_one_image).not_to be_valid end + it "doesn't allow more than `min_trust_to_post_images`" do + SiteSetting.min_trust_to_post_images = 4 + post_one_image.user.trust_level = 3 + expect(post_image_within_quote).not_to be_valid + end + it "doesn't allow more than `min_trust_to_post_images`" do SiteSetting.min_trust_to_post_images = 4 post_one_image.user.trust_level = 4 From ca1a3f37e343ddebee7a9a89cae92a7b261b4f81 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 21 Feb 2018 15:19:59 +1100 Subject: [PATCH 042/299] FEATURE: add instrumentation for all external net calls --- config/initializers/100-lograge.rb | 16 +++++++++++++++- lib/hijack.rb | 30 ++++++++++++++++++++++++++++++ lib/middleware/request_tracker.rb | 8 ++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/config/initializers/100-lograge.rb b/config/initializers/100-lograge.rb index a1089a7b27..6ab44c6abe 100644 --- a/config/initializers/100-lograge.rb +++ b/config/initializers/100-lograge.rb @@ -8,6 +8,13 @@ if (Rails.env.production? && SiteSetting.logging_provider == 'lograge') || ENV[" Rails.application.configure do config.lograge.enabled = true + Lograge.ignore(lambda do |event| + # this is our hijack magic status, + # no point logging this cause we log again + # direct from hijack + event.payload[:status] == 418 + end) + config.lograge.custom_payload do |controller| begin username = @@ -46,7 +53,7 @@ if (Rails.env.production? && SiteSetting.logging_provider == 'lograge') || ENV[" database: RailsMultisite::ConnectionManagement.current_db, } - if data = Thread.current[:_method_profiler] + if data = (Thread.current[:_method_profiler] || event.payload[:timings]) sql = data[:sql] if sql @@ -60,6 +67,13 @@ if (Rails.env.production? && SiteSetting.logging_provider == 'lograge') || ENV[" output[:redis] = redis[:duration] * 1000 output[:redis_calls] = redis[:calls] end + + net = data[:net] + + if net + output[:net] = net[:duration] * 1000 + output[:net_calls] = net[:calls] + end end output diff --git a/lib/hijack.rb b/lib/hijack.rb index 9814ea4bef..4fc49c2720 100644 --- a/lib/hijack.rb +++ b/lib/hijack.rb @@ -51,12 +51,14 @@ module Hijack instance.response.headers[k] = v end + view_start = Process.clock_gettime(Process::CLOCK_MONOTONIC) begin instance.instance_eval(&blk) rescue => e # TODO we need to reuse our exception handling in ApplicationController Discourse.warn_exception(e, message: "Failed to process hijacked response correctly", env: env) end + view_runtime = Process.clock_gettime(Process::CLOCK_MONOTONIC) - view_start unless instance.response_body || response.committed? instance.status = 500 @@ -94,6 +96,34 @@ module Hijack # happens if client terminated before we responded, ignore io = nil ensure + + if Rails.configuration.lograge.enabled + if timings + db_runtime = 0 + if timings[:sql] + db_runtime = timings[:sql][:duration] + end + + subscriber = Lograge::RequestLogSubscriber.new + payload = ActiveSupport::HashWithIndifferentAccess.new( + controller: self.class.name, + action: action_name, + params: request.filtered_parameters, + headers: request.headers, + format: request.format.ref, + method: request.request_method, + path: request.fullpath, + view_runtime: view_runtime * 1000.0, + db_runtime: db_runtime * 1000.0, + timings: timings, + status: response.status + ) + + event = ActiveSupport::Notifications::Event.new("hijack", Time.now, Time.now + timings[:total_duration], "", payload) + subscriber.process_action(event) + end + end + MethodProfiler.clear Thread.current[Logster::Logger::LOGSTER_ENV] = nil diff --git a/lib/middleware/request_tracker.rb b/lib/middleware/request_tracker.rb index f0ce009a6c..d53b029ee8 100644 --- a/lib/middleware/request_tracker.rb +++ b/lib/middleware/request_tracker.rb @@ -24,6 +24,14 @@ class Middleware::RequestTracker MethodProfiler.patch(Redis::Client, [ :call, :call_pipeline ], :redis) + + MethodProfiler.patch(Net::HTTP, [ + :request + ], :net) + + MethodProfiler.patch(Excon::Connection, [ + :request + ], :net) @patched_instrumentation = true end From 26450f75877ed21c14617df3ccdd713b378053fb Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 21 Feb 2018 15:40:37 +1100 Subject: [PATCH 043/299] allow for no lograge (fixes tests) --- lib/hijack.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/hijack.rb b/lib/hijack.rb index 4fc49c2720..25a6acc7db 100644 --- a/lib/hijack.rb +++ b/lib/hijack.rb @@ -97,7 +97,7 @@ module Hijack io = nil ensure - if Rails.configuration.lograge.enabled + if Rails.configuration.try(:lograge).try(:enabled) if timings db_runtime = 0 if timings[:sql] From 94fb8094c6d8baf85ad41e7139e5520e4e7be23b Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Wed, 21 Feb 2018 11:32:40 +0530 Subject: [PATCH 044/299] further optimize spec thanks @tgxworld for the review. --- spec/components/validators/max_emojis_validator_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/components/validators/max_emojis_validator_spec.rb b/spec/components/validators/max_emojis_validator_spec.rb index 0b97215256..a81bed2fb8 100644 --- a/spec/components/validators/max_emojis_validator_spec.rb +++ b/spec/components/validators/max_emojis_validator_spec.rb @@ -16,7 +16,7 @@ describe MaxEmojisValidator do SiteSetting.max_emojis_in_title = 3 record.title = '🧐 Lots of emojis here 🎃 :joy: :sunglasses:' validate - expect(record.errors[:title][0]).to eq("can't have more than 3 emoji") + expect(record.errors[:title][0]).to eq(I18n.t("errors.messages.max_emojis", max_emojis_count: 3)) end end From 14f3594f9f39dce7f6a5dda44287560b0a6e53e8 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Tue, 20 Feb 2018 14:44:51 +0800 Subject: [PATCH 045/299] Review Changes for https://github.com/discourse/discourse/pull/5612/commits/f4f8a293e74e6a37c7bee7645d9ca1d72a7d5bd3. --- .../admin/controllers/admin-user-index.js.es6 | 10 +- .../admin/models/admin-user.js.es6 | 2 +- .../discourse/controllers/login.js.es6 | 22 ++- .../controllers/password-reset.js.es6 | 16 +- .../preferences/second-factor.js.es6 | 67 +++---- .../javascripts/discourse/models/user.js.es6 | 13 +- .../routes/preferences-second-factor.js.es6 | 10 +- .../discourse/routes/preferences.js.es6 | 1 + .../components/second-factor-form.hbs | 1 + .../templates/mobile/modal/login.hbs | 16 +- .../discourse/templates/modal/login.hbs | 8 +- .../templates/preferences-second-factor.hbs | 165 +++++++++++------- .../templates/preferences/account.hbs | 17 +- app/controllers/admin/users_controller.rb | 13 +- app/controllers/second_factor_controller.rb | 51 ------ app/controllers/session_controller.rb | 30 ++-- app/controllers/users_controller.rb | 95 ++++++++-- app/controllers/users_email_controller.rb | 35 ++-- app/helpers/second_factor_helper.rb | 35 ---- app/models/concerns/second_factor_manager.rb | 38 ++++ app/models/email_token.rb | 9 - app/models/user.rb | 5 +- app/models/user_second_factor.rb | 20 +++ .../admin_detailed_user_serializer.rb | 11 +- app/serializers/user_serializer.rb | 7 +- app/views/session/email_login.html.erb | 3 +- app/views/users_email/confirm.html.erb | 2 +- config/application.rb | 15 +- config/locales/client.en.yml | 18 +- config/locales/server.en.yml | 15 +- config/routes.rb | 5 +- ...180109222722_create_user_second_factors.rb | 4 +- .../concern/second_factor_manager_spec.rb | 93 ++++++++++ .../admin/users_controller_spec.rb | 13 -- .../second_factor_controller_spec.rb | 69 -------- spec/controllers/session_controller_spec.rb | 69 +++++--- spec/controllers/users_controller_spec.rb | 36 ++-- .../user_second_factor_fabricator.rb | 6 + spec/models/user_second_factor_spec.rb | 9 + spec/requests/admin/users_controller_spec.rb | 50 ++++++ spec/requests/session_controller_spec.rb | 17 +- spec/requests/users_controller_spec.rb | 114 ++++++++++++ spec/requests/users_email_controller_spec.rb | 39 +++-- .../acceptance/password-reset-test.js.es6 | 29 ++- .../acceptance/preferences-test.js.es6 | 24 ++- .../acceptance/sign-in-test.js.es6 | 5 +- .../helpers/create-pretender.js.es6 | 3 +- 47 files changed, 843 insertions(+), 492 deletions(-) delete mode 100644 app/controllers/second_factor_controller.rb delete mode 100644 app/helpers/second_factor_helper.rb create mode 100644 app/models/concerns/second_factor_manager.rb create mode 100644 spec/components/concern/second_factor_manager_spec.rb delete mode 100644 spec/controllers/second_factor_controller_spec.rb create mode 100644 spec/fabricators/user_second_factor_fabricator.rb create mode 100644 spec/models/user_second_factor_spec.rb create mode 100644 spec/requests/admin/users_controller_spec.rb diff --git a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 index b232d5bc28..770d5588b0 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 @@ -19,6 +19,11 @@ export default Ember.Controller.extend(CanCheckEmails, { primaryGroupDirty: propertyNotEqual('originalPrimaryGroupId', 'model.primary_group_id'), + canDisableSecondFactor: Ember.computed.and( + 'model.second_factor_enabled', + 'model.can_disable_second_factor' + ), + automaticGroups: function() { return this.get("model.automaticGroups").map((g) => g.name).join(", "); }.property("model.automaticGroups"), @@ -41,11 +46,6 @@ export default Ember.Controller.extend(CanCheckEmails, { return userPath(`${username}/preferences`); }, - @computed('model.second_factor_enabled','model.can_disable_second_factor') - canDisableSecondFactor(secondFactorEnabled, canDisableSecondFactor) { - return secondFactorEnabled && canDisableSecondFactor; - }, - actions: { impersonate() { return this.get("model").impersonate(); }, diff --git a/app/assets/javascripts/admin/models/admin-user.js.es6 b/app/assets/javascripts/admin/models/admin-user.js.es6 index 52348e4019..99aec6aaf7 100644 --- a/app/assets/javascripts/admin/models/admin-user.js.es6 +++ b/app/assets/javascripts/admin/models/admin-user.js.es6 @@ -169,7 +169,7 @@ const AdminUser = Discourse.User.extend({ }, disableSecondFactor() { - return ajax("/admin/users/" + this.get('id') + "/disable_second_factor", { + return ajax(`/admin/users/${this.get('id')}/disable_second_factor`, { type: 'PUT' }).then(() => { this.set('second_factor_enabled', false); diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6 index 5103317221..31d339b7c0 100644 --- a/app/assets/javascripts/discourse/controllers/login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/login.js.es6 @@ -4,6 +4,7 @@ import showModal from 'discourse/lib/show-modal'; import { setting } from 'discourse/lib/computed'; import { findAll } from 'discourse/models/login-method'; import { escape } from 'pretty-text/sanitizer'; +import computed from 'ember-addons/ember-computed-decorators'; // This is happening outside of the app via popup const AuthErrors = [ @@ -41,9 +42,10 @@ export default Ember.Controller.extend(ModalFunctionality, { return findAll(this.siteSettings).length > 0; }.property(), - loginButtonText: function() { - return this.get('loggingIn') ? I18n.t('login.logging_in') : I18n.t('login.title'); - }.property('loggingIn'), + @computed('loggingIn') + loginButtonLabel(loggingIn) { + return loggingIn ? 'login.logging_in' : 'login.title'; + }, loginDisabled: Em.computed.or('loggingIn', 'loggedIn'), @@ -70,20 +72,24 @@ export default Ember.Controller.extend(ModalFunctionality, { this.set('loggingIn', true); ajax("/session", { - data: { login: this.get('loginName'), password: this.get('loginPassword'), second_factor_token: this.get('loginSecondFactor') }, - type: 'POST' + type: 'POST', + data: { + login: this.get('loginName'), + password: this.get('loginPassword'), + second_factor_token: this.get('loginSecondFactor') + }, }).then(function (result) { // Successful login if (result && result.error) { self.set('loggingIn', false); - if(result.reason === 'invalid_second_factor' && !self.get('secondFactorRequired')) { + + if (result.reason === 'invalid_second_factor' && !self.get('secondFactorRequired')) { $('#modal-alert').hide(); self.set('secondFactorRequired', true); $("#credentials").hide(); $("#second-factor").show(); return; - } - if (result.reason === 'not_activated') { + } else if (result.reason === 'not_activated') { self.send('showNotActivated', { username: self.get('loginName'), sentTo: escape(result.sent_to_email), diff --git a/app/assets/javascripts/discourse/controllers/password-reset.js.es6 b/app/assets/javascripts/discourse/controllers/password-reset.js.es6 index 0c1a9e0faf..bcf40ca88c 100644 --- a/app/assets/javascripts/discourse/controllers/password-reset.js.es6 +++ b/app/assets/javascripts/discourse/controllers/password-reset.js.es6 @@ -47,22 +47,22 @@ export default Ember.Controller.extend(PasswordValidation, { DiscourseURL.redirectTo(result.redirect_to || '/'); } } else { - if (result.errors && result.errors.second_factor) { + if (result.errors && result.errors.user_second_factor) { this.setProperties({ secondFactorRequired: true, password: null, errorMessage: result.message }); - } - else if (this.get('secondFactorRequired')) { - //ok 2factor - this.set('secondFactorRequired',false); - this.set('errorMessage', null); - } - else if (result.errors && result.errors.password && result.errors.password.length > 0) { + } else if (this.get('secondFactorRequired')) { + this.setProperties({ + secondFactorRequired: false, + errorMessage: null + }); + } else if (result.errors && result.errors.password && result.errors.password.length > 0) { this.get('rejectedPasswords').pushObject(this.get('accountPassword')); this.get('rejectedPasswordsMessages').set(this.get('accountPassword'), result.errors.password[0]); } + if (result.message) { this.set('errorMessage', result.message); } diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 index 4365bdd168..990d2eb6f9 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 @@ -1,70 +1,71 @@ import { default as computed } from 'ember-addons/ember-computed-decorators'; -import DiscourseURL from 'discourse/lib/url'; -import { userPath } from 'discourse/lib/url'; +import { default as DiscourseURL, userPath } from 'discourse/lib/url'; import { popupAjaxError } from 'discourse/lib/ajax-error'; export default Ember.Controller.extend({ - loading: false, password: null, secondFactorImage: null, secondFactorKey: null, showSecondFactorKey: false, - errorMessage: null, newUsername: null, - @computed('secondFactorImage','secondFactorKey') - loaded(secondFactorImage, secondFactorKey) { - return secondFactorImage && secondFactorKey; - }, + loaded: Ember.computed.and('secondFactorImage', 'secondFactorKey'), @computed('loading') submitButtonText(loading) { - if (loading) return I18n.t('loading'); - return I18n.t('submit'); + return loading ? 'loading' : 'submit'; }, toggleSecondFactor(enable) { - if(!this.get('second_factor_token')) { - return; - } + if (!this.get('second_factor_token')) return; this.set('loading', true); - this.get('content').toggleSecondFactor(this.get('second_factor_token'), enable).then((resp) => { - if(resp.error) { - this.set('errorMessage',resp.error); - return; - } - this.set('errorMessage',null); - DiscourseURL.redirectTo(userPath(this.get('content').username.toLowerCase() + "/preferences")); - }) + + this.get('content').toggleSecondFactor(this.get('second_factor_token'), enable) + .then(response => { + if (response.error) { + this.set('errorMessage', response.error); + return; + } + + this.set('errorMessage',null); + DiscourseURL.redirectTo(userPath(`${this.get('content').username.toLowerCase()}/preferences`)); + }) .catch(popupAjaxError) .finally(() => this.set('loading', false)); }, actions: { confirmPassword() { - if(!this.get('password')) { - return; - } + if (!this.get('password')) return; this.set('loading', true); - this.get('content').loadSecondFactorCodes(this.get('password')).then((resp) => { - if(resp.error) { - this.set('errorMessage',resp.error); - return; - } - this.set('errorMessage',null); - this.set('secondFactorKey', resp.key); - this.set('secondFactorImage', resp.qr); - }).catch(popupAjaxError) + + this.get('content').loadSecondFactorCodes(this.get('password')) + .then(response => { + if(response.error) { + this.set('errorMessage', response.error); + return; + } + + this.setProperties({ + errorMessage: null, + secondFactorKey: response.key, + secondFactorImage: response.qr, + }); + }) + .catch(popupAjaxError) .finally(() => this.set('loading', false)); }, + showSecondFactorKey() { this.set('showSecondFactorKey', true); }, + enableSecondFactor() { this.toggleSecondFactor(true); }, + disableSecondFactor() { this.toggleSecondFactor(false); } diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index ad06bb36e4..eef39aa8ac 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -305,19 +305,16 @@ const User = RestModel.extend({ }, loadSecondFactorCodes(password) { - return ajax("/second_factor/create", { - dataType: 'json', - data: { login: this.get('username'), - password: password}, + return ajax("/u/second_factors.json", { + data: { password }, type: 'POST' }); }, toggleSecondFactor(token, enable) { - return ajax(userPath(`${this.get('username_lower')}/preferences/second-factor`), { - dataType: 'json', - data: { token, enable }, - type: 'POST' + return ajax("/u/second_factor.json", { + data: { second_factor_token: token, enable }, + type: 'PUT' }); }, diff --git a/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 b/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 index fdc9a5fac7..b688ec813b 100644 --- a/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 +++ b/app/assets/javascripts/discourse/routes/preferences-second-factor.js.es6 @@ -9,13 +9,7 @@ export default RestrictedUserRoute.extend({ return this.render({ into: 'user' }); }, - // A bit odd, but if we leave to /preferences we need to re-render that outlet - deactivate() { - this._super(); - this.render('preferences', { into: 'user', controller: 'preferences' }); - }, - - setupController(controller, user) { - controller.setProperties({ model: user, newUsername: user.get('username') }); + setupController(controller, model) { + controller.setProperties({ model, newUsername: model.get('username') }); } }); diff --git a/app/assets/javascripts/discourse/routes/preferences.js.es6 b/app/assets/javascripts/discourse/routes/preferences.js.es6 index ef42132ddc..128a301731 100644 --- a/app/assets/javascripts/discourse/routes/preferences.js.es6 +++ b/app/assets/javascripts/discourse/routes/preferences.js.es6 @@ -18,6 +18,7 @@ export default RestrictedUserRoute.extend({ showTwoFactorModal() { showModal('second-factor-intro'); }, + showAvatarSelector() { showModal('avatar-selector'); diff --git a/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs b/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs index 0535a9578c..a1d5e03340 100644 --- a/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs +++ b/app/assets/javascripts/discourse/templates/components/second-factor-form.hbs @@ -1,6 +1,7 @@ {{#second-factor-form}} - {{text-field value=loginSecondFactor id="login-second-factor" autocorrect="off" autocapitalize="off" autofocus="autofocus"}} + {{text-field value=loginSecondFactor + id="login-second-factor" + autocorrect="off" + autocapitalize="off" + autofocus="autofocus"}} {{/second-factor-form}} {{/if}} @@ -44,11 +48,11 @@ {{/if}} {{#if canLoginLocal}} - + {{d-button action="login" + icon="unlock" + label=loginButtonLabel + disabled=loginDisabled + class='btn btn-large btn-primary'}} {{#if showSignupLink}} + {{d-button action="login" + icon="unlock" + label=loginButtonLabel + disabled=loginDisabled + class='btn btn-large btn-primary'}} {{#if showSignupLink}} - {{else}} - {{#if loaded}} -

{{i18n 'user.second_factor.enable_description'}}

-
- {{{ secondFactorImage }}} -

- {{#if showSecondFactorKey}} - {{ secondFactorKey }} - {{else}} - {{i18n 'user.second_factor.show_key_description'}} - {{/if}} -

-
-
- - {{text-field value=second_factor_token id="second_factor_token" classNames="input-large" autofocus="autofocus"}} -
-

- {{#if errorMessage}} - {{errorMessage}} - {{/if}} -

-
- -
- {{else}} -
-

{{i18n 'user.second_factor.confirm_password_description'}}

- -
- {{text-field value=password id="password" type="password" classNames="input-xxlarge" autofocus="autofocus"}} -
-

- {{#if errorMessage}} - {{errorMessage}} - {{/if}} -

-
- -
-
- - {{#if saved}}{{i18n 'saved'}}{{/if}} -
-
- {{/if}} - {{/if}} +

{{i18n 'user.second_factor.title'}}

+ {{#if errorMessage}} +
+
+
{{errorMessage}}
+
+
+ {{/if}} + {{#if model.second_factor_enabled}} + + +
+
+ {{text-field value=second_factor_token + id="second_factor_token" + classNames="input-large" + autofocus="autofocus"}} +
+ +
+ {{i18n 'user.second_factor.disable_description'}} +
+
+ +
+
+ {{d-button action="disableSecondFactor" + class="btn btn-primary" + disabled=loading + label=submitButtonText}} +
+
+ {{else}} + {{#if loaded}} +
+
+ {{i18n 'user.second_factor.enable_description'}} +
+
+ +
+
+ {{{secondFactorImage}}} + +

+ {{#if showSecondFactorKey}} + {{secondFactorKey}} + {{else}} + {{i18n 'user.second_factor.show_key_description'}} + {{/if}} +

+
+
+ +
+ + +
+ {{text-field value=second_factor_token + id="second-factor-token" + classNames="input-xxlarge" + autofocus="autofocus"}} +
+
+ +
+
+ {{d-button action="enableSecondFactor" + class="btn btn-primary" + disabled=loading + label=submitButtonText}} +
+
+ {{else}} +
+ + +
+ {{text-field value=password + id="password" + type="password" + classNames="input-xxlarge" + autofocus="autofocus"}} +
+ +
+ {{i18n 'user.second_factor.confirm_password_description'}} +
+
+ +
+
+ {{d-button action="confirmPassword" + class="btn btn-primary" + disabled=loading + label=submitButtonText}} + + {{#if saved}}{{i18n 'saved'}}{{/if}} +
+
+ {{/if}} + {{/if}} diff --git a/app/assets/javascripts/discourse/templates/preferences/account.hbs b/app/assets/javascripts/discourse/templates/preferences/account.hbs index a37f525e9c..225db632b9 100644 --- a/app/assets/javascripts/discourse/templates/preferences/account.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/account.hbs @@ -14,7 +14,6 @@ {{/if}} - {{#if canEditName}}
@@ -69,17 +68,19 @@
+
- {{#link-to "preferences.second-factor" class="btn"}} - {{#if model.second_factor_enabled}} + {{#link-to "preferences.second-factor" class="btn"}} + {{#if model.second_factor_enabled}} {{d-icon "unlock-alt"}} - {{i18n 'user.second_factor.disable'}} - {{else}} + {{i18n 'user.second_factor.disable'}} + {{else}} {{d-icon "lock"}} - {{i18n 'user.second_factor.enable'}} - {{/if}} - {{/link-to}} + {{i18n 'user.second_factor.enable'}} + {{/if}} + {{/link-to}}
+ diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 75615c23b0..298161a88d 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -342,15 +342,20 @@ class Admin::UsersController < Admin::AdminController end def disable_second_factor - guardian.ensure_can_disable_second_factor! @user - if @user.user_second_factor.try(:delete) - StaffActionLogger.new(current_user).log_disable_second_factor_auth(@user) - end + guardian.ensure_can_disable_second_factor!(@user) + user_second_factor = @user.user_second_factor + raise Discourse::InvalidParameters unless user_second_factor + + user_second_factor.destroy! + StaffActionLogger.new(current_user).log_disable_second_factor_auth(@user) + Jobs.enqueue( :critical_user_email, type: :account_second_factor_disabled, user_id: @user.id ) + + render json: success_json end def destroy diff --git a/app/controllers/second_factor_controller.rb b/app/controllers/second_factor_controller.rb deleted file mode 100644 index ca22f0d25d..0000000000 --- a/app/controllers/second_factor_controller.rb +++ /dev/null @@ -1,51 +0,0 @@ -class SecondFactorController < ApplicationController - - def create - RateLimiter.new(nil, "login-hr-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_hour, 1.hour).performed! - RateLimiter.new(nil, "login-min-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_minute, 1.minute).performed! - if user = User.find_by_username_or_email(params[:login]) - unless user.confirm_password?(params[:password]) - return invalid_credentials - end - qrcode = RQRCode::QRCode.new(SecondFactorHelper.provisioning_uri(user)) - qrcode_svg = qrcode.as_svg( - offset: 0, - color: '000', - shape_rendering: 'crispEdges', - module_size: 4 - ) - render json: { key: user.user_second_factor.data, qr: qrcode_svg } - end - end - - def update - params.require(:token) - user = fetch_user_from_params - unless SecondFactorHelper.authenticate(user, params[:token]) - RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! - render json: { error: I18n.t("login.invalid_second_factor_code") } - return - end - if params[:enable] == "true" - SecondFactorHelper.create_totp(user) - user.user_second_factor.enabled = true - user.user_second_factor.save! - return render json: { result: "ok", action: "enabled" } - else - user.user_second_factor.delete - Jobs.enqueue( - :critical_user_email, - type: :account_second_factor_disabled, - user_id: user.id - ) - return render json: { result: "ok", action: "disabled" } - end - end - - private - - def invalid_credentials - render json: { error: I18n.t("login.incorrect_username_email_or_password") } - end - -end diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 61f93af276..063ea7d247 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -225,12 +225,13 @@ class SessionController < ApplicationController if payload = login_error_check(user) render json: payload else - - if SecondFactorHelper.totp_enabled?(user) - unless SecondFactorHelper.authenticate(user, params[:second_factor_token]) - return render json: { error: I18n.t("login.invalid_second_factor_code"), reason: "invalid_second_factor" } - end + if user.totp_enabled? && !user.authenticate_totp(params[:second_factor_token]) + return render json: failed_json.merge( + error: I18n.t("login.invalid_second_factor_code"), + reason: "invalid_second_factor" + ) end + (user.active && user.email_confirmed?) ? login(user) : not_activated(user) end end @@ -242,25 +243,28 @@ class SessionController < ApplicationController @error = I18n.t("login.invalid_second_factor_code") RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! end - unless EmailToken.second_factor_valid(params[:token], params[:second_factor_token]) + + token = params[:token] + valid_token = !!EmailToken.valid_token_format?(token) + user = EmailToken.confirmable(token)&.user + + if valid_token && user&.totp_enabled? && !user.authenticate_totp(params[:second_factor_token]) @second_factor_required = true - return render layout: 'no_ember' - end - if EmailToken.valid_token_format?(params[:token]) && (user = EmailToken.confirm(params[:token])) + @error = I18n.t('login.invalid_second_factor_code') + elsif user = EmailToken.confirm(token) if login_not_approved_for?(user) @error = login_not_approved[:error] - return render layout: 'no_ember' elsif payload = login_error_check(user) @error = payload[:error] - return render layout: 'no_ember' else log_on_user(user) - redirect_to path("/") + return redirect_to path("/") end else @error = I18n.t('email_login.invalid_token') - return render layout: 'no_ember' end + + render layout: 'no_ember' end def forgot_password diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 4a9ccd31b0..6fdbb5ad92 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -12,7 +12,7 @@ class UsersController < ApplicationController requires_login only: [ :username, :update, :user_preferences_redirect, :upload_user_image, :pick_avatar, :destroy_user_image, :destroy, :check_emails, :topic_tracking_state, - :preferences + :preferences, :create_second_factor, :update_second_factor ] skip_before_action :check_xhr, only: [ @@ -470,19 +470,22 @@ class UsersController < ApplicationController end end - if @user && (!SecondFactorHelper.totp_enabled?(@user) || SecondFactorHelper.authenticate(@user, params[:second_factor_token])) + totp_enabled = @user&.totp_enabled? + + if !totp_enabled || @user.authenticate_totp(params[:second_factor_token]) secure_session["second-factor-#{token}"] = "true" end - @valid_second_factor = secure_session["second-factor-#{token}"] == "true" + + valid_second_factor = secure_session["second-factor-#{token}"] == "true" if !@user @error = I18n.t('password_reset.no_token') elsif request.put? @invalid_password = params[:password].blank? || params[:password].length > User.max_password_length - if !@valid_second_factor + if !valid_second_factor RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! - @user.errors.add(:second_factor, :invalid) + @user.errors.add(:user_second_factor, :invalid) @error = I18n.t('login.invalid_second_factor_code') elsif @invalid_password @user.errors.add(:password, :invalid) @@ -506,9 +509,14 @@ class UsersController < ApplicationController else store_preloaded( "password_reset", - MultiJson.dump(is_developer: UsernameCheckerService.is_developer?(@user.email), admin: @user.admin?, second_factor_required: !@valid_second_factor) + MultiJson.dump( + is_developer: UsernameCheckerService.is_developer?(@user.email), + admin: @user.admin?, + second_factor_required: !valid_second_factor + ) ) end + return redirect_to(wizard_path) if request.put? && Wizard.user_requires_completion?(@user) end @@ -531,7 +539,11 @@ class UsersController < ApplicationController } end else - render json: { is_developer: UsernameCheckerService.is_developer?(@user.email), admin: @user.admin?, second_factor_required: !@valid_second_factor } + render json: { + is_developer: UsernameCheckerService.is_developer?(@user.email), + admin: @user.admin?, + second_factor_required: !valid_second_factor + } end end end @@ -571,13 +583,20 @@ class UsersController < ApplicationController else @message = I18n.t("admin_login.errors.unknown_email_address") end - elsif params[:token].present? - if EmailToken.valid_token_format?(params[:token]) + elsif (token = params[:token]).present? + valid_token = EmailToken.valid_token_format?(token) + + if valid_token if params[:second_factor_token].present? RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! end - if EmailToken.second_factor_valid(params[:token], params[:second_factor_token]) - @user = EmailToken.confirm(params[:token]) + + email_token_user = EmailToken.confirmable(token)&.user + totp_enabled = email_token_user.totp_enabled? + + if !totp_enabled || email_token_user.authenticate_totp(params[:second_factor_token]) + @user = EmailToken.confirm(token) + if @user && @user.admin? log_on_user(@user) return redirect_to path("/") @@ -916,6 +935,60 @@ class UsersController < ApplicationController render layout: 'no_ember' end + def create_second_factor + RateLimiter.new(nil, "login-hr-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_hour, 1.hour).performed! + RateLimiter.new(nil, "login-min-#{request.remote_ip}", SiteSetting.max_logins_per_ip_per_minute, 1.minute).performed! + + unless current_user.confirm_password?(params[:password]) + return render json: failed_json.merge( + error: I18n.t("login.incorrect_password") + ) + end + + qrcode_svg = RQRCode::QRCode.new(current_user.totp_provisioning_uri).as_svg( + offset: 0, + color: '000', + shape_rendering: 'crispEdges', + module_size: 4 + ) + + render json: success_json.merge( + key: current_user.user_second_factor.data, + qr: qrcode_svg + ) + end + + def update_second_factor + params.require(:second_factor_token) + + [request.remote_ip, current_user.id].each do |key| + RateLimiter.new(nil, "second-factor-min-#{key}", 3, 1.minute).performed! + end + + user_second_factor = current_user.user_second_factor + raise Discourse::InvalidParameters unless user_second_factor + + unless current_user.authenticate_totp(params[:second_factor_token]) + return render json: failed_json.merge( + error: I18n.t("login.invalid_second_factor_code") + ) + end + + if params[:enable] == "true" + user_second_factor.update!(enabled: true) + else + user_second_factor.destroy! + + Jobs.enqueue( + :critical_user_email, + type: :account_second_factor_disabled, + user_id: current_user.id + ) + end + + render json: success_json + end + private def honeypot_value diff --git a/app/controllers/users_email_controller.rb b/app/controllers/users_email_controller.rb index 45b7920a9a..2fd289edb0 100644 --- a/app/controllers/users_email_controller.rb +++ b/app/controllers/users_email_controller.rb @@ -33,27 +33,32 @@ class UsersEmailController < ApplicationController def confirm expires_now - token = EmailToken.confirmable params[:token] - change_req = token&.user&.email_change_requests - &.where('new_email_token_id = :token_id', token_id: token.id) - &.first - if change_req.try(:change_state) == EmailChangeRequest.states[:authorizing_new] && - !EmailToken.second_factor_valid(params[:token], params[:second_factor_token]) + + token = EmailToken.confirmable(params[:token]) + user = token&.user + + change_request = + if user + user.email_change_requests.where(new_email_token_id: token.id).first + end + + if change_request&.change_state == EmailChangeRequest.states[:authorizing_new] && + user.totp_enabled? && !user.authenticate_totp(params[:second_factor_token]) + @update_result = :invalid_second_factor + if params[:second_factor_token].present? RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! @show_invalid_second_factor_error = true end - render layout: 'no_ember' - return - end + else + updater = EmailUpdater.new + @update_result = updater.confirm(params[:token]) - updater = EmailUpdater.new - @update_result = updater.confirm(params[:token]) - - if @update_result == :complete - updater.user.user_stat.reset_bounce_score! - log_on_user(updater.user) + if @update_result == :complete + updater.user.user_stat.reset_bounce_score! + log_on_user(updater.user) + end end render layout: 'no_ember' diff --git a/app/helpers/second_factor_helper.rb b/app/helpers/second_factor_helper.rb deleted file mode 100644 index 0d2feb304a..0000000000 --- a/app/helpers/second_factor_helper.rb +++ /dev/null @@ -1,35 +0,0 @@ -module SecondFactorHelper - - def self.totp(user) - self.create_totp user - ROTP::TOTP.new(user.user_second_factor.data, issuer: SiteSetting.title) - end - - def self.create_totp(user) - if !user.user_second_factor - user.user_second_factor = UserSecondFactor.create(user_id: user.id, method: "totp", data: ROTP::Base32.random_base32) - end - end - - def self.provisioning_uri(user) - self.totp(user).provisioning_uri(user.email) - end - - def self.authenticate(user, token) - totp = self.totp(user) - last_used = 0 - if user.user_second_factor.last_used - last_used = user.user_second_factor.last_used.to_i - end - authenticated = !token.blank? && totp.verify_with_drift_and_prior(token, 0, last_used) - if authenticated - user.user_second_factor.last_used = DateTime.now - user.user_second_factor.save - end - return authenticated - end - - def self.totp_enabled?(user) - !!user.user_second_factor && user.user_second_factor.enabled? - end -end diff --git a/app/models/concerns/second_factor_manager.rb b/app/models/concerns/second_factor_manager.rb new file mode 100644 index 0000000000..a9a04cd390 --- /dev/null +++ b/app/models/concerns/second_factor_manager.rb @@ -0,0 +1,38 @@ +module SecondFactorManager + extend ActiveSupport::Concern + + def totp + self.create_totp + ROTP::TOTP.new(self.user_second_factor.data, issuer: SiteSetting.title) + end + + def create_totp(opts = {}) + if !self.user_second_factor + self.create_user_second_factor!({ + method: UserSecondFactor.methods[:totp], + data: ROTP::Base32.random_base32 + }.merge(opts)) + end + end + + def totp_provisioning_uri + self.totp.provisioning_uri(self.email) + end + + def authenticate_totp(token) + totp = self.totp + last_used = 0 + + if self.user_second_factor.last_used + last_used = self.user_second_factor.last_used.to_i + end + + authenticated = !token.blank? && totp.verify_with_drift_and_prior(token, 0, last_used) + self.user_second_factor.update!(last_used: DateTime.now) if authenticated + !!authenticated + end + + def totp_enabled? + !!(self&.user_second_factor&.enabled?) + end +end diff --git a/app/models/email_token.rb b/app/models/email_token.rb index 7cfe74dab5..2a10dd4e23 100644 --- a/app/models/email_token.rb +++ b/app/models/email_token.rb @@ -39,15 +39,6 @@ class EmailToken < ActiveRecord::Base token.present? && token =~ /\h{#{token.length / 2}}/i end - def self.second_factor_valid(token, second_factor_token) - # Fail only when token is valid, second factor token is required, and does NOT check out. - return true unless valid_token_format?(token) - email_token = confirmable(token) - return true if email_token.blank? - return true unless SecondFactorHelper.totp_enabled?(email_token.user) - return SecondFactorHelper.authenticate(email_token.user, second_factor_token) - end - def self.atomic_confirm(token) failure = { success: false } return failure unless valid_token_format?(token) diff --git a/app/models/user.rb b/app/models/user.rb index 0514b7038c..f993bddab1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -18,6 +18,7 @@ class User < ActiveRecord::Base include Searchable include Roleable include HasCustomFields + include SecondFactorManager # TODO: Remove this after 7th Jan 2018 self.ignored_columns = %w{email} @@ -462,10 +463,6 @@ class User < ActiveRecord::Base '' # so that validator doesn't complain that a password attribute doesn't exist end - def second_factor - '' # so that validator doesn't complain that a password attribute doesn't exist - end - # Indicate that this is NOT a passwordless account for the purposes of validation def password_required! @password_required = true diff --git a/app/models/user_second_factor.rb b/app/models/user_second_factor.rb index da48d2302f..acd16cf134 100644 --- a/app/models/user_second_factor.rb +++ b/app/models/user_second_factor.rb @@ -1,3 +1,23 @@ class UserSecondFactor < ActiveRecord::Base belongs_to :user + + def self.methods + @methods ||= Enum.new( + totp: 1, + ) + end end + +# == Schema Information +# +# Table name: user_second_factors +# +# id :integer not null, primary key +# user_id :integer not null +# method :string +# data :string +# enabled :boolean default(FALSE), not null +# last_used :datetime +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/app/serializers/admin_detailed_user_serializer.rb b/app/serializers/admin_detailed_user_serializer.rb index f1a0e1c93f..e0ad2abcfc 100644 --- a/app/serializers/admin_detailed_user_serializer.rb +++ b/app/serializers/admin_detailed_user_serializer.rb @@ -36,17 +36,12 @@ class AdminDetailedUserSerializer < AdminUserSerializer has_one :tl3_requirements, serializer: TrustLevel3RequirementsSerializer, embed: :objects has_many :groups, embed: :object, serializer: BasicGroupSerializer - def include_second_factor_enabled? - scope.is_staff? + def second_factor_enabled + object.totp_enabled? end def can_disable_second_factor - (object.id && object.id != scope.user.try(:id)) && - scope.is_staff? - end - - def second_factor_enabled - SecondFactorHelper.totp_enabled?(object) + object&.id != scope.user.id end def can_revoke_admin diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 63677173ce..1e45721b68 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -1,5 +1,3 @@ -require 'rqrcode' - class UserSerializer < BasicUserSerializer attr_accessor :omit_stats, @@ -149,12 +147,11 @@ class UserSerializer < BasicUserSerializer end def include_second_factor_enabled? - (object.id && object.id == scope.user.try(:id)) || - scope.is_staff? + (object&.id == scope.user&.id) || scope.is_staff? end def second_factor_enabled - SecondFactorHelper.totp_enabled?(object) + object.totp_enabled? end def can_change_bio diff --git a/app/views/session/email_login.html.erb b/app/views/session/email_login.html.erb index 03704e0a44..43d988162f 100644 --- a/app/views/session/email_login.html.erb +++ b/app/views/session/email_login.html.erb @@ -3,6 +3,7 @@ <%= @error %>
<%end%> + <%if @second_factor_required%>
@@ -10,7 +11,7 @@

<%=t "login.second_factor_title" %>

<%= label_tag(:second_factor_token, t("login.second_factor_description")) %>
<%= text_field_tag(:second_factor_token) %>
- <%= submit_tag(t("login.submit"), class: "btn btn-large btn-primary") %> + <%= submit_tag(t("submit"), class: "btn btn-large btn-primary") %> <%end%>
diff --git a/app/views/users_email/confirm.html.erb b/app/views/users_email/confirm.html.erb index 9411b3473c..35877cd95d 100644 --- a/app/views/users_email/confirm.html.erb +++ b/app/views/users_email/confirm.html.erb @@ -16,7 +16,7 @@ <% if @show_invalid_second_factor_error %>
<%= t('login.invalid_second_factor_code') %>
<% end %> - <%= submit_tag t('login.submit'), class: "btn btn-primary" %> + <%= submit_tag t('submit'), class: "btn btn-primary" %> <% end %> <% else %>
diff --git a/config/application.rb b/config/application.rb index eb2b23be4d..bb98916354 100644 --- a/config/application.rb +++ b/config/application.rb @@ -129,13 +129,14 @@ module Discourse # Configure sensitive parameters which will be filtered from the log file. config.filter_parameters += [ - :password, - :pop3_polling_password, - :api_key, - :s3_secret_access_key, - :twitter_consumer_secret, - :facebook_app_secret, - :github_client_secret + :password, + :pop3_polling_password, + :api_key, + :s3_secret_access_key, + :twitter_consumer_secret, + :facebook_app_secret, + :github_client_secret, + :second_factor_token, ] # Enable the asset pipeline diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 835cbfbe4f..a0069439d1 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -710,14 +710,14 @@ en: second_factor: title: "Two Factor Authentication" - enable: "Enable 2-Step Verification" - disable: "Disable 2-Step Verification" - confirm_password_description: "Confirm your password to continue enabling 2-Step Verification." - enable_description: "To complete 2-Step Verification setup, scan the following QR code and submit a 2-Step Verification code." - disable_description: "Enter a 2-Step Verification code to disable." + enable: "Enable Two Factor Authentication" + disable: "Disable Two Factor Authentication" + confirm_password_description: "Confirm your password to continue enabling Two Factor Authentication." + enable_description: "To complete Two Factor Authentication setup, scan the following QR code and submit a Two Factor Authentication code." + disable_description: "Enter a Two Factor Authentication code to disable." show_key_description: "Or enter the key manually." - info_prompt: "What is Two Factor authentication?" - extended_description: "Two-factor authentication adds an extra security step to logging in by requiring a one-time token in addition to your password. These tokens are generated by compatible apps for iPhone or Android such as Google Authenticator, Authy, and FreeOTP." + info_prompt: "What is Two Factor Authentication?" + extended_description: "Two Factor Authentication adds an extra security step to logging in by requiring a one-time token in addition to your password. These tokens are generated by compatible apps for iPhone or Android such as Google Authenticator, Authy, and FreeOTP." change_about: title: "Change About Me" @@ -1109,7 +1109,7 @@ en: title: "Log In" username: "User" password: "Password" - second_factor_title: "2-Step Verification Required" + second_factor_title: "Two Factor Authentication Required" second_factor_description: "Enter a generated verification code." second_factor_label: "Code" email_placeholder: "email or username" @@ -3277,7 +3277,7 @@ en: post_locked: "post locked" post_unlocked: "post unlocked" check_personal_message: "check personal message" - disabled_second_factor: "disable 2-step auth" + disabled_second_factor: "disable 2 factor authentication" screened_emails: title: "Screened Emails" description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 42e61730f4..0f0d44035c 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -49,6 +49,7 @@ en: loading: "Loading" powered_by_html: 'Powered by Discourse, best viewed with JavaScript enabled' log_in: "Log In" + submit: "Submit" purge_reason: "Automatically deleted as abandoned, deactivated account" disable_remote_images_download_reason: "Remote images download was disabled because there wasn't enough disk space available." @@ -1761,6 +1762,7 @@ en: login: not_approved: "Your account hasn't been approved yet. You will be notified by email when you are ready to log in." incorrect_username_email_or_password: "Incorrect username, email or password" + incorrect_password: "Incorrect password" wait_approval: "Thanks for signing up. We will notify you when your account has been approved." active: "Your account is activated and ready to use." activate_email: "

You’re almost done! We sent an activation mail to %{email}. Please follow the instructions in the mail to activate your account.

If it doesn’t arrive, check your spam folder.

" @@ -1783,10 +1785,9 @@ en: auth_complete: "Authentication is complete." click_to_continue: "Click here to continue." already_logged_in: "Oops, looks like you are attempting to accept an invitation for another user. If you are not %{current_user}, please log out and try again." - second_factor_title: "2-Step Verification Required" - second_factor_description: "Enter a generated verification code." - invalid_second_factor_code: "Invalid 2-Step Verification Code" - submit: "Submit" + second_factor_title: "Two Factor Authentication Required" + second_factor_description: "Enter a generated authentication code." + invalid_second_factor_code: "Invalid Two Factor Authentication Code" user: no_accounts_associated: "No accounts associated" @@ -2735,10 +2736,10 @@ en: account_second_factor_disabled: - title: "2-Step Verification disabled" - subject_template: "[%{email_prefix}] 2-Step Verification disabled" + title: "Two Factor Authentication disabled" + subject_template: "[%{email_prefix}] Two Factor Authentication disabled" text_body_template: | - Your account’s 2-Step verification at %{site_name} has been disabled. The account no longer needs a 2-Step Verification code to sign in. + Your account’s Two Factor Authentication at %{site_name} has been disabled. The account no longer needs a Two Factor Authentication code to sign in. If you have any questions, [contact our friendly staff](%{base_url}/about). diff --git a/config/routes.rb b/config/routes.rb index bf838b8ff4..a3b09d8fca 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -304,7 +304,6 @@ Discourse::Application.routes.draw do get "session/csrf" => "session#csrf" get "session/email-login/:token" => "session#email_login" post "session/email-login/:token" => "session#email_login" - post "second_factor/create" => "second_factor#create" get "composer_messages" => "composer_messages#index" post "composer/parse_html" => "composer#parse_html" @@ -332,6 +331,9 @@ Discourse::Application.routes.draw do end end + post "#{root_path}/second_factors" => "users#create_second_factor" + put "#{root_path}/second_factor" => "users#update_second_factor" + put "#{root_path}/update-activation-email" => "users#update_activation_email" get "#{root_path}/hp" => "users#get_honeypot_value" post "#{root_path}/email-login" => "users#email_login" @@ -385,7 +387,6 @@ Discourse::Application.routes.draw do put "#{root_path}/:username/preferences/badge_title" => "users#badge_title", constraints: { username: RouteFormat.username } get "#{root_path}/:username/preferences/username" => "users#preferences", constraints: { username: RouteFormat.username } put "#{root_path}/:username/preferences/username" => "users#username", constraints: { username: RouteFormat.username } - post "#{root_path}/:username/preferences/second-factor" => "second_factor#update", constraints: { username: RouteFormat.username } get "#{root_path}/:username/preferences/second-factor" => "users#preferences", constraints: { username: RouteFormat.username } delete "#{root_path}/:username/preferences/user_image" => "users#destroy_user_image", constraints: { username: RouteFormat.username } put "#{root_path}/:username/preferences/avatar/pick" => "users#pick_avatar", constraints: { username: RouteFormat.username } diff --git a/db/migrate/20180109222722_create_user_second_factors.rb b/db/migrate/20180109222722_create_user_second_factors.rb index 46abb216d1..ff038c1776 100644 --- a/db/migrate/20180109222722_create_user_second_factors.rb +++ b/db/migrate/20180109222722_create_user_second_factors.rb @@ -2,8 +2,8 @@ class CreateUserSecondFactors < ActiveRecord::Migration[5.1] def change create_table :user_second_factors do |t| t.integer :user_id, null: false - t.string :method - t.string :data + t.integer :method, null: false + t.string :data, null: false t.boolean :enabled, null: false, default: false t.timestamp :last_used t.timestamps diff --git a/spec/components/concern/second_factor_manager_spec.rb b/spec/components/concern/second_factor_manager_spec.rb new file mode 100644 index 0000000000..afc969d915 --- /dev/null +++ b/spec/components/concern/second_factor_manager_spec.rb @@ -0,0 +1,93 @@ +require 'rails_helper' + +RSpec.describe SecondFactorManager do + let(:user_second_factor) { Fabricate(:user_second_factor) } + let(:user) { user_second_factor.user } + let(:another_user) { Fabricate(:user) } + + describe '#totp' do + it 'should return the right data' do + totp = nil + + expect do + totp = another_user.totp + end.to change { UserSecondFactor.count }.by(1) + + expect(totp.issuer).to eq(SiteSetting.title) + expect(totp.secret).to eq(another_user.reload.user_second_factor.data) + end + end + + describe '#create_totp' do + it 'should create the right record' do + second_factor = another_user.create_totp(enabled: true) + + expect(second_factor.method).to eq(UserSecondFactor.methods[:totp]) + expect(second_factor.data).to be_present + expect(second_factor.enabled).to eq(true) + end + + describe 'when user has a second factor' do + it 'should return nil' do + expect(user.create_totp).to eq(nil) + end + end + end + + describe '#totp_provisioning_uri' do + it 'should return the right uri' do + expect(user.totp_provisioning_uri).to eq( + "otpauth://totp/#{SiteSetting.title}:#{user.email}?secret=#{user_second_factor.data}&issuer=#{SiteSetting.title}" + ) + end + end + + describe '#authenticate_totp' do + it 'should be able to authenticate a token' do + freeze_time do + expect(user.user_second_factor.last_used).to eq(nil) + + token = user.totp.now + + expect(user.authenticate_totp(token)).to eq(true) + expect(user.user_second_factor.last_used).to eq(DateTime.now) + expect(user.authenticate_totp(token)).to eq(false) + end + end + + describe 'when token is blank' do + it 'should be false' do + expect(user.authenticate_totp(nil)).to eq(false) + expect(user.user_second_factor.last_used).to eq(nil) + end + end + + describe 'when token is invalid' do + it 'should be false' do + expect(user.authenticate_totp('111111')).to eq(false) + expect(user.user_second_factor.last_used).to eq(nil) + end + end + end + + describe '#totp_enabled?' do + describe 'when user does not have a second factor record' do + it 'should return false' do + expect(another_user.totp_enabled?).to eq(false) + end + end + + describe "when user's second factor record is disabled" do + it 'should return false' do + user.user_second_factor.update!(enabled: false) + expect(user.totp_enabled?).to eq(false) + end + end + + describe "when user's second factor record is enabled" do + it 'should return true' do + expect(user.totp_enabled?).to eq(true) + end + end + end +end diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 0c81cde596..703021890f 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -265,19 +265,6 @@ describe Admin::UsersController do end end - context '#disable_second_factor' do - before do - @another_user = Fabricate(:user) - SecondFactorHelper.create_totp(@another_user) - end - - it 'disables the second factor' do - expect(User.find(@another_user.id).user_second_factor).not_to eq(nil) - put :disable_second_factor, params: { user_id: @another_user.id }, format: :json - expect(User.find(@another_user.id).user_second_factor).to eq(nil) - end - end - context '#add_group' do let(:user) { Fabricate(:user) } let(:group) { Fabricate(:group) } diff --git a/spec/controllers/second_factor_controller_spec.rb b/spec/controllers/second_factor_controller_spec.rb deleted file mode 100644 index d74ba44ce7..0000000000 --- a/spec/controllers/second_factor_controller_spec.rb +++ /dev/null @@ -1,69 +0,0 @@ -require 'rails_helper' - -RSpec.describe SecondFactorController, type: :controller do - # featheredtoast-todo also write qunit tests. - describe '.create' do - - let(:user) { Fabricate(:user) } - - describe 'create 2fa request' do - it 'fails on incorrect password' do - post :create, params: { - login: user.username, password: 'wrongpassword' - }, format: :json - expect(JSON.parse(response.body)['error']).to eq(I18n.t("login.incorrect_username_email_or_password")) - end - - it 'succeeds on correct password' do - post :create, params: { - login: user.username, password: 'myawesomepassword' - }, format: :json - expect(JSON.parse(response.body).keys).to contain_exactly('key', 'qr') - end - end - end - - describe '.update' do - let(:user) { Fabricate(:user) } - - context 'when user has totp setup' do - second_factor_data = "rcyryaqage3jexfj" - before do - user.user_second_factor = UserSecondFactor.create(user_id: user.id, method: "totp", data: second_factor_data) - end - - it 'errors on incorrect code' do - post :update, params: { - username: user.username, - token: '000000', - enable: 'true' - }, format: :json - expect(JSON.parse(response.body)['error']).to eq(I18n.t("login.invalid_second_factor_code")) - user.reload - end - - it 'can be enabled' do - post :update, params: { - username: user.username, - token: ROTP::TOTP.new(second_factor_data).now, - enable: 'true' - }, format: :json - expect(JSON.parse(response.body)['result']).to eq('ok') - user.reload - expect(user.user_second_factor.enabled).to be true - end - - it 'can be disabled' do - post :update, params: { - username: user.username, - enable: 'false', - token: ROTP::TOTP.new(second_factor_data).now - }, format: :json - expect(JSON.parse(response.body)['result']).to eq('ok') - user.reload - expect(user.user_second_factor).to be_nil - end - end - end - -end diff --git a/spec/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb index 0b83ab8ab2..1f980a1fcb 100644 --- a/spec/controllers/session_controller_spec.rb +++ b/spec/controllers/session_controller_spec.rb @@ -585,34 +585,50 @@ describe SessionController do end context 'when user has 2-factor logins' do - second_factor_data = "rcyryaqage3jexfj" - before do - user.user_second_factor = UserSecondFactor.create(user_id: user.id, method: "totp", data: second_factor_data, enabled: true) - end + let!(:user_second_factor) { Fabricate(:user_second_factor, user: user) } - describe 'failure no 2-factor' do - it 'should return an error' do + describe 'when second factor token is missing' do + it 'should return the right response' do post :create, params: { - login: user.username, password: 'myawesomepassword' - }, format: :json - expect(JSON.parse(response.body)['error']).to eq(I18n.t('login.invalid_second_factor_code')) + login: user.username, + password: 'myawesomepassword', + }, format: :json + + expect(JSON.parse(response.body)['error']).to eq(I18n.t( + 'login.invalid_second_factor_code' + )) end end - describe 'successful 2-factor' do - it 'logs in correctly' do - events = DiscourseEvent.track_events do - post :create, params: { - login: user.username, password: 'myawesomepassword', second_factor_token: ROTP::TOTP.new(second_factor_data).now - }, format: :json - end - expect(events.map { |event| event[:event_name] }).to include(:user_logged_in, :user_first_logged_in) + describe 'when second factor token is invalid' do + it 'should return the right response' do + post :create, params: { + login: user.username, + password: 'myawesomepassword', + second_factor_token: '00000000' + }, format: :json + + expect(JSON.parse(response.body)['error']).to eq(I18n.t( + 'login.invalid_second_factor_code' + )) + end + end + + describe 'when second factor token is valid' do + it 'should log the user in' do + post :create, params: { + login: user.username, + password: 'myawesomepassword', + second_factor_token: ROTP::TOTP.new(user_second_factor.data).now + }, format: :json user.reload expect(session[:current_user_id]).to eq(user.id) expect(user.user_auth_tokens.count).to eq(1) - expect(UserAuthToken.hash_token(cookies[:_t])).to eq(user.user_auth_tokens.first.auth_token) + + expect(UserAuthToken.hash_token(cookies[:_t])) + .to eq(user.user_auth_tokens.first.auth_token) end end end @@ -810,27 +826,32 @@ describe SessionController do login: user.username, password: 'myawesomepassword' }, format: :json - expect(response).not_to be_success + expect(response.status).to eq(429) json = JSON.parse(response.body) expect(json["error_type"]).to eq("rate_limit") end + it 'rate limits second factor attempts' do RateLimiter.enable RateLimiter.clear_all! 3.times do post :create, params: { - login: user.username, password: 'myawesomepassword', second_factor_token: '000000' - }, format: :json + login: user.username, + password: 'myawesomepassword', + second_factor_token: '000000' + }, format: :json expect(response).to be_success end post :create, params: { - login: user.username, password: 'myawesomepassword', second_factor_token: '000000' - }, format: :json + login: user.username, + password: 'myawesomepassword', + second_factor_token: '000000' + }, format: :json - expect(response).not_to be_success + expect(response.status).to eq(429) json = JSON.parse(response.body) expect(json["error_type"]).to eq("rate_limit") end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 206fd6bcf5..19a324d7f4 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -407,17 +407,11 @@ describe UsersController do expect(UserAuthToken.where(id: user_token.id).count).to eq(1) end - context '2-factor required' do - - second_factor_data = "rcyryaqage3jexfj" - let(:user) { Fabricate(:user) } - - before do - user.user_second_factor = UserSecondFactor.create(user_id: user.id, method: "totp", data: second_factor_data, enabled: true) - end + context '2 factor authentication required' do + let!(:second_factor) { Fabricate(:user_second_factor, user: user) } it 'does not change with an invalid token' do - token = user.email_tokens.create(email: user.email).token + token = user.email_tokens.create!(email: user.email).token get :password_reset, params: { token: token } @@ -438,8 +432,11 @@ describe UsersController do get :password_reset, params: { token: token } - put :password_reset, - params: { token: token, password: 'hg9ow8yHG32O', second_factor_token: ROTP::TOTP.new(second_factor_data).now } + put :password_reset, params: { + token: token, + password: 'hg9ow8yHG32O', + second_factor_token: ROTP::TOTP.new(second_factor.data).now + } user.reload expect(user.confirm_password?('hg9ow8yHG32O')).to eq(true) @@ -515,7 +512,7 @@ describe UsersController do end end - describe '.admin_login' do + describe '#admin_login' do let(:admin) { Fabricate(:admin) } let(:user) { Fabricate(:user) } @@ -555,14 +552,12 @@ describe UsersController do end end - context 'needs 2-factor' do + describe 'when 2 factor authentication is enabled' do + let(:second_factor) { Fabricate(:user_second_factor, user: admin) } render_views - second_factor_data = "rcyryaqage3jexfj" - before do - admin.user_second_factor = UserSecondFactor.create(user_id: admin.id, method: "totp", data: second_factor_data, enabled: true) - end it 'does not log in when token required' do + second_factor token = admin.email_tokens.create(email: admin.email).token get :admin_login, params: { token: token } expect(response).not_to redirect_to('/') @@ -572,7 +567,12 @@ describe UsersController do it 'logs in when a valid 2-factor token is given' do token = admin.email_tokens.create(email: admin.email).token - put :admin_login, params: { token: token, second_factor_token: ROTP::TOTP.new(second_factor_data).now } + + put :admin_login, params: { + token: token, + second_factor_token: ROTP::TOTP.new(second_factor.data).now + } + expect(response).to redirect_to('/') expect(session[:current_user_id]).to eq(admin.id) end diff --git a/spec/fabricators/user_second_factor_fabricator.rb b/spec/fabricators/user_second_factor_fabricator.rb new file mode 100644 index 0000000000..1bb8856787 --- /dev/null +++ b/spec/fabricators/user_second_factor_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:user_second_factor) do + user + data 'rcyryaqage3jexfj' + enabled true + method UserSecondFactor.methods[:totp] +end diff --git a/spec/models/user_second_factor_spec.rb b/spec/models/user_second_factor_spec.rb new file mode 100644 index 0000000000..2a61b06422 --- /dev/null +++ b/spec/models/user_second_factor_spec.rb @@ -0,0 +1,9 @@ +require 'rails_helper' + +RSpec.describe UserSecondFactor do + describe '.methods' do + it 'should retain the right order' do + expect(described_class.methods[:totp]).to eq(1) + end + end +end diff --git a/spec/requests/admin/users_controller_spec.rb b/spec/requests/admin/users_controller_spec.rb new file mode 100644 index 0000000000..8d665aea2e --- /dev/null +++ b/spec/requests/admin/users_controller_spec.rb @@ -0,0 +1,50 @@ +require 'rails_helper' + +RSpec.describe Admin::UsersController do + let(:admin) { Fabricate(:admin) } + let(:user) { Fabricate(:user) } + + describe '#disable_second_factor' do + let(:second_factor) { user.create_totp } + + describe 'as an admin' do + before do + sign_in(admin) + second_factor + expect(user.reload.user_second_factor).to eq(second_factor) + end + + it 'should able to disable the second factor for another user' do + SiteSetting.queue_jobs = true + + expect do + put "/admin/users/#{user.id}/disable_second_factor.json" + end.to change { Jobs::CriticalUserEmail.jobs.length }.by(1) + + expect(response.status).to eq(200) + expect(user.reload.user_second_factor).to eq(nil) + + job_args = Jobs::CriticalUserEmail.jobs.first["args"].first + + expect(job_args["user_id"]).to eq(user.id) + expect(job_args["type"]).to eq('account_second_factor_disabled') + end + + it 'should not be able to disable the second factor for the current user' do + put "/admin/users/#{admin.id}/disable_second_factor.json" + + expect(response.status).to eq(403) + end + + describe 'when user does not have second factor enabled' do + it 'should raise the right error' do + user.user_second_factor.destroy! + + put "/admin/users/#{user.id}/disable_second_factor.json" + + expect(response.status).to eq(400) + end + end + end + end +end diff --git a/spec/requests/session_controller_spec.rb b/spec/requests/session_controller_spec.rb index 65004efe14..246f30c647 100644 --- a/spec/requests/session_controller_spec.rb +++ b/spec/requests/session_controller_spec.rb @@ -138,10 +138,7 @@ RSpec.describe SessionController do end context 'user has 2-factor logins' do - second_factor_data = "rcyryaqage3jexfj" - before do - user.user_second_factor = UserSecondFactor.create(user_id: user.id, method: "totp", data: second_factor_data, enabled: true) - end + let!(:user_second_factor) { Fabricate(:user_second_factor, user: user) } describe 'requires second factor' do it 'should return a second factor prompt' do @@ -149,7 +146,9 @@ RSpec.describe SessionController do expect(response.status).to eq(200) - expect(CGI.unescapeHTML(response.body)).to include(I18n.t("login.second_factor_title")) + expect(CGI.unescapeHTML(response.body)).to include(I18n.t( + "login.second_factor_title" + )) end end @@ -159,13 +158,17 @@ RSpec.describe SessionController do expect(response.status).to eq(200) - expect(CGI.unescapeHTML(response.body)).to include(I18n.t("login.invalid_second_factor_code")) + expect(CGI.unescapeHTML(response.body)).to include(I18n.t( + "login.invalid_second_factor_code" + )) end end describe 'allows successful 2-factor' do it 'logs in correctly' do - post "/session/email-login/#{email_token.token}", params: { second_factor_token: ROTP::TOTP.new(second_factor_data).now } + post "/session/email-login/#{email_token.token}", params: { + second_factor_token: ROTP::TOTP.new(user_second_factor.data).now + } expect(response).to redirect_to("/") end diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index ba41c482ec..cd2628ba3c 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -401,4 +401,118 @@ RSpec.describe UsersController do end end end + + describe '#create_second_factor' do + context 'when not logged in' do + it 'should return the right response' do + post "/users/second_factors.json", params: { + password: 'wrongpassword' + } + + expect(response.status).to eq(403) + end + end + + context 'when logged in' do + before do + sign_in(user) + end + + describe 'create 2fa request' do + it 'fails on incorrect password' do + post "/users/second_factors.json", params: { + password: 'wrongpassword' + } + + expect(response.status).to eq(200) + + expect(JSON.parse(response.body)['error']).to eq(I18n.t( + "login.incorrect_password") + ) + end + + it 'succeeds on correct password' do + post "/users/second_factors.json", params: { + password: 'somecomplicatedpassword' + } + + expect(response.status).to eq(200) + + response_body = JSON.parse(response.body) + + expect(response_body['key']).to eq(user.user_second_factor.data) + expect(response_body['qr']).to be_present + end + end + end + end + + describe '#update_second_factor' do + let(:user_second_factor) { Fabricate(:user_second_factor, user: user) } + + context 'when not logged in' do + it 'should return the right response' do + put "/users/second_factor.json", params: { + second_factor_token: ROTP::TOTP.new(user_second_factor.data).now + } + + expect(response.status).to eq(403) + end + end + + context 'when logged in' do + before do + sign_in(user) + user_second_factor + end + + context 'when user has totp setup' do + context 'when token is missing' do + it 'returns the right response' do + put "/users/second_factor.json", params: { + enable: 'true', + } + + expect(response.status).to eq(400) + end + end + + context 'when token is invalid' do + it 'returns the right response' do + put "/users/second_factor.json", params: { + second_factor_token: '000000', + enable: 'true', + } + + expect(response.status).to eq(200) + + expect(JSON.parse(response.body)['error']).to eq(I18n.t( + "login.invalid_second_factor_code" + )) + end + end + + context 'when token is valid' do + it 'should allow second factor for the user to be enabled' do + put "/users/second_factor.json", params: { + second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, + enable: 'true', + } + + expect(response.status).to eq(200) + expect(user.reload.user_second_factor.enabled).to be true + end + + it 'should allow second factor for the user to be disabled' do + put "/users/second_factor.json", params: { + second_factor_token: ROTP::TOTP.new(user_second_factor.data).now, + } + + expect(response.status).to eq(200) + expect(user.reload.user_second_factor).to eq(nil) + end + end + end + end + end end diff --git a/spec/requests/users_email_controller_spec.rb b/spec/requests/users_email_controller_spec.rb index e7feb560db..624ba871e5 100644 --- a/spec/requests/users_email_controller_spec.rb +++ b/spec/requests/users_email_controller_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' describe UsersEmailController do - describe '.confirm' do + describe '#confirm' do it 'errors out for invalid tokens' do get "/u/authorize-email/asdfasdf" @@ -62,29 +62,37 @@ describe UsersEmailController do end context 'second factor required' do - second_factor_data = "rcyryaqage3jexfj" - before do - user.user_second_factor = UserSecondFactor.create(user_id: user.id, method: "totp", data: second_factor_data, enabled: true) - end + let!(:second_factor) { Fabricate(:user_second_factor, user: user) } it 'requires a second factor token' do get "/u/authorize-email/#{user.email_tokens.last.token}" - expect(response.body).to include(I18n.t("login.second_factor_title")) - expect(response.body).not_to include(I18n.t("login.invalid_second_factor_code")) + + expect(response.status).to eq(200) + + response_body = response.body + + expect(response_body).to include(I18n.t("login.second_factor_title")) + expect(response_body).not_to include(I18n.t("login.invalid_second_factor_code")) end it 'adds an error on a second factor attempt' do get "/u/authorize-email/#{user.email_tokens.last.token}", params: { - second_factor_token: "000000" - } + second_factor_token: "000000" + } + + expect(response.status).to eq(200) expect(response.body).to include(I18n.t("login.invalid_second_factor_code")) end it 'confirms with a correct second token' do get "/u/authorize-email/#{user.email_tokens.last.token}", params: { - second_factor_token: ROTP::TOTP.new(second_factor_data).now - } - expect(response).to be_success + second_factor_token: ROTP::TOTP.new(second_factor.data).now + } + + expect(response.status).to eq(200) + + response_body = response.body + expect(response.body).not_to include(I18n.t("login.second_factor_title")) expect(response.body).not_to include(I18n.t("login.invalid_second_factor_code")) end @@ -92,17 +100,16 @@ describe UsersEmailController do end end - describe '.update' do + describe '#update' do + let(:user) { Fabricate(:user) } let(:new_email) { 'bubblegum@adventuretime.ooo' } it "requires you to be logged in" do - put "/u/asdf/preferences/email.json" + put "/u/#{user.username}/preferences/email.json", params: { email: new_email } expect(response.status).to eq(403) end context 'when logged in' do - let(:user) { Fabricate(:user) } - before do sign_in(user) end diff --git a/test/javascripts/acceptance/password-reset-test.js.es6 b/test/javascripts/acceptance/password-reset-test.js.es6 index 211ef5f47b..b105315b47 100644 --- a/test/javascripts/acceptance/password-reset-test.js.es6 +++ b/test/javascripts/acceptance/password-reset-test.js.es6 @@ -26,16 +26,21 @@ acceptance("Password Reset", { }); server.get('/u/confirm-email-token/requiretwofactor.json', () => { //eslint-disable-line - return response({success: "OK"}); + return response({ success: "OK" }); }); + server.put('/u/password-reset/requiretwofactor.json', request => { //eslint-disable-line const body = parsePostData(request.requestBody); if (body.password === "perf3ctly5ecur3" && body.second_factor_token === "123123") { - return response({success: "OK", message: I18n.t('password_reset.success')}); + return response({ success: "OK", message: I18n.t('password_reset.success') }); } else if (body.second_factor_token === "123123") { - return response({success: false, errors: {password: ["invalid"]}}); + return response({ success: false, errors: { password: ["invalid"] } }); } else { - return response({success: false, message: "invalid token", errors: {second_factor: ["invalid token"]}}); + return response({ + success: false, + message: "invalid token", + errors: { user_second_factor: ["invalid token"] } + }); } }); } @@ -75,24 +80,33 @@ QUnit.test("Password Reset Page", assert => { }); QUnit.test("Password Reset Page With Second Factor", assert => { - PreloadStore.store('password_reset', {is_developer: false, second_factor_required: true}); + PreloadStore.store('password_reset', { + is_developer: false, + second_factor_required: true + }); visit("/u/password-reset/requiretwofactor"); + andThen(() => { assert.notOk(exists("#new-account-password"), "does not show the input"); assert.ok(exists("#second-factor"), "shows the second factor prompt"); }); fillIn('#second-factor', '0000'); - click('.password-reset form button'); + andThen(() => { assert.ok(exists(".alert-error"), "shows 2 factor error"); - assert.ok(find(".alert-error").html().indexOf("invalid token") > -1, "server validation error message shows"); + + assert.ok( + find(".alert-error").html().indexOf("invalid token") > -1, + "shows server validation error message" + ); }); fillIn('#second-factor', '123123'); click('.password-reset form button'); + andThen(() => { assert.notOk(exists(".alert-error"), "hides error"); assert.ok(exists("#new-account-password"), "shows the input"); @@ -100,6 +114,7 @@ QUnit.test("Password Reset Page With Second Factor", assert => { fillIn('.password-reset input', 'perf3ctly5ecur3'); click('.password-reset form button'); + andThen(() => { assert.ok(!exists(".password-reset form"), "form is gone"); }); diff --git a/test/javascripts/acceptance/preferences-test.js.es6 b/test/javascripts/acceptance/preferences-test.js.es6 index c8fbee7aae..5176eed55c 100644 --- a/test/javascripts/acceptance/preferences-test.js.es6 +++ b/test/javascripts/acceptance/preferences-test.js.es6 @@ -10,8 +10,15 @@ acceptance("User Preferences", { ]; }; - server.post('/second_factor/create', () => { //eslint-disable-line - return response({key: "rcyryaqage3jexfj", qr: '
qr-code
'}); + server.post('/u/second_factors.json', () => { //eslint-disable-line + return response({ + key: "rcyryaqage3jexfj", + qr: '
qr-code
' + }); + }); + + server.put('/u/second_factor.json', () => { //eslint-disable-line + return response({ error: 'invalid token' }); }); } }); @@ -91,13 +98,26 @@ QUnit.test("email", assert => { QUnit.test("second factor", assert => { visit("/u/eviltrout/preferences/second-factor"); + andThen(() => { assert.ok(exists("#password"), "it has a password input"); }); + fillIn('#password', 'secrets'); click(".user-content .btn-primary"); + andThen(() => { assert.ok(exists("#test-qr"), "shows qr code"); assert.notOk(exists("#password"), "it hides the password input"); }); + + fillIn("#second-factor-token", '111111'); + click('.btn-primary'); + + andThen(() => { + assert.ok( + find(".alert-error").html().indexOf("invalid token") > -1, + "shows server validation error message" + ); + }); }); diff --git a/test/javascripts/acceptance/sign-in-test.js.es6 b/test/javascripts/acceptance/sign-in-test.js.es6 index ffe8c9cab7..1320be56ee 100644 --- a/test/javascripts/acceptance/sign-in-test.js.es6 +++ b/test/javascripts/acceptance/sign-in-test.js.es6 @@ -79,14 +79,15 @@ QUnit.test("sign in - not activated - edit email", assert => { QUnit.test("second factor", assert => { visit("/"); click("header .login-button"); + andThen(() => { assert.ok(exists('.login-modal'), "it shows the login modal"); }); - // Login with username and password only fillIn('#login-account-name', 'eviltrout'); fillIn('#login-account-password', 'need-second-factor'); click('.modal-footer .btn-primary'); + andThen(() => { assert.not(exists('#modal-alert:visible'), 'it hides the login error'); assert.not(exists('#credentials:visible'), 'it hides the username and password prompt'); @@ -94,9 +95,9 @@ QUnit.test("second factor", assert => { assert.not(exists('.modal-footer .btn-primary:disabled'), "enables the login button"); }); - // Login with username, password, and token fillIn('#login-second-factor', '123456'); click('.modal-footer .btn-primary'); + andThen(() => { assert.ok(exists('.modal-footer .btn-primary:disabled'), "disables the login button"); }); diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6 index db8b8520a1..28e2607b21 100644 --- a/test/javascripts/helpers/create-pretender.js.es6 +++ b/test/javascripts/helpers/create-pretender.js.es6 @@ -229,8 +229,9 @@ export default function() { if (data.password === 'need-second-factor') { if (data.second_factor_token) { - return response({username: 'eviltrout'}); + return response({ username: 'eviltrout' }); } + return response({ error: "Invalid Second Factor", reason: "invalid_second_factor", sent_to_email: 'eviltrout@example.com', From b16471edfb7908c448dae8af76053c3e381d68cb Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 21 Feb 2018 15:46:53 +0800 Subject: [PATCH 046/299] FIX: Invalid token error incorrectly displayed on email login page. --- app/controllers/session_controller.rb | 24 ++++++++++++++---------- spec/requests/session_controller_spec.rb | 8 +++++++- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 063ea7d247..2421ebbcae 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -238,20 +238,24 @@ class SessionController < ApplicationController def email_login raise Discourse::NotFound if !SiteSetting.enable_local_logins_via_email - - if params[:second_factor_token].present? - @error = I18n.t("login.invalid_second_factor_code") - RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! - end - + second_factor_token = params[:second_factor_token] token = params[:token] valid_token = !!EmailToken.valid_token_format?(token) user = EmailToken.confirmable(token)&.user - if valid_token && user&.totp_enabled? && !user.authenticate_totp(params[:second_factor_token]) - @second_factor_required = true - @error = I18n.t('login.invalid_second_factor_code') - elsif user = EmailToken.confirm(token) + if valid_token && user&.totp_enabled? + RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! + + if !second_factor_token.present? + @second_factor_required = true + return render layout: 'no_ember' + elsif !user.authenticate_totp(second_factor_token) + @error = I18n.t('login.invalid_second_factor_code') + return render layout: 'no_ember' + end + end + + if user = EmailToken.confirm(token) if login_not_approved_for?(user) @error = login_not_approved[:error] elsif payload = login_error_check(user) diff --git a/spec/requests/session_controller_spec.rb b/spec/requests/session_controller_spec.rb index 246f30c647..5b003d39ff 100644 --- a/spec/requests/session_controller_spec.rb +++ b/spec/requests/session_controller_spec.rb @@ -146,9 +146,15 @@ RSpec.describe SessionController do expect(response.status).to eq(200) - expect(CGI.unescapeHTML(response.body)).to include(I18n.t( + response_body = CGI.unescapeHTML(response.body) + + expect(response_body).to include(I18n.t( "login.second_factor_title" )) + + expect(response_body).to_not include(I18n.t( + "login.invalid_second_factor_code" + )) end end From 1446753fd238861e2e8839c65e52a5edd331b9b5 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 21 Feb 2018 14:31:52 +0530 Subject: [PATCH 047/299] FIX: Include deleted topics in the post serializer --- app/serializers/post_serializer.rb | 14 +++++++++---- .../web_hook_post_serializer_spec.rb | 21 +++++++++++++++++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index 3f1b2a7254..f12ad4fcca 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -82,7 +82,7 @@ class PostSerializer < BasicPostSerializer end def topic_slug - object.topic && object.topic.slug + topic&.slug end def include_topic_title? @@ -98,15 +98,15 @@ class PostSerializer < BasicPostSerializer end def topic_title - object.topic.title + topic&.title end def topic_html_title - object.topic.fancy_title + topic&.fancy_title end def category_id - object.topic.category_id + topic&.category_id end def moderator? @@ -376,6 +376,12 @@ class PostSerializer < BasicPostSerializer private + def topic + @topic = object.topic + @topic ||= Topic.with_deleted.find(object.topic_id) if scope.is_staff? + @topic + end + def post_actions @post_actions ||= (@topic_view&.all_post_actions || {})[object.id] end diff --git a/spec/serializers/web_hook_post_serializer_spec.rb b/spec/serializers/web_hook_post_serializer_spec.rb index 6190f8e27e..51cd450746 100644 --- a/spec/serializers/web_hook_post_serializer_spec.rb +++ b/spec/serializers/web_hook_post_serializer_spec.rb @@ -3,10 +3,13 @@ require 'rails_helper' RSpec.describe WebHookPostSerializer do let(:admin) { Fabricate(:admin) } let(:post) { Fabricate(:post) } - let(:serializer) { WebHookPostSerializer.new(post, scope: Guardian.new(admin), root: false) } + + def serialized_for_user(u) + WebHookPostSerializer.new(post, scope: Guardian.new(u), root: false).as_json + end it 'should only include the required keys' do - count = serializer.as_json.keys.count + count = serialized_for_user(admin).keys.count difference = count - 41 expect(difference).to eq(0), lambda { @@ -21,4 +24,18 @@ RSpec.describe WebHookPostSerializer do message << "\nPlease verify if those key(s) are required as part of the web hook's payload." } end + + it 'should only include deleted topic title for staffs' do + topic = post.topic + PostDestroyer.new(Discourse.system_user, post).destroy + post.reload + + [nil, post.user, Fabricate(:user)].each do |user| + expect(serialized_for_user(user)[:topic_title]).to eq(nil) + end + + [Fabricate(:moderator), admin].each do |user| + expect(serialized_for_user(user)[:topic_title]).to eq(topic.title) + end + end end From c7c8f38eac434f290e3d0f660088f5bfaf892232 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 21 Feb 2018 17:06:35 +0800 Subject: [PATCH 048/299] Use proper encoding for email fixtures. --- .gitattributes | 7 +- spec/fixtures/emails/encoded_display_name.eml | 22 +-- spec/fixtures/emails/forwarded_email_3.eml | 36 ++--- spec/fixtures/emails/inline_image.eml | 152 +++++++++--------- .../emails/reply_with_8bit_encoding.eml | 24 +-- 5 files changed, 122 insertions(+), 119 deletions(-) diff --git a/.gitattributes b/.gitattributes index ae7015b472..546b134a0c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,11 +1,14 @@ # Set default behaviour, in case users don't have core.autocrlf set. * text=auto -# Explicitly declare text files we want to always be normalized and converted +# Treat email fixtures as binary files so CRLF are not converted to LF. +*.eml binary + +# Explicitly declare text files we want to always be normalized and converted # to native line endings on checkout. *.yml text -# Custom for Visual Studio, very unlikely, but lets keep it +# Custom for Visual Studio, very unlikely, but lets keep it *.cs diff=csharp *.sln merge=union *.csproj merge=union diff --git a/spec/fixtures/emails/encoded_display_name.eml b/spec/fixtures/emails/encoded_display_name.eml index 1b5d6bb3a1..132e440401 100644 --- a/spec/fixtures/emails/encoded_display_name.eml +++ b/spec/fixtures/emails/encoded_display_name.eml @@ -1,11 +1,11 @@ -Return-Path: -From: =?UTF-8?B?0KHQu9GD0YfQsNC50L3QsNGP?= =?UTF-8?B?INCY0LzRjw==?= -To: meat@bar.com -Subject: I need help -Date: Fri, 15 Jan 2016 00:12:43 +0100 -Message-ID: <29@foo.bar.mail> -Mime-Version: 1.0 -Content-Type: text/plain; charset=UTF-8 -Content-Transfer-Encoding: quoted-printable - -Будьте здоровы! +Return-Path: +From: =?UTF-8?B?0KHQu9GD0YfQsNC50L3QsNGP?= =?UTF-8?B?INCY0LzRjw==?= +To: meat@bar.com +Subject: I need help +Date: Fri, 15 Jan 2016 00:12:43 +0100 +Message-ID: <29@foo.bar.mail> +Mime-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +Будьте здоровы! diff --git a/spec/fixtures/emails/forwarded_email_3.eml b/spec/fixtures/emails/forwarded_email_3.eml index 1f37822b0a..549dd44a68 100644 --- a/spec/fixtures/emails/forwarded_email_3.eml +++ b/spec/fixtures/emails/forwarded_email_3.eml @@ -1,18 +1,18 @@ -Message-ID: <60@foo.bar.mail> -From: Ba Bar -To: Team -Date: Mon, 9 Dec 2016 13:37:42 +0100 -Subject: Fwd: Ça Discourse ? - -@team, can you have a look at this email below? - -Objet: Ça Discourse ? -Date: 2017-01-04 11:27 -De: Un Français -À: ba@bar.com - -Bonjour, - -Ça Discourse bien aujourd'hui ? - -Bises +Message-ID: <60@foo.bar.mail> +From: Ba Bar +To: Team +Date: Mon, 9 Dec 2016 13:37:42 +0100 +Subject: Fwd: Ça Discourse ? + +@team, can you have a look at this email below? + +Objet: Ça Discourse ? +Date: 2017-01-04 11:27 +De: Un Français +À: ba@bar.com + +Bonjour, + +Ça Discourse bien aujourd'hui ? + +Bises diff --git a/spec/fixtures/emails/inline_image.eml b/spec/fixtures/emails/inline_image.eml index af3283702a..b169f8167a 100644 --- a/spec/fixtures/emails/inline_image.eml +++ b/spec/fixtures/emails/inline_image.eml @@ -1,76 +1,76 @@ -Return-Path: -From: Foo Bar -To: reply+4f97315cc828096c9cb34c6f1a0d6fe8@bar.com -Date: Fri, 15 Jan 2016 00:12:43 +0100 -Message-ID: <28@foo.bar.mail> -Mime-Version: 1.0 -Content-Type: multipart/related; boundary=001a114b2eccff183a052998ec68 - ---001a114b2eccff183a052998ec68 -Content-Type: multipart/alternative; boundary=001a114b2eccff1836052998ec67 - ---001a114b2eccff1836052998ec67 -Content-Type: text/plain; charset=UTF-8 - -Before - -[image: 内嵌图片 1] - -After - ---001a114b2eccff1836052998ec67 -Content-Type: text/html; charset=UTF-8 - -
Before

内嵌图片 1

After -
- ---001a114b2eccff1836052998ec67-- ---001a114b2eccff183a052998ec68 -Content-Type: image/png; name="logo.png" -Content-Disposition: inline; filename="logo.png" -Content-Transfer-Encoding: base64 -Content-ID: -X-Attachment-Id: ii_1525434659ddb4cb - -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 -uIDrr6oAAAAASUVORK5CYII= ---001a114b2eccff183a052998ec68-- +Return-Path: +From: Foo Bar +To: reply+4f97315cc828096c9cb34c6f1a0d6fe8@bar.com +Date: Fri, 15 Jan 2016 00:12:43 +0100 +Message-ID: <28@foo.bar.mail> +Mime-Version: 1.0 +Content-Type: multipart/related; boundary=001a114b2eccff183a052998ec68 + +--001a114b2eccff183a052998ec68 +Content-Type: multipart/alternative; boundary=001a114b2eccff1836052998ec67 + +--001a114b2eccff1836052998ec67 +Content-Type: text/plain; charset=UTF-8 + +Before + +[image: 内嵌图片 1] + +After + +--001a114b2eccff1836052998ec67 +Content-Type: text/html; charset=UTF-8 + +
Before

内嵌图片 1

After +
+ +--001a114b2eccff1836052998ec67-- +--001a114b2eccff183a052998ec68 +Content-Type: image/png; name="logo.png" +Content-Disposition: inline; filename="logo.png" +Content-Transfer-Encoding: base64 +Content-ID: +X-Attachment-Id: ii_1525434659ddb4cb + +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 +uIDrr6oAAAAASUVORK5CYII= +--001a114b2eccff183a052998ec68-- diff --git a/spec/fixtures/emails/reply_with_8bit_encoding.eml b/spec/fixtures/emails/reply_with_8bit_encoding.eml index ed99f6752a..2d025dd929 100644 --- a/spec/fixtures/emails/reply_with_8bit_encoding.eml +++ b/spec/fixtures/emails/reply_with_8bit_encoding.eml @@ -1,12 +1,12 @@ -Return-Path: -From: Foo Bar -To: reply+4f97315cc828096c9cb34c6f1a0d6fe8@bar.com -Date: Fri, 15 Jan 2016 00:12:43 +0100 -Message-ID: <43@foo.bar.mail> -MIME-Version: 1.0 -Content-Type: text/plain; charset=iso-8859-1 -Content-Disposition: inline -Content-Transfer-Encoding: 8bit - -hab vergessen kritische zeichen einzufgen: - +Return-Path: +From: Foo Bar +To: reply+4f97315cc828096c9cb34c6f1a0d6fe8@bar.com +Date: Fri, 15 Jan 2016 00:12:43 +0100 +Message-ID: <43@foo.bar.mail> +MIME-Version: 1.0 +Content-Type: text/plain; charset=iso-8859-1 +Content-Disposition: inline +Content-Transfer-Encoding: 8bit + +hab vergessen kritische zeichen einzufgen: + From b5b892d5c8a5a7214f936a849f6c5416bdd9d157 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Wed, 21 Feb 2018 17:12:44 +0800 Subject: [PATCH 049/299] Remove code climate badge which is meaningless for us. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 9a4c6f7595..1069b7ee84 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,6 @@ Plus *lots* of Ruby Gems, a complete list of which is at [/master/Gemfile](https ## Contributing [![Build Status](https://api.travis-ci.org/discourse/discourse.svg?branch=master)](https://travis-ci.org/discourse/discourse) -[![Code Climate](https://codeclimate.com/github/discourse/discourse.svg)](https://codeclimate.com/github/discourse/discourse) Discourse is **100% free** and **open source**. We encourage and support an active, healthy community that accepts contributions from the public – including you! From 210939de689de42f15029a429bd3da489bcea76a Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Wed, 21 Feb 2018 11:14:36 +0100 Subject: [PATCH 050/299] FEATURE: Use HTML instead of text for incoming emails by default --- config/site_settings.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/site_settings.yml b/config/site_settings.yml index 6a2cf5f3fc..744ae92ba5 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -718,7 +718,7 @@ email: pop3_polling_username: '' pop3_polling_password: '' log_mail_processing_failures: false - incoming_email_prefer_html: false + incoming_email_prefer_html: true email_in: default: false client: true From 97e19a7d02c48f9cc61e1256fe4647d682b9535a Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Wed, 21 Feb 2018 11:26:41 +0100 Subject: [PATCH 051/299] Fix the build --- spec/components/email/receiver_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb index 485dd6add4..838e3bb9b4 100644 --- a/spec/components/email/receiver_spec.rb +++ b/spec/components/email/receiver_spec.rb @@ -212,7 +212,8 @@ describe Email::Receiver do expect(topic.posts.last.raw).to eq("hab vergessen kritische zeichen einzufügen:\näöüÄÖÜß") end - it "prefers text over html" do + it "prefers text over html when site setting is disabled" do + SiteSetting.incoming_email_prefer_html = false expect { process(:text_and_html_reply) }.to change { topic.posts.count } expect(topic.posts.last.raw).to eq("This is the *text* part.") end @@ -362,6 +363,7 @@ describe Email::Receiver do end it "supports attached images in TEXT part" do + SiteSetting.incoming_email_prefer_html = false SiteSetting.queue_jobs = true expect { process(:no_body_with_image) }.to change { topic.posts.count } From 84ce1acfefbdef9897ab6e52c43bc122b788bcae Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 14 Feb 2018 02:16:25 +0530 Subject: [PATCH 052/299] FEATURE: Allow staffs to tag PMs --- .../components/topic-category.js.es6 | 9 +++- .../discourse/controllers/composer.js.es6 | 3 +- .../discourse/controllers/topic.js.es6 | 2 +- .../discourse/lib/render-tag.js.es6 | 7 ++- .../discourse/lib/render-tags.js.es6 | 3 +- .../discourse/routes/app-route-map.js.es6 | 1 + .../routes/user-private-messages-tag.js.es6 | 10 +++++ .../templates/components/topic-category.hbs | 14 +++--- .../javascripts/discourse/templates/topic.hbs | 4 +- .../stylesheets/common/base/compose.scss | 4 +- app/assets/stylesheets/common/base/topic.scss | 4 ++ app/controllers/list_controller.rb | 2 + app/models/tag.rb | 17 +------ app/models/topic_tag.rb | 22 +++++---- app/serializers/post_revision_serializer.rb | 4 +- .../search_topic_list_item_serializer.rb | 11 +---- app/serializers/site_serializer.rb | 9 +++- app/serializers/suggested_topic_serializer.rb | 11 +---- app/serializers/topic_list_item_serializer.rb | 10 +---- app/serializers/topic_tags_mixin.rb | 13 ++++++ app/serializers/topic_view_serializer.rb | 2 +- config/locales/server.en.yml | 1 + config/routes.rb | 27 +++++------ config/site_settings.yml | 2 + lib/discourse_tagging.rb | 4 +- lib/guardian.rb | 6 +++ lib/guardian/tag_guardian.rb | 6 ++- lib/topic_query.rb | 8 ++++ spec/models/tag_spec.rb | 45 ------------------- .../serializers/topic_view_serializer_spec.rb | 43 +++++++++++++++--- 30 files changed, 163 insertions(+), 141 deletions(-) create mode 100644 app/assets/javascripts/discourse/routes/user-private-messages-tag.js.es6 create mode 100644 app/serializers/topic_tags_mixin.rb diff --git a/app/assets/javascripts/discourse/components/topic-category.js.es6 b/app/assets/javascripts/discourse/components/topic-category.js.es6 index 97fcde13cd..58e58e0d1b 100644 --- a/app/assets/javascripts/discourse/components/topic-category.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-category.js.es6 @@ -1,2 +1,7 @@ -// Injections don't occur without a class -export default Ember.Component.extend(); +import computed from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + + @computed('topic.isPrivateMessage') + +}); diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index 68b5a75bac..9416e8df57 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -140,7 +140,8 @@ export default Ember.Controller.extend({ return !this.site.mobileView && this.site.get('can_tag_topics') && canEditTitle && - !creatingPrivateMessage; + !creatingPrivateMessage && + (!this.get('model.topic.isPrivateMessage') || this.site.get('can_tag_pms')); }, @computed('model.whisper', 'model.unlistTopic') diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 49110ce83b..a4e6b2e08d 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -104,7 +104,7 @@ export default Ember.Controller.extend(BufferedContent, { @computed('model.isPrivateMessage') canEditTags(isPrivateMessage) { - return !isPrivateMessage && this.site.get('can_tag_topics'); + return this.site.get('can_tag_topics') && (!isPrivateMessage || this.site.get('can_tag_pms')); }, actions: { diff --git a/app/assets/javascripts/discourse/lib/render-tag.js.es6 b/app/assets/javascripts/discourse/lib/render-tag.js.es6 index 127dc5c9fe..70fa81179f 100644 --- a/app/assets/javascripts/discourse/lib/render-tag.js.es6 +++ b/app/assets/javascripts/discourse/lib/render-tag.js.es6 @@ -3,7 +3,12 @@ export default function renderTag(tag, params) { tag = Handlebars.Utils.escapeExpression(tag); const classes = ['tag-' + tag, 'discourse-tag']; const tagName = params.tagName || "a"; - const href = (tagName === "a" && !params.noHref) ? " href='" + Discourse.getURL("/tags/" + tag) + "' " : ""; + let path; + if (tagName === "a" && !params.noHref) { + const current_user = Discourse.User.current(); + path = params.isPrivateMessage ? `/u/${current_user.username}/messages/tag/${tag}` : `/tags/${tag}`; + } + const href = path ? ` href='${Discourse.getURL(path)}' ` : ""; if (Discourse.SiteSettings.tag_style || params.style) { classes.push(params.style || Discourse.SiteSettings.tag_style); diff --git a/app/assets/javascripts/discourse/lib/render-tags.js.es6 b/app/assets/javascripts/discourse/lib/render-tags.js.es6 index 6989eab57b..ef86b502d3 100644 --- a/app/assets/javascripts/discourse/lib/render-tags.js.es6 +++ b/app/assets/javascripts/discourse/lib/render-tags.js.es6 @@ -20,6 +20,7 @@ export function addTagsHtmlCallback(callback, options) { export default function(topic, params){ let tags = topic.tags; let buffer = ""; + const isPrivateMessage = topic.get('isPrivateMessage'); if (params && params.mode === "list") { tags = topic.get("visibleListTags"); @@ -43,7 +44,7 @@ export default function(topic, params){ buffer = "
"; if (tags) { for(let i=0; i {{#if siteSettings.tagging_enabled}}
- {{#each topic.tags as |t|}} - {{discourse-tag t}} - {{/each}} + {{discourse-tags topic mode="list"}}
{{/if}} {{#if siteSettings.topic_featured_link_enabled}} diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index ef2d4912cc..167ffcf138 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -63,9 +63,7 @@ {{/if}} - {{#unless model.isPrivateMessage}} - {{topic-category topic=model class="topic-category"}} - {{/unless}} + {{topic-category topic=model class="topic-category"}} {{/if}} {{/topic-title}} {{/if}} diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss index 1a93bad72d..ad742d5971 100644 --- a/app/assets/stylesheets/common/base/compose.scss +++ b/app/assets/stylesheets/common/base/compose.scss @@ -189,7 +189,7 @@ .category-input { display: flex; flex: 1 0 35%; - margin: 0 0 5px 10px; + margin: 0 5px 5px 10px; @media screen and (max-width: 955px) { flex: 1 0 100%; margin-left: 0; @@ -223,7 +223,7 @@ .mini-tag-chooser { flex: 1 1 25%; - margin: 0 0 5px 5px; + margin: 0 0 5px 0; background: $secondary; @media all and (max-width: 900px) { margin: 0; diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss index 5b267622e3..d8b25930d3 100644 --- a/app/assets/stylesheets/common/base/topic.scss +++ b/app/assets/stylesheets/common/base/topic.scss @@ -122,6 +122,10 @@ a.badge-category { } } +.archetype-private_message #topic-title .edit-topic-title .tag-chooser { + margin-left: 19px; +} + .private_message { #topic-title { .edit-topic-title { diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb index 905f0077cb..384d5a00c2 100644 --- a/app/controllers/list_controller.rb +++ b/app/controllers/list_controller.rb @@ -151,6 +151,7 @@ class ListController < ApplicationController private_messages_archive private_messages_group private_messages_group_archive + private_messages_tag }.each do |action| generate_message_route(action) end @@ -332,6 +333,7 @@ class ListController < ApplicationController def build_topic_list_options options = {} params[:page] = params[:page].to_i rescue 1 + params[:tags] = [params[:tag_id]] if params[:tag_id].present? && guardian.can_tag_pms? TopicQuery.public_valid_options.each do |key| options[key] = params[key] diff --git a/app/models/tag.rb b/app/models/tag.rb index 45d900d422..500ba8e6e3 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -16,21 +16,6 @@ class Tag < ActiveRecord::Base after_save :index_search - COUNT_ARG = "topics.id" - - # Apply more activerecord filters to the tags_by_count_query, and then - # fetch the result with .count(Tag::COUNT_ARG). - # - # e.g., Tag.tags_by_count_query.where("topics.category_id = ?", category.id).count(Tag::COUNT_ARG) - def self.tags_by_count_query(opts = {}) - q = Tag.joins("LEFT JOIN topic_tags ON tags.id = topic_tags.tag_id") - .joins("LEFT JOIN topics ON topics.id = topic_tags.topic_id AND topics.deleted_at IS NULL") - .group("tags.id, tags.name") - .order('count_topics_id DESC') - q = q.limit(opts[:limit]) if opts[:limit] - q - end - def self.ensure_consistency! update_topic_counts # topic_count counter cache can miscount end @@ -43,7 +28,7 @@ class Tag < ActiveRecord::Base SELECT COUNT(topics.id) AS topic_count, tags.id AS tag_id FROM tags LEFT JOIN topic_tags ON tags.id = topic_tags.tag_id - LEFT JOIN topics ON topics.id = topic_tags.topic_id AND topics.deleted_at IS NULL + LEFT JOIN topics ON topics.id = topic_tags.topic_id AND topics.deleted_at IS NULL AND topics.archetype != "private_message" GROUP BY tags.id ) x WHERE x.tag_id = t.id diff --git a/app/models/topic_tag.rb b/app/models/topic_tag.rb index 2dc203290b..de53e4cf8a 100644 --- a/app/models/topic_tag.rb +++ b/app/models/topic_tag.rb @@ -1,22 +1,28 @@ class TopicTag < ActiveRecord::Base belongs_to :topic - belongs_to :tag, counter_cache: "topic_count" + belongs_to :tag after_create do - if topic.category_id - if stat = CategoryTagStat.where(tag_id: tag_id, category_id: topic.category_id).first - stat.increment!(:topic_count) - else - CategoryTagStat.create(tag_id: tag_id, category_id: topic.category_id, topic_count: 1) + if topic.archetype != Archetype.private_message + tag.increment!(:topic_count) + + if topic.category_id + if stat = CategoryTagStat.where(tag_id: tag_id, category_id: topic.category_id).first + stat.increment!(:topic_count) + else + CategoryTagStat.create(tag_id: tag_id, category_id: topic.category_id, topic_count: 1) + end end end end after_destroy do - if topic.category_id - if stat = CategoryTagStat.where(tag_id: tag_id, category: topic.category_id).first + if topic.archetype != Archetype.private_message + if topic.category_id && stat = CategoryTagStat.where(tag_id: tag_id, category: topic.category_id).first stat.topic_count == 1 ? stat.destroy : stat.decrement!(:topic_count) end + + tag.decrement!(:topic_count) end end end diff --git a/app/serializers/post_revision_serializer.rb b/app/serializers/post_revision_serializer.rb index 11b13677f4..7ed34769ae 100644 --- a/app/serializers/post_revision_serializer.rb +++ b/app/serializers/post_revision_serializer.rb @@ -164,7 +164,7 @@ class PostRevisionSerializer < ApplicationSerializer end def include_tags_changes? - SiteSetting.tagging_enabled && previous["tags"] != current["tags"] + SiteSetting.tagging_enabled && previous["tags"] != current["tags"] && (!topic.private_message? || scope.can_tag_pms?) end protected @@ -206,7 +206,7 @@ class PostRevisionSerializer < ApplicationSerializer latest_modifications["featured_link"] = [post.topic.featured_link] end - if SiteSetting.tagging_enabled + if SiteSetting.tagging_enabled && (!topic.private_message? || scope.can_tag_pms?) latest_modifications["tags"] = [post.topic.tags.map(&:name)] end diff --git a/app/serializers/search_topic_list_item_serializer.rb b/app/serializers/search_topic_list_item_serializer.rb index abe002899a..29640c13d0 100644 --- a/app/serializers/search_topic_list_item_serializer.rb +++ b/app/serializers/search_topic_list_item_serializer.rb @@ -1,12 +1,5 @@ class SearchTopicListItemSerializer < ListableTopicSerializer - attributes :tags, - :category_id + include TopicTagsMixin - def include_tags? - SiteSetting.tagging_enabled - end - - def tags - object.tags.map(&:name) - end + attributes :category_id end diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index 850a8dd8dd..1a81501d5e 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -21,6 +21,7 @@ class SiteSerializer < ApplicationSerializer :topic_flag_types, :can_create_tag, :can_tag_topics, + :can_tag_pms, :tags_filter_regexp, :top_tags, :wizard_required, @@ -106,11 +107,15 @@ class SiteSerializer < ApplicationSerializer end def can_create_tag - SiteSetting.tagging_enabled && scope.can_create_tag? + scope.can_create_tag? end def can_tag_topics - SiteSetting.tagging_enabled && scope.can_tag_topics? + scope.can_tag_topics? + end + + def can_tag_pms + scope.can_tag_pms? end def include_tags_filter_regexp? diff --git a/app/serializers/suggested_topic_serializer.rb b/app/serializers/suggested_topic_serializer.rb index 85eeea9499..a2dcdef6b1 100644 --- a/app/serializers/suggested_topic_serializer.rb +++ b/app/serializers/suggested_topic_serializer.rb @@ -1,4 +1,5 @@ class SuggestedTopicSerializer < ListableTopicSerializer + include TopicTagsMixin # need to embed so we have users # front page json gets away without embedding @@ -7,21 +8,13 @@ class SuggestedTopicSerializer < ListableTopicSerializer has_one :user, serializer: BasicUserSerializer, embed: :objects end - attributes :archetype, :like_count, :views, :category_id, :tags, :featured_link, :featured_link_root_domain + attributes :archetype, :like_count, :views, :category_id, :featured_link, :featured_link_root_domain has_many :posters, serializer: SuggestedPosterSerializer, embed: :objects def posters object.posters || [] end - def include_tags? - SiteSetting.tagging_enabled - end - - def tags - object.tags.map(&:name) - end - def include_featured_link? SiteSetting.topic_featured_link_enabled end diff --git a/app/serializers/topic_list_item_serializer.rb b/app/serializers/topic_list_item_serializer.rb index 0ce4f4cd50..66092c6246 100644 --- a/app/serializers/topic_list_item_serializer.rb +++ b/app/serializers/topic_list_item_serializer.rb @@ -1,4 +1,5 @@ class TopicListItemSerializer < ListableTopicSerializer + include TopicTagsMixin attributes :views, :like_count, @@ -10,7 +11,6 @@ class TopicListItemSerializer < ListableTopicSerializer :pinned_globally, :bookmarked_post_numbers, :liked_post_numbers, - :tags, :featured_link, :featured_link_root_domain @@ -66,14 +66,6 @@ class TopicListItemSerializer < ListableTopicSerializer object.association(:first_post).loaded? end - def include_tags? - SiteSetting.tagging_enabled - end - - def tags - object.tags.map(&:name) - end - def include_featured_link? SiteSetting.topic_featured_link_enabled end diff --git a/app/serializers/topic_tags_mixin.rb b/app/serializers/topic_tags_mixin.rb new file mode 100644 index 0000000000..2257ebac12 --- /dev/null +++ b/app/serializers/topic_tags_mixin.rb @@ -0,0 +1,13 @@ +module TopicTagsMixin + def self.included(klass) + klass.attributes :tags + end + + def include_tags? + SiteSetting.tagging_enabled && (!object.private_message? || scope.can_tag_pms?) + end + + def tags + object.tags.pluck(:name) + end +end diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index 839a931134..86a57abef6 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -239,7 +239,7 @@ class TopicViewSerializer < ApplicationSerializer end def include_tags? - SiteSetting.tagging_enabled + SiteSetting.tagging_enabled && (!object.topic.private_message? || scope.can_tag_pms?) end def topic_timer diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index d5ef33a877..7484554340 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1609,6 +1609,7 @@ en: tags_listed_by_group: "List tags by tag group on the Tags page (/tags)." tag_style: "Visual style for tag badges." staff_tags: "A list of tags that can only be applied by staff members" + allow_staff_to_tag_in_pm: "Allow staff members to tag any personal message" min_trust_level_to_tag_topics: "Minimum trust level required to tag topics" suppress_overlapping_tags_in_list: "If tags match exact words in topic titles, don't show the tag" remove_muted_tags_from_latest: "Don't show topics tagged with muted tags in the latest topic list." diff --git a/config/routes.rb b/config/routes.rb index 11edecf0f9..5c0e947df6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -360,6 +360,7 @@ Discourse::Application.routes.draw do get "#{root_path}/:username/messages/:filter" => "user_actions#private_messages", constraints: { username: RouteFormat.username } get "#{root_path}/:username/messages/group/:group_name" => "user_actions#private_messages", constraints: { username: RouteFormat.username, group_name: RouteFormat.username } get "#{root_path}/:username/messages/group/:group_name/archive" => "user_actions#private_messages", constraints: { username: RouteFormat.username, group_name: RouteFormat.username } + get "#{root_path}/:username/messages/tag/:tag_id" => "user_actions#private_messages", constraints: StaffConstraint.new get "#{root_path}/:username.json" => "users#show", constraints: { username: RouteFormat.username }, defaults: { format: :json } get({ "#{root_path}/:username" => "users#show", constraints: { username: RouteFormat.username, format: /(json|html)/ } }.merge(index == 1 ? { as: 'user' } : {})) put "#{root_path}/:username" => "users#update", constraints: { username: RouteFormat.username }, defaults: { format: :json } @@ -589,20 +590,20 @@ Discourse::Application.routes.draw do resources :similar_topics get "topics/feature_stats" - get "topics/created-by/:username" => "list#topics_by", as: "topics_by", constraints: { username: RouteFormat.username } - get "topics/private-messages/:username" => "list#private_messages", as: "topics_private_messages", constraints: { username: RouteFormat.username } - get "topics/private-messages-sent/:username" => "list#private_messages_sent", as: "topics_private_messages_sent", constraints: { username: RouteFormat.username } - get "topics/private-messages-archive/:username" => "list#private_messages_archive", as: "topics_private_messages_archive", constraints: { username: RouteFormat.username } - get "topics/private-messages-unread/:username" => "list#private_messages_unread", as: "topics_private_messages_unread", constraints: { username: RouteFormat.username } - get "topics/private-messages-group/:username/:group_name.json" => "list#private_messages_group", as: "topics_private_messages_group", constraints: { - username: RouteFormat.username, - group_name: RouteFormat.username - } - get "topics/private-messages-group/:username/:group_name/archive.json" => "list#private_messages_group_archive", as: "topics_private_messages_group_archive", constraints: { - username: RouteFormat.username, - group_name: RouteFormat.username - } + scope "/topics", username: RouteFormat.username do + get "created-by/:username" => "list#topics_by", as: "topics_by" + get "private-messages/:username" => "list#private_messages", as: "topics_private_messages" + get "private-messages-sent/:username" => "list#private_messages_sent", as: "topics_private_messages_sent" + get "private-messages-archive/:username" => "list#private_messages_archive", as: "topics_private_messages_archive" + get "private-messages-unread/:username" => "list#private_messages_unread", as: "topics_private_messages_unread" + get "private-messages-tag/:username/:tag_id.json" => "list#private_messages_tag", as: "topics_private_messages_tag", constraints: StaffConstraint.new + + scope "/private-messages-group/:username", group_name: RouteFormat.username do + get ":group_name.json" => "list#private_messages_group", as: "topics_private_messages_group" + get ":group_name/archive.json" => "list#private_messages_group_archive", as: "topics_private_messages_group_archive" + end + end get 'embed/comments' => 'embed#comments' get 'embed/count' => 'embed#count' diff --git a/config/site_settings.yml b/config/site_settings.yml index 55163cd323..e68b1000ec 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1560,6 +1560,8 @@ tags: type: list client: true default: '' + allow_staff_to_tag_in_pm: + default: false suppress_overlapping_tags_in_list: default: false client: true diff --git a/lib/discourse_tagging.rb b/lib/discourse_tagging.rb index 0fcf8bda9b..e30457035a 100644 --- a/lib/discourse_tagging.rb +++ b/lib/discourse_tagging.rb @@ -4,10 +4,10 @@ module DiscourseTagging TAGS_FILTER_REGEXP = /[\/\?#\[\]@!\$&'\(\)\*\+,;=\.%\\`^\s|\{\}"<>]+/ # /?#[]@!$&'()*+,;=.%\`^|{}"<> def self.tag_topic_by_names(topic, guardian, tag_names_arg, append: false) - if SiteSetting.tagging_enabled + if can_tag?(topic) tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, guardian) || [] - old_tag_names = topic.tags.map(&:name) || [] + old_tag_names = topic.tags.pluck(:name) || [] new_tag_names = tag_names - old_tag_names removed_tag_names = old_tag_names - tag_names diff --git a/lib/guardian.rb b/lib/guardian.rb index 19d9fe7823..1b658ef888 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -129,6 +129,12 @@ class Guardian alias :can_see_flags? :can_moderate? alias :can_close? :can_moderate? + def can_tag?(obj) + return false unless obj && obj.is_a?(Topic) + + obj.private_message? ? can_tag_pms? : can_tag_topics? + end + def can_send_activation_email?(user) user && is_staff? && !SiteSetting.must_approve_users? end diff --git a/lib/guardian/tag_guardian.rb b/lib/guardian/tag_guardian.rb index b9315078df..8f60c8223c 100644 --- a/lib/guardian/tag_guardian.rb +++ b/lib/guardian/tag_guardian.rb @@ -5,7 +5,11 @@ module TagGuardian end def can_tag_topics? - user && user.has_trust_level?(SiteSetting.min_trust_level_to_tag_topics.to_i) + user && SiteSetting.tagging_enabled && user.has_trust_level?(SiteSetting.min_trust_level_to_tag_topics.to_i) + end + + def can_tag_pms? + is_staff? && SiteSetting.tagging_enabled && SiteSetting.allow_staff_to_tag_in_pm end def can_admin_tags? diff --git a/lib/topic_query.rb b/lib/topic_query.rb index 34ca3f32dd..fd9464fffc 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -269,6 +269,14 @@ class TopicQuery create_list(:private_messages, {}, list) end + def list_private_messages_tag(user) + list = private_messages_for(user, :all) + tag_id = Tag.where('name ilike ?', @options[:tags][0]).pluck(:id).first + list = list.joins("JOIN topic_tags tt ON tt.topic_id = topics.id AND + tt.tag_id = #{tag_id}") + create_list(:private_messages, {}, list) + end + def list_category_topic_ids(category) query = default_results(category: category.id) pinned_ids = query.where('pinned_at IS NOT NULL AND category_id = ?', category.id).limit(nil).order('pinned_at DESC').pluck(:id) diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 6cea43f305..faa6145a80 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -15,51 +15,6 @@ describe Tag do SiteSetting.min_trust_level_to_tag_topics = 0 end - describe '#tags_by_count_query' do - it "returns empty hash if nothing is tagged" do - expect(described_class.tags_by_count_query.count(Tag::COUNT_ARG)).to eq({}) - end - - context "with some tagged topics" do - before do - @topics = [] - 3.times { @topics << Fabricate(:topic) } - make_some_tags(count: 2) - @topics[0].tags << @tags[0] - @topics[0].tags << @tags[1] - @topics[1].tags << @tags[0] - end - - it "returns tag names with topic counts in a hash" do - counts = described_class.tags_by_count_query.count(Tag::COUNT_ARG) - expect(counts[@tags[0].name]).to eq(2) - expect(counts[@tags[1].name]).to eq(1) - end - - it "can be used to filter before doing the count" do - counts = described_class.tags_by_count_query.where("topics.id = ?", @topics[1].id).count(Tag::COUNT_ARG) - expect(counts).to eq(@tags[0].name => 1) - end - - it "returns unused tags too" do - unused = Fabricate(:tag) - counts = described_class.tags_by_count_query.count(Tag::COUNT_ARG) - expect(counts[unused.name]).to eq(0) - end - - it "doesn't include deleted topics in counts" do - deleted_topic_tag = Fabricate(:tag) - delete_topic = Fabricate(:topic) - post = Fabricate(:post, topic: delete_topic, user: delete_topic.user) - delete_topic.tags << deleted_topic_tag - PostDestroyer.new(Fabricate(:admin), post).destroy - - counts = described_class.tags_by_count_query.count(Tag::COUNT_ARG) - expect(counts[deleted_topic_tag.name]).to eq(0) - end - end - end - describe '#top_tags' do it "returns nothing if nothing has been tagged" do make_some_tags(tag_a_topic: false) diff --git a/spec/serializers/topic_view_serializer_spec.rb b/spec/serializers/topic_view_serializer_spec.rb index c2970db194..90d3e7a5fc 100644 --- a/spec/serializers/topic_view_serializer_spec.rb +++ b/spec/serializers/topic_view_serializer_spec.rb @@ -1,6 +1,11 @@ require 'rails_helper' describe TopicViewSerializer do + def serialize_topic(topic, user) + topic_view = TopicView.new(topic.id, user) + described_class.new(topic_view, scope: Guardian.new(user), root: false).as_json + end + let(:topic) { Fabricate(:topic) } let(:user) { Fabricate(:user) } @@ -12,8 +17,7 @@ describe TopicViewSerializer do topic.update!(featured_link: featured_link) SiteSetting.topic_featured_link_enabled = false - topic_view = TopicView.new(topic.id, user) - json = described_class.new(topic_view, scope: Guardian.new(user), root: false).as_json + json = serialize_topic(topic, user) expect(json[:featured_link]).to eq(nil) expect(json[:featured_link_root_domain]).to eq(nil) @@ -24,8 +28,7 @@ describe TopicViewSerializer do it 'should return the right attributes' do topic.update!(featured_link: featured_link) - topic_view = TopicView.new(topic.id, user) - json = described_class.new(topic_view, scope: Guardian.new(user), root: false).as_json + json = serialize_topic(topic, user) expect(json[:featured_link]).to eq(featured_link) expect(json[:featured_link_root_domain]).to eq('discourse.org') @@ -42,8 +45,7 @@ describe TopicViewSerializer do describe 'when loading last chunk' do it 'should include suggested topics' do - topic_view = TopicView.new(topic.id, user) - json = described_class.new(topic_view, scope: Guardian.new(user), root: false).as_json + json = serialize_topic(topic, user) expect(json[:suggested_topics].first.id).to eq(topic2.id) end @@ -64,4 +66,33 @@ describe TopicViewSerializer do end end end + + let(:user) { Fabricate(:user) } + let(:moderator) { Fabricate(:moderator) } + let(:tag) { Fabricate(:tag) } + let(:pm) do + Fabricate(:private_message_topic, tags: [tag], topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: moderator), + Fabricate.build(:topic_allowed_user, user: user) + ]) + end + + describe 'when tags added to private message topics' do + before do + SiteSetting.tagging_enabled = true + end + + it "should not include the tag for normal users" do + json = serialize_topic(pm, user) + expect(json[:tags]).to eq(nil) + end + + it "should include the tag for staff users" do + json = serialize_topic(pm, moderator) + expect(json[:tags]).to eq([tag.name]) + + json = serialize_topic(pm, Fabricate(:admin)) + expect(json[:tags]).to eq([tag.name]) + end + end end From d4b2e840cb40a4b969308a1e0f0bff644bfbded9 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 21 Feb 2018 20:19:19 +0530 Subject: [PATCH 053/299] remove unwanted code --- .../discourse/components/topic-category.js.es6 | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/discourse/components/topic-category.js.es6 b/app/assets/javascripts/discourse/components/topic-category.js.es6 index 58e58e0d1b..97fcde13cd 100644 --- a/app/assets/javascripts/discourse/components/topic-category.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-category.js.es6 @@ -1,7 +1,2 @@ -import computed from 'ember-addons/ember-computed-decorators'; - -export default Ember.Component.extend({ - - @computed('topic.isPrivateMessage') - -}); +// Injections don't occur without a class +export default Ember.Component.extend(); From 776ab73a8d159c02c7d72d6d5b4201480808cf02 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 21 Feb 2018 21:22:56 +0530 Subject: [PATCH 054/299] FIX: can_tag method called without guardian variable --- lib/discourse_tagging.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/discourse_tagging.rb b/lib/discourse_tagging.rb index e30457035a..f9284f9751 100644 --- a/lib/discourse_tagging.rb +++ b/lib/discourse_tagging.rb @@ -4,7 +4,7 @@ module DiscourseTagging TAGS_FILTER_REGEXP = /[\/\?#\[\]@!\$&'\(\)\*\+,;=\.%\\`^\s|\{\}"<>]+/ # /?#[]@!$&'()*+,;=.%\`^|{}"<> def self.tag_topic_by_names(topic, guardian, tag_names_arg, append: false) - if can_tag?(topic) + if guardian.can_tag?(topic) tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, guardian) || [] old_tag_names = topic.tags.pluck(:name) || [] From 4d842ef2d5ab0641128b04f544db36eac8838886 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 21 Feb 2018 21:47:02 +0530 Subject: [PATCH 055/299] Additional spec test function added and fixed the existing --- spec/serializers/topic_view_serializer_spec.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/spec/serializers/topic_view_serializer_spec.rb b/spec/serializers/topic_view_serializer_spec.rb index 90d3e7a5fc..098f7f89d7 100644 --- a/spec/serializers/topic_view_serializer_spec.rb +++ b/spec/serializers/topic_view_serializer_spec.rb @@ -80,6 +80,7 @@ describe TopicViewSerializer do describe 'when tags added to private message topics' do before do SiteSetting.tagging_enabled = true + SiteSetting.allow_staff_to_tag_in_pm = true end it "should not include the tag for normal users" do @@ -94,5 +95,15 @@ describe TopicViewSerializer do json = serialize_topic(pm, Fabricate(:admin)) expect(json[:tags]).to eq([tag.name]) end + + it "should not include the tag if pm tags disabled" do + SiteSetting.allow_staff_to_tag_in_pm = false + + json = serialize_topic(pm, moderator) + expect(json[:tags]).to eq(nil) + + json = serialize_topic(pm, Fabricate(:admin)) + expect(json[:tags]).to eq(nil) + end end end From 4e7244d8d9aa101641c2503029555a8794376ae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Wed, 21 Feb 2018 17:51:53 +0100 Subject: [PATCH 056/299] FIX: never open internal links in a new tab when user prefers opening external links in a new tab --- .../javascripts/discourse/lib/click-track.js.es6 | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/lib/click-track.js.es6 b/app/assets/javascripts/discourse/lib/click-track.js.es6 index 13845062a4..d4dc44f2cb 100644 --- a/app/assets/javascripts/discourse/lib/click-track.js.es6 +++ b/app/assets/javascripts/discourse/lib/click-track.js.es6 @@ -26,7 +26,7 @@ export default { } // don't track links in quotes or in elided part - let tracking = $link.parents('aside.quote,.elided').length === 0; + let tracking = $link.parents('aside.quote, .elided').length === 0; let href = $link.attr('href') || $link.data('href'); @@ -113,8 +113,10 @@ export default { return false; } + const isInternal = DiscourseURL.isInternal(href); + // If we're on the same site, use the router and track via AJAX - if (tracking && DiscourseURL.isInternal(href) && !$link.hasClass('attachment')) { + if (tracking && isInternal && !$link.hasClass('attachment')) { ajax("/clicks/track", { data: { url: href, @@ -128,9 +130,11 @@ export default { return false; } - // Otherwise, use a custom URL with a redirect - // consider CTRL+mouse-left-click / CMD+mouse-left-click or mouse-middle-click as well - if (Discourse.User.currentProp('external_links_in_new_tab') || ((e.ctrlKey || e.metaKey) && (e.which === 1)) || (e.which === 2)) { + const modifierLeftClicked = (e.ctrlKey || e.metaKey) && e.which === 1; + const middleClicked = e.which === 2; + const openExternalInNewTab = Discourse.User.currentProp('external_links_in_new_tab'); + + if (modifierLeftClicked || middleClicked || (!isInternal && openExternalInNewTab)) { window.open(destUrl, '_blank').focus(); } else { DiscourseURL.redirectTo(destUrl); From 81e873138fa2a4d4aed2d81892179b3cf2913917 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Wed, 21 Feb 2018 12:35:53 -0500 Subject: [PATCH 057/299] FIX: error when deleting a tag associated with a deleted topic --- app/models/topic_tag.rb | 4 ++-- spec/models/tag_spec.rb | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/models/topic_tag.rb b/app/models/topic_tag.rb index f0996d1c9d..067e1f79bf 100644 --- a/app/models/topic_tag.rb +++ b/app/models/topic_tag.rb @@ -3,7 +3,7 @@ class TopicTag < ActiveRecord::Base belongs_to :tag, counter_cache: "topic_count" after_create do - if topic.category_id + if topic&.category_id if stat = CategoryTagStat.where(tag_id: tag_id, category_id: topic.category_id).first stat.increment!(:topic_count) else @@ -13,7 +13,7 @@ class TopicTag < ActiveRecord::Base end after_destroy do - if topic.category_id + if topic&.category_id if stat = CategoryTagStat.where(tag_id: tag_id, category: topic.category_id).first stat.topic_count == 1 ? stat.destroy : stat.decrement!(:topic_count) end diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 6cea43f305..06287133a5 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -15,6 +15,13 @@ describe Tag do SiteSetting.min_trust_level_to_tag_topics = 0 end + it "can delete tags on deleted topics" do + tag = Fabricate(:tag) + topic = Fabricate(:topic, tags: [tag]) + topic.trash! + expect { tag.destroy }.to change { Tag.count }.by(-1) + end + describe '#tags_by_count_query' do it "returns empty hash if nothing is tagged" do expect(described_class.tags_by_count_query.count(Tag::COUNT_ARG)).to eq({}) From 23f7c3607c5ec4e19d35746ffe67c061abaaf945 Mon Sep 17 00:00:00 2001 From: Joshua Rosenfeld Date: Wed, 21 Feb 2018 13:07:33 -0500 Subject: [PATCH 058/299] Update Twitter login site setting description text --- config/locales/server.en.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 0f0d44035c..39de1da367 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1167,8 +1167,8 @@ en: google_oauth2_client_secret: "Client secret of your Google application." enable_twitter_logins: "Enable Twitter authentication, requires twitter_consumer_key and twitter_consumer_secret" - twitter_consumer_key: "Consumer key for Twitter authentication, registered at http://dev.twitter.com" - twitter_consumer_secret: "Consumer secret for Twitter authentication, registered at http://dev.twitter.com" + twitter_consumer_key: "Consumer key for Twitter authentication, registered at https://apps.twitter.com/" + twitter_consumer_secret: "Consumer secret for Twitter authentication, registered at https://apps.twitter.com/" enable_instagram_logins: "Enable Instagram authentication, requires instagram_consumer_key and instagram_consumer_secret" instagram_consumer_key: "Consumer key for Instagram authentication" From 83d8fa2892b748f03aff6a4519da9f48897a313e Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 21 Feb 2018 13:37:14 -0500 Subject: [PATCH 059/299] FIX: Allow customized usernames to work in this route Co-authored-by: jjaffeux --- app/controllers/list_controller.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb index 905f0077cb..e79137211c 100644 --- a/app/controllers/list_controller.rb +++ b/app/controllers/list_controller.rb @@ -293,10 +293,11 @@ class ListController < ApplicationController def page_params(opts = nil) opts ||= {} route_params = { format: 'json' } - route_params[:category] = @category.slug_for_url if @category - route_params[:parent_category] = @category.parent_category.slug_for_url if @category && @category.parent_category - route_params[:order] = opts[:order] if opts[:order].present? - route_params[:ascending] = opts[:ascending] if opts[:ascending].present? + route_params[:category] = @category.slug_for_url if @category + route_params[:parent_category] = @category.parent_category.slug_for_url if @category && @category.parent_category + route_params[:order] = opts[:order] if opts[:order].present? + route_params[:ascending] = opts[:ascending] if opts[:ascending].present? + route_params[:username] = UrlHelper.escape_uri(params[:username]) if params[:username].present? route_params end From 3ec8b387963b4499ad8a6cb113d3e2d20bf35903 Mon Sep 17 00:00:00 2001 From: Joshua Rosenfeld Date: Wed, 21 Feb 2018 15:28:26 -0500 Subject: [PATCH 060/299] A few more 'private message' strings to update Follow up from https://github.com/discourse/discourse/commit/a08832bd0896655167a58c2079d100c40d8d94d0 --- config/locales/server.en.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 39de1da367..47e0ac97b0 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -700,8 +700,8 @@ en: long_form: 'flagged this as inappropriate' notify_user: title: 'Send @{{username}} a message' - description: 'I want to talk to this person directly and privately about their post.' - short_description: 'I want to talk to this person directly and privately about their post.' + description: 'I want to talk to this person directly and personally about their post.' + short_description: 'I want to talk to this person directly and personally about their post.' long_form: 'messaged user' email_title: 'Your post in "%{title}"' email_body: "%{link}\n\n%{message}" @@ -1049,7 +1049,7 @@ en: enable_personal_messages: "Allow trust level 1 (configurable via min trust level to send messages) users to create messages and reply to messages. Note that staff can always send messages no matter what." enable_system_message_replies: "Allows users to reply to system messages, even if personal messages are disabled" - enable_personal_email_messages: "Allow trust level 4 (configurable via min trust level to send messages) users to send private email messages. Note that staff can always send messages no matter what." + enable_personal_email_messages: "Allow trust level 4 (configurable via min trust level to send messages) users to send personal email messages. Note that staff can always send messages no matter what." enable_long_polling: "Message bus used for notification can use long polling" long_polling_base_url: "Base URL used for long polling (when a CDN is serving dynamic content, be sure to set this to origin pull) eg: http://origin.site.com" @@ -1319,7 +1319,7 @@ en: create_thumbnails: "Create thumbnails and lightbox images that are too large to fit in a post." email_time_window_mins: "Wait (n) minutes before sending any notification emails, to give users a chance to edit and finalize their posts." - personal_email_time_window_seconds: "Wait (n) seconds before sending any private notification emails, to give users a chance to edit and finalize their messages." + personal_email_time_window_seconds: "Wait (n) seconds before sending any personal message notification emails, to give users a chance to edit and finalize their messages." email_posts_context: "How many prior replies to include as context in notification emails." flush_timings_secs: "How frequently we flush timing data to the server, in seconds." title_max_word_length: "The maximum allowed word length, in characters, in a topic title." From d2b518c61c916322ae0d03cc5d5c13cdea973e94 Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Wed, 21 Feb 2018 14:23:47 -0800 Subject: [PATCH 061/299] QR code display fix on dark backgrounds (#5613) https://meta.discourse.org/t/2fa-qr-code-not-visible-on-dark-theme/81152?u=awole20 --- .../discourse/templates/preferences-second-factor.hbs | 6 +++++- app/assets/stylesheets/common/base/user.scss | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs index 29a3a61df3..2865656f39 100644 --- a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs +++ b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs @@ -48,7 +48,11 @@
- {{{secondFactorImage}}} +
+
+ {{{secondFactorImage}}} +
+

{{#if showSecondFactorKey}} diff --git a/app/assets/stylesheets/common/base/user.scss b/app/assets/stylesheets/common/base/user.scss index 8c39904e93..59c235e5be 100644 --- a/app/assets/stylesheets/common/base/user.scss +++ b/app/assets/stylesheets/common/base/user.scss @@ -565,3 +565,10 @@ } } +.qr-code-container { + display: flex; + .qr-code { + padding: 5px 5px 0 5px; + background-color: white; + } +} From 720e1965e3da431c56cef6719d857c603b7aa9ef Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 22 Feb 2018 09:56:18 +1100 Subject: [PATCH 062/299] FEATURE: add category suppress from latest In the past we used suppress_from_homepage, it had mixed semantics it would remove from category list if category list was on home and unconditionally remove from latest. New setting explicitly only removes from latest list but leaves the category list alond --- .../discourse/models/category.js.es6 | 2 +- .../models/topic-tracking-state.js.es6 | 4 +-- .../components/edit-category-settings.hbs | 4 +-- app/controllers/categories_controller.rb | 4 +-- app/controllers/list_controller.rb | 4 +-- app/models/category.rb | 2 +- app/models/category_list.rb | 1 - app/models/site.rb | 4 +-- app/serializers/category_serializer.rb | 4 +-- app/serializers/site_serializer.rb | 2 +- config/locales/client.en.yml | 2 +- db/fixtures/001_categories.rb | 4 +-- ..._add_suppress_from_latest_to_categories.rb | 11 ++++++++ lib/import_export/base_exporter.rb | 2 +- script/import_scripts/discuz_x.rb | 2 +- spec/controllers/list_controller_spec.rb | 6 ++--- spec/fixtures/json/import-export.json | 12 ++++----- spec/requests/list_controller_spec.rb | 27 +++++++++++++++++++ 18 files changed, 67 insertions(+), 30 deletions(-) create mode 100644 db/migrate/20180221215641_add_suppress_from_latest_to_categories.rb diff --git a/app/assets/javascripts/discourse/models/category.js.es6 b/app/assets/javascripts/discourse/models/category.js.es6 index 52c3eb443c..1eca95047e 100644 --- a/app/assets/javascripts/discourse/models/category.js.es6 +++ b/app/assets/javascripts/discourse/models/category.js.es6 @@ -97,7 +97,7 @@ const Category = RestModel.extend({ allow_badges: this.get('allow_badges'), custom_fields: this.get('custom_fields'), topic_template: this.get('topic_template'), - suppress_from_homepage: this.get('suppress_from_homepage'), + suppress_from_latest: this.get('suppress_from_latest'), all_topics_wiki: this.get('all_topics_wiki'), allowed_tags: this.get('allowed_tags'), allowed_tag_groups: this.get('allowed_tag_groups'), diff --git a/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 b/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 index d4c3a0354a..5e7cabd775 100644 --- a/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 +++ b/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 @@ -117,8 +117,8 @@ const TopicTrackingState = Discourse.Model.extend({ } if (filter === defaultHomepage()) { - const suppressed_from_homepage_category_ids = Discourse.Site.currentProp("suppressed_from_homepage_category_ids"); - if (_.include(suppressed_from_homepage_category_ids, data.payload.category_id)) { + const suppressed_from_latest_category_ids = Discourse.Site.currentProp("suppressed_from_latest_category_ids"); + if (_.include(suppressed_from_latest_category_ids, data.payload.category_id)) { return; } } diff --git a/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs b/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs index fa7b2a5653..2c9735d40c 100644 --- a/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs +++ b/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs @@ -21,8 +21,8 @@

diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 58a95610c4..9c95a1da07 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -61,7 +61,7 @@ class CategoriesController < ApplicationController topic_options = { per_page: SiteSetting.categories_topics, no_definitions: true, - exclude_category_ids: Category.where(suppress_from_homepage: true).pluck(:id) + exclude_category_ids: Category.where(suppress_from_latest: true).pluck(:id) } result = CategoryAndTopicLists.new @@ -235,7 +235,7 @@ class CategoriesController < ApplicationController :email_in, :email_in_allow_strangers, :mailinglist_mirror, - :suppress_from_homepage, + :suppress_from_latest, :all_topics_wiki, :parent_category_id, :auto_close_hours, diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb index e79137211c..e5f30bcae5 100644 --- a/app/controllers/list_controller.rb +++ b/app/controllers/list_controller.rb @@ -63,7 +63,7 @@ class ListController < ApplicationController if filter == :latest list_opts[:no_definitions] = true end - if filter.to_s == current_homepage + if [:latest, :categories].include?(filter) list_opts[:exclude_category_ids] = get_excluded_category_ids(list_opts[:category]) end end @@ -369,7 +369,7 @@ class ListController < ApplicationController end def get_excluded_category_ids(current_category = nil) - exclude_category_ids = Category.where(suppress_from_homepage: true) + exclude_category_ids = Category.where(suppress_from_latest: true) exclude_category_ids = exclude_category_ids.where.not(id: current_category) if current_category exclude_category_ids.pluck(:id) end diff --git a/app/models/category.rb b/app/models/category.rb index cdc03b5bf5..ef8893209e 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -542,7 +542,7 @@ end # name_lower :string(50) not null # auto_close_based_on_last_post :boolean default(FALSE) # topic_template :text -# suppress_from_homepage :boolean default(FALSE) +# suppress_from_latest :boolean default(FALSE) # contains_messages :boolean # sort_order :string # sort_ascending :boolean diff --git a/app/models/category_list.rb b/app/models/category_list.rb index ccc23d88b6..76511d5b8b 100644 --- a/app/models/category_list.rb +++ b/app/models/category_list.rb @@ -72,7 +72,6 @@ class CategoryList subcategories: [:topic_only_relative_url] ).secured(@guardian) - @categories = @categories.where(suppress_from_homepage: false) if @options[:is_homepage] @categories = @categories.where("categories.parent_category_id = ?", @options[:parent_category_id].to_i) if @options[:parent_category_id].present? if SiteSetting.fixed_category_positions diff --git a/app/models/site.rb b/app/models/site.rb index 9d90b2c1bc..b3001e49d9 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -71,8 +71,8 @@ class Site end end - def suppressed_from_homepage_category_ids - categories.select { |c| c.suppress_from_homepage == true }.map(&:id) + def suppressed_from_latest_category_ids + categories.select { |c| c.suppress_from_latest == true }.map(&:id) end def archetypes diff --git a/app/serializers/category_serializer.rb b/app/serializers/category_serializer.rb index 3c5f98a97b..d9d37497a2 100644 --- a/app/serializers/category_serializer.rb +++ b/app/serializers/category_serializer.rb @@ -9,7 +9,7 @@ class CategorySerializer < BasicCategorySerializer :email_in, :email_in_allow_strangers, :mailinglist_mirror, - :suppress_from_homepage, + :suppress_from_latest, :all_topics_wiki, :can_delete, :cannot_delete_reason, @@ -72,7 +72,7 @@ class CategorySerializer < BasicCategorySerializer scope && scope.can_edit?(object) end - def include_suppress_from_homepage? + def include_suppress_from_latest? scope && scope.can_edit?(object) end diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index 850a8dd8dd..fce74a802a 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -16,7 +16,7 @@ class SiteSerializer < ApplicationSerializer :is_readonly, :disabled_plugins, :user_field_max_length, - :suppressed_from_homepage_category_ids, + :suppressed_from_latest_category_ids, :post_action_types, :topic_flag_types, :can_create_tag, diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index a0069439d1..284f70a6fd 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2209,7 +2209,7 @@ en: email_in_disabled: "Posting new topics via email is disabled in the Site Settings. To enable posting new topics via email, " email_in_disabled_click: 'enable the "email in" setting.' mailinglist_mirror: "Category mirrors a mailing list" - suppress_from_homepage: "Suppress this category from the homepage." + suppress_from_latest: "Suppress category from latest topics." show_subcategory_list: "Show subcategory list above topics in this category." num_featured_topics: "Number of topics shown on the categories page:" subcategory_num_featured_topics: "Number of featured topics on parent category's page:" diff --git a/db/fixtures/001_categories.rb b/db/fixtures/001_categories.rb index 4dc13268ac..98b0be1a32 100644 --- a/db/fixtures/001_categories.rb +++ b/db/fixtures/001_categories.rb @@ -28,8 +28,8 @@ end ColumnDropper.drop( table: 'categories', - after_migration: 'AddUploadsToCategories', - columns: ['logo_url', 'background_url'], + after_migration: 'AddSuppressFromLatestToCategories', + columns: ['logo_url', 'background_url', 'suppress_from_homepage'], on_drop: ->() { STDERR.puts 'Removing superflous categories columns!' } diff --git a/db/migrate/20180221215641_add_suppress_from_latest_to_categories.rb b/db/migrate/20180221215641_add_suppress_from_latest_to_categories.rb new file mode 100644 index 0000000000..3a812a3c11 --- /dev/null +++ b/db/migrate/20180221215641_add_suppress_from_latest_to_categories.rb @@ -0,0 +1,11 @@ +class AddSuppressFromLatestToCategories < ActiveRecord::Migration[5.1] + def up + add_column :categories, :suppress_from_latest, :boolean, default: false + execute <<~SQL + UPDATE categories SET suppress_from_latest = suppress_from_homepage + SQL + end + def down + raise "can not be removed" + end +end diff --git a/lib/import_export/base_exporter.rb b/lib/import_export/base_exporter.rb index dec228fbc0..58d3b77e4e 100644 --- a/lib/import_export/base_exporter.rb +++ b/lib/import_export/base_exporter.rb @@ -5,7 +5,7 @@ module ImportExport CATEGORY_ATTRS = [:id, :name, :color, :created_at, :user_id, :slug, :description, :text_color, :auto_close_hours, :parent_category_id, :auto_close_based_on_last_post, - :topic_template, :suppress_from_homepage, :all_topics_wiki, :permissions_params] + :topic_template, :suppress_from_latest, :all_topics_wiki, :permissions_params] GROUP_ATTRS = [ :id, :name, :created_at, :mentionable_level, :messageable_level, :visibility_level, :automatic_membership_email_domains, :automatic_membership_retroactive, diff --git a/script/import_scripts/discuz_x.rb b/script/import_scripts/discuz_x.rb index 8ee30b359c..8f99ae4911 100644 --- a/script/import_scripts/discuz_x.rb +++ b/script/import_scripts/discuz_x.rb @@ -273,7 +273,7 @@ class ImportScripts::DiscuzX < ImportScripts::Base description: row['description'], position: row['position'].to_i + max_position, color: color, - suppress_from_homepage: (row['status'] == (0) || row['status'] == (3)), + suppress_from_latest: (row['status'] == (0) || row['status'] == (3)), post_create_action: lambda do |category| if slug = @category_slug[row['id']] category.update(slug: slug) diff --git a/spec/controllers/list_controller_spec.rb b/spec/controllers/list_controller_spec.rb index e47ceac19d..7a04838d81 100644 --- a/spec/controllers/list_controller_spec.rb +++ b/spec/controllers/list_controller_spec.rb @@ -364,13 +364,13 @@ describe ListController do describe "categories suppression" do let(:category_one) { Fabricate(:category) } - let(:sub_category) { Fabricate(:category, parent_category: category_one, suppress_from_homepage: true) } + let(:sub_category) { Fabricate(:category, parent_category: category_one, suppress_from_latest: true) } let!(:topic_in_sub_category) { Fabricate(:topic, category: sub_category) } - let(:category_two) { Fabricate(:category, suppress_from_homepage: true) } + let(:category_two) { Fabricate(:category, suppress_from_latest: true) } let!(:topic_in_category_two) { Fabricate(:topic, category: category_two) } - it "suppresses categories from the homepage" do + it "suppresses categories from the latest list" do get SiteSetting.homepage, format: :json expect(response).to be_success diff --git a/spec/fixtures/json/import-export.json b/spec/fixtures/json/import-export.json index 1bc4da072a..02446824cb 100644 --- a/spec/fixtures/json/import-export.json +++ b/spec/fixtures/json/import-export.json @@ -4,12 +4,12 @@ {"id":42,"name":"custom_group_import","created_at":"2017-10-26T15:33:46.328Z","mentionable_level":0,"messageable_level":0,"visibility_level":0,"automatic_membership_email_domains":"","automatic_membership_retroactive":false,"primary_group":false,"title":null,"grant_trust_level":null,"incoming_email":null,"user_ids":[2]} ], "categories":[ - {"id":8,"name":"Custom Category","color":"AB9364","created_at":"2017-10-26T15:32:44.083Z","user_id":1,"slug":"custom-category","description":null,"text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":3,"auto_close_based_on_last_post":false,"topic_template":"","suppress_from_homepage":false,"all_topics_wiki":false,"permissions_params":{"custom_group":1,"everyone":2}}, - {"id":10,"name":"Site Feedback Import","color":"808281","created_at":"2017-10-26T17:12:39.995Z","user_id":-1,"slug":"site-feedback-import","description":"Discussion about this site, its organization, how it works, and how we can improve it.","text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"suppress_from_homepage":false,"all_topics_wiki":false,"permissions_params":{}}, - {"id":11,"name":"Uncategorized Import","color":"AB9364","created_at":"2017-10-26T17:12:32.359Z","user_id":-1,"slug":"uncategorized-import","description":"","text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"suppress_from_homepage":false,"all_topics_wiki":false,"permissions_params":{}}, - {"id":12,"name":"Lounge Import","color":"EEEEEE","created_at":"2017-10-26T17:12:39.490Z","user_id":-1,"slug":"lounge-import","description":"A category exclusive to members with trust level 3 and higher.","text_color":"652D90","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"suppress_from_homepage":false,"all_topics_wiki":false,"permissions_params":{"trust_level_3":1}}, - {"id":13,"name":"Staff Import","color":"283890","created_at":"2017-10-26T17:12:42.806Z","user_id":2,"slug":"staff-import","description":"Private category for staff discussions. Topics are only visible to admins and moderators.","text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"suppress_from_homepage":false,"all_topics_wiki":false,"permissions_params":{"custom_group_import":1,"staff":1}}, - {"id":15,"name":"Custom Category Import","color":"AB9364","created_at":"2017-10-26T15:32:44.083Z","user_id":2,"slug":"custom-category-import","description":null,"text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":10,"auto_close_based_on_last_post":false,"topic_template":"","suppress_from_homepage":false,"all_topics_wiki":false,"permissions_params":{"custom_group_import":1,"everyone":2}} + {"id":8,"name":"Custom Category","color":"AB9364","created_at":"2017-10-26T15:32:44.083Z","user_id":1,"slug":"custom-category","description":null,"text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":3,"auto_close_based_on_last_post":false,"topic_template":"","suppress_from_latest":false,"all_topics_wiki":false,"permissions_params":{"custom_group":1,"everyone":2}}, + {"id":10,"name":"Site Feedback Import","color":"808281","created_at":"2017-10-26T17:12:39.995Z","user_id":-1,"slug":"site-feedback-import","description":"Discussion about this site, its organization, how it works, and how we can improve it.","text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"suppress_from_latest":false,"all_topics_wiki":false,"permissions_params":{}}, + {"id":11,"name":"Uncategorized Import","color":"AB9364","created_at":"2017-10-26T17:12:32.359Z","user_id":-1,"slug":"uncategorized-import","description":"","text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"suppress_from_latest":false,"all_topics_wiki":false,"permissions_params":{}}, + {"id":12,"name":"Lounge Import","color":"EEEEEE","created_at":"2017-10-26T17:12:39.490Z","user_id":-1,"slug":"lounge-import","description":"A category exclusive to members with trust level 3 and higher.","text_color":"652D90","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"suppress_from_latest":false,"all_topics_wiki":false,"permissions_params":{"trust_level_3":1}}, + {"id":13,"name":"Staff Import","color":"283890","created_at":"2017-10-26T17:12:42.806Z","user_id":2,"slug":"staff-import","description":"Private category for staff discussions. Topics are only visible to admins and moderators.","text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"suppress_from_latest":false,"all_topics_wiki":false,"permissions_params":{"custom_group_import":1,"staff":1}}, + {"id":15,"name":"Custom Category Import","color":"AB9364","created_at":"2017-10-26T15:32:44.083Z","user_id":2,"slug":"custom-category-import","description":null,"text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":10,"auto_close_based_on_last_post":false,"topic_template":"","suppress_from_latest":false,"all_topics_wiki":false,"permissions_params":{"custom_group_import":1,"everyone":2}} ], "users":[ {"id":1,"email":"email@example.com","username":"example","name":"Example","created_at":"2017-10-07T15:01:24.597Z","trust_level":4,"active":true,"last_emailed_at":null}, diff --git a/spec/requests/list_controller_spec.rb b/spec/requests/list_controller_spec.rb index 9a0d21a5c9..abe124e980 100644 --- a/spec/requests/list_controller_spec.rb +++ b/spec/requests/list_controller_spec.rb @@ -15,6 +15,33 @@ RSpec.describe ListController do end end + describe 'suppress from latest' do + + it 'supresses categories' do + topic + + get "/latest.json" + data = JSON.parse(response.body) + expect(data["topic_list"]["topics"].length).to eq(1) + + get "/categories_and_latest.json" + data = JSON.parse(response.body) + expect(data["topic_list"]["topics"].length).to eq(1) + + topic.category.suppress_from_latest = true + topic.category.save + + get "/latest.json" + data = JSON.parse(response.body) + expect(data["topic_list"]["topics"].length).to eq(0) + + get "/categories_and_latest.json" + data = JSON.parse(response.body) + expect(data["topic_list"]["topics"].length).to eq(0) + end + + end + describe 'titles for crawler layout' do it 'has no title for the default URL' do topic From 6f5acfe783223f7b22311c1f7c9b57ab2355ba81 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Tue, 20 Feb 2018 13:29:43 +0100 Subject: [PATCH 063/299] Login with email/forget password UI refactoring * move button into login modal with social buttons * adds email link next to login field when filling it * adds proper validation messages * improves forgot password flash clearing * more tests --- .../discourse/components/d-modal-body.js.es6 | 16 ++- .../discourse/components/login-buttons.js.es6 | 8 +- .../controllers/forgot-password.js.es6 | 67 +++++----- .../discourse/controllers/login.js.es6 | 40 ++++++ .../mixins/modal-functionality.js.es6 | 4 + .../templates/components/login-buttons.hbs | 9 ++ .../templates/mobile/modal/login.hbs | 17 ++- .../templates/modal/forgot-password.hbs | 6 - .../discourse/templates/modal/login.hbs | 15 ++- .../common/components/buttons.scss | 2 +- config/locales/client.en.yml | 4 +- .../acceptance/forgot-password-test.js.es6 | 59 +++++---- .../acceptance/login-with-email-test.js.es6 | 116 ++++++++++++++++++ 13 files changed, 277 insertions(+), 86 deletions(-) create mode 100644 test/javascripts/acceptance/login-with-email-test.js.es6 diff --git a/app/assets/javascripts/discourse/components/d-modal-body.js.es6 b/app/assets/javascripts/discourse/components/d-modal-body.js.es6 index 660456db7f..8de322600d 100644 --- a/app/assets/javascripts/discourse/components/d-modal-body.js.es6 +++ b/app/assets/javascripts/discourse/components/d-modal-body.js.es6 @@ -14,11 +14,13 @@ export default Ember.Component.extend({ Ember.run.scheduleOnce('afterRender', this, this._afterFirstRender); this.appEvents.on('modal-body:flash', msg => this._flash(msg)); + this.appEvents.on('modal-body:clearFlash', () => this._clearFlash()); }, willDestroyElement() { this._super(); this.appEvents.off('modal-body:flash'); + this.appEvents.off('modal-body:clearFlash'); }, _afterFirstRender() { @@ -45,10 +47,16 @@ export default Ember.Component.extend({ ); }, + _clearFlash() { + $('#modal-alert').hide().removeClass('alert-error', 'alert-success'); + }, + _flash(msg) { - $('#modal-alert').hide() - .removeClass('alert-error', 'alert-success') - .addClass(`alert alert-${msg.messageClass || 'success'}`).html(msg.text || '') - .fadeIn(); + this._clearFlash(); + + $('#modal-alert') + .addClass(`alert alert-${msg.messageClass || 'success'}`) + .html(msg.text || '') + .fadeIn(); }, }); diff --git a/app/assets/javascripts/discourse/components/login-buttons.js.es6 b/app/assets/javascripts/discourse/components/login-buttons.js.es6 index 4c68d1740f..aa90e565c7 100644 --- a/app/assets/javascripts/discourse/components/login-buttons.js.es6 +++ b/app/assets/javascripts/discourse/components/login-buttons.js.es6 @@ -13,8 +13,12 @@ export default Ember.Component.extend({ }, actions: { - externalLogin: function(provider) { - this.sendAction('action', provider); + emailLogin() { + this.sendAction('emailLogin'); + }, + + externalLogin(provider) { + this.sendAction('externalLogin', provider); } } }); diff --git a/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 b/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 index 7e385fb302..a5dff79c90 100644 --- a/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 +++ b/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 @@ -29,45 +29,36 @@ export default Ember.Controller.extend(ModalFunctionality, { }, resetPassword() { - return this._submit('/session/forgot_password', 'forgot_password.complete'); - }, + if (this.get('submitDisabled')) return false; + this.set('disabled', true); - emailLogin() { - return this._submit('/u/email-login', 'email_login.complete'); + this.clearFlash(); + + ajax('/session/forgot_password', { + data: { login: this.get('accountEmailOrUsername').trim() }, + type: 'POST' + }).then(data => { + const accountEmailOrUsername = escapeExpression(this.get("accountEmailOrUsername")); + const isEmail = accountEmailOrUsername.match(/@/); + let key = `forgot_password.complete_${isEmail ? 'email' : 'username'}`; + if (data.user_found) { + this.set('offerHelp', I18n.t(`${key}_found`, { + email: accountEmailOrUsername, + username: accountEmailOrUsername + })); + } else { + this.flash(I18n.t(`${key}_not_found`, { + email: accountEmailOrUsername, + username: accountEmailOrUsername + }), 'error'); + } + }).catch(e => { + this.flash(extractError(e), 'error'); + }).finally(() => { + this.set('disabled', false); + }); + + return false; } }, - - _submit(route, translationKey) { - if (this.get('submitDisabled')) return false; - this.set('disabled', true); - - ajax(route, { - data: { login: this.get('accountEmailOrUsername').trim() }, - type: 'POST' - }).then(data => { - const escaped = escapeExpression(this.get('accountEmailOrUsername')); - const isEmail = this.get('accountEmailOrUsername').match(/@/); - let key = `${translationKey}_${isEmail ? 'email' : 'username'}`; - let extraClass; - - if (data.user_found === true) { - key += '_found'; - this.set('accountEmailOrUsername', ''); - this.set('offerHelp', I18n.t(key, { email: escaped, username: escaped })); - } else { - if (data.user_found === false) { - key += '_not_found'; - extraClass = 'error'; - } - - this.flash(I18n.t(key, { email: escaped, username: escaped }), extraClass); - } - }).catch(e => { - this.flash(extractError(e), 'error'); - }).finally(() => { - this.set('disabled', false); - }); - - return false; - }, }); diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6 index 31d339b7c0..7646c8ecb3 100644 --- a/app/assets/javascripts/discourse/controllers/login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/login.js.es6 @@ -4,6 +4,8 @@ import showModal from 'discourse/lib/show-modal'; import { setting } from 'discourse/lib/computed'; import { findAll } from 'discourse/models/login-method'; import { escape } from 'pretty-text/sanitizer'; +import { escapeExpression } from 'discourse/lib/utilities'; +import { extractError } from 'discourse/lib/ajax-error'; import computed from 'ember-addons/ember-computed-decorators'; // This is happening outside of the app via popup @@ -24,8 +26,10 @@ export default Ember.Controller.extend(ModalFunctionality, { authenticate: null, loggingIn: false, loggedIn: false, + processingEmailLink: false, canLoginLocal: setting('enable_local_logins'), + canLoginLocalWithEmail: setting('enable_local_logins_via_email'), loginRequired: Em.computed.alias('application.loginRequired'), resetForm: function() { @@ -59,6 +63,11 @@ export default Ember.Controller.extend(ModalFunctionality, { return this.get('loggingIn') || this.get('authenticate'); }.property('loggingIn', 'authenticate'), + @computed('canLoginLocalWithEmail', 'loginName', 'processingEmailLink') + showLoginWithEmailLink(canLoginLocalWithEmail, loginName, processingEmailLink) { + return canLoginLocalWithEmail && !Ember.isEmpty(loginName) && !processingEmailLink; + }, + actions: { login() { const self = this; @@ -198,6 +207,37 @@ export default Ember.Controller.extend(ModalFunctionality, { const forgotPasswordController = this.get('forgotPassword'); if (forgotPasswordController) { forgotPasswordController.set("accountEmailOrUsername", this.get("loginName")); } this.send("showForgotPassword"); + }, + + emailLogin() { + if (this.get('processingEmailLink')) { + return; + } + + if (Ember.isEmpty(this.get('loginName'))){ + this.flash(I18n.t('login.blank_username'), 'error'); + return; + } + + this.set('processingEmailLink', true); + + ajax('/u/email-login', { + data: { login: this.get('loginName').trim() }, + type: 'POST' + }).then(data => { + const loginName = escapeExpression(this.get('loginName')); + const isEmail = loginName.match(/@/); + let key = `email_login.complete_${isEmail ? 'email' : 'username'}`; + if (data.user_found) { + this.flash(I18n.t(`${key}_found`, { email: loginName, username: loginName })); + } else { + this.flash(I18n.t(`${key}_not_found`, { email: loginName, username: loginName }), 'error'); + } + }).catch(e => { + this.flash(extractError(e), 'error'); + }).finally(() => { + this.set('processingEmailLink', false); + }); } }, diff --git a/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 b/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 index d78478dae6..b34b906bdc 100644 --- a/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 +++ b/app/assets/javascripts/discourse/mixins/modal-functionality.js.es6 @@ -5,6 +5,10 @@ export default Ember.Mixin.create({ this.appEvents.trigger('modal-body:flash', { text, messageClass }); }, + clearFlash() { + this.appEvents.trigger('modal-body:clearFlash'); + }, + showModal(...args) { return showModal(...args); }, diff --git a/app/assets/javascripts/discourse/templates/components/login-buttons.hbs b/app/assets/javascripts/discourse/templates/components/login-buttons.hbs index 776ddff179..bdd430c4e7 100644 --- a/app/assets/javascripts/discourse/templates/components/login-buttons.hbs +++ b/app/assets/javascripts/discourse/templates/components/login-buttons.hbs @@ -1,3 +1,12 @@ {{#each buttons as |b|}} {{/each}} + +{{#if canLoginLocalWithEmail}} + {{d-button + action="emailLogin" + label="email_login.button_label" + disabled=processingEmailLink + icon="envelope-o" + class="login-with-email-button"}} +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs b/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs index 02ccc7820e..41353ac8ed 100644 --- a/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs +++ b/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs @@ -1,6 +1,11 @@ {{#login-modal screenX=lastX screenY=lastY loginName=loginName loginPassword=loginPassword loginSecondFactor=loginSecondFactor action="login"}} {{#d-modal-body title="login.title" class="login-modal"}} - {{login-buttons action="externalLogin"}} + {{login-buttons + canLoginLocalWithEmail=canLoginLocalWithEmail + processingEmailLink=processingEmailLink + emailLogin='emailLogin' + externalLogin='externalLogin'}} + {{#if canLoginLocal}}
@@ -13,6 +18,16 @@ {{text-field value=loginName type="email" placeholderKey="login.email_placeholder" id="login-account-name" autocorrect="off" autocapitalize="off"}} + {{#if showLoginWithEmailLink}} + + + + + + + {{/if}} diff --git a/app/assets/javascripts/discourse/templates/modal/forgot-password.hbs b/app/assets/javascripts/discourse/templates/modal/forgot-password.hbs index 8b42f88739..dd24392734 100644 --- a/app/assets/javascripts/discourse/templates/modal/forgot-password.hbs +++ b/app/assets/javascripts/discourse/templates/modal/forgot-password.hbs @@ -13,12 +13,6 @@ label="forgot_password.reset" disabled=submitDisabled class="btn-primary"}} - {{#if siteSettings.enable_local_logins_via_email}} - {{d-button action="emailLogin" - label="email_login.label" - disabled=submitDisabled - class="email-login"}} - {{/if}} {{else}} {{d-button class="btn-large btn-primary" label="forgot_password.button_ok" diff --git a/app/assets/javascripts/discourse/templates/modal/login.hbs b/app/assets/javascripts/discourse/templates/modal/login.hbs index 75e2a59ffe..37ce830c25 100644 --- a/app/assets/javascripts/discourse/templates/modal/login.hbs +++ b/app/assets/javascripts/discourse/templates/modal/login.hbs @@ -1,6 +1,11 @@ {{#login-modal screenX=lastX screenY=lastY loginName=loginName loginPassword=loginPassword loginSecondFactor=loginSecondFactor action="login"}} {{#d-modal-body title="login.title" class="login-modal"}} - {{login-buttons action="externalLogin"}} + {{login-buttons + canLoginLocalWithEmail=canLoginLocalWithEmail + processingEmailLink=processingEmailLink + emailLogin='emailLogin' + externalLogin='externalLogin'}} + {{#if canLoginLocal}}
@@ -8,7 +13,13 @@ {{text-field value=loginName placeholderKey="login.email_placeholder" id="login-account-name" autocorrect="off" autocapitalize="off" autofocus="autofocus"}} - + + {{#if showLoginWithEmailLink}} + + {{/if}} + diff --git a/app/assets/stylesheets/common/components/buttons.scss b/app/assets/stylesheets/common/components/buttons.scss index 2dd074265b..f0e2255a0d 100644 --- a/app/assets/stylesheets/common/components/buttons.scss +++ b/app/assets/stylesheets/common/components/buttons.scss @@ -128,7 +128,7 @@ &:before { margin-right: 9px; font-family: FontAwesome; - font-size: 1.214em; + font-size: $font-0; } &.google, &.google_oauth2 { background: $google; diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 284f70a6fd..dfb6f115a8 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1097,7 +1097,8 @@ en: button_help: "Help" email_login: - label: "Login With Email" + link_label: "Email me a magic link" + button_label: "with email" complete_username: "If an account matches the username %{username}, you should receive an email with a magic login link shortly." complete_email: "If an account matches %{email}, you should receive an email with a magic login link shortly." complete_username_found: "We found an account that matches the username %{username}, you should receive an email with a magic login link shortly." @@ -1116,6 +1117,7 @@ en: caps_lock_warning: "Caps Lock is on" error: "Unknown error" rate_limit: "Please wait before trying to log in again." + blank_username: "Please enter your email or username." blank_username_or_password: "Please enter your email or username, and password." reset_password: 'Reset Password' logging_in: "Signing In..." diff --git a/test/javascripts/acceptance/forgot-password-test.js.es6 b/test/javascripts/acceptance/forgot-password-test.js.es6 index d3a868cb1f..5e21fc8b60 100644 --- a/test/javascripts/acceptance/forgot-password-test.js.es6 +++ b/test/javascripts/acceptance/forgot-password-test.js.es6 @@ -3,9 +3,6 @@ import { acceptance } from "helpers/qunit-helpers"; let userFound = false; acceptance("Forgot password", { - settings: { - enable_local_logins_via_email: true - }, beforeEach() { const response = object => { return [ @@ -15,41 +12,44 @@ acceptance("Forgot password", { ]; }; - server.post('/u/email-login', () => { // eslint-disable-line no-undef + server.post('/session/forgot_password', () => { // eslint-disable-line no-undef return response({ "user_found": userFound }); }); } }); -QUnit.test("logging in via email", assert => { +QUnit.test("requesting password reset", assert => { visit("/"); click("header .login-button"); - - andThen(() => { - assert.ok(exists('.login-modal'), "it shows the login modal"); - }); - click('#forgot-password-link'); - fillIn("#username-or-email", 'someuser'); - click('.email-login'); - andThen(() => { assert.equal( - find(".alert-error").html(), - I18n.t('email_login.complete_username_not_found', { username: 'someuser' }), - 'it should display the right error message' + find('button[title="Reset Password"]').attr("disabled"), + "disabled", + 'it should disable the button until the field is filled' + ); + }); + + fillIn("#username-or-email", 'someuser'); + click('button[title="Reset Password"]'); + + andThen(() => { + assert.equal( + find(".alert-error").html().trim(), + I18n.t('forgot_password.complete_username_not_found', { username: 'someuser' }), + 'it should display an error for an invalid username' ); }); fillIn("#username-or-email", 'someuser@gmail.com'); - click('.email-login'); + click('button[title="Reset Password"]'); andThen(() => { assert.equal( - find(".alert-error").html(), - I18n.t('email_login.complete_email_not_found', { email: 'someuser@gmail.com' }), - 'it should display the right error message' + find(".alert-error").html().trim(), + I18n.t('forgot_password.complete_email_not_found', { email: 'someuser@gmail.com' }), + 'it should display an error for an invalid email' ); }); @@ -59,32 +59,29 @@ QUnit.test("logging in via email", assert => { userFound = true; }); - click('.email-login'); + click('button[title="Reset Password"]'); andThen(() => { + assert.notOk(exists(find(".alert-error")), 'it should remove the flash error when succeeding'); + assert.equal( find(".modal-body").html().trim(), - I18n.t('email_login.complete_username_found', { username: 'someuser' }), - 'it should display the right message' + I18n.t('forgot_password.complete_username_found', { username: 'someuser' }), + 'it should display a success message for a valid username' ); }); visit("/"); click("header .login-button"); - - andThen(() => { - assert.ok(exists('.login-modal'), "it shows the login modal"); - }); - click('#forgot-password-link'); fillIn("#username-or-email", 'someuser@gmail.com'); - click('.email-login'); + click('button[title="Reset Password"]'); andThen(() => { assert.equal( find(".modal-body").html().trim(), - I18n.t('email_login.complete_email_found', { email: 'someuser@gmail.com' }), - 'it should display the right message' + I18n.t('forgot_password.complete_email_found', { email: 'someuser@gmail.com' }), + 'it should display a success message for a valid email' ); }); }); diff --git a/test/javascripts/acceptance/login-with-email-test.js.es6 b/test/javascripts/acceptance/login-with-email-test.js.es6 new file mode 100644 index 0000000000..7c575ce9a3 --- /dev/null +++ b/test/javascripts/acceptance/login-with-email-test.js.es6 @@ -0,0 +1,116 @@ +import { acceptance } from "helpers/qunit-helpers"; + +let userFound = false; + +acceptance("Login with email", { + settings: { + enable_local_logins_via_email: true, + enable_facebook_logins: true + }, + beforeEach() { + const response = object => { + return [ + 200, + { "Content-Type": "application/json" }, + object + ]; + }; + + server.post('/u/email-login', () => { // eslint-disable-line no-undef + return response({ "user_found": userFound }); + }); + } +}); + +QUnit.test("logging in via email (link)", assert => { + visit("/"); + click("header .login-button"); + + andThen(() => { + assert.notOk(exists(".login-with-email-link"), 'it displays the link only when field is filled'); + }); + + fillIn("#login-account-name", "someuser"); + click(".login-with-email-link"); + + andThen(() => { + assert.equal( + find(".alert-error").html(), + I18n.t('email_login.complete_username_not_found', { username: 'someuser' }), + 'it should display an error for an invalid username' + ); + }); + + fillIn("#login-account-name", 'someuser@gmail.com'); + click('.login-with-email-link'); + + andThen(() => { + assert.equal( + find(".alert-error").html(), + I18n.t('email_login.complete_email_not_found', { email: 'someuser@gmail.com' }), + 'it should display an error for an invalid email' + ); + }); + + fillIn("#login-account-name", 'someuser'); + + andThen(() => { + userFound = true; + }); + + click('.login-with-email-link'); + + andThen(() => { + assert.equal( + find(".alert-success").html().trim(), + I18n.t('email_login.complete_username_found', { username: 'someuser' }), + 'it should display a success message for a valid username' + ); + }); + + visit("/"); + click("header .login-button"); + fillIn("#login-account-name", 'someuser@gmail.com'); + click('.login-with-email-link'); + + andThen(() => { + assert.equal( + find(".alert-success").html().trim(), + I18n.t('email_login.complete_email_found', { email: 'someuser@gmail.com' }), + 'it should display a success message for a valid email' + ); + }); + + andThen(() => { + userFound = false; + }); +}); + +QUnit.test("logging in via email (button)", assert => { + visit("/"); + click("header .login-button"); + click('.login-with-email-button'); + + andThen(() => { + assert.equal( + find(".alert-error").html(), + I18n.t('login.blank_username'), + 'it should display an error for blank username' + ); + }); + + andThen(() => { + userFound = true; + }); + + fillIn("#login-account-name", 'someuser'); + click('.login-with-email-button'); + + andThen(() => { + assert.equal( + find(".alert-success").html().trim(), + I18n.t('email_login.complete_username_found', { username: 'someuser' }), + 'it should display a success message for a valid username' + ); + }); +}); From edf326a9a5fddb884af5782b4a896da33793bcfa Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 22 Feb 2018 08:06:37 +0800 Subject: [PATCH 064/299] Fix incorrect translation. --- app/views/users/admin_login.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/users/admin_login.html.erb b/app/views/users/admin_login.html.erb index b6fc06f972..77be764feb 100644 --- a/app/views/users/admin_login.html.erb +++ b/app/views/users/admin_login.html.erb @@ -9,7 +9,7 @@ <%=form_tag({}, method: :put) do %> <%= label_tag(:second_factor_token, t('login.second_factor_description')) %> <%= text_field_tag(:second_factor_token, nil, autofocus: true) %>

- <%= submit_tag t('login.submit')%> + <%= submit_tag t('submit')%> <% end %> <% end %> <% else %> From 1b04d881c5a006dacab6ed4bf144bbbf6be5172a Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 22 Feb 2018 08:59:11 +0800 Subject: [PATCH 065/299] UX: Display lock icon in admin user lists when user has 2FA enabled. --- .../javascripts/admin/templates/users-list-show.hbs | 4 ++++ app/serializers/admin_user_list_serializer.rb | 11 ++++++++++- config/locales/client.en.yml | 1 + lib/admin_user_index_query.rb | 2 +- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/admin/templates/users-list-show.hbs b/app/assets/javascripts/admin/templates/users-list-show.hbs index 0ccaee0251..7401077ee6 100644 --- a/app/assets/javascripts/admin/templates/users-list-show.hbs +++ b/app/assets/javascripts/admin/templates/users-list-show.hbs @@ -98,6 +98,10 @@ {{#if user.moderator}} {{d-icon "shield" title="admin.moderator" }} {{/if}} + + {{#if user.second_factor_enabled}} + {{d-icon "lock" title="admin.user.second_factor_enabled" }} + {{/if}} {{/each}} diff --git a/app/serializers/admin_user_list_serializer.rb b/app/serializers/admin_user_list_serializer.rb index e4a95b3efb..32021dad74 100644 --- a/app/serializers/admin_user_list_serializer.rb +++ b/app/serializers/admin_user_list_serializer.rb @@ -25,7 +25,8 @@ class AdminUserListSerializer < BasicUserSerializer :silenced, :silenced_till, :time_read, - :staged + :staged, + :second_factor_enabled [:days_visited, :posts_read_count, :topics_entered, :post_count].each do |sym| attributes sym @@ -115,4 +116,12 @@ class AdminUserListSerializer < BasicUserSerializer SiteSetting.must_approve_users end + def include_second_factor_enabled? + object.totp_enabled? + end + + def second_factor_enabled + true + end + end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index dfb6f115a8..590c6fa188 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3471,6 +3471,7 @@ en: private_topics_count: Private Topics posts_read_count: Posts Read post_count: Posts Created + second_factor_enabled: Two Factor Authentication Enabled topics_entered: Topics Viewed flags_given_count: Flags Given flags_received_count: Flags Received diff --git a/lib/admin_user_index_query.rb b/lib/admin_user_index_query.rb index b9d74e8a52..fd3da2a23a 100644 --- a/lib/admin_user_index_query.rb +++ b/lib/admin_user_index_query.rb @@ -63,7 +63,7 @@ class AdminUserIndexQuery if params[:stats].present? && params[:stats] == false klass.order(order.reject(&:blank?).join(",")) else - klass.includes(:user_stat).order(order.reject(&:blank?).join(",")) + klass.includes(:user_stat, :user_second_factor).order(order.reject(&:blank?).join(",")) end end From 412b298f555a83002e727d5d998331ea6370d6cd Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 22 Feb 2018 09:07:43 +0800 Subject: [PATCH 066/299] UX: Smaller input field for preferences 2FA form. --- .../discourse/templates/preferences-second-factor.hbs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs index 2865656f39..027f8ae462 100644 --- a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs +++ b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs @@ -70,7 +70,7 @@
{{text-field value=second_factor_token id="second-factor-token" - classNames="input-xxlarge" + classNames="input-large" autofocus="autofocus"}}
@@ -91,7 +91,7 @@ {{text-field value=password id="password" type="password" - classNames="input-xxlarge" + classNames="input-large" autofocus="autofocus"}}
From 84867c1c0743cf25e9c1d609b4f3122994699009 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Thu, 22 Feb 2018 06:48:34 +0530 Subject: [PATCH 067/299] Rename site setting to allow_staff_to_tag_pms from allow_staff_to_tag_in_pm --- config/locales/server.en.yml | 2 +- config/site_settings.yml | 2 +- lib/guardian/tag_guardian.rb | 2 +- spec/serializers/topic_view_serializer_spec.rb | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 1fd7967002..747cbf8b1e 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1616,7 +1616,7 @@ en: tags_listed_by_group: "List tags by tag group on the Tags page (/tags)." tag_style: "Visual style for tag badges." staff_tags: "A list of tags that can only be applied by staff members" - allow_staff_to_tag_in_pm: "Allow staff members to tag any personal message" + allow_staff_to_tag_pms: "Allow staff members to tag any personal message" min_trust_level_to_tag_topics: "Minimum trust level required to tag topics" suppress_overlapping_tags_in_list: "If tags match exact words in topic titles, don't show the tag" remove_muted_tags_from_latest: "Don't show topics tagged with muted tags in the latest topic list." diff --git a/config/site_settings.yml b/config/site_settings.yml index abb5d65dd5..c692b9887d 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1571,7 +1571,7 @@ tags: type: list client: true default: '' - allow_staff_to_tag_in_pm: + allow_staff_to_tag_pms: default: false suppress_overlapping_tags_in_list: default: false diff --git a/lib/guardian/tag_guardian.rb b/lib/guardian/tag_guardian.rb index 8f60c8223c..84856d4d15 100644 --- a/lib/guardian/tag_guardian.rb +++ b/lib/guardian/tag_guardian.rb @@ -9,7 +9,7 @@ module TagGuardian end def can_tag_pms? - is_staff? && SiteSetting.tagging_enabled && SiteSetting.allow_staff_to_tag_in_pm + is_staff? && SiteSetting.tagging_enabled && SiteSetting.allow_staff_to_tag_pms end def can_admin_tags? diff --git a/spec/serializers/topic_view_serializer_spec.rb b/spec/serializers/topic_view_serializer_spec.rb index 098f7f89d7..772fc15eef 100644 --- a/spec/serializers/topic_view_serializer_spec.rb +++ b/spec/serializers/topic_view_serializer_spec.rb @@ -80,7 +80,7 @@ describe TopicViewSerializer do describe 'when tags added to private message topics' do before do SiteSetting.tagging_enabled = true - SiteSetting.allow_staff_to_tag_in_pm = true + SiteSetting.allow_staff_to_tag_pms = true end it "should not include the tag for normal users" do @@ -97,7 +97,7 @@ describe TopicViewSerializer do end it "should not include the tag if pm tags disabled" do - SiteSetting.allow_staff_to_tag_in_pm = false + SiteSetting.allow_staff_to_tag_pms = false json = serialize_topic(pm, moderator) expect(json[:tags]).to eq(nil) From 964624f3ab58181c775cd9cd1dc9f2c099162d09 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 22 Feb 2018 09:45:57 +0800 Subject: [PATCH 068/299] FIX: No error displayed when 2FA token is invalid on admin login page. --- app/controllers/users_controller.rb | 24 +++++++++++++++++++---- app/views/users/admin_login.html.erb | 12 ++++++------ spec/controllers/users_controller_spec.rb | 23 +++++++++++++++++----- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 6fdbb5ad92..0633c93ae8 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -593,8 +593,27 @@ class UsersController < ApplicationController email_token_user = EmailToken.confirmable(token)&.user totp_enabled = email_token_user.totp_enabled? + second_factor_token = params[:second_factor_token] + confirm_email = false - if !totp_enabled || email_token_user.authenticate_totp(params[:second_factor_token]) + confirm_email = + if totp_enabled + @second_factor_required = true + @message = I18n.t("login.second_factor_title") + + if second_factor_token.present? + if email_token_user.authenticate_totp(second_factor_token) + true + else + @error = I18n.t("login.invalid_second_factor_code") + false + end + end + else + true + end + + if confirm_email @user = EmailToken.confirm(token) if @user && @user.admin? @@ -603,9 +622,6 @@ class UsersController < ApplicationController else @message = I18n.t("admin_login.errors.unknown_email_address") end - else - @second_factor_required = true - @message = I18n.t("login.second_factor_title") end else @message = I18n.t("admin_login.errors.invalid_token") diff --git a/app/views/users/admin_login.html.erb b/app/views/users/admin_login.html.erb index 77be764feb..c7752523e9 100644 --- a/app/views/users/admin_login.html.erb +++ b/app/views/users/admin_login.html.erb @@ -5,12 +5,12 @@ <% if @message %> <%= @message %> - <% if @second_factor_required %> - <%=form_tag({}, method: :put) do %> - <%= label_tag(:second_factor_token, t('login.second_factor_description')) %> - <%= text_field_tag(:second_factor_token, nil, autofocus: true) %>

- <%= submit_tag t('submit')%> - <% end %> + <% if @error %>

<%= @error %>

<% end %> + + <%=form_tag({}, method: :put) do %> + <%= label_tag(:second_factor_token, t('login.second_factor_description')) %> + <%= text_field_tag(:second_factor_token, nil, autofocus: true) %>

+ <%= submit_tag t('submit')%> <% end %> <% else %> <%=form_tag({}, method: :put) do %> diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 19a324d7f4..35f172aa04 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -554,22 +554,35 @@ describe UsersController do describe 'when 2 factor authentication is enabled' do let(:second_factor) { Fabricate(:user_second_factor, user: admin) } + let(:email_token) { Fabricate(:email_token, user: admin) } render_views it 'does not log in when token required' do second_factor - token = admin.email_tokens.create(email: admin.email).token - get :admin_login, params: { token: token } + get :admin_login, params: { token: email_token.token } expect(response).not_to redirect_to('/') expect(session[:current_user_id]).not_to eq(admin.id) expect(response.body).to include(I18n.t('login.second_factor_description')); end - it 'logs in when a valid 2-factor token is given' do - token = admin.email_tokens.create(email: admin.email).token + describe 'invalid 2 factor token' do + it 'should display the right error' do + second_factor + put :admin_login, params: { + token: email_token.token, + second_factor_token: '13213' + } + + expect(response.status).to eq(200) + expect(response.body).to include(I18n.t('login.second_factor_description')); + expect(response.body).to include(I18n.t('login.invalid_second_factor_code')); + end + end + + it 'logs in when a valid 2-factor token is given' do put :admin_login, params: { - token: token, + token: email_token.token, second_factor_token: ROTP::TOTP.new(second_factor.data).now } From f4418ae88482d01e0e2fe38a5963f5a4c21f8048 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 22 Feb 2018 12:52:30 +1100 Subject: [PATCH 069/299] PERF: fast docking of timeline so it does not overlap In the past we debounced all dock check this causes situations where sometimes timeline would not dock in time especially on slow computers This works around it by performing the dock by hand. Also there was missing integer casting causing over aggressive re-rendering --- .../discourse/components/topic-timeline.js.es6 | 16 ++++++++++++++-- .../javascripts/discourse/mixins/docking.js.es6 | 6 ++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/components/topic-timeline.js.es6 b/app/assets/javascripts/discourse/components/topic-timeline.js.es6 index c693fd9c94..b89f804194 100644 --- a/app/assets/javascripts/discourse/components/topic-timeline.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-timeline.js.es6 @@ -47,6 +47,17 @@ export default MountWidget.extend(Docking, { this.queueRerender(); }, + fastDockCheck(){ + // we need to dock super fast here, avoid any slow methods + // this is not debounced + const offset = window.pageYOffset; + + if (offset && this.fastDockAt && offset > this.fastDockAt) { + this.fastDockAt = null; + $('.timeline-container').addClass('timeline-docked timeline-docked-bottom'); + } + }, + dockCheck(info) { const mainOffset = $('#main').offset(); const offsetTop = mainOffset ? mainOffset.top : 0; @@ -62,13 +73,14 @@ export default MountWidget.extend(Docking, { this.dockBottom = false; if (posTop < topicTop) { - this.dockAt = topicTop; + this.dockAt = parseInt(topicTop, 10); } else if (pos > topicBottom + footerHeight) { - this.dockAt = (topicBottom - timelineHeight) + footerHeight; + this.dockAt = parseInt((topicBottom - timelineHeight) + footerHeight, 10); this.dockBottom = true; if (this.dockAt < 0) { this.dockAt = 0; } } else { this.dockAt = null; + this.fastDockAt = parseInt(topicBottom - timelineHeight + footerHeight - offsetTop, 10); } if (this.dockAt !== prev) { diff --git a/app/assets/javascripts/discourse/mixins/docking.js.es6 b/app/assets/javascripts/discourse/mixins/docking.js.es6 index 4b2505b151..2a0fc36fa0 100644 --- a/app/assets/javascripts/discourse/mixins/docking.js.es6 +++ b/app/assets/javascripts/discourse/mixins/docking.js.es6 @@ -12,6 +12,12 @@ export default Ember.Mixin.create({ init() { this._super(); this.queueDockCheck = () => { + + // we want to do a very fast non-debounced check first + if (this.fastDockCheck) { + this.fastDockCheck(); + } + Ember.run.debounce(this, this.safeDockCheck, 5); }; }, From bbb30bedf39ac6a14e1cc606c3422fd32c83998f Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 22 Feb 2018 11:26:13 +0800 Subject: [PATCH 070/299] Improve output of SSO verbose logging. --- app/models/discourse_single_sign_on.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/discourse_single_sign_on.rb b/app/models/discourse_single_sign_on.rb index 3b976a57b3..84a7c30ad6 100644 --- a/app/models/discourse_single_sign_on.rb +++ b/app/models/discourse_single_sign_on.rb @@ -144,7 +144,7 @@ class DiscourseSingleSignOn < SingleSignOn user = User.create!(user_params) if SiteSetting.verbose_sso_logging - Rails.logger.warn("Verbose SSO log: New User (user_id: #{user.id}) Created with #{user_params}") + Rails.logger.warn("Verbose SSO log: New User (user_id: #{user.id}) Created with #{user_params} Email: #{user.primary_email.attributes}") end end From 3df0626aa5562b7acda52dc2947e3a3790401083 Mon Sep 17 00:00:00 2001 From: Kane York Date: Mon, 15 Jan 2018 18:19:36 -0800 Subject: [PATCH 071/299] UX: Apply hover styling to post actions on focus --- app/assets/stylesheets/desktop/topic-post.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index d9e5627bb1..28195f0607 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -126,7 +126,7 @@ nav.post-controls { border: none; margin-left: 3px; - &.d-hover { + &.d-hover, &:focus { background: $primary-low; color: $primary; } @@ -142,12 +142,12 @@ nav.post-controls { position: relative; } - &.delete.d-hover { + &.delete.d-hover, &.delete:focus { background: $danger; color: $secondary; } - &.like.d-hover { + &.like.d-hover, &.like:focus { color: $love; background: $love-low; } From 3212cdda78288099d7ca7d84882f6bdf1868643f Mon Sep 17 00:00:00 2001 From: Kane York Date: Mon, 15 Jan 2018 18:20:33 -0800 Subject: [PATCH 072/299] UX: Use focus as the first selector for J/K navigation --- .../discourse/components/topic-list-item.js.es6 | 3 ++- .../discourse/lib/keyboard-shortcuts.js.es6 | 15 +++++++++++---- .../javascripts/discourse/widgets/post.js.es6 | 4 ++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/discourse/components/topic-list-item.js.es6 b/app/assets/javascripts/discourse/components/topic-list-item.js.es6 index f9b3cd38b7..f9ebaf7dd5 100644 --- a/app/assets/javascripts/discourse/components/topic-list-item.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-list-item.js.es6 @@ -30,8 +30,9 @@ export default Ember.Component.extend(bufferedRender({ rerenderTriggers: ['bulkSelectEnabled', 'topic.pinned'], tagName: 'tr', classNameBindings: [':topic-list-item', 'unboundClassNames', 'topic.visited'], - attributeBindings: ['data-topic-id'], + attributeBindings: ['data-topic-id', 'tabindex'], 'data-topic-id': Em.computed.alias('topic.id'), + 'tabindex': 0, actions: { toggleBookmark() { diff --git a/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 b/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 index e65240981e..3274b9bfa5 100644 --- a/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 +++ b/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 @@ -315,9 +315,15 @@ export default { return; } - const $selected = ($articles.filter('.selected').length !== 0) - ? $articles.filter('.selected') - : $articles.filter('[data-islastviewedtopic=true]'); + let $selected = $articles.filter(function(_, el) { + return el.contains(document.activeElement); // :focus + }); + if ($selected.length === 0) { + $selected = $articles.filter('.selected'); + } + if ($selected.length === 0) { + $selected = $articles.filter('[data-islastviewedtopic=true]'); + } let index = $articles.index($selected); if ($selected.length !== 0) { //boundries check @@ -354,10 +360,11 @@ export default { $article.addClass('selected'); if ($article.is('.topic-post')) { - $('a.tabLoc', $article).focus(); + $('article', $article).focus(); this._scrollToPost($article); } else { + $article.focus(); this._scrollList($article, direction); } } diff --git a/app/assets/javascripts/discourse/widgets/post.js.es6 b/app/assets/javascripts/discourse/widgets/post.js.es6 index 887493fd45..a31bd578a7 100644 --- a/app/assets/javascripts/discourse/widgets/post.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post.js.es6 @@ -394,11 +394,11 @@ createWidget('post-article', { }, buildAttributes(attrs) { - return { 'data-post-id': attrs.id, 'data-user-id': attrs.user_id }; + return { 'data-post-id': attrs.id, 'data-user-id': attrs.user_id, 'tabindex': '0' }; }, html(attrs, state) { - const rows = [h('a.tabLoc', { attributes: { href: ''} })]; + const rows = []; if (state.repliesAbove.length) { const replies = state.repliesAbove.map(p => { return this.attach('embedded-post', p, { model: this.store.createRecord('post', p), state: { above: true } }); From 59b7760e2ee1388d9bbe0db6f61137fdf3de8637 Mon Sep 17 00:00:00 2001 From: Kane York Date: Mon, 15 Jan 2018 18:22:08 -0800 Subject: [PATCH 073/299] UX: Make the .selected class follow focus --- .../discourse/lib/keyboard-shortcuts.js.es6 | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 b/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 index 3274b9bfa5..01bd8356be 100644 --- a/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 +++ b/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 @@ -89,6 +89,8 @@ export default { this._bindToClick(binding.click, key); } }); + + this._bindFocus(); }, toggleBookmark() { @@ -308,6 +310,19 @@ export default { } }, + _bindFocus() { + const addSelected = function(e, addOrRemove) { + const row = '.topic-post, .topic-list-item, .topic-list tbody tr'; + const $wrapper = $(e.target).closest(row); + const $srcWrapper = $(e.relatedTarget).closest(row); + if (!$wrapper.is($srcWrapper)) $wrapper.toggleClass('selected', addOrRemove); + }; + + const $document = $(document); + $document.on('focusin.topic-post', e => addSelected(e, true)); + $document.on('focusout.topic-post', e => addSelected(e, false)); + }, + _moveSelection(direction) { const $articles = this._findArticles(); From bfc13018237c6e77ccec403a445d605d9478080f Mon Sep 17 00:00:00 2001 From: Kane York Date: Mon, 15 Jan 2018 18:21:09 -0800 Subject: [PATCH 074/299] UX: Remove default focus styling from posts & topic list items The styling is superseded by the .selected management --- .../common/components/keyboard_shortcuts.scss | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/assets/stylesheets/common/components/keyboard_shortcuts.scss b/app/assets/stylesheets/common/components/keyboard_shortcuts.scss index 643c314463..77016571a7 100644 --- a/app/assets/stylesheets/common/components/keyboard_shortcuts.scss +++ b/app/assets/stylesheets/common/components/keyboard_shortcuts.scss @@ -6,10 +6,20 @@ box-shadow: -3px 0 0 $danger; } +.mobile-view { + .topic-list tr.selected td:first-child, .topic-list-item.selected td:first-child, .topic-post.selected { + box-shadow: none; + } +} + .topic-list-item.selected { background-color: inherit; } +.topic-post article:focus, .topic-list tr:focus, .topic-list-item:focus { + outline: 0; +} + .keyboard-shortcuts-modal .modal-body { max-height: 560px; } From f74d6bb605f0395f4cba5e69d3c32206ca7c39a8 Mon Sep 17 00:00:00 2001 From: Geoffrey Challen Date: Thu, 11 Jan 2018 14:14:31 -0600 Subject: [PATCH 075/299] Add prompt and HD settings to the Google OAuth2 plugin. --- config/site_settings.yml | 8 ++++++++ lib/auth/google_oauth2_authenticator.rb | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/config/site_settings.yml b/config/site_settings.yml index 744ae92ba5..d57e111be9 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -255,6 +255,14 @@ login: default: false google_oauth2_client_id: '' google_oauth2_client_secret: '' + google_oauth2_prompt: + default: 'none' + type: enum + choices: + - 'none' + - 'consent' + - 'select_account' + google_oauth2_hd: '' enable_yahoo_logins: client: true default: false diff --git a/lib/auth/google_oauth2_authenticator.rb b/lib/auth/google_oauth2_authenticator.rb index dcee38d217..67200adcab 100644 --- a/lib/auth/google_oauth2_authenticator.rb +++ b/lib/auth/google_oauth2_authenticator.rb @@ -59,7 +59,9 @@ class Auth::GoogleOAuth2Authenticator < Auth::Authenticator strategy.options[:client_id] = SiteSetting.google_oauth2_client_id strategy.options[:client_secret] = SiteSetting.google_oauth2_client_secret }, - skip_jwt: true + skip_jwt: true, + prompt: SiteSetting.google_oauth2_prompt, + hd: SiteSetting.google_oauth2_hd end protected From d170c8fccca1b3e7fd4c1642527598d69f768647 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Thu, 22 Feb 2018 10:30:39 +0530 Subject: [PATCH 076/299] Revert 'Accessibility: focus management in topics' reverts: - 3df0626aa5562b7acda52dc2947e3a3790401083 - 3212cdda78288099d7ca7d84882f6bdf1868643f - 59b7760e2ee1388d9bbe0db6f61137fdf3de8637 - bfc13018237c6e77ccec403a445d605d9478080f --- .../components/topic-list-item.js.es6 | 3 +- .../discourse/lib/keyboard-shortcuts.js.es6 | 30 +++---------------- .../javascripts/discourse/widgets/post.js.es6 | 4 +-- .../common/components/keyboard_shortcuts.scss | 10 ------- .../stylesheets/desktop/topic-post.scss | 6 ++-- 5 files changed, 10 insertions(+), 43 deletions(-) diff --git a/app/assets/javascripts/discourse/components/topic-list-item.js.es6 b/app/assets/javascripts/discourse/components/topic-list-item.js.es6 index f9ebaf7dd5..f9b3cd38b7 100644 --- a/app/assets/javascripts/discourse/components/topic-list-item.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-list-item.js.es6 @@ -30,9 +30,8 @@ export default Ember.Component.extend(bufferedRender({ rerenderTriggers: ['bulkSelectEnabled', 'topic.pinned'], tagName: 'tr', classNameBindings: [':topic-list-item', 'unboundClassNames', 'topic.visited'], - attributeBindings: ['data-topic-id', 'tabindex'], + attributeBindings: ['data-topic-id'], 'data-topic-id': Em.computed.alias('topic.id'), - 'tabindex': 0, actions: { toggleBookmark() { diff --git a/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 b/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 index 01bd8356be..e65240981e 100644 --- a/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 +++ b/app/assets/javascripts/discourse/lib/keyboard-shortcuts.js.es6 @@ -89,8 +89,6 @@ export default { this._bindToClick(binding.click, key); } }); - - this._bindFocus(); }, toggleBookmark() { @@ -310,19 +308,6 @@ export default { } }, - _bindFocus() { - const addSelected = function(e, addOrRemove) { - const row = '.topic-post, .topic-list-item, .topic-list tbody tr'; - const $wrapper = $(e.target).closest(row); - const $srcWrapper = $(e.relatedTarget).closest(row); - if (!$wrapper.is($srcWrapper)) $wrapper.toggleClass('selected', addOrRemove); - }; - - const $document = $(document); - $document.on('focusin.topic-post', e => addSelected(e, true)); - $document.on('focusout.topic-post', e => addSelected(e, false)); - }, - _moveSelection(direction) { const $articles = this._findArticles(); @@ -330,15 +315,9 @@ export default { return; } - let $selected = $articles.filter(function(_, el) { - return el.contains(document.activeElement); // :focus - }); - if ($selected.length === 0) { - $selected = $articles.filter('.selected'); - } - if ($selected.length === 0) { - $selected = $articles.filter('[data-islastviewedtopic=true]'); - } + const $selected = ($articles.filter('.selected').length !== 0) + ? $articles.filter('.selected') + : $articles.filter('[data-islastviewedtopic=true]'); let index = $articles.index($selected); if ($selected.length !== 0) { //boundries check @@ -375,11 +354,10 @@ export default { $article.addClass('selected'); if ($article.is('.topic-post')) { - $('article', $article).focus(); + $('a.tabLoc', $article).focus(); this._scrollToPost($article); } else { - $article.focus(); this._scrollList($article, direction); } } diff --git a/app/assets/javascripts/discourse/widgets/post.js.es6 b/app/assets/javascripts/discourse/widgets/post.js.es6 index a31bd578a7..887493fd45 100644 --- a/app/assets/javascripts/discourse/widgets/post.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post.js.es6 @@ -394,11 +394,11 @@ createWidget('post-article', { }, buildAttributes(attrs) { - return { 'data-post-id': attrs.id, 'data-user-id': attrs.user_id, 'tabindex': '0' }; + return { 'data-post-id': attrs.id, 'data-user-id': attrs.user_id }; }, html(attrs, state) { - const rows = []; + const rows = [h('a.tabLoc', { attributes: { href: ''} })]; if (state.repliesAbove.length) { const replies = state.repliesAbove.map(p => { return this.attach('embedded-post', p, { model: this.store.createRecord('post', p), state: { above: true } }); diff --git a/app/assets/stylesheets/common/components/keyboard_shortcuts.scss b/app/assets/stylesheets/common/components/keyboard_shortcuts.scss index 77016571a7..643c314463 100644 --- a/app/assets/stylesheets/common/components/keyboard_shortcuts.scss +++ b/app/assets/stylesheets/common/components/keyboard_shortcuts.scss @@ -6,20 +6,10 @@ box-shadow: -3px 0 0 $danger; } -.mobile-view { - .topic-list tr.selected td:first-child, .topic-list-item.selected td:first-child, .topic-post.selected { - box-shadow: none; - } -} - .topic-list-item.selected { background-color: inherit; } -.topic-post article:focus, .topic-list tr:focus, .topic-list-item:focus { - outline: 0; -} - .keyboard-shortcuts-modal .modal-body { max-height: 560px; } diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index 28195f0607..d9e5627bb1 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -126,7 +126,7 @@ nav.post-controls { border: none; margin-left: 3px; - &.d-hover, &:focus { + &.d-hover { background: $primary-low; color: $primary; } @@ -142,12 +142,12 @@ nav.post-controls { position: relative; } - &.delete.d-hover, &.delete:focus { + &.delete.d-hover { background: $danger; color: $secondary; } - &.like.d-hover, &.like:focus { + &.like.d-hover { color: $love; background: $love-low; } From ae2d7ba8574a789a5ca2de37f1a6f260cd24b687 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 22 Feb 2018 13:42:04 +0800 Subject: [PATCH 077/299] Partially revert https://github.com/discourse/discourse/commit/d170c8fccca1b3e7fd4c1642527598d69f768647 to bring back https://github.com/discourse/discourse/commit/3df0626aa5562b7acda52dc2947e3a3790401083. --- app/assets/stylesheets/desktop/topic-post.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index d9e5627bb1..28195f0607 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -126,7 +126,7 @@ nav.post-controls { border: none; margin-left: 3px; - &.d-hover { + &.d-hover, &:focus { background: $primary-low; color: $primary; } @@ -142,12 +142,12 @@ nav.post-controls { position: relative; } - &.delete.d-hover { + &.delete.d-hover, &.delete:focus { background: $danger; color: $secondary; } - &.like.d-hover { + &.like.d-hover, &.like:focus { color: $love; background: $love-low; } From ef1b82a2261a1de59580edc5d2d3e326b090b513 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 22 Feb 2018 13:52:22 +0800 Subject: [PATCH 078/299] Add missing site setting description. --- config/locales/server.en.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 47e0ac97b0..80c7039ce6 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1165,6 +1165,8 @@ en: enable_google_oauth2_logins: "Enable Google Oauth2 authentication. This is the method of authentication that Google currently supports. Requires key and secret." google_oauth2_client_id: "Client ID of your Google application." google_oauth2_client_secret: "Client secret of your Google application." + google_oauth2_prompt: "[Type of prompt](https://developers.google.com/identity/protocols/OpenIDConnect#prompt) that the authorization server will show to the user. " + google_oauth2_hd: "[Google Apps Hosted domain](https://developers.google.com/identity/protocols/OpenIDConnect#hd-param) that the sign-in will be limited to" enable_twitter_logins: "Enable Twitter authentication, requires twitter_consumer_key and twitter_consumer_secret" twitter_consumer_key: "Consumer key for Twitter authentication, registered at https://apps.twitter.com/" From 7bcc0c1da9969a7652e5d682ff9058af104d504d Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 22 Feb 2018 14:01:07 +0800 Subject: [PATCH 079/299] FIX: Login buttons not working on sign up modal. --- .../javascripts/discourse/components/login-buttons.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/components/login-buttons.js.es6 b/app/assets/javascripts/discourse/components/login-buttons.js.es6 index aa90e565c7..5006c10456 100644 --- a/app/assets/javascripts/discourse/components/login-buttons.js.es6 +++ b/app/assets/javascripts/discourse/components/login-buttons.js.es6 @@ -18,7 +18,7 @@ export default Ember.Component.extend({ }, externalLogin(provider) { - this.sendAction('externalLogin', provider); + this.sendAction('action', provider); } } }); From 9d0807224bc8df425241d33a1ce26171d7c887a8 Mon Sep 17 00:00:00 2001 From: scossar Date: Mon, 22 Jan 2018 11:58:01 -0800 Subject: [PATCH 080/299] Don't enqueue topic webhook unless a post has a topic --- config/initializers/012-web_hook_events.rb | 9 +++++++-- spec/models/web_hook_spec.rb | 20 +++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/config/initializers/012-web_hook_events.rb b/config/initializers/012-web_hook_events.rb index ad5d94dcce..2a84492694 100644 --- a/config/initializers/012-web_hook_events.rb +++ b/config/initializers/012-web_hook_events.rb @@ -18,8 +18,13 @@ end end DiscourseEvent.on(:post_edited) do |post, topic_changed| - WebHook.enqueue_post_hooks(:post_edited, post) - WebHook.enqueue_topic_hooks(:topic_edited, post.topic) if post.is_first_post? && topic_changed + if post.topic + WebHook.enqueue_post_hooks(:post_edited, post) + + if post.is_first_post? && topic_changed + WebHook.enqueue_topic_hooks(:topic_edited, post.topic) + end + end end %i( diff --git a/spec/models/web_hook_spec.rb b/spec/models/web_hook_spec.rb index 73f4470c8f..fe4e9a2de2 100644 --- a/spec/models/web_hook_spec.rb +++ b/spec/models/web_hook_spec.rb @@ -145,6 +145,24 @@ describe WebHook do end end + describe 'when topic has been deleted' do + it 'should not enqueue a post/topic edited hooks' do + topic.trash! + post.reload + + PostRevisor.new(post, topic).revise!( + post.user, + { + category_id: Category.last.id, + raw: "#{post.raw} new" + }, + {} + ) + + expect(Jobs::EmitWebHookEvent.jobs.count).to eq(0) + end + end + it 'should enqueue the right hooks for post events' do Fabricate(:web_hook) @@ -188,7 +206,7 @@ describe WebHook do end it 'should enqueue the right hooks for user events' do - _user_web_hook = Fabricate(:user_web_hook, active: true) + Fabricate(:user_web_hook, active: true) Sidekiq::Testing.fake! do user From 76a2fc3d0782aede35b77bae1604ef297d7ee29c Mon Sep 17 00:00:00 2001 From: Maja Komel Date: Wed, 31 Jan 2018 21:04:09 +0100 Subject: [PATCH 081/299] UX: Add og metadata for groups. https://meta.discourse.org/t/onebox-for-groups/79155 --- app/controllers/groups_controller.rb | 15 ++++++++++++++- app/views/groups/show.html.erb | 3 +++ spec/requests/groups_controller_spec.rb | 20 +++++++++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 app/views/groups/show.html.erb diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 74f5479d26..7ba55917fe 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -12,6 +12,7 @@ class GroupsController < ApplicationController ] skip_before_action :preload_json, :check_xhr, only: [:posts_feed, :mentions_feed] + skip_before_action :check_xhr, only: [:show] def index unless SiteSetting.enable_group_directory? @@ -48,7 +49,19 @@ class GroupsController < ApplicationController end def show - render_serialized(find_group(:id), GroupShowSerializer, root: 'basic_group') + respond_to do |format| + group = find_group(:id) + + format.html do + @title = group.full_name.present? ? group.full_name.capitalize : group.name + @description_meta = group.bio_cooked.present? ? PrettyText.excerpt(group.bio_cooked, 300) : @title + render :show + end + + format.json do + render_serialized(group, GroupShowSerializer, root: 'basic_group') + end + end end def edit diff --git a/app/views/groups/show.html.erb b/app/views/groups/show.html.erb new file mode 100644 index 0000000000..cc250d4684 --- /dev/null +++ b/app/views/groups/show.html.erb @@ -0,0 +1,3 @@ +<% content_for :head do %> + <%= raw crawlable_meta_data(title: @title, description: @description_meta) %> +<% end %> diff --git a/spec/requests/groups_controller_spec.rb b/spec/requests/groups_controller_spec.rb index 6aff127509..dc68c61f00 100644 --- a/spec/requests/groups_controller_spec.rb +++ b/spec/requests/groups_controller_spec.rb @@ -57,6 +57,24 @@ describe GroupsController do end end + describe '#show' do + it 'should respond to HTML' do + group.update_attribute(:bio_cooked, 'testing group bio') + + get "/groups/#{group.name}.html" + + expect(response.status).to eq(200) + + expect(response.body).to have_tag(:meta, with: { + property: 'og:title', content: group.name + }) + + expect(response.body).to have_tag(:meta, with: { + property: 'og:description', content: group.bio_cooked + }) + end + end + describe '#mentionable' do it "should return the right response" do sign_in(user) @@ -226,7 +244,7 @@ describe GroupsController do end end - describe "edit" do + describe "#edit" do let(:group) { Fabricate(:group) } context 'when user is not signed in' do From c302c28a7d0d2454afd3ddad6552ff59ee3f78e2 Mon Sep 17 00:00:00 2001 From: Felix Wolfsteller Date: Thu, 22 Feb 2018 10:48:23 +0100 Subject: [PATCH 082/299] Switch ids in References-Header field of mails. (#5567) This change allows email-clients to show threaded views of mails as expected. Apparently most algorithms expect the message ids of mails in the Reference-header-field to be sorted such that they build a traversal through the thread, so the oldest (original) message being first, then its child, grandchild and so on until it arrives at the message id that the "new" mail (that is to be sent) is the reply to. MSGA [1] +- Re: MSGA [1-1] | +- Re: Re: MSGA [1-2-1] | +- Re: Re: MSGA [1-2-2] +- Re: MSGA [1-1] If the stuff in brackets would be the message ID, the References-Header field of a message that is a reply to [1-2-1] should look like: References: 1, 1-1, 1-2-1 Discussion took place in: https://meta.discourse.org/t/e-mail-threading-in-ml-mode-does-not-work-in-thunderbird Main information taken from: https://www.jwz.org/doc/threading.html --- lib/email/sender.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/email/sender.rb b/lib/email/sender.rb index 08e141a923..fbe78e1a40 100644 --- a/lib/email/sender.rb +++ b/lib/email/sender.rb @@ -109,7 +109,7 @@ module Email else @message.header['Message-ID'] = post_message_id @message.header['In-Reply-To'] = referenced_post_message_ids[0] || topic_message_id - @message.header['References'] = [referenced_post_message_ids, topic_message_id].flatten.compact.uniq + @message.header['References'] = [topic_message_id, referenced_post_message_ids].flatten.compact.uniq end # https://www.ietf.org/rfc/rfc2919.txt From 7a13e50aa6ad0ab5198a7d3811c43a0b20608949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Thu, 22 Feb 2018 11:17:49 +0100 Subject: [PATCH 083/299] fix build --- spec/components/email/sender_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/components/email/sender_spec.rb b/spec/components/email/sender_spec.rb index 72124c6854..7c73e7e2fb 100644 --- a/spec/components/email/sender_spec.rb +++ b/spec/components/email/sender_spec.rb @@ -211,9 +211,9 @@ describe Email::Sender do email_sender.send references = [ + "", "", "", - "", ] expect(message.header['References'].to_s).to eq(references.join(" ")) @@ -231,9 +231,9 @@ describe Email::Sender do expect(message.header['Message-Id'].to_s).to eq("<#{post_4_incoming_email.message_id}>") references = [ + "<#{topic_incoming_email.message_id}>", "", "<#{post_2_incoming_email.message_id}>", - "<#{topic_incoming_email.message_id}>", ] expect(message.header['References'].to_s).to eq(references.join(" ")) From 7cbda949f13ba19d070a8e9c3518d3c4eb58e97e Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Thu, 22 Feb 2018 20:27:02 +0530 Subject: [PATCH 084/299] REFACTOR: New spec tests and code improvement --- app/models/tag.rb | 2 +- .../{ => concerns}/topic_tags_mixin.rb | 8 +++- app/serializers/post_revision_serializer.rb | 15 ++---- app/serializers/topic_view_serializer.rb | 10 +--- lib/guardian.rb | 10 ++-- lib/topic_query.rb | 5 +- spec/components/topic_query_spec.rb | 13 +++++ spec/fabricators/topic_tag_fabricator.rb | 4 ++ spec/models/tag_spec.rb | 15 +++++- spec/models/topic_tag_spec.rb | 47 +++++++++++++++++++ spec/requests/list_controller_spec.rb | 28 +++++++++++ .../serializers/topic_view_serializer_spec.rb | 38 +++++++-------- 12 files changed, 144 insertions(+), 51 deletions(-) rename app/serializers/{ => concerns}/topic_tags_mixin.rb (50%) create mode 100644 spec/fabricators/topic_tag_fabricator.rb create mode 100644 spec/models/topic_tag_spec.rb diff --git a/app/models/tag.rb b/app/models/tag.rb index 9bc1d29350..f26c793b39 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -28,7 +28,7 @@ class Tag < ActiveRecord::Base SELECT COUNT(topics.id) AS topic_count, tags.id AS tag_id FROM tags LEFT JOIN topic_tags ON tags.id = topic_tags.tag_id - LEFT JOIN topics ON topics.id = topic_tags.topic_id AND topics.deleted_at IS NULL AND topics.archetype != "private_message" + LEFT JOIN topics ON topics.id = topic_tags.topic_id AND topics.deleted_at IS NULL AND topics.archetype != 'private_message' GROUP BY tags.id ) x WHERE x.tag_id = t.id diff --git a/app/serializers/topic_tags_mixin.rb b/app/serializers/concerns/topic_tags_mixin.rb similarity index 50% rename from app/serializers/topic_tags_mixin.rb rename to app/serializers/concerns/topic_tags_mixin.rb index 2257ebac12..2fdbc3deb6 100644 --- a/app/serializers/topic_tags_mixin.rb +++ b/app/serializers/concerns/topic_tags_mixin.rb @@ -4,10 +4,14 @@ module TopicTagsMixin end def include_tags? - SiteSetting.tagging_enabled && (!object.private_message? || scope.can_tag_pms?) + scope.can_see_tags?(topic) end def tags - object.tags.pluck(:name) + topic.tags.pluck(:name) + end + + def topic + object.is_a?(Topic) ? object : object.topic end end diff --git a/app/serializers/post_revision_serializer.rb b/app/serializers/post_revision_serializer.rb index 7ed34769ae..b86fda2379 100644 --- a/app/serializers/post_revision_serializer.rb +++ b/app/serializers/post_revision_serializer.rb @@ -164,7 +164,7 @@ class PostRevisionSerializer < ApplicationSerializer end def include_tags_changes? - SiteSetting.tagging_enabled && previous["tags"] != current["tags"] && (!topic.private_message? || scope.can_tag_pms?) + scope.can_see_tags?(topic) && previous["tags"] != current["tags"] end protected @@ -197,18 +197,11 @@ class PostRevisionSerializer < ApplicationSerializer # Retrieve any `tracked_topic_fields` PostRevisor.tracked_topic_fields.each_key do |field| - if topic.respond_to?(field) - latest_modifications[field.to_s] = [topic.send(field)] - end + latest_modifications[field.to_s] = [topic.send(field)] if topic.respond_to?(field) end - if SiteSetting.topic_featured_link_enabled - latest_modifications["featured_link"] = [post.topic.featured_link] - end - - if SiteSetting.tagging_enabled && (!topic.private_message? || scope.can_tag_pms?) - latest_modifications["tags"] = [post.topic.tags.map(&:name)] - end + latest_modifications["featured_link"] = [post.topic.featured_link] if SiteSetting.topic_featured_link_enabled + latest_modifications["tags"] = [topic.tags.pluck(:name)] if scope.can_see_tags?(topic) post_revisions << PostRevision.new( number: post_revisions.last.number + 1, diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index 86a57abef6..fc98d32aea 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -4,6 +4,7 @@ require_dependency 'new_post_manager' class TopicViewSerializer < ApplicationSerializer include PostStreamSerializerMixin include SuggestedTopicsMixin + include TopicTagsMixin include ApplicationHelper def self.attributes_from_topic(*list) @@ -60,7 +61,6 @@ class TopicViewSerializer < ApplicationSerializer :chunk_size, :bookmarked, :message_archived, - :tags, :topic_timer, :private_topic_timer, :unicode_title, @@ -238,10 +238,6 @@ class TopicViewSerializer < ApplicationSerializer scope.is_staff? && NewPostManager.queue_enabled? end - def include_tags? - SiteSetting.tagging_enabled && (!object.topic.private_message? || scope.can_tag_pms?) - end - def topic_timer TopicTimerSerializer.new(object.topic.public_topic_timer, root: false) end @@ -255,10 +251,6 @@ class TopicViewSerializer < ApplicationSerializer TopicTimerSerializer.new(timer, root: false) end - def tags - object.topic.tags.map(&:name) - end - def include_featured_link? SiteSetting.topic_featured_link_enabled end diff --git a/lib/guardian.rb b/lib/guardian.rb index 1b658ef888..c494d395f4 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -129,10 +129,14 @@ class Guardian alias :can_see_flags? :can_moderate? alias :can_close? :can_moderate? - def can_tag?(obj) - return false unless obj && obj.is_a?(Topic) + def can_tag?(topic) + return false if topic.blank? - obj.private_message? ? can_tag_pms? : can_tag_topics? + topic.private_message? ? can_tag_pms? : can_tag_topics? + end + + def can_see_tags?(topic) + SiteSetting.tagging_enabled && topic.present? && (!topic.private_message? || can_tag_pms?) end def can_send_activation_email?(user) diff --git a/lib/topic_query.rb b/lib/topic_query.rb index 55047e977b..ee65210573 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -271,9 +271,8 @@ class TopicQuery def list_private_messages_tag(user) list = private_messages_for(user, :all) - tag_id = Tag.where('name ilike ?', @options[:tags][0]).pluck(:id).first - list = list.joins("JOIN topic_tags tt ON tt.topic_id = topics.id AND - tt.tag_id = #{tag_id}") + list = list.joins("JOIN topic_tags tt ON tt.topic_id = topics.id + JOIN tags t ON t.id = tt.tag_id AND t.name = '#{@options[:tags][0]}'") create_list(:private_messages, {}, list) end diff --git a/spec/components/topic_query_spec.rb b/spec/components/topic_query_spec.rb index 7d400cd5d4..47bf9f4c23 100644 --- a/spec/components/topic_query_spec.rb +++ b/spec/components/topic_query_spec.rb @@ -823,6 +823,19 @@ describe TopicQuery do expect(suggested_topics).to eq([private_group_topic.id, private_message.id]) end end + + context "by tag filter" do + let(:tag) { Fabricate(:tag) } + let!(:user) { group_user } + + it 'should return only tagged topics' do + Fabricate(:topic_tag, topic: private_message, tag: tag) + Fabricate(:topic_tag, topic: private_group_topic) + + expect(TopicQuery.new(user, tags: [tag.name]).list_private_messages_tag(user).topics).to eq([private_message]) + end + + end end context 'with some existing topics' do diff --git a/spec/fabricators/topic_tag_fabricator.rb b/spec/fabricators/topic_tag_fabricator.rb new file mode 100644 index 0000000000..033f50656c --- /dev/null +++ b/spec/fabricators/topic_tag_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:topic_tag) do + tag + topic +end diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index bdccd26fcb..448000e382 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -10,14 +10,15 @@ describe Tag do end end + let(:tag) { Fabricate(:tag) } + let(:topic) { Fabricate(:topic, tags: [tag]) } + before do SiteSetting.tagging_enabled = true SiteSetting.min_trust_level_to_tag_topics = 0 end it "can delete tags on deleted topics" do - tag = Fabricate(:tag) - topic = Fabricate(:topic, tags: [tag]) topic.trash! expect { tag.destroy }.to change { Tag.count }.by(-1) end @@ -94,4 +95,14 @@ describe Tag do end end end + + context "topic counts" do + it "should exclude private message topics" do + topic + Fabricate(:private_message_topic, tags: [tag]) + described_class.ensure_consistency! + tag.reload + expect(tag.topic_count).to eq(1) + end + end end diff --git a/spec/models/topic_tag_spec.rb b/spec/models/topic_tag_spec.rb new file mode 100644 index 0000000000..c944ecd499 --- /dev/null +++ b/spec/models/topic_tag_spec.rb @@ -0,0 +1,47 @@ +require 'rails_helper' + +describe TopicTag do + + let(:topic) { Fabricate(:topic) } + let(:tag) { Fabricate(:tag) } + let(:topic_tag) { Fabricate(:topic_tag, topic: topic, tag: tag) } + + context '#after_create' do + + it "tag topic_count should be increased" do + expect { + topic_tag + }.to change(tag, :topic_count).by(1) + end + + it "tag topic_count should not be increased" do + topic.archetype = Archetype.private_message + + expect { + topic_tag + }.to change(tag, :topic_count).by(0) + end + + end + + context '#after_destroy' do + + it "tag topic_count should be decreased" do + topic_tag + expect { + topic_tag.destroy + }.to change(tag, :topic_count).by(-1) + end + + it "tag topic_count should not be decreased" do + topic.archetype = Archetype.private_message + topic_tag + + expect { + topic_tag.destroy + }.to change(tag, :topic_count).by(0) + end + + end + +end diff --git a/spec/requests/list_controller_spec.rb b/spec/requests/list_controller_spec.rb index 9a0d21a5c9..aeb06b7dbe 100644 --- a/spec/requests/list_controller_spec.rb +++ b/spec/requests/list_controller_spec.rb @@ -38,4 +38,32 @@ RSpec.describe ListController do ) end end + + describe "filter private messages by tag" do + let(:user) { Fabricate(:user) } + let(:moderator) { Fabricate(:moderator) } + let(:admin) { Fabricate(:admin) } + let(:tag) { Fabricate(:tag) } + let(:private_message) { Fabricate(:private_message_topic) } + + before do + SiteSetting.tagging_enabled = true + SiteSetting.allow_staff_to_tag_pms = true + Fabricate(:topic_tag, tag: tag, topic: private_message) + end + + it 'should fail for non-staff users' do + sign_in(user) + get "/topics/private-messages-tag/#{user.username}/#{tag.name}.json" + expect(response.status).to eq(404) + end + + it 'should be success for staff users' do + [moderator, admin].each do |user| + sign_in(user) + get "/topics/private-messages-tag/#{user.username}/#{tag.name}.json" + expect(response).to be_success + end + end + end end diff --git a/spec/serializers/topic_view_serializer_spec.rb b/spec/serializers/topic_view_serializer_spec.rb index 772fc15eef..a4b88a1a1c 100644 --- a/spec/serializers/topic_view_serializer_spec.rb +++ b/spec/serializers/topic_view_serializer_spec.rb @@ -67,17 +67,17 @@ describe TopicViewSerializer do end end - let(:user) { Fabricate(:user) } - let(:moderator) { Fabricate(:moderator) } - let(:tag) { Fabricate(:tag) } - let(:pm) do - Fabricate(:private_message_topic, tags: [tag], topic_allowed_users: [ - Fabricate.build(:topic_allowed_user, user: moderator), - Fabricate.build(:topic_allowed_user, user: user) - ]) - end - describe 'when tags added to private message topics' do + let(:moderator) { Fabricate(:moderator) } + let(:admin) { Fabricate(:admin) } + let(:tag) { Fabricate(:tag) } + let(:pm) do + Fabricate(:private_message_topic, tags: [tag], topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: moderator), + Fabricate.build(:topic_allowed_user, user: user) + ]) + end + before do SiteSetting.tagging_enabled = true SiteSetting.allow_staff_to_tag_pms = true @@ -89,21 +89,19 @@ describe TopicViewSerializer do end it "should include the tag for staff users" do - json = serialize_topic(pm, moderator) - expect(json[:tags]).to eq([tag.name]) - - json = serialize_topic(pm, Fabricate(:admin)) - expect(json[:tags]).to eq([tag.name]) + [moderator, admin].each do |user| + json = serialize_topic(pm, user) + expect(json[:tags]).to eq([tag.name]) + end end it "should not include the tag if pm tags disabled" do SiteSetting.allow_staff_to_tag_pms = false - json = serialize_topic(pm, moderator) - expect(json[:tags]).to eq(nil) - - json = serialize_topic(pm, Fabricate(:admin)) - expect(json[:tags]).to eq(nil) + [moderator, admin].each do |user| + json = serialize_topic(pm, user) + expect(json[:tags]).to eq(nil) + end end end end From 2cf5479678735e51956964904073fb527afd7117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Thu, 22 Feb 2018 17:56:56 +0100 Subject: [PATCH 085/299] WIP --- lib/file_helper.rb | 65 ++++++++++++++++++++++------------------------ 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/lib/file_helper.rb b/lib/file_helper.rb index c8039bb9c5..02be566f8d 100644 --- a/lib/file_helper.rb +++ b/lib/file_helper.rb @@ -25,57 +25,54 @@ class FileHelper follow_redirect: false, read_timeout: 5, skip_rate_limit: false, - verbose: nil) - - # verbose logging is default while debugging onebox - verbose = verbose.nil? ? true : verbose + verbose: false) url = "https:" + url if url.start_with?("//") raise Discourse::InvalidParameters.new(:url) unless url =~ /^https?:\/\// - dest = FinalDestination.new( + tmp = nil + + fd = FinalDestination.new( url, max_redirects: follow_redirect ? 5 : 1, skip_rate_limit: skip_rate_limit, verbose: verbose ) - uri = dest.resolve - if !uri && dest.status_code.to_i >= 400 - # attempt error API compatability - io = FakeIO.new - io.status = [dest.status_code.to_s, ""] + fd.get do |response, chunk, uri| + if tmp.nil? + # error handling + if uri.blank? + if response.code.to_i >= 400 + # attempt error API compatibility + io = FakeIO.new + io.status = [response.code, ""] + raise OpenURI::HTTPError.new("#{response.code} Error", io) + else + log(:error, "FinalDestination did not work for: #{url}") if verbose + throw :done + end + end - # TODO perhaps translate and add Discourse::DownloadError - raise OpenURI::HTTPError.new("#{dest.status_code} Error", io) - end + # first run + tmp_file_ext = File.extname(uri.path) - unless uri - log(:error, "FinalDestination did not work for: #{url}") if verbose - return - end + if tmp_file_ext.blank? && response.content_type.present? + ext = MiniMime.lookup_by_content_type(response.content_type)&.extension + ext = "jpg" if ext == "jpe" + tmp_file_ext = "." + ext if ext.present? + end - downloaded = uri.open("rb", read_timeout: read_timeout) - - extension = File.extname(uri.path) - - if extension.blank? && downloaded.content_type.present? - ext = MiniMime.lookup_by_content_type(downloaded.content_type)&.extension - ext = "jpg" if ext == "jpe" - extension = "." + ext if ext.present? - end - - tmp = Tempfile.new([tmp_file_name, extension]) - - File.open(tmp.path, "wb") do |f| - while f.size <= max_file_size && data = downloaded.read(512.kilobytes) - f.write(data) + tmp = Tempfile.new([tmp_file_name, tmp_file_ext]) + tmp.binmode end + + tmp.write(chunk) + + throw :done if tmp.size > max_file_size end tmp - ensure - downloaded&.close end def self.optimize_image!(filename) From 0210a7f2bf251cffceba656fa945e2315eb3d77f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Thu, 22 Feb 2018 18:06:28 +0100 Subject: [PATCH 086/299] FIX: social login buttons were not working --- .../javascripts/discourse/components/login-buttons.js.es6 | 2 +- .../javascripts/discourse/templates/modal/create-account.hbs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/components/login-buttons.js.es6 b/app/assets/javascripts/discourse/components/login-buttons.js.es6 index 5006c10456..aa90e565c7 100644 --- a/app/assets/javascripts/discourse/components/login-buttons.js.es6 +++ b/app/assets/javascripts/discourse/components/login-buttons.js.es6 @@ -18,7 +18,7 @@ export default Ember.Component.extend({ }, externalLogin(provider) { - this.sendAction('action', provider); + this.sendAction('externalLogin', provider); } } }); diff --git a/app/assets/javascripts/discourse/templates/modal/create-account.hbs b/app/assets/javascripts/discourse/templates/modal/create-account.hbs index 9efde75e55..483a42827d 100644 --- a/app/assets/javascripts/discourse/templates/modal/create-account.hbs +++ b/app/assets/javascripts/discourse/templates/modal/create-account.hbs @@ -3,7 +3,7 @@ {{#unless complete}} {{#d-modal-body title="create_account.title"}} {{#unless hasAuthOptions}} - {{login-buttons action="externalLogin"}} + {{login-buttons externalLogin="externalLogin"}} {{/unless}} {{#if showCreateForm}} From ca1fd774a169aa83e818cd7e68bd8e96b99f8354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Thu, 22 Feb 2018 18:15:42 +0100 Subject: [PATCH 087/299] Revert "WIP" This reverts commit 2cf5479678735e51956964904073fb527afd7117. --- lib/file_helper.rb | 65 ++++++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/lib/file_helper.rb b/lib/file_helper.rb index 02be566f8d..c8039bb9c5 100644 --- a/lib/file_helper.rb +++ b/lib/file_helper.rb @@ -25,54 +25,57 @@ class FileHelper follow_redirect: false, read_timeout: 5, skip_rate_limit: false, - verbose: false) + verbose: nil) + + # verbose logging is default while debugging onebox + verbose = verbose.nil? ? true : verbose url = "https:" + url if url.start_with?("//") raise Discourse::InvalidParameters.new(:url) unless url =~ /^https?:\/\// - tmp = nil - - fd = FinalDestination.new( + dest = FinalDestination.new( url, max_redirects: follow_redirect ? 5 : 1, skip_rate_limit: skip_rate_limit, verbose: verbose ) + uri = dest.resolve - fd.get do |response, chunk, uri| - if tmp.nil? - # error handling - if uri.blank? - if response.code.to_i >= 400 - # attempt error API compatibility - io = FakeIO.new - io.status = [response.code, ""] - raise OpenURI::HTTPError.new("#{response.code} Error", io) - else - log(:error, "FinalDestination did not work for: #{url}") if verbose - throw :done - end - end + if !uri && dest.status_code.to_i >= 400 + # attempt error API compatability + io = FakeIO.new + io.status = [dest.status_code.to_s, ""] - # first run - tmp_file_ext = File.extname(uri.path) + # TODO perhaps translate and add Discourse::DownloadError + raise OpenURI::HTTPError.new("#{dest.status_code} Error", io) + end - if tmp_file_ext.blank? && response.content_type.present? - ext = MiniMime.lookup_by_content_type(response.content_type)&.extension - ext = "jpg" if ext == "jpe" - tmp_file_ext = "." + ext if ext.present? - end + unless uri + log(:error, "FinalDestination did not work for: #{url}") if verbose + return + end - tmp = Tempfile.new([tmp_file_name, tmp_file_ext]) - tmp.binmode + downloaded = uri.open("rb", read_timeout: read_timeout) + + extension = File.extname(uri.path) + + if extension.blank? && downloaded.content_type.present? + ext = MiniMime.lookup_by_content_type(downloaded.content_type)&.extension + ext = "jpg" if ext == "jpe" + extension = "." + ext if ext.present? + end + + tmp = Tempfile.new([tmp_file_name, extension]) + + File.open(tmp.path, "wb") do |f| + while f.size <= max_file_size && data = downloaded.read(512.kilobytes) + f.write(data) end - - tmp.write(chunk) - - throw :done if tmp.size > max_file_size end tmp + ensure + downloaded&.close end def self.optimize_image!(filename) From 1c790ae6bc2b53b6075db0deb52ae42258203afb Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 22 Feb 2018 19:17:02 +0100 Subject: [PATCH 088/299] Revert "Add prompt and HD settings to the Google OAuth2 plugin." This reverts commit f74d6bb605f0395f4cba5e69d3c32206ca7c39a8. --- config/site_settings.yml | 8 -------- lib/auth/google_oauth2_authenticator.rb | 4 +--- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/config/site_settings.yml b/config/site_settings.yml index d57e111be9..744ae92ba5 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -255,14 +255,6 @@ login: default: false google_oauth2_client_id: '' google_oauth2_client_secret: '' - google_oauth2_prompt: - default: 'none' - type: enum - choices: - - 'none' - - 'consent' - - 'select_account' - google_oauth2_hd: '' enable_yahoo_logins: client: true default: false diff --git a/lib/auth/google_oauth2_authenticator.rb b/lib/auth/google_oauth2_authenticator.rb index 67200adcab..dcee38d217 100644 --- a/lib/auth/google_oauth2_authenticator.rb +++ b/lib/auth/google_oauth2_authenticator.rb @@ -59,9 +59,7 @@ class Auth::GoogleOAuth2Authenticator < Auth::Authenticator strategy.options[:client_id] = SiteSetting.google_oauth2_client_id strategy.options[:client_secret] = SiteSetting.google_oauth2_client_secret }, - skip_jwt: true, - prompt: SiteSetting.google_oauth2_prompt, - hd: SiteSetting.google_oauth2_hd + skip_jwt: true end protected From 0e3e5f25a6c7858408e0988bcdaf29ddab93653e Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Thu, 22 Feb 2018 23:56:37 +0200 Subject: [PATCH 089/299] Try extracting time period only when the filter is 'top'. --- .../javascripts/discourse/routes/build-topic-route.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 b/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 index 14dd5e3d20..a7c1dc00ee 100644 --- a/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 @@ -91,7 +91,7 @@ export default function(filter, extras) { const topicOpts = { model, category: null, - period: model.get('for_period') || (filter.indexOf('/') > 0 ? filter.split('/')[1] : ''), + period: model.get('for_period') || (filter.indexOf('top/') >= 0 ? filter.split('/')[1] : ''), selected: [], expandGloballyPinned: true }; From 184d521fc9be512ddb0cca5358bae91809324be5 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Thu, 22 Feb 2018 23:58:53 +0200 Subject: [PATCH 090/299] Added the required hooks for discourse-favorites plugin. --- .../discourse/components/category-title-before.js.es6 | 3 +++ .../templates/components/categories-boxes-with-topics.hbs | 1 + .../discourse/templates/components/categories-only.hbs | 1 + .../discourse/templates/components/category-title-before.hbs | 1 + .../discourse/templates/components/category-title-link.hbs | 1 + 5 files changed, 7 insertions(+) create mode 100644 app/assets/javascripts/discourse/components/category-title-before.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/components/category-title-before.hbs diff --git a/app/assets/javascripts/discourse/components/category-title-before.js.es6 b/app/assets/javascripts/discourse/components/category-title-before.js.es6 new file mode 100644 index 0000000000..9250c1ae73 --- /dev/null +++ b/app/assets/javascripts/discourse/components/category-title-before.js.es6 @@ -0,0 +1,3 @@ +export default Ember.Component.extend({ + tagName: '' +}); diff --git a/app/assets/javascripts/discourse/templates/components/categories-boxes-with-topics.hbs b/app/assets/javascripts/discourse/templates/components/categories-boxes-with-topics.hbs index 434e18fe62..aae157c521 100644 --- a/app/assets/javascripts/discourse/templates/components/categories-boxes-with-topics.hbs +++ b/app/assets/javascripts/discourse/templates/components/categories-boxes-with-topics.hbs @@ -11,6 +11,7 @@ {{#if c.read_restricted}} {{d-icon 'lock'}} {{/if}} + {{category-title-before category=c}} {{c.name}} diff --git a/app/assets/javascripts/discourse/templates/components/categories-only.hbs b/app/assets/javascripts/discourse/templates/components/categories-only.hbs index 2fa528e3a9..282e5d520c 100644 --- a/app/assets/javascripts/discourse/templates/components/categories-only.hbs +++ b/app/assets/javascripts/discourse/templates/components/categories-only.hbs @@ -24,6 +24,7 @@
{{#each c.subcategories as |s|}} + {{category-title-before category=s}} {{category-link s hideParent="true"}} {{category-unread category=s}} diff --git a/app/assets/javascripts/discourse/templates/components/category-title-before.hbs b/app/assets/javascripts/discourse/templates/components/category-title-before.hbs new file mode 100644 index 0000000000..af92a5ecd6 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/category-title-before.hbs @@ -0,0 +1 @@ +{{plugin-outlet name="category-title-before" noTags=true args=(hash category=category)}} diff --git a/app/assets/javascripts/discourse/templates/components/category-title-link.hbs b/app/assets/javascripts/discourse/templates/components/category-title-link.hbs index 49dd92606d..51db0e5f77 100644 --- a/app/assets/javascripts/discourse/templates/components/category-title-link.hbs +++ b/app/assets/javascripts/discourse/templates/components/category-title-link.hbs @@ -1,3 +1,4 @@ +{{category-title-before category=category}} {{#if category.read_restricted}} {{d-icon 'lock'}} From 18c1d1565ca28cde538dcc4d7e6aef89e486e17d Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 22 Feb 2018 15:37:27 +0800 Subject: [PATCH 091/299] UX: Fix missing css styles on invite modal. --- .../javascripts/discourse/templates/modal/invite.hbs | 3 ++- app/assets/stylesheets/common/base/modal.scss | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/modal/invite.hbs b/app/assets/javascripts/discourse/templates/modal/invite.hbs index 4c27a8ee28..e401b8e712 100644 --- a/app/assets/javascripts/discourse/templates/modal/invite.hbs +++ b/app/assets/javascripts/discourse/templates/modal/invite.hbs @@ -27,13 +27,14 @@ {{else}} {{text-field value=emailOrUsername placeholderKey="topic.invite_reply.email_placeholder"}} {{/if}} + {{#if showGroups}} {{group-selector groupFinder=groupFinder groupNames=model.groupNames placeholderKey="topic.invite_private.group_name"}} {{/if}} {{#if showCustomMessage}} -
{{i18n 'invite.custom_message_link'}}. + {{#if hasCustomMessage}}{{textarea value=customMessage placeholder=customMessagePlaceholder}}{{/if}} {{/if}} diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 6d52ce436d..8fd78de468 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -282,11 +282,13 @@ } } -.invite-modal { +#invite-modal { overflow: visible; - .ember-text-field { - width: 550px; + + label { + margin-top: 7px; } + .optional { color: #9e9ea6; } From 24d0a7a4c71ba5147a5741c7a19dd061296098a0 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 23 Feb 2018 07:19:36 +0800 Subject: [PATCH 092/299] Take 2 on https://github.com/discourse/discourse/commit/f74d6bb605f0395f4cba5e69d3c32206ca7c39a8. New options are left out by default when not configured so that an incorrect default configuration doesn't blow up google oauth for everyone. --- config/locales/server.en.yml | 4 ++-- config/site_settings.yml | 9 +++++++++ lib/auth/google_oauth2_authenticator.rb | 24 +++++++++++++++++------- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index f7ecfb6884..82dc3dc2e1 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1165,8 +1165,8 @@ en: enable_google_oauth2_logins: "Enable Google Oauth2 authentication. This is the method of authentication that Google currently supports. Requires key and secret." google_oauth2_client_id: "Client ID of your Google application." google_oauth2_client_secret: "Client secret of your Google application." - google_oauth2_prompt: "[Type of prompt](https://developers.google.com/identity/protocols/OpenIDConnect#prompt) that the authorization server will show to the user. " - google_oauth2_hd: "[Google Apps Hosted domain](https://developers.google.com/identity/protocols/OpenIDConnect#hd-param) that the sign-in will be limited to" + google_oauth2_prompt: "A space-delimited list of string values that specifies whether the authorization server prompts the user for reauthentication and consent. See https://developers.google.com/identity/protocols/OpenIDConnect#prompt for the possible values." + google_oauth2_hd: "Google Apps Hosted domain that the sign-in will be limited to. See https://developers.google.com/identity/protocols/OpenIDConnect#hd-param for more details." enable_twitter_logins: "Enable Twitter authentication, requires twitter_consumer_key and twitter_consumer_secret" twitter_consumer_key: "Consumer key for Twitter authentication, registered at https://apps.twitter.com/" diff --git a/config/site_settings.yml b/config/site_settings.yml index c692b9887d..dc15dc66cd 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -255,6 +255,15 @@ login: default: false google_oauth2_client_id: '' google_oauth2_client_secret: '' + google_oauth2_prompt: + default: '' + type: list + choices: + - 'none' + - 'consent' + - 'select_account' + google_oauth2_hd: + default: '' enable_yahoo_logins: client: true default: false diff --git a/lib/auth/google_oauth2_authenticator.rb b/lib/auth/google_oauth2_authenticator.rb index dcee38d217..a280408193 100644 --- a/lib/auth/google_oauth2_authenticator.rb +++ b/lib/auth/google_oauth2_authenticator.rb @@ -51,15 +51,25 @@ class Auth::GoogleOAuth2Authenticator < Auth::Authenticator end def register_middleware(omniauth) + options = { + setup: lambda { |env| + strategy = env["omniauth.strategy"] + strategy.options[:client_id] = SiteSetting.google_oauth2_client_id + strategy.options[:client_secret] = SiteSetting.google_oauth2_client_secret + }, + skip_jwt: true + } + + if (google_oauth2_prompt = SiteSetting.google_oauth2_prompt).present? + options[:prompt] = google_oauth2_prompt.gsub("|", " ") + end + + google_oauth2_hd = SiteSetting.google_oauth2_hd + options[:hd] = google_oauth2_hd if google_oauth2_hd.present? + # jwt encoding is causing auth to fail in quite a few conditions # skipping - omniauth.provider :google_oauth2, - setup: lambda { |env| - strategy = env["omniauth.strategy"] - strategy.options[:client_id] = SiteSetting.google_oauth2_client_id - strategy.options[:client_secret] = SiteSetting.google_oauth2_client_secret - }, - skip_jwt: true + omniauth.provider :google_oauth2, options end protected From ee9be65b2c8e22d734459877694cc4171e4e5ea1 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 22 Feb 2018 19:50:10 -0500 Subject: [PATCH 093/299] FIX: Show names when available --- .../discourse/helpers/user-avatar.js.es6 | 6 +++--- .../discourse/lib/transform-post.js.es6 | 2 ++ .../discourse/widgets/header.js.es6 | 14 ++++++++++---- .../javascripts/discourse/widgets/post.js.es6 | 3 ++- .../discourse/widgets/topic-map.js.es6 | 19 ++++++++++++++++--- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 b/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 index e20528bd42..dc685eee6e 100644 --- a/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 +++ b/app/assets/javascripts/discourse/helpers/user-avatar.js.es6 @@ -36,7 +36,7 @@ function renderAvatar(user, options) { if (!username || !avatarTemplate) { return ''; } - let formattedUsername = formatUsername(username); + let displayName = Ember.get(user, 'name') || formatUsername(username); let title = options.title; if (!title && !options.ignoreTitle) { @@ -49,7 +49,7 @@ function renderAvatar(user, options) { // if a description has been provided if (description && description.length > 0) { // preprend the username before the description - title = formattedUsername + " - " + description; + title = displayName + " - " + description; } } } @@ -57,7 +57,7 @@ function renderAvatar(user, options) { return avatarImg({ size: options.imageSize, extraClasses: Em.get(user, 'extras') || options.extraClasses, - title: title || formattedUsername, + title: title || displayName, avatarTemplate: avatarTemplate }); } else { diff --git a/app/assets/javascripts/discourse/lib/transform-post.js.es6 b/app/assets/javascripts/discourse/lib/transform-post.js.es6 index 03ac928c1f..3aa31c4714 100644 --- a/app/assets/javascripts/discourse/lib/transform-post.js.es6 +++ b/app/assets/javascripts/discourse/lib/transform-post.js.es6 @@ -138,10 +138,12 @@ export default function transformPost(currentUser, site, post, prevPost, nextPos postAtts.topicCreatedAt = topic.created_at; postAtts.createdByUsername = createdBy.username; postAtts.createdByAvatarTemplate = createdBy.avatar_template; + postAtts.createdByName = createdBy.name; postAtts.lastPostUrl = topic.get('lastPostUrl'); postAtts.lastPostUsername = details.last_poster.username; postAtts.lastPostAvatarTemplate = details.last_poster.avatar_template; + postAtts.lastPostName = details.last_poster.name; postAtts.lastPostAt = topic.last_posted_at; postAtts.topicReplyCount = topic.get('replyCount'); diff --git a/app/assets/javascripts/discourse/widgets/header.js.es6 b/app/assets/javascripts/discourse/widgets/header.js.es6 index 04fdd8902a..4d4a06c508 100644 --- a/app/assets/javascripts/discourse/widgets/header.js.es6 +++ b/app/assets/javascripts/discourse/widgets/header.js.es6 @@ -31,11 +31,17 @@ createWidget('header-notifications', { html(attrs) { const { user } = attrs; + let avatarAttrs = { + template: user.get('avatar_template'), + username: user.get('username') + }; + + if (this.siteSettings.enable_names) { + avatarAttrs.name = user.get('name'); + } + const contents = [ - avatarImg(this.settings.avatarSize, addExtraUserClasses(user, { - template: user.get('avatar_template'), - username: user.get('username') - })) + avatarImg(this.settings.avatarSize, addExtraUserClasses(user, avatarAttrs)) ]; const unreadNotifications = user.get('unread_notifications'); diff --git a/app/assets/javascripts/discourse/widgets/post.js.es6 b/app/assets/javascripts/discourse/widgets/post.js.es6 index 887493fd45..27f263c216 100644 --- a/app/assets/javascripts/discourse/widgets/post.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post.js.es6 @@ -15,7 +15,7 @@ export function avatarImg(wanted, attrs) { // We won't render an invalid url if (!url || url.length === 0) { return; } - const title = formatUsername(attrs.username); + const title = attrs.name || formatUsername(attrs.username); let className = 'avatar' + ( attrs.extraClasses ? " " + attrs.extraClasses : "" @@ -123,6 +123,7 @@ createWidget('post-avatar', { body = avatarFor.call(this, this.settings.size, { template: attrs.avatar_template, username: attrs.username, + name: attrs.name, url: attrs.usernameUrl, className: 'main-avatar' }); diff --git a/app/assets/javascripts/discourse/widgets/topic-map.js.es6 b/app/assets/javascripts/discourse/widgets/topic-map.js.es6 index fbf6074e0a..d63b9169ce 100644 --- a/app/assets/javascripts/discourse/widgets/topic-map.js.es6 +++ b/app/assets/javascripts/discourse/widgets/topic-map.js.es6 @@ -37,7 +37,12 @@ createWidget('topic-participant', { }, html(attrs, state) { - const linkContents = [avatarImg('medium', { username: attrs.username, template: attrs.avatar_template })]; + const linkContents = [ + avatarImg('medium', { + username: attrs.username, + template: attrs.avatar_template, + name: attrs.name + })]; if (attrs.post_count > 2) { linkContents.push(h('span.post-count', attrs.post_count.toString())); @@ -67,7 +72,11 @@ createWidget('topic-map-summary', { [ h('h4', I18n.t('created_lowercase')), h('div.topic-map-post.created-at', [ - avatarFor('tiny', { username: attrs.createdByUsername, template: attrs.createdByAvatarTemplate }), + avatarFor('tiny', { + username: attrs.createdByUsername, + template: attrs.createdByAvatarTemplate, + name: attrs.createdByName + }), dateNode(attrs.topicCreatedAt) ]) ] @@ -76,7 +85,11 @@ createWidget('topic-map-summary', { h('a', { attributes: { href: attrs.lastPostUrl } }, [ h('h4', I18n.t('last_reply_lowercase')), h('div.topic-map-post.last-reply', [ - avatarFor('tiny', { username: attrs.lastPostUsername, template: attrs.lastPostAvatarTemplate }), + avatarFor('tiny', { + username: attrs.lastPostUsername, + template: attrs.lastPostAvatarTemplate, + name: attrs.lastPostName + }), dateNode(attrs.lastPostAt) ]) ]) From 69af881f7fc7ac4ac5931793981fd22aae69dc84 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 22 Feb 2018 20:39:24 -0500 Subject: [PATCH 094/299] New site setting `trusted_users_can_edit_others` The default is true to keep with previous discourse behavior. If disabled, high trust level users cannot edit the topics or posts of other users. --- config/locales/server.en.yml | 1 + config/site_settings.yml | 3 +++ lib/guardian/post_guardian.rb | 10 +++++++--- lib/guardian/topic_guardian.rb | 16 ++++++++++++++-- spec/components/guardian_spec.rb | 15 +++++++++++++++ 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 82dc3dc2e1..e0b65780c9 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1291,6 +1291,7 @@ en: tl3_requires_likes_given: "The minimum number of likes that must be given in the last (tl3 time period) days to qualify for promotion to trust level 3." tl3_requires_likes_received: "The minimum number of likes that must be received in the last (tl3 time period) days to qualify for promotion to trust level 3." tl3_links_no_follow: "Do not remove rel=nofollow from links posted by trust level 3 users." + trusted_users_can_edit_others: "Allow users with high trust levels to edit content from other users" min_trust_to_create_topic: "The minimum trust level required to create a new topic." allow_flagging_staff: "If enabled, users can flag posts from staff accounts." diff --git a/config/site_settings.yml b/config/site_settings.yml index dc15dc66cd..420ed7c86e 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -971,6 +971,9 @@ trust: tl3_links_no_follow: default: false client: true + trusted_users_can_edit_others: + default: true + client: false security: force_https: diff --git a/lib/guardian/post_guardian.rb b/lib/guardian/post_guardian.rb index 4344e5fd34..c0e110a1bd 100644 --- a/lib/guardian/post_guardian.rb +++ b/lib/guardian/post_guardian.rb @@ -115,9 +115,13 @@ module PostGuardian # Must be staff to edit a locked post return false if post.locked? && !is_staff? - if is_staff? || @user.has_trust_level?(TrustLevel[4]) - return can_create_post?(post.topic) - end + return can_create_post?(post.topic) if ( + is_staff? || + ( + SiteSetting.trusted_users_can_edit_others? && + @user.has_trust_level?(TrustLevel[4]) + ) + ) if post.topic.archived? || post.user_deleted || post.deleted_at return false diff --git a/lib/guardian/topic_guardian.rb b/lib/guardian/topic_guardian.rb index 1bf07ef82c..1774ac7c04 100644 --- a/lib/guardian/topic_guardian.rb +++ b/lib/guardian/topic_guardian.rb @@ -46,10 +46,22 @@ module TopicGuardian return false if !can_create_topic_on_category?(topic.category) # TL4 users can edit archived topics, but can not edit private messages - return true if (topic.archived && !topic.private_message? && user.has_trust_level?(TrustLevel[4]) && can_create_post?(topic)) + return true if ( + SiteSetting.trusted_users_can_edit_others? && + topic.archived && + !topic.private_message? && + user.has_trust_level?(TrustLevel[4]) && + can_create_post?(topic) + ) # TL3 users can not edit archived topics and private messages - return true if (!topic.archived && !topic.private_message? && user.has_trust_level?(TrustLevel[3]) && can_create_post?(topic)) + return true if ( + SiteSetting.trusted_users_can_edit_others? && + !topic.archived && + !topic.private_message? && + user.has_trust_level?(TrustLevel[3]) && + can_create_post?(topic) + ) return false if topic.archived is_my_own?(topic) && !topic.edit_time_limit_expired? diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index 0fc91d7b79..c0775b839b 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -1225,6 +1225,11 @@ describe Guardian do expect(Guardian.new(trust_level_4).can_edit?(post)).to be_truthy end + it 'returns false as a TL4 user if trusted_users_can_edit_others is true' do + SiteSetting.trusted_users_can_edit_others = false + expect(Guardian.new(trust_level_4).can_edit?(post)).to eq(false) + end + it 'returns false when trying to edit a post with no trust' do SiteSetting.min_trust_to_edit_post = 2 post.user.trust_level = 1 @@ -1332,6 +1337,11 @@ describe Guardian do expect(Guardian.new(trust_level_3).can_edit?(topic)).to eq(true) end + it 'is false at TL3, if `trusted_users_can_edit_others` is false' do + SiteSetting.trusted_users_can_edit_others = false + expect(Guardian.new(trust_level_3).can_edit?(topic)).to eq(false) + end + it "returns false when the category is read only" do topic.category.set_permissions(everyone: :readonly) topic.category.save @@ -1381,6 +1391,11 @@ describe Guardian do expect(Guardian.new(trust_level_4).can_edit?(archived_topic)).to be_truthy end + it 'is false at TL4, if `trusted_users_can_edit_others` is false' do + SiteSetting.trusted_users_can_edit_others = false + expect(Guardian.new(trust_level_4).can_edit?(archived_topic)).to eq(false) + end + it 'returns false at trust level 3' do expect(Guardian.new(trust_level_3).can_edit?(archived_topic)).to be_falsey end From 9b704b21b5baaf494aecf7a80c9b9f970b7bf1f7 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 22 Feb 2018 21:22:09 -0500 Subject: [PATCH 095/299] Don't include `client` when false --- config/site_settings.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/config/site_settings.yml b/config/site_settings.yml index 420ed7c86e..0e956aba00 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -973,7 +973,6 @@ trust: client: true trusted_users_can_edit_others: default: true - client: false security: force_https: From 2e2da3a6e2dd157a0497ab5bea78b9cbea6d2316 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 23 Feb 2018 09:56:08 +0800 Subject: [PATCH 096/299] Update copy for 2FA. --- .../templates/preferences-second-factor.hbs | 2 +- config/locales/client.en.yml | 19 ++++++++++++++----- config/locales/server.en.yml | 2 +- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs index 027f8ae462..13cbbc5da9 100644 --- a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs +++ b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs @@ -42,7 +42,7 @@ {{#if loaded}}
- {{i18n 'user.second_factor.enable_description'}} + {{{i18n 'user.second_factor.enable_description'}}}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 590c6fa188..1f7d00b805 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -713,11 +713,20 @@ en: enable: "Enable Two Factor Authentication" disable: "Disable Two Factor Authentication" confirm_password_description: "Confirm your password to continue enabling Two Factor Authentication." - enable_description: "To complete Two Factor Authentication setup, scan the following QR code and submit a Two Factor Authentication code." - disable_description: "Enter a Two Factor Authentication code to disable." + enable_description: | + To complete Two Factor Authentication setup, scan the following QR code + in one of the supported apps + (Android and iOS, + Windows Phone) + and submit the generated Two Factor Authentication code. + disable_description: "Enter the authentication code from your app" show_key_description: "Or enter the key manually." info_prompt: "What is Two Factor Authentication?" - extended_description: "Two Factor Authentication adds an extra security step to logging in by requiring a one-time token in addition to your password. These tokens are generated by compatible apps for iPhone or Android such as Google Authenticator, Authy, and FreeOTP." + extended_description: | + Two Factor Authentication adds an extra security step to logging in by + requiring a one-time token in addition to your password. These tokens + can be generated on Android and iOS by Google Authenticator + and on Windows Phone by Authenticator. change_about: title: "Change About Me" @@ -1111,7 +1120,7 @@ en: username: "User" password: "Password" second_factor_title: "Two Factor Authentication Required" - second_factor_description: "Enter a generated verification code." + second_factor_description: "Enter the authentication code from your app." second_factor_label: "Code" email_placeholder: "email or username" caps_lock_warning: "Caps Lock is on" @@ -3279,7 +3288,7 @@ en: post_locked: "post locked" post_unlocked: "post unlocked" check_personal_message: "check personal message" - disabled_second_factor: "disable 2 factor authentication" + disabled_second_factor: "disable Two Factor Authentication" screened_emails: title: "Screened Emails" description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index e0b65780c9..64837655be 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1790,7 +1790,7 @@ en: click_to_continue: "Click here to continue." already_logged_in: "Oops, looks like you are attempting to accept an invitation for another user. If you are not %{current_user}, please log out and try again." second_factor_title: "Two Factor Authentication Required" - second_factor_description: "Enter a generated authentication code." + second_factor_description: "Enter the authentication code from your app." invalid_second_factor_code: "Invalid Two Factor Authentication Code" user: From 5e60f6b533bcd547c82920d328d6b2825ff1d957 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 23 Feb 2018 10:02:08 +0800 Subject: [PATCH 097/299] UX: Don't disable submit button before transitioning in 2FA flow. --- .../discourse/controllers/preferences/second-factor.js.es6 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 index 990d2eb6f9..d66ee78360 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 @@ -26,14 +26,17 @@ export default Ember.Controller.extend({ .then(response => { if (response.error) { this.set('errorMessage', response.error); + this.set('loading', false); return; } this.set('errorMessage',null); DiscourseURL.redirectTo(userPath(`${this.get('content').username.toLowerCase()}/preferences`)); }) - .catch(popupAjaxError) - .finally(() => this.set('loading', false)); + .catch(error => { + this.set('loading', false); + popupAjaxError(error); + }); }, actions: { From e137b7f8367a16226cf9818f6b148d11ddd7a514 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 23 Feb 2018 10:29:03 +0800 Subject: [PATCH 098/299] UX: Improve indication of 2FA status in user's preferences. --- .../controllers/preferences/account.js.es6 | 5 ++++ .../templates/preferences/account.hbs | 28 +++++++++++-------- config/locales/client.en.yml | 4 +-- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 index 9dc6083f15..d8c54177d4 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 @@ -40,6 +40,11 @@ export default Ember.Controller.extend(CanCheckEmails, PreferencesTabController, return !this.siteSettings.enable_sso && this.siteSettings.enable_local_logins; }, + @computed("model.second_factor_enabled") + secondFactorStatusClass(secondFactorEnabled) { + return secondFactorEnabled ? 'tip good' : 'tip bad'; + }, + actions: { save() { this.set('saved', false); diff --git a/app/assets/javascripts/discourse/templates/preferences/account.hbs b/app/assets/javascripts/discourse/templates/preferences/account.hbs index 225db632b9..a9a367d3da 100644 --- a/app/assets/javascripts/discourse/templates/preferences/account.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/account.hbs @@ -69,21 +69,25 @@
-
- {{#link-to "preferences.second-factor" class="btn"}} - {{#if model.second_factor_enabled}} - {{d-icon "unlock-alt"}} - {{i18n 'user.second_factor.disable'}} - {{else}} - {{d-icon "lock"}} - {{i18n 'user.second_factor.enable'}} - {{/if}} - {{/link-to}} -
- + +
+ + {{#if model.second_factor_enabled}} + {{i18n 'user.second_factor.enabled_status'}} + {{d-icon 'check'}} + {{else}} + {{i18n 'user.second_factor.disabled_status'}} + {{d-icon 'times'}} + {{/if}} + + + {{#link-to "preferences.second-factor" class="btn btn-small btn-icon pad-left no-text"}} + {{d-icon "pencil"}} + {{/link-to}} +
{{/if}} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 1f7d00b805..877297c198 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -710,8 +710,8 @@ en: second_factor: title: "Two Factor Authentication" - enable: "Enable Two Factor Authentication" - disable: "Disable Two Factor Authentication" + enabled_status: "Status: On" + disabled_status: "Status: Off" confirm_password_description: "Confirm your password to continue enabling Two Factor Authentication." enable_description: | To complete Two Factor Authentication setup, scan the following QR code From 3637f0d3bbfeac4cee755d879a47bb59f4f2b764 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 23 Feb 2018 10:39:51 +0800 Subject: [PATCH 099/299] Update copy to reflect that 2FA key should be kept a secret. --- config/locales/client.en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 877297c198..2fb1c74158 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -720,7 +720,7 @@ en: Windows Phone) and submit the generated Two Factor Authentication code. disable_description: "Enter the authentication code from your app" - show_key_description: "Or enter the key manually." + show_key_description: "Or enter the secret key manually." info_prompt: "What is Two Factor Authentication?" extended_description: | Two Factor Authentication adds an extra security step to logging in by From 1f74509a7542b7bbfbd180d490774ab27ab6d2b4 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 23 Feb 2018 11:05:39 +0800 Subject: [PATCH 100/299] FIX: 2FA prompt incorrectly displayed on admin login page. --- app/views/users/admin_login.html.erb | 10 ++++++---- spec/controllers/users_controller_spec.rb | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/app/views/users/admin_login.html.erb b/app/views/users/admin_login.html.erb index c7752523e9..ed372e49d6 100644 --- a/app/views/users/admin_login.html.erb +++ b/app/views/users/admin_login.html.erb @@ -7,10 +7,12 @@ <%= @message %> <% if @error %>

<%= @error %>

<% end %> - <%=form_tag({}, method: :put) do %> - <%= label_tag(:second_factor_token, t('login.second_factor_description')) %> - <%= text_field_tag(:second_factor_token, nil, autofocus: true) %>

- <%= submit_tag t('submit')%> + <% if @second_factor_required %> + <%=form_tag({}, method: :put) do %> + <%= label_tag(:second_factor_token, t('login.second_factor_description')) %> + <%= text_field_tag(:second_factor_token, nil, autofocus: true) %>

+ <%= submit_tag t('submit')%> + <% end %> <% end %> <% else %> <%=form_tag({}, method: :put) do %> diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 35f172aa04..502efb7e4f 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -523,6 +523,21 @@ describe UsersController do end end + context 'when email is incorrect' do + render_views + + it 'should return the right response' do + get :admin_login, params: { email: 'random' } + + expect(response.status).to eq(200) + + response_body = response.body + + expect(response_body).to match(I18n.t("admin_login.errors.unknown_email_address")) + expect(response_body).to_not match(I18n.t("login.second_factor_description")) + end + end + context 'logs in admin' do it 'does not log in admin with invalid token' do SiteSetting.sso_url = "https://www.example.com/sso" From 66062ed6d922fea87a28ecb4a9f0b7f38f9e9488 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 23 Feb 2018 11:23:08 +0800 Subject: [PATCH 101/299] Add missing default choice for `SiteSetting.google_oauth2_prompt`. --- config/site_settings.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/site_settings.yml b/config/site_settings.yml index 0e956aba00..8932b333af 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -259,6 +259,7 @@ login: default: '' type: list choices: + - '' - 'none' - 'consent' - 'select_account' From ea1733ca6402fa9a9d8f2bb88edca2c6715a13cc Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 23 Feb 2018 11:31:10 +0800 Subject: [PATCH 102/299] Fix failing spec. --- spec/controllers/users_controller_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 502efb7e4f..62a9114441 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -527,7 +527,7 @@ describe UsersController do render_views it 'should return the right response' do - get :admin_login, params: { email: 'random' } + put :admin_login, params: { email: 'random' } expect(response.status).to eq(200) From 4ac5fc8cd371b84947f9aa1ad32293ac3275a14e Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 23 Feb 2018 12:17:04 +0800 Subject: [PATCH 103/299] Fix incorrect data type for `SiteSetting.google_oauth2_prompt`. --- ...3041147_fix_google_oauth2_prompt_data_type.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 db/migrate/20180223041147_fix_google_oauth2_prompt_data_type.rb diff --git a/db/migrate/20180223041147_fix_google_oauth2_prompt_data_type.rb b/db/migrate/20180223041147_fix_google_oauth2_prompt_data_type.rb new file mode 100644 index 0000000000..1317ec46c4 --- /dev/null +++ b/db/migrate/20180223041147_fix_google_oauth2_prompt_data_type.rb @@ -0,0 +1,16 @@ +class FixGoogleOauth2PromptDataType < ActiveRecord::Migration[5.1] + def up + sql = <<~SQL + UPDATE site_settings + SET data_type=#{SiteSettings::TypeSupervisor.types[:list]} + WHERE data_type=#{SiteSettings::TypeSupervisor.types[:enum]} + AND name='google_oauth2_prompt' + SQL + + execute sql + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end From 845cec3ba02276cc704ba69c01ad75f8eb22df75 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 23 Feb 2018 14:33:27 +1100 Subject: [PATCH 104/299] FIX: preview theme not working consistently Avoid flash, this makes debugging much simpler as well. Additionally URL now clearly shows you are previewing a theme. --- app/controllers/admin/themes_controller.rb | 8 ++++++-- app/controllers/application_controller.rb | 8 +++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb index b0f8d21f39..5c711a1e6a 100644 --- a/app/controllers/admin/themes_controller.rb +++ b/app/controllers/admin/themes_controller.rb @@ -5,10 +5,14 @@ class Admin::ThemesController < Admin::AdminController skip_before_action :check_xhr, only: [:show, :preview] + def self.whitelist_theme_key(user) + "whitelist_theme_key_#{user.id}" + end + def preview @theme = Theme.find(params[:id]) - - redirect_to path("/"), flash: { preview_theme_key: @theme.key } + $redis.setex(Admin::ThemesController.whitelist_theme_key(current_user), 60, @theme.key) + redirect_to path("/?preview_theme_key=#{@theme.key}") end def upload_asset diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 653ba952fc..e784e68f0f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -307,7 +307,13 @@ class ApplicationController < ActionController::Base resolve_safe_mode return if request.env[NO_CUSTOM] - theme_key = flash[:preview_theme_key] + theme_key = nil + if (k = request[:preview_theme_key]) && current_user + # some extra security, only to use the magic param the key needs to be whitelisted + if k == $redis.get(::Admin::ThemesController.whitelist_theme_key(current_user)) + theme_key = k + end + end user_option = current_user&.user_option From 4250ab522a7961f3b1cd657f9e888bcb84785e9f Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 23 Feb 2018 13:51:02 +0800 Subject: [PATCH 105/299] UX: Don't show admin 2FA edit icon on profile of other users. --- .../javascripts/discourse/mixins/can-check-emails.js.es6 | 4 ++-- .../discourse/templates/preferences/account.hbs | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/mixins/can-check-emails.js.es6 b/app/assets/javascripts/discourse/mixins/can-check-emails.js.es6 index dc802c4d04..8e15c955c2 100644 --- a/app/assets/javascripts/discourse/mixins/can-check-emails.js.es6 +++ b/app/assets/javascripts/discourse/mixins/can-check-emails.js.es6 @@ -1,9 +1,9 @@ import { propertyEqual, setting } from 'discourse/lib/computed'; export default Ember.Mixin.create({ - isOwnEmail: propertyEqual("model.id", "currentUser.id"), + isCurrentUser: propertyEqual("model.id", "currentUser.id"), showEmailOnProfile: setting("show_email_on_profile"), canStaffCheckEmails: Em.computed.and("showEmailOnProfile", "currentUser.staff"), canAdminCheckEmails: Em.computed.alias("currentUser.admin"), - canCheckEmails: Em.computed.or("isOwnEmail", "canStaffCheckEmails", "canAdminCheckEmails"), + canCheckEmails: Em.computed.or("isCurrentUser", "canStaffCheckEmails", "canAdminCheckEmails"), }); diff --git a/app/assets/javascripts/discourse/templates/preferences/account.hbs b/app/assets/javascripts/discourse/templates/preferences/account.hbs index a9a367d3da..60c3e58b42 100644 --- a/app/assets/javascripts/discourse/templates/preferences/account.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/account.hbs @@ -66,6 +66,7 @@ {{passwordProgress}}
+
@@ -84,9 +85,11 @@ {{/if}} - {{#link-to "preferences.second-factor" class="btn btn-small btn-icon pad-left no-text"}} - {{d-icon "pencil"}} - {{/link-to}} + {{#if isCurrentUser}} + {{#link-to "preferences.second-factor" class="btn btn-small btn-icon pad-left no-text"}} + {{d-icon "pencil"}} + {{/link-to}} + {{/if}}
{{/if}} From 6f076963f2c1cc419f8be8af901e6b7c111f99d2 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 23 Feb 2018 17:58:13 +1100 Subject: [PATCH 106/299] FIX: incorrect caching of theme keys --- app/models/theme.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/theme.rb b/app/models/theme.rb index 280fa6847f..e8aeaf0553 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -72,7 +72,7 @@ class Theme < ActiveRecord::Base if keys = @cache["user_theme_keys"] return keys end - @cache["theme_keys"] = Set.new( + @cache["user_theme_keys"] = Set.new( Theme .where('user_selectable OR key = ?', SiteSetting.default_theme_key) .pluck(:key) From a94dc0c7311f744bb8d5801787b0a1a5df0f036b Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 23 Feb 2018 17:59:00 +1100 Subject: [PATCH 107/299] Revert "FIX: preview theme not working consistently" This reverts commit 845cec3ba02276cc704ba69c01ad75f8eb22df75. was not a needed change, but was elsewhere --- app/controllers/admin/themes_controller.rb | 8 ++------ app/controllers/application_controller.rb | 8 +------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb index 5c711a1e6a..b0f8d21f39 100644 --- a/app/controllers/admin/themes_controller.rb +++ b/app/controllers/admin/themes_controller.rb @@ -5,14 +5,10 @@ class Admin::ThemesController < Admin::AdminController skip_before_action :check_xhr, only: [:show, :preview] - def self.whitelist_theme_key(user) - "whitelist_theme_key_#{user.id}" - end - def preview @theme = Theme.find(params[:id]) - $redis.setex(Admin::ThemesController.whitelist_theme_key(current_user), 60, @theme.key) - redirect_to path("/?preview_theme_key=#{@theme.key}") + + redirect_to path("/"), flash: { preview_theme_key: @theme.key } end def upload_asset diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e784e68f0f..653ba952fc 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -307,13 +307,7 @@ class ApplicationController < ActionController::Base resolve_safe_mode return if request.env[NO_CUSTOM] - theme_key = nil - if (k = request[:preview_theme_key]) && current_user - # some extra security, only to use the magic param the key needs to be whitelisted - if k == $redis.get(::Admin::ThemesController.whitelist_theme_key(current_user)) - theme_key = k - end - end + theme_key = flash[:preview_theme_key] user_option = current_user&.user_option From 4791b3977362de6f47ef36a82debf39bd38e9d14 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 23 Feb 2018 15:32:18 +0800 Subject: [PATCH 108/299] UX: Add reset password email button when confirming password before enabling 2FA. --- .../controllers/preferences/second-factor.js.es6 | 15 +++++++++++++++ .../templates/preferences-second-factor.hbs | 9 +++++++-- config/locales/client.en.yml | 2 +- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 index d66ee78360..f1d4bb0dbf 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 @@ -4,6 +4,8 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; export default Ember.Controller.extend({ loading: false, + resetPasswordLoading: false, + resetPasswordProgress: '', password: null, secondFactorImage: null, secondFactorKey: null, @@ -61,6 +63,19 @@ export default Ember.Controller.extend({ .finally(() => this.set('loading', false)); }, + resetPassword() { + this.setProperties({ + resetPasswordLoading: true, + resetPasswordProgress: '' + }); + + return this.get('model').changePassword().then(() => { + this.set('resetPasswordProgress', I18n.t('user.change_password.success')); + }) + .catch(popupAjaxError) + .finally(() => this.set('resetPasswordLoading', false)); + }, + showSecondFactorKey() { this.set('showSecondFactorKey', true); }, diff --git a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs index 13cbbc5da9..a1b6f7ef1a 100644 --- a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs +++ b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs @@ -91,7 +91,7 @@ {{text-field value=password id="password" type="password" - classNames="input-large" + classNames="input-xxlarge" autofocus="autofocus"}}
@@ -107,7 +107,12 @@ disabled=loading label=submitButtonText}} - {{#if saved}}{{i18n 'saved'}}{{/if}} + {{d-button action="resetPassword" + class="btn" + disabled=resetPasswordLoading + label='user.change_password.action'}} + + {{resetPasswordProgress}}
{{/if}} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 2fb1c74158..bf70f73c73 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -712,7 +712,7 @@ en: title: "Two Factor Authentication" enabled_status: "Status: On" disabled_status: "Status: Off" - confirm_password_description: "Confirm your password to continue enabling Two Factor Authentication." + confirm_password_description: "Confirm your password to continue enabling Two Factor Authentication" enable_description: | To complete Two Factor Authentication setup, scan the following QR code in one of the supported apps From 709f201bd4a7d6e1e02a1d84cf035d0628b31c50 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Fri, 23 Feb 2018 14:09:49 +0530 Subject: [PATCH 109/299] FIX: update group user count when bulk adding users --- app/models/group.rb | 10 ++++++++++ spec/models/group_spec.rb | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/app/models/group.rb b/app/models/group.rb index 6cc39fb1c6..ccdb0fe651 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -534,6 +534,16 @@ class Group < ActiveRecord::Base if user_attributes.present? User.where(id: user_ids).update_all(user_attributes) end + + # update group user count + Group.exec_sql <<-SQL.squish + UPDATE groups g + SET user_count = + (SELECT COUNT(gu.user_id) + FROM group_users gu + WHERE gu.group_id = g.id) + WHERE g.id = #{self.id}; + SQL end if self.grant_trust_level.present? diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 856b6fa0f5..23ccc1ca7b 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -619,6 +619,13 @@ describe Group do expect(group.group_users.map(&:user_id)).to contain_exactly(user.id, admin.id) end + + it 'updates group user count' do + expect { + group.bulk_add([user.id, admin.id]) + group.reload + }.to change { group.user_count }.by(2) + end end it "Correctly updates has_messages" do From ff12dee922df81e4c4a04b4d54108b14d40b64d9 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Fri, 23 Feb 2018 15:25:15 +0530 Subject: [PATCH 110/299] make rubocop happy --- spec/models/group_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 23ccc1ca7b..ee3faf189b 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -622,9 +622,9 @@ describe Group do it 'updates group user count' do expect { - group.bulk_add([user.id, admin.id]) - group.reload - }.to change { group.user_count }.by(2) + group.bulk_add([user.id, admin.id]) + group.reload + }.to change { group.user_count }.by(2) end end From 23498e54aa1d32ee2182552c27ae5b00b9b55eeb Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Fri, 23 Feb 2018 13:35:15 +0100 Subject: [PATCH 111/299] Fix the build --- spec/controllers/topic_controller_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/controllers/topic_controller_spec.rb b/spec/controllers/topic_controller_spec.rb index 6be013eb88..37e5c9cb30 100644 --- a/spec/controllers/topic_controller_spec.rb +++ b/spec/controllers/topic_controller_spec.rb @@ -33,7 +33,7 @@ describe TopicsController do get :show, params: { id: 666 } expect(controller.theme_key).to eq(theme.key) - theme.update_columns(user_selectable: false) + theme.update_attribute(:user_selectable, false) get :show, params: { id: 666 } expect(controller.theme_key).not_to eq(theme.key) From a1ea4776043cf35fbc9dcae078ef601671005720 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Fri, 23 Feb 2018 18:15:07 +0530 Subject: [PATCH 112/299] rescue error when cleaning avatars --- lib/tasks/avatars.rake | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/tasks/avatars.rake b/lib/tasks/avatars.rake index 5b2df506cf..f9bdf8e8b9 100644 --- a/lib/tasks/avatars.rake +++ b/lib/tasks/avatars.rake @@ -25,7 +25,11 @@ task "avatars:clean" => :environment do upload_id IN (SELECT gravatar_upload_id FROM user_avatars) OR upload_id IN (SELECT uploaded_avatar_id FROM users)") .find_each do |optimized_image| - optimized_image.destroy! + begin + optimized_image.destroy! + rescue + # skip + end putc "." if (i += 1) % 10 == 0 end From 43f0884660f1df1f6c04bd4d29bc9d76bf0534f5 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Fri, 23 Feb 2018 20:05:51 +0530 Subject: [PATCH 113/299] PERF: Remove N+1 queries on topic list page. --- app/serializers/concerns/topic_tags_mixin.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/serializers/concerns/topic_tags_mixin.rb b/app/serializers/concerns/topic_tags_mixin.rb index 2fdbc3deb6..6ad88b47ea 100644 --- a/app/serializers/concerns/topic_tags_mixin.rb +++ b/app/serializers/concerns/topic_tags_mixin.rb @@ -8,7 +8,8 @@ module TopicTagsMixin end def tags - topic.tags.pluck(:name) + # Calling method `pluck` along with `includes` causing N+1 queries + topic.tags.map(&:name) end def topic From d00118382832b7a32e44c24aaab1b920829b5bc0 Mon Sep 17 00:00:00 2001 From: Kris Date: Fri, 23 Feb 2018 11:44:49 -0500 Subject: [PATCH 114/299] Prevent timestamp modal calendar from overflowing container --- app/assets/stylesheets/common/base/modal.scss | 24 +++++++++++++++++++ app/assets/stylesheets/desktop/modal.scss | 16 ------------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 8fd78de468..50941f56af 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -402,3 +402,27 @@ margin-right: 0.5em; } } + + +.change-timestamp { + + .date-picker { + width: 10em; + } + + #date-container { + .pika-single { + position: relative !important; // overriding another important + display: inline-block; + } + } + + input[type=time] { + width: 6em; + } + + form { + margin: 0; + } + } + \ No newline at end of file diff --git a/app/assets/stylesheets/desktop/modal.scss b/app/assets/stylesheets/desktop/modal.scss index d950497110..5b35ce44dd 100644 --- a/app/assets/stylesheets/desktop/modal.scss +++ b/app/assets/stylesheets/desktop/modal.scss @@ -211,19 +211,3 @@ max-height: 150px; margin-bottom: 10px; } - -.change-timestamp { - min-height: 300px; - - .date-picker { - width: 10em; - } - - input[type=time] { - width: 6em; - } - - form { - margin: 0; - } -} From de30f3515bcdfc34ce21a8f16c3df1ad2f53e04e Mon Sep 17 00:00:00 2001 From: Kris Date: Fri, 23 Feb 2018 13:14:32 -0500 Subject: [PATCH 115/299] Updating modal headers to flexbox for better alignment --- app/assets/stylesheets/common/base/modal.scss | 10 ++++++++++ app/assets/stylesheets/desktop/modal.scss | 9 +-------- app/assets/stylesheets/mobile/modal.scss | 11 ++--------- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 50941f56af..03d2cd5517 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -27,7 +27,17 @@ } .modal-header { + display: flex; + align-items: center; + padding: 10px 15px; border-bottom: 1px solid $primary-low; + h3 { + margin-bottom: 0; + } + .modal-close { + order: 2; + margin-left: auto; + } } .modal-backdrop { diff --git a/app/assets/stylesheets/desktop/modal.scss b/app/assets/stylesheets/desktop/modal.scss index 5b35ce44dd..e49ae38813 100644 --- a/app/assets/stylesheets/desktop/modal.scss +++ b/app/assets/stylesheets/desktop/modal.scss @@ -35,19 +35,12 @@ margin-left: -1px; } -.modal-close { - display: inline-block; - float: right; - margin: 7px; -} - .modal-header { h3 { - display: inline-block;; font-size: $font-up-3; - padding: 10px 15px 7px; } } + .close { font-size: $font-up-3; text-decoration: none; diff --git a/app/assets/stylesheets/mobile/modal.scss b/app/assets/stylesheets/mobile/modal.scss index 2eb136e07f..7469f8876c 100644 --- a/app/assets/stylesheets/mobile/modal.scss +++ b/app/assets/stylesheets/mobile/modal.scss @@ -33,20 +33,13 @@ padding: 15px 7px 10px 7px; } -.modal-close { - display: inline-block; - float: right; -} - .modal-header { - padding: 10px 0 10px 10px; - + padding: 10px; h3 { - display: inline; font-size: $font-up-2; - margin: 0; } } + .close { font-size: $font-up-4; padding: 10px 15px 5px 5px; From bd892199e7138f006ee88785a18b982468b5b213 Mon Sep 17 00:00:00 2001 From: Kris Date: Fri, 23 Feb 2018 13:51:06 -0500 Subject: [PATCH 116/299] Increasing topic-admin menu width to prevent wrapping on mobile --- app/assets/stylesheets/common/base/topic-admin-menu.scss | 4 ++-- app/assets/stylesheets/common/base/topic.scss | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/common/base/topic-admin-menu.scss b/app/assets/stylesheets/common/base/topic-admin-menu.scss index b3fb88d090..a2ef7aaddd 100644 --- a/app/assets/stylesheets/common/base/topic-admin-menu.scss +++ b/app/assets/stylesheets/common/base/topic-admin-menu.scss @@ -10,7 +10,7 @@ .popup-menu { background-color: $secondary; - width: 205px; + width: 215px; padding: 10px; border: 1px solid $primary-low; z-index: z("dropdown"); @@ -26,7 +26,7 @@ } button { - width: 200px; + width: 100%; margin-bottom: 5px; i { width: 14px; diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss index d8b25930d3..e0cfaa11e2 100644 --- a/app/assets/stylesheets/common/base/topic.scss +++ b/app/assets/stylesheets/common/base/topic.scss @@ -35,13 +35,13 @@ .topic-admin-popup-menu.right-side { position: relative; - right: 35px; + right: 50px; } } #topic-progress-wrapper.docked { .topic-admin-popup-menu.right-side { - right: 40px; + right: 50px; } } From 82e68670bf5b798b5682dd8bd34273c7b321ebe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Sat, 24 Feb 2018 00:42:17 +0100 Subject: [PATCH 117/299] FIX: SimplePress importer wasn't handling increment imports properly --- script/import_scripts/simplepress.rb | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/script/import_scripts/simplepress.rb b/script/import_scripts/simplepress.rb index d5846a359a..0e4c5e22a0 100644 --- a/script/import_scripts/simplepress.rb +++ b/script/import_scripts/simplepress.rb @@ -99,8 +99,6 @@ class ImportScripts::SimplePress < ImportScripts::Base total_count = mysql_query("SELECT COUNT(*) count FROM #{TABLE_PREFIX}posts WHERE post_index = 1").first["count"] - @topic_first_post_id = {} - batches(BATCH_SIZE) do |offset| results = mysql_query(" SELECT p.post_id id, @@ -126,11 +124,10 @@ class ImportScripts::SimplePress < ImportScripts::Base next if all_records_exist? :posts, results.map { |m| m['id'].to_i } create_posts(results, total: total_count, offset: offset) do |m| - @topic_first_post_id[m['topic_id']] = m['id'] created_at = Time.zone.at(m['post_time']) { id: m['id'], - user_id: user_id_from_imported_user_id(m['user_id']) || -1, + user_id: user_id_from_imported_user_id(m['user_id']) || -1, raw: process_simplepress_post(m['raw'], m['id']), created_at: created_at, category: category_id_from_imported_category_id(m['category_id']), @@ -145,6 +142,15 @@ class ImportScripts::SimplePress < ImportScripts::Base def import_posts puts "", "creating posts" + topic_first_post_id = {} + + mysql_query(" + SELECT t.topic_id, p.post_id + FROM #{TABLE_PREFIX}topics t + JOIN #{TABLE_PREFIX}posts p ON p.topic_id = t.topic_id + WHERE p.post_index = 1 + ").each { |r| topic_first_post_id[r["topic_id"]] = r["post_id"] } + total_count = mysql_query("SELECT count(*) count FROM #{TABLE_PREFIX}posts WHERE post_index <> 1").first["count"] batches(BATCH_SIZE) do |offset| @@ -168,13 +174,13 @@ class ImportScripts::SimplePress < ImportScripts::Base next if all_records_exist? :posts, results.map { |m| m['id'].to_i } create_posts(results, total: total_count, offset: offset) do |m| - if parent = topic_lookup_from_imported_post_id(@topic_first_post_id[m['topic_id']]) + if parent = topic_lookup_from_imported_post_id(topic_first_post_id[m['topic_id']]) { id: m['id'], user_id: user_id_from_imported_user_id(m['user_id']) || -1, topic_id: parent[:topic_id], - raw: process_simplepress_post(m['raw'], m['id']), - created_at: Time.zone.at(m['post_time']), + raw: process_simplepress_post(m['raw'], m['id']), + created_at: Time.zone.at(m['post_time']), } else puts "Parent post #{m['topic_id']} doesn't exist. Skipping #{m["id"]}" From b731d5d9b5a317a1449b4feb8eab98225143d74a Mon Sep 17 00:00:00 2001 From: Kris Date: Fri, 23 Feb 2018 21:41:40 -0500 Subject: [PATCH 118/299] Removing unneeded and duplicate styles --- .../stylesheets/common/base/directory.scss | 31 +++++------ .../stylesheets/common/base/header.scss | 3 -- .../stylesheets/common/base/history.scss | 6 +++ app/assets/stylesheets/common/base/login.scss | 12 +++++ app/assets/stylesheets/common/base/modal.scss | 54 ++++++++++++++++++- app/assets/stylesheets/desktop.scss | 1 - app/assets/stylesheets/desktop/history.scss | 9 +--- app/assets/stylesheets/desktop/login.scss | 13 ----- .../stylesheets/desktop/menu-panel.scss | 12 ----- app/assets/stylesheets/desktop/modal.scss | 47 ---------------- app/assets/stylesheets/mobile.scss | 1 - app/assets/stylesheets/mobile/directory.scss | 20 +------ app/assets/stylesheets/mobile/emoji.scss | 2 - app/assets/stylesheets/mobile/faqs.scss | 3 +- app/assets/stylesheets/mobile/history.scss | 5 -- app/assets/stylesheets/mobile/login.scss | 10 +--- app/assets/stylesheets/mobile/menu-panel.scss | 7 --- app/assets/stylesheets/mobile/modal.scss | 44 +-------------- 18 files changed, 89 insertions(+), 191 deletions(-) delete mode 100644 app/assets/stylesheets/desktop/menu-panel.scss delete mode 100644 app/assets/stylesheets/mobile/menu-panel.scss diff --git a/app/assets/stylesheets/common/base/directory.scss b/app/assets/stylesheets/common/base/directory.scss index 0a03b20871..65114a11b5 100644 --- a/app/assets/stylesheets/common/base/directory.scss +++ b/app/assets/stylesheets/common/base/directory.scss @@ -1,10 +1,10 @@ .directory { margin-bottom: 100px; - + .user-info { margin-bottom: 0; } - + .period-chooser { float: left; } @@ -18,16 +18,16 @@ .spinner { clear: both; } - + table { width: 100%; margin-bottom: 1em; - + td, th { padding: 0.5em; text-align: left; border-bottom: 1px solid $primary-low; - + .number, .time-read { font-size: $font-up-3; color: $primary-medium; @@ -36,21 +36,10 @@ white-space: nowrap; } } - - tr.me { - td { - background-color: dark-light-choose($highlight-low, $highlight-medium); - - .username a, .name, .title, .number, .time-read { - color: $primary-medium; - } - } - } - + th.sortable { cursor: pointer; white-space: nowrap; - width: 13%; .d-icon-heart { color: $love; @@ -59,10 +48,16 @@ .d-icon-chevron-down, .d-icon-chevron-up { margin-left: 0.5em; } - + &:hover { background-color: $primary-low; } } } + .me { + background-color: dark-light-choose($highlight-low, $highlight-medium); + .username a, .name, .title, .number, .time-read { + color: $primary-medium; + } + } } diff --git a/app/assets/stylesheets/common/base/header.scss b/app/assets/stylesheets/common/base/header.scss index 4daaeafac0..7a9f1ce173 100644 --- a/app/assets/stylesheets/common/base/header.scss +++ b/app/assets/stylesheets/common/base/header.scss @@ -50,9 +50,6 @@ margin-left: 7px; } - .d-header-icons { - float: right; - } } .header-dropdown-toggle, .drop-down, .panel-body { diff --git a/app/assets/stylesheets/common/base/history.scss b/app/assets/stylesheets/common/base/history.scss index a1faeea65e..cb11152e06 100644 --- a/app/assets/stylesheets/common/base/history.scss +++ b/app/assets/stylesheets/common/base/history.scss @@ -24,6 +24,12 @@ } } + #revision-details { + padding: 5px; + margin-top: 10px; + border-bottom: 3px solid $primary-low; + } + #revisions .row:first-of-type { margin-top: 10px; } diff --git a/app/assets/stylesheets/common/base/login.scss b/app/assets/stylesheets/common/base/login.scss index 1c471da2f8..a5a68a84da 100644 --- a/app/assets/stylesheets/common/base/login.scss +++ b/app/assets/stylesheets/common/base/login.scss @@ -12,6 +12,18 @@ display: none; } +#login-form { + a { + color: dark-light-choose($primary-high, $secondary-low); + } + td { + padding-right: 5px; + } +} + +#new-account-link { + cursor: pointer; +} $label-width: 92px; $input-width: 220px; diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 03d2cd5517..2195c8e018 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -111,6 +111,7 @@ z-index: z("modal","content"); overflow: auto; } + .modal-form { margin-bottom: 0; } @@ -156,6 +157,8 @@ .modal-body { box-sizing: border-box; width: 100%; + overflow-y: auto; + max-height: 400px; &.full-height-modal { max-height: calc(100vh - 150px); } @@ -413,7 +416,6 @@ } } - .change-timestamp { .date-picker { @@ -434,5 +436,55 @@ form { margin: 0; } +} + +.flag-modal { + max-height: 450px; + .flag-action-type-details { + line-height: $line-height-large; } +} + +.flag-message { + width: 95% !important; + margin: 0; +} + +.custom-message-length { + color: dark-light-choose($primary-low-mid, $secondary-high); + font-size: $font-down-1; +} + +.edit-category-modal { + .secure-category-options { + margin: 10px 0 0 16px; + .badge-list { + margin: 10px 0; + li { + margin: 0 4px 8px 0; + a { + color: dark-light-choose($primary-medium, $secondary-medium); + cursor: pointer; + } + a:hover { + color: dark-light-choose($primary-medium, $secondary-medium); + } + } + } + } +} + +.tabbed-modal { + .modal-body { + position: relative; + height: 350px; + } +} + + +.modal-tab { + position: absolute; + width: 95%; +} + \ No newline at end of file diff --git a/app/assets/stylesheets/desktop.scss b/app/assets/stylesheets/desktop.scss index 8534c2f783..e11252ac35 100644 --- a/app/assets/stylesheets/desktop.scss +++ b/app/assets/stylesheets/desktop.scss @@ -19,7 +19,6 @@ @import "desktop/user"; @import "desktop/history"; @import "desktop/queued-posts"; -@import "desktop/menu-panel"; @import "desktop/group"; // Import all component-specific files diff --git a/app/assets/stylesheets/desktop/history.scss b/app/assets/stylesheets/desktop/history.scss index 8481485d10..3192d0b9b2 100644 --- a/app/assets/stylesheets/desktop/history.scss +++ b/app/assets/stylesheets/desktop/history.scss @@ -7,9 +7,6 @@ max-width: 960px; } #revision-controls { - float: left; - padding-right: 5px; - .btn[disabled] { cursor: not-allowed; background-color: $primary-low; @@ -32,11 +29,7 @@ font-weight: bold; } } - #revision-details { - padding: 5px; - margin-top: 10px; - border-bottom: 3px solid $primary-low; - } + #revisions { word-wrap: break-word; table { diff --git a/app/assets/stylesheets/desktop/login.scss b/app/assets/stylesheets/desktop/login.scss index 3e7e61efcc..b7ce599cb8 100644 --- a/app/assets/stylesheets/desktop/login.scss +++ b/app/assets/stylesheets/desktop/login.scss @@ -12,21 +12,8 @@ margin-bottom: 20px; } -#login-form { - a { - color: dark-light-choose($primary-high, $secondary-low); - } - td { - padding-right: 5px; - } -} - // Create account -#new-account-link { - cursor: pointer; -} - .create-account { form { margin-bottom: 0; diff --git a/app/assets/stylesheets/desktop/menu-panel.scss b/app/assets/stylesheets/desktop/menu-panel.scss deleted file mode 100644 index 74266de4cf..0000000000 --- a/app/assets/stylesheets/desktop/menu-panel.scss +++ /dev/null @@ -1,12 +0,0 @@ -.docked #hamburger-menu { - position: fixed; -} - -#hamburger-menu { - position: absolute; - top: 63px; - // compensate on the other end for this top - .hamburger-body { - bottom: 100px; - } -} diff --git a/app/assets/stylesheets/desktop/modal.scss b/app/assets/stylesheets/desktop/modal.scss index e49ae38813..2a0fbf8d98 100644 --- a/app/assets/stylesheets/desktop/modal.scss +++ b/app/assets/stylesheets/desktop/modal.scss @@ -22,8 +22,6 @@ } .modal-body { - overflow-y: auto; - max-height: 400px; padding: 15px; } @@ -69,23 +67,6 @@ } } -.flag-modal { - max-height: 450px; - .flag-action-type-details { - line-height: $line-height-large; - } -} - -.custom-message-length { - color: dark-light-choose($primary-low-mid, $secondary-high); - font-size: $font-down-1; -} - -.flag-message { - width: 95%; - margin: 0; -} - .edit-category-modal { .modal-body { position: relative; @@ -93,22 +74,6 @@ max-height: 420px; padding-bottom: 0; } - .secure-category-options { - margin: 10px 0 0 16px; - .badge-list { - margin: 10px 0; - li { - margin: 0 4px 8px 0; - a { - color: dark-light-choose($primary-medium, $secondary-medium); - cursor: pointer; - } - a:hover { - color: dark-light-choose($primary-medium, $secondary-medium); - } - } - } - } .disable_info_wrap { margin-top: -70px; @@ -147,18 +112,6 @@ max-width: 23%; } } - -} -.tabbed-modal { - .modal-body { - position: relative; - height: 350px; - } -} - -.modal-tab { - position: absolute; - width: 95%; } .split-modal { diff --git a/app/assets/stylesheets/mobile.scss b/app/assets/stylesheets/mobile.scss index 08abaaf2ae..66c13c4f5c 100644 --- a/app/assets/stylesheets/mobile.scss +++ b/app/assets/stylesheets/mobile.scss @@ -18,7 +18,6 @@ @import "mobile/user"; @import "mobile/history"; @import "mobile/directory"; -@import "mobile/menu-panel"; @import "mobile/search"; @import "mobile/emoji"; @import "mobile/ring"; diff --git a/app/assets/stylesheets/mobile/directory.scss b/app/assets/stylesheets/mobile/directory.scss index 472345a79e..15fdb95165 100644 --- a/app/assets/stylesheets/mobile/directory.scss +++ b/app/assets/stylesheets/mobile/directory.scss @@ -1,36 +1,18 @@ -.user-controls { - padding: 1em; -} - -.total-rows { - padding: 0.25em 0.5em; -} - .directory .user { border-top: 1px solid $primary-low; padding: 1em; - - &.me { - background-color: dark-light-choose($highlight-low, $highlight-medium); - - .username a, .name, .title, .number, .time-read, .user-stat .label { - color: scale-color($highlight, $lightness: -50%); - } - } .user-stat { margin-left: 55px; - .value { font-weight: bold; } .label { margin-left: 0.2em; - color: blend-primary-secondary(50%); + color: $primary-medium; } .d-icon-heart { color: $love; } } - margin-bottom: 1em; } diff --git a/app/assets/stylesheets/mobile/emoji.scss b/app/assets/stylesheets/mobile/emoji.scss index 34d67e4c01..3bd4ef1ae0 100644 --- a/app/assets/stylesheets/mobile/emoji.scss +++ b/app/assets/stylesheets/mobile/emoji.scss @@ -1,6 +1,4 @@ .emoji-picker { - box-shadow: none; height: 250px; - border-radius: 0; border: none; } diff --git a/app/assets/stylesheets/mobile/faqs.scss b/app/assets/stylesheets/mobile/faqs.scss index 4fdfda90ba..4e34e9b2cf 100644 --- a/app/assets/stylesheets/mobile/faqs.scss +++ b/app/assets/stylesheets/mobile/faqs.scss @@ -4,7 +4,6 @@ .body-page { margin-top: 20px; - margin-left: 15px; - width: 90%; + width: 100%; padding-left: 0; } \ No newline at end of file diff --git a/app/assets/stylesheets/mobile/history.scss b/app/assets/stylesheets/mobile/history.scss index 93a2a29e7e..bae7149be0 100644 --- a/app/assets/stylesheets/mobile/history.scss +++ b/app/assets/stylesheets/mobile/history.scss @@ -8,11 +8,6 @@ #revision-numbers { line-height: $line-height-large; } - #revision-details { - background-color: $primary-low; - padding: 5px; - margin-top: 10px; - } img { max-width: 95%; height: auto; diff --git a/app/assets/stylesheets/mobile/login.scss b/app/assets/stylesheets/mobile/login.scss index 53d8e7b41a..e7b346f9ac 100644 --- a/app/assets/stylesheets/mobile/login.scss +++ b/app/assets/stylesheets/mobile/login.scss @@ -15,9 +15,7 @@ } #login-form { - a { - color: dark-light-choose($primary-high, $secondary-low); - } + label { float: left; display: block; } textarea, input, select { font-size: $font-up-1; @@ -31,12 +29,6 @@ a#new-account-link { white-space:nowrap; } // Create account -#new-account-link { - cursor: pointer; -} - -a#forgot-password-link {clear: left; float: left; } - .login-modal, .create-account { .btn-primary { margin-bottom: 10px; diff --git a/app/assets/stylesheets/mobile/menu-panel.scss b/app/assets/stylesheets/mobile/menu-panel.scss deleted file mode 100644 index 972ea30ab2..0000000000 --- a/app/assets/stylesheets/mobile/menu-panel.scss +++ /dev/null @@ -1,7 +0,0 @@ -.menu-panel { - span.badge-category { - max-width: 85px; - overflow: hidden; - text-overflow: ellipsis; - } -} diff --git a/app/assets/stylesheets/mobile/modal.scss b/app/assets/stylesheets/mobile/modal.scss index 7469f8876c..7d2e19e7b3 100644 --- a/app/assets/stylesheets/mobile/modal.scss +++ b/app/assets/stylesheets/mobile/modal.scss @@ -22,8 +22,6 @@ top: 50%; } .modal-body { - overflow-y: auto; - max-height: 400px; padding: 10px; } @@ -70,27 +68,12 @@ } } -.flag-modal { - max-height: 450px; -} - @media only screen and (max-device-width: 568px) { .modal .flag-modal .flag-message { - height: 1.2em; + height: 3em; } } -.custom-message-length { - margin: -4px 0 10px 20px; - color: dark-light-choose($primary-high, $secondary-low); - font-size: $font-down-1; -} - -.flag-message { - margin-left: 20px; - width: 95% !important; -} - .edit-category-modal { .modal-body { box-sizing: border-box; @@ -100,19 +83,6 @@ &.small .modal-body { height: 310px; } - .secure-category-options { - margin: 10px 0 0 16px; - .badge-list { - margin: 10px 0; - li { - margin: 0 4px 8px 0; - a { - color: #888; - cursor: pointer; - } - } - } - } .disable_info_wrap .cannot_delete_reason { top: -114px; @@ -121,18 +91,6 @@ } } -.tabbed-modal { - .modal-body { - position: relative; - height: 350px; - } -} - - -.modal-tab { - position: absolute; -} - /* fixes for the new account confirm dialog on mobile */ .modal-inner-container { From 0559a4736a1dfa64908cd86bcc8e80e792598890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Sat, 24 Feb 2018 12:35:57 +0100 Subject: [PATCH 119/299] FIX: don't double request when downloading a file --- app/controllers/static_controller.rb | 8 ++- lib/file_helper.rb | 66 +++++++++---------- lib/final_destination.rb | 30 +++------ spec/components/cooked_post_processor_spec.rb | 4 +- spec/components/file_helper_spec.rb | 2 - spec/components/final_destination_spec.rb | 13 ++-- spec/components/inline_oneboxer_spec.rb | 10 +-- spec/components/oneboxer_spec.rb | 2 +- spec/components/pretty_text_spec.rb | 11 +--- spec/controllers/onebox_controller_spec.rb | 7 +- spec/controllers/uploads_controller_spec.rb | 17 +++-- .../user_avatars_controller_spec.rb | 1 - spec/jobs/poll_feed_spec.rb | 6 +- spec/jobs/pull_hotlinked_images_spec.rb | 8 +-- spec/jobs/update_gravatar_spec.rb | 1 - spec/models/user_avatar_spec.rb | 2 +- spec/requests/static_controller_spec.rb | 24 ++----- 17 files changed, 80 insertions(+), 132 deletions(-) diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb index 069562e3e3..356214fbc2 100644 --- a/app/controllers/static_controller.rb +++ b/app/controllers/static_controller.rb @@ -94,6 +94,8 @@ class StaticController < ApplicationController redirect_to destination end + FAVICON ||= -"favicon" + # We need to be able to draw our favicon on a canvas # and pull it off the canvas into a data uri # This can work by ensuring people set all the right CORS @@ -101,16 +103,16 @@ class StaticController < ApplicationController # instead we cache the favicon in redis and serve it out real quick with # a huge expiry, we also cache these assets in nginx so it bypassed if needed def favicon - hijack do - data = DistributedMemoizer.memoize('favicon' + SiteSetting.favicon_url, 60 * 30) do + data = DistributedMemoizer.memoize(FAVICON + SiteSetting.favicon_url, 60 * 30) do begin file = FileHelper.download( SiteSetting.favicon_url, max_file_size: 50.kilobytes, - tmp_file_name: "favicon.png", + tmp_file_name: FAVICON, follow_redirect: true ) + file ||= Tempfile.new([FAVICON, ".png"]) data = file.read file.unlink data diff --git a/lib/file_helper.rb b/lib/file_helper.rb index c8039bb9c5..7ef5952390 100644 --- a/lib/file_helper.rb +++ b/lib/file_helper.rb @@ -25,57 +25,55 @@ class FileHelper follow_redirect: false, read_timeout: 5, skip_rate_limit: false, - verbose: nil) - - # verbose logging is default while debugging onebox - verbose = verbose.nil? ? true : verbose + verbose: false) url = "https:" + url if url.start_with?("//") raise Discourse::InvalidParameters.new(:url) unless url =~ /^https?:\/\// - dest = FinalDestination.new( + tmp = nil + + fd = FinalDestination.new( url, max_redirects: follow_redirect ? 5 : 1, skip_rate_limit: skip_rate_limit, verbose: verbose ) - uri = dest.resolve - if !uri && dest.status_code.to_i >= 400 - # attempt error API compatability - io = FakeIO.new - io.status = [dest.status_code.to_s, ""] + fd.get do |response, chunk, uri| + if tmp.nil? + # error handling + if uri.blank? + if response.code.to_i >= 400 + # attempt error API compatibility + io = FakeIO.new + io.status = [response.code, ""] + raise OpenURI::HTTPError.new("#{response.code} Error", io) + else + log(:error, "FinalDestination did not work for: #{url}") if verbose + throw :done + end + end - # TODO perhaps translate and add Discourse::DownloadError - raise OpenURI::HTTPError.new("#{dest.status_code} Error", io) - end + # first run + tmp_file_ext = File.extname(uri.path) - unless uri - log(:error, "FinalDestination did not work for: #{url}") if verbose - return - end + if tmp_file_ext.blank? && response.content_type.present? + ext = MiniMime.lookup_by_content_type(response.content_type)&.extension + ext = "jpg" if ext == "jpe" + tmp_file_ext = "." + ext if ext.present? + end - downloaded = uri.open("rb", read_timeout: read_timeout) - - extension = File.extname(uri.path) - - if extension.blank? && downloaded.content_type.present? - ext = MiniMime.lookup_by_content_type(downloaded.content_type)&.extension - ext = "jpg" if ext == "jpe" - extension = "." + ext if ext.present? - end - - tmp = Tempfile.new([tmp_file_name, extension]) - - File.open(tmp.path, "wb") do |f| - while f.size <= max_file_size && data = downloaded.read(512.kilobytes) - f.write(data) + tmp = Tempfile.new([tmp_file_name, tmp_file_ext]) + tmp.binmode end + + tmp.write(chunk) + + throw :done if tmp.size > max_file_size end + tmp&.rewind tmp - ensure - downloaded&.close end def self.optimize_image!(filename) diff --git a/lib/final_destination.rb b/lib/final_destination.rb index a1180b4618..00e93b90a9 100644 --- a/lib/final_destination.rb +++ b/lib/final_destination.rb @@ -96,9 +96,7 @@ class FinalDestination uri = URI(uri.to_s) end - unless validate_uri - return nil - end + return nil unless validate_uri result, (location, cookie) = safe_get(uri, &blk) @@ -120,9 +118,7 @@ class FinalDestination return nil if !uri extra = nil - if cookie - extra = { 'Cookie' => cookie } - end + extra = { 'Cookie' => cookie } if cookie get(uri, redirects - 1, extra_headers: extra, &blk) elsif result == :ok @@ -251,7 +247,6 @@ class FinalDestination end def is_dest_valid? - return false unless @uri && @uri.host # Whitelisted hosts @@ -260,9 +255,7 @@ class FinalDestination hostname_matches?(Discourse.base_url_no_prefix) if SiteSetting.whitelist_internal_hosts.present? - SiteSetting.whitelist_internal_hosts.split('|').each do |h| - return true if @uri.hostname.downcase == h.downcase - end + return true if SiteSetting.whitelist_internal_hosts.split("|").any? { |h| h.downcase == @uri.hostname.downcase } end address_s = @opts[:lookup_ip].call(@uri.hostname) @@ -331,12 +324,10 @@ class FinalDestination protected def safe_get(uri) - result = nil unsafe_close = false safe_session(uri) do |http| - headers = request_headers.merge( 'Accept-Encoding' => 'gzip', 'Host' => uri.host @@ -350,9 +341,8 @@ class FinalDestination end if Net::HTTPSuccess === resp - resp.decode_content = true - resp.read_body { |chunk| + resp.read_body do |chunk| read_next = true catch(:done) do @@ -370,19 +360,19 @@ class FinalDestination http.finish raise StandardError end - } + end result = :ok + else + catch(:done) do + yield resp, nil, nil + end end end end result rescue StandardError - if unsafe_close - :ok - else - raise - end + unsafe_close ? :ok : raise end def safe_session(uri) diff --git a/spec/components/cooked_post_processor_spec.rb b/spec/components/cooked_post_processor_spec.rb index 618b69a349..74c2ba7f8d 100644 --- a/spec/components/cooked_post_processor_spec.rb +++ b/spec/components/cooked_post_processor_spec.rb @@ -507,8 +507,8 @@ describe CookedPostProcessor do HTML - stub_request(:head, url).to_return(status: 200) - stub_request(:get , url).to_return(status: 200, body: body) + stub_request(:head, url) + stub_request(:get , url).to_return(body: body) FinalDestination.stubs(:lookup_ip).returns('1.2.3.4') # not an ideal stub but shipping the whole image to fast image can add diff --git a/spec/components/file_helper_spec.rb b/spec/components/file_helper_spec.rb index d5d0ccd86b..2ba6cb405e 100644 --- a/spec/components/file_helper_spec.rb +++ b/spec/components/file_helper_spec.rb @@ -15,7 +15,6 @@ describe FileHelper do it "correctly raises an OpenURI HTTP error if it gets a 404 even with redirect" do url = "http://fourohfour.com/404" - stub_request(:head, url).to_return(status: 404, body: "404") stub_request(:get, url).to_return(status: 404, body: "404") expect do @@ -36,7 +35,6 @@ describe FileHelper do it "correctly raises an OpenURI HTTP error if it gets a 404" do url = "http://fourohfour.com/404" - stub_request(:head, url).to_return(status: 404, body: "404") stub_request(:get, url).to_return(status: 404, body: "404") expect do diff --git a/spec/components/final_destination_spec.rb b/spec/components/final_destination_spec.rb index aa5364c5b1..5cb93e533e 100644 --- a/spec/components/final_destination_spec.rb +++ b/spec/components/final_destination_spec.rb @@ -176,7 +176,7 @@ describe FinalDestination do 'Set-Cookie' => 'evil=trout' } ) - stub_request(:head, 'https://discourse.org').to_return(status: 200) + stub_request(:head, 'https://discourse.org') end context "when the status code is 405" do @@ -218,7 +218,6 @@ describe FinalDestination do stub_request(:head, "https://eviltrout.com") .with(headers: { "Cookie" => "bar=1; baz=2; foo=219ffwef9w0f" }) - .to_return(status: 200, body: "") final = FinalDestination.new("https://eviltrout.com", opts) expect(final.resolve.to_s).to eq("https://eviltrout.com") @@ -229,14 +228,13 @@ describe FinalDestination do it "should use the correct format for cookies when there is only one cookie" do stub_request(:head, "https://eviltrout.com") - .to_return(status: 302, body: "" , headers: { + .to_return(status: 302, headers: { "Location" => "https://eviltrout.com", "Set-Cookie" => "foo=219ffwef9w0f; expires=Mon, 19-Feb-2018 10:44:24 GMT; path=/; domain=eviltrout.com" }) stub_request(:head, "https://eviltrout.com") .with(headers: { "Cookie" => "foo=219ffwef9w0f" }) - .to_return(status: 200, body: "") final = FinalDestination.new("https://eviltrout.com", opts) expect(final.resolve.to_s).to eq("https://eviltrout.com") @@ -246,7 +244,7 @@ describe FinalDestination do it "should use the correct format for cookies when there are multiple cookies" do stub_request(:head, "https://eviltrout.com") - .to_return(status: 302, body: "" , headers: { + .to_return(status: 302, headers: { "Location" => "https://eviltrout.com", "Set-Cookie" => ["foo=219ffwef9w0f; expires=Mon, 19-Feb-2018 10:44:24 GMT; path=/; domain=eviltrout.com", "bar=1", @@ -255,7 +253,6 @@ describe FinalDestination do stub_request(:head, "https://eviltrout.com") .with(headers: { "Cookie" => "bar=1; baz=2; foo=219ffwef9w0f" }) - .to_return(status: 200, body: "") final = FinalDestination.new("https://eviltrout.com", opts) expect(final.resolve.to_s).to eq("https://eviltrout.com") @@ -267,7 +264,6 @@ describe FinalDestination do describe '.get' do it "can correctly stream with a redirect" do - FinalDestination.clear_https_cache!("wikipedia.com") stub_request(:get, "http://wikipedia.com/"). @@ -399,13 +395,12 @@ describe FinalDestination do stub_request(:head, "http://wikipedia.com/image.png") .to_return(status: 302, body: "", headers: { location: 'https://wikipedia.com/image.png' }) + stub_request(:head, "https://wikipedia.com/image.png") - .to_return(status: 200, body: "", headers: []) fd('http://wikipedia.com/image.png').resolve stub_request(:head, "https://wikipedia.com/image2.png") - .to_return(status: 200, body: "", headers: []) fd('http://wikipedia.com/image2.png').resolve end diff --git a/spec/components/inline_oneboxer_spec.rb b/spec/components/inline_oneboxer_spec.rb index 33db691bf0..1779a7fbfa 100644 --- a/spec/components/inline_oneboxer_spec.rb +++ b/spec/components/inline_oneboxer_spec.rb @@ -71,11 +71,8 @@ describe InlineOneboxer do it "will crawl anything if allowed to" do SiteSetting.enable_inline_onebox_on_all_domains = true - # Final destination does a HEAD and a GET - stub_request(:head, "https://eviltrout.com/some-path").to_return(status: 200) - stub_request(:get, "https://eviltrout.com/some-path"). - to_return(status: 200, body: "a blog", headers: {}) + to_return(status: 200, body: "a blog") onebox = InlineOneboxer.lookup( "https://eviltrout.com/some-path", @@ -90,11 +87,8 @@ describe InlineOneboxer do it "will not return a onebox if it does not meet minimal length" do SiteSetting.enable_inline_onebox_on_all_domains = true - # Final destination does a HEAD and a GET - stub_request(:head, "https://eviltrout.com/some-path").to_return(status: 200) - stub_request(:get, "https://eviltrout.com/some-path"). - to_return(status: 200, body: "a", headers: {}) + to_return(status: 200, body: "a") onebox = InlineOneboxer.lookup( "https://eviltrout.com/some-path", diff --git a/spec/components/oneboxer_spec.rb b/spec/components/oneboxer_spec.rb index b6637d30f2..03525ff10a 100644 --- a/spec/components/oneboxer_spec.rb +++ b/spec/components/oneboxer_spec.rb @@ -4,8 +4,8 @@ require_dependency 'oneboxer' describe Oneboxer do it "returns blank string for an invalid onebox" do + stub_request(:head, "http://boom.com") stub_request(:get, "http://boom.com").to_return(body: "") - stub_request(:head, "http://boom.com").to_return(body: "") expect(Oneboxer.preview("http://boom.com")).to eq("") expect(Oneboxer.onebox("http://boom.com")).to eq("") diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb index 487ec56968..6dc422dabc 100644 --- a/spec/components/pretty_text_spec.rb +++ b/spec/components/pretty_text_spec.rb @@ -952,10 +952,8 @@ HTML SiteSetting.enable_inline_onebox_on_all_domains = true InlineOneboxer.purge("http://cnn.com/a") - stub_request(:head, "http://cnn.com/a").to_return(status: 200) - stub_request(:get, "http://cnn.com/a"). - to_return(status: 200, body: "news", headers: {}) + to_return(status: 200, body: "news") expect(PrettyText.cook("- http://cnn.com/a\n- a http://cnn.com/a").split("news").length).to eq(3) expect(PrettyText.cook("- http://cnn.com/a\n - a http://cnn.com/a").split("news").length).to eq(3) @@ -965,10 +963,8 @@ HTML SiteSetting.enable_inline_onebox_on_all_domains = true InlineOneboxer.purge("http://cnn.com?a") - stub_request(:head, "http://cnn.com?a").to_return(status: 200) - stub_request(:get, "http://cnn.com?a"). - to_return(status: 200, body: "news", headers: {}) + to_return(status: 200, body: "news") expect(PrettyText.cook("- http://cnn.com?a\n- a http://cnn.com?a").split("news").length).to eq(3) expect(PrettyText.cook("- http://cnn.com?a\n - a http://cnn.com?a").split("news").length).to eq(3) @@ -981,9 +977,8 @@ HTML SiteSetting.enable_inline_onebox_on_all_domains = true InlineOneboxer.purge("http://cnn.com/") - stub_request(:head, "http://cnn.com/").to_return(status: 200) stub_request(:get, "http://cnn.com/"). - to_return(status: 200, body: "news", headers: {}) + to_return(status: 200, body: "news") expect(PrettyText.cook("- http://cnn.com/\n- a http://cnn.com/").split("news").length).to eq(1) expect(PrettyText.cook("- cnn.com\n - a http://cnn.com/").split("news").length).to eq(1) diff --git a/spec/controllers/onebox_controller_spec.rb b/spec/controllers/onebox_controller_spec.rb index 4a8cb5b9bf..9e2008c848 100644 --- a/spec/controllers/onebox_controller_spec.rb +++ b/spec/controllers/onebox_controller_spec.rb @@ -35,11 +35,8 @@ describe OneboxController do url = "http://noodle.com/" - stub_request(:head, url). - to_return(status: 200, body: "", headers: {}).then.to_raise - - stub_request(:get, url) - .to_return(status: 200, headers: {}, body: onebox_html).then.to_raise + stub_request(:head, url) + stub_request(:get, url).to_return(body: onebox_html).then.to_raise get :show, params: { url: url, refresh: "true" }, format: :json diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index abf27ceb47..a98cd9ebe7 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -59,21 +59,20 @@ describe UploadsController do expect(id).to be end - it 'is successful with synchronous api' do + it 'is successful with api' do SiteSetting.authorized_extensions = "*" controller.stubs(:is_api?).returns(true) + FinalDestination.stubs(:lookup_ip).returns("1.2.3.4") + Jobs.expects(:enqueue).with(:create_avatar_thumbnails, anything) - stub_request(:head, 'http://example.com/image.png') - stub_request(:get, "http://example.com/image.png").to_return(body: File.read('spec/fixtures/images/logo.png')) + url = "http://example.com/image.png" + png = File.read(Rails.root + "spec/fixtures/images/logo.png") - post :create, params: { - url: 'http://example.com/image.png', - type: "avatar", - synchronous: true, - format: :json - } + stub_request(:get, url).to_return(status: 200, body: png) + + post :create, params: { url: url, type: "avatar", format: :json } json = ::JSON.parse(response.body) diff --git a/spec/controllers/user_avatars_controller_spec.rb b/spec/controllers/user_avatars_controller_spec.rb index 57a9a2cdf3..7218cd8a06 100644 --- a/spec/controllers/user_avatars_controller_spec.rb +++ b/spec/controllers/user_avatars_controller_spec.rb @@ -31,7 +31,6 @@ describe UserAvatarsController do SiteSetting.s3_upload_bucket = "test" SiteSetting.s3_cdn_url = "http://cdn.com" - stub_request(:head, "http://cdn.com/something/else") stub_request(:get, "http://cdn.com/something/else").to_return(body: 'image') GlobalSetting.expects(:cdn_url).returns("http://awesome.com/boom") diff --git a/spec/jobs/poll_feed_spec.rb b/spec/jobs/poll_feed_spec.rb index 5ba0383068..e3b5c6a203 100644 --- a/spec/jobs/poll_feed_spec.rb +++ b/spec/jobs/poll_feed_spec.rb @@ -99,9 +99,8 @@ describe Jobs::PollFeed do SiteSetting.feed_polling_url = 'https://blog.discourse.org/feed/' SiteSetting.embed_by_username = embed_by_username - stub_request(:head, SiteSetting.feed_polling_url).to_return(status: 200) + stub_request(:head, SiteSetting.feed_polling_url) stub_request(:get, SiteSetting.feed_polling_url).to_return( - status: 200, body: file_from_fixtures('feed.rss', 'feed').read, headers: { "Content-Type" => "application/rss+xml" } ) @@ -116,9 +115,8 @@ describe Jobs::PollFeed do SiteSetting.feed_polling_url = 'https://blog.discourse.org/feed/atom/' SiteSetting.embed_by_username = embed_by_username - stub_request(:head, SiteSetting.feed_polling_url).to_return(status: 200) + stub_request(:head, SiteSetting.feed_polling_url) stub_request(:get, SiteSetting.feed_polling_url).to_return( - status: 200, body: file_from_fixtures('feed.atom', 'feed').read, headers: { "Content-Type" => "application/atom+xml" } ) diff --git a/spec/jobs/pull_hotlinked_images_spec.rb b/spec/jobs/pull_hotlinked_images_spec.rb index 0c8ee74c43..371b81f4e4 100644 --- a/spec/jobs/pull_hotlinked_images_spec.rb +++ b/spec/jobs/pull_hotlinked_images_spec.rb @@ -11,10 +11,8 @@ describe Jobs::PullHotlinkedImages do before do stub_request(:get, image_url).to_return(body: png, headers: { "Content-Type" => "image/png" }) - stub_request(:head, image_url) - stub_request(:head, broken_image_url).to_return(status: 404) + stub_request(:get, broken_image_url).to_return(status: 404) stub_request(:get, large_image_url).to_return(body: large_png, headers: { "Content-Type" => "image/png" }) - stub_request(:head, large_image_url) SiteSetting.crawl_images = true SiteSetting.download_remote_images_to_local = true SiteSetting.max_image_size_kb = 2 @@ -59,7 +57,6 @@ describe Jobs::PullHotlinkedImages do it 'replaces images without extension' do url = image_url.sub(/\.[a-zA-Z0-9]+$/, '') stub_request(:get, url).to_return(body: png, headers: { "Content-Type" => "image/png" }) - stub_request(:head, url) post = Fabricate(:post, raw: "") Jobs::PullHotlinkedImages.new.execute(post_id: post.id) @@ -75,8 +72,8 @@ describe Jobs::PullHotlinkedImages do before do SiteSetting.queue_jobs = true - stub_request(:get, url).to_return(body: '') stub_request(:head, url) + stub_request(:get, url).to_return(body: '') stub_request(:get, api_url).to_return(body: "{ \"query\": { \"pages\": { @@ -91,7 +88,6 @@ describe Jobs::PullHotlinkedImages do } } }") - stub_request(:head, api_url) end it 'replaces image src' do diff --git a/spec/jobs/update_gravatar_spec.rb b/spec/jobs/update_gravatar_spec.rb index 096a4d6b3c..6dcd52876e 100644 --- a/spec/jobs/update_gravatar_spec.rb +++ b/spec/jobs/update_gravatar_spec.rb @@ -9,7 +9,6 @@ describe Jobs::UpdateGravatar do png = Base64.decode64("R0lGODlhAQABALMAAAAAAIAAAACAAICAAAAAgIAAgACAgMDAwICAgP8AAAD/AP//AAAA//8A/wD//wBiZCH5BAEAAA8ALAAAAAABAAEAAAQC8EUAOw==") url = "https://www.gravatar.com/avatar/d10ca8d11301c2f4993ac2279ce4b930.png?s=360&d=404" - stub_request(:head, url).to_return(status: 200) stub_request(:get, url).to_return(body: png) SiteSetting.automatically_download_gravatars = true diff --git a/spec/models/user_avatar_spec.rb b/spec/models/user_avatar_spec.rb index e76da53807..eb7f723064 100644 --- a/spec/models/user_avatar_spec.rb +++ b/spec/models/user_avatar_spec.rb @@ -47,7 +47,7 @@ describe UserAvatar do describe 'when avatar url returns an invalid status code' do it 'should not do anything' do url = "http://thisfakesomething.something.com/" - stub_request(:head, url).to_return(status: 404) + UserAvatar.import_url_for_user(url, user) user.reload diff --git a/spec/requests/static_controller_spec.rb b/spec/requests/static_controller_spec.rb index f4376253ac..624bdadb2c 100644 --- a/spec/requests/static_controller_spec.rb +++ b/spec/requests/static_controller_spec.rb @@ -3,20 +3,14 @@ require 'rails_helper' describe StaticController do context '#favicon' do - before do - # this is a mess in test, will fix in a future commit - FinalDestination.stubs(:lookup_ip).returns('1.2.3.4') - end - let(:png) { Base64.decode64("R0lGODlhAQABALMAAAAAAIAAAACAAICAAAAAgIAAgACAgMDAwICAgP8AAAD/AP//AAAA//8A/wD//wBiZCH5BAEAAA8ALAAAAAABAAEAAAQC8EUAOw==") } + before { FinalDestination.stubs(:lookup_ip).returns("1.2.3.4") } + it 'returns the default favicon for a missing download' do + url = "https://fav.icon/#{SecureRandom.hex}.png" - url = "https://somewhere1.over.rainbow/#{SecureRandom.hex}.png" - - stub_request(:head, url). - with(headers: { 'Host' => 'somewhere1.over.rainbow' }). - to_return(status: 404, body: "", headers: {}) + stub_request(:get, url).to_return(status: 404) SiteSetting.favicon_url = url @@ -30,14 +24,9 @@ describe StaticController do end it 'can proxy a favicon correctly' do - url = "https://somewhere.over.rainbow/#{SecureRandom.hex}.png" + url = "https://fav.icon/#{SecureRandom.hex}.png" - stub_request(:head, url). - with(headers: { 'Host' => 'somewhere.over.rainbow' }). - to_return(status: 200, body: "", headers: {}) - - stub_request(:get, url). - to_return(status: 200, body: png, headers: {}) + stub_request(:get, url).to_return(status: 200, body: png) SiteSetting.favicon_url = url @@ -51,7 +40,6 @@ describe StaticController do context '#brotli_asset' do it 'returns a non brotli encoded 404 if asset is missing' do - get "/brotli_asset/missing.js" expect(response.status).to eq(404) From aa990604c552d0ac7e8aee9cc5f6174b48d441a9 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Sat, 24 Feb 2018 17:11:04 +0100 Subject: [PATCH 120/299] Adds :puke: as alias to :face_vomiting: --- lib/emoji/db.json | 3 +++ lib/tasks/emoji.rake | 1 + public/images/emoji/apple/puke.png | Bin 0 -> 7503 bytes public/images/emoji/emoji_one/puke.png | Bin 0 -> 1763 bytes public/images/emoji/facebook_messenger/puke.png | Bin 0 -> 1198 bytes public/images/emoji/google/puke.png | Bin 0 -> 1771 bytes public/images/emoji/google_classic/puke.png | Bin 0 -> 844 bytes public/images/emoji/twitter/puke.png | Bin 0 -> 844 bytes public/images/emoji/win10/puke.png | Bin 0 -> 938 bytes 9 files changed, 4 insertions(+) create mode 100644 public/images/emoji/apple/puke.png create mode 100644 public/images/emoji/emoji_one/puke.png create mode 100644 public/images/emoji/facebook_messenger/puke.png create mode 100644 public/images/emoji/google/puke.png create mode 100644 public/images/emoji/google_classic/puke.png create mode 100644 public/images/emoji/twitter/puke.png create mode 100644 public/images/emoji/win10/puke.png diff --git a/lib/emoji/db.json b/lib/emoji/db.json index 1f021752e4..4fd33ff3ba 100644 --- a/lib/emoji/db.json +++ b/lib/emoji/db.json @@ -6758,6 +6758,9 @@ ], "fleur_de_lis": [ "fleur-de-lis" + ], + "face_vomiting": [ + "puke" ] } } \ No newline at end of file diff --git a/lib/tasks/emoji.rake b/lib/tasks/emoji.rake index 376416338e..2a1a7e1a8a 100644 --- a/lib/tasks/emoji.rake +++ b/lib/tasks/emoji.rake @@ -199,6 +199,7 @@ EMOJI_ALIASES ||= { "new_moon" => [ "moon" ], "oncoming_automobile" => [ "car", "automobile" ], "fleur_de_lis" => [ "fleur-de-lis" ], + "face_vomiting" => [ "puke" ] } EMOJI_GROUPS ||= [ diff --git a/public/images/emoji/apple/puke.png b/public/images/emoji/apple/puke.png new file mode 100644 index 0000000000000000000000000000000000000000..2eae8d3328e251069ed7fb3417c9984e93f2c19c GIT binary patch literal 7503 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 Z{{zQu?I;jX1s4DS002ovPDHLkV1ko>(dhsH literal 0 HcmV?d00001 diff --git a/public/images/emoji/emoji_one/puke.png b/public/images/emoji/emoji_one/puke.png new file mode 100644 index 0000000000000000000000000000000000000000..60b8fb4babd655cd6fad754d5a59a7543fa8a279 GIT binary patch literal 1763 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 M07*qoM6N<$g0i$OxBvhE literal 0 HcmV?d00001 diff --git a/public/images/emoji/google/puke.png b/public/images/emoji/google/puke.png new file mode 100644 index 0000000000000000000000000000000000000000..d3ce5f69bdc03f02d8350cb377758321f16c8444 GIT binary patch literal 1771 zcmV;P)us)lBL#L=ksmcH5jkI$1g~x4m zrUL{rwR+dna-CxUoAy1^VIYdB@B}dDm1;+%&Z(gzCAPlQZ8$%JZDd71JD^ zf;mNb5|gS2#0Jq!kd{= z(FmN#iGW@|7g|dZVA@{dOqf`no{wB3Hl~~q(`l+CU-el55KWhAaDV-_mQyxW%$MBv_{+p|MK?FCH> z*aF@qZw@ep3$SW0=}q*kZYaDQ+l%D&39ieujMG_R-V~tLl+e><|TSE zgq7pJ9D=T+cV3AUlS6nQhe2`(<+A~i@`6{Sy;4Marx@N(^CK`%eGFr0q=cYXytJ`C z2a__Z-lJWdJ?`-nDNT5KaQoDfV(jxoq1MO{zUJA^iZjScQc+K_`Wl(~*c zCo{yxhQ~JC0(X2wo=(`B--P)Qq&_CykZ2#$p<(kEdo7g~EuK&3tTpAXuEtvDp3kvb zB)e0$$ju+u_H*Zta<&YQ*zz%EyK3r2?2!MCaBZaYk=*}kF{9l!VJHl@Y#G}igXP>9 zER~stXx64c0I2~5MZ3vW*7tv${l_-2b)mc3*VMFC;YmS2Ml4 zi;6i}Q38XuCKfqn(WzpJKX=G|CLN6E=tLO=Nd}}AE^5iaijGF~ZT#Gy@p*AoU3D)- zK$@V5^lzdLo|1R^#)W2A*7YcoX{JS%j$|pk#1q*ls_SBha=m$lVACW@Gk9hs0Xd7< zvv3)CXH0`V{!FDxL4aV}#E6u#qU%tXJ9F|&roJAI*XI@@ODbJ2gNJ8R(O$2|q3)5- z{o=LwEuUT8b!T#-tt6dVqkxFBq;uER)!~(kUl7G>T#=r7Bib6UB9Yo|LX;ptRM-9Y z*RG-g5qbBY1qpQ0ZEk)vM2PH^5^b);xqrG`E^pxRJ@1yy4>p_7k4bQ8soWw-pgRMR zWfcc^9<9Q=B|IW6kx#y7&t7SX-pn2%ZJ@~a!_Z&QgkDUHPSSjDaA8Jaefc)i6hpFv zw<4i6DkDWsUlxER5k#wCS;QN}sVo@wwp9`fW7B5Y6htIACCSyH)YhReLb>v^1$~&J zWf7lT97QVv0?ru%W_lV#>+ql7Wrbl7c%HXeJ2n^uLFlFdk5KpjgQ literal 0 HcmV?d00001 diff --git a/public/images/emoji/google_classic/puke.png b/public/images/emoji/google_classic/puke.png new file mode 100644 index 0000000000000000000000000000000000000000..cd7c3d1f2b76cb00cf13081adf38a2f78b2f0d94 GIT binary patch literal 844 zcmV-S1GD^zP)T5v@M{Jpcdz4|GyaQvhCx zzYnH+5`)$6h=FWie_PR%liJ0IauI)O%nn% zQ#I$ONu9d)^Q+DV<(L>5p;GK_ziW^OT#8Dw&Vn(LP<&e@c8%8JxnNN@Y<+8Y^$EQJFM> zf>bs%Qczh*MEwF%Fw+i)vKazHVP}8@-!i!@3+GIP1cGs{|040m1*V!`HS6`dzy$XyN(be*_ zCi%QtTv6|dTn|GSKci!==~G3LuSnIV0kh6Xey9i{;e?8ewnfcN{@V~1BHd{Y!fEjdD%sZDvK5Cep*W+>1JRUDhRpiq~eUH}M{$f-t;WKjbJVpbR zXF5esG-5TLc^rS3RuMnjoZY?Wr9G482Da|F6Lq8{VHz`{`A2gie%KnEgl4~@*-2@L zpR>3X-9}T8BjVAM7Mfdj5g5nOiWW2$rU{93Y10UinZ>w?y%kN!2>V9}GVJ@Iy!#d1 zx928kl70~}BA9eW_pIwk1(6Fx$R~Om35#$Oi9@Q82yG=ohmdHX*;m?kq6V6DI(Mu$ zItX$_7@~h6njre$6~R=R5hxVRx|X?EWVhOBE*Fi{##kaEmsX1=14QkGWJoS;ZG%Qd zpsU+UlBUIKc^RRyxTl5&tL9UuiQ?3Gt%3a35H|3_&RhPrt?RD48E4mZb=|h%<^KaU WXz4J+w2-O*0000T5v@M{Jpcdz4|GyaQvhCx zzYnH+5`)$6h=FWie_PR%liJ0IauI)O%nn% zQ#I$ONu9d)^Q+DV<(L>5p;GK_ziW^OT#8Dw&Vn(LP<&e@c8%8JxnNN@Y<+8Y^$EQJFM> zf>bs%Qczh*MEwF%Fw+i)vKazHVP}8@-!i!@3+GIP1cGs{|040m1*V!`HS6`dzy$XyN(be*_ zCi%QtTv6|dTn|GSKci!==~G3LuSnIV0kh6Xey9i{;e?8ewnfcN{@V~1BHd{Y!fEjdD%sZDvK5Cep*W+>1JRUDhRpiq~eUH}M{$f-t;WKjbJVpbR zXF5esG-5TLc^rS3RuMnjoZY?Wr9G482Da|F6Lq8{VHz`{`A2gie%KnEgl4~@*-2@L zpR>3X-9}T8BjVAM7Mfdj5g5nOiWW2$rU{93Y10UinZ>w?y%kN!2>V9}GVJ@Iy!#d1 zx928kl70~}BA9eW_pIwk1(6Fx$R~Om35#$Oi9@Q82yG=ohmdHX*;m?kq6V6DI(Mu$ zItX$_7@~h6njre$6~R=R5hxVRx|X?EWVhOBE*Fi{##kaEmsX1=14QkGWJoS;ZG%Qd zpsU+UlBUIKc^RRyxTl5&tL9UuiQ?3Gt%3a35H|3_&RhPrt?RD48E4mZb=|h%<^KaU WXz4J+w2-O*0000z?YK5ECk)c3GKgy+?5!aWbq@dl z0AEWM4NnTzjsg&T5NMtt6P^wgxD7Uj4;ugg010$bPE!E0OAd}B`_*v%k=SAY00RO^ zL_t(|UhSCca;q>5giitpeC3|B@BgC5HsGz1AqoB4olXZ2&NtG^7bX9Gr$1Hq@>bK} zMD?w_SJx=_vL1G-%loI4q5&mkHN_{Gl7>T3`8KMF7}F}Nr4UkE)tZQE-g9_MYE)7r zl4{%%tlkcXS}iitExj33xniU^kX%vaIp?+{rNxQ#lHl#c^J3vB@j?oulX#33FJ(<2 zo%&<6cthHp_|)t;-8^>!!Wg7%?JC-HoCGB1nUN^Bp29k`DJKP`BpX&{J~7lwdJ0P| zNTPPR25X(_9tzojyX4Q*o5ExyCC zMG?g?#-=9S60fa*>j|GV>^67$v0p)(v zA}#DTH5zhEGb*-4uxazhh*T>Ey%2gPY+81V@Po}`w#Xnv;!*5i%N?~wVbV_98p__ON>vJ0nwY+6Vb6kJFAf`p_QCI{&s-`@`6|NGH27)-(i$**jv zU1I^r$*~BNaAor*0-#{c*VmR8MiV$_B6wS~O4>efA_=1&F7dX8H(Jlf5$N$`jn-M~ zhh#Qk#9F_zX7s@b5Nk${+hh6kX@HXso-*4WLq)xitjGKy?Vdf#E}XhJ`<`aD={z;O zG5Fq=k0&f=fS?o<7WqL;&BDR-GmzIp;x5(Lq6?|V*1!|qxS%Loe~%9wh=Wq~xQZC) zBLT!y>nHa&y#h{tR*%$}*%#F0%6~z*C0Sh{)z$eAD_qxqaDo2xA8#7Jbt4+KWB>pF M07*qoM6N<$f^*57^Z)<= literal 0 HcmV?d00001 From b9a669ba320945a538a36a33daa2a5a7cdf4b350 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Sun, 25 Feb 2018 22:01:51 +0530 Subject: [PATCH 121/299] FIX: do not log personal message view if user can't see the message --- lib/topic_view.rb | 10 +++++++--- spec/components/topic_view_spec.rb | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/topic_view.rb b/lib/topic_view.rb index 041f52fe3e..9153a9880c 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -489,11 +489,15 @@ class TopicView raise Discourse::NotFound if @topic.blank? # Special case: If the topic is private and the user isn't logged in, ask them # to log in! - if @topic.present? && @topic.private_message? - raise Discourse::NotLoggedIn.new if @user.blank? - StaffActionLogger.new(@user).log_check_personal_message(@topic) if SiteSetting.log_personal_messages_views && @topic.all_allowed_users.where(id: @user.id).blank? + if @topic.present? && @topic.private_message? && @user.blank? + raise Discourse::NotLoggedIn.new end + # can user see this topic? raise Discourse::InvalidAccess.new("can't see #{@topic}", @topic) unless @guardian.can_see?(@topic) + # log personal message views + if SiteSetting.log_personal_messages_views && @topic.present? && @topic.private_message? && @topic.all_allowed_users.where(id: @user.id).blank? + StaffActionLogger.new(@user).log_check_personal_message(@topic) + end end def get_minmax_ids(post_number) diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb index 29ee51328e..06615bda10 100644 --- a/spec/components/topic_view_spec.rb +++ b/spec/components/topic_view_spec.rb @@ -138,6 +138,11 @@ describe TopicView do TopicView.new(private_message.id, evil_trout) expect(UserHistory.where(action: UserHistory.actions[:check_personal_message]).count).to eq(0) end + + it "does not log personal message view if user can't see the message" do + expect { TopicView.new(private_message.id, Fabricate(:user)) }.to raise_error(Discourse::InvalidAccess) + expect(UserHistory.where(action: UserHistory.actions[:check_personal_message]).count).to eq(0) + end end it "provides an absolute url" do From 79e0cd7f529c14d482b2c5dc1394bc90d6070d56 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 26 Feb 2018 10:15:14 +1100 Subject: [PATCH 122/299] update onebox --- Gemfile | 2 +- Gemfile.lock | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index 01aef3667e..40cf6997da 100644 --- a/Gemfile +++ b/Gemfile @@ -36,7 +36,7 @@ gem 'redis-namespace' gem 'active_model_serializers', '~> 0.8.3' -gem 'onebox', '1.8.40' +gem 'onebox', '1.8.41' gem 'http_accept_language', '~>2.0.5', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 48ca0c83c0..773384b5e3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -229,8 +229,7 @@ GEM omniauth-twitter (1.3.0) omniauth-oauth (~> 1.1) rack - onebox (1.8.40) - fast_blank (>= 1.0.0) + onebox (1.8.41) htmlentities (~> 4.3) moneta (~> 1.0) multi_json (~> 1.11) @@ -465,7 +464,7 @@ DEPENDENCIES omniauth-oauth2 omniauth-openid omniauth-twitter - onebox (= 1.8.40) + onebox (= 1.8.41) openid-redis-store pg (~> 0.21.0) pry-nav From b301c9f6c12f517ee64e15599d7cac6ed2b9d7d8 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 26 Feb 2018 10:25:58 +1100 Subject: [PATCH 123/299] more prep work for jRuby --- Gemfile | 9 +++++---- lib/pbkdf2.rb | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index 40cf6997da..f9a8841d52 100644 --- a/Gemfile +++ b/Gemfile @@ -49,9 +49,10 @@ gem 'message_bus' gem 'rails_multisite' -gem 'fast_xs' +gem 'fast_xs', platform: :mri -gem 'fast_xor' +# may move to xorcist post: https://github.com/fny/xorcist/issues/4 +gem 'fast_xor', platform: :mri gem 'fastimage' @@ -141,7 +142,7 @@ end # this is an optional gem, it provides a high performance replacement # to String#blank? a method that is called quite frequently in current # ActiveRecord, this may change in the future -gem 'fast_blank' +gem 'fast_blank', platform: :mri # this provides a very efficient lru cache gem 'lru_redux' @@ -155,7 +156,7 @@ gem 'htmlentities', require: false gem 'flamegraph', require: false gem 'rack-mini-profiler', require: false -gem 'unicorn', require: false +gem 'unicorn', require: false, platform: :mri gem 'puma', require: false gem 'rbtrace', require: false, platform: :mri gem 'gc_tracer', require: false, platform: :mri diff --git a/lib/pbkdf2.rb b/lib/pbkdf2.rb index 1032b4a1ad..c73639326b 100644 --- a/lib/pbkdf2.rb +++ b/lib/pbkdf2.rb @@ -31,7 +31,7 @@ class Pbkdf2 # fallback xor in case we need it for jruby ... way slower def self.xor(x, y) - x.bytes.zip(y.bytes).map { |x, y| x ^ y }.pack('c*') + x.bytes.zip(y.bytes).map { |a, b| a ^ b }.pack('c*') end def self.prf(hash_function, password, data) From c234a14f0de458168adb5100cb7fd9d21a362d7d Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 26 Feb 2018 10:29:25 +1100 Subject: [PATCH 124/299] Make bootsnap MRI only for now --- Gemfile | 3 +-- config/boot.rb | 24 +++++++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Gemfile b/Gemfile index f9a8841d52..607ecc0fea 100644 --- a/Gemfile +++ b/Gemfile @@ -2,8 +2,7 @@ source 'https://rubygems.org' # if there is a super emergency and rubygems is playing up, try #source 'http://production.cf.rubygems.org' -# does not install in linux ATM, so hack this for now -gem 'bootsnap', require: false +gem 'bootsnap', require: false, platform: :mri def rails_master? ENV["RAILS_MASTER"] == '1' diff --git a/config/boot.rb b/config/boot.rb index 16ed53b095..ba9f61acee 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -11,14 +11,20 @@ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) require 'bundler/setup' if File.exists?(ENV['BUNDLE_GEMFILE']) if ENV['RAILS_ENV'] != 'production' && ENV['RAILS_ENV'] != 'profile' - require 'bootsnap' + begin + require 'bootsnap' + rescue LoadError + # not a strong requirement + end - Bootsnap.setup( - cache_dir: 'tmp/cache', # Path to your cache - load_path_cache: true, # Should we optimize the LOAD_PATH with a cache? - autoload_paths_cache: true, # Should we optimize ActiveSupport autoloads with cache? - disable_trace: false, # Sets `RubyVM::InstructionSequence.compile_option = { trace_instruction: false }` - compile_cache_iseq: true, # Should compile Ruby code into ISeq cache? - compile_cache_yaml: false # Skip YAML cache for now, cause we were seeing issues with it - ) + if defined? Bootsnap + Bootsnap.setup( + cache_dir: 'tmp/cache', # Path to your cache + load_path_cache: true, # Should we optimize the LOAD_PATH with a cache? + autoload_paths_cache: true, # Should we optimize ActiveSupport autoloads with cache? + disable_trace: false, # Sets `RubyVM::InstructionSequence.compile_option = { trace_instruction: false }` + compile_cache_iseq: true, # Should compile Ruby code into ISeq cache? + compile_cache_yaml: false # Skip YAML cache for now, cause we were seeing issues with it + ) + end end From 3e1afbedc521a6687a9575b4319b821ada7f3bd1 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 26 Feb 2018 10:11:18 +0800 Subject: [PATCH 125/299] FIX: Missing translation for non-admin when editing a group. https://meta.discourse.org/t/text-glitch-on-group-admin-page/77303 --- app/assets/javascripts/admin/templates/group.hbs | 2 +- .../discourse/templates/components/group-members-input.hbs | 2 +- config/locales/client.en.yml | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/admin/templates/group.hbs b/app/assets/javascripts/admin/templates/group.hbs index 0689bd30ce..c10ca20bb3 100644 --- a/app/assets/javascripts/admin/templates/group.hbs +++ b/app/assets/javascripts/admin/templates/group.hbs @@ -35,7 +35,7 @@ {{user-selector usernames=model.ownerUsernames - placeholderKey="admin.groups.selector_placeholder" + placeholderKey="groups.selector_placeholder" id="owner-selector"}} {{#if model.id}} diff --git a/app/assets/javascripts/discourse/templates/components/group-members-input.hbs b/app/assets/javascripts/discourse/templates/components/group-members-input.hbs index ad66499dbb..4f0ab683cc 100644 --- a/app/assets/javascripts/discourse/templates/components/group-members-input.hbs +++ b/app/assets/javascripts/discourse/templates/components/group-members-input.hbs @@ -16,7 +16,7 @@ {{#unless model.automatic}}
{{user-selector usernames=model.usernames - placeholderKey="admin.groups.selector_placeholder" + placeholderKey="groups.selector_placeholder" id="member-selector"}} {{#if addButton}} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index bf70f73c73..c35f7a4e59 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -447,7 +447,7 @@ en: name: "Name" user_count: "Number of Members" bio: "About Group" - selector_placeholder: "Add members" + selector_placeholder: "enter username" owner: "owner" index: title: "Groups" @@ -2805,7 +2805,6 @@ en: edit: "Edit Groups" refresh: "Refresh" new: "New" - selector_placeholder: "enter username" about: "Edit your group membership and names here" group_members: "Group members" delete: "Delete" From c64f09b6b777cd8d979e939aff39bc1f7808aa5e Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 26 Feb 2018 10:42:06 +0800 Subject: [PATCH 126/299] REFACTOR: Simplify and DRY `Group#invite`. --- app/controllers/topics_controller.rb | 13 ++- app/models/topic.rb | 77 +++++++-------- spec/controllers/topics_controller_spec.rb | 97 ------------------- spec/models/topic_spec.rb | 39 +------- spec/requests/topics_controller_spec.rb | 105 +++++++++++++++++++++ 5 files changed, 156 insertions(+), 175 deletions(-) diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 76e6c879e1..e5aa3b033c 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -477,9 +477,19 @@ class TopicsController < ApplicationController end def invite - username_or_email = params[:user] ? fetch_username : fetch_email + unless guardian.is_staff? + RateLimiter.new( + current_user, + "topic-invitations-per-day", + SiteSetting.max_topic_invitations_per_day, + 1.day.to_i + ).performed! + end topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::InvalidParameters.new unless topic + + username_or_email = params[:user] ? fetch_username : fetch_email groups = Group.lookup_groups( group_ids: params[:group_ids], @@ -492,6 +502,7 @@ class TopicsController < ApplicationController begin if topic.invite(current_user, username_or_email, group_ids, params[:custom_message]) user = User.find_by_username_or_email(username_or_email) + if user render_json_dump BasicUserSerializer.new(user, scope: guardian, root: 'user') else diff --git a/app/models/topic.rb b/app/models/topic.rb index b5244d7375..d48825e0cd 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -792,62 +792,42 @@ SQL true end - # Invite a user to the topic by username or email. Returns success/failure def invite(invited_by, username_or_email, group_ids = nil, custom_message = nil) user = User.find_by_username_or_email(username_or_email) - if private_message? - # If the user exists, add them to the message. - raise UserExists.new I18n.t("topic_invite.user_exists") if user.present? && topic_allowed_users.where(user_id: user.id).exists? - - if user && topic_allowed_users.create!(user_id: user.id) - # Create a small action message - add_small_action(invited_by, "invited_user", user.username) - - # Notify the user they've been invited - user.notifications.create(notification_type: Notification.types[:invited_to_private_message], - topic_id: id, - post_number: 1, - data: { topic_title: title, - display_username: invited_by.username }.to_json) - return true - end + if user && topic_allowed_users.where(user_id: user.id).exists? + raise UserExists.new(I18n.t("topic_invite.user_exists")) end - if username_or_email =~ /^.+@.+$/ && Guardian.new(invited_by).can_invite_via_email?(self) - RateLimiter.new(invited_by, "topic-invitations-per-day", SiteSetting.max_topic_invitations_per_day, 1.day.to_i).performed! + if user && private_message? && topic_allowed_users.create!(user_id: user.id) + add_small_action(invited_by, "invited_user", user.username) - if user.present? - # add existing users + create_invite_notification!( + Notification.types[:invited_to_private_message], + invited_by.username + ) + + true + elsif username_or_email =~ /^.+@.+$/ && Guardian.new(invited_by).can_invite_via_email?(self) + if user Invite.extend_permissions(self, user, invited_by) - # Notify the user they've been invited - user.notifications.create(notification_type: Notification.types[:invited_to_topic], - topic_id: id, - post_number: 1, - data: { topic_title: title, - display_username: invited_by.username }.to_json) - return true + create_invite_notification!( + Notification.types[:invited_to_topic], + invited_by.username + ) else - # NOTE callers expect an invite object if an invite was sent via email invite_by_email(invited_by, username_or_email, group_ids, custom_message) end - else - raise UserExists.new I18n.t("topic_invite.user_exists") if user.present? && topic_allowed_users.where(user_id: user.id).exists? - if user && topic_allowed_users.create!(user_id: user.id) - RateLimiter.new(invited_by, "topic-invitations-per-day", SiteSetting.max_topic_invitations_per_day, 1.day.to_i).performed! + true + elsif user && topic_allowed_users.create!(user_id: user.id) + create_invite_notification!( + Notification.types[:invited_to_topic], + invited_by.username + ) - # Notify the user they've been invited - user.notifications.create(notification_type: Notification.types[:invited_to_topic], - topic_id: id, - post_number: 1, - data: { topic_title: title, - display_username: invited_by.username }.to_json) - return true - else - false - end + true end end @@ -1310,6 +1290,17 @@ SQL RateLimiter.new(user, "#{key}-per-day", SiteSetting.send(method_name), 1.day.to_i) end + def create_invite_notification!(notification_type, username) + user.notifications.create!( + notification_type: notification_type, + topic_id: self.id, + post_number: 1, + data: { + topic_title: self.title, + display_username: username + }.to_json + ) + end end # == Schema Information diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb index 01637a4b09..0b6722e63e 100644 --- a/spec/controllers/topics_controller_spec.rb +++ b/spec/controllers/topics_controller_spec.rb @@ -1260,103 +1260,6 @@ describe TopicsController do end end - describe 'invite' do - - describe "group invites" do - it "works correctly" do - group = Fabricate(:group) - topic = Fabricate(:topic) - _admin = log_in(:admin) - - post :invite, params: { - topic_id: topic.id, email: 'hiro@from.heros', group_ids: "#{group.id}" - }, format: :json - - expect(response).to be_success - - invite = Invite.find_by(email: 'hiro@from.heros') - groups = invite.groups.to_a - expect(groups.count).to eq(1) - expect(groups[0].id).to eq(group.id) - end - end - - it "won't allow us to invite toa topic when we're not logged in" do - post :invite, params: { - topic_id: 1, email: 'jake@adventuretime.ooo' - }, format: :json - expect(response.status).to eq(403) - end - - describe 'when logged in as group manager' do - let(:group_manager) { log_in } - let(:group) { Fabricate(:group).tap { |g| g.add_owner(group_manager) } } - let(:private_category) { Fabricate(:private_category, group: group) } - let(:group_private_topic) { Fabricate(:topic, category: private_category, user: group_manager) } - let(:recipient) { 'jake@adventuretime.ooo' } - - it "should attach group to the invite" do - post :invite, params: { - topic_id: group_private_topic.id, user: recipient - }, format: :json - - expect(response).to be_success - expect(Invite.find_by(email: recipient).groups).to eq([group]) - end - end - - describe 'when logged in' do - before do - @topic = Fabricate(:topic, user: log_in) - end - - it 'requires an email parameter' do - expect do - post :invite, params: { topic_id: @topic.id }, format: :json - end.to raise_error(ActionController::ParameterMissing) - end - - describe 'without permission' do - it "raises an exception when the user doesn't have permission to invite to the topic" do - post :invite, params: { - topic_id: @topic.id, user: 'jake@adventuretime.ooo' - }, format: :json - - expect(response).to be_forbidden - end - end - - describe 'with admin permission' do - - let!(:admin) do - log_in :admin - end - - it 'should work as expected' do - post :invite, params: { - topic_id: @topic.id, user: 'jake@adventuretime.ooo' - }, format: :json - - expect(response).to be_success - expect(::JSON.parse(response.body)).to eq('success' => 'OK') - expect(Invite.where(invited_by_id: admin.id).count).to eq(1) - end - - it 'should fail on shoddy email' do - post :invite, params: { - topic_id: @topic.id, user: 'i_am_not_an_email' - }, format: :json - - expect(response).not_to be_success - expect(::JSON.parse(response.body)).to eq('failed' => 'FAILED') - end - - end - - end - - end - describe 'make_banner' do it 'needs you to be a staff member' do diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index a5f61c591d..2e69779555 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -493,8 +493,7 @@ describe Topic do expect(Guardian.new(coding_horror).can_see?(topic)).to eq(true) expect(TopicQuery.new(evil_trout).list_latest.topics).not_to include(topic) - # invites - expect(topic.invite(topic.user, 'duhhhhh')).to eq(false) + expect(topic.invite(topic.user, 'duhhhhh')).to eq(nil) end context 'invite' do @@ -567,7 +566,8 @@ describe Topic do it 'adds user correctly' do expect { expect(topic.invite(topic.user, walter.email)).to eq(true) - }.to change(Notification, :count) + }.to change(Notification, :count).by(1) + expect(topic.allowed_users.include?(walter)).to eq(true) end @@ -591,35 +591,6 @@ describe Topic do end - context 'rate limits' do - - it "rate limits topic invitations" do - SiteSetting.max_topic_invitations_per_day = 2 - RateLimiter.enable - RateLimiter.clear_all! - - start = Time.now.tomorrow.beginning_of_day - freeze_time(start) - - user = Fabricate(:user) - trust_level_2 = Fabricate(:user, trust_level: 2) - topic = Fabricate(:topic, user: trust_level_2) - - freeze_time(start + 10.minutes) - topic.invite(topic.user, user.username) - - freeze_time(start + 20.minutes) - topic.invite(topic.user, "walter@white.com") - - freeze_time(start + 30.minutes) - - expect { - topic.invite(topic.user, "user@example.com") - }.to raise_error(RateLimiter::LimitExceeded) - end - - end - context 'bumping topics' do before do @@ -1824,8 +1795,8 @@ describe Topic do let(:randolph) { 'randolph@duke.ooo' } it "should attach group to the invite" do - invite = group_private_topic.invite(group_manager, randolph) - expect(invite.groups).to eq([group]) + expect(group_private_topic.invite(group_manager, randolph)).to eq(true) + expect(Invite.last.groups).to eq([group]) end end diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index 72f00371cf..a6bfdf56c9 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -156,6 +156,111 @@ RSpec.describe TopicsController do end end + describe '#invite' do + describe 'when not logged in' do + it "should return the right response" do + post "/t/#{topic.id}/invite.json", params: { + email: 'jake@adventuretime.ooo' + } + + expect(response.status).to eq(403) + end + end + + describe 'when logged in' do + before do + sign_in(user) + end + + describe 'as a valid user' do + let(:topic) { Fabricate(:topic, user: user) } + + it 'should return the right response' do + user.update!(trust_level: TrustLevel[2]) + + expect do + post "/t/#{topic.id}/invite.json", params: { + email: 'someguy@email.com' + } + end.to change { Invite.where(invited_by_id: user.id).count }.by(1) + + expect(response.status).to eq(200) + end + end + + describe 'when user is a group manager' do + let(:group) { Fabricate(:group).tap { |g| g.add_owner(user) } } + let(:private_category) { Fabricate(:private_category, group: group) } + + let(:group_private_topic) do + Fabricate(:topic, category: private_category, user: user) + end + + let(:recipient) { 'jake@adventuretime.ooo' } + + it "should attach group to the invite" do + + post "/t/#{group_private_topic.id}/invite.json", params: { + user: recipient + } + + expect(response.status).to eq(200) + expect(Invite.find_by(email: recipient).groups).to eq([group]) + end + end + + describe 'when topic id is invalid' do + it 'should return the right response' do + post "/t/999/invite.json", params: { + email: Fabricate(:user).email + } + + expect(response.status).to eq(400) + end + end + + it 'requires an email parameter' do + post "/t/#{topic.id}/invite.json" + + expect(response.status).to eq(400) + end + + describe 'when user does not have permission to invite to the topic' do + let(:topic) { Fabricate(:private_message_topic) } + + it "should return the right response" do + post "/t/#{topic.id}/invite.json", params: { + user: user.username + } + + expect(response.status).to eq(403) + end + end + end + + describe "when inviting a group to a topic" do + let(:group) { Fabricate(:group) } + + before do + sign_in(Fabricate(:admin)) + end + + it "should work correctly" do + email = 'hiro@from.heros' + + post "/t/#{topic.id}/invite.json", params: { + email: email, group_ids: group.id + } + + expect(response.status).to eq(200) + + groups = Invite.find_by(email: email).groups + expect(groups.count).to eq(1) + expect(groups.first.id).to eq(group.id) + end + end + end + describe 'invite_group' do let(:admins) { Group[:admins] } let(:pm) { Fabricate(:private_message_topic) } From 07f928e05e1e540de932c9a11912a0566557625f Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 26 Feb 2018 12:42:55 +0800 Subject: [PATCH 127/299] Fix the build. --- app/models/topic.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/topic.rb b/app/models/topic.rb index d48825e0cd..a805180761 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -816,11 +816,11 @@ SQL Notification.types[:invited_to_topic], invited_by.username ) + + true else invite_by_email(invited_by, username_or_email, group_ids, custom_message) end - - true elsif user && topic_allowed_users.create!(user_id: user.id) create_invite_notification!( Notification.types[:invited_to_topic], From 1b5d955a3464487880f77b511c6045fe72851f41 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 26 Feb 2018 12:46:15 +0800 Subject: [PATCH 128/299] Fix the build. --- spec/mailers/invite_mailer_spec.rb | 17 +++++++++++++++-- spec/models/invite_spec.rb | 6 ++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/spec/mailers/invite_mailer_spec.rb b/spec/mailers/invite_mailer_spec.rb index 4bf13fddfd..dc1b60fe30 100644 --- a/spec/mailers/invite_mailer_spec.rb +++ b/spec/mailers/invite_mailer_spec.rb @@ -78,7 +78,11 @@ describe InviteMailer do let(:topic) { Fabricate(:topic, excerpt: "Topic invite support is now available in Discourse!", user: trust_level_2) } context "default invite message" do - let(:invite) { topic.invite(topic.user, 'name@example.com') } + let(:invite) do + topic.invite(topic.user, 'name@example.com') + Invite.find_by(invited_by_id: topic.user.id) + end + let(:invite_mail) { InviteMailer.send_invite(invite) } it 'renders the invitee email' do @@ -123,7 +127,16 @@ describe InviteMailer do end context "custom invite message" do - let(:invite) { topic.invite(topic.user, 'name@example.com', nil, "Hey, I thought you might enjoy this topic!") } + let(:invite) do + topic.invite( + topic.user, + 'name@example.com', + nil, + "Hey, I thought you might enjoy this topic!" + ) + + Invite.find_by(invited_by_id: topic.user.id) + end let(:custom_invite_mail) { InviteMailer.send_invite(invite) } it 'renders custom_message' do diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb index c72ef714ed..78e6d2c4db 100644 --- a/spec/models/invite_spec.rb +++ b/spec/models/invite_spec.rb @@ -329,9 +329,11 @@ describe Invite do context 'invited to topics' do let(:tl2_user) { Fabricate(:user, trust_level: 2) } let!(:topic) { Fabricate(:private_message_topic, user: tl2_user) } - let!(:invite) { + + let!(:invite) do topic.invite(topic.user, 'jake@adventuretime.ooo') - } + Invite.find_by(invited_by_id: topic.user) + end context 'redeem topic invite' do it 'adds the user to the topic_users' do From 31242335a64336799f03785b5bfff8acf5768964 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 26 Feb 2018 13:08:10 +0800 Subject: [PATCH 129/299] Revert "Fix the build." This reverts commit 07f928e05e1e540de932c9a11912a0566557625f. --- app/models/topic.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/topic.rb b/app/models/topic.rb index a805180761..d48825e0cd 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -816,11 +816,11 @@ SQL Notification.types[:invited_to_topic], invited_by.username ) - - true else invite_by_email(invited_by, username_or_email, group_ids, custom_message) end + + true elsif user && topic_allowed_users.create!(user_id: user.id) create_invite_notification!( Notification.types[:invited_to_topic], From 6c1c5fe2d6445c60491d4eb78abd8efd5d32ed5c Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 26 Feb 2018 13:09:13 +0800 Subject: [PATCH 130/299] Fix the build take 2. --- spec/models/topic_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 2e69779555..a1f11585d6 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -1795,7 +1795,7 @@ describe Topic do let(:randolph) { 'randolph@duke.ooo' } it "should attach group to the invite" do - expect(group_private_topic.invite(group_manager, randolph)).to eq(true) + group_private_topic.invite(group_manager, randolph) expect(Invite.last.groups).to eq([group]) end end From 6a88f7db61b2a5aceddca2bab25e12ac481861f9 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 26 Feb 2018 13:19:52 +0800 Subject: [PATCH 131/299] Notification created for wrong user after invite. Introduced in https://github.com/discourse/discourse/commit/c64f09b6b777cd8d979e939aff39bc1f7808aa5e --- app/models/topic.rb | 21 ++++++++++++--------- spec/models/topic_spec.rb | 7 +++++++ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/app/models/topic.rb b/app/models/topic.rb index d48825e0cd..2f6c116da6 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -793,26 +793,28 @@ SQL end def invite(invited_by, username_or_email, group_ids = nil, custom_message = nil) - user = User.find_by_username_or_email(username_or_email) + target_user = User.find_by_username_or_email(username_or_email) - if user && topic_allowed_users.where(user_id: user.id).exists? + if target_user && topic_allowed_users.where(user_id: target_user.id).exists? raise UserExists.new(I18n.t("topic_invite.user_exists")) end - if user && private_message? && topic_allowed_users.create!(user_id: user.id) - add_small_action(invited_by, "invited_user", user.username) + if target_user && private_message? && topic_allowed_users.create!(user_id: target_user.id) + add_small_action(invited_by, "invited_user", target_user.username) create_invite_notification!( + target_user, Notification.types[:invited_to_private_message], invited_by.username ) true elsif username_or_email =~ /^.+@.+$/ && Guardian.new(invited_by).can_invite_via_email?(self) - if user - Invite.extend_permissions(self, user, invited_by) + if target_user + Invite.extend_permissions(self, target_user, invited_by) create_invite_notification!( + target_user, Notification.types[:invited_to_topic], invited_by.username ) @@ -821,8 +823,9 @@ SQL end true - elsif user && topic_allowed_users.create!(user_id: user.id) + elsif target_user && topic_allowed_users.create!(user_id: target_user.id) create_invite_notification!( + target_user, Notification.types[:invited_to_topic], invited_by.username ) @@ -1290,8 +1293,8 @@ SQL RateLimiter.new(user, "#{key}-per-day", SiteSetting.send(method_name), 1.day.to_i) end - def create_invite_notification!(notification_type, username) - user.notifications.create!( + def create_invite_notification!(target_user, notification_type, username) + target_user.notifications.create!( notification_type: notification_type, topic_id: self.id, post_number: 1, diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index a1f11585d6..926cb4b790 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -546,6 +546,13 @@ describe Topic do expect(topic.invite(topic.user, walter.username)).to eq(true) expect(topic.allowed_users.include?(walter)).to eq(true) + notification = Notification.last + + expect(notification.user).to eq(walter) + + expect(notification.notification_type) + .to eq(Notification.types[:invited_to_private_message]) + expect(topic.remove_allowed_user(topic.user, walter.username)).to eq(true) topic.reload expect(topic.allowed_users.include?(walter)).to eq(false) From 65cb785374bc642e3adf68e667b93d0fdff5526c Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 26 Feb 2018 15:18:34 +0800 Subject: [PATCH 132/299] Improve specs for `Topic#invite`. --- spec/models/topic_spec.rb | 166 ++++++++++++++++++++++++++++---------- 1 file changed, 122 insertions(+), 44 deletions(-) diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 926cb4b790..5dcfd9f2e5 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -481,6 +481,128 @@ describe Topic do end + describe '#invite' do + let(:topic) { Fabricate(:topic, user: user) } + let(:another_user) { Fabricate(:user) } + + describe 'when username_or_email is not valid' do + it 'should return the right value' do + expect do + expect(topic.invite(user, 'somerandomstring')).to eq(nil) + end.to_not change { topic.allowed_users } + end + end + + describe 'when user is already allowed' do + it 'should raise the right error' do + topic.allowed_users << another_user + + expect { topic.invite(user, another_user.username) } + .to raise_error(Topic::UserExists) + end + end + + describe 'private message' do + let(:user) { Fabricate(:user, trust_level: TrustLevel[2]) } + let(:topic) { Fabricate(:private_message_topic, user: user) } + + describe 'by username' do + it 'should be able to invite a user' do + expect(topic.invite(user, another_user.username)).to eq(true) + expect(topic.allowed_users).to include(another_user) + expect(Post.last.action_code).to eq("invited_user") + + notification = Notification.last + + expect(notification.notification_type) + .to eq(Notification.types[:invited_to_private_message]) + + expect(topic.remove_allowed_user(user, another_user.username)).to eq(true) + expect(topic.reload.allowed_users).to_not include(another_user) + expect(Post.last.action_code).to eq("removed_user") + end + end + + describe 'by email' do + it 'should be able to invite a user' do + expect(topic.invite(user, another_user.email)).to eq(true) + expect(topic.allowed_users).to include(another_user) + + expect(Notification.last.notification_type) + .to eq(Notification.types[:invited_to_private_message]) + end + + describe 'when user is not found' do + it 'should create the right invite' do + expect(topic.invite(user, 'test@email.com')).to eq(true) + + invite = Invite.last + + expect(invite.email).to eq('test@email.com') + expect(invite.invited_by).to eq(user) + end + + describe 'when user does not have sufficient trust level' do + before { user.update!(trust_level: TrustLevel[1]) } + + it 'should not create an invite' do + expect do + expect(topic.invite(user, 'test@email.com')).to eq(nil) + end.to_not change { Invite.count } + end + end + end + end + end + + describe 'public topic' do + def expect_the_right_notification_to_be_created + notification = Notification.last + + expect(notification.notification_type) + .to eq(Notification.types[:invited_to_topic]) + + expect(notification.user).to eq(another_user) + expect(notification.topic).to eq(topic) + + notification_data = JSON.parse(notification.data) + + expect(notification_data["topic_title"]).to eq(topic.title) + expect(notification_data["display_username"]).to eq(user.username) + end + + describe 'by username' do + it 'should invite user into a topic' do + topic.invite(user, another_user.username) + + expect(topic.reload.allowed_users.last).to eq(another_user) + expect_the_right_notification_to_be_created + end + end + + describe 'by email' do + it 'should be able to invite a user' do + expect(topic.invite(user, another_user.email)).to eq(true) + expect(topic.reload.allowed_users.last).to eq(another_user) + expect_the_right_notification_to_be_created + end + + describe 'when user can invite via email' do + before { user.update!(trust_level: TrustLevel[2]) } + + it 'should create an invite' do + expect(topic.invite(user, 'test@email.com')).to eq(true) + + invite = Invite.last + + expect(invite.email).to eq('test@email.com') + expect(invite.invited_by).to eq(user) + end + end + end + end + end + context 'private message' do let(:coding_horror) { User.find_by(username: "CodingHorror") } let(:evil_trout) { Fabricate(:evil_trout) } @@ -492,8 +614,6 @@ describe Topic do expect(Guardian.new(evil_trout).can_see?(topic)).to eq(false) expect(Guardian.new(coding_horror).can_see?(topic)).to eq(true) expect(TopicQuery.new(evil_trout).list_latest.topics).not_to include(topic) - - expect(topic.invite(topic.user, 'duhhhhh')).to eq(nil) end context 'invite' do @@ -537,50 +657,8 @@ describe Topic do expect(notification.notification_type) .to eq(Notification.types[:invited_to_private_message]) end - - end - - context 'by username' do - - it 'adds and removes walter to the allowed users' do - expect(topic.invite(topic.user, walter.username)).to eq(true) - expect(topic.allowed_users.include?(walter)).to eq(true) - - notification = Notification.last - - expect(notification.user).to eq(walter) - - expect(notification.notification_type) - .to eq(Notification.types[:invited_to_private_message]) - - expect(topic.remove_allowed_user(topic.user, walter.username)).to eq(true) - topic.reload - expect(topic.allowed_users.include?(walter)).to eq(false) - end - - it 'creates a notification' do - expect { topic.invite(topic.user, walter.username) }.to change(Notification, :count) - end - - it 'creates a small action post' do - expect { topic.invite(topic.user, walter.username) }.to change(Post, :count) - expect { topic.remove_allowed_user(topic.user, walter.username) }.to change(Post, :count) - end - end - - context 'by email' do - - it 'adds user correctly' do - expect { - expect(topic.invite(topic.user, walter.email)).to eq(true) - }.to change(Notification, :count).by(1) - - expect(topic.allowed_users.include?(walter)).to eq(true) - end - end end - end context "user actions" do From 982e5bae3a8c2d9991a9cd999c05282bbb039f78 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 26 Feb 2018 15:32:04 +0800 Subject: [PATCH 133/299] Update annotations. --- app/models/user_second_factor.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/user_second_factor.rb b/app/models/user_second_factor.rb index acd16cf134..0ffa9717d5 100644 --- a/app/models/user_second_factor.rb +++ b/app/models/user_second_factor.rb @@ -14,8 +14,8 @@ end # # id :integer not null, primary key # user_id :integer not null -# method :string -# data :string +# method :integer not null +# data :string not null # enabled :boolean default(FALSE), not null # last_used :datetime # created_at :datetime not null From c1f53e1ece69421cec4c03e99c4872c852dba2db Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 26 Feb 2018 17:57:16 +0800 Subject: [PATCH 134/299] UX: Invited users should watch PM topic once topic has been visited. https://meta.discourse.org/t/notifications-not-received-for-private-messages-im-invited-to/71577/11 --- app/models/topic_user.rb | 25 ++++++++++++++++++++----- spec/models/topic_user_spec.rb | 33 +++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/app/models/topic_user.rb b/app/models/topic_user.rb index f620c4648a..060b4cbefb 100644 --- a/app/models/topic_user.rb +++ b/app/models/topic_user.rb @@ -187,15 +187,30 @@ SQL end unless attrs[:notification_level] - auto_track_after = UserOption.where(user_id: user_id).pluck(:auto_track_topics_after_msecs).first - auto_track_after ||= SiteSetting.default_other_auto_track_topics_after_msecs + if Topic.private_messages.where(id: topic_id).exists? && + Notification.where( + user_id: user_id, + topic_id: topic_id, + notification_type: Notification.types[:invited_to_private_message] + ).exists? - if auto_track_after >= 0 && auto_track_after <= (attrs[:total_msecs_viewed].to_i || 0) - attrs[:notification_level] ||= notification_levels[:tracking] + attrs[:notification_level] = notification_levels[:watching] + else + auto_track_after = UserOption.where(user_id: user_id).pluck(:auto_track_topics_after_msecs).first + auto_track_after ||= SiteSetting.default_other_auto_track_topics_after_msecs + + if auto_track_after >= 0 && auto_track_after <= (attrs[:total_msecs_viewed].to_i || 0) + attrs[:notification_level] ||= notification_levels[:tracking] + end end end - TopicUser.create(attrs.merge!(user_id: user_id, topic_id: topic_id, first_visited_at: now , last_visited_at: now)) + TopicUser.create!(attrs.merge!( + user_id: user_id, + topic_id: topic_id, + first_visited_at: now , + last_visited_at: now + )) end def track_visit!(topic_id, user_id) diff --git a/spec/models/topic_user_spec.rb b/spec/models/topic_user_spec.rb index 1c47b31a8b..65438aafdb 100644 --- a/spec/models/topic_user_spec.rb +++ b/spec/models/topic_user_spec.rb @@ -234,13 +234,38 @@ describe TopicUser do end context 'private messages' do + let(:target_user) { Fabricate(:user) } + + let(:post) do + create_post( + archetype: Archetype.private_message, + target_usernames: target_user.username + ); + end + + let(:topic) { post.topic } + it 'should ensure recepients and senders are watching' do + expect(TopicUser.get(topic, post.user).notification_level) + .to eq(TopicUser.notification_levels[:watching]) - target_user = Fabricate(:user) - post = create_post(archetype: Archetype.private_message, target_usernames: target_user.username); + expect(TopicUser.get(topic, target_user).notification_level) + .to eq(TopicUser.notification_levels[:watching]) + end - expect(TopicUser.get(post.topic, post.user).notification_level).to eq(TopicUser.notification_levels[:watching]) - expect(TopicUser.get(post.topic, target_user).notification_level).to eq(TopicUser.notification_levels[:watching]) + it 'should ensure invited user is watching once visited' do + another_user = Fabricate(:user) + topic.invite(target_user, another_user.username) + TopicUser.track_visit!(topic.id, another_user.id) + + expect(TopicUser.get(topic, another_user).notification_level) + .to eq(TopicUser.notification_levels[:watching]) + + another_user = Fabricate(:user) + TopicUser.track_visit!(topic.id, another_user.id) + + expect(TopicUser.get(topic, another_user).notification_level) + .to eq(TopicUser.notification_levels[:regular]) end end From 7d7f6faf40c0f5afd1128d7de21429ebb513d83a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Mon, 26 Feb 2018 11:16:53 +0100 Subject: [PATCH 135/299] FIX: properly render emojis in local oneboxes --- lib/oneboxer.rb | 2 +- spec/components/oneboxer_spec.rb | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/oneboxer.rb b/lib/oneboxer.rb index f89e0426e9..682251ed50 100644 --- a/lib/oneboxer.rb +++ b/lib/oneboxer.rb @@ -206,7 +206,7 @@ module Oneboxer original_url: url, title: PrettyText.unescape_emoji(CGI::escapeHTML(topic.title)), category_html: CategoryBadge.html_for(topic.category), - quote: post.excerpt(SiteSetting.post_onebox_maxlength), + quote: PrettyText.unescape_emoji(post.excerpt(SiteSetting.post_onebox_maxlength)), } template = File.read("#{Rails.root}/lib/onebox/templates/discourse_topic_onebox.hbs") diff --git a/spec/components/oneboxer_spec.rb b/spec/components/oneboxer_spec.rb index 03525ff10a..1bbc1e76b5 100644 --- a/spec/components/oneboxer_spec.rb +++ b/spec/components/oneboxer_spec.rb @@ -35,7 +35,7 @@ describe Oneboxer do replier = Fabricate(:user) - public_post = Fabricate(:post) + public_post = Fabricate(:post, raw: "This post has an emoji :+1:") public_topic = public_post.topic public_reply = Fabricate(:post, topic: public_topic, post_number: 2, user: replier) public_hidden = Fabricate(:post, topic: public_topic, post_number: 3, hidden: true) @@ -48,7 +48,9 @@ describe Oneboxer do secured_reply = Fabricate(:post, user: staff, topic: secured_topic, post_number: 2) expect(preview(public_topic.relative_url, user, public_category)).to include(public_topic.title) - expect(preview(public_post.url, user, public_category)).to include(public_topic.title) + onebox = preview(public_post.url, user, public_category) + expect(onebox).to include(public_topic.title) + expect(onebox).to include("/images/emoji/") onebox = preview(public_reply.url, user, public_category) expect(onebox).to include(public_reply.excerpt) From a9699da67290905174879ef971eb6876e30bf675 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 26 Feb 2018 18:28:47 +0800 Subject: [PATCH 136/299] UX: Specify pattern and maxlength for 2FA input fields. --- .../discourse/templates/mobile/modal/login.hbs | 2 ++ .../javascripts/discourse/templates/modal/login.hbs | 8 +++++++- .../discourse/templates/preferences-second-factor.hbs | 6 +++++- app/views/session/email_login.html.erb | 2 +- app/views/users/admin_login.html.erb | 2 +- 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs b/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs index 41353ac8ed..5f0336aa76 100644 --- a/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs +++ b/app/assets/javascripts/discourse/templates/mobile/modal/login.hbs @@ -46,6 +46,8 @@
{{#second-factor-form}} {{text-field value=loginSecondFactor + pattern='[0-9]{6}' + maxlength='6' id="login-second-factor" autocorrect="off" autocapitalize="off" diff --git a/app/assets/javascripts/discourse/templates/modal/login.hbs b/app/assets/javascripts/discourse/templates/modal/login.hbs index 37ce830c25..3e0889be68 100644 --- a/app/assets/javascripts/discourse/templates/modal/login.hbs +++ b/app/assets/javascripts/discourse/templates/modal/login.hbs @@ -34,7 +34,13 @@ {{#second-factor-form}} - {{text-field value=loginSecondFactor id="login-second-factor" autocorrect="off" autocapitalize="off" autofocus="autofocus"}} + {{text-field value=loginSecondFactor + pattern='[0-9]{6}' + maxlength='6' + id="login-second-factor" + autocorrect="off" + autocapitalize="off" + autofocus="autofocus"}} {{/second-factor-form}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs index a1b6f7ef1a..b85a063b70 100644 --- a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs +++ b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs @@ -20,7 +20,9 @@
{{text-field value=second_factor_token - id="second_factor_token" + pattern='[0-9]{6}' + maxlength='6' + id="second-factor-token" classNames="input-large" autofocus="autofocus"}}
@@ -69,6 +71,8 @@
{{text-field value=second_factor_token + pattern='[0-9]{6}' + maxlength='6' id="second-factor-token" classNames="input-large" autofocus="autofocus"}} diff --git a/app/views/session/email_login.html.erb b/app/views/session/email_login.html.erb index 43d988162f..6e84c22490 100644 --- a/app/views/session/email_login.html.erb +++ b/app/views/session/email_login.html.erb @@ -10,7 +10,7 @@ <%= form_tag(method: "post") do%>

<%=t "login.second_factor_title" %>

<%= label_tag(:second_factor_token, t("login.second_factor_description")) %> -
<%= text_field_tag(:second_factor_token) %>
+
<%= text_field_tag(:second_factor_token, pattern: '[0-9]{6}', maxlength: 6) %>
<%= submit_tag(t("submit"), class: "btn btn-large btn-primary") %> <%end%>
diff --git a/app/views/users/admin_login.html.erb b/app/views/users/admin_login.html.erb index ed372e49d6..ee3c941ac7 100644 --- a/app/views/users/admin_login.html.erb +++ b/app/views/users/admin_login.html.erb @@ -10,7 +10,7 @@ <% if @second_factor_required %> <%=form_tag({}, method: :put) do %> <%= label_tag(:second_factor_token, t('login.second_factor_description')) %> - <%= text_field_tag(:second_factor_token, nil, autofocus: true) %>

+ <%= text_field_tag(:second_factor_token, nil, autofocus: true, pattern: '[0-9]{6}', maxlength: 6) %>

<%= submit_tag t('submit')%> <% end %> <% end %> From ac701696b34866e4ea4b032c38066bcf01e33a1f Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Mon, 26 Feb 2018 11:42:57 +0100 Subject: [PATCH 137/299] FEATURE: replaces tag-chooser/tag-group-chooser with select-kit component These component were also the last using select2. As a consequence select2 is removed from Discourse in this commit. --- .../discourse/components/tag-chooser.js.es6 | 142 - .../components/tag-group-chooser.js.es6 | 86 - .../controllers/preferences/categories.js.es6 | 4 +- .../controllers/preferences/tags.js.es6 | 7 +- .../discourse/templates/bulk-tag.hbs | 2 +- .../components/edit-category-tags.hbs | 8 +- .../templates/components/queued-post.hbs | 2 +- .../components/search-advanced-options.hbs | 7 +- .../discourse/templates/preferences/tags.hbs | 32 +- .../discourse/templates/tag-groups-show.hbs | 11 +- .../javascripts/discourse/templates/topic.hbs | 2 +- .../components/mini-tag-chooser.js.es6 | 138 +- .../select-kit/components/multi-select.js.es6 | 47 +- .../multi-select/selected-name.js.es6 | 28 +- .../select-kit/components/select-kit.js.es6 | 46 +- .../select-kit/select-kit-filter.js.es6 | 11 +- .../components/single-select.js.es6 | 22 +- .../select-kit/components/tag-chooser.js.es6 | 107 + .../components/tag-group-chooser.js.es6 | 79 + .../javascripts/select-kit/mixins/tags.js.es6 | 52 + .../templates/components/multi-select.hbs | 50 + .../multi-select/multi-select-header.hbs | 7 +- .../multi-select/selected-category.hbs | 4 - .../components/multi-select/selected-name.hbs | 10 - .../select-kit/select-kit-collection.hbs | 2 +- .../select-kit/select-kit-filter.hbs | 2 +- .../{select-kit.hbs => single-select.hbs} | 8 +- app/assets/javascripts/vendor.js | 1 - app/assets/javascripts/wizard-vendor.js | 1 - app/assets/stylesheets/common.scss | 1 - .../stylesheets/common/admin/admin_base.scss | 20 +- .../stylesheets/common/admin/customize.scss | 2 +- .../stylesheets/common/base/compose.scss | 4 - app/assets/stylesheets/common/base/modal.scss | 4 + .../stylesheets/common/base/search.scss | 14 +- .../stylesheets/common/base/tagging.scss | 49 +- app/assets/stylesheets/common/base/topic.scss | 10 +- app/assets/stylesheets/common/base/user.scss | 19 +- .../common/select-kit/legacy-combo-box.scss | 88 - .../common/select-kit/mini-tag-chooser.scss | 3 +- .../common/select-kit/multi-select.scss | 86 +- .../common/select-kit/select-kit.scss | 1 + .../common/select-kit/tag-chooser.scss | 14 + app/assets/stylesheets/desktop/modal.scss | 8 - .../stylesheets/desktop/queued-posts.scss | 9 + app/assets/stylesheets/desktop/topic.scss | 5 +- app/assets/stylesheets/desktop/user.scss | 8 +- app/assets/stylesheets/mobile/topic.scss | 5 - app/assets/stylesheets/vendor/select2.scss | 575 --- app/assets/stylesheets/wizard.scss | 5 - .../finish_installation/register.html.erb | 6 - .../acceptance/search-full-test.js.es6 | 24 - vendor/assets/javascripts/select2.js | 3729 ----------------- 53 files changed, 637 insertions(+), 4970 deletions(-) delete mode 100644 app/assets/javascripts/discourse/components/tag-chooser.js.es6 delete mode 100644 app/assets/javascripts/discourse/components/tag-group-chooser.js.es6 create mode 100644 app/assets/javascripts/select-kit/components/tag-chooser.js.es6 create mode 100644 app/assets/javascripts/select-kit/components/tag-group-chooser.js.es6 create mode 100644 app/assets/javascripts/select-kit/mixins/tags.js.es6 create mode 100644 app/assets/javascripts/select-kit/templates/components/multi-select.hbs rename app/assets/javascripts/select-kit/templates/components/{select-kit.hbs => single-select.hbs} (86%) delete mode 100644 app/assets/stylesheets/common/select-kit/legacy-combo-box.scss create mode 100644 app/assets/stylesheets/common/select-kit/tag-chooser.scss delete mode 100644 app/assets/stylesheets/vendor/select2.scss delete mode 100644 vendor/assets/javascripts/select2.js diff --git a/app/assets/javascripts/discourse/components/tag-chooser.js.es6 b/app/assets/javascripts/discourse/components/tag-chooser.js.es6 deleted file mode 100644 index 9f962596e4..0000000000 --- a/app/assets/javascripts/discourse/components/tag-chooser.js.es6 +++ /dev/null @@ -1,142 +0,0 @@ -import renderTag from 'discourse/lib/render-tag'; - -function formatTag(t) { - return renderTag(t.id, {count: t.count, noHref: true}); -} - -export default Ember.TextField.extend({ - classNameBindings: [':tag-chooser'], - attributeBindings: ['tabIndex', 'placeholderKey', 'categoryId'], - - init() { - this._super(); - const tags = this.get('tags') || []; - this.set('value', tags.join(", ")); - - if (this.get('allowCreate') !== false) { - this.set('allowCreate', this.site.get('can_create_tag')); - } - - this.set('termMatchesForbidden', false); - }, - - _valueChanged: function() { - const tags = this.get('value').split(',').map(v => v.trim()).reject(v => v.length === 0).uniq(); - this.set('tags', tags); - }.observes('value'), - - _tagsChanged: function() { - const $tagChooser = this.$(), - val = this.get('value'); - - if ($tagChooser && val !== this.get('tags')) { - if (this.get('tags')) { - const data = this.get('tags').map((t) => {return {id: t, text: t};}); - $tagChooser.select2('data', data); - } else { - $tagChooser.select2('data', []); - } - } - }.observes('tags'), - - didInsertElement() { - this._super(); - - const self = this; - const filterRegexp = new RegExp(this.site.tags_filter_regexp, "g"); - - let limit = this.siteSettings.max_tags_per_topic; - - if (this.get('unlimitedTagCount')) { - limit = null; - } else if (this.get('limit')) { - limit = parseInt(this.get('limit')); - } - - this.$().select2({ - tags: true, - placeholder: this.get('placeholder') === "" ? "" : I18n.t(this.get('placeholderKey') || 'tagging.choose_for_topic'), - maximumInputLength: this.siteSettings.max_tag_length, - maximumSelectionSize: limit, - width: this.get('width') || 'resolve', - initSelection(element, callback) { - const data = []; - - function splitVal(string, separator) { - var val, i, l; - if (string === null || string.length < 1) return []; - val = string.split(separator); - for (i = 0, l = val.length; i < l; i = i + 1) val[i] = $.trim(val[i]); - return val; - } - - $(splitVal(element.val(), ",")).each(function () { - data.push({ - id: this, - text: this - }); - }); - - callback(data); - }, - createSearchChoice(term, data) { - term = term.replace(filterRegexp, '').trim().toLowerCase(); - - // No empty terms, make sure the user has permission to create the tag - if (!term.length || !self.get('allowCreate') || self.get('termMatchesForbidden')) return; - - if ($(data).filter(function() { - return this.text.localeCompare(term) === 0; - }).length === 0) { - return { id: term, text: term }; - } - }, - createSearchChoicePosition(list, item) { - // Search term goes on the bottom - list.push(item); - }, - formatSelection(data) { - return data ? renderTag(this.text(data), {noHref: true}) : undefined; - }, - formatSelectionCssClass() { - return "discourse-tag-select2"; - }, - formatResult: formatTag, - multiple: true, - ajax: { - quietMillis: 200, - cache: true, - url: Discourse.getURL("/tags/filter/search"), - dataType: 'json', - data: function (term) { - const selectedTags = self.get('tags'); - const d = { - q: term, - limit: self.siteSettings.max_tag_search_results, - categoryId: self.get('categoryId') - }; - if (selectedTags) { - d.selected_tags = selectedTags.slice(0,100); - } - if (!self.get('everyTag')) { - d.filterForInput = true; - } - return d; - }, - results: function (data) { - if (self.siteSettings.tags_sort_alphabetically) { - data.results = data.results.sort(function(a,b) { return a.id > b.id; }); - } - self.set('termMatchesForbidden', data.forbidden ? true : false); - return data; - } - }, - }); - }, - - willDestroyElement() { - this._super(); - this.$().select2('destroy'); - } - -}); diff --git a/app/assets/javascripts/discourse/components/tag-group-chooser.js.es6 b/app/assets/javascripts/discourse/components/tag-group-chooser.js.es6 deleted file mode 100644 index efe35db5bb..0000000000 --- a/app/assets/javascripts/discourse/components/tag-group-chooser.js.es6 +++ /dev/null @@ -1,86 +0,0 @@ -function renderTagGroup(tag) { - return "" + Handlebars.Utils.escapeExpression(tag.text ? tag.text : tag) + ""; -}; - -export default Ember.TextField.extend({ - classNameBindings: [':tag-chooser'], - attributeBindings: ['tabIndex', 'placeholderKey', 'categoryId'], - - _initValue: function() { - const names = this.get('tagGroups') || []; - this.set('value', names.join(",")); - }.on('init'), - - _valueChanged: function() { - const names = this.get('value').split(',').map(v => v.trim()).reject(v => v.length === 0).uniq(); - if ( this.get('tagGroups').join(',') !== this.get('value') ) { - this.set('tagGroups', names); - } - }.observes('value'), - - _tagGroupsChanged: function() { - const $chooser = this.$(), - val = this.get('value'); - - if ($chooser && val !== this.get('tagGroups')) { - if (this.get('tagGroups')) { - const data = this.get('tagGroups').map((t) => {return {id: t, text: t};}); - $chooser.select2('data', data); - } else { - $chooser.select2('data', []); - } - } - }.observes('tagGroups'), - - _initializeChooser: function() { - const self = this; - - this.$().select2({ - tags: true, - placeholder: this.get('placeholderKey') ? I18n.t(this.get('placeholderKey')) : null, - initSelection(element, callback) { - const data = []; - - function splitVal(string, separator) { - var val, i, l; - if (string === null || string.length < 1) return []; - val = string.split(separator); - for (i = 0, l = val.length; i < l; i = i + 1) val[i] = $.trim(val[i]); - return val; - } - - $(splitVal(element.val(), ",")).each(function () { - data.push({ id: this, text: this }); - }); - - callback(data); - }, - formatSelection: function (data) { - return data ? renderTagGroup(this.text(data)) : undefined; - }, - formatSelectionCssClass: function(){ - return "discourse-tag-select2"; - }, - formatResult: renderTagGroup, - multiple: true, - ajax: { - quietMillis: 200, - cache: true, - url: Discourse.getURL("/tag_groups/filter/search"), - dataType: 'json', - data: function (term) { - return { q: term, limit: self.siteSettings.max_tag_search_results }; - }, - results: function (data) { - data.results = data.results.sort(function(a,b) { return a.text > b.text; }); - return data; - } - }, - }); - }.on('didInsertElement'), - - _destroyChooser: function() { - this.$().select2('destroy'); - }.on('willDestroyElement') - -}); diff --git a/app/assets/javascripts/discourse/controllers/preferences/categories.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/categories.js.es6 index f394acd3e1..6e27ea04e0 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/categories.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/categories.js.es6 @@ -1,6 +1,6 @@ import PreferencesTabController from "discourse/mixins/preferences-tab-controller"; import { popupAjaxError } from 'discourse/lib/ajax-error'; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import computed from "ember-addons/ember-computed-decorators"; export default Ember.Controller.extend(PreferencesTabController, { saveAttrNames: [ @@ -12,7 +12,7 @@ export default Ember.Controller.extend(PreferencesTabController, { @computed("model.watchedCategories", "model.watchedFirstPostCategories", "model.trackedCategories", "model.mutedCategories") selectedCategories(watched, watchedFirst, tracked, muted) { - return [].concat(watched, watchedFirst, tracked, muted); + return [].concat(watched, watchedFirst, tracked, muted).filter(t => t); }, canSave: function() { diff --git a/app/assets/javascripts/discourse/controllers/preferences/tags.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/tags.js.es6 index 6264d42b2d..b7e0ab97cf 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/tags.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/tags.js.es6 @@ -1,8 +1,8 @@ import PreferencesTabController from "discourse/mixins/preferences-tab-controller"; import { popupAjaxError } from 'discourse/lib/ajax-error'; +import computed from "ember-addons/ember-computed-decorators"; export default Ember.Controller.extend(PreferencesTabController, { - saveAttrNames: [ 'muted_tags', 'tracked_tags', @@ -10,6 +10,11 @@ export default Ember.Controller.extend(PreferencesTabController, { 'watching_first_post_tags' ], + @computed("model.watched_tags", "model.watching_first_post_tags", "model.tracked_tags", "model.muted_tags") + selectedTags(watched, watchedFirst, tracked, muted) { + return [].concat(watched, watchedFirst, tracked, muted).filter(t => t); + }, + actions: { save() { this.set('saved', false); diff --git a/app/assets/javascripts/discourse/templates/bulk-tag.hbs b/app/assets/javascripts/discourse/templates/bulk-tag.hbs index 4ed48ddc2b..3a54edf758 100644 --- a/app/assets/javascripts/discourse/templates/bulk-tag.hbs +++ b/app/assets/javascripts/discourse/templates/bulk-tag.hbs @@ -1,5 +1,5 @@

{{i18n (concat "topics.bulk." title)}}

-

{{tag-chooser tags=tags categoryId=categoryId}}

+

{{tag-chooser filterPlaceholder=null tags=tags categoryId=categoryId}}

{{d-button action=action disabled=emptyTags label=(concat "topics.bulk." label)}} diff --git a/app/assets/javascripts/discourse/templates/components/edit-category-tags.hbs b/app/assets/javascripts/discourse/templates/components/edit-category-tags.hbs index df6544d770..c8ea053636 100644 --- a/app/assets/javascripts/discourse/templates/components/edit-category-tags.hbs +++ b/app/assets/javascripts/discourse/templates/components/edit-category-tags.hbs @@ -1,7 +1,11 @@

{{i18n 'category.tags_allowed_tags'}}

- {{tag-chooser placeholderKey="category.tags_placeholder" tags=category.allowed_tags everyTag="true" unlimitedTagCount="true"}} + {{tag-chooser + filterPlaceholder="category.tags_placeholder" + tags=category.allowed_tags + everyTag=true + unlimitedTagCount=true}}

{{i18n 'category.tags_allowed_tag_groups'}}

- {{tag-group-chooser placeholderKey="category.tag_groups_placeholder" tagGroups=category.allowed_tag_groups}} + {{tag-group-chooser tagGroups=category.allowed_tag_groups}}
diff --git a/app/assets/javascripts/discourse/templates/components/queued-post.hbs b/app/assets/javascripts/discourse/templates/components/queued-post.hbs index de2709ca4a..99162937d1 100644 --- a/app/assets/javascripts/discourse/templates/components/queued-post.hbs +++ b/app/assets/javascripts/discourse/templates/components/queued-post.hbs @@ -52,7 +52,7 @@ {{/each}}
{{else if editTags}} - {{tag-chooser tags=buffered.tags categoryId=buffered.category_id width='100%'}} + {{tag-chooser tags=buffered.tags categoryId=buffered.category_id}} {{/if}}
diff --git a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs index f53a0e0c50..3ce9b3d84f 100644 --- a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs +++ b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs @@ -40,7 +40,12 @@
- {{tag-chooser tags=searchedTerms.tags blacklist=searchedTerms.tags allowCreate=false placeholder="" everyTag="true" unlimitedTagCount="true" width="70%"}} + {{tag-chooser + tags=searchedTerms.tags + allowCreate=false + filterPlaceholder=null + everyTag=true + unlimitedTagCount=true}}
diff --git a/app/assets/javascripts/discourse/templates/preferences/tags.hbs b/app/assets/javascripts/discourse/templates/preferences/tags.hbs index 1c24147a2f..af170058de 100644 --- a/app/assets/javascripts/discourse/templates/preferences/tags.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/tags.hbs @@ -5,25 +5,49 @@
- {{tag-chooser tags=model.watched_tags blacklist=selectedTags allowCreate=false placeholder="" everyTag="true" unlimitedTagCount="true"}} + {{tag-chooser + tags=model.watched_tags + blacklist=selectedTags + filterPlaceholder=null + allowCreate=false + everyTag=true + unlimitedTagCount=true}}
{{i18n 'user.watched_tags_instructions'}}
- {{tag-chooser tags=model.tracked_tags blacklist=selectedTags allowCreate=false placeholder="" everyTag="true" unlimitedTagCount="true"}} + {{tag-chooser + tags=model.tracked_tags + blacklist=selectedTags + filterPlaceholder=null + allowCreate=false + everyTag=true + unlimitedTagCount=true}}
{{i18n 'user.tracked_tags_instructions'}}
- {{tag-chooser tags=model.watching_first_post_tags blacklist=selectedTags allowCreate=false placeholder="" everyTag="true" unlimitedTagCount="true"}} + {{tag-chooser + tags=model.watching_first_post_tags + blacklist=selectedTags + filterPlaceholder=null + allowCreate=false + everyTag=true + unlimitedTagCount=true}}
{{i18n 'user.watched_first_post_tags_instructions'}}
- {{tag-chooser tags=model.muted_tags blacklist=selectedTags allowCreate=false placeholder="" everyTag="true" unlimitedTagCount="true"}} + {{tag-chooser + tags=model.muted_tags + blacklist=selectedTags + filterPlaceholder=null + allowCreate=false + everyTag=true + unlimitedTagCount=true}}
{{i18n 'user.muted_tags_instructions'}}
diff --git a/app/assets/javascripts/discourse/templates/tag-groups-show.hbs b/app/assets/javascripts/discourse/templates/tag-groups-show.hbs index 26fde8830f..dec3a0e3e9 100644 --- a/app/assets/javascripts/discourse/templates/tag-groups-show.hbs +++ b/app/assets/javascripts/discourse/templates/tag-groups-show.hbs @@ -3,12 +3,19 @@

- {{tag-chooser tags=model.tag_names everyTag="true" unlimitedTagCount="true"}} + {{tag-chooser + tags=model.tag_names + everyTag=true + unlimitedTagCount=true}}
- {{tag-chooser tags=model.parent_tag_name everyTag="true" limit="1" placeholderKey="tagging.groups.parent_tag_placeholder"}} + {{tag-chooser + tags=model.parent_tag_name + everyTag=true + limit=1 + filterPlaceholder="tagging.groups.parent_tag_placeholder"}} {{i18n 'tagging.groups.parent_tag_description'}}
diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index 167ffcf138..810679b374 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -22,7 +22,7 @@ {{/if}} {{#if canEditTags}} - {{tag-chooser tags=buffered.tags categoryId=buffered.category_id}} + {{mini-tag-chooser tags=buffered.tags categoryId=buffered.category_id}} {{/if}} {{plugin-outlet name="edit-topic" args=(hash model=model buffered=buffered)}} diff --git a/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 index d28f7a1caa..1a9d402890 100644 --- a/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 +++ b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 @@ -1,22 +1,22 @@ import ComboBox from "select-kit/components/combo-box"; -import { ajax } from "discourse/lib/ajax"; -import { popupAjaxError } from 'discourse/lib/ajax-error'; +import Tags from "select-kit/mixins/tags"; import { default as computed } from "ember-addons/ember-computed-decorators"; import renderTag from "discourse/lib/render-tag"; -const { get, isEmpty, isPresent, run, makeArray } = Ember; +const { get, isEmpty, run, makeArray } = Ember; -export default ComboBox.extend({ +export default ComboBox.extend(Tags, { allowContentReplacement: true, pluginApiIdentifiers: ["mini-tag-chooser"], + attributeBindings: ["categoryId"], classNames: ["mini-tag-chooser"], classNameBindings: ["noTags"], verticalOffset: 3, filterable: true, - noTags: Ember.computed.empty("computedTags"), + noTags: Ember.computed.empty("selectedTags"), allowAny: true, - maximumSelectionSize: Ember.computed.alias("siteSettings.max_tags_per_topic"), caretUpIcon: Ember.computed.alias("caretIcon"), caretDownIcon: Ember.computed.alias("caretIcon"), + isAsync: true, init() { this._super(); @@ -30,17 +30,8 @@ export default ComboBox.extend({ noHref: true }); }); - }, - @computed("limitReached", "maximumSelectionSize") - maxContentRow(limitReached, count) { - if (limitReached) { - return I18n.t("select_kit.max_content_reached", { count }); - } - }, - - mutateAttributes() { - this.set("value", null); + this.set("limit", parseInt(this.get("limit") || this.get("siteSettings.max_tags_per_topic"))); }, @computed("limitReached") @@ -48,9 +39,9 @@ export default ComboBox.extend({ return limitReached ? null : "plus"; }, - @computed("computedTags.[]", "maximumSelectionSize") - limitReached(computedTags, maximumSelectionSize) { - if (computedTags.length >= maximumSelectionSize) { + @computed("selectedTags.[]", "limit") + limitReached(selectedTags, limit) { + if (selectedTags.length >= limit) { return true; } @@ -58,33 +49,10 @@ export default ComboBox.extend({ }, @computed("tags") - computedTags(tags) { + selectedTags(tags) { return makeArray(tags); }, - validateCreate(term) { - if (this.get("limitReached") || !this.site.get("can_create_tag")) { - return false; - } - - const filterRegexp = new RegExp(this.site.tags_filter_regexp, "g"); - term = term.replace(filterRegexp, "").trim().toLowerCase(); - - if (!term.length || this.get("termMatchesForbidden")) { - return false; - } - - if (this.get("siteSettings.max_tag_length") < term.length) { - return false; - } - - return true; - }, - - validateSelect() { - return this.get("computedTags").length < this.get("siteSettings.max_tags_per_topic"); - }, - filterComputedContent(computedContent) { return computedContent; }, @@ -92,7 +60,7 @@ export default ComboBox.extend({ didRender() { this._super(); - this.$().on("click.mini-tag-chooser", ".selected-tag", (event) => { + $(".select-kit-body").on("click.mini-tag-chooser", ".selected-tag", (event) => { event.stopImmediatePropagation(); this.send("removeTag", $(event.target).attr("data-value")); }); @@ -102,9 +70,6 @@ export default ComboBox.extend({ this._super(); $(".select-kit-body").off("click.mini-tag-chooser"); - - const searchDebounce = this.get("searchDebounce"); - if (isPresent(searchDebounce)) { run.cancel(searchDebounce); } }, didPressEscape(event) { @@ -167,9 +132,9 @@ export default ComboBox.extend({ computeHeaderContent() { let content = this.baseHeaderComputedContent(); - const joinedTags = this.get("computedTags").join(", "); + const joinedTags = this.get("selectedTags").join(", "); - if (isEmpty(this.get("computedTags"))) { + if (isEmpty(this.get("selectedTags"))) { content.label = I18n.t("tagging.choose_for_topic"); } else { content.label = joinedTags; @@ -182,80 +147,61 @@ export default ComboBox.extend({ actions: { removeTag(tag) { - let tags = this.get("computedTags"); + let tags = this.get("selectedTags"); delete tags[tags.indexOf(tag)]; this.set("tags", tags.filter(t => t)); - this.set("content", []); - this.set("searchDebounce", run.debounce(this, this._searchTags, this.get("filter"), 250)); + this.set("searchDebounce", run.debounce(this, this.prepareSearch, this.get("filter"), 200)); }, onExpand() { - if (isEmpty(this.get("content"))) { - this.set("searchDebounce", run.debounce(this, this._searchTags, this.get("filter"), 250)); + if (isEmpty(this.get("collectionComputedContent"))) { + this.set("searchDebounce", run.debounce(this, this.prepareSearch, this.get("filter"), 200)); } }, onFilter(filter) { filter = isEmpty(filter) ? null : filter; - this.set("searchDebounce", run.debounce(this, this._searchTags, filter, 250)); + this.set("searchDebounce", run.debounce(this, this.prepareSearch, filter, 200)); }, onSelect(tag) { - if (isEmpty(this.get("computedTags"))) { + if (isEmpty(this.get("selectedTags"))) { this.set("tags", makeArray(tag)); } else { - this.set("tags", this.get("computedTags").concat(tag)); + this.set("tags", this.get("selectedTags").concat(tag)); } - this.set("content", []); - this.set("searchDebounce", run.debounce(this, this._searchTags, this.get("filter"), 250)); + this.set("searchDebounce", run.debounce(this, this.prepareSearch, this.get("filter"), 50)); + + this.autoHighlight(); } }, - _searchTags(query) { - this.startLoading(); - - const self = this; - const selectedTags = makeArray(this.get("computedTags")).filter(t => t); - const sortTags = this.siteSettings.tags_sort_alphabetically; + prepareSearch(query) { const data = { q: query, - limit: this.siteSettings.max_tag_search_results, + limit: this.get("siteSettings.max_tag_search_results"), categoryId: this.get("categoryId") }; + if (this.get("selectedTags")) data.selected_tags = this.get("selectedTags").slice(0, 100); + if (!this.get("everyTag")) data.filterForInput = true; - if (selectedTags) { - data.selected_tags = selectedTags.slice(0, 100); + this.searchTags("/tags/filter/search", data, this._transformJson); + }, + + _transformJson(context, json) { + let results = json.results; + + context.set("termMatchesForbidden", json.forbidden ? true : false); + + if (context.get("siteSettings.tags_sort_alphabetically")) { + results = results.sort((a, b) => a.id > b.id); } - ajax(Discourse.getURL("/tags/filter/search"), { - quietMillis: 200, - cache: true, - dataType: "json", - data, - }).then(json => { - let results = json.results; + results = results.filter(r => !context.get("selectedTags").includes(r.id)); - self.set("termMatchesForbidden", json.forbidden ? true : false); - - if (sortTags) { - results = results.sort((a, b) => a.id > b.id); - } - - const content = results.map((result) => { - return { - id: result.text, - name: result.text, - count: result.count - }; - }).filter(c => !selectedTags.includes(c.id)); - - self.set("content", content); - self.stopLoading(); - this.autoHighlight(); - }).catch(error => { - self.stopLoading(); - popupAjaxError(error); - }); + return results.map(result => { + return { id: result.text, name: result.text, count: result.count }; + }); } }); diff --git a/app/assets/javascripts/select-kit/components/multi-select.js.es6 b/app/assets/javascripts/select-kit/components/multi-select.js.es6 index e3e0ad9a13..b74b371c59 100644 --- a/app/assets/javascripts/select-kit/components/multi-select.js.es6 +++ b/app/assets/javascripts/select-kit/components/multi-select.js.es6 @@ -8,14 +8,15 @@ import { export default SelectKitComponent.extend({ pluginApiIdentifiers: ["multi-select"], + layoutName: "select-kit/templates/components/multi-select", classNames: "multi-select", headerComponent: "multi-select/multi-select-header", - filterComponent: null, headerText: "select_kit.default_header_text", allowAny: true, allowInitialValueMutation: false, autoFilterable: true, selectedNameComponent: "multi-select/selected-name", + filterIcon: null, init() { this._super(); @@ -38,16 +39,22 @@ export default SelectKitComponent.extend({ _compute() { Ember.run.scheduleOnce("afterRender", () => { this.willComputeAttributes(); - let content = this.willComputeContent(this.get("content") || []); + let content = this.get("content") || []; + let asyncContent = this.get("asyncContent") || []; + content = this.willComputeContent(content); + asyncContent = this.willComputeAsyncContent(asyncContent); let values = this._beforeWillComputeValues(this.get("values")); content = this.computeContent(content); + asyncContent = this.computeAsyncContent(asyncContent); content = this._beforeDidComputeContent(content); + asyncContent = this._beforeDidComputeAsyncContent(asyncContent); values = this.willComputeValues(values); values = this.computeValues(values); values = this._beforeDidComputeValues(values); this._setHeaderComputedContent(); this._setCollectionHeaderComputedContent(); this.didComputeContent(content); + this.didComputeAsyncContent(asyncContent); this.didComputeValues(values); this.didComputeAttributes(); }); @@ -102,6 +109,19 @@ export default SelectKitComponent.extend({ }); }, + @computed("computedAsyncContent.[]", "computedValues.[]") + filteredAsyncComputedContent(computedAsyncContent, computedValues) { + computedAsyncContent = computedAsyncContent.filter(c => { + return !computedValues.includes(get(c, "value")); + }); + + if (this.get("limitMatches")) { + return computedAsyncContent.slice(0, this.get("limitMatches")); + } + + return computedAsyncContent; + }, + @computed("computedContent.[]", "computedValues.[]", "filter") filteredComputedContent(computedContent, computedValues, filter) { computedContent = computedContent.filter(c => { @@ -133,6 +153,16 @@ export default SelectKitComponent.extend({ }; }, + @computed("limit", "computedValues.[]") + limitReached(limit, computedValues) { + if (!limit) return false; + return computedValues.length >= limit; + }, + + validateSelect() { + return this._super() && !this.get("limitReached"); + }, + didPressBackspace(event) { this.expand(event); this.keyDown(event); @@ -178,16 +208,16 @@ export default SelectKitComponent.extend({ if ($lastSelectedValue.length === 0) { return; } if ($filterInput.not(":visible") && $lastSelectedValue.length > 0) { - $lastSelectedValue.click(); + $lastSelectedValue.trigger("backspace"); return false; } if ($filterInput.val() === "") { if ($filterInput.is(":focus")) { - if ($lastSelectedValue.length > 0) { $lastSelectedValue.click(); } + if ($lastSelectedValue.length > 0) { $lastSelectedValue.trigger("backspace"); } } else { if ($lastSelectedValue.length > 0) { - $lastSelectedValue.click(); + $lastSelectedValue.trigger("backspace"); } else { $filterInput.focus(); } @@ -217,14 +247,14 @@ export default SelectKitComponent.extend({ if (!this.get("renderedBodyOnce")) return; if (!isNone(this.get("highlightedValue"))) return; - if (isEmpty(this.get("filteredComputedContent"))) { + if (isEmpty(this.get("collectionComputedContent"))) { if (this.get("createRowComputedContent")) { this.send("highlight", this.get("createRowComputedContent")); } else if (this.get("noneRowComputedContent") && this.get("hasSelection")) { this.send("highlight", this.get("noneRowComputedContent")); } } else { - this.send("highlight", this.get("filteredComputedContent.firstObject")); + this.send("highlight", this.get("collectionComputedContent.firstObject")); } }); }, @@ -247,9 +277,10 @@ export default SelectKitComponent.extend({ this.set("highlightedValue", null); }, - didDeselect() { + didDeselect(rowComputedContentItems) { this.focusFilterOrHeader(); this.autoHighlight(); + this._boundaryActionHandler("onDeselect", rowComputedContentItems); }, actions: { diff --git a/app/assets/javascripts/select-kit/components/multi-select/selected-name.js.es6 b/app/assets/javascripts/select-kit/components/multi-select/selected-name.js.es6 index a6b672d50e..eff2b9716d 100644 --- a/app/assets/javascripts/select-kit/components/multi-select/selected-name.js.es6 +++ b/app/assets/javascripts/select-kit/components/multi-select/selected-name.js.es6 @@ -28,6 +28,20 @@ export default Ember.Component.extend({ return null; }, + didInsertElement() { + this._super(); + + $(this.element).on("backspace.selected-name", () => { + this._handleBackspace(); + }); + }, + + willDestroyElement() { + this._super(); + + $(this.element).off("backspace.selected-name"); + }, + label: Ember.computed.or("computedContent.label", "title", "name"), name: Ember.computed.alias("computedContent.name"), @@ -39,8 +53,18 @@ export default Ember.Component.extend({ }), click() { - if (this.get("isLocked") === true) { return false; } - this.toggleProperty("isHighlighted"); + if (this.get("isLocked") === true) return false; + this.sendAction("deselect", [this.get("computedContent")]); return false; + }, + + _handleBackspace() { + if (this.get("isLocked") === true) return false; + + if (this.get("isHighlighted")) { + this.sendAction("deselect", [this.get("computedContent")]); + } else { + this.set("isHighlighted", true); + } } }); diff --git a/app/assets/javascripts/select-kit/components/select-kit.js.es6 b/app/assets/javascripts/select-kit/components/select-kit.js.es6 index b8bdf5c667..b841880508 100644 --- a/app/assets/javascripts/select-kit/components/select-kit.js.es6 +++ b/app/assets/javascripts/select-kit/components/select-kit.js.es6 @@ -32,6 +32,7 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi isFocused: false, isHidden: false, isLoading: false, + isAsync: false, renderedBodyOnce: false, renderedFilterOnce: false, tabindex: 0, @@ -69,8 +70,6 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi allowContentReplacement: false, collectionHeader: null, allowAutoSelectFirst: true, - maximumSelectionSize: null, - maxContentRow: null, init() { this._super(); @@ -91,11 +90,16 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi if (this.get("allowContentReplacement")) { this.addObserver(`content.[]`, this, this._compute); } + + if (this.get("isAsync")) { + this.addObserver(`asyncContent.[]`, this, this._compute); + } }, willDestroyElement() { this.removeObserver(`content.@each.${this.get("nameProperty")}`, this, this._compute); this.removeObserver(`content.[]`, this, this._compute); + this.removeObserver(`asyncContent.[]`, this, this._compute); }, willComputeAttributes() {}, @@ -114,6 +118,17 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi }, didComputeContent() {}, + willComputeAsyncContent(content) { return content; }, + computeAsyncContent(content) { return content; }, + _beforeDidComputeAsyncContent(content) { + content = applyContentPluginApiCallbacks(this.get("pluginApiIdentifiers"), content, this); + this.setProperties({ + computedAsyncContent: content.map(c => this.computeAsyncContentItem(c)) + }); + return content; + }, + didComputeAsyncContent() {}, + computeHeaderContent() { return this.baseHeaderComputedContent(); }, @@ -122,6 +137,15 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi return this.baseComputedContentItem(contentItem, options); }, + computeAsyncContentItem(contentItem, options) { + return this.computeContentItem(contentItem, options); + }, + + @computed("isAsync", "filteredAsyncComputedContent", "filteredComputedContent") + collectionComputedContent(isAsync, filteredAsyncComputedContent, filteredComputedContent) { + return isAsync ? filteredAsyncComputedContent : filteredComputedContent; + }, + validateCreate() { return true; }, validateSelect() { return true; }, @@ -155,13 +179,20 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi return false; }, - @computed("filter", "filteredComputedContent.[]") - noContentRow(filter, filteredComputedContent) { - if (filter.length > 0 && filteredComputedContent.length === 0) { + @computed("filter", "collectionComputedContent.[]") + noContentRow(filter, collectionComputedContent) { + if (filter.length > 0 && collectionComputedContent.length === 0) { return I18n.t("select_kit.no_content"); } }, + @computed("limitReached", "limit") + maxContentRow(limitReached, limit) { + if (limitReached) { + return I18n.t("select_kit.max_content_reached", { count: limit }); + } + }, + @computed("filter", "filterable", "autoFilterable", "renderedFilterOnce") shouldFilter(filter, filterable, autoFilterable, renderedFilterOnce) { if (renderedFilterOnce && filterable) return true; @@ -170,8 +201,9 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi return false; }, - @computed("filter", "computedContent") - shouldDisplayCreateRow(filter, computedContent) { + @computed("filter", "computedContent", "limitReached") + shouldDisplayCreateRow(filter, computedContent, limitReached) { + if (limitReached) return false; if (computedContent.map(c => c.value).includes(filter)) return false; if (this.get("allowAny") && filter.length > 0 && this.validateCreate(filter)) return true; return false; diff --git a/app/assets/javascripts/select-kit/components/select-kit/select-kit-filter.js.es6 b/app/assets/javascripts/select-kit/components/select-kit/select-kit-filter.js.es6 index fbfed55bc3..60e7ec9819 100644 --- a/app/assets/javascripts/select-kit/components/select-kit/select-kit-filter.js.es6 +++ b/app/assets/javascripts/select-kit/components/select-kit/select-kit-filter.js.es6 @@ -1,6 +1,15 @@ +import computed from "ember-addons/ember-computed-decorators"; +const { isEmpty } = Ember; + export default Ember.Component.extend({ layoutName: "select-kit/templates/components/select-kit/select-kit-filter", classNames: ["select-kit-filter"], classNameBindings: ["isFocused", "isHidden"], - isHidden: Ember.computed.not("shouldDisplayFilter") + isHidden: Ember.computed.not("shouldDisplayFilter"), + + @computed("placeholder", "hasSelection") + computedPlaceholder(placeholder, hasSelection) { + if (hasSelection) return ""; + return isEmpty(placeholder) ? "" : I18n.t(placeholder); + } }); diff --git a/app/assets/javascripts/select-kit/components/single-select.js.es6 b/app/assets/javascripts/select-kit/components/single-select.js.es6 index d442a9e2ae..e859effe40 100644 --- a/app/assets/javascripts/select-kit/components/single-select.js.es6 +++ b/app/assets/javascripts/select-kit/components/single-select.js.es6 @@ -4,6 +4,7 @@ const { get, isNone, isEmpty, isPresent, run } = Ember; export default SelectKitComponent.extend({ pluginApiIdentifiers: ["single-select"], + layoutName: "select-kit/templates/components/single-select", classNames: "single-select", computedValue: null, value: null, @@ -13,14 +14,20 @@ export default SelectKitComponent.extend({ _compute() { run.scheduleOnce("afterRender", () => { this.willComputeAttributes(); - let content = this.willComputeContent(this.get("content") || []); + let content = this.get("content") || []; + let asyncContent = this.get("asyncContent") || []; + content = this.willComputeContent(content); + asyncContent = this.willComputeAsyncContent(asyncContent); let value = this._beforeWillComputeValue(this.get("value")); content = this.computeContent(content); + asyncContent = this.computeAsyncContent(asyncContent); content = this._beforeDidComputeContent(content); + asyncContent = this._beforeDidComputeAsyncContent(asyncContent); value = this.willComputeValue(value); value = this.computeValue(value); value = this._beforeDidComputeValue(value); this.didComputeContent(content); + this.didComputeAsyncContent(asyncContent); this.didComputeValue(value); this.didComputeAttributes(); @@ -86,6 +93,19 @@ export default SelectKitComponent.extend({ }; }, + @computed("computedAsyncContent.[]", "computedValue") + filteredAsyncComputedContent(computedAsyncContent, computedValue) { + computedAsyncContent = computedAsyncContent.filter(c => { + return computedValue !== get(c, "value"); + }); + + if (this.get("limitMatches")) { + return computedAsyncContent.slice(0, this.get("limitMatches")); + } + + return computedAsyncContent; + }, + @computed("computedContent.[]", "computedValue", "filter", "shouldFilter") filteredComputedContent(computedContent, computedValue, filter, shouldFilter) { if (shouldFilter) { diff --git a/app/assets/javascripts/select-kit/components/tag-chooser.js.es6 b/app/assets/javascripts/select-kit/components/tag-chooser.js.es6 new file mode 100644 index 0000000000..c79e88bab0 --- /dev/null +++ b/app/assets/javascripts/select-kit/components/tag-chooser.js.es6 @@ -0,0 +1,107 @@ +import MultiSelectComponent from "select-kit/components/multi-select"; +import Tags from "select-kit/mixins/tags"; +import renderTag from "discourse/lib/render-tag"; +import computed from "ember-addons/ember-computed-decorators"; +const { get, isEmpty, run, makeArray } = Ember; + +export default MultiSelectComponent.extend(Tags, { + pluginApiIdentifiers: ["tag-chooser"], + classNames: "tag-chooser", + isAsync: true, + filterable: true, + filterPlaceholder: "tagging.choose_for_topic", + limit: null, + attributeBindings: ["categoryId"], + allowAny: Ember.computed.alias("allowCreate"), + + init() { + this._super(); + + if (this.get("allowCreate") !== false) { + this.set("allowCreate", this.get("siteSettings.can_create_tag")); + } + + this.set("termMatchesForbidden", false); + + this.set("templateForRow", (rowComponent) => { + const tag = rowComponent.get("computedContent"); + return renderTag(get(tag, "value"), { + count: get(tag, "originalContent.count"), + noHref: true + }); + }); + + if (!this.get("unlimitedTagCount")) { + this.set("limit", parseInt(this.get("limit") || this.get("siteSettings.max_tags_per_topic"))); + } + }, + + mutateValues(values) { + this.set("tags", values.filter(v => v)); + }, + + @computed("tags") + values(tags) { + return makeArray(tags); + }, + + @computed("tags") + content(tags) { + return makeArray(tags); + }, + + actions: { + onFilter(filter) { + this.expand(); + this.set("searchDebounce", run.debounce(this, this.prepareSearch, filter, 200)); + }, + + onExpand() { + if (isEmpty(this.get("collectionComputedContent"))) { + this.set("searchDebounce", run.debounce(this, this.prepareSearch, this.get("filter"), 200)); + } + }, + + onDeselect() { + this.set("searchDebounce", run.debounce(this, this.prepareSearch, this.get("filter"), 200)); + }, + + onSelect() { + this.set("searchDebounce", run.debounce(this, this.prepareSearch, this.get("filter"), 50)); + } + }, + + prepareSearch(query) { + const selectedTags = makeArray(this.get("values")).filter(t => t); + + const data = { + q: query, + limit: this.get("siteSettings.max_tag_search_results"), + categoryId: this.get("categoryId") + }; + if (selectedTags) data.selected_tags = selectedTags.slice(0, 100); + if (!this.get("everyTag")) data.filterForInput = true; + + this.searchTags("/tags/filter/search", data, this._transformJson); + }, + + _transformJson(context, json) { + let results = json.results; + + context.set("termMatchesForbidden", json.forbidden ? true : false); + + if (context.get("blacklist")) { + results = results.filter(result => { + return !context.get("blacklist").includes(result.id); + }); + } + + if (context.get("siteSettings.tags_sort_alphabetically")) { + results = results.sort((a, b) => a.id > b.id); + } + + return results.map(result => { + return { id: result.text, name: result.text, count: result.count }; + }); + } +}); diff --git a/app/assets/javascripts/select-kit/components/tag-group-chooser.js.es6 b/app/assets/javascripts/select-kit/components/tag-group-chooser.js.es6 new file mode 100644 index 0000000000..2b16037f72 --- /dev/null +++ b/app/assets/javascripts/select-kit/components/tag-group-chooser.js.es6 @@ -0,0 +1,79 @@ +import MultiSelectComponent from "select-kit/components/multi-select"; +import Tags from "select-kit/mixins/tags"; +import renderTag from "discourse/lib/render-tag"; +import computed from "ember-addons/ember-computed-decorators"; +const { get, isEmpty, run, makeArray } = Ember; + +export default MultiSelectComponent.extend(Tags, { + pluginApiIdentifiers: ["tag-group-chooser"], + classNames: ["tag-group-chooser", "tag-chooser"], + isAsync: true, + filterable: true, + filterPlaceholder: "category.tag_groups_placeholder", + limit: null, + allowAny: false, + + init() { + this._super(); + + this.set("templateForRow", (rowComponent) => { + const tag = rowComponent.get("computedContent"); + return renderTag(get(tag, "value"), { + count: get(tag, "originalContent.count"), + noHref: true + }); + }); + }, + + mutateValues(values) { + this.set("tagGroups", values.filter(v => v)); + }, + + @computed("tagGroups") + values(tagGroups) { + return makeArray(tagGroups); + }, + + @computed("tagGroups") + content(tagGroups) { + return makeArray(tagGroups); + }, + + actions: { + onFilter(filter) { + this.expand(); + this.set("searchDebounce", run.debounce(this, this.prepareSearch, filter, 200)); + }, + + onExpand() { + if (isEmpty(this.get("collectionComputedContent"))) { + this.set("searchDebounce", run.debounce(this, this.prepareSearch, this.get("filter"), 200)); + } + }, + + onDeselect() { + this.set("searchDebounce", run.debounce(this, this.prepareSearch, this.get("filter"), 200)); + }, + + onSelect() { + this.set("searchDebounce", run.debounce(this, this.prepareSearch, this.get("filter"), 50)); + } + }, + + prepareSearch(query) { + const data = { + q: query, + limit: this.get("siteSettings.max_tag_search_results") + }; + + this.searchTags("/tags/filter/search", data, this._transformJson); + }, + + _transformJson(context, json) { + let results = json.results.sort((a, b) => a.id > b.id); + + return results.map(result => { + return { id: result.text, name: result.text, count: result.count }; + }); + }, +}); diff --git a/app/assets/javascripts/select-kit/mixins/tags.js.es6 b/app/assets/javascripts/select-kit/mixins/tags.js.es6 new file mode 100644 index 0000000000..8d2b2da3b6 --- /dev/null +++ b/app/assets/javascripts/select-kit/mixins/tags.js.es6 @@ -0,0 +1,52 @@ +const { run } = Ember; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default Ember.Mixin.create({ + willDestroyElement() { + this._super(); + + const searchDebounce = this.get("searchDebounce"); + if (searchDebounce) run.cancel(searchDebounce); + }, + + searchTags(url, data, callback) { + const self = this; + + this.startLoading(); + + return ajax(Discourse.getURL(url), { + quietMillis: 200, + cache: true, + dataType: "json", + data + }).then(json => { + self.set("asyncContent", callback(self, json)); + }).catch(error => { + popupAjaxError(error); + }) + .finally(() => { + self.stopLoading(); + self.autoHighlight(); + }); + }, + + validateCreate(term) { + if (this.get("limitReached") || !this.site.get("can_create_tag")) { + return false; + } + + const filterRegexp = new RegExp(this.site.tags_filter_regexp, "g"); + term = term.replace(filterRegexp, "").trim().toLowerCase(); + + if (!term.length || this.get("termMatchesForbidden")) { + return false; + } + + if (this.get("siteSettings.max_tag_length") < term.length) { + return false; + } + + return true; + }, +}); diff --git a/app/assets/javascripts/select-kit/templates/components/multi-select.hbs b/app/assets/javascripts/select-kit/templates/components/multi-select.hbs new file mode 100644 index 0000000000..072a4bff86 --- /dev/null +++ b/app/assets/javascripts/select-kit/templates/components/multi-select.hbs @@ -0,0 +1,50 @@ +{{#component headerComponent + tabindex=tabindex + isFocused=isFocused + isExpanded=isExpanded + computedContent=headerComputedContent + deselect=(action "deselect") + toggle=(action "toggle") + clearSelection=(action "clearSelection") + options=headerComponentOptions +}} + {{component filterComponent + icon=filterIcon + placeholder=filterPlaceholder + filter=filter + hasSelection=hasSelection + isLoading=isLoading + shouldDisplayFilter=shouldDisplayFilter + isFocused=isFocused + filterComputedContent=(action "filterComputedContent") + }} +{{/component}} + +
+ {{#if renderedBodyOnce}} + {{component collectionComponent + collectionHeaderComputedContent=collectionHeaderComputedContent + hasSelection=hasSelection + noneRowComputedContent=noneRowComputedContent + createRowComputedContent=createRowComputedContent + collectionComputedContent=collectionComputedContent + rowComponent=rowComponent + noneRowComponent=noneRowComponent + createRowComponent=createRowComponent + templateForRow=templateForRow + templateForNoneRow=templateForNoneRow + templateForCreateRow=templateForCreateRow + clearSelection=(action "clearSelection") + select=(action "select") + highlight=(action "highlight") + create=(action "create") + highlightedValue=highlightedValue + computedValue=computedValue + rowComponentOptions=rowComponentOptions + noContentRow=noContentRow + maxContentRow=maxContentRow + }} + {{/if}} +
+ +
diff --git a/app/assets/javascripts/select-kit/templates/components/multi-select/multi-select-header.hbs b/app/assets/javascripts/select-kit/templates/components/multi-select/multi-select-header.hbs index eb4aba3f06..71173acb30 100644 --- a/app/assets/javascripts/select-kit/templates/components/multi-select/multi-select-header.hbs +++ b/app/assets/javascripts/select-kit/templates/components/multi-select/multi-select-header.hbs @@ -5,11 +5,6 @@ computedContent=selectedComputedContent}} {{/each}} - {{component "select-kit/select-kit-filter" - filterComputedContent=filterComputedContent - shouldDisplayFilter=shouldDisplayFilter - isFocused=isFocused - filter=filter - }} + {{yield}}
diff --git a/app/assets/javascripts/select-kit/templates/components/multi-select/selected-category.hbs b/app/assets/javascripts/select-kit/templates/components/multi-select/selected-category.hbs index 6e2b1b362f..09b5adfa85 100644 --- a/app/assets/javascripts/select-kit/templates/components/multi-select/selected-category.hbs +++ b/app/assets/javascripts/select-kit/templates/components/multi-select/selected-category.hbs @@ -1,7 +1,3 @@
- - {{d-icon "times"}} - - {{badge}}
diff --git a/app/assets/javascripts/select-kit/templates/components/multi-select/selected-name.hbs b/app/assets/javascripts/select-kit/templates/components/multi-select/selected-name.hbs index e4167b47b4..ffa6a8f3b9 100644 --- a/app/assets/javascripts/select-kit/templates/components/multi-select/selected-name.hbs +++ b/app/assets/javascripts/select-kit/templates/components/multi-select/selected-name.hbs @@ -1,16 +1,6 @@ {{#if headerContent}}
{{headerContent}}
{{/if}}
- {{#if isLocked}} - - {{d-icon "lock"}} - - {{else}} - - {{d-icon "times"}} - - {{/if}} - {{{label}}} diff --git a/app/assets/javascripts/select-kit/templates/components/select-kit/select-kit-collection.hbs b/app/assets/javascripts/select-kit/templates/components/select-kit/select-kit-collection.hbs index 35d46789dd..12f5d05380 100644 --- a/app/assets/javascripts/select-kit/templates/components/select-kit/select-kit-collection.hbs +++ b/app/assets/javascripts/select-kit/templates/components/select-kit/select-kit-collection.hbs @@ -40,7 +40,7 @@ {{noContentRow}} {{else}} - {{#each filteredComputedContent as |computedContent|}} + {{#each collectionComputedContent as |computedContent|}} {{component rowComponent computedContent=computedContent highlightedValue=highlightedValue diff --git a/app/assets/javascripts/select-kit/templates/components/select-kit/select-kit-filter.hbs b/app/assets/javascripts/select-kit/templates/components/select-kit/select-kit-filter.hbs index e7d1e96a7e..dfe505f5b1 100644 --- a/app/assets/javascripts/select-kit/templates/components/select-kit/select-kit-filter.hbs +++ b/app/assets/javascripts/select-kit/templates/components/select-kit/select-kit-filter.hbs @@ -1,7 +1,7 @@ {{input tabindex=-1 class="filter-input" - placeholder=placeholder + placeholder=computedPlaceholder key-up=filterComputedContent autocomplete="off" autocorrect="off" diff --git a/app/assets/javascripts/select-kit/templates/components/select-kit.hbs b/app/assets/javascripts/select-kit/templates/components/single-select.hbs similarity index 86% rename from app/assets/javascripts/select-kit/templates/components/select-kit.hbs rename to app/assets/javascripts/select-kit/templates/components/single-select.hbs index cd92fb8312..8f67603603 100644 --- a/app/assets/javascripts/select-kit/templates/components/select-kit.hbs +++ b/app/assets/javascripts/select-kit/templates/components/single-select.hbs @@ -1,13 +1,10 @@ {{component headerComponent tabindex=tabindex - shouldDisplayFilter=shouldDisplayFilter isFocused=isFocused isExpanded=isExpanded computedContent=headerComputedContent deselect=(action "deselect") toggle=(action "toggle") - isLoading=isLoading - filterComputedContent=(action "filterComputedContent") clearSelection=(action "clearSelection") options=headerComponentOptions }} @@ -17,8 +14,9 @@ filter=filter isLoading=isLoading icon=filterIcon + hasSelection=hasSelection shouldDisplayFilter=shouldDisplayFilter - placeholder=(i18n filterPlaceholder) + placeholder=filterPlaceholder isFocused=isFocused filterComputedContent=(action "filterComputedContent") }} @@ -29,7 +27,7 @@ hasSelection=hasSelection noneRowComputedContent=noneRowComputedContent createRowComputedContent=createRowComputedContent - filteredComputedContent=filteredComputedContent + collectionComputedContent=collectionComputedContent rowComponent=rowComponent noneRowComponent=noneRowComponent createRowComponent=createRowComponent diff --git a/app/assets/javascripts/vendor.js b/app/assets/javascripts/vendor.js index 6a7bd345ec..03accd5982 100644 --- a/app/assets/javascripts/vendor.js +++ b/app/assets/javascripts/vendor.js @@ -14,7 +14,6 @@ //= require bootstrap-dropdown.js //= require bootstrap-modal.js //= require bootstrap-transition.js -//= require select2.js //= require div_resizer //= require caret_position //= require favcount.js diff --git a/app/assets/javascripts/wizard-vendor.js b/app/assets/javascripts/wizard-vendor.js index ecb8d2bd7e..4d07d0ceea 100644 --- a/app/assets/javascripts/wizard-vendor.js +++ b/app/assets/javascripts/wizard-vendor.js @@ -1,5 +1,4 @@ //= require template_include.js -//= require select2.js //= require jquery.ui.widget.js //= require jquery.fileupload.js //= require sweetalert.js diff --git a/app/assets/stylesheets/common.scss b/app/assets/stylesheets/common.scss index 565e431744..9bf5830056 100644 --- a/app/assets/stylesheets/common.scss +++ b/app/assets/stylesheets/common.scss @@ -3,7 +3,6 @@ @import "vendor/pikaday"; @import "common/foundation/helpers"; @import "common/foundation/base"; -@import "vendor/select2"; @import "common/foundation/mixins"; @import "common/foundation/variables"; @import "common/select-kit/*"; diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index c9f786c9b1..1b199179aa 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -270,7 +270,7 @@ $mobile-breakpoint: 700px; @include clearfix; nav { float: left; - margin-left: 12px; + margin-left: 12px; } .nav.nav-pills { li.active { @@ -493,9 +493,6 @@ $mobile-breakpoint: 700px; width: 100% !important; // !important overrides hard-coded mobile width of 68px } } - .select2-container-multi .select2-choices { - border: none; - } } .setting-controls { float: left; @@ -521,13 +518,6 @@ $mobile-breakpoint: 700px; border-radius: 3px; transition: border linear 0.2s, box-shadow linear 0.2s; - li.select2-search-choice { - cursor: pointer; - .select2-search-choice-close { - content: "x" - } - } - li.sortable-placeholder { padding: 3px 5px 3px 18px; margin: 3px 0 3px 5px; @@ -831,14 +821,6 @@ section.details { .controls { margin-top: 10px; } - .select2-container { - width: 100%; - } - .select2-choices { - width: 100%; - border-color: dark-light-choose($primary-low-mid, $secondary-high); - } - .content-list { margin-right: 20px; } diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss index e3204d60f5..841e4d4d99 100644 --- a/app/assets/stylesheets/common/admin/customize.scss +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -36,7 +36,7 @@ .admin-footer { margin-top: 20px; } - .select2-chosen, .color-schemes li { + .color-schemes li { .fa { margin-right: 6px; color: dark-light-choose($primary-medium, $secondary-medium); diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss index 2504fbd97a..e050f69cef 100644 --- a/app/assets/stylesheets/common/base/compose.scss +++ b/app/assets/stylesheets/common/base/compose.scss @@ -229,10 +229,6 @@ margin: 0; flex: 1 1 100%; } - - &.select2-dropdown-open, &.select2-container-active { - border-color: $tertiary; - } } .wmd-controls { diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 2195c8e018..f4c70b4b19 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -88,6 +88,10 @@ .select-kit { width: 220px; + + &.tag-chooser { + width: 100%; + } } } diff --git a/app/assets/stylesheets/common/base/search.scss b/app/assets/stylesheets/common/base/search.scss index cf5b0e4b9a..46e6bb36f6 100644 --- a/app/assets/stylesheets/common/base/search.scss +++ b/app/assets/stylesheets/common/base/search.scss @@ -92,7 +92,7 @@ } .search-bar { - display: flex; + display: flex; margin-bottom: 10px; max-width: 780px; input { @@ -122,7 +122,7 @@ position: relative; margin: 10px 0 15px; max-width: 780px; - border-bottom: 3px solid $primary-low; + border-bottom: 3px solid $primary-low; width: 100%; .term { font-weight: bold; @@ -130,7 +130,7 @@ .result-count { float: left; - margin-bottom: 4px; + margin-bottom: 4px; span { line-height: $line-height-large; height: 28px; @@ -163,7 +163,7 @@ max-width: 780px; .search-advanced-options { border: 1px solid $primary-low; - padding: 0 20px; + padding: 0 20px; width: 100%; .date-picker-wrapper { vertical-align: top; @@ -183,6 +183,10 @@ font-weight: bold; } + .tag-chooser { + width: 70%; + } + .container { display: flex; flex: 1 1 100%; @@ -206,4 +210,4 @@ } } } -} \ No newline at end of file +} diff --git a/app/assets/stylesheets/common/base/tagging.scss b/app/assets/stylesheets/common/base/tagging.scss index 11248496fc..eb93138ccf 100644 --- a/app/assets/stylesheets/common/base/tagging.scss +++ b/app/assets/stylesheets/common/base/tagging.scss @@ -25,8 +25,8 @@ .tag-count { font-size: $font-down-1; - vertical-align: middle; - line-height: $line-height-small; + vertical-align: middle; + line-height: $line-height-small; } } @@ -61,7 +61,7 @@ &.box, &.bullet { } - + &.box + .topic-header-extra, &.bullet + .topic-header-extra, &.bar + .topic-header-extra { @@ -69,25 +69,17 @@ } } -.add-tags .select2 { - margin: 0; -} - $tag-color: $primary-medium; .discourse-tag-count { font-size: $font-down-1; color: $tag-color; line-height: $line-height-small; - vertical-align: middle; -} - -.select2-result-label .discourse-tag { - margin-right: 0; + vertical-align: middle; } .discourse-tag { - max-width: 14em; + max-width: 14em; display: inline-block; white-space: nowrap; overflow: hidden; @@ -131,29 +123,13 @@ $tag-color: $primary-medium; } .d-header .topic-header-extra { - .discourse-tags { + .discourse-tags { display: inline-block; - font-size: $font-down-1; + font-size: $font-down-1; } .topic-featured-link { margin-left: 8px; } } -.select2-container-multi .select2-choices .select2-search-choice.discourse-tag-select2 { - -webkit-box-shadow: none; - box-shadow: none; - border: 0; - border-radius: 0; - background-color: transparent; - .discourse-tag { - padding: 4px; - &.box { - padding: 1px 8px; - margin: 3px 5px; - } - } -} - - .fps-result .add-full-page-tags { display: inline-block; } @@ -213,11 +189,6 @@ header .discourse-tag {color: $tag-color } width: 100%; max-width: 100%; margin: 5px 0; - ul.select2-choices { - max-height: 30px; - padding-left: 10px; - overflow-y: auto; - } } .title-wrapper .tag-chooser { @@ -278,11 +249,7 @@ header .discourse-tag {color: $tag-color } } } .group-tags-list .tag-chooser { - height: 250px !important; - ul.select2-choices { - height: 250px !important; // to fight with select2.scss's important - max-height: none; - } + width: 100%; } .btn {margin-left: 10px;} .saving { diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss index e0cfaa11e2..0a76f198ab 100644 --- a/app/assets/stylesheets/common/base/topic.scss +++ b/app/assets/stylesheets/common/base/topic.scss @@ -105,15 +105,15 @@ a.badge-category { .private-message-glyph { margin: 5px 5px 0 0; } - .category-chooser, .tag-chooser { + .category-chooser, .mini-tag-chooser { flex: 1 1 49%; margin: 0 0 9px 0; @media all and (max-width: 500px) { - flex: 1 1 100%; + flex: 1 1 100%; } - + } - .tag-chooser { + .mini-tag-chooser { margin-left: 2%; @media all and (max-width: 500px) { margin-left: 0; @@ -204,7 +204,7 @@ a.badge-category { .post-links { margin-top: 1em; padding-top: 1em; - border-top: 1px solid $primary-low; + border-top: 1px solid $primary-low; li:last-of-type { margin-bottom: 1em; } diff --git a/app/assets/stylesheets/common/base/user.scss b/app/assets/stylesheets/common/base/user.scss index 59c235e5be..4fedecbc54 100644 --- a/app/assets/stylesheets/common/base/user.scss +++ b/app/assets/stylesheets/common/base/user.scss @@ -198,9 +198,9 @@ .staff-counters { background: $primary; - color: $secondary; + color: $secondary; display: flex; - padding: 10px; + padding: 10px; > div, > div a { display: flex; align-items: baseline; @@ -209,9 +209,9 @@ span { padding: 1px 6px; border-radius: 10px; - margin-right: 5px; + margin-right: 5px; } - } + } a { color: $secondary; @@ -404,7 +404,7 @@ &.linked-stat { // This makes the entire "box" (the li) clickable instead of a narrow area. padding: 0; a { - padding: 10px 14px; + padding: 10px 14px; width: 100%; height: 100%; display: block; @@ -519,15 +519,6 @@ .tag-notifications .tag-controls { margin-top: 24px; } - - .tags .select2-container-multi { - border: 1px solid $primary-low; - width: 540px; - border-radius: 0; - .select2-choices { - border: none; - } - } } .paginated-topics-list { diff --git a/app/assets/stylesheets/common/select-kit/legacy-combo-box.scss b/app/assets/stylesheets/common/select-kit/legacy-combo-box.scss deleted file mode 100644 index 2b72735d4c..0000000000 --- a/app/assets/stylesheets/common/select-kit/legacy-combo-box.scss +++ /dev/null @@ -1,88 +0,0 @@ -// DO NOT MODIFY -// TODO: remove when all select2 instances are gone -.select2-results .select2-highlighted { - background: $highlight-medium; - color: $primary; -} - -.select2-drop { - .badge-category { - display: inline-block; - } - .topic-count { - font-size: $font-down-2; - color: $primary; - display: inline-block; - } - .highlighted .topic-count, .select2-highlighted .category-desc { - color: $primary; - } - .category-desc { - color: $primary; - font-size: $font-down-1; - line-height: 16px; - } -} - -.select2-drop { - background: $secondary; - .d-icon { - color: dark-light-choose($primary-medium, $secondary-medium); - } -} - -.select2-search input { - background: image-url("select2.png") no-repeat 100% -22px, $secondary 0 0 -} - -.select2-container { - min-width: 200px; - - &.select2-dropdown-open { - border: 0; - margin-bottom: 2px; - } - &.select2-container-active { - border-color: $tertiary; - } - &.select2-container-disabled .select2-chosen { - color: blend-primary-secondary(50%); - } -} - -.select2-container-multi .select2-choices .select2-search-field input.select2-active { - background: $secondary image-url("select2-spinner.gif") no-repeat 100% !important; -} - -.select2-container-multi .select2-choices { - border: 1px solid $primary-medium; -} - -.select2-container a.select2-choice { - background: $secondary; - border-radius: 3px; - border-color: $secondary; - color: $primary; -} - -.select2-dropdown-open a.select2-choice { - box-shadow: none; - border-radius: 3px 3px 0 0; - border-color: $tertiary; -} -.select2-drop { - color: $primary; -} -.select2-drop-active { - border: 1px solid $tertiary; - border-top: 0; -} - -.select2-container-active { - box-shadow: shadow("focus"); -} - -.select2-results .select2-no-results, .select2-results .select2-searching, .select2-results .select2-selection-limit { - background: $secondary; - color: $primary; -} diff --git a/app/assets/stylesheets/common/select-kit/mini-tag-chooser.scss b/app/assets/stylesheets/common/select-kit/mini-tag-chooser.scss index 362e9b187a..27c8f44fc9 100644 --- a/app/assets/stylesheets/common/select-kit/mini-tag-chooser.scss +++ b/app/assets/stylesheets/common/select-kit/mini-tag-chooser.scss @@ -7,7 +7,6 @@ &.is-expanded { .select-kit-header { border: 1px solid $tertiary; - box-shadow: shadow("focus"); } } @@ -59,7 +58,7 @@ } .selected-tag { - background: $primary-very-low; + background: $primary-low; padding: 2px 4px; margin: 2px; border: 0; diff --git a/app/assets/stylesheets/common/select-kit/multi-select.scss b/app/assets/stylesheets/common/select-kit/multi-select.scss index db3734bfb6..91ef4c1539 100644 --- a/app/assets/stylesheets/common/select-kit/multi-select.scss +++ b/app/assets/stylesheets/common/select-kit/multi-select.scss @@ -17,6 +17,7 @@ .select-kit-filter { border: 0; + flex: 1; } .multi-select-header { @@ -79,7 +80,7 @@ justify-content: space-between; flex-wrap: wrap; flex-direction: row; - margin: 2.5px; + margin: 2px; } .filter { @@ -89,6 +90,7 @@ min-width: 50px; padding: 0; outline: none; + flex: 1; .filter-input, .filter-input:focus { border: none; @@ -117,17 +119,36 @@ .color-preview { height: 5px; margin: 0 2px 2px 2px; - border-radius: 5px; display: flex; width: 100%; } } + .selected-category { + .badge-wrapper { + &.bullet { + margin-right: 2.5px; + } + + margin: auto 2.5px; + padding: 2px 4px; + line-height: $line-height-medium; + display: flex; + flex: 1; + align-items: center; + + &:after { + content: '\f00d'; + color: $primary-low-mid; + font-family: 'FontAwesome'; + font-size: $font-down-2; + margin-left: 5px; + } + } + } + .selected-name { color: $primary; - border: 1px solid $primary-medium; - border-radius: 3px; - box-shadow: 0 0 2px $secondary inset, 0 1px 0 rgba(0,0,0,0.05); background-clip: padding-box; -webkit-touch-callout: none; user-select: none; @@ -148,55 +169,32 @@ } .body { - width: 100%; - display: inline-flex; + display: flex; align-items: center; - - .locked-icon, .delete-icon { - justify-content: center; - align-items: center; - display: inline-flex; - height: 21px; - width: 21px; - - .d-icon { - color: $primary-medium; - cursor: pointer; - font-size: 1em; - margin: 0; - - &:hover { - color: $primary; - } - } - } + flex: 1; } .name { - padding: 0 5px; + padding: 2px 4px; line-height: $line-height-medium; + + &:after { + content: '\f00d'; + color: $primary-low-mid; + font-family: 'FontAwesome'; + font-size: $font-down-2; + } + + &:hover { + &:after { + color: $danger; + } + } } &.is-highlighted { box-shadow: 0 0 2px $danger, 0 1px 0 rgba(0,0,0,0.05); } - - .locked-icon, .delete-icon { - justify-content: center; - align-items: center; - width: 21px; - height: 21px; - display: inline-flex; - .d-icon { - color: $primary-medium; - cursor: pointer; - font-size: $font-0; - - &:hover { - color: $primary; - } - } - } } } } diff --git a/app/assets/stylesheets/common/select-kit/select-kit.scss b/app/assets/stylesheets/common/select-kit/select-kit.scss index b5c1597ef3..3f61e5e260 100644 --- a/app/assets/stylesheets/common/select-kit/select-kit.scss +++ b/app/assets/stylesheets/common/select-kit/select-kit.scss @@ -202,6 +202,7 @@ border-radius: inherit; -webkit-overflow-scrolling: touch; margin: 0; + padding: 0; max-height: 200px; .select-kit-collection { diff --git a/app/assets/stylesheets/common/select-kit/tag-chooser.scss b/app/assets/stylesheets/common/select-kit/tag-chooser.scss new file mode 100644 index 0000000000..cc51e23ef5 --- /dev/null +++ b/app/assets/stylesheets/common/select-kit/tag-chooser.scss @@ -0,0 +1,14 @@ +.select-kit { + &.multi-select { + &.tag-chooser { + .select-kit-row { + display: flex; + align-items: center; + + .discourse-tag-count { + margin-left: 5px; + } + } + } + } +} diff --git a/app/assets/stylesheets/desktop/modal.scss b/app/assets/stylesheets/desktop/modal.scss index 2a0fbf8d98..d52c0d4869 100644 --- a/app/assets/stylesheets/desktop/modal.scss +++ b/app/assets/stylesheets/desktop/modal.scss @@ -56,14 +56,6 @@ .category-combobox { width: 430px; - - .select2-drop { - left: -9000px; - width: 428px; - } - .select2-search input { - width: 378px; - } } } diff --git a/app/assets/stylesheets/desktop/queued-posts.scss b/app/assets/stylesheets/desktop/queued-posts.scss index 79cfb1f630..b4c5ef0888 100644 --- a/app/assets/stylesheets/desktop/queued-posts.scss +++ b/app/assets/stylesheets/desktop/queued-posts.scss @@ -24,6 +24,15 @@ } } + .tag-chooser { + width: 100%; + margin-bottom: .5em; + + .select-kit-collection { + padding: 0; + } + } + .queue-controls { button { float: left; diff --git a/app/assets/stylesheets/desktop/topic.scss b/app/assets/stylesheets/desktop/topic.scss index b4d33b0958..db173a3681 100644 --- a/app/assets/stylesheets/desktop/topic.scss +++ b/app/assets/stylesheets/desktop/topic.scss @@ -28,15 +28,12 @@ font-size: $font-up-4; line-height: $line-height-medium; overflow: hidden; - width: 100%; + width: 100%; a {color: $primary;} } .topic-statuses { margin-top: -2px; } - .select2-container { - vertical-align: middle; - } .private-message-glyph { display: none; } .remove-featured-link { float: right; diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index 0fb3e102f9..d393e58994 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -279,10 +279,12 @@ width: 530px; } + .category-selector, .tag-chooser { + width: 530px; + } + input { - &.category-selector, - &.user-selector, - &.tag-chooser { + &.user-selector { width: 530px; } diff --git a/app/assets/stylesheets/mobile/topic.scss b/app/assets/stylesheets/mobile/topic.scss index 703bba38ce..48e682e147 100644 --- a/app/assets/stylesheets/mobile/topic.scss +++ b/app/assets/stylesheets/mobile/topic.scss @@ -181,11 +181,6 @@ sup sup, sub sup, sup sub, sub sub { top: 0; } width: 100%; height: 32px; } - - .select2-container { - box-sizing: border-box; - width: 100% !important; - } .btn-small { padding: 6px 12px; margin: 6px 6px 0 0; diff --git a/app/assets/stylesheets/vendor/select2.scss b/app/assets/stylesheets/vendor/select2.scss deleted file mode 100644 index 0981475b45..0000000000 --- a/app/assets/stylesheets/vendor/select2.scss +++ /dev/null @@ -1,575 +0,0 @@ -/* -Version: @@ver@@ Timestamp: @@timestamp@@ -*/ -.select2-container { - margin: 0; - position: relative; - display: inline-block; - /* inline-block for ie7 */ - zoom: 1; - vertical-align: middle; -} - -.select2-container, -.select2-drop, -.select2-search, -.select2-search input { - /* - Force border-box so that % widths fit the parent - container without overlap because of margin/padding. - More Info : http://www.quirksmode.org/css/box.html - */ - -webkit-box-sizing: border-box; /* webkit */ - -moz-box-sizing: border-box; /* firefox */ - box-sizing: border-box; /* css3 */ -} - -.select2-container .select2-choice { - display: block; - height: 26px; - padding: 0 0 0 8px; - overflow: hidden; - position: relative; - - border: 1px solid #aaa; - white-space: nowrap; - line-height: 26px; - color: #444; - text-decoration: none; - - border-radius: 4px; - - background-clip: padding-box; - - -webkit-touch-callout: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - - background-color: #fff; -} - -.select2-container.select2-drop-above .select2-choice { - border-bottom-color: #aaa; - - border-radius: 0 0 4px 4px; - -} - -.select2-container.select2-allowclear .select2-choice .select2-chosen { - margin-right: 42px; -} - -.select2-container .select2-choice > .select2-chosen { - margin-right: 26px; - display: block; - overflow: hidden; - - white-space: nowrap; - - text-overflow: ellipsis; - float: none; - width: auto; -} - -.select2-container .select2-choice abbr { - display: none; - width: 12px; - height: 12px; - position: absolute; - right: 24px; - top: 8px; - - font-size: 1px; - text-decoration: none; - - border: 0; - background: asset-url('select2.png') right top no-repeat; - cursor: pointer; - outline: 0; -} - -.select2-container.select2-allowclear .select2-choice abbr { - display: inline-block; -} - -.select2-container .select2-choice abbr:hover { - background-position: right -11px; - cursor: pointer; -} - -.select2-drop-mask { - border: 0; - margin: 0; - padding: 0; - position: fixed; - left: 0; - top: 0; - min-height: 100%; - min-width: 100%; - height: auto; - width: auto; - opacity: 0; - z-index: 9998; - /* styles required for IE to work */ - background-color: #fff; - filter: alpha(opacity=0); -} - -.select2-drop { - width: 100%; - margin-top: -1px; - position: absolute; - z-index: 9999; - top: 100%; - - background: #fff; - color: #000; - border: 1px solid #aaa; - border-top: 0; - - - -webkit-box-shadow: 0 4px 5px rgba(0, 0, 0, .15); - box-shadow: 0 4px 5px rgba(0, 0, 0, .15); -} - -.select2-drop.select2-drop-above { - margin-top: 1px; - border-top: 1px solid #aaa; - border-bottom: 0; - - - -webkit-box-shadow: 0 -4px 5px rgba(0, 0, 0, .15); - box-shadow: 0 -4px 5px rgba(0, 0, 0, .15); -} - -.select2-drop-active { - border: 1px solid #5897fb; - border-top: none; -} - -.select2-drop.select2-drop-above.select2-drop-active { - border-top: 1px solid #5897fb; -} - -.select2-drop-auto-width { - border-top: 1px solid #aaa; - width: auto; -} - -.select2-drop-auto-width .select2-search { - padding-top: 4px; -} - -.select2-container .select2-choice .select2-arrow { - display: inline-block; - width: 18px; - height: 100%; - position: absolute; - right: 0; - top: 0; - - border-radius: 0 4px 4px 0; - - background-clip: padding-box; - -} - -.select2-container .select2-choice .select2-arrow b { - display: block; - width: 100%; - height: 100%; - background: asset-url('select2.png') no-repeat 0 1px; -} - -.select2-search { - display: inline-block; - width: 100%; - min-height: 26px; - margin: 0; - padding-left: 4px; - padding-right: 4px; - - position: relative; - z-index: 10000; - - white-space: nowrap; -} - -//noinspection CssOverwrittenProperties -.select2-search input { - width: 100%; - height: auto !important; - min-height: 26px; - padding: 4px 20px 4px 5px; - margin: 0; - - outline: 0; - font-family: sans-serif; - font-size: 1em; - - border: 1px solid #aaa; - border-radius: 0; - - -webkit-box-shadow: none; - box-shadow: none; - - background: #fff asset-url('select2.png') no-repeat 100% -22px; - background: asset-url('select2.png') no-repeat 100% -22px, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, #fff), color-stop(0.99, #eee)); - background: asset-url('select2.png') no-repeat 100% -22px, -webkit-linear-gradient(center bottom, #fff 85%, #eee 99%); - background: asset-url('select2.png') no-repeat 100% -22px, -moz-linear-gradient(center bottom, #fff 85%, #eee 99%); - background: asset-url('select2.png') no-repeat 100% -22px, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; -} - -.select2-drop.select2-drop-above .select2-search input { - margin-top: 4px; -} - -//noinspection CssOverwrittenProperties -.select2-search input.select2-active { - background: #fff asset-url('select2-spinner.gif') no-repeat 100%; - background: asset-url('select2-spinner.gif') no-repeat 100%, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, #fff), color-stop(0.99, #eee)); - background: asset-url('select2-spinner.gif') no-repeat 100%, -webkit-linear-gradient(center bottom, #fff 85%, #eee 99%); - background: asset-url('select2-spinner.gif') no-repeat 100%, -moz-linear-gradient(center bottom, #fff 85%, #eee 99%); - background: asset-url('select2-spinner.gif') no-repeat 100%, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; -} - -.select2-container-active .select2-choice, -.select2-container-active .select2-choices { - border: 1px solid #5897fb; - outline: none; - - -webkit-box-shadow: 0 0 5px rgba(0, 0, 0, .3); - box-shadow: 0 0 5px rgba(0, 0, 0, .3); -} - -.select2-dropdown-open .select2-choice { - border-bottom-color: transparent; - -webkit-box-shadow: 0 1px 0 #fff inset; - box-shadow: 0 1px 0 #fff inset; - - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - - background-color: #eee; -} - -.select2-dropdown-open.select2-drop-above .select2-choice, -.select2-dropdown-open.select2-drop-above .select2-choices { - border: 1px solid #5897fb; - border-top-color: transparent; -} - -.select2-dropdown-open .select2-choice .select2-arrow { - background: transparent; - border-left: none; - filter: none; -} -.select2-dropdown-open .select2-choice .select2-arrow b { - background-position: -18px 1px; -} - -.select2-hidden-accessible { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; -} - -/* results */ -.select2-results { - max-height: 200px; - padding: 0 0 0 4px; - margin: 4px 4px 4px 0; - position: relative; - overflow-x: hidden; - overflow-y: auto; - -webkit-tap-highlight-color: rgba(0, 0, 0, 0); -} - -.select2-results ul.select2-result-sub { - margin: 0; - padding-left: 0; -} - -.select2-results li { - list-style: none; - display: list-item; - background-image: none; -} - -.select2-results li.select2-result-with-children > .select2-result-label { - font-weight: bold; -} - -.select2-results .select2-result-label { - padding: 3px 7px 4px; - margin: 0; - cursor: pointer; - - min-height: 1em; - - -webkit-touch-callout: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -.select2-results-dept-1 .select2-result-label { padding-left: 20px } -.select2-results-dept-2 .select2-result-label { padding-left: 40px } -.select2-results-dept-3 .select2-result-label { padding-left: 60px } -.select2-results-dept-4 .select2-result-label { padding-left: 80px } -.select2-results-dept-5 .select2-result-label { padding-left: 100px } -.select2-results-dept-6 .select2-result-label { padding-left: 110px } -.select2-results-dept-7 .select2-result-label { padding-left: 120px } - -.select2-results li em { - background: #feffde; - font-style: normal; -} - -.select2-results .select2-highlighted em { - background: transparent; -} - -.select2-results .select2-highlighted ul { - background: #fff; - color: #000; -} - - -.select2-results .select2-no-results, -.select2-results .select2-searching, -.select2-results .select2-selection-limit { - background: #f4f4f4; - display: list-item; - padding-left: 5px; -} - -/* -disabled look for disabled choices in the results dropdown -*/ -.select2-results .select2-disabled.select2-highlighted { - color: #666; - background: #f4f4f4; - display: list-item; - cursor: default; -} -.select2-results .select2-disabled { - background: #f4f4f4; - display: list-item; - cursor: default; -} - -.select2-results .select2-selected { - display: none; -} - -.select2-more-results.select2-active { - background: #f4f4f4 asset-url('select2-spinner.gif') no-repeat 100%; -} - -.select2-more-results { - background: #f4f4f4; - display: list-item; -} - -/* disabled styles */ - -.select2-container.select2-container-disabled .select2-choice { - background: #f4f4f4 none; - border: 1px solid #ddd; - cursor: default; -} - -.select2-container.select2-container-disabled .select2-choice .select2-arrow { - background: #f4f4f4 none; - border-left: 0; -} - -.select2-container.select2-container-disabled .select2-choice abbr { - display: none; -} - - -/* multiselect */ - -.select2-container-multi .select2-choices { - height: auto !important; - margin: 0; - padding: 0 5px 0 0; - position: relative; - cursor: text; - overflow: hidden; -} - -.select2-locked { - padding: 3px 5px 3px 5px !important; -} - -.select2-container-multi .select2-choices { - min-height: 26px; -} - -.select2-container-multi.select2-container-active .select2-choices { - border: 1px solid #5897fb; - outline: none; - - -webkit-box-shadow: 0 0 5px rgba(0, 0, 0, .3); - box-shadow: 0 0 5px rgba(0, 0, 0, .3); -} -.select2-container-multi .select2-choices li { - float: left; - list-style: none; -} -html[dir="rtl"] .select2-container-multi .select2-choices li -{ - float: right; -} -.select2-container-multi .select2-choices .select2-search-field { - margin: 0; - padding: 0; - white-space: nowrap; -} - -.select2-container-multi .select2-choices .select2-search-field input { - padding-left: 0; - font-family: sans-serif; - font-size: 1em; - color: #666; - outline: 0; - border: 0; - margin-bottom: 0; - -webkit-box-shadow: none; - box-shadow: none; - background: transparent !important; -} - -.select2-container-multi .select2-choices .select2-search-field input.select2-active { - background: #fff asset-url('select2-spinner.gif') no-repeat 100% !important; -} - -.select2-default { - color: #999 !important; -} - -.select2-container-multi .select2-choices .select2-search-choice { - padding: 0 0 0 12px; - margin: 0; - position: relative; - - color: #333; - cursor: default; - border: 1px solid #aaaaaa; - - border-radius: 3px; - - -webkit-box-shadow: 0 0 2px #fff inset, 0 1px 0 rgba(0, 0, 0, 0.05); - box-shadow: 0 0 2px #fff inset, 0 1px 0 rgba(0, 0, 0, 0.05); - - background-clip: padding-box; - - -webkit-touch-callout: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - - background-color: #e4e4e4; -} -html[dir="rtl"] .select2-container-multi .select2-choices .select2-search-choice -{ - margin-left: 0; - margin-right: 5px; -} -.select2-container-multi .select2-choices .select2-search-choice .select2-chosen { - cursor: default; -} -.select2-container-multi .select2-choices .select2-search-choice-focus { - background: #d4d4d4; -} - -.select2-search-choice-close { - display: block; - width: 12px; - height: 13px; - position: absolute; - right: 3px; - top: 8px; - - font-size: 1px; - outline: none; - background: asset-url('select2.png') right top no-repeat; -} -html[dir="rtl"] .select2-search-choice-close { - right: auto; - left: 3px; -} - -.select2-container-multi .select2-search-choice-close { - left: 3px; -} - -.select2-container-multi .select2-choices .select2-search-choice .select2-search-choice-close:hover { - background-position: right -11px; -} -.select2-container-multi .select2-choices .select2-search-choice-focus .select2-search-choice-close { - background-position: right -11px; -} - -/* disabled styles */ -.select2-container-multi.select2-container-disabled .select2-choices { - background: #f4f4f4 none; - border: 1px solid #ddd; - cursor: default; -} - -.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice { - padding: 3px 5px 3px 5px; - border: 1px solid #ddd; - background: #f4f4f4 none; -} - -.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice .select2-search-choice-close { display: none; - background: none; -} -/* end multiselect */ - - -.select2-result-selectable .select2-match, -.select2-result-unselectable .select2-match { - text-decoration: underline; -} - -.select2-offscreen, .select2-offscreen:focus { - clip: rect(0 0 0 0) !important; - width: 1px !important; - height: 1px !important; - border: 0 !important; - margin: 0 !important; - padding: 0 !important; - overflow: hidden !important; - position: absolute !important; - outline: 0 !important; - left: 0 !important; - top: 0 !important; -} - -.select2-display-none { - display: none; -} - -.select2-measure-scrollbar { - position: absolute; - top: -10000px; - left: -10000px; - width: 100px; - height: 100px; - overflow: scroll; -} diff --git a/app/assets/stylesheets/wizard.scss b/app/assets/stylesheets/wizard.scss index 821c8e8689..0fa4c95008 100644 --- a/app/assets/stylesheets/wizard.scss +++ b/app/assets/stylesheets/wizard.scss @@ -1,6 +1,5 @@ @import "vendor/normalize"; @import "vendor/font_awesome/font-awesome"; -@import "vendor/select2"; @import "vendor/sweetalert"; @import "common/foundation/colors"; @import "common/foundation/variables"; @@ -63,9 +62,6 @@ body.wizard { .select { width: 400px; } -.select2-results .select2-highlighted { - background: #ff9; -} .wizard-canvas { position: absolute; @@ -484,7 +480,6 @@ body.wizard { .wizard-column { margin: auto !important; } .wizard-step-contents { min-height: auto !important; } .wizard-step-banner { width: 100% !important; margin-bottom: 1em !important; } - .select2-container { width: 100% !important; } .wizard-step-footer { display: block !important; } .wizard-progress { margin-bottom: 10px !important; } .wizard-buttons { text-align: right !important; } diff --git a/app/views/finish_installation/register.html.erb b/app/views/finish_installation/register.html.erb index 8fa60e3e38..cf6457da3d 100644 --- a/app/views/finish_installation/register.html.erb +++ b/app/views/finish_installation/register.html.erb @@ -51,9 +51,3 @@ <%- else -%>

<%= raw(t 'finish_installation.register.no_emails') %>

<%- end %> - - diff --git a/test/javascripts/acceptance/search-full-test.js.es6 b/test/javascripts/acceptance/search-full-test.js.es6 index dba53a9252..8027f64823 100644 --- a/test/javascripts/acceptance/search-full-test.js.es6 +++ b/test/javascripts/acceptance/search-full-test.js.es6 @@ -75,30 +75,6 @@ QUnit.test("open advanced search", assert => { andThen(() => assert.ok(visible('.search-advanced .search-advanced-options'), '"search-advanced-options" is visible')); }); -// these tests are screwy with the runloop - -// test("validate population of advanced search", assert => { -// visit("/search"); -// fillIn('.search input.full-page-search', 'test user:admin #bug group:moderators badge:Reader tags:monkey in:likes in:private in:wiki in:bookmarks status:open after:2016-10-05 min_post_count:10'); -// click('.search-advanced-btn'); -// -// andThen(() => { -// assert.ok(exists('.search-advanced-options span:contains("admin")'), 'has "admin" pre-populated'); -// assert.ok(exists('.search-advanced-options .badge-category:contains("bug")'), 'has "bug" pre-populated'); -// //assert.ok(exists('.search-advanced-options span:contains("moderators")'), 'has "moderators" pre-populated'); -// //assert.ok(exists('.search-advanced-options span:contains("Reader")'), 'has "Reader" pre-populated'); -// assert.ok(exists('.search-advanced-options .tag-chooser .tag-monkey'), 'has "monkey" pre-populated'); -// assert.ok(exists('.search-advanced-options .in-likes:checked'), 'has "I liked" pre-populated'); -// assert.ok(exists('.search-advanced-options .in-private:checked'), 'has "are in my messages" pre-populated'); -// assert.ok(exists('.search-advanced-options .in-wiki:checked'), 'has "are wiki" pre-populated'); -// assert.ok(exists('.search-advanced-options .combobox .select2-choice .select2-chosen:contains("I\'ve bookmarked")'), 'has "I\'ve bookmarked" pre-populated'); -// assert.ok(exists('.search-advanced-options .combobox .select2-choice .select2-chosen:contains("are open")'), 'has "are open" pre-populated'); -// assert.ok(exists('.search-advanced-options .combobox .select2-choice .select2-chosen:contains("after")'), 'has "after" pre-populated'); -// assert.equal(find('.search-advanced-options #search-post-date').val(), "2016-10-05", 'has "2016-10-05" pre-populated'); -// assert.equal(find('.search-advanced-options #search-min-post-count').val(), "10", 'has "10" pre-populated'); -// }); -// }); - QUnit.test("escape search term", (assert) => { visit("/search"); fillIn('.search input.full-page-search', '@gmail.com'); diff --git a/vendor/assets/javascripts/select2.js b/vendor/assets/javascripts/select2.js deleted file mode 100644 index 195ccee5bb..0000000000 --- a/vendor/assets/javascripts/select2.js +++ /dev/null @@ -1,3729 +0,0 @@ -/* -Copyright 2012 Igor Vaynberg - -Version: 3.5.4 Timestamp: Sun Aug 30 13:30:32 EDT 2015 - -This software is licensed under the Apache License, Version 2.0 (the "Apache License") or the GNU -General Public License version 2 (the "GPL License"). You may choose either license to govern your -use of this software only upon the condition that you accept all of the terms of either the Apache -License or the GPL License. - -You may obtain a copy of the Apache License and the GPL License at: - - http://www.apache.org/licenses/LICENSE-2.0 - http://www.gnu.org/licenses/gpl-2.0.html - -Unless required by applicable law or agreed to in writing, software distributed under the -Apache License or the GPL License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR -CONDITIONS OF ANY KIND, either express or implied. See the Apache License and the GPL License for -the specific language governing permissions and limitations under the Apache License and the GPL License. -*/ -(function ($) { - if(typeof $.fn.each2 == "undefined") { - $.extend($.fn, { - /* - * 4-10 times faster .each replacement - * use it carefully, as it overrides jQuery context of element on each iteration - */ - each2 : function (c) { - var j = $([0]), i = -1, l = this.length; - while ( - ++i < l - && (j.context = j[0] = this[i]) - && c.call(j[0], i, j) !== false //"this"=DOM, i=index, j=jQuery object - ); - return this; - } - }); - } -})(jQuery); - -(function ($, undefined) { - "use strict"; - /*global document, window, jQuery, console */ - - if (window.Select2 !== undefined) { - return; - } - - var AbstractSelect2, SingleSelect2, MultiSelect2, nextUid, sizer, - lastMousePosition={x:0,y:0}, $document, scrollBarDimensions, - - KEY = { - TAB: 9, - ENTER: 13, - ESC: 27, - SPACE: 32, - LEFT: 37, - UP: 38, - RIGHT: 39, - DOWN: 40, - SHIFT: 16, - CTRL: 17, - ALT: 18, - PAGE_UP: 33, - PAGE_DOWN: 34, - HOME: 36, - END: 35, - BACKSPACE: 8, - DELETE: 46, - isArrow: function (k) { - k = k.which ? k.which : k; - switch (k) { - case KEY.LEFT: - case KEY.RIGHT: - case KEY.UP: - case KEY.DOWN: - return true; - } - return false; - }, - isControl: function (e) { - var k = e.which; - switch (k) { - case KEY.SHIFT: - case KEY.CTRL: - case KEY.ALT: - return true; - } - - if (e.metaKey) return true; - - return false; - }, - isFunctionKey: function (k) { - k = k.which ? k.which : k; - return k >= 112 && k <= 123; - } - }, - MEASURE_SCROLLBAR_TEMPLATE = "
", - - DIACRITICS = {"\u24B6":"A","\uFF21":"A","\u00C0":"A","\u00C1":"A","\u00C2":"A","\u1EA6":"A","\u1EA4":"A","\u1EAA":"A","\u1EA8":"A","\u00C3":"A","\u0100":"A","\u0102":"A","\u1EB0":"A","\u1EAE":"A","\u1EB4":"A","\u1EB2":"A","\u0226":"A","\u01E0":"A","\u00C4":"A","\u01DE":"A","\u1EA2":"A","\u00C5":"A","\u01FA":"A","\u01CD":"A","\u0200":"A","\u0202":"A","\u1EA0":"A","\u1EAC":"A","\u1EB6":"A","\u1E00":"A","\u0104":"A","\u023A":"A","\u2C6F":"A","\uA732":"AA","\u00C6":"AE","\u01FC":"AE","\u01E2":"AE","\uA734":"AO","\uA736":"AU","\uA738":"AV","\uA73A":"AV","\uA73C":"AY","\u24B7":"B","\uFF22":"B","\u1E02":"B","\u1E04":"B","\u1E06":"B","\u0243":"B","\u0182":"B","\u0181":"B","\u24B8":"C","\uFF23":"C","\u0106":"C","\u0108":"C","\u010A":"C","\u010C":"C","\u00C7":"C","\u1E08":"C","\u0187":"C","\u023B":"C","\uA73E":"C","\u24B9":"D","\uFF24":"D","\u1E0A":"D","\u010E":"D","\u1E0C":"D","\u1E10":"D","\u1E12":"D","\u1E0E":"D","\u0110":"D","\u018B":"D","\u018A":"D","\u0189":"D","\uA779":"D","\u01F1":"DZ","\u01C4":"DZ","\u01F2":"Dz","\u01C5":"Dz","\u24BA":"E","\uFF25":"E","\u00C8":"E","\u00C9":"E","\u00CA":"E","\u1EC0":"E","\u1EBE":"E","\u1EC4":"E","\u1EC2":"E","\u1EBC":"E","\u0112":"E","\u1E14":"E","\u1E16":"E","\u0114":"E","\u0116":"E","\u00CB":"E","\u1EBA":"E","\u011A":"E","\u0204":"E","\u0206":"E","\u1EB8":"E","\u1EC6":"E","\u0228":"E","\u1E1C":"E","\u0118":"E","\u1E18":"E","\u1E1A":"E","\u0190":"E","\u018E":"E","\u24BB":"F","\uFF26":"F","\u1E1E":"F","\u0191":"F","\uA77B":"F","\u24BC":"G","\uFF27":"G","\u01F4":"G","\u011C":"G","\u1E20":"G","\u011E":"G","\u0120":"G","\u01E6":"G","\u0122":"G","\u01E4":"G","\u0193":"G","\uA7A0":"G","\uA77D":"G","\uA77E":"G","\u24BD":"H","\uFF28":"H","\u0124":"H","\u1E22":"H","\u1E26":"H","\u021E":"H","\u1E24":"H","\u1E28":"H","\u1E2A":"H","\u0126":"H","\u2C67":"H","\u2C75":"H","\uA78D":"H","\u24BE":"I","\uFF29":"I","\u00CC":"I","\u00CD":"I","\u00CE":"I","\u0128":"I","\u012A":"I","\u012C":"I","\u0130":"I","\u00CF":"I","\u1E2E":"I","\u1EC8":"I","\u01CF":"I","\u0208":"I","\u020A":"I","\u1ECA":"I","\u012E":"I","\u1E2C":"I","\u0197":"I","\u24BF":"J","\uFF2A":"J","\u0134":"J","\u0248":"J","\u24C0":"K","\uFF2B":"K","\u1E30":"K","\u01E8":"K","\u1E32":"K","\u0136":"K","\u1E34":"K","\u0198":"K","\u2C69":"K","\uA740":"K","\uA742":"K","\uA744":"K","\uA7A2":"K","\u24C1":"L","\uFF2C":"L","\u013F":"L","\u0139":"L","\u013D":"L","\u1E36":"L","\u1E38":"L","\u013B":"L","\u1E3C":"L","\u1E3A":"L","\u0141":"L","\u023D":"L","\u2C62":"L","\u2C60":"L","\uA748":"L","\uA746":"L","\uA780":"L","\u01C7":"LJ","\u01C8":"Lj","\u24C2":"M","\uFF2D":"M","\u1E3E":"M","\u1E40":"M","\u1E42":"M","\u2C6E":"M","\u019C":"M","\u24C3":"N","\uFF2E":"N","\u01F8":"N","\u0143":"N","\u00D1":"N","\u1E44":"N","\u0147":"N","\u1E46":"N","\u0145":"N","\u1E4A":"N","\u1E48":"N","\u0220":"N","\u019D":"N","\uA790":"N","\uA7A4":"N","\u01CA":"NJ","\u01CB":"Nj","\u24C4":"O","\uFF2F":"O","\u00D2":"O","\u00D3":"O","\u00D4":"O","\u1ED2":"O","\u1ED0":"O","\u1ED6":"O","\u1ED4":"O","\u00D5":"O","\u1E4C":"O","\u022C":"O","\u1E4E":"O","\u014C":"O","\u1E50":"O","\u1E52":"O","\u014E":"O","\u022E":"O","\u0230":"O","\u00D6":"O","\u022A":"O","\u1ECE":"O","\u0150":"O","\u01D1":"O","\u020C":"O","\u020E":"O","\u01A0":"O","\u1EDC":"O","\u1EDA":"O","\u1EE0":"O","\u1EDE":"O","\u1EE2":"O","\u1ECC":"O","\u1ED8":"O","\u01EA":"O","\u01EC":"O","\u00D8":"O","\u01FE":"O","\u0186":"O","\u019F":"O","\uA74A":"O","\uA74C":"O","\u01A2":"OI","\uA74E":"OO","\u0222":"OU","\u24C5":"P","\uFF30":"P","\u1E54":"P","\u1E56":"P","\u01A4":"P","\u2C63":"P","\uA750":"P","\uA752":"P","\uA754":"P","\u24C6":"Q","\uFF31":"Q","\uA756":"Q","\uA758":"Q","\u024A":"Q","\u24C7":"R","\uFF32":"R","\u0154":"R","\u1E58":"R","\u0158":"R","\u0210":"R","\u0212":"R","\u1E5A":"R","\u1E5C":"R","\u0156":"R","\u1E5E":"R","\u024C":"R","\u2C64":"R","\uA75A":"R","\uA7A6":"R","\uA782":"R","\u24C8":"S","\uFF33":"S","\u1E9E":"S","\u015A":"S","\u1E64":"S","\u015C":"S","\u1E60":"S","\u0160":"S","\u1E66":"S","\u1E62":"S","\u1E68":"S","\u0218":"S","\u015E":"S","\u2C7E":"S","\uA7A8":"S","\uA784":"S","\u24C9":"T","\uFF34":"T","\u1E6A":"T","\u0164":"T","\u1E6C":"T","\u021A":"T","\u0162":"T","\u1E70":"T","\u1E6E":"T","\u0166":"T","\u01AC":"T","\u01AE":"T","\u023E":"T","\uA786":"T","\uA728":"TZ","\u24CA":"U","\uFF35":"U","\u00D9":"U","\u00DA":"U","\u00DB":"U","\u0168":"U","\u1E78":"U","\u016A":"U","\u1E7A":"U","\u016C":"U","\u00DC":"U","\u01DB":"U","\u01D7":"U","\u01D5":"U","\u01D9":"U","\u1EE6":"U","\u016E":"U","\u0170":"U","\u01D3":"U","\u0214":"U","\u0216":"U","\u01AF":"U","\u1EEA":"U","\u1EE8":"U","\u1EEE":"U","\u1EEC":"U","\u1EF0":"U","\u1EE4":"U","\u1E72":"U","\u0172":"U","\u1E76":"U","\u1E74":"U","\u0244":"U","\u24CB":"V","\uFF36":"V","\u1E7C":"V","\u1E7E":"V","\u01B2":"V","\uA75E":"V","\u0245":"V","\uA760":"VY","\u24CC":"W","\uFF37":"W","\u1E80":"W","\u1E82":"W","\u0174":"W","\u1E86":"W","\u1E84":"W","\u1E88":"W","\u2C72":"W","\u24CD":"X","\uFF38":"X","\u1E8A":"X","\u1E8C":"X","\u24CE":"Y","\uFF39":"Y","\u1EF2":"Y","\u00DD":"Y","\u0176":"Y","\u1EF8":"Y","\u0232":"Y","\u1E8E":"Y","\u0178":"Y","\u1EF6":"Y","\u1EF4":"Y","\u01B3":"Y","\u024E":"Y","\u1EFE":"Y","\u24CF":"Z","\uFF3A":"Z","\u0179":"Z","\u1E90":"Z","\u017B":"Z","\u017D":"Z","\u1E92":"Z","\u1E94":"Z","\u01B5":"Z","\u0224":"Z","\u2C7F":"Z","\u2C6B":"Z","\uA762":"Z","\u24D0":"a","\uFF41":"a","\u1E9A":"a","\u00E0":"a","\u00E1":"a","\u00E2":"a","\u1EA7":"a","\u1EA5":"a","\u1EAB":"a","\u1EA9":"a","\u00E3":"a","\u0101":"a","\u0103":"a","\u1EB1":"a","\u1EAF":"a","\u1EB5":"a","\u1EB3":"a","\u0227":"a","\u01E1":"a","\u00E4":"a","\u01DF":"a","\u1EA3":"a","\u00E5":"a","\u01FB":"a","\u01CE":"a","\u0201":"a","\u0203":"a","\u1EA1":"a","\u1EAD":"a","\u1EB7":"a","\u1E01":"a","\u0105":"a","\u2C65":"a","\u0250":"a","\uA733":"aa","\u00E6":"ae","\u01FD":"ae","\u01E3":"ae","\uA735":"ao","\uA737":"au","\uA739":"av","\uA73B":"av","\uA73D":"ay","\u24D1":"b","\uFF42":"b","\u1E03":"b","\u1E05":"b","\u1E07":"b","\u0180":"b","\u0183":"b","\u0253":"b","\u24D2":"c","\uFF43":"c","\u0107":"c","\u0109":"c","\u010B":"c","\u010D":"c","\u00E7":"c","\u1E09":"c","\u0188":"c","\u023C":"c","\uA73F":"c","\u2184":"c","\u24D3":"d","\uFF44":"d","\u1E0B":"d","\u010F":"d","\u1E0D":"d","\u1E11":"d","\u1E13":"d","\u1E0F":"d","\u0111":"d","\u018C":"d","\u0256":"d","\u0257":"d","\uA77A":"d","\u01F3":"dz","\u01C6":"dz","\u24D4":"e","\uFF45":"e","\u00E8":"e","\u00E9":"e","\u00EA":"e","\u1EC1":"e","\u1EBF":"e","\u1EC5":"e","\u1EC3":"e","\u1EBD":"e","\u0113":"e","\u1E15":"e","\u1E17":"e","\u0115":"e","\u0117":"e","\u00EB":"e","\u1EBB":"e","\u011B":"e","\u0205":"e","\u0207":"e","\u1EB9":"e","\u1EC7":"e","\u0229":"e","\u1E1D":"e","\u0119":"e","\u1E19":"e","\u1E1B":"e","\u0247":"e","\u025B":"e","\u01DD":"e","\u24D5":"f","\uFF46":"f","\u1E1F":"f","\u0192":"f","\uA77C":"f","\u24D6":"g","\uFF47":"g","\u01F5":"g","\u011D":"g","\u1E21":"g","\u011F":"g","\u0121":"g","\u01E7":"g","\u0123":"g","\u01E5":"g","\u0260":"g","\uA7A1":"g","\u1D79":"g","\uA77F":"g","\u24D7":"h","\uFF48":"h","\u0125":"h","\u1E23":"h","\u1E27":"h","\u021F":"h","\u1E25":"h","\u1E29":"h","\u1E2B":"h","\u1E96":"h","\u0127":"h","\u2C68":"h","\u2C76":"h","\u0265":"h","\u0195":"hv","\u24D8":"i","\uFF49":"i","\u00EC":"i","\u00ED":"i","\u00EE":"i","\u0129":"i","\u012B":"i","\u012D":"i","\u00EF":"i","\u1E2F":"i","\u1EC9":"i","\u01D0":"i","\u0209":"i","\u020B":"i","\u1ECB":"i","\u012F":"i","\u1E2D":"i","\u0268":"i","\u0131":"i","\u24D9":"j","\uFF4A":"j","\u0135":"j","\u01F0":"j","\u0249":"j","\u24DA":"k","\uFF4B":"k","\u1E31":"k","\u01E9":"k","\u1E33":"k","\u0137":"k","\u1E35":"k","\u0199":"k","\u2C6A":"k","\uA741":"k","\uA743":"k","\uA745":"k","\uA7A3":"k","\u24DB":"l","\uFF4C":"l","\u0140":"l","\u013A":"l","\u013E":"l","\u1E37":"l","\u1E39":"l","\u013C":"l","\u1E3D":"l","\u1E3B":"l","\u017F":"l","\u0142":"l","\u019A":"l","\u026B":"l","\u2C61":"l","\uA749":"l","\uA781":"l","\uA747":"l","\u01C9":"lj","\u24DC":"m","\uFF4D":"m","\u1E3F":"m","\u1E41":"m","\u1E43":"m","\u0271":"m","\u026F":"m","\u24DD":"n","\uFF4E":"n","\u01F9":"n","\u0144":"n","\u00F1":"n","\u1E45":"n","\u0148":"n","\u1E47":"n","\u0146":"n","\u1E4B":"n","\u1E49":"n","\u019E":"n","\u0272":"n","\u0149":"n","\uA791":"n","\uA7A5":"n","\u01CC":"nj","\u24DE":"o","\uFF4F":"o","\u00F2":"o","\u00F3":"o","\u00F4":"o","\u1ED3":"o","\u1ED1":"o","\u1ED7":"o","\u1ED5":"o","\u00F5":"o","\u1E4D":"o","\u022D":"o","\u1E4F":"o","\u014D":"o","\u1E51":"o","\u1E53":"o","\u014F":"o","\u022F":"o","\u0231":"o","\u00F6":"o","\u022B":"o","\u1ECF":"o","\u0151":"o","\u01D2":"o","\u020D":"o","\u020F":"o","\u01A1":"o","\u1EDD":"o","\u1EDB":"o","\u1EE1":"o","\u1EDF":"o","\u1EE3":"o","\u1ECD":"o","\u1ED9":"o","\u01EB":"o","\u01ED":"o","\u00F8":"o","\u01FF":"o","\u0254":"o","\uA74B":"o","\uA74D":"o","\u0275":"o","\u01A3":"oi","\u0223":"ou","\uA74F":"oo","\u24DF":"p","\uFF50":"p","\u1E55":"p","\u1E57":"p","\u01A5":"p","\u1D7D":"p","\uA751":"p","\uA753":"p","\uA755":"p","\u24E0":"q","\uFF51":"q","\u024B":"q","\uA757":"q","\uA759":"q","\u24E1":"r","\uFF52":"r","\u0155":"r","\u1E59":"r","\u0159":"r","\u0211":"r","\u0213":"r","\u1E5B":"r","\u1E5D":"r","\u0157":"r","\u1E5F":"r","\u024D":"r","\u027D":"r","\uA75B":"r","\uA7A7":"r","\uA783":"r","\u24E2":"s","\uFF53":"s","\u00DF":"s","\u015B":"s","\u1E65":"s","\u015D":"s","\u1E61":"s","\u0161":"s","\u1E67":"s","\u1E63":"s","\u1E69":"s","\u0219":"s","\u015F":"s","\u023F":"s","\uA7A9":"s","\uA785":"s","\u1E9B":"s","\u24E3":"t","\uFF54":"t","\u1E6B":"t","\u1E97":"t","\u0165":"t","\u1E6D":"t","\u021B":"t","\u0163":"t","\u1E71":"t","\u1E6F":"t","\u0167":"t","\u01AD":"t","\u0288":"t","\u2C66":"t","\uA787":"t","\uA729":"tz","\u24E4":"u","\uFF55":"u","\u00F9":"u","\u00FA":"u","\u00FB":"u","\u0169":"u","\u1E79":"u","\u016B":"u","\u1E7B":"u","\u016D":"u","\u00FC":"u","\u01DC":"u","\u01D8":"u","\u01D6":"u","\u01DA":"u","\u1EE7":"u","\u016F":"u","\u0171":"u","\u01D4":"u","\u0215":"u","\u0217":"u","\u01B0":"u","\u1EEB":"u","\u1EE9":"u","\u1EEF":"u","\u1EED":"u","\u1EF1":"u","\u1EE5":"u","\u1E73":"u","\u0173":"u","\u1E77":"u","\u1E75":"u","\u0289":"u","\u24E5":"v","\uFF56":"v","\u1E7D":"v","\u1E7F":"v","\u028B":"v","\uA75F":"v","\u028C":"v","\uA761":"vy","\u24E6":"w","\uFF57":"w","\u1E81":"w","\u1E83":"w","\u0175":"w","\u1E87":"w","\u1E85":"w","\u1E98":"w","\u1E89":"w","\u2C73":"w","\u24E7":"x","\uFF58":"x","\u1E8B":"x","\u1E8D":"x","\u24E8":"y","\uFF59":"y","\u1EF3":"y","\u00FD":"y","\u0177":"y","\u1EF9":"y","\u0233":"y","\u1E8F":"y","\u00FF":"y","\u1EF7":"y","\u1E99":"y","\u1EF5":"y","\u01B4":"y","\u024F":"y","\u1EFF":"y","\u24E9":"z","\uFF5A":"z","\u017A":"z","\u1E91":"z","\u017C":"z","\u017E":"z","\u1E93":"z","\u1E95":"z","\u01B6":"z","\u0225":"z","\u0240":"z","\u2C6C":"z","\uA763":"z","\u0386":"\u0391","\u0388":"\u0395","\u0389":"\u0397","\u038A":"\u0399","\u03AA":"\u0399","\u038C":"\u039F","\u038E":"\u03A5","\u03AB":"\u03A5","\u038F":"\u03A9","\u03AC":"\u03B1","\u03AD":"\u03B5","\u03AE":"\u03B7","\u03AF":"\u03B9","\u03CA":"\u03B9","\u0390":"\u03B9","\u03CC":"\u03BF","\u03CD":"\u03C5","\u03CB":"\u03C5","\u03B0":"\u03C5","\u03C9":"\u03C9","\u03C2":"\u03C3"}; - - $document = $(document); - - nextUid=(function() { var counter=1; return function() { return counter++; }; }()); - - - function reinsertElement(element) { - var placeholder = $(document.createTextNode('')); - - element.before(placeholder); - placeholder.before(element); - placeholder.remove(); - } - - function stripDiacritics(str) { - // Used 'uni range + named function' from http://jsperf.com/diacritics/18 - function match(a) { - return DIACRITICS[a] || a; - } - - return str.replace(/[^\u0000-\u007E]/g, match); - } - - function indexOf(value, array) { - var i = 0, l = array.length; - for (; i < l; i = i + 1) { - if (equal(value, array[i])) return i; - } - return -1; - } - - function measureScrollbar () { - var $template = $( MEASURE_SCROLLBAR_TEMPLATE ); - $template.appendTo(document.body); - - var dim = { - width: $template.width() - $template[0].clientWidth, - height: $template.height() - $template[0].clientHeight - }; - $template.remove(); - - return dim; - } - - /** - * Compares equality of a and b - * @param a - * @param b - */ - function equal(a, b) { - if (a === b) return true; - if (a === undefined || b === undefined) return false; - if (a === null || b === null) return false; - // Check whether 'a' or 'b' is a string (primitive or object). - // The concatenation of an empty string (+'') converts its argument to a string's primitive. - if (a.constructor === String) return a+'' === b+''; // a+'' - in case 'a' is a String object - if (b.constructor === String) return b+'' === a+''; // b+'' - in case 'b' is a String object - return false; - } - - /** - * Splits the string into an array of values, transforming each value. An empty array is returned for nulls or empty - * strings - * @param string - * @param separator - */ - function splitVal(string, separator, transform) { - var val, i, l; - if (string === null || string.length < 1) return []; - val = string.split(separator); - for (i = 0, l = val.length; i < l; i = i + 1) val[i] = transform(val[i]); - return val; - } - - function getSideBorderPadding(element) { - return element.outerWidth(false) - element.width(); - } - - function installKeyUpChangeEvent(element) { - var key="keyup-change-value"; - element.on("keydown", function () { - if ($.data(element, key) === undefined) { - $.data(element, key, element.val()); - } - }); - element.on("keyup", function () { - var val= $.data(element, key); - if (val !== undefined && element.val() !== val) { - $.removeData(element, key); - element.trigger("keyup-change"); - } - }); - } - - - /** - * filters mouse events so an event is fired only if the mouse moved. - * - * filters out mouse events that occur when mouse is stationary but - * the elements under the pointer are scrolled. - */ - function installFilteredMouseMove(element) { - element.on("mousemove", function (e) { - var lastpos = lastMousePosition; - if (lastpos === undefined || lastpos.x !== e.pageX || lastpos.y !== e.pageY) { - $(e.target).trigger("mousemove-filtered", e); - } - }); - } - - /** - * Debounces a function. Returns a function that calls the original fn function only if no invocations have been made - * within the last quietMillis milliseconds. - * - * @param quietMillis number of milliseconds to wait before invoking fn - * @param fn function to be debounced - * @param ctx object to be used as this reference within fn - * @return debounced version of fn - */ - function debounce(quietMillis, fn, ctx) { - ctx = ctx || undefined; - var timeout; - return function () { - var args = arguments; - window.clearTimeout(timeout); - timeout = window.setTimeout(function() { - fn.apply(ctx, args); - }, quietMillis); - }; - } - - function installDebouncedScroll(threshold, element) { - var notify = debounce(threshold, function (e) { element.trigger("scroll-debounced", e);}); - element.on("scroll", function (e) { - if (indexOf(e.target, element.get()) >= 0) notify(e); - }); - } - - function focus($el) { - if ($el[0] === document.activeElement) return; - - /* set the focus in a 0 timeout - that way the focus is set after the processing - of the current event has finished - which seems like the only reliable way - to set focus */ - window.setTimeout(function() { - var el=$el[0], pos=$el.val().length, range; - - $el.focus(); - - /* make sure el received focus so we do not error out when trying to manipulate the caret. - sometimes modals or others listeners may steal it after its set */ - var isVisible = (el.offsetWidth > 0 || el.offsetHeight > 0); - if (isVisible && el === document.activeElement) { - - /* after the focus is set move the caret to the end, necessary when we val() - just before setting focus */ - if(el.setSelectionRange) - { - el.setSelectionRange(pos, pos); - } - else if (el.createTextRange) { - range = el.createTextRange(); - range.collapse(false); - range.select(); - } - } - }, 0); - } - - function getCursorInfo(el) { - el = $(el)[0]; - var offset = 0; - var length = 0; - if ('selectionStart' in el) { - offset = el.selectionStart; - length = el.selectionEnd - offset; - } else if ('selection' in document) { - el.focus(); - var sel = document.selection.createRange(); - length = document.selection.createRange().text.length; - sel.moveStart('character', -el.value.length); - offset = sel.text.length - length; - } - return { offset: offset, length: length }; - } - - function killEvent(event) { - event.preventDefault(); - event.stopPropagation(); - } - function killEventImmediately(event) { - event.preventDefault(); - event.stopImmediatePropagation(); - } - - function measureTextWidth(e) { - if (!sizer){ - var style = e[0].currentStyle || window.getComputedStyle(e[0], null); - sizer = $(document.createElement("div")).css({ - position: "absolute", - left: "-10000px", - top: "-10000px", - display: "none", - fontSize: style.fontSize, - fontFamily: style.fontFamily, - fontStyle: style.fontStyle, - fontWeight: style.fontWeight, - letterSpacing: style.letterSpacing, - textTransform: style.textTransform, - whiteSpace: "nowrap" - }); - sizer.attr("class","select2-sizer"); - $(document.body).append(sizer); - } - sizer.text(e.val()); - return sizer.width(); - } - - function syncCssClasses(dest, src, adapter) { - var classes, replacements = [], adapted; - - classes = $.trim(dest.attr("class")); - - if (classes) { - classes = '' + classes; // for IE which returns object - - $(classes.split(/\s+/)).each2(function() { - if (this.indexOf("select2-") === 0) { - replacements.push(this); - } - }); - } - - classes = $.trim(src.attr("class")); - - if (classes) { - classes = '' + classes; // for IE which returns object - - $(classes.split(/\s+/)).each2(function() { - if (this.indexOf("select2-") !== 0) { - adapted = adapter(this); - - if (adapted) { - replacements.push(adapted); - } - } - }); - } - - dest.attr("class", replacements.join(" ")); - } - - - function markMatch(text, term, markup, escapeMarkup) { - var match=stripDiacritics(text.toUpperCase()).indexOf(stripDiacritics(term.toUpperCase())), - tl=term.length; - - if (match<0) { - markup.push(escapeMarkup(text)); - return; - } - - markup.push(escapeMarkup(text.substring(0, match))); - markup.push(""); - markup.push(escapeMarkup(text.substring(match, match + tl))); - markup.push(""); - markup.push(escapeMarkup(text.substring(match + tl, text.length))); - } - - function defaultEscapeMarkup(markup) { - var replace_map = { - '\\': '\', - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - "/": '/' - }; - - return String(markup).replace(/[&<>"'\/\\]/g, function (match) { - return replace_map[match]; - }); - } - - /** - * Produces an ajax-based query function - * - * @param options object containing configuration parameters - * @param options.params parameter map for the transport ajax call, can contain such options as cache, jsonpCallback, etc. see $.ajax - * @param options.transport function that will be used to execute the ajax request. must be compatible with parameters supported by $.ajax - * @param options.url url for the data - * @param options.data a function(searchTerm, pageNumber, context) that should return an object containing query string parameters for the above url. - * @param options.dataType request data type: ajax, jsonp, other datatypes supported by jQuery's $.ajax function or the transport function if specified - * @param options.quietMillis (optional) milliseconds to wait before making the ajaxRequest, helps debounce the ajax function if invoked too often - * @param options.results a function(remoteData, pageNumber, query) that converts data returned form the remote request to the format expected by Select2. - * The expected format is an object containing the following keys: - * results array of objects that will be used as choices - * more (optional) boolean indicating whether there are more results available - * Example: {results:[{id:1, text:'Red'},{id:2, text:'Blue'}], more:true} - */ - function ajax(options) { - var timeout, // current scheduled but not yet executed request - handler = null, - quietMillis = options.quietMillis || 100, - ajaxUrl = options.url, - self = this; - - return function (query) { - window.clearTimeout(timeout); - timeout = window.setTimeout(function () { - var data = options.data, // ajax data function - url = ajaxUrl, // ajax url string or function - transport = options.transport || $.fn.select2.ajaxDefaults.transport, - // deprecated - to be removed in 4.0 - use params instead - deprecated = { - type: options.type || 'GET', // set type of request (GET or POST) - cache: options.cache || false, - jsonpCallback: options.jsonpCallback||undefined, - dataType: options.dataType||"json" - }, - params = $.extend({}, $.fn.select2.ajaxDefaults.params, deprecated); - - data = data ? data.call(self, query.term, query.page, query.context) : null; - url = (typeof url === 'function') ? url.call(self, query.term, query.page, query.context) : url; - - if (handler && typeof handler.abort === "function") { handler.abort(); } - - if (options.params) { - if ($.isFunction(options.params)) { - $.extend(params, options.params.call(self)); - } else { - $.extend(params, options.params); - } - } - - $.extend(params, { - url: url, - dataType: options.dataType, - data: data, - success: function (data) { - // TODO - replace query.page with query so users have access to term, page, etc. - // added query as third paramter to keep backwards compatibility - var results = options.results(data, query.page, query); - query.callback(results); - }, - error: function(jqXHR, textStatus, errorThrown){ - var results = { - hasError: true, - jqXHR: jqXHR, - textStatus: textStatus, - errorThrown: errorThrown - }; - - query.callback(results); - } - }); - handler = transport.call(self, params); - }, quietMillis); - }; - } - - /** - * Produces a query function that works with a local array - * - * @param options object containing configuration parameters. The options parameter can either be an array or an - * object. - * - * If the array form is used it is assumed that it contains objects with 'id' and 'text' keys. - * - * If the object form is used it is assumed that it contains 'data' and 'text' keys. The 'data' key should contain - * an array of objects that will be used as choices. These objects must contain at least an 'id' key. The 'text' - * key can either be a String in which case it is expected that each element in the 'data' array has a key with the - * value of 'text' which will be used to match choices. Alternatively, text can be a function(item) that can extract - * the text. - */ - function local(options) { - var data = options, // data elements - dataText, - tmp, - text = function (item) { return ""+item.text; }; // function used to retrieve the text portion of a data item that is matched against the search - - if ($.isArray(data)) { - tmp = data; - data = { results: tmp }; - } - - if ($.isFunction(data) === false) { - tmp = data; - data = function() { return tmp; }; - } - - var dataItem = data(); - if (dataItem.text) { - text = dataItem.text; - // if text is not a function we assume it to be a key name - if (!$.isFunction(text)) { - dataText = dataItem.text; // we need to store this in a separate variable because in the next step data gets reset and data.text is no longer available - text = function (item) { return item[dataText]; }; - } - } - - return function (query) { - var t = query.term, filtered = { results: [] }, process; - if (t === "") { - query.callback(data()); - return; - } - - process = function(datum, collection) { - var group, attr; - datum = datum[0]; - if (datum.children) { - group = {}; - for (attr in datum) { - if (datum.hasOwnProperty(attr)) group[attr]=datum[attr]; - } - group.children=[]; - $(datum.children).each2(function(i, childDatum) { process(childDatum, group.children); }); - if (group.children.length || query.matcher(t, text(group), datum)) { - collection.push(group); - } - } else { - if (query.matcher(t, text(datum), datum)) { - collection.push(datum); - } - } - }; - - $(data().results).each2(function(i, datum) { process(datum, filtered.results); }); - query.callback(filtered); - }; - } - - // TODO javadoc - function tags(data) { - var isFunc = $.isFunction(data); - return function (query) { - var t = query.term, filtered = {results: []}; - var result = isFunc ? data(query) : data; - if ($.isArray(result)) { - $(result).each(function () { - var isObject = this.text !== undefined, - text = isObject ? this.text : this; - if (t === "" || query.matcher(t, text)) { - filtered.results.push(isObject ? this : {id: this, text: this}); - } - }); - query.callback(filtered); - } - }; - } - - /** - * Checks if the formatter function should be used. - * - * Throws an error if it is not a function. Returns true if it should be used, - * false if no formatting should be performed. - * - * @param formatter - */ - function checkFormatter(formatter, formatterName) { - if ($.isFunction(formatter)) return true; - if (!formatter) return false; - if (typeof(formatter) === 'string') return true; - throw new Error(formatterName +" must be a string, function, or falsy value"); - } - - /** - * Returns a given value - * If given a function, returns its output - * - * @param val string|function - * @param context value of "this" to be passed to function - * @returns {*} - */ - function evaluate(val, context) { - if ($.isFunction(val)) { - var args = Array.prototype.slice.call(arguments, 2); - return val.apply(context, args); - } - return val; - } - - function countResults(results) { - var count = 0; - $.each(results, function(i, item) { - if (item.children) { - count += countResults(item.children); - } else { - count++; - } - }); - return count; - } - - /** - * Default tokenizer. This function uses breaks the input on substring match of any string from the - * opts.tokenSeparators array and uses opts.createSearchChoice to create the choice object. Both of those - * two options have to be defined in order for the tokenizer to work. - * - * @param input text user has typed so far or pasted into the search field - * @param selection currently selected choices - * @param selectCallback function(choice) callback tho add the choice to selection - * @param opts select2's opts - * @return undefined/null to leave the current input unchanged, or a string to change the input to the returned value - */ - function defaultTokenizer(input, selection, selectCallback, opts) { - var original = input, // store the original so we can compare and know if we need to tell the search to update its text - dupe = false, // check for whether a token we extracted represents a duplicate selected choice - token, // token - index, // position at which the separator was found - i, l, // looping variables - separator; // the matched separator - - if (!opts.createSearchChoice || !opts.tokenSeparators || opts.tokenSeparators.length < 1) return undefined; - - while (true) { - index = -1; - - for (i = 0, l = opts.tokenSeparators.length; i < l; i++) { - separator = opts.tokenSeparators[i]; - index = input.indexOf(separator); - if (index >= 0) break; - } - - if (index < 0) break; // did not find any token separator in the input string, bail - - token = input.substring(0, index); - input = input.substring(index + separator.length); - - if (token.length > 0) { - token = opts.createSearchChoice.call(this, token, selection); - if (token !== undefined && token !== null && opts.id(token) !== undefined && opts.id(token) !== null) { - dupe = false; - for (i = 0, l = selection.length; i < l; i++) { - if (equal(opts.id(token), opts.id(selection[i]))) { - dupe = true; break; - } - } - - if (!dupe) selectCallback(token); - } - } - } - - if (original!==input) return input; - } - - function cleanupJQueryElements() { - var self = this; - - $.each(arguments, function (i, element) { - self[element].remove(); - self[element] = null; - }); - } - - /** - * Creates a new class - * - * @param superClass - * @param methods - */ - function clazz(SuperClass, methods) { - var constructor = function () {}; - constructor.prototype = new SuperClass; - constructor.prototype.constructor = constructor; - constructor.prototype.parent = SuperClass.prototype; - constructor.prototype = $.extend(constructor.prototype, methods); - return constructor; - } - - AbstractSelect2 = clazz(Object, { - - // abstract - bind: function (func) { - var self = this; - return function () { - func.apply(self, arguments); - }; - }, - - // abstract - init: function (opts) { - var results, search, resultsSelector = ".select2-results"; - - // prepare options - this.opts = opts = this.prepareOpts(opts); - - this.id=opts.id; - - // destroy if called on an existing component - if (opts.element.data("select2") !== undefined && - opts.element.data("select2") !== null) { - opts.element.data("select2").destroy(); - } - - this.container = this.createContainer(); - - this.liveRegion = $('.select2-hidden-accessible'); - if (this.liveRegion.length == 0) { - this.liveRegion = $("", { - role: "status", - "aria-live": "polite" - }) - .addClass("select2-hidden-accessible") - .appendTo(document.body); - } - - this.containerId="s2id_"+(opts.element.attr("id") || "autogen"+nextUid()); - this.containerEventName= this.containerId - .replace(/([.])/g, '_') - .replace(/([;&,\-\.\+\*\~':"\!\^#$%@\[\]\(\)=>\|])/g, '\\$1'); - this.container.attr("id", this.containerId); - - this.container.attr("title", opts.element.attr("title")); - - this.body = $(document.body); - - syncCssClasses(this.container, this.opts.element, this.opts.adaptContainerCssClass); - - this.container.attr("style", opts.element.attr("style")); - this.container.css(evaluate(opts.containerCss, this.opts.element)); - this.container.addClass(evaluate(opts.containerCssClass, this.opts.element)); - - this.elementTabIndex = this.opts.element.attr("tabindex"); - - // swap container for the element - this.opts.element - .data("select2", this) - .attr("tabindex", "-1") - .before(this.container) - .on("click.select2", killEvent); // do not leak click events - - this.container.data("select2", this); - - this.dropdown = this.container.find(".select2-drop"); - - syncCssClasses(this.dropdown, this.opts.element, this.opts.adaptDropdownCssClass); - - this.dropdown.addClass(evaluate(opts.dropdownCssClass, this.opts.element)); - this.dropdown.data("select2", this); - this.dropdown.on("click", killEvent); - - this.results = results = this.container.find(resultsSelector); - this.search = search = this.container.find("input.select2-input"); - - this.queryCount = 0; - this.resultsPage = 0; - this.context = null; - - // initialize the container - this.initContainer(); - - this.container.on("click", killEvent); - - installFilteredMouseMove(this.results); - - this.dropdown.on("mousemove-filtered", resultsSelector, this.bind(this.highlightUnderEvent)); - this.dropdown.on("touchstart touchmove touchend", resultsSelector, this.bind(function (event) { - this._touchEvent = true; - this.highlightUnderEvent(event); - })); - this.dropdown.on("touchmove", resultsSelector, this.bind(this.touchMoved)); - this.dropdown.on("touchstart touchend", resultsSelector, this.bind(this.clearTouchMoved)); - - // Waiting for a click event on touch devices to select option and hide dropdown - // otherwise click will be triggered on an underlying element - this.dropdown.on('click', this.bind(function (event) { - if (this._touchEvent) { - this._touchEvent = false; - this.selectHighlighted(); - } - })); - - installDebouncedScroll(80, this.results); - this.dropdown.on("scroll-debounced", resultsSelector, this.bind(this.loadMoreIfNeeded)); - - // do not propagate change event from the search field out of the component - $(this.container).on("change", ".select2-input", function(e) {e.stopPropagation();}); - $(this.dropdown).on("change", ".select2-input", function(e) {e.stopPropagation();}); - - // if jquery.mousewheel plugin is installed we can prevent out-of-bounds scrolling of results via mousewheel - if ($.fn.mousewheel) { - results.mousewheel(function (e, delta, deltaX, deltaY) { - var top = results.scrollTop(); - if (deltaY > 0 && top - deltaY <= 0) { - results.scrollTop(0); - killEvent(e); - } else if (deltaY < 0 && results.get(0).scrollHeight - results.scrollTop() + deltaY <= results.height()) { - results.scrollTop(results.get(0).scrollHeight - results.height()); - killEvent(e); - } - }); - } - - installKeyUpChangeEvent(search); - search.on("keyup-change input paste", this.bind(this.updateResults)); - search.on("focus", function () { search.addClass("select2-focused"); }); - search.on("blur", function () { search.removeClass("select2-focused");}); - - this.dropdown.on("mouseup", resultsSelector, this.bind(function (e) { - if ($(e.target).closest(".select2-result-selectable").length > 0) { - this.highlightUnderEvent(e); - this.selectHighlighted(e); - } - })); - - // trap all mouse events from leaving the dropdown. sometimes there may be a modal that is listening - // for mouse events outside of itself so it can close itself. since the dropdown is now outside the select2's - // dom it will trigger the popup close, which is not what we want - // focusin can cause focus wars between modals and select2 since the dropdown is outside the modal. - this.dropdown.on("click mouseup mousedown touchstart touchend focusin", function (e) { e.stopPropagation(); }); - - this.lastSearchTerm = undefined; - - if ($.isFunction(this.opts.initSelection)) { - // initialize selection based on the current value of the source element - this.initSelection(); - - // if the user has provided a function that can set selection based on the value of the source element - // we monitor the change event on the element and trigger it, allowing for two way synchronization - this.monitorSource(); - } - - if (opts.maximumInputLength !== null) { - this.search.attr("maxlength", opts.maximumInputLength); - } - - var disabled = opts.element.prop("disabled"); - if (disabled === undefined) disabled = false; - this.enable(!disabled); - - var readonly = opts.element.prop("readonly"); - if (readonly === undefined) readonly = false; - this.readonly(readonly); - - // Calculate size of scrollbar - scrollBarDimensions = scrollBarDimensions || measureScrollbar(); - - this.autofocus = opts.element.prop("autofocus"); - opts.element.prop("autofocus", false); - if (this.autofocus) this.focus(); - - this.search.attr("placeholder", opts.searchInputPlaceholder); - }, - - // abstract - destroy: function () { - var element=this.opts.element, select2 = element.data("select2"), self = this; - - this.close(); - - if (element.length && element[0].detachEvent && self._sync) { - element.each(function () { - if (self._sync) { - this.detachEvent("onpropertychange", self._sync); - } - }); - } - if (this.propertyObserver) { - this.propertyObserver.disconnect(); - this.propertyObserver = null; - } - this._sync = null; - - if (select2 !== undefined) { - select2.container.remove(); - select2.liveRegion.remove(); - select2.dropdown.remove(); - element.removeData("select2") - .off(".select2"); - if (!element.is("input[type='hidden']")) { - element - .show() - .prop("autofocus", this.autofocus || false); - if (this.elementTabIndex) { - element.attr({tabindex: this.elementTabIndex}); - } else { - element.removeAttr("tabindex"); - } - element.show(); - } else { - element.css("display", ""); - } - } - - cleanupJQueryElements.call(this, - "container", - "liveRegion", - "dropdown", - "results", - "search" - ); - }, - - // abstract - optionToData: function(element) { - if (element.is("option")) { - return { - id:element.prop("value"), - text:element.text(), - element: element.get(), - css: element.attr("class"), - disabled: element.prop("disabled"), - locked: equal(element.attr("locked"), "locked") || equal(element.data("locked"), true) - }; - } else if (element.is("optgroup")) { - return { - text:element.attr("label"), - children:[], - element: element.get(), - css: element.attr("class") - }; - } - }, - - // abstract - prepareOpts: function (opts) { - var element, select, idKey, ajaxUrl, self = this; - - element = opts.element; - - if (element.get(0).tagName.toLowerCase() === "select") { - this.select = select = opts.element; - } - - if (select) { - // these options are not allowed when attached to a select because they are picked up off the element itself - $.each(["id", "multiple", "ajax", "query", "createSearchChoice", "initSelection", "data", "tags"], function () { - if (this in opts) { - throw new Error("Option '" + this + "' is not allowed for Select2 when attached to a ", - "
", - " ", - "
    ", - "
", - "
"].join("")); - return container; - }, - - // single - enableInterface: function() { - if (this.parent.enableInterface.apply(this, arguments)) { - this.focusser.prop("disabled", !this.isInterfaceEnabled()); - } - }, - - // single - opening: function () { - var el, range, len; - - if (this.opts.minimumResultsForSearch >= 0) { - this.showSearch(true); - } - - this.parent.opening.apply(this, arguments); - - if (this.showSearchInput !== false) { - // IE appends focusser.val() at the end of field :/ so we manually insert it at the beginning using a range - // all other browsers handle this just fine - - this.search.val(this.focusser.val()); - } - if (this.opts.shouldFocusInput(this)) { - this.search.focus(); - // move the cursor to the end after focussing, otherwise it will be at the beginning and - // new text will appear *before* focusser.val() - el = this.search.get(0); - if (el.createTextRange) { - range = el.createTextRange(); - range.collapse(false); - range.select(); - } else if (el.setSelectionRange) { - len = this.search.val().length; - el.setSelectionRange(len, len); - } - } - - this.prefillNextSearchTerm(); - - this.focusser.prop("disabled", true).val(""); - this.updateResults(true); - this.opts.element.trigger($.Event("select2-open")); - }, - - // single - close: function () { - if (!this.opened()) return; - this.parent.close.apply(this, arguments); - - this.focusser.prop("disabled", false); - - if (this.opts.shouldFocusInput(this)) { - this.focusser.focus(); - } - }, - - // single - focus: function () { - if (this.opened()) { - this.close(); - } else { - this.focusser.prop("disabled", false); - if (this.opts.shouldFocusInput(this)) { - this.focusser.focus(); - } - } - }, - - // single - isFocused: function () { - return this.container.hasClass("select2-container-active"); - }, - - // single - cancel: function () { - this.parent.cancel.apply(this, arguments); - this.focusser.prop("disabled", false); - - if (this.opts.shouldFocusInput(this)) { - this.focusser.focus(); - } - }, - - // single - destroy: function() { - $("label[for='" + this.focusser.attr('id') + "']") - .attr('for', this.opts.element.attr("id")); - this.parent.destroy.apply(this, arguments); - - cleanupJQueryElements.call(this, - "selection", - "focusser" - ); - }, - - // single - initContainer: function () { - - var selection, - container = this.container, - dropdown = this.dropdown, - idSuffix = nextUid(), - elementLabel; - - if (this.opts.minimumResultsForSearch < 0) { - this.showSearch(false); - } else { - this.showSearch(true); - } - - this.selection = selection = container.find(".select2-choice"); - - this.focusser = container.find(".select2-focusser"); - - // add aria associations - selection.find(".select2-chosen").attr("id", "select2-chosen-"+idSuffix); - this.focusser.attr("aria-labelledby", "select2-chosen-"+idSuffix); - this.results.attr("id", "select2-results-"+idSuffix); - this.search.attr("aria-owns", "select2-results-"+idSuffix); - - // rewrite labels from original element to focusser - this.focusser.attr("id", "s2id_autogen"+idSuffix); - - elementLabel = $("label[for='" + this.opts.element.attr("id") + "']"); - this.opts.element.on('focus.select2', this.bind(function () { this.focus(); })); - - this.focusser.prev() - .text(elementLabel.text()) - .attr('for', this.focusser.attr('id')); - - // Ensure the original element retains an accessible name - var originalTitle = this.opts.element.attr("title"); - this.opts.element.attr("title", (originalTitle || elementLabel.text())); - - this.focusser.attr("tabindex", this.elementTabIndex); - - // write label for search field using the label from the focusser element - this.search.attr("id", this.focusser.attr('id') + '_search'); - - this.search.prev() - .text($("label[for='" + this.focusser.attr('id') + "']").text()) - .attr('for', this.search.attr('id')); - - this.search.on("keydown", this.bind(function (e) { - if (!this.isInterfaceEnabled()) return; - - // filter 229 keyCodes (input method editor is processing key input) - if (229 == e.keyCode) return; - - if (e.which === KEY.PAGE_UP || e.which === KEY.PAGE_DOWN) { - // prevent the page from scrolling - killEvent(e); - return; - } - - switch (e.which) { - case KEY.UP: - case KEY.DOWN: - this.moveHighlight((e.which === KEY.UP) ? -1 : 1); - killEvent(e); - return; - case KEY.ENTER: - this.selectHighlighted(); - killEvent(e); - return; - case KEY.TAB: - this.selectHighlighted({noFocus: true}); - return; - case KEY.ESC: - this.cancel(e); - killEvent(e); - return; - } - })); - - this.search.on("blur", this.bind(function(e) { - // a workaround for chrome to keep the search field focussed when the scroll bar is used to scroll the dropdown. - // without this the search field loses focus which is annoying - if (document.activeElement === this.body.get(0)) { - window.setTimeout(this.bind(function() { - if (this.opened() && this.results && this.results.length > 1) { - this.search.focus(); - } - }), 0); - } - })); - - this.focusser.on("keydown", this.bind(function (e) { - if (!this.isInterfaceEnabled()) return; - - if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) || e.which === KEY.ESC) { - return; - } - - if (this.opts.openOnEnter === false && e.which === KEY.ENTER) { - killEvent(e); - return; - } - - if (e.which == KEY.DOWN || e.which == KEY.UP - || (e.which == KEY.ENTER && this.opts.openOnEnter)) { - - if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) return; - - this.open(); - killEvent(e); - return; - } - - if (e.which == KEY.DELETE || e.which == KEY.BACKSPACE) { - if (this.opts.allowClear) { - this.clear(); - } - killEvent(e); - return; - } - })); - - - installKeyUpChangeEvent(this.focusser); - this.focusser.on("keyup-change input", this.bind(function(e) { - if (this.opts.minimumResultsForSearch >= 0) { - e.stopPropagation(); - if (this.opened()) return; - this.open(); - } - })); - - selection.on("mousedown touchstart", "abbr", this.bind(function (e) { - if (!this.isInterfaceEnabled()) { - return; - } - - this.clear(); - killEventImmediately(e); - this.close(); - - if (this.selection) { - this.selection.focus(); - } - })); - - selection.on("mousedown touchstart", this.bind(function (e) { - // Prevent IE from generating a click event on the body - reinsertElement(selection); - - if (!this.container.hasClass("select2-container-active")) { - this.opts.element.trigger($.Event("select2-focus")); - } - - if (this.opened()) { - this.close(); - } else if (this.isInterfaceEnabled()) { - this.open(); - } - - killEvent(e); - })); - - dropdown.on("mousedown touchstart", this.bind(function() { - if (this.opts.shouldFocusInput(this)) { - this.search.focus(); - } - })); - - selection.on("focus", this.bind(function(e) { - killEvent(e); - })); - - this.focusser.on("focus", this.bind(function(){ - if (!this.container.hasClass("select2-container-active")) { - this.opts.element.trigger($.Event("select2-focus")); - } - this.container.addClass("select2-container-active"); - })).on("blur", this.bind(function() { - if (!this.opened()) { - this.container.removeClass("select2-container-active"); - this.opts.element.trigger($.Event("select2-blur")); - } - })); - this.search.on("focus", this.bind(function(){ - if (!this.container.hasClass("select2-container-active")) { - this.opts.element.trigger($.Event("select2-focus")); - } - this.container.addClass("select2-container-active"); - })); - - this.initContainerWidth(); - this.opts.element.hide(); - this.setPlaceholder(); - - }, - - // single - clear: function(triggerChange) { - var data=this.selection.data("select2-data"); - if (data) { // guard against queued quick consecutive clicks - var evt = $.Event("select2-clearing"); - this.opts.element.trigger(evt); - if (evt.isDefaultPrevented()) { - return; - } - var placeholderOption = this.getPlaceholderOption(); - this.opts.element.val(placeholderOption ? placeholderOption.val() : ""); - this.selection.find(".select2-chosen").empty(); - this.selection.removeData("select2-data"); - this.setPlaceholder(); - - if (triggerChange !== false){ - this.opts.element.trigger({ type: "select2-removed", val: this.id(data), choice: data }); - this.triggerChange({removed:data}); - } - } - }, - - /** - * Sets selection based on source element's value - */ - // single - initSelection: function () { - var selected; - if (this.isPlaceholderOptionSelected()) { - this.updateSelection(null); - this.close(); - this.setPlaceholder(); - } else { - var self = this; - this.opts.initSelection.call(null, this.opts.element, function(selected){ - if (selected !== undefined && selected !== null) { - self.updateSelection(selected); - self.close(); - self.setPlaceholder(); - self.lastSearchTerm = self.search.val(); - } - }); - } - }, - - isPlaceholderOptionSelected: function() { - var placeholderOption; - if (this.getPlaceholder() === undefined) return false; // no placeholder specified so no option should be considered - return ((placeholderOption = this.getPlaceholderOption()) !== undefined && placeholderOption.prop("selected")) - || (this.opts.element.val() === "") - || (this.opts.element.val() === undefined) - || (this.opts.element.val() === null); - }, - - // single - prepareOpts: function () { - var opts = this.parent.prepareOpts.apply(this, arguments), - self=this; - - if (opts.element.get(0).tagName.toLowerCase() === "select") { - // install the selection initializer - opts.initSelection = function (element, callback) { - var selected = element.find("option").filter(function() { return this.selected && !this.disabled }); - // a single select box always has a value, no need to null check 'selected' - callback(self.optionToData(selected)); - }; - } else if ("data" in opts) { - // install default initSelection when applied to hidden input and data is local - opts.initSelection = opts.initSelection || function (element, callback) { - var id = element.val(); - //search in data by id, storing the actual matching item - var match = null; - opts.query({ - matcher: function(term, text, el){ - var is_match = equal(id, opts.id(el)); - if (is_match) { - match = el; - } - return is_match; - }, - callback: !$.isFunction(callback) ? $.noop : function() { - callback(match); - } - }); - }; - } - - return opts; - }, - - // single - getPlaceholder: function() { - // if a placeholder is specified on a single select without a valid placeholder option ignore it - if (this.select) { - if (this.getPlaceholderOption() === undefined) { - return undefined; - } - } - - return this.parent.getPlaceholder.apply(this, arguments); - }, - - // single - setPlaceholder: function () { - var placeholder = this.getPlaceholder(); - - if (this.isPlaceholderOptionSelected() && placeholder !== undefined) { - - // check for a placeholder option if attached to a select - if (this.select && this.getPlaceholderOption() === undefined) return; - - this.selection.find(".select2-chosen").html(this.opts.escapeMarkup(placeholder)); - - this.selection.addClass("select2-default"); - - this.container.removeClass("select2-allowclear"); - } - }, - - // single - postprocessResults: function (data, initial, noHighlightUpdate) { - var selected = 0, self = this, showSearchInput = true; - - // find the selected element in the result list - - this.findHighlightableChoices().each2(function (i, elm) { - if (equal(self.id(elm.data("select2-data")), self.opts.element.val())) { - selected = i; - return false; - } - }); - - // and highlight it - if (noHighlightUpdate !== false) { - if (initial === true && selected >= 0) { - this.highlight(selected); - } else { - this.highlight(0); - } - } - - // hide the search box if this is the first we got the results and there are enough of them for search - - if (initial === true) { - var min = this.opts.minimumResultsForSearch; - if (min >= 0) { - this.showSearch(countResults(data.results) >= min); - } - } - }, - - // single - showSearch: function(showSearchInput) { - if (this.showSearchInput === showSearchInput) return; - - this.showSearchInput = showSearchInput; - - this.dropdown.find(".select2-search").toggleClass("select2-search-hidden", !showSearchInput); - this.dropdown.find(".select2-search").toggleClass("select2-offscreen", !showSearchInput); - //add "select2-with-searchbox" to the container if search box is shown - $(this.dropdown, this.container).toggleClass("select2-with-searchbox", showSearchInput); - }, - - // single - onSelect: function (data, options) { - - if (!this.triggerSelect(data)) { return; } - - var old = this.opts.element.val(), - oldData = this.data(); - - this.opts.element.val(this.id(data)); - this.updateSelection(data); - - this.opts.element.trigger({ type: "select2-selected", val: this.id(data), choice: data }); - - this.lastSearchTerm = this.search.val(); - this.close(); - - if ((!options || !options.noFocus) && this.opts.shouldFocusInput(this)) { - this.focusser.focus(); - } - - if (!equal(old, this.id(data))) { - this.triggerChange({ added: data, removed: oldData }); - } - }, - - // single - updateSelection: function (data) { - - var container=this.selection.find(".select2-chosen"), formatted, cssClass; - - this.selection.data("select2-data", data); - - container.empty(); - if (data !== null) { - formatted=this.opts.formatSelection(data, container, this.opts.escapeMarkup); - } - if (formatted !== undefined) { - container.append(formatted); - } - cssClass=this.opts.formatSelectionCssClass(data, container); - if (cssClass !== undefined) { - container.addClass(cssClass); - } - - this.selection.removeClass("select2-default"); - - if (this.opts.allowClear && this.getPlaceholder() !== undefined) { - this.container.addClass("select2-allowclear"); - } - }, - - // single - val: function () { - var val, - triggerChange = false, - data = null, - self = this, - oldData = this.data(); - - if (arguments.length === 0) { - return this.opts.element.val(); - } - - val = arguments[0]; - - if (arguments.length > 1) { - triggerChange = arguments[1]; - - if (this.opts.debug && console && console.warn) { - console.warn( - 'Select2: The second option to `select2("val")` is not supported in Select2 4.0.0. ' + - 'The `change` event will always be triggered in 4.0.0.' - ); - } - } - - if (this.select) { - if (this.opts.debug && console && console.warn) { - console.warn( - 'Select2: Setting the value on a ", - " ", - "", - "
", - "
    ", - "
", - "
"].join("")); - return container; - }, - - // multi - prepareOpts: function () { - var opts = this.parent.prepareOpts.apply(this, arguments), - self=this; - - // TODO validate placeholder is a string if specified - if (opts.element.get(0).tagName.toLowerCase() === "select") { - // install the selection initializer - opts.initSelection = function (element, callback) { - - var data = []; - - element.find("option").filter(function() { return this.selected && !this.disabled }).each2(function (i, elm) { - data.push(self.optionToData(elm)); - }); - callback(data); - }; - } else if ("data" in opts) { - // install default initSelection when applied to hidden input and data is local - opts.initSelection = opts.initSelection || function (element, callback) { - var ids = splitVal(element.val(), opts.separator, opts.transformVal); - //search in data by array of ids, storing matching items in a list - var matches = []; - opts.query({ - matcher: function(term, text, el){ - var is_match = $.grep(ids, function(id) { - return equal(id, opts.id(el)); - }).length; - if (is_match) { - matches.push(el); - } - return is_match; - }, - callback: !$.isFunction(callback) ? $.noop : function() { - // reorder matches based on the order they appear in the ids array because right now - // they are in the order in which they appear in data array - var ordered = []; - for (var i = 0; i < ids.length; i++) { - var id = ids[i]; - for (var j = 0; j < matches.length; j++) { - var match = matches[j]; - if (equal(id, opts.id(match))) { - ordered.push(match); - matches.splice(j, 1); - break; - } - } - } - callback(ordered); - } - }); - }; - } - - return opts; - }, - - // multi - selectChoice: function (choice) { - - var selected = this.container.find(".select2-search-choice-focus"); - if (selected.length && choice && choice[0] == selected[0]) { - - } else { - if (selected.length) { - this.opts.element.trigger("choice-deselected", selected); - } - selected.removeClass("select2-search-choice-focus"); - if (choice && choice.length) { - this.close(); - choice.addClass("select2-search-choice-focus"); - this.opts.element.trigger("choice-selected", choice); - } - } - }, - - // multi - destroy: function() { - $("label[for='" + this.search.attr('id') + "']") - .attr('for', this.opts.element.attr("id")); - this.parent.destroy.apply(this, arguments); - - cleanupJQueryElements.call(this, - "searchContainer", - "selection" - ); - }, - - // multi - initContainer: function () { - - var selector = ".select2-choices", selection; - - this.searchContainer = this.container.find(".select2-search-field"); - this.selection = selection = this.container.find(selector); - - var _this = this; - this.selection.on("click", ".select2-container:not(.select2-container-disabled) .select2-search-choice:not(.select2-locked)", function (e) { - _this.search[0].focus(); - _this.selectChoice($(this)); - }); - - // rewrite labels from original element to focusser - this.search.attr("id", "s2id_autogen"+nextUid()); - - this.search.prev() - .text($("label[for='" + this.opts.element.attr("id") + "']").text()) - .attr('for', this.search.attr('id')); - this.opts.element.on('focus.select2', this.bind(function () { this.focus(); })); - - this.search.on("input paste", this.bind(function() { - if (this.search.attr('placeholder') && this.search.val().length == 0) return; - if (!this.isInterfaceEnabled()) return; - if (!this.opened()) { - this.open(); - } - })); - - this.search.attr("tabindex", this.elementTabIndex); - - this.keydowns = 0; - this.search.on("keydown", this.bind(function (e) { - if (!this.isInterfaceEnabled()) return; - - ++this.keydowns; - var selected = selection.find(".select2-search-choice-focus"); - var prev = selected.prev(".select2-search-choice:not(.select2-locked)"); - var next = selected.next(".select2-search-choice:not(.select2-locked)"); - var pos = getCursorInfo(this.search); - - if (selected.length && - (e.which == KEY.LEFT || e.which == KEY.RIGHT || e.which == KEY.BACKSPACE || e.which == KEY.DELETE || e.which == KEY.ENTER)) { - var selectedChoice = selected; - if (e.which == KEY.LEFT && prev.length) { - selectedChoice = prev; - } - else if (e.which == KEY.RIGHT) { - selectedChoice = next.length ? next : null; - } - else if (e.which === KEY.BACKSPACE) { - if (this.unselect(selected.first())) { - this.search.width(10); - selectedChoice = prev.length ? prev : next; - } - } else if (e.which == KEY.DELETE) { - if (this.unselect(selected.first())) { - this.search.width(10); - selectedChoice = next.length ? next : null; - } - } else if (e.which == KEY.ENTER) { - selectedChoice = null; - } - - this.selectChoice(selectedChoice); - killEvent(e); - if (!selectedChoice || !selectedChoice.length) { - this.open(); - } - return; - } else if (((e.which === KEY.BACKSPACE && this.keydowns == 1) - || e.which == KEY.LEFT) && (pos.offset == 0 && !pos.length)) { - - this.selectChoice(selection.find(".select2-search-choice:not(.select2-locked)").last()); - killEvent(e); - return; - } else { - this.selectChoice(null); - } - - if (this.opened()) { - switch (e.which) { - case KEY.UP: - case KEY.DOWN: - this.moveHighlight((e.which === KEY.UP) ? -1 : 1); - killEvent(e); - return; - case KEY.ENTER: - this.selectHighlighted(); - killEvent(e); - return; - case KEY.TAB: - this.selectHighlighted({noFocus:true}); - this.close(); - return; - case KEY.ESC: - this.cancel(e); - killEvent(e); - return; - } - } - - if (e.which === KEY.TAB || KEY.isControl(e) || KEY.isFunctionKey(e) - || e.which === KEY.BACKSPACE || e.which === KEY.ESC) { - return; - } - - if (e.which === KEY.ENTER) { - if (this.opts.openOnEnter === false) { - return; - } else if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) { - return; - } - } - - this.open(); - - if (e.which === KEY.PAGE_UP || e.which === KEY.PAGE_DOWN) { - // prevent the page from scrolling - killEvent(e); - } - - if (e.which === KEY.ENTER) { - // prevent form from being submitted - killEvent(e); - } - - })); - - this.search.on("keyup", this.bind(function (e) { - this.keydowns = 0; - this.resizeSearch(); - }) - ); - - this.search.on("blur", this.bind(function(e) { - this.container.removeClass("select2-container-active"); - this.search.removeClass("select2-focused"); - this.selectChoice(null); - if (!this.opened()) this.clearSearch(); - e.stopImmediatePropagation(); - this.opts.element.trigger($.Event("select2-blur")); - })); - - this.container.on("click", selector, this.bind(function (e) { - if (!this.isInterfaceEnabled()) return; - if ($(e.target).closest(".select2-search-choice").length > 0) { - // clicked inside a select2 search choice, do not open - return; - } - this.selectChoice(null); - this.clearPlaceholder(); - if (!this.container.hasClass("select2-container-active")) { - this.opts.element.trigger($.Event("select2-focus")); - } - this.open(); - this.focusSearch(); - e.preventDefault(); - })); - - this.container.on("focus", selector, this.bind(function () { - if (!this.isInterfaceEnabled()) return; - if (!this.container.hasClass("select2-container-active")) { - this.opts.element.trigger($.Event("select2-focus")); - } - this.container.addClass("select2-container-active"); - this.dropdown.addClass("select2-drop-active"); - this.clearPlaceholder(); - })); - - this.initContainerWidth(); - this.opts.element.hide(); - - // set the placeholder if necessary - this.clearSearch(); - }, - - // multi - enableInterface: function() { - if (this.parent.enableInterface.apply(this, arguments)) { - this.search.prop("disabled", !this.isInterfaceEnabled()); - } - }, - - // multi - initSelection: function () { - var data; - if (this.opts.element.val() === "" && this.opts.element.text() === "") { - this.updateSelection([]); - this.close(); - // set the placeholder if necessary - this.clearSearch(); - } - if (this.select || this.opts.element.val() !== "") { - var self = this; - this.opts.initSelection.call(null, this.opts.element, function(data){ - if (data !== undefined && data !== null) { - self.updateSelection(data); - self.close(); - // set the placeholder if necessary - self.clearSearch(); - } - }); - } - }, - - // multi - clearSearch: function () { - var placeholder = this.getPlaceholder(), - maxWidth = this.getMaxSearchWidth(); - - if (placeholder !== undefined && this.getVal().length === 0 && this.search.hasClass("select2-focused") === false) { - this.search.val(placeholder).addClass("select2-default"); - // stretch the search box to full width of the container so as much of the placeholder is visible as possible - // we could call this.resizeSearch(), but we do not because that requires a sizer and we do not want to create one so early because of a firefox bug, see #944 - this.search.width(maxWidth > 0 ? maxWidth : this.container.css("width")); - } else { - this.search.val("").width(10); - } - }, - - // multi - clearPlaceholder: function () { - if (this.search.hasClass("select2-default")) { - this.search.val("").removeClass("select2-default"); - } - }, - - // multi - opening: function () { - this.clearPlaceholder(); // should be done before super so placeholder is not used to search - this.resizeSearch(); - - this.parent.opening.apply(this, arguments); - - this.focusSearch(); - - this.prefillNextSearchTerm(); - this.updateResults(true); - - if (this.opts.shouldFocusInput(this)) { - this.search.focus(); - } - this.opts.element.trigger($.Event("select2-open")); - }, - - // multi - close: function () { - if (!this.opened()) return; - this.parent.close.apply(this, arguments); - }, - - // multi - focus: function () { - this.close(); - this.search.focus(); - }, - - // multi - isFocused: function () { - return this.search.hasClass("select2-focused"); - }, - - // multi - updateSelection: function (data) { - var ids = {}, filtered = [], self = this; - - // filter out duplicates - $(data).each(function () { - if (!(self.id(this) in ids)) { - ids[self.id(this)] = 0; - filtered.push(this); - } - }); - - this.selection.find(".select2-search-choice").remove(); - this.addSelectedChoice(filtered); - self.postprocessResults(); - }, - - // multi - tokenize: function() { - var input = this.search.val(); - input = this.opts.tokenizer.call(this, input, this.data(), this.bind(this.onSelect), this.opts); - if (input != null && input != undefined) { - this.search.val(input); - if (input.length > 0) { - this.open(); - } - } - - }, - - // multi - onSelect: function (data, options) { - - if (!this.triggerSelect(data) || data.text === "") { return; } - - this.addSelectedChoice(data); - - this.opts.element.trigger({ type: "selected", val: this.id(data), choice: data }); - - // keep track of the search's value before it gets cleared - this.lastSearchTerm = this.search.val(); - - this.clearSearch(); - this.updateResults(); - - if (this.select || !this.opts.closeOnSelect) this.postprocessResults(data, false, this.opts.closeOnSelect===true); - - if (this.opts.closeOnSelect) { - this.close(); - this.search.width(10); - } else { - if (this.countSelectableResults()>0) { - this.search.width(10); - this.resizeSearch(); - if (this.getMaximumSelectionSize() > 0 && this.val().length >= this.getMaximumSelectionSize()) { - // if we reached max selection size repaint the results so choices - // are replaced with the max selection reached message - this.updateResults(true); - } else { - // initializes search's value with nextSearchTerm and update search result - if (this.prefillNextSearchTerm()) { - this.updateResults(); - } - } - this.positionDropdown(); - } else { - // if nothing left to select close - this.close(); - this.search.width(10); - } - } - - // since its not possible to select an element that has already been - // added we do not need to check if this is a new element before firing change - this.triggerChange({ added: data }); - - if (!options || !options.noFocus) - this.focusSearch(); - }, - - // multi - cancel: function () { - this.close(); - this.focusSearch(); - }, - - addSelectedChoice: function (data) { - var val = this.getVal(), self = this; - $(data).each(function () { - val.push(self.createChoice(this)); - }); - this.setVal(val); - }, - - createChoice: function (data) { - var enableChoice = !data.locked, - enabledItem = $( - "
  • " + - "
    " + - " " + - "
  • "), - disabledItem = $( - "
  • " + - "
    " + - "
  • "); - var choice = enableChoice ? enabledItem : disabledItem, - id = this.id(data), - formatted, - cssClass; - - formatted=this.opts.formatSelection(data, choice.find("div"), this.opts.escapeMarkup); - if (formatted != undefined) { - choice.find("div").replaceWith($("
    ").html(formatted)); - } - cssClass=this.opts.formatSelectionCssClass(data, choice.find("div")); - if (cssClass != undefined) { - choice.addClass(cssClass); - } - - if(enableChoice){ - choice.find(".select2-search-choice-close") - .on("mousedown", killEvent) - .on("click dblclick", this.bind(function (e) { - if (!this.isInterfaceEnabled()) return; - - this.unselect($(e.target)); - this.selection.find(".select2-search-choice-focus").removeClass("select2-search-choice-focus"); - killEvent(e); - this.close(); - this.focusSearch(); - })).on("focus", this.bind(function () { - if (!this.isInterfaceEnabled()) return; - this.container.addClass("select2-container-active"); - this.dropdown.addClass("select2-drop-active"); - })); - } - - choice.data("select2-data", data); - choice.insertBefore(this.searchContainer); - - return id; - }, - - // multi - unselect: function (selected) { - var val = this.getVal(), - data, - index; - selected = selected.closest(".select2-search-choice"); - - if (selected.length === 0) { - throw "Invalid argument: " + selected + ". Must be .select2-search-choice"; - } - - data = selected.data("select2-data"); - - if (!data) { - // prevent a race condition when the 'x' is clicked really fast repeatedly the event can be queued - // and invoked on an element already removed - return; - } - - var evt = $.Event("select2-removing"); - evt.val = this.id(data); - evt.choice = data; - this.opts.element.trigger(evt); - - if (evt.isDefaultPrevented()) { - return false; - } - - while((index = indexOf(this.id(data), val)) >= 0) { - val.splice(index, 1); - this.setVal(val); - if (this.select) this.postprocessResults(); - } - - selected.remove(); - - this.opts.element.trigger({ type: "select2-removed", val: this.id(data), choice: data }); - this.triggerChange({ removed: data }); - - return true; - }, - - // multi - postprocessResults: function (data, initial, noHighlightUpdate) { - var val = this.getVal(), - choices = this.results.find(".select2-result"), - compound = this.results.find(".select2-result-with-children"), - self = this; - - choices.each2(function (i, choice) { - var id = self.id(choice.data("select2-data")); - if (indexOf(id, val) >= 0) { - choice.addClass("select2-selected"); - // mark all children of the selected parent as selected - choice.find(".select2-result-selectable").addClass("select2-selected"); - } - }); - - compound.each2(function(i, choice) { - // hide an optgroup if it doesn't have any selectable children - if (!choice.is('.select2-result-selectable') - && choice.find(".select2-result-selectable:not(.select2-selected)").length === 0) { - choice.addClass("select2-selected"); - } - }); - - if (this.highlight() == -1 && noHighlightUpdate !== false && this.opts.closeOnSelect === true){ - self.highlight(0); - } - - //If all results are chosen render formatNoMatches - if(!this.opts.createSearchChoice && !choices.filter('.select2-result:not(.select2-selected)').length > 0){ - if(!data || data && !data.more && this.results.find(".select2-no-results").length === 0) { - if (checkFormatter(self.opts.formatNoMatches, "formatNoMatches")) { - this.results.append("
  • " + evaluate(self.opts.formatNoMatches, self.opts.element, self.search.val()) + "
  • "); - } - } - } - - }, - - // multi - getMaxSearchWidth: function() { - return this.selection.width() - getSideBorderPadding(this.search); - }, - - // multi - resizeSearch: function () { - var minimumWidth, left, maxWidth, containerLeft, searchWidth, - sideBorderPadding = getSideBorderPadding(this.search); - - minimumWidth = measureTextWidth(this.search) + 10; - - left = this.search.offset().left; - - maxWidth = this.selection.width(); - containerLeft = this.selection.offset().left; - - searchWidth = maxWidth - (left - containerLeft) - sideBorderPadding; - - if (searchWidth < minimumWidth) { - searchWidth = maxWidth - sideBorderPadding; - } - - if (searchWidth < 40) { - searchWidth = maxWidth - sideBorderPadding; - } - - if (searchWidth <= 0) { - searchWidth = minimumWidth; - } - - this.search.width(Math.floor(searchWidth)); - }, - - // multi - getVal: function () { - var val; - if (this.select) { - val = this.select.val(); - return val === null ? [] : val; - } else { - val = this.opts.element.val(); - return splitVal(val, this.opts.separator, this.opts.transformVal); - } - }, - - // multi - setVal: function (val) { - if (this.select) { - this.select.val(val); - } else { - var unique = [], valMap = {}; - // filter out duplicates - $(val).each(function () { - if (!(this in valMap)) { - unique.push(this); - valMap[this] = 0; - } - }); - this.opts.element.val(unique.length === 0 ? "" : unique.join(this.opts.separator)); - } - }, - - // multi - buildChangeDetails: function (old, current) { - var current = current.slice(0), - old = old.slice(0); - - // remove intersection from each array - for (var i = 0; i < current.length; i++) { - for (var j = 0; j < old.length; j++) { - if (equal(this.opts.id(current[i]), this.opts.id(old[j]))) { - current.splice(i, 1); - i--; - old.splice(j, 1); - break; - } - } - } - - return {added: current, removed: old}; - }, - - - // multi - val: function (val, triggerChange) { - var oldData, self=this; - - if (arguments.length === 0) { - return this.getVal(); - } - - oldData=this.data(); - if (!oldData.length) oldData=[]; - - // val is an id. !val is true for [undefined,null,'',0] - 0 is legal - if (!val && val !== 0) { - this.opts.element.val(""); - this.updateSelection([]); - this.clearSearch(); - if (triggerChange) { - this.triggerChange({added: this.data(), removed: oldData}); - } - return; - } - - // val is a list of ids - this.setVal(val); - - if (this.select) { - this.opts.initSelection(this.select, this.bind(this.updateSelection)); - if (triggerChange) { - this.triggerChange(this.buildChangeDetails(oldData, this.data())); - } - } else { - if (this.opts.initSelection === undefined) { - throw new Error("val() cannot be called if initSelection() is not defined"); - } - - this.opts.initSelection(this.opts.element, function(data){ - var ids=$.map(data, self.id); - self.setVal(ids); - self.updateSelection(data); - self.clearSearch(); - if (triggerChange) { - self.triggerChange(self.buildChangeDetails(oldData, self.data())); - } - }); - } - this.clearSearch(); - }, - - // multi - onSortStart: function() { - if (this.select) { - throw new Error("Sorting of elements is not supported when attached to instead."); - } - - // collapse search field into 0 width so its container can be collapsed as well - this.search.width(0); - // hide the container - this.searchContainer.hide(); - }, - - // multi - onSortEnd:function() { - - var val=[], self=this; - - // show search and move it to the end of the list - this.searchContainer.show(); - // make sure the search container is the last item in the list - this.searchContainer.appendTo(this.searchContainer.parent()); - // since we collapsed the width in dragStarted, we resize it here - this.resizeSearch(); - - // update selection - this.selection.find(".select2-search-choice").each(function() { - val.push(self.opts.id($(this).data("select2-data"))); - }); - this.setVal(val); - this.triggerChange(); - }, - - // multi - data: function(values, triggerChange) { - var self=this, ids, old; - if (arguments.length === 0) { - return this.selection - .children(".select2-search-choice") - .map(function() { return $(this).data("select2-data"); }) - .get(); - } else { - old = this.data(); - if (!values) { values = []; } - ids = $.map(values, function(e) { return self.opts.id(e); }); - this.setVal(ids); - this.updateSelection(values); - this.clearSearch(); - if (triggerChange) { - this.triggerChange(this.buildChangeDetails(old, this.data())); - } - } - } - }); - - $.fn.select2 = function () { - - var args = Array.prototype.slice.call(arguments, 0), - opts, - select2, - method, value, multiple, - allowedMethods = ["val", "destroy", "opened", "open", "close", "focus", "isFocused", "container", "dropdown", "onSortStart", "onSortEnd", "enable", "disable", "readonly", "positionDropdown", "data", "search"], - valueMethods = ["opened", "isFocused", "container", "dropdown"], - propertyMethods = ["val", "data"], - methodsMap = { search: "externalSearch" }; - - this.each(function () { - if (args.length === 0 || typeof(args[0]) === "object") { - opts = args.length === 0 ? {} : $.extend({}, args[0]); - opts.element = $(this); - - if (opts.element.get(0).tagName.toLowerCase() === "select") { - multiple = opts.element.prop("multiple"); - } else { - multiple = opts.multiple || false; - if ("tags" in opts) {opts.multiple = multiple = true;} - } - - select2 = multiple ? new window.Select2["class"].multi() : new window.Select2["class"].single(); - select2.init(opts); - } else if (typeof(args[0]) === "string") { - - if (indexOf(args[0], allowedMethods) < 0) { - throw "Unknown method: " + args[0]; - } - - value = undefined; - select2 = $(this).data("select2"); - if (select2 === undefined) return; - - method=args[0]; - - if (method === "container") { - value = select2.container; - } else if (method === "dropdown") { - value = select2.dropdown; - } else { - if (methodsMap[method]) method = methodsMap[method]; - - value = select2[method].apply(select2, args.slice(1)); - } - if (indexOf(args[0], valueMethods) >= 0 - || (indexOf(args[0], propertyMethods) >= 0 && args.length == 1)) { - return false; // abort the iteration, ready to return first matched value - } - } else { - throw "Invalid arguments to select2 plugin: " + args; - } - }); - return (value === undefined) ? this : value; - }; - - // plugin defaults, accessible to users - $.fn.select2.defaults = { - debug: false, - width: "copy", - loadMorePadding: 0, - closeOnSelect: true, - openOnEnter: true, - containerCss: {}, - dropdownCss: {}, - containerCssClass: "", - dropdownCssClass: "", - formatResult: function(result, container, query, escapeMarkup) { - var markup=[]; - markMatch(this.text(result), query.term, markup, escapeMarkup); - return markup.join(""); - }, - transformVal: function(val) { - return $.trim(val); - }, - formatSelection: function (data, container, escapeMarkup) { - return data ? escapeMarkup(this.text(data)) : undefined; - }, - sortResults: function (results, container, query) { - return results; - }, - formatResultCssClass: function(data) {return data.css;}, - formatSelectionCssClass: function(data, container) {return undefined;}, - minimumResultsForSearch: 0, - minimumInputLength: 0, - maximumInputLength: null, - maximumSelectionSize: 0, - id: function (e) { return e == undefined ? null : e.id; }, - text: function (e) { - if (e && this.data && this.data.text) { - if ($.isFunction(this.data.text)) { - return this.data.text(e); - } else { - return e[this.data.text]; - } - } else { - return e.text; - } - }, - matcher: function(term, text) { - return stripDiacritics(''+text).toUpperCase().indexOf(stripDiacritics(''+term).toUpperCase()) >= 0; - }, - separator: ",", - tokenSeparators: [], - tokenizer: defaultTokenizer, - escapeMarkup: defaultEscapeMarkup, - blurOnChange: false, - selectOnBlur: false, - adaptContainerCssClass: function(c) { return c; }, - adaptDropdownCssClass: function(c) { return null; }, - nextSearchTerm: function(selectedObject, currentSearchTerm) { return undefined; }, - searchInputPlaceholder: '', - createSearchChoicePosition: 'top', - shouldFocusInput: function (instance) { - // Attempt to detect touch devices - var supportsTouchEvents = (('ontouchstart' in window) || - (navigator.msMaxTouchPoints > 0)); - - // Only devices which support touch events should be special cased - if (!supportsTouchEvents) { - return true; - } - - // Never focus the input if search is disabled - if (instance.opts.minimumResultsForSearch < 0) { - return false; - } - - return true; - } - }; - - $.fn.select2.locales = []; - - $.fn.select2.locales['en'] = { - formatMatches: function (matches) { if (matches === 1) { return "One result is available, press enter to select it."; } return matches + " results are available, use up and down arrow keys to navigate."; }, - formatNoMatches: function () { return "No matches found"; }, - formatAjaxError: function (jqXHR, textStatus, errorThrown) { return "Loading failed"; }, - formatInputTooShort: function (input, min) { var n = min - input.length; return "Please enter " + n + " or more character" + (n == 1 ? "" : "s"); }, - formatInputTooLong: function (input, max) { var n = input.length - max; return "Please delete " + n + " character" + (n == 1 ? "" : "s"); }, - formatSelectionTooBig: function (limit) { return "You can only select " + limit + " item" + (limit == 1 ? "" : "s"); }, - formatLoadMore: function (pageNumber) { return "Loading more results…"; }, - formatSearching: function () { return "Searching…"; } - }; - - $.extend($.fn.select2.defaults, $.fn.select2.locales['en']); - - $.fn.select2.ajaxDefaults = { - transport: $.ajax, - params: { - type: "GET", - cache: false, - dataType: "json" - } - }; - - // exports - window.Select2 = { - query: { - ajax: ajax, - local: local, - tags: tags - }, util: { - debounce: debounce, - markMatch: markMatch, - escapeMarkup: defaultEscapeMarkup, - stripDiacritics: stripDiacritics - }, "class": { - "abstract": AbstractSelect2, - "single": SingleSelect2, - "multi": MultiSelect2 - } - }; - -}(jQuery)); From 3be0294465ae32528ba2b854bdb7ad30ce804a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Mon, 26 Feb 2018 16:05:35 +0100 Subject: [PATCH 138/299] FIX: local post onebox was always pointing to 1st post --- lib/onebox/templates/discourse_topic_onebox.hbs | 2 +- lib/oneboxer.rb | 1 + spec/components/oneboxer_spec.rb | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/onebox/templates/discourse_topic_onebox.hbs b/lib/onebox/templates/discourse_topic_onebox.hbs index 8b6d746b71..856d3342d4 100644 --- a/lib/onebox/templates/discourse_topic_onebox.hbs +++ b/lib/onebox/templates/discourse_topic_onebox.hbs @@ -1,4 +1,4 @@ -