From e4a33cbc0abb91a0a1aca1c821f5fc4ec61c69a9 Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Mon, 14 May 2018 12:23:54 -0700 Subject: [PATCH 001/278] FIX: update cache times for service workers Add a last modified time. Register newer service workers and claim clients more quickly. --- app/assets/javascripts/service-worker.js.erb | 4 ++++ app/controllers/static_controller.rb | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/service-worker.js.erb b/app/assets/javascripts/service-worker.js.erb index fba1cfdef2..1b216d8c5c 100644 --- a/app/assets/javascripts/service-worker.js.erb +++ b/app/assets/javascripts/service-worker.js.erb @@ -37,6 +37,8 @@ self.addEventListener('install', function(event) { return caches.open(CURRENT_CACHES.offline).then(function(cache) { return cache.put(OFFLINE_URL, response); }); + }).then(function(cache) { + self.skipWaiting(); }) ); }); @@ -60,6 +62,8 @@ self.addEventListener('activate', function(event) { } }) ); + }).then(function() { + self.clients.claim() }) ); }); diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb index 76a2085883..196a4fe02c 100644 --- a/app/controllers/static_controller.rb +++ b/app/controllers/static_controller.rb @@ -160,8 +160,11 @@ class StaticController < ApplicationController format.js do # https://github.com/w3c/ServiceWorker/blob/master/explainer.md#updating-a-service-worker # Maximum cache that the service worker will respect is 24 hours. - immutable_for 24.hours + # However, ensure that these may be cached and served for longer on servers. + immutable_for 1.year + path = File.expand_path(Rails.root + "public/assets/#{Rails.application.assets_manifest.assets['service-worker.js']}") + response.headers["Last-Modified"] = File.ctime(path).httpdate render( plain: Rails.application.assets_manifest.find_sources('service-worker.js').first, content_type: 'application/javascript' From 82a95164e9d0ab82b82f444ddd216bccc7f82c4a Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Tue, 15 May 2018 14:18:45 -0700 Subject: [PATCH 002/278] Fix: Safari failing to download backups from email links disable service workers on safari bypass serviceworker network if this is a URL with a token param --- .../discourse/initializers/register-service-worker.js.es6 | 3 ++- app/assets/javascripts/service-worker.js.erb | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/initializers/register-service-worker.js.es6 b/app/assets/javascripts/discourse/initializers/register-service-worker.js.es6 index bb1bff2b7c..8632ced10e 100644 --- a/app/assets/javascripts/discourse/initializers/register-service-worker.js.es6 +++ b/app/assets/javascripts/discourse/initializers/register-service-worker.js.es6 @@ -6,9 +6,10 @@ export default { (location.hostname === "localhost"); const isSupported= isSecured && ('serviceWorker' in navigator); + const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); if (isSupported) { - if (Discourse.ServiceWorkerURL) { + if (Discourse.ServiceWorkerURL && !isSafari) { navigator.serviceWorker .register(`${Discourse.BaseUri}/${Discourse.ServiceWorkerURL}`) .catch(error => { diff --git a/app/assets/javascripts/service-worker.js.erb b/app/assets/javascripts/service-worker.js.erb index fba1cfdef2..cee37f2902 100644 --- a/app/assets/javascripts/service-worker.js.erb +++ b/app/assets/javascripts/service-worker.js.erb @@ -65,6 +65,10 @@ self.addEventListener('activate', function(event) { }); self.addEventListener('fetch', function(event) { + // Bypass service workers if this is a url with a token param + if(/\?.*token/i.test(event.request.url)) { + return; + } // We only want to call event.respondWith() if this is a navigation request // for an HTML page. // request.mode of 'navigate' is unfortunately not supported in Chrome From 0db04956d7b4b0bf2629decab448f2dcc86efc76 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 17 May 2018 12:24:13 +1000 Subject: [PATCH 003/278] update description of graph --- 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 d67781b656..35354b561f 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -855,8 +855,8 @@ en: dau_by_mau: title: "DAU/MAU" xaxis: "Day" - yaxis: "DAU/MAY" - description: "DAU / MAU – no. of members that logged in in the last day divided by no. of members that logged in in the last month – returns a %" + yaxis: "DAU/MAU" + description: "No. of members that logged in in the last day divided by no of members that logged in in the last month – returns a % which indicates community 'stickiness'. Aim for >30%." daily_engaged_users: title: "Daily Engaged Users" xaxis: "Day" From b6322a35f287ca84f66298bc1ce074b8a08b919e Mon Sep 17 00:00:00 2001 From: Barry van Oudtshoorn Date: Thu, 17 May 2018 12:01:13 +0800 Subject: [PATCH 004/278] Fix super-wide tables in Firefox In Firefox (tested in Fx61), the "activity metrics" and related tables can become stupendously wide. Adding a `max-width` resolves this issue. --- app/assets/stylesheets/common/admin/dashboard_next.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index 71cb1ec305..08023a7aae 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -17,6 +17,7 @@ .section-column { min-width: calc(50% - .5em); + max-width: 100%; @include small-width { min-width: 100%; From 560a16d864ebaab68deb7dcbd834a647f2e0d7de Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 17 May 2018 14:47:10 +1000 Subject: [PATCH 005/278] correct resizing in firefox --- app/assets/stylesheets/common/admin/dashboard_next.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index 08023a7aae..0259bb5688 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -218,6 +218,7 @@ justify-content: space-between; flex: 1; width: 100%; + min-width: 0; } @include small-width { From aee4045dd0b0cbadb661bfa97e2a070fdc220491 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Thu, 17 May 2018 10:34:46 +0530 Subject: [PATCH 006/278] FIX: suggest name when username/name is provided --- app/models/user.rb | 8 +++++++- spec/models/user_spec.rb | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/models/user.rb b/app/models/user.rb index 765050e92f..27156250ac 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -288,7 +288,13 @@ class User < ActiveRecord::Base def self.suggest_name(email) return "" if email.blank? - email[/\A[^@]+/].tr(".", " ").titleize + if email[/\A[^@]+/].present? + # email provided + email[/\A[^@]+/].tr(".", " ").titleize + else + # username/name provided + email[/[^@]+\z/].tr(".", " ").titleize + end end def self.find_by_username_or_email(username_or_email) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 35983247a2..e2b0cf3e72 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -449,6 +449,14 @@ describe User do it 'is able to guess a decent name from an email' do expect(User.suggest_name('sam.saffron@gmail.com')).to eq('Sam Saffron') end + + it 'is able to guess a decent name from username' do + expect(User.suggest_name('@sam.saffron')).to eq('Sam Saffron') + end + + it 'is able to guess a decent name from name' do + expect(User.suggest_name('sam saffron')).to eq('Sam Saffron') + end end describe 'username format' do From 392f184b24f30cc42f62c94662fc609681e3e7d9 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Thu, 17 May 2018 10:49:01 +0530 Subject: [PATCH 007/278] FIX: check for existence of topic before looking for category --- app/mailers/user_notifications.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index cda60d2897..5e8a60af06 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -421,7 +421,7 @@ class UserNotifications < ActionMailer::Base end # category name - category = Topic.find_by(id: post.topic_id).category + category = Topic.find_by(id: post.topic_id)&.category if opts[:show_category_in_subject] && post.topic_id && category && !category.uncategorized? show_category_in_subject = category.name From 441a52ad17f95cbea9ce415329fc668a6fad547d Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 17 May 2018 16:00:43 +1000 Subject: [PATCH 008/278] DEV: add more information about failed smoke test log the http status code and path that failed --- test/smoke_test.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/test/smoke_test.js b/test/smoke_test.js index f166bca408..391a3242e9 100644 --- a/test/smoke_test.js +++ b/test/smoke_test.js @@ -14,8 +14,8 @@ const path = require('path'); (async () => { const browser = await puppeteer.launch({ - // headless: false, - // slowMo: 10, + // when debugging localy setting headless to "false" can be very helpful + headless: true, args: ["--disable-local-storage"] }); const page = await browser.newPage(); @@ -53,6 +53,16 @@ const path = require('path'); page.on('console', msg => console.log(`PAGE LOG: ${msg.text}`)); + page.setRequestInterception(true); + + page.on('request', req => { return req.continue(); }); + page.on('response', resp => { + if (resp.status !== 200) { + console.log("FAILED HTTP REQUEST TO " + resp.url + " Status is: " + resp.status); + } + return resp; + }); + if (process.env.AUTH_USER && process.env.AUTH_PASSWORD) { await exec("basic authentication", () => { return page.setExtraHTTPHeaders({ From 117763493b17494af1185e6591a46cb9fbe8c267 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 17 May 2018 14:51:48 +0800 Subject: [PATCH 009/278] Refactor `User#suggest_name`. * Rename `email` to `string` as variable can be an email, username or any valid string. --- app/models/user.rb | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 27156250ac..568886d418 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -286,15 +286,11 @@ class User < ActiveRecord::Base user end - def self.suggest_name(email) - return "" if email.blank? - if email[/\A[^@]+/].present? - # email provided - email[/\A[^@]+/].tr(".", " ").titleize - else - # username/name provided - email[/[^@]+\z/].tr(".", " ").titleize - end + def self.suggest_name(string) + return "" if string.blank? + local_part = string[/\A[^@]+/] + suggestion = local_part.present? ? local_part : string[/[^@]+\z/] + suggestion.tr(".", " ").titleize end def self.find_by_username_or_email(username_or_email) From 539cf32f87eb1b517a177b66f6aaf9d3a1b3f751 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Thu, 17 May 2018 12:43:30 +0530 Subject: [PATCH 010/278] FIX: handle encoded mailto links when looking for upload record --- app/models/upload.rb | 2 +- spec/models/upload_spec.rb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/upload.rb b/app/models/upload.rb index 055b396e27..a990795492 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -84,7 +84,7 @@ class Upload < ActiveRecord::Base # always try to get the path uri = begin - URI(url) + URI(URI.unescape(url)) rescue URI::InvalidURIError end diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb index e7d9fdde7a..a7c0d4ab0e 100644 --- a/spec/models/upload_spec.rb +++ b/spec/models/upload_spec.rb @@ -87,6 +87,7 @@ describe Upload do it "doesn't blow up with an invalid URI" do expect { Upload.get_from_url("http://ip:port/index.html") }.not_to raise_error + expect { Upload.get_from_url("mailto:admin%40example.com") }.not_to raise_error end describe "s3 store" do From efe7b6dbc1ea1e51e94b00772593c858adfba50c Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 17 May 2018 17:16:28 +1000 Subject: [PATCH 011/278] DEV: after installing official plugins, install the gems --- lib/tasks/docker.rake | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/tasks/docker.rake b/lib/tasks/docker.rake index a0bfc0c0b7..f66450a050 100644 --- a/lib/tasks/docker.rake +++ b/lib/tasks/docker.rake @@ -74,6 +74,7 @@ task 'docker:test' do if ENV["INSTALL_OFFICIAL_PLUGINS"] @good &&= run_or_fail("bundle exec rake plugin:install_all_official") + @good &&= run_or_fail("bundle exec rails r 'puts \"installing all gems\"'") end unless ENV["JS_ONLY"] From 17a7bb8e232614769f2f714fde57b5101f291120 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 17 May 2018 17:17:58 +1000 Subject: [PATCH 012/278] use async test --- test/javascripts/lib/user-search-test.js.es6 | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/javascripts/lib/user-search-test.js.es6 b/test/javascripts/lib/user-search-test.js.es6 index 98b5938859..a34f114395 100644 --- a/test/javascripts/lib/user-search-test.js.es6 +++ b/test/javascripts/lib/user-search-test.js.es6 @@ -61,8 +61,7 @@ QUnit.module("lib:user-search", { } }); -QUnit.test("it places groups unconditionally for exact match", assert => { - return userSearch({term: 'Team'}).then((results)=>{ - assert.equal(results[results.length-1]["name"], "team"); - }); +QUnit.test("it places groups unconditionally for exact match", async assert => { + let results = await userSearch({term: 'Team'}); + assert.equal(results[results.length-1]["name"], "team"); }); From 238a13643d15346ea3295f51d0e36630db3150e9 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Thu, 17 May 2018 12:52:01 +0530 Subject: [PATCH 013/278] FIX: handle missing users when sending push notifications --- app/jobs/regular/send_push_notification.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/jobs/regular/send_push_notification.rb b/app/jobs/regular/send_push_notification.rb index c27f1bbf27..355e2465cb 100644 --- a/app/jobs/regular/send_push_notification.rb +++ b/app/jobs/regular/send_push_notification.rb @@ -1,8 +1,8 @@ module Jobs class SendPushNotification < Jobs::Base def execute(args) - user = User.find(args[:user_id]) - PushNotificationPusher.push(user, args[:payload]) + user = User.find_by(id: args[:user_id]) + PushNotificationPusher.push(user, args[:payload]) if user end end end From 33899664ce50f7b1080b42282943944df7b3a644 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Thu, 17 May 2018 13:21:24 +0530 Subject: [PATCH 014/278] FIX: handle bad user profile website --- app/models/user_profile.rb | 5 ++++- spec/models/user_profile_spec.rb | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/models/user_profile.rb b/app/models/user_profile.rb index 721d04196a..11ebf37cb5 100644 --- a/app/models/user_profile.rb +++ b/app/models/user_profile.rb @@ -140,7 +140,10 @@ class UserProfile < ActiveRecord::Base allowed_domains = SiteSetting.user_website_domains_whitelist return if (allowed_domains.blank? || self.website.blank?) - domain = URI.parse(self.website).host + domain = begin + URI.parse(self.website).host + rescue URI::InvalidURIError + end self.errors.add :base, (I18n.t('user.website.domain_not_allowed', domains: allowed_domains.split('|').join(", "))) unless allowed_domains.split('|').include?(domain) end diff --git a/spec/models/user_profile_spec.rb b/spec/models/user_profile_spec.rb index 52db905933..ee05902c3e 100644 --- a/spec/models/user_profile_spec.rb +++ b/spec/models/user_profile_spec.rb @@ -71,6 +71,13 @@ describe UserProfile do user_profile.website = "http://discourse.org" expect(user_profile).to be_valid end + + it "doesn't blow up with an invalid URI" do + SiteSetting.user_website_domains_whitelist = "discourse.org" + + user_profile.website = 'user - https://forum.example.com/user' + expect { user_profile.save! }.to raise_error(ActiveRecord::RecordInvalid) + end end describe 'after save' do From 147ea37115d043caf6b547933b97eabf34e9f27e Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 17 May 2018 16:09:21 +0800 Subject: [PATCH 015/278] FIX: Missing notification for watching first post users when topic is recategorized. https://meta.discourse.org/t/not-receiving-notifications-for-announcements/87275/2?u=tgxworld --- app/models/topic.rb | 8 +++++++- app/services/post_alerter.rb | 37 +++++++++++++++++++----------------- spec/models/topic_spec.rb | 29 +++++++++++++++++++++------- 3 files changed, 49 insertions(+), 25 deletions(-) diff --git a/app/models/topic.rb b/app/models/topic.rb index 4ee0068001..369d0e3367 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -677,10 +677,16 @@ SQL post = self.ordered_posts.first if post - PostAlerter.new.notify_post_users( + post_alerter = PostAlerter.new + + post_alerter.notify_post_users( post, [post.user, post.last_editor].uniq ) + + post_alerter.notify_first_post_watchers( + post, post_alerter.category_watchers(self) + ) end end diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 2bc5524cf4..66648a69ee 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -101,28 +101,31 @@ class PostAlerter topic = post.topic if topic.present? - cat_watchers = topic.category_users - .where(notification_level: CategoryUser.notification_levels[:watching_first_post]) - .pluck(:user_id) - - tag_watchers = topic.tag_users - .where(notification_level: TagUser.notification_levels[:watching_first_post]) - .pluck(:user_id) - - group_ids = topic.allowed_groups.pluck(:group_id) - - group_watchers = GroupUser.where( - group_id: group_ids, - notification_level: GroupUser.notification_levels[:watching_first_post] - ).pluck(:user_id) - - watchers = [cat_watchers, tag_watchers, group_watchers].flatten - + watchers = category_watchers(topic) + tag_watchers(topic) + group_watchers(topic) notify_first_post_watchers(post, watchers) end end end + def group_watchers(topic) + GroupUser.where( + group_id: topic.allowed_groups.pluck(:group_id), + notification_level: GroupUser.notification_levels[:watching_first_post] + ).pluck(:user_id) + end + + def tag_watchers(topic) + topic.tag_users + .where(notification_level: TagUser.notification_levels[:watching_first_post]) + .pluck(:user_id) + end + + def category_watchers(topic) + topic.category_users + .where(notification_level: CategoryUser.notification_levels[:watching_first_post]) + .pluck(:user_id) + end + def notify_first_post_watchers(post, user_ids) return if user_ids.blank? user_ids.uniq! diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 72eb939fee..2ec56aa94a 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -1212,7 +1212,7 @@ describe Topic do expect(category.reload.topic_count).to eq(0) end - describe 'user that watching the new category' do + describe 'user that is watching the new category' do it 'should generate the notification for the topic' do topic.posts << Fabricate(:post) @@ -1222,16 +1222,31 @@ describe Topic do new_category.id ) + another_user = Fabricate(:user) + + CategoryUser.set_notification_level_for_category( + another_user, + CategoryUser::notification_levels[:watching_first_post], + new_category.id + ) + expect do topic.change_category_to_id(new_category.id) - end.to change { Notification.count }.by(1) + end.to change { Notification.count }.by(2) - notification = Notification.last + expect(Notification.where( + user_id: user.id, + topic_id: topic.id, + post_number: 1, + notification_type: Notification.types[:posted] + ).exists?).to eq(true) - expect(notification.notification_type).to eq(Notification.types[:posted]) - expect(notification.topic_id).to eq(topic.id) - expect(notification.user_id).to eq(user.id) - expect(notification.post_number).to eq(1) + expect(Notification.where( + user_id: another_user.id, + topic_id: topic.id, + post_number: 1, + notification_type: Notification.types[:watching_first_post] + ).exists?).to eq(true) end end From 8238097d0f450c01256bdfb6db5d246bb912821a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Thu, 17 May 2018 10:22:53 +0200 Subject: [PATCH 016/278] bump email_reply_trimmer --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index c89be78a7c..da3752b1b8 100644 --- a/Gemfile +++ b/Gemfile @@ -58,7 +58,7 @@ gem 'aws-sdk-s3', require: false gem 'excon', require: false gem 'unf', require: false -gem 'email_reply_trimmer', '0.1.11' +gem 'email_reply_trimmer', '~> 0.1' # Forked until https://github.com/toy/image_optim/pull/149 is merged gem 'discourse_image_optim', require: 'image_optim' diff --git a/Gemfile.lock b/Gemfile.lock index c5b26bd87c..a9c7d695bc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -89,7 +89,7 @@ GEM image_size (~> 1.5) in_threads (~> 1.3) progress (~> 3.0, >= 3.0.1) - email_reply_trimmer (0.1.11) + email_reply_trimmer (0.1.12) ember-data-source (2.2.1) ember-source (>= 1.8, < 3.0) ember-handlebars-template (0.7.5) @@ -415,7 +415,7 @@ DEPENDENCIES certified cppjieba_rb discourse_image_optim - email_reply_trimmer (= 0.1.11) + email_reply_trimmer (~> 0.1) ember-handlebars-template (= 0.7.5) ember-rails (= 0.18.5) ember-source (= 2.13.3) From b3981ddc43a3ea1ae262ac63ed344d478b14f710 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 17 May 2018 16:34:16 +0800 Subject: [PATCH 017/278] Shorten `User.suggest_name` further. --- app/models/user.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 568886d418..1621ddee87 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -288,9 +288,7 @@ class User < ActiveRecord::Base def self.suggest_name(string) return "" if string.blank? - local_part = string[/\A[^@]+/] - suggestion = local_part.present? ? local_part : string[/[^@]+\z/] - suggestion.tr(".", " ").titleize + (string[/\A[^@]+/].presence || string[/[^@]+\z/]).tr(".", " ").titleize end def self.find_by_username_or_email(username_or_email) From 3f8e3a6f7384c986ae0e7edbb386d6617af85ff7 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 17 May 2018 16:56:39 +0800 Subject: [PATCH 018/278] Don't intercept requests in smoke tests. --- test/smoke_test.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/smoke_test.js b/test/smoke_test.js index 391a3242e9..7690db3421 100644 --- a/test/smoke_test.js +++ b/test/smoke_test.js @@ -53,9 +53,6 @@ const path = require('path'); page.on('console', msg => console.log(`PAGE LOG: ${msg.text}`)); - page.setRequestInterception(true); - - page.on('request', req => { return req.continue(); }); page.on('response', resp => { if (resp.status !== 200) { console.log("FAILED HTTP REQUEST TO " + resp.url + " Status is: " + resp.status); From b74c108c0961fcc224b5c4324f531d261c3b69f8 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 17 May 2018 18:10:17 +0800 Subject: [PATCH 019/278] UX: Display group tabs in user's messages tab for non-staff as well. https://meta.discourse.org/t/group-inboxes-in-user-messages/86787 --- app/serializers/basic_group_serializer.rb | 2 +- .../basic_group_serializer_spec.rb | 51 ++++++++++++++++--- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/app/serializers/basic_group_serializer.rb b/app/serializers/basic_group_serializer.rb index aec7f7cf85..26fb3ea2de 100644 --- a/app/serializers/basic_group_serializer.rb +++ b/app/serializers/basic_group_serializer.rb @@ -43,7 +43,7 @@ class BasicGroupSerializer < ApplicationSerializer end def include_has_messages? - staff? + staff? || scope.can_see_group_messages?(object) end def include_bio_raw? diff --git a/spec/serializers/basic_group_serializer_spec.rb b/spec/serializers/basic_group_serializer_spec.rb index 4d1801ff92..7b06b2959d 100644 --- a/spec/serializers/basic_group_serializer_spec.rb +++ b/spec/serializers/basic_group_serializer_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe BasicGroupSerializer do let(:guardian) { Guardian.new } let(:group) { Fabricate(:group) } - subject { described_class.new(group, scope: Guardian.new, root: false) } + subject { described_class.new(group, scope: guardian, root: false) } describe '#display_name' do describe 'automatic group' do @@ -26,17 +26,52 @@ describe BasicGroupSerializer do describe '#bio_raw' do let(:group) { Fabricate(:group, bio_raw: 'testing') } - let(:user) do - user = Fabricate(:user) - group.add_owner(user) - user + subject do + described_class.new(group, scope: guardian, root: false, owner_group_ids: [group.id]) end - let(:guardian) { Guardian.new(user) } - describe 'group owner' do + let(:user) do + user = Fabricate(:user) + group.add_owner(user) + user + end + it 'should include bio_raw' do - expect(subject.bio_raw).to eq('testing') + expect(subject.as_json[:bio_raw]).to eq('testing') + end + end + end + + describe '#has_messages' do + let(:group) { Fabricate(:group, has_messages: true) } + + describe 'for a staff user' do + let(:guardian) { Guardian.new(Fabricate(:moderator)) } + + it 'should be present' do + expect(subject.as_json[:has_messages]).to eq(true) + end + end + + describe 'for a group user' do + let(:user) { Fabricate(:user) } + let(:guardian) { Guardian.new(user) } + + before do + group.add(user) + end + + it 'should be present' do + expect(subject.as_json[:has_messages]).to eq(true) + end + end + + describe 'for a normal user' do + let(:guardian) { Guardian.new(Fabricate(:user)) } + + it 'should not be present' do + expect(subject.as_json[:has_messages]).to eq(nil) end end end From 9532d9a555a629c4940c821003c58547bee9de99 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Thu, 17 May 2018 19:32:15 +0530 Subject: [PATCH 020/278] FIX: handle invalid tags --- app/controllers/tags_controller.rb | 12 ++++++++---- spec/requests/tags_controller_spec.rb | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 67c4ee54d3..d5c086d2ae 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -277,10 +277,14 @@ class TagsController < ::ApplicationController def construct_url_with(action, opts) method = url_method(opts) - url = if action == :prev - public_send(method, opts.merge(prev_page_params(opts))) - else # :next - public_send(method, opts.merge(next_page_params(opts))) + begin + url = if action == :prev + public_send(method, opts.merge(prev_page_params(opts))) + else # :next + public_send(method, opts.merge(next_page_params(opts))) + end + rescue + raise Discourse::NotFound end url.sub('.json?', '?') end diff --git a/spec/requests/tags_controller_spec.rb b/spec/requests/tags_controller_spec.rb index 13a991ad8d..65165852a9 100644 --- a/spec/requests/tags_controller_spec.rb +++ b/spec/requests/tags_controller_spec.rb @@ -50,6 +50,22 @@ describe TagsController do end end + describe '#show' do + before do + Fabricate(:tag, name: 'test') + end + + it "should return the right response" do + get "/tags/test" + expect(response).to be_success + end + + it "should handle invalid tags" do + get "/tags/%2ftest%2f" + expect(response.status).to eq(404) + end + end + describe '#check_hashtag' do let(:tag) { Fabricate(:tag, name: 'test') } From 7ab7696c948e008a92c44bf8f97294cc33acf89f Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 17 May 2018 11:41:58 -0400 Subject: [PATCH 021/278] fine-tuning dashboard alignment --- .../admin/templates/dashboard_next.hbs | 2 +- .../stylesheets/common/admin/admin_base.scss | 1 - .../common/admin/dashboard_next.scss | 21 +++++++++++++++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/admin/templates/dashboard_next.hbs b/app/assets/javascripts/admin/templates/dashboard_next.hbs index 8782518248..ec1e52e4ba 100644 --- a/app/assets/javascripts/admin/templates/dashboard_next.hbs +++ b/app/assets/javascripts/admin/templates/dashboard_next.hbs @@ -53,7 +53,7 @@
-
+
{{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.activity_metrics")}}

{{i18n "admin.dashboard.activity_metrics"}}

diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 33da3ec0e0..d473536d09 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -880,7 +880,6 @@ section.details { .upgrade-header { flex: 1 1 100%; - margin: .25em 0 1em 0; @media screen and (max-width: 650px) { margin: 0; } diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index 0259bb5688..3fe538aa7d 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -54,6 +54,9 @@ } display: flex; + @media screen and (max-width: 400px) { + flex-wrap: wrap; + } align-items: center; justify-content: space-between; border-bottom: 1px solid $primary-low; @@ -62,7 +65,7 @@ } .section-body { - padding: 1em 0; + padding: 1em 0 0; } } @@ -266,6 +269,17 @@ } } +.dashboard-table.activity-metrics { + table { + @media screen and (min-width: 400px) { + table-layout: auto; + } + tr th { + text-align: right; + } + } +} + .top-referred-topics, .trending-search { th:first-of-type { text-align: left; @@ -347,7 +361,10 @@ td.value { text-align: right; - transform: translateX(-40%); + padding: 8px 21px 8px 8px; // accounting for negative right caret margin + &:nth-of-type(2) { + padding: 8px 12px 8px; + } i { display: none; margin-right: -12px; // align on caret From 53f8f6095d806cca49af9cac21142035fded7bc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Thu, 17 May 2018 18:09:27 +0200 Subject: [PATCH 022/278] FEATURE: staff action logs when creating/updating/deleting badges --- .../admin/models/staff-action-log.js.es6 | 96 +++--- app/controllers/admin/badges_controller.rb | 14 +- app/models/user_history.rb | 10 +- app/services/staff_action_logger.rb | 326 ++++++++++++------ config/locales/client.en.yml | 3 + .../admin/badges_controller_spec.rb | 13 +- 6 files changed, 291 insertions(+), 171 deletions(-) diff --git a/app/assets/javascripts/admin/models/staff-action-log.js.es6 b/app/assets/javascripts/admin/models/staff-action-log.js.es6 index 78d39869cf..88cba94b4c 100644 --- a/app/assets/javascripts/admin/models/staff-action-log.js.es6 +++ b/app/assets/javascripts/admin/models/staff-action-log.js.es6 @@ -1,54 +1,58 @@ -import { ajax } from 'discourse/lib/ajax'; -import AdminUser from 'admin/models/admin-user'; -import { escapeExpression } from 'discourse/lib/utilities'; +import computed from "ember-addons/ember-computed-decorators"; +import { ajax } from "discourse/lib/ajax"; +import AdminUser from "admin/models/admin-user"; +import { escapeExpression } from "discourse/lib/utilities"; + +function format(label, value, escape = true) { + return value ? `${I18n.t(label)}: ${escape ? escapeExpression(value) : value}` : ""; +}; const StaffActionLog = Discourse.Model.extend({ showFullDetails: false, - actionName: function() { - return I18n.t("admin.logs.staff_actions.actions." + this.get('action_name')); - }.property('action_name'), - - formattedDetails: function() { - let formatted = ""; - formatted += this.format('email', 'email'); - formatted += this.format('admin.logs.ip_address', 'ip_address'); - formatted += this.format('admin.logs.topic_id', 'topic_id'); - formatted += this.format('admin.logs.post_id', 'post_id'); - formatted += this.format('admin.logs.category_id', 'category_id'); - if (!this.get('useCustomModalForDetails')) { - formatted += this.format('admin.logs.staff_actions.new_value', 'new_value'); - formatted += this.format('admin.logs.staff_actions.previous_value', 'previous_value'); - } - if (!this.get('useModalForDetails')) { - if (this.get('details')) formatted += escapeExpression(this.get('details')) + '
'; - } - return formatted; - }.property('ip_address', 'email', 'topic_id', 'post_id', 'category_id'), - - format(label, propertyName) { - if (this.get(propertyName)) { - let value = escapeExpression(this.get(propertyName)); - if (propertyName === 'post_id') { - value = `${value}`; - } - return `${I18n.t(label)}: ${value}
`; - } else { - return ''; - } + @computed("action_name") + actionName(actionName) { + return I18n.t(`admin.logs.staff_actions.actions.${actionName}`); }, - useModalForDetails: function() { - return (this.get('details') && this.get('details').length > 100); - }.property('action_name'), + @computed("email", "ip_address", "topic_id", "post_id", "category_id", "new_value", "previous_value", "details", "useCustomModalForDetails", "useModalForDetails") + formattedDetails(email, ipAddress, topicId, postId, categoryId, newValue, previousValue, details, useCustomModalForDetails, useModalForDetails) { + const postLink = postId ? `${postId}` : null; - useCustomModalForDetails: function() { - return _.contains(['change_theme', 'delete_theme'], this.get('action_name')); - }.property('action_name') + let lines = [ + format("email", email), + format("admin.logs.ip_address", ipAddress), + format("admin.logs.topic_id", topicId), + format("admin.logs.post_id", postLink, false), + format("admin.logs.category_id", categoryId), + ]; + + if (!useCustomModalForDetails) { + lines.push(format("admin.logs.staff_actions.new_value", newValue)); + lines.push(format("admin.logs.staff_actions.previous_value", previousValue)); + } + + if (!useModalForDetails && details) { + lines = [...lines, ...escapeExpression(details).split("\n")]; + } + + const formatted = lines.filter(l => l.length > 0).join("
"); + return formatted.length > 0 ? formatted + "
" : ""; + }, + + @computed("details") + useModalForDetails(details) { + return details && details.length > 100; + }, + + @computed("action_name") + useCustomModalForDetails(actionName) { + return ["change_theme", "delete_theme"].includes(actionName); + } }); StaffActionLog.reopenClass({ - create: function(attrs) { + create(attrs) { attrs = attrs || {}; if (attrs.acting_user) { @@ -60,13 +64,11 @@ StaffActionLog.reopenClass({ return this._super(attrs); }, - findAll: function(filters) { - return ajax("/admin/logs/staff_action_logs.json", { data: filters }).then((data) => { + findAll(data) { + return ajax("/admin/logs/staff_action_logs.json", { data }).then(result => { return { - staff_action_logs: data.staff_action_logs.map(function(s) { - return StaffActionLog.create(s); - }), - user_history_actions: data.user_history_actions + staff_action_logs: result.staff_action_logs.map(s => StaffActionLog.create(s)), + user_history_actions: result.user_history_actions }; }); } diff --git a/app/controllers/admin/badges_controller.rb b/app/controllers/admin/badges_controller.rb index e84a7dc65f..53089c8e51 100644 --- a/app/controllers/admin/badges_controller.rb +++ b/app/controllers/admin/badges_controller.rb @@ -15,10 +15,8 @@ class Admin::BadgesController < Admin::AdminController end def preview - unless SiteSetting.enable_badge_sql - render json: "preview not allowed", status: 403 - return + return render json: "preview not allowed", status: 403 end render json: BadgeGranter.preview(params[:sql], @@ -39,13 +37,12 @@ class Admin::BadgesController < Admin::AdminController end def save_badge_groupings - badge_groupings = BadgeGrouping.all.order(:position).to_a ids = params[:ids].map(&:to_i) params[:names].each_with_index do |name, index| id = ids[index].to_i - group = badge_groupings.find { |b| b.id == id } || BadgeGrouping.new() + group = badge_groupings.find { |b| b.id == id } || BadgeGrouping.new group.name = name group.position = index group.save @@ -66,24 +63,27 @@ class Admin::BadgesController < Admin::AdminController if errors.present? render_json_error errors else + StaffActionLogger.new(current_user).log_badge_creation(badge) render_serialized(badge, AdminBadgeSerializer, root: "badge") end end def update badge = find_badge - errors = update_badge_from_params(badge) if errors.present? render_json_error errors else + StaffActionLogger.new(current_user).log_badge_change(badge) render_serialized(badge, AdminBadgeSerializer, root: "badge") end end def destroy - find_badge.destroy + badge = find_badge + StaffActionLogger.new(current_user).log_badge_deletion(badge) + badge.destroy render body: nil end diff --git a/app/models/user_history.rb b/app/models/user_history.rb index 7155d2ad3e..0a24733115 100644 --- a/app/models/user_history.rb +++ b/app/models/user_history.rb @@ -72,7 +72,10 @@ class UserHistory < ActiveRecord::Base post_edit: 53, topic_published: 54, recover_topic: 55, - post_approved: 56 + post_approved: 56, + create_badge: 57, + change_badge: 58, + delete_badge: 59, ) end @@ -123,7 +126,10 @@ class UserHistory < ActiveRecord::Base :post_edit, :topic_published, :recover_topic, - :post_approved + :post_approved, + :create_badge, + :change_badge, + :delete_badge, ] end diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb index ff137a0db5..07a47a4c48 100644 --- a/app/services/staff_action_logger.rb +++ b/app/services/staff_action_logger.rb @@ -12,11 +12,16 @@ class StaffActionLogger raise Discourse::InvalidParameters.new(:admin) unless @admin && @admin.is_a?(User) end + USER_FIELDS ||= %i{id username name created_at trust_level last_seen_at last_emailed_at} + def log_user_deletion(deleted_user, opts = {}) raise Discourse::InvalidParameters.new(:deleted_user) unless deleted_user && deleted_user.is_a?(User) - UserHistory.create(params(opts).merge(action: UserHistory.actions[:delete_user], - ip_address: deleted_user.ip_address.to_s, - details: [:id, :username, :name, :created_at, :trust_level, :last_seen_at, :last_emailed_at].map { |x| "#{x}: #{deleted_user.send(x)}" }.join("\n"))) + details = USER_FIELDS.map { |x| "#{x}: #{deleted_user.send(x)}" }.join("\n") + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:delete_user], + ip_address: deleted_user.ip_address.to_s, + details: details + )) end def log_custom(custom_type, details = nil) @@ -33,7 +38,7 @@ class StaffActionLogger attrs[:action] = UserHistory.actions[:custom_staff] attrs[:custom_type] = custom_type - UserHistory.create(attrs) + UserHistory.create!(attrs) end def log_post_deletion(deleted_post, opts = {}) @@ -54,9 +59,11 @@ class StaffActionLogger "raw: #{deleted_post.raw}" ] - UserHistory.create(params(opts).merge(action: UserHistory.actions[:delete_post], - post_id: deleted_post.id, - details: details.join("\n"))) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:delete_post], + post_id: deleted_post.id, + details: details.join("\n") + )) end def log_topic_delete_recover(topic, action = "delete_topic", opts = {}) @@ -75,24 +82,31 @@ class StaffActionLogger details << "raw: #{first_post.raw}" end - UserHistory.create(params(opts).merge(action: UserHistory.actions[action.to_sym], - topic_id: topic.id, - details: details.join("\n"))) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[action.to_sym], + topic_id: topic.id, + details: details.join("\n") + )) end def log_trust_level_change(user, old_trust_level, new_trust_level, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user && user.is_a?(User) raise Discourse::InvalidParameters.new(:old_trust_level) unless TrustLevel.valid? old_trust_level raise Discourse::InvalidParameters.new(:new_trust_level) unless TrustLevel.valid? new_trust_level - UserHistory.create!(params(opts).merge(action: UserHistory.actions[:change_trust_level], - target_user_id: user.id, - details: "old trust level: #{old_trust_level}\nnew trust level: #{new_trust_level}")) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:change_trust_level], + target_user_id: user.id, + details: "old trust level: #{old_trust_level}\nnew trust level: #{new_trust_level}" + )) end def log_lock_trust_level(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user && user.is_a?(User) - UserHistory.create!(params(opts).merge(action: UserHistory.actions[user.manual_locked_trust_level.nil? ? :unlock_trust_level : :lock_trust_level], - target_user_id: user.id)) + action = UserHistory.actions[user.manual_locked_trust_level.nil? ? :unlock_trust_level : :lock_trust_level] + UserHistory.create!(params(opts).merge( + action: action, + target_user_id: user.id + )) end def log_topic_published(topic, opts = {}) @@ -122,10 +136,12 @@ class StaffActionLogger def log_site_setting_change(setting_name, previous_value, new_value, opts = {}) raise Discourse::InvalidParameters.new(:setting_name) unless setting_name.present? && SiteSetting.respond_to?(setting_name) - UserHistory.create(params(opts).merge(action: UserHistory.actions[:change_site_setting], - subject: setting_name, - previous_value: previous_value, - new_value: new_value)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:change_site_setting], + subject: setting_name, + previous_value: previous_value, + new_value: new_value + )) end def theme_json(theme) @@ -157,41 +173,51 @@ class StaffActionLogger old_json, new_json = strip_duplicates(old_json, new_json) - UserHistory.create(params(opts).merge(action: UserHistory.actions[:change_theme], - subject: new_theme.name, - previous_value: old_json, - new_value: new_json)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:change_theme], + subject: new_theme.name, + previous_value: old_json, + new_value: new_json + )) end def log_theme_destroy(theme, opts = {}) raise Discourse::InvalidParameters.new(:theme) unless theme - UserHistory.create(params(opts).merge(action: UserHistory.actions[:delete_theme], - subject: theme.name, - previous_value: theme_json(theme))) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:delete_theme], + subject: theme.name, + previous_value: theme_json(theme) + )) end def log_site_text_change(subject, new_text = nil, old_text = nil, opts = {}) raise Discourse::InvalidParameters.new(:subject) unless subject.present? - UserHistory.create!(params(opts).merge(action: UserHistory.actions[:change_site_text], - subject: subject, - previous_value: old_text, - new_value: new_text)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:change_site_text], + subject: subject, + previous_value: old_text, + new_value: new_text + )) end def log_username_change(user, old_username, new_username, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge(action: UserHistory.actions[:change_username], - target_user_id: user.id, - previous_value: old_username, - new_value: new_username)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:change_username], + target_user_id: user.id, + previous_value: old_username, + new_value: new_username + )) end def log_name_change(user_id, old_name, new_name, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user_id - UserHistory.create(params(opts).merge(action: UserHistory.actions[:change_name], - target_user_id: user_id, - previous_value: old_name, - new_value: new_name)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:change_name], + target_user_id: user_id, + previous_value: old_name, + new_value: new_name + )) end def log_user_suspend(user, reason, opts = {}) @@ -205,50 +231,96 @@ class StaffActionLogger details: details ) args[:post_id] = opts[:post_id] if opts[:post_id] - UserHistory.create(args) + UserHistory.create!(args) end def log_user_unsuspend(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge(action: UserHistory.actions[:unsuspend_user], - target_user_id: user.id)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:unsuspend_user], + target_user_id: user.id + )) + end + + BADGE_FIELDS ||= %i{id name description long_description icon image badge_type_id + badge_grouping_id query allow_title multiple_grant listable target_posts + enabled auto_revoke show_posts system} + + def log_badge_creation(badge) + raise Discourse::InvalidParameters.new(:badge) unless badge + details = BADGE_FIELDS.map { |f| [f, badge.send(f)] }.select { |f, v| v.present? }.map { |f, v| "#{f}: #{v}" } + UserHistory.create!(params.merge( + action: UserHistory.actions[:create_badge], + details: details.join("\n") + )) + end + + def log_badge_change(badge) + raise Discourse::InvalidParameters.new(:badge) unless badge + details = ["id: #{badge.id}"] + badge.previous_changes.each { |f, values| details << "#{f}: #{values[1]}" if BADGE_FIELDS.include?(f.to_sym) } + UserHistory.create!(params.merge( + action: UserHistory.actions[:change_badge], + details: details.join("\n") + )) + end + + def log_badge_deletion(badge) + raise Discourse::InvalidParameters.new(:badge) unless badge + details = BADGE_FIELDS.map { |f| [f, badge.send(f)] }.select { |f, v| v.present? }.map { |f, v| "#{f}: #{v}" } + UserHistory.create!(params.merge( + action: UserHistory.actions[:delete_badge], + details: details.join("\n") + )) end def log_badge_grant(user_badge, opts = {}) raise Discourse::InvalidParameters.new(:user_badge) unless user_badge - UserHistory.create(params(opts).merge(action: UserHistory.actions[:grant_badge], - target_user_id: user_badge.user_id, - details: user_badge.badge.name)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:grant_badge], + target_user_id: user_badge.user_id, + details: user_badge.badge.name + )) end def log_badge_revoke(user_badge, opts = {}) raise Discourse::InvalidParameters.new(:user_badge) unless user_badge - UserHistory.create(params(opts).merge(action: UserHistory.actions[:revoke_badge], - target_user_id: user_badge.user_id, - details: user_badge.badge.name)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:revoke_badge], + target_user_id: user_badge.user_id, + details: user_badge.badge.name + )) end def log_check_email(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge(action: UserHistory.actions[:check_email], - target_user_id: user.id)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:check_email], + target_user_id: user.id + )) end def log_show_emails(users, opts = {}) return if users.blank? - UserHistory.create(params(opts).merge(action: UserHistory.actions[:check_email], - details: users.map { |u| "[#{u.id}] #{u.username}" }.join("\n"))) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:check_email], + details: users.map { |u| "[#{u.id}] #{u.username}" }.join("\n") + )) end def log_impersonate(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge(action: UserHistory.actions[:impersonate], - target_user_id: user.id)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:impersonate], + target_user_id: user.id + )) end def log_roll_up(subnets, opts = {}) - UserHistory.create(params(opts).merge(action: UserHistory.actions[:roll_up], - details: subnets.join(", "))) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:roll_up], + details: subnets.join(", ") + )) end def log_category_settings_change(category, category_params, old_permissions = nil) @@ -261,12 +333,14 @@ class StaffActionLogger end changed_attributes.each do |key, value| - UserHistory.create(params.merge(action: UserHistory.actions[:change_category_settings], - category_id: category.id, - context: category.url, - subject: key, - previous_value: value[0], - new_value: value[1])) + UserHistory.create!(params.merge( + action: UserHistory.actions[:change_category_settings], + category_id: category.id, + context: category.url, + subject: key, + previous_value: value[0], + new_value: value[1] + )) end end @@ -283,10 +357,12 @@ class StaffActionLogger details << "parent_category: #{parent_category.name}" end - UserHistory.create(params.merge(action: UserHistory.actions[:delete_category], - category_id: category.id, - details: details.join("\n"), - context: category.url)) + UserHistory.create!(params.merge( + action: UserHistory.actions[:delete_category], + category_id: category.id, + details: details.join("\n"), + context: category.url + )) end def log_category_creation(category) @@ -297,10 +373,12 @@ class StaffActionLogger "name: #{category.name}" ] - UserHistory.create(params.merge(action: UserHistory.actions[:create_category], - details: details.join("\n"), - category_id: category.id, - context: category.url)) + UserHistory.create!(params.merge( + action: UserHistory.actions[:create_category], + details: details.join("\n"), + category_id: category.id, + context: category.url + )) end def log_silence_user(user, opts = {}) @@ -313,109 +391,139 @@ class StaffActionLogger ) create_args[:post_id] = opts[:post_id] if opts[:post_id] - UserHistory.create(create_args) + UserHistory.create!(create_args) end def log_unsilence_user(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge(action: UserHistory.actions[:unsilence_user], - target_user_id: user.id)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:unsilence_user], + 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)) + 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], - target_user_id: user.id)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:grant_admin], + target_user_id: user.id + )) end def log_revoke_admin(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge(action: UserHistory.actions[:revoke_admin], - target_user_id: user.id)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:revoke_admin], + target_user_id: user.id + )) end def log_grant_moderation(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge(action: UserHistory.actions[:grant_moderation], - target_user_id: user.id)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:grant_moderation], + target_user_id: user.id + )) end def log_revoke_moderation(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge(action: UserHistory.actions[:revoke_moderation], - target_user_id: user.id)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:revoke_moderation], + target_user_id: user.id + )) end def log_backup_create(opts = {}) - UserHistory.create(params(opts).merge(action: UserHistory.actions[:backup_create], - ip_address: @admin.ip_address.to_s)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:backup_create], + ip_address: @admin.ip_address.to_s + )) end def log_backup_download(backup, opts = {}) raise Discourse::InvalidParameters.new(:backup) unless backup - UserHistory.create(params(opts).merge(action: UserHistory.actions[:backup_download], - ip_address: @admin.ip_address.to_s, - details: backup.filename)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:backup_download], + ip_address: @admin.ip_address.to_s, + details: backup.filename + )) end def log_backup_destroy(backup, opts = {}) raise Discourse::InvalidParameters.new(:backup) unless backup - UserHistory.create(params(opts).merge(action: UserHistory.actions[:backup_destroy], - ip_address: @admin.ip_address.to_s, - details: backup.filename)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:backup_destroy], + ip_address: @admin.ip_address.to_s, + details: backup.filename + )) end def log_revoke_email(user, reason, opts = {}) - UserHistory.create(params(opts).merge(action: UserHistory.actions[:revoke_email], - target_user_id: user.id, - details: reason)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:revoke_email], + target_user_id: user.id, + details: reason + )) end def log_user_deactivate(user, reason, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge(action: UserHistory.actions[:deactivate_user], - target_user_id: user.id, - details: reason)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:deactivate_user], + target_user_id: user.id, + details: reason + )) end def log_user_activate(user, reason, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge(action: UserHistory.actions[:activate_user], - target_user_id: user.id, - details: reason)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:activate_user], + target_user_id: user.id, + details: reason + )) end def log_wizard_step(step, opts = {}) raise Discourse::InvalidParameters.new(:step) unless step - UserHistory.create(params(opts).merge(action: UserHistory.actions[:wizard_step], - context: step.id)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:wizard_step], + context: step.id + )) end def log_change_readonly_mode(state) - UserHistory.create(params.merge(action: UserHistory.actions[:change_readonly_mode], - previous_value: !state, - new_value: state)) + UserHistory.create!(params.merge( + action: UserHistory.actions[:change_readonly_mode], + previous_value: !state, + new_value: state + )) end def log_check_personal_message(topic, opts = {}) raise Discourse::InvalidParameters.new(:topic) unless topic && topic.is_a?(Topic) - UserHistory.create(params(opts).merge(action: UserHistory.actions[:check_personal_message], - topic_id: topic.id, - context: topic.relative_url)) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:check_personal_message], + topic_id: topic.id, + context: topic.relative_url + )) end def log_post_approved(post, opts = {}) raise Discourse::InvalidParameters.new(:post) unless post && post.is_a?(Post) UserHistory.create!(params(opts).merge( action: UserHistory.actions[:post_approved], - post_id: post.id) - ) + post_id: post.id + )) end private diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 44e4b4c18e..65f78257c4 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3401,6 +3401,9 @@ en: disabled_second_factor: "disable Two Factor Authentication" topic_published: "topic published" post_approved: "post approved" + create_badge: "create badge" + change_badge: "change badge" + delete_badge: "delete badge" 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/spec/controllers/admin/badges_controller_spec.rb b/spec/controllers/admin/badges_controller_spec.rb index 3fea129cf6..d5995aac40 100644 --- a/spec/controllers/admin/badges_controller_spec.rb +++ b/spec/controllers/admin/badges_controller_spec.rb @@ -48,6 +48,8 @@ describe Admin::BadgesController do expect(response.status).to eq(200) expect(json["badge"]["name"]).to eq('test') expect(json["badge"]["query"]).to eq('select 1 as user_id, null as granted_at') + + expect(UserHistory.where(acting_user_id: user.id, action: UserHistory.actions[:create_badge]).exists?).to eq(true) end end @@ -83,14 +85,11 @@ describe Admin::BadgesController do end context '.destroy' do - it 'returns success' do - delete :destroy, params: { id: badge.id }, format: :json - expect(response).to be_success - end - it 'deletes the badge' do delete :destroy, params: { id: badge.id }, format: :json - expect(Badge.where(id: badge.id).count).to eq(0) + expect(response).to be_success + expect(Badge.where(id: badge.id).exists?).to eq(false) + expect(UserHistory.where(acting_user_id: user.id, action: UserHistory.actions[:delete_badge]).exists?).to eq(true) end end @@ -108,6 +107,8 @@ describe Admin::BadgesController do expect(response).to be_success editor_badge.reload expect(editor_badge.name).to eq(editor_badge_name) + + expect(UserHistory.where(acting_user_id: user.id, action: UserHistory.actions[:change_badge]).exists?).to eq(true) end it 'does not allow query updates if badge_sql is disabled' do From bc89674b58022db0acf345d6d99bddbb1c67c0cd Mon Sep 17 00:00:00 2001 From: Andrew Schleifer Date: Wed, 16 May 2018 15:10:15 -0500 Subject: [PATCH 023/278] FIX for bucket name in GlobalSetting with folder When `s3_bucket="bucket/folder` in discourse.conf, absolute_base_url was bucket/folder.s3-region.amazonaws.com These names are bad, but this mirrors the s3_bucket/s3_bucket_name in S3Store N.B. that nearby s3_upload_bucket _should_ include the folder --- app/models/global_setting.rb | 4 ++++ app/models/site_setting.rb | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/models/global_setting.rb b/app/models/global_setting.rb index f0fb74b5a3..c81430d2cd 100644 --- a/app/models/global_setting.rb +++ b/app/models/global_setting.rb @@ -85,6 +85,10 @@ class GlobalSetting end) == :true end + def self.s3_bucket_name + @s3_bucket_name ||= s3_bucket.downcase.split("/")[0] + end + # for testing def self.reset_s3_cache! @use_s3 = nil diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb index 8f8332a39c..ec8b8e7624 100644 --- a/app/models/site_setting.rb +++ b/app/models/site_setting.rb @@ -133,7 +133,7 @@ class SiteSetting < ActiveRecord::Base end def self.absolute_base_url - bucket = SiteSetting.enable_s3_uploads ? Discourse.store.s3_bucket_name : GlobalSetting.s3_bucket + bucket = SiteSetting.enable_s3_uploads ? Discourse.store.s3_bucket_name : GlobalSetting.s3_bucket_name # cf. http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region if SiteSetting.Upload.s3_region == "us-east-1" From 41ffafb65e4fc9a305e35b900d689f15cf0f3890 Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Thu, 17 May 2018 12:10:35 -0700 Subject: [PATCH 024/278] FIX: best effort at returning correct mime types in manifest.json --- app/controllers/metadata_controller.rb | 9 ++++++++- spec/controllers/metadata_controller_spec.rb | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/controllers/metadata_controller.rb b/app/controllers/metadata_controller.rb index e5993b7a69..3852daf8a6 100644 --- a/app/controllers/metadata_controller.rb +++ b/app/controllers/metadata_controller.rb @@ -30,7 +30,7 @@ class MetadataController < ApplicationController { src: logo, sizes: "512x512", - type: "image/png" + type: guess_mime(logo) } ] } @@ -49,4 +49,11 @@ class MetadataController < ApplicationController manifest end + + def guess_mime(filename) + extension = filename.split(".").last + valid_image_mimes = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", ico: "image/x-icon" } + valid_image_mimes[extension.to_sym] || "image/png" + end + end diff --git a/spec/controllers/metadata_controller_spec.rb b/spec/controllers/metadata_controller_spec.rb index cf15a697de..842e466474 100644 --- a/spec/controllers/metadata_controller_spec.rb +++ b/spec/controllers/metadata_controller_spec.rb @@ -15,6 +15,20 @@ RSpec.describe MetadataController do expect(manifest["name"]).to eq(title) expect(manifest["icons"].first["src"]).to eq("http://big.square/png") end + + it 'can guess mime types' do + SiteSetting.large_icon_url = "http://big.square/ico.ico" + get :manifest + manifest = JSON.parse(response.body) + expect(manifest["icons"].first["type"]).to eq("image/x-icon") + end + + it 'defaults to png' do + SiteSetting.large_icon_url = "http://big.square/noidea.bogus" + get :manifest + manifest = JSON.parse(response.body) + expect(manifest["icons"].first["type"]).to eq("image/png") + end end describe 'opensearch.xml' do From 0639b902dc326d819b4e9b18ead0499b5d61009d Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Fri, 18 May 2018 01:34:31 +0530 Subject: [PATCH 025/278] Import lithium nodes into categories --- script/import_scripts/lithium.rb | 97 ++++++++++++++++---------------- 1 file changed, 48 insertions(+), 49 deletions(-) diff --git a/script/import_scripts/lithium.rb b/script/import_scripts/lithium.rb index d3d5ac8a7c..0b15b58bd1 100644 --- a/script/import_scripts/lithium.rb +++ b/script/import_scripts/lithium.rb @@ -27,7 +27,6 @@ class ImportScripts::Lithium < ImportScripts::Base # CHANGE THESE BEFORE RUNNING THE IMPORTER DATABASE = "wd" PASSWORD = "password" - CATEGORY_CSV = "/tmp/wd-cats.csv" UPLOAD_DIR = '/tmp/uploads' OLD_DOMAIN = 'community.wd.com' @@ -184,72 +183,72 @@ class ImportScripts::Lithium < ImportScripts::Base def import_categories puts "", "importing top level categories..." - categories = mysql_query("SELECT node_id, display_id, position, parent_node_id from nodes").to_a + categories = mysql_query <<-SQL + SELECT n.node_id, n.display_id, c.nvalue c_title, b.nvalue b_title, n.position, n.parent_node_id, n.type_id + FROM nodes n + LEFT JOIN settings c ON n.node_id = c.node_id AND c.param = 'category.title' + LEFT JOIN settings b ON n.node_id = b.node_id AND b.param = 'board.title' + ORDER BY n.type_id DESC, n.node_id ASC + SQL - category_info = {} - top_level_ids = Set.new - child_ids = Set.new - - parent = nil - CSV.foreach(CATEGORY_CSV) do |row| - display_id = row[2].strip - - node = { - name: (row[0] || row[1]).strip, - secure: row[3] == "x", - top_level: !!row[0] - } - - if row[0] - top_level_ids << display_id - parent = node - else - child_ids << display_id - node[:parent] = parent - end - - category_info[display_id] = node + categories = categories.map { |c| (c["name"] = c["c_title"] || c["b_title"] || c["display_id"]) && c } + # To prevent duplicate category names + categories = categories.map do |category| + count = categories.to_a.count { |c| c["name"].present? && c["name"] == category["name"] } + category["name"] << " (#{category["node_id"]})" if count > 1 + category end - top_level_categories = categories.select { |c| top_level_ids.include? c["display_id"] } - - create_categories(top_level_categories) do |category| - info = category_info[category["display_id"]] - info[:id] = category["node_id"] + parent_categories = categories.select { |c| c["parent_node_id"] <= 2 } + create_categories(parent_categories) do |category| { - id: info[:id], - name: info[:name], - position: category["position"] + id: category["node_id"], + name: category["name"], + position: category["position"], + post_create_action: lambda do |record| + after_category_create(record, category) + end } end puts "", "importing children categories..." - children_categories = categories.select { |c| child_ids.include? c["display_id"] } + children_categories = categories.select { |c| c["parent_node_id"] > 2 } create_categories(children_categories) do |category| - info = category_info[category["display_id"]] - info[:id] = category["node_id"] - { - id: info[:id], - name: info[:name], + id: category["node_id"], + name: category["name"], position: category["position"], - parent_category_id: category_id_from_imported_category_id(info[:parent][:id]) + parent_category_id: category_id_from_imported_category_id(category["parent_node_id"]), + post_create_action: lambda do |record| + after_category_create(record, category) + end } end + end - puts "", "securing categories" - category_info.each do |_, info| - if info[:secure] - id = category_id_from_imported_category_id(info[:id]) - if id - cat = Category.find(id) - cat.set_permissions({}) - cat.save - putc "." + def after_category_create(category, params) + node_id = category.custom_fields["import_id"] + roles = mysql_query <<-SQL + SELECT name + FROM roles + WHERE node_id = #{node_id} + SQL + + if roles.count > 0 + category.update(read_restricted: true) + + roles.each do |role| + group_id = group_id_from_imported_group_id(role["name"]) + if group_id.present? + CategoryGroup.find_or_create_by(category: category, group_id: group_id) do |cg| + cg.permission_type = CategoryGroup.permission_types[:full] + end + else + puts "", "Group not found for id '#{role["name"]}'" end end end From af548c23c4ef96eaa63a85effc16de06e4bff6f1 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 17 May 2018 22:44:33 +0200 Subject: [PATCH 026/278] new dashboard quality pass (code, tests and UI) --- .../admin/components/admin-graph.js.es6 | 7 + .../admin/controllers/admin-reports.js.es6 | 1 + .../admin/mixins/async-report.js.es6 | 4 +- .../javascripts/admin/models/report.js.es6 | 191 ++++++++---------- .../components/admin-report-counts.hbs | 4 +- .../javascripts/admin/templates/reports.hbs | 82 ++++---- .../stylesheets/common/admin/admin_base.scss | 7 + .../common/admin/admin_reports.scss | 53 +++++ .../common/admin/dashboard_next.scss | 92 ++++----- .../conditional-loading-section.scss | 1 - app/models/report.rb | 15 +- .../acceptance/dashboard-next-test.js.es6 | 6 + test/javascripts/models/report-test.js.es6 | 165 ++++++++++++--- 13 files changed, 394 insertions(+), 234 deletions(-) create mode 100644 app/assets/stylesheets/common/admin/admin_reports.scss diff --git a/app/assets/javascripts/admin/components/admin-graph.js.es6 b/app/assets/javascripts/admin/components/admin-graph.js.es6 index c99c7b186b..724e967374 100644 --- a/app/assets/javascripts/admin/components/admin-graph.js.es6 +++ b/app/assets/javascripts/admin/components/admin-graph.js.es6 @@ -1,4 +1,5 @@ import loadScript from 'discourse/lib/load-script'; +import { number } from 'discourse/lib/formatter'; export default Ember.Component.extend({ tagName: 'canvas', @@ -22,10 +23,16 @@ export default Ember.Component.extend({ data: data, options: { responsive: true, + tooltips: { + callbacks: { + title: (context) => moment(context[0].xLabel, "YYYY-MM-DD").format("LL") + } + }, scales: { yAxes: [{ display: true, ticks: { + callback: (label) => number(label), suggestedMin: 0 } }] diff --git a/app/assets/javascripts/admin/controllers/admin-reports.js.es6 b/app/assets/javascripts/admin/controllers/admin-reports.js.es6 index 83fde41c7e..1b02d5575a 100644 --- a/app/assets/javascripts/admin/controllers/admin-reports.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-reports.js.es6 @@ -4,6 +4,7 @@ import Report from 'admin/models/report'; import computed from 'ember-addons/ember-computed-decorators'; export default Ember.Controller.extend({ + classNames: ["admin-reports"], queryParams: ["mode", "start_date", "end_date", "category_id", "group_id"], viewMode: 'graph', viewingTable: Em.computed.equal('viewMode', 'table'), diff --git a/app/assets/javascripts/admin/mixins/async-report.js.es6 b/app/assets/javascripts/admin/mixins/async-report.js.es6 index 2c22ab15c2..df225f42f6 100644 --- a/app/assets/javascripts/admin/mixins/async-report.js.es6 +++ b/app/assets/javascripts/admin/mixins/async-report.js.es6 @@ -1,7 +1,7 @@ import computed from "ember-addons/ember-computed-decorators"; export default Ember.Mixin.create({ - classNameBindings: ["isLoading"], + classNameBindings: ["isLoading", "dataSourceNames"], reports: null, isLoading: false, dataSourceNames: "", @@ -25,7 +25,6 @@ export default Ember.Mixin.create({ // the array contains only unique values reports = reports.uniqBy("report_key"); - const sort = (r) => { if (r.length > 1) { return dataSourceNames @@ -40,7 +39,6 @@ export default Ember.Mixin.create({ return sort(reports); } - return sort(reports.filter(report => { return report.report_key.includes(startDate.format("YYYYMMDD")) && report.report_key.includes(endDate.format("YYYYMMDD")); diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6 index 5441be004a..f8078e85ab 100644 --- a/app/assets/javascripts/admin/models/report.js.es6 +++ b/app/assets/javascripts/admin/models/report.js.es6 @@ -1,22 +1,24 @@ -import { ajax } from 'discourse/lib/ajax'; +import { ajax } from "discourse/lib/ajax"; import round from "discourse/lib/round"; -import { fillMissingDates } from 'discourse/lib/utilities'; -import computed from 'ember-addons/ember-computed-decorators'; +import { fillMissingDates } from "discourse/lib/utilities"; +import computed from "ember-addons/ember-computed-decorators"; +import { number } from 'discourse/lib/formatter'; const Report = Discourse.Model.extend({ average: false, percent: false, + higher_is_better: true, @computed("type", "start_date", "end_date") reportUrl(type, start_date, end_date) { - start_date = moment(start_date).locale('en').format("YYYY-MM-DD"); - end_date = moment(end_date).locale('en').format("YYYY-MM-DD"); + start_date = moment(start_date).locale("en").format("YYYY-MM-DD"); + end_date = moment(end_date).locale("en").format("YYYY-MM-DD"); return Discourse.getURL(`/admin/reports/${type}?start_date=${start_date}&end_date=${end_date}`); }, valueAt(numDaysAgo) { if (this.data) { - const wantedDate = moment().subtract(numDaysAgo, "days").locale('en').format("YYYY-MM-DD"); + const wantedDate = moment().subtract(numDaysAgo, "days").locale("en").format("YYYY-MM-DD"); const item = this.data.find(d => d.x === wantedDate); if (item) { return item.y; @@ -29,7 +31,7 @@ const Report = Discourse.Model.extend({ if (this.data) { const earliestDate = moment().subtract(endDaysAgo, "days").startOf("day"); const latestDate = moment().subtract(startDaysAgo, "days").startOf("day"); - var d, sum = 0, count = 0; + let d, sum = 0, count = 0; _.each(this.data, datum => { d = moment(datum.x); if (d >= earliestDate && d <= latestDate) { @@ -46,6 +48,7 @@ const Report = Discourse.Model.extend({ yesterdayCount: function() { return this.valueAt(1); }.property("data", "average"), sevenDaysAgoCount: function() { return this.valueAt(7); }.property("data", "average"), thirtyDaysAgoCount: function() { return this.valueAt(30); }.property("data", "average"), + lastSevenDaysCount: function() { return this.averageCount(7, this.valueFor(1, 7)); }.property("data", "average"), @@ -57,50 +60,22 @@ const Report = Discourse.Model.extend({ return this.get("average") ? value / count : value; }, - @computed('yesterdayCount') + @computed("yesterdayCount") yesterdayTrend(yesterdayCount) { - const yesterdayVal = yesterdayCount; - const twoDaysAgoVal = this.valueAt(2); - const change = ((yesterdayVal - twoDaysAgoVal) / yesterdayVal) * 100; - - if (change > 50) { - return "high-trending-up"; - } else if (change > 0) { - return "trending-up"; - } else if (change === 0) { - return "no-change"; - } else if (change < -50) { - return "high-trending-down"; - } else if (change < 0) { - return "trending-down"; - } + return this._computeTrend(this.valueAt(2), yesterdayCount); }, - @computed('lastSevenDaysCount') - sevenDayTrend(lastSevenDaysCount) { - const currentPeriod = lastSevenDaysCount; - const prevPeriod = this.valueFor(8, 14); - const change = ((currentPeriod - prevPeriod) / prevPeriod) * 100; - - if (change > 50) { - return "high-trending-up"; - } else if (change > 0) { - return "trending-up"; - } else if (change === 0) { - return "no-change"; - } else if (change < -50) { - return "high-trending-down"; - } else if (change < 0) { - return "trending-down"; - } + @computed("lastSevenDaysCount") + sevenDaysTrend(lastSevenDaysCount) { + return this._computeTrend(this.valueFor(8, 14), lastSevenDaysCount); }, - @computed('data') + @computed("data") currentTotal(data){ return _.reduce(data, (cur, pair) => cur + pair.y, 0); }, - @computed('data', 'currentTotal') + @computed("data", "currentTotal") currentAverage(data, total) { return Ember.makeArray(data).length === 0 ? 0 : parseFloat((total / parseFloat(data.length)).toFixed(1)); }, @@ -121,43 +96,18 @@ const Report = Discourse.Model.extend({ } }, - @computed('prev_period', 'currentTotal', 'currentAverage') + @computed("prev_period", "currentTotal", "currentAverage") trend(prev, currentTotal, currentAverage) { - const total = this.get('average') ? currentAverage : currentTotal; - const change = ((total - prev) / total) * 100; - - if (change > 50) { - return "high-trending-up"; - } else if (change > 0) { - return "trending-up"; - } else if (change === 0) { - return "no-change"; - } else if (change < -50) { - return "high-trending-down"; - } else if (change < 0) { - return "trending-down"; - } + const total = this.get("average") ? currentAverage : currentTotal; + return this._computeTrend(prev, total); }, - @computed('prev30Days', 'lastThirtyDaysCount') - thirtyDayTrend(prev30Days, lastThirtyDaysCount) { - const currentPeriod = lastThirtyDaysCount; - const change = ((currentPeriod - prev30Days) / currentPeriod) * 100; - - if (change > 50) { - return "high-trending-up"; - } else if (change > 0) { - return "trending-up"; - } else if (change === 0) { - return "no-change"; - } else if (change < -50) { - return "high-trending-down"; - } else if (change < 0) { - return "trending-down"; - } + @computed("prev30Days", "lastThirtyDaysCount") + thirtyDaysTrend(prev30Days, lastThirtyDaysCount) { + return this._computeTrend(prev30Days, lastThirtyDaysCount); }, - @computed('type') + @computed("type") icon(type) { if (type.indexOf("message") > -1) { return "envelope"; @@ -170,7 +120,7 @@ const Report = Discourse.Model.extend({ } }, - @computed('type') + @computed("type") method(type) { if (type === "time_to_first_response") { return "average"; @@ -180,75 +130,98 @@ const Report = Discourse.Model.extend({ }, percentChangeString(val1, val2) { - const val = ((val1 - val2) / val2) * 100; - if (isNaN(val) || !isFinite(val)) { + const change = this._computeChange(val1, val2); + + if (isNaN(change) || !isFinite(change)) { return null; - } else if (val > 0) { - return "+" + val.toFixed(0) + "%"; + } else if (change > 0) { + return "+" + change.toFixed(0) + "%"; } else { - return val.toFixed(0) + "%"; + return change.toFixed(0) + "%"; } }, - @computed('prev_period', 'currentTotal', 'currentAverage') + @computed("prev_period", "currentTotal", "currentAverage") trendTitle(prev, currentTotal, currentAverage) { - let current = this.get('average') ? currentAverage : currentTotal; - let percent = this.percentChangeString(current, prev); + let current = this.get("average") ? currentAverage : currentTotal; + let percent = this.percentChangeString(prev, current); - if (this.get('average')) { + if (this.get("average")) { prev = prev ? prev.toFixed(1) : "0"; - if (this.get('percent')) { - current += '%'; - prev += '%'; + if (this.get("percent")) { + current += "%"; + prev += "%"; } + } else { + prev = number(prev); + current = number(current); } - return I18n.t('admin.dashboard.reports.trend_title', {percent: percent, prev: prev, current: current}); + return I18n.t("admin.dashboard.reports.trend_title", {percent, prev, current}); }, - changeTitle(val1, val2, prevPeriodString) { - const percentChange = this.percentChangeString(val1, val2); - var title = ""; - if (percentChange) { title += percentChange + " change. "; } - title += "Was " + val2 + " " + prevPeriodString + "."; + changeTitle(valAtT1, valAtT2, prevPeriodString) { + const change = this.percentChangeString(valAtT1, valAtT2); + let title = ""; + if (change) { title += `${change} change. `; } + title += `Was ${number(valAtT1)} ${prevPeriodString}.`; return title; }, - @computed('yesterdayCount') + @computed("yesterdayCount") yesterdayCountTitle(yesterdayCount) { - return this.changeTitle(yesterdayCount, this.valueAt(2), "two days ago"); + return this.changeTitle(this.valueAt(2), yesterdayCount, "two days ago"); }, - @computed('lastSevenDaysCount') - sevenDayCountTitle(lastSevenDaysCount) { - return this.changeTitle(lastSevenDaysCount, this.valueFor(8, 14), "two weeks ago"); + @computed("lastSevenDaysCount") + sevenDaysCountTitle(lastSevenDaysCount) { + return this.changeTitle(this.valueFor(8, 14), lastSevenDaysCount, "two weeks ago"); }, - @computed('prev30Days', 'lastThirtyDaysCount') - thirtyDayCountTitle(prev30Days, lastThirtyDaysCount) { - return this.changeTitle(lastThirtyDaysCount, prev30Days, "in the previous 30 day period"); + @computed("prev30Days", "lastThirtyDaysCount") + thirtyDaysCountTitle(prev30Days, lastThirtyDaysCount) { + return this.changeTitle(prev30Days, lastThirtyDaysCount, "in the previous 30 day period"); }, - @computed('data') + @computed("data") sortedData(data) { - return this.get('xAxisIsDate') ? data.toArray().reverse() : data.toArray(); + return this.get("xAxisIsDate") ? data.toArray().reverse() : data.toArray(); }, - @computed('data') + @computed("data") xAxisIsDate() { if (!this.data[0]) return false; return this.data && this.data[0].x.match(/\d{4}-\d{1,2}-\d{1,2}/); - } + }, + _computeChange(valAtT1, valAtT2) { + return ((valAtT2 - valAtT1) / valAtT1) * 100; + }, + + _computeTrend(valAtT1, valAtT2) { + const change = this._computeChange(valAtT1, valAtT2); + const higherIsBetter = this.get("higher_is_better"); + + if (change > 50) { + return higherIsBetter ? "high-trending-up" : "high-trending-down"; + } else if (change > 0) { + return higherIsBetter ? "trending-up" : "trending-down"; + } else if (change === 0) { + return "no-change"; + } else if (change < -50) { + return higherIsBetter ? "high-trending-down" : "high-trending-up"; + } else if (change < 0) { + return higherIsBetter ? "trending-down" : "trending-up"; + } + } }); Report.reopenClass({ - fillMissingDates(report) { if (_.isArray(report.data)) { - const startDateFormatted = moment.utc(report.start_date).locale('en').format('YYYY-MM-DD'); - const endDateFormatted = moment.utc(report.end_date).locale('en').format('YYYY-MM-DD'); + const startDateFormatted = moment.utc(report.start_date).locale("en").format("YYYY-MM-DD"); + const endDateFormatted = moment.utc(report.end_date).locale("en").format("YYYY-MM-DD"); report.data = fillMissingDates(report.data, startDateFormatted, endDateFormatted); } }, @@ -272,7 +245,7 @@ Report.reopenClass({ // TODO: fillMissingDates if xaxis is date const related = Report.create({ type: json.report.related_report.type }); related.setProperties(json.report.related_report); - model.set('relatedReport', related); + model.set("relatedReport", related); } return model; diff --git a/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs b/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs index 793573e250..2dc0600e44 100644 --- a/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs +++ b/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs @@ -11,11 +11,11 @@ {{number report.yesterdayCount}} {{d-icon "caret-up" class="up"}} {{d-icon "caret-down" class="down"}} - + {{number report.lastSevenDaysCount}} {{d-icon "caret-up" class="up"}} {{d-icon "caret-down" class="down"}} - + {{number report.lastThirtyDaysCount}} {{d-icon "caret-up" class="up"}} {{d-icon "caret-down" class="down"}} diff --git a/app/assets/javascripts/admin/templates/reports.hbs b/app/assets/javascripts/admin/templates/reports.hbs index eab2782425..26b5bfb4fe 100644 --- a/app/assets/javascripts/admin/templates/reports.hbs +++ b/app/assets/javascripts/admin/templates/reports.hbs @@ -4,41 +4,49 @@

{{model.description}}

{{/if}} -
- {{i18n 'admin.dashboard.reports.start_date'}} {{date-picker-past value=startDate defaultDate=startDate}} - {{i18n 'admin.dashboard.reports.end_date'}} {{date-picker-past value=endDate defaultDate=endDate}} - {{#if showCategoryOptions}} - {{combo-box filterable=true valueAttribute="value" content=categoryOptions value=categoryId}} - {{/if}} - {{#if showGroupOptions}} - {{combo-box filterable=true valueAttribute="value" content=groupOptions value=groupId}} - {{/if}} - {{d-button action="refreshReport" class="btn-primary" label="admin.dashboard.reports.refresh_report" icon="refresh"}} - {{d-button action="exportCsv" label="admin.export_csv.button_text" icon="download"}} +
+
+ {{#conditional-loading-spinner condition=refreshing}} +
+ {{#if viewingTable}} + {{i18n 'admin.dashboard.reports.view_table'}} + {{else}} + {{i18n 'admin.dashboard.reports.view_table'}} + {{/if}} + | + {{#if viewingGraph}} + {{i18n 'admin.dashboard.reports.view_graph'}} + {{else}} + {{i18n 'admin.dashboard.reports.view_graph'}} + {{/if}} +
+ + {{#if viewingGraph}} + {{admin-graph model=model}} + {{else}} + {{admin-table-report model=model}} + {{/if}} + + {{#if model.relatedReport}} + {{admin-table-report model=model.relatedReport}} + {{/if}} + {{/conditional-loading-spinner}} +
+ +
+ + {{i18n 'admin.dashboard.reports.start_date'}} {{date-picker-past value=startDate defaultDate=startDate}} + + + {{i18n 'admin.dashboard.reports.end_date'}} {{date-picker-past value=endDate defaultDate=endDate}} + + {{#if showCategoryOptions}} + {{combo-box filterable=true valueAttribute="value" content=categoryOptions value=categoryId}} + {{/if}} + {{#if showGroupOptions}} + {{combo-box filterable=true valueAttribute="value" content=groupOptions value=groupId}} + {{/if}} + {{d-button action="refreshReport" class="btn-primary" label="admin.dashboard.reports.refresh_report" icon="refresh"}} + {{d-button action="exportCsv" label="admin.export_csv.button_text" icon="download"}} +
- -
- {{#if viewingTable}} - {{i18n 'admin.dashboard.reports.view_table'}} - {{else}} - {{i18n 'admin.dashboard.reports.view_table'}} - {{/if}} - | - {{#if viewingGraph}} - {{i18n 'admin.dashboard.reports.view_graph'}} - {{else}} - {{i18n 'admin.dashboard.reports.view_graph'}} - {{/if}} -
- -{{#conditional-loading-spinner condition=refreshing}} - {{#if viewingGraph}} - {{admin-graph model=model}} - {{else}} - {{admin-table-report model=model}} - {{/if}} - - {{#if model.relatedReport}} - {{admin-table-report model=model.relatedReport}} - {{/if}} -{{/conditional-loading-spinner}} diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index d473536d09..5557f7c795 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -6,6 +6,7 @@ @import "common/admin/customize"; @import "common/admin/flagging"; @import "common/admin/dashboard_next"; +@import "common/admin/admin_reports"; @import "common/admin/moderation_history"; @import "common/admin/suspend"; @@ -1968,6 +1969,12 @@ table#user-badges { margin-right: 20px; } +.admin-reports, .dashboard-next { + &.admin-contents { + margin: 0; + } +} + .cbox0 { background: blend-primary-secondary(0%); } .cbox10 { background: blend-primary-secondary(10%); } .cbox20 { background: blend-primary-secondary(20%); } diff --git a/app/assets/stylesheets/common/admin/admin_reports.scss b/app/assets/stylesheets/common/admin/admin_reports.scss new file mode 100644 index 0000000000..9a064a85c0 --- /dev/null +++ b/app/assets/stylesheets/common/admin/admin_reports.scss @@ -0,0 +1,53 @@ +.admin-reports { + h3 { + border-bottom: 1px solid $primary-low; + margin-bottom: .5em; + padding-bottom: .5em; + } + + .report-container { + display: flex; + + .loading-container { + width: 100%; + } + + .visualization { + display: flex; + flex: 4; + } + + .filters { + display: flex; + flex: 1; + align-items: center; + flex-direction: column; + margin-left: 2em; + + .date-picker { + margin: 0; + width: 195px; + } + + .combo-box, .date-picker-wrapper, .btn { + width: 100%; + margin-bottom: 1em; + } + } + + @include small-width { + flex-direction: column; + min-width: 100%; + + .visualization { + order: 2; + } + + .filters { + order: 1; + margin: 0; + align-items: flex-start; + } + } + } +} diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index 3fe538aa7d..57cce7659e 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -1,8 +1,4 @@ .dashboard-next { - &.admin-contents { - margin: 0; - } - .section-top { margin-bottom: 1em; } @@ -19,6 +15,14 @@ min-width: calc(50% - .5em); max-width: 100%; + &:last-child, { + margin-left: 1em; + } + + &:first-child { + margin-right: 1em; + } + @include small-width { min-width: 100%; @@ -32,16 +36,9 @@ } } - .section-column:last-child, { - margin-left: 1em; - } - - .section-column:first-child { - margin-right: 1em; - } - @include small-width { - .section-column:last-child, .section-column:first-child { + .section-column:last-child, + .section-column:first-child { margin: 0; } } @@ -107,16 +104,17 @@ .durability, .last-dashboard-update { flex: 1 1 50%; box-sizing: border-box; - margin: 20px 0; - padding: 0 20px; + margin: 1em 0; + padding: 0 1em; } .durability { display: flex; flex-wrap: wrap; justify-content: space-between; + .backups, .uploads { - flex: 1 1 100%; + flex: 1 1 100%; } .uploads p:last-of-type { @@ -146,7 +144,7 @@ border-left: 1px solid $primary-low; text-align: center; display: flex; - justify-content: center; + justify-content: center; div { align-self: center; h4 { @@ -156,7 +154,17 @@ } } + .top-referred-topics, .trending-search { + th:first-of-type { + text-align: left; + } + } + .top-referred-topics { + .dashboard-table table { + table-layout: auto; + } + } .community-health { .period-chooser .period-chooser-header { @@ -171,7 +179,6 @@ } } - .dashboard-mini-chart { .status { display: flex; @@ -239,7 +246,7 @@ width: 100%; } - .d-icon-question-circle { + .tooltip { cursor: pointer; } @@ -255,41 +262,6 @@ } } } - - &.high-trending-up, &.trending-up { - .chart-trend, .data-point { - color: $success; - } - } - - &.high-trending-down, &.trending-down { - .chart-trend, .data-point { - color: $danger; - } - } -} - -.dashboard-table.activity-metrics { - table { - @media screen and (min-width: 400px) { - table-layout: auto; - } - tr th { - text-align: right; - } - } -} - -.top-referred-topics, .trending-search { - th:first-of-type { - text-align: left; - } -} - -.top-referred-topics { - .dashboard-table table { - table-layout: auto; - } } .dashboard-table { @@ -351,6 +323,7 @@ text-align: center; padding: 8px; } + td.left { text-align: left; } @@ -396,3 +369,14 @@ } } } + +.dashboard-table.activity-metrics { + table { + @media screen and (min-width: 400px) { + table-layout: auto; + } + tr th { + text-align: right; + } + } +} diff --git a/app/assets/stylesheets/common/components/conditional-loading-section.scss b/app/assets/stylesheets/common/components/conditional-loading-section.scss index e081dcde46..cb6c052b18 100644 --- a/app/assets/stylesheets/common/components/conditional-loading-section.scss +++ b/app/assets/stylesheets/common/components/conditional-loading-section.scss @@ -1,5 +1,4 @@ .conditional-loading-section { - &.is-loading { padding: 2em; margin: 1em; diff --git a/app/models/report.rb b/app/models/report.rb index 744275b155..d0ebb790e9 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -4,7 +4,8 @@ class Report attr_accessor :type, :data, :total, :prev30Days, :start_date, :end_date, :category_id, :group_id, :labels, :async, - :prev_period, :facets, :limit, :processing, :average, :percent + :prev_period, :facets, :limit, :processing, :average, :percent, + :higher_is_better def self.default_days 30 @@ -14,6 +15,9 @@ class Report @type = type @start_date ||= Report.default_days.days.ago.beginning_of_day @end_date ||= Time.zone.now.end_of_day + @average = false + @percent = false + @higher_is_better = true end def self.cache_key(report) @@ -54,7 +58,8 @@ class Report labels: labels, processing: self.processing, average: self.average, - percent: self.percent + percent: self.percent, + higher_is_better: self.higher_is_better }.tap do |json| json[:total] = total if total json[:prev_period] = prev_period if prev_period @@ -83,8 +88,9 @@ class Report report.facets = opts[:facets] || [:total, :prev30Days] report.limit = opts[:limit] if opts[:limit] report.processing = false - report.average = opts[:average] || false - report.percent = opts[:percent] || false + report.average = opts[:average] if opts[:average] + report.percent = opts[:percent] if opts[:percent] + report.higher_is_better = opts[:higher_is_better] if opts[:higher_is_better] report end @@ -278,6 +284,7 @@ class Report end def self.report_time_to_first_response(report) + report.higher_is_better = false report.data = [] Topic.time_to_first_response_per_day(report.start_date, report.end_date, category_id: report.category_id).each do |r| report.data << { x: Date.parse(r["date"]), y: r["hours"].to_f.round(2) } diff --git a/test/javascripts/acceptance/dashboard-next-test.js.es6 b/test/javascripts/acceptance/dashboard-next-test.js.es6 index 12eed20c24..46d50d0087 100644 --- a/test/javascripts/acceptance/dashboard-next-test.js.es6 +++ b/test/javascripts/acceptance/dashboard-next-test.js.es6 @@ -12,5 +12,11 @@ QUnit.test("Visit dashboard next page", assert => { andThen(() => { assert.ok($('.dashboard-next').length, "has dashboard-next class"); + + assert.ok($('.dashboard-mini-chart.signups').length, "has a signups chart"); + assert.ok($('.dashboard-mini-chart.posts').length, "has a posts chart"); + assert.ok($('.dashboard-mini-chart.dau_by_mau').length, "has a dau_by_mau chart"); + assert.ok($('.dashboard-mini-chart.daily_engaged_users').length, "has a daily_engaged_users chart"); + assert.ok($('.dashboard-mini-chart.new_contributors').length, "has a new_contributors chart"); }); }); diff --git a/test/javascripts/models/report-test.js.es6 b/test/javascripts/models/report-test.js.es6 index e9661eadb6..a2544836e0 100644 --- a/test/javascripts/models/report-test.js.es6 +++ b/test/javascripts/models/report-test.js.es6 @@ -1,63 +1,180 @@ -import Report from 'admin/models/report'; +import Report from "admin/models/report"; QUnit.module("Report"); function reportWithData(data) { return Report.create({ - type: 'topics', - data: _.map(data, function(val, index) { - return { x: moment().subtract(index, "days").format('YYYY-MM-DD'), y: val }; + type: "topics", + data: _.map(data, (val, index) => { + return { x: moment().subtract(index, "days").format("YYYY-MM-DD"), y: val }; }) }); } QUnit.test("counts", assert => { - var report = reportWithData([5, 4, 3, 2, 1, 100, 99, 98, 1000]); + const report = reportWithData([5, 4, 3, 2, 1, 100, 99, 98, 1000]); - assert.equal(report.get('todayCount'), 5); - assert.equal(report.get('yesterdayCount'), 4); + assert.equal(report.get("todayCount"), 5); + assert.equal(report.get("yesterdayCount"), 4); assert.equal(report.valueFor(2, 4), 6, "adds the values for the given range of days, inclusive"); - assert.equal(report.get('lastSevenDaysCount'), 307, "sums 7 days excluding today"); + assert.equal(report.get("lastSevenDaysCount"), 307, "sums 7 days excluding today"); report.set("method", "average"); assert.equal(report.valueFor(2, 4), 2, "averages the values for the given range of days"); }); QUnit.test("percentChangeString", assert => { - var report = reportWithData([]); + const report = reportWithData([]); - assert.equal(report.percentChangeString(8, 5), "+60%", "value increased"); - assert.equal(report.percentChangeString(2, 8), "-75%", "value decreased"); + assert.equal(report.percentChangeString(5, 8), "+60%", "value increased"); + assert.equal(report.percentChangeString(8, 2), "-75%", "value decreased"); assert.equal(report.percentChangeString(8, 8), "0%", "value unchanged"); - assert.blank(report.percentChangeString(8, 0), "returns blank when previous value was 0"); - assert.equal(report.percentChangeString(0, 8), "-100%", "yesterday was 0"); + assert.blank(report.percentChangeString(0, 8), "returns blank when previous value was 0"); + assert.equal(report.percentChangeString(8, 0), "-100%", "yesterday was 0"); assert.blank(report.percentChangeString(0, 0), "returns blank when both were 0"); }); QUnit.test("yesterdayCountTitle with valid values", assert => { - var title = reportWithData([6,8,5,2,1]).get('yesterdayCountTitle'); - assert.ok(title.indexOf('+60%') !== -1); + const title = reportWithData([6,8,5,2,1]).get("yesterdayCountTitle"); + assert.ok(title.indexOf("+60%") !== -1); assert.ok(title.match(/Was 5/)); }); QUnit.test("yesterdayCountTitle when two days ago was 0", assert => { - var title = reportWithData([6,8,0,2,1]).get('yesterdayCountTitle'); - assert.equal(title.indexOf('%'), -1); + const title = reportWithData([6,8,0,2,1]).get("yesterdayCountTitle"); + assert.equal(title.indexOf("%"), -1); assert.ok(title.match(/Was 0/)); }); -QUnit.test("sevenDayCountTitle", assert => { - var title = reportWithData([100,1,1,1,1,1,1,1,2,2,2,2,2,2,2,100,100]).get('sevenDayCountTitle'); +QUnit.test("sevenDaysCountTitle", assert => { + const title = reportWithData([100,1,1,1,1,1,1,1,2,2,2,2,2,2,2,100,100]).get("sevenDaysCountTitle"); assert.ok(title.match(/-50%/)); assert.ok(title.match(/Was 14/)); }); -QUnit.test("thirtyDayCountTitle", assert => { - var report = reportWithData([5,5,5,5]); - report.set('prev30Days', 10); - var title = report.get('thirtyDayCountTitle'); +QUnit.test("thirtyDaysCountTitle", assert => { + const report = reportWithData([5,5,5,5]); + report.set("prev30Days", 10); + const title = report.get("thirtyDaysCountTitle"); - assert.ok(title.indexOf('+50%') !== -1); + assert.ok(title.indexOf("+50%") !== -1); assert.ok(title.match(/Was 10/)); }); + +QUnit.test("sevenDaysTrend", assert => { + let report; + let trend; + + report = reportWithData([0, 1,1,1,1,1,1,1, 1,1,1,1,1,1,1]); + trend = report.get("sevenDaysTrend"); + assert.ok(trend === "no-change"); + + report = reportWithData([0, 1,1,1,1,1,1,1, 0,0,0,0,0,0,0]); + trend = report.get("sevenDaysTrend"); + assert.ok(trend === "high-trending-up"); + + report = reportWithData([0, 1,1,1,1,1,1,1, 1,1,1,1,1,1,0]); + trend = report.get("sevenDaysTrend"); + assert.ok(trend === "trending-up"); + + report = reportWithData([0, 0,0,0,0,0,0,0, 1,1,1,1,1,1,1]); + trend = report.get("sevenDaysTrend"); + assert.ok(trend === "high-trending-down"); + + report = reportWithData([0, 1,1,1,1,1,1,0, 1,1,1,1,1,1,1]); + trend = report.get("sevenDaysTrend"); + assert.ok(trend === "trending-down");; +}); + +QUnit.test("yesterdayTrend", assert => { + let report; + let trend; + + report = reportWithData([0, 1, 1]); + trend = report.get("yesterdayTrend"); + assert.ok(trend === "no-change"); + + report = reportWithData([0, 1, 0]); + trend = report.get("yesterdayTrend"); + assert.ok(trend === "high-trending-up"); + + report = reportWithData([0, 1.1, 1]); + trend = report.get("yesterdayTrend"); + assert.ok(trend === "trending-up"); + + report = reportWithData([0, 0, 1]); + trend = report.get("yesterdayTrend"); + assert.ok(trend === "high-trending-down"); + + report = reportWithData([0, 1, 1.1]); + trend = report.get("yesterdayTrend"); + assert.ok(trend === "trending-down");; +}); + +QUnit.test("thirtyDaysTrend", assert => { + let report; + let trend; + + report = reportWithData([0, 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]); + report.set("prev30Days", 30); + trend = report.get("thirtyDaysTrend"); + assert.ok(trend === "no-change"); + + report = reportWithData([0, 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]); + report.set("prev30Days", 0); + trend = report.get("thirtyDaysTrend"); + assert.ok(trend === "high-trending-up"); + + report = reportWithData([0, 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]); + report.set("prev30Days", 25); + trend = report.get("thirtyDaysTrend"); + assert.ok(trend === "trending-up"); + + report = reportWithData([0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]); + report.set("prev30Days", 60); + trend = report.get("thirtyDaysTrend"); + assert.ok(trend === "high-trending-down"); + + report = reportWithData([0, 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0]); + report.set("prev30Days", 35); + trend = report.get("thirtyDaysTrend"); + assert.ok(trend === "trending-down");; +}); + +QUnit.test("higher is better false", assert => { + let report; + let trend; + + report = reportWithData([0, 1, 0]); + report.set("higher_is_better", false); + trend = report.get("yesterdayTrend"); + assert.ok(trend === "high-trending-down"); + + report = reportWithData([0, 1.1, 1]); + report.set("higher_is_better", false); + trend = report.get("yesterdayTrend"); + assert.ok(trend === "trending-down"); + + report = reportWithData([0, 0, 1]); + report.set("higher_is_better", false); + trend = report.get("yesterdayTrend"); + assert.ok(trend === "high-trending-up"); + + report = reportWithData([0, 1, 1.1]); + report.set("higher_is_better", false); + trend = report.get("yesterdayTrend"); + assert.ok(trend === "trending-up");; +}); + +QUnit.test("average", assert => { + let report; + + report = reportWithData([5, 5, 5, 5, 5, 5, 5, 5]); + + report.set("average", true); + assert.ok(report.get("lastSevenDaysCount") === 5); + + report.set("average", false); + assert.ok(report.get("lastSevenDaysCount") === 35); +}); From c1f13ce6a69247c33ee33b155de98e7c1c905a33 Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 17 May 2018 17:03:56 -0400 Subject: [PATCH 027/278] adding icons, updating style --- app/assets/javascripts/admin/models/report.js.es6 | 3 +++ .../stylesheets/common/admin/dashboard_next.scss | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6 index f8078e85ab..cddf5e625a 100644 --- a/app/assets/javascripts/admin/models/report.js.es6 +++ b/app/assets/javascripts/admin/models/report.js.es6 @@ -113,6 +113,9 @@ const Report = Discourse.Model.extend({ return "envelope"; } switch (type) { + case "page_view_total_reqs": return "file"; + case "visits": return "user"; + case "time_to_first_response": return "reply"; case "flags": return "flag"; case "likes": return "heart"; case "bookmarks": return "bookmark"; diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index 57cce7659e..d534d85561 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -378,5 +378,16 @@ tr th { text-align: right; } + .d-icon { + color: $primary-low-mid; + min-width: 14px; + text-align: center; + } + @media screen and (max-width: 400px) { + .d-icon { display: none; } + td.title { + padding: 8px 0 8px 4px; + } + } } } From 4fd17abb76d49fff953672bed6122c25bc34b6e5 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 17 May 2018 23:44:38 +0200 Subject: [PATCH 028/278] linting --- app/assets/javascripts/admin/models/report.js.es6 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6 index cddf5e625a..d7fc55ed1f 100644 --- a/app/assets/javascripts/admin/models/report.js.es6 +++ b/app/assets/javascripts/admin/models/report.js.es6 @@ -113,8 +113,8 @@ const Report = Discourse.Model.extend({ return "envelope"; } switch (type) { - case "page_view_total_reqs": return "file"; - case "visits": return "user"; + case "page_view_total_reqs": return "file"; + case "visits": return "user"; case "time_to_first_response": return "reply"; case "flags": return "flag"; case "likes": return "heart"; From 04c7dbafa32991836a688e979e03b117e8ec8c19 Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Thu, 17 May 2018 14:43:22 -0700 Subject: [PATCH 029/278] FIX: manifest.json better detection at mime type. Find size if uploaded --- app/controllers/metadata_controller.rb | 13 +++++++------ spec/controllers/metadata_controller_spec.rb | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/controllers/metadata_controller.rb b/app/controllers/metadata_controller.rb index 3852daf8a6..0ddb212ebd 100644 --- a/app/controllers/metadata_controller.rb +++ b/app/controllers/metadata_controller.rb @@ -14,6 +14,7 @@ class MetadataController < ApplicationController def default_manifest logo = SiteSetting.large_icon_url.presence || SiteSetting.logo_small_url.presence || SiteSetting.apple_touch_icon_url.presence + file_info = get_file_info(logo) manifest = { name: SiteSetting.title, @@ -29,8 +30,8 @@ class MetadataController < ApplicationController icons: [ { src: logo, - sizes: "512x512", - type: guess_mime(logo) + sizes: file_info[:size], + type: file_info[:type] } ] } @@ -50,10 +51,10 @@ class MetadataController < ApplicationController manifest end - def guess_mime(filename) - extension = filename.split(".").last - valid_image_mimes = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", ico: "image/x-icon" } - valid_image_mimes[extension.to_sym] || "image/png" + def get_file_info(filename) + type = MiniMime.lookup_by_filename(filename)&.content_type || "image/png" + upload = Upload.find_by_url(filename) + { size: "#{upload&.width || 512}x#{upload&.height || 512}", type: type } end end diff --git a/spec/controllers/metadata_controller_spec.rb b/spec/controllers/metadata_controller_spec.rb index 842e466474..96558ec0c7 100644 --- a/spec/controllers/metadata_controller_spec.rb +++ b/spec/controllers/metadata_controller_spec.rb @@ -17,10 +17,10 @@ RSpec.describe MetadataController do end it 'can guess mime types' do - SiteSetting.large_icon_url = "http://big.square/ico.ico" + SiteSetting.large_icon_url = "http://big.square/ico.jpg" get :manifest manifest = JSON.parse(response.body) - expect(manifest["icons"].first["type"]).to eq("image/x-icon") + expect(manifest["icons"].first["type"]).to eq("image/jpeg") end it 'defaults to png' do From 417bcc5f2a978dfcba7f0235e82619f8913ea315 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 18 May 2018 11:07:57 +0800 Subject: [PATCH 030/278] Remove blank test. --- spec/models/user_action_spec.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/spec/models/user_action_spec.rb b/spec/models/user_action_spec.rb index d76d6afad6..1988fecdf6 100644 --- a/spec/models/user_action_spec.rb +++ b/spec/models/user_action_spec.rb @@ -127,10 +127,6 @@ describe UserAction do end - describe "mentions" do - it "returns the mention in the stream" do - end - end describe 'when user likes' do let(:post) { Fabricate(:post) } From 416d19af27fc3e33d6f406517e4ea2726881ebf9 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 18 May 2018 11:28:13 +0800 Subject: [PATCH 031/278] FIX: Wrong target user displayed for user actions in activity stream. https://meta.discourse.org/t/wrong-assigned-username-in-activity-list/73816 --- .../components/user-stream-item.js.es6 | 2 +- app/models/user_action.rb | 2 + app/serializers/user_action_serializer.rb | 9 ++++ spec/models/user_action_spec.rb | 54 +++++++++++++------ 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/app/assets/javascripts/discourse/components/user-stream-item.js.es6 b/app/assets/javascripts/discourse/components/user-stream-item.js.es6 index 103f450b24..b239613c47 100644 --- a/app/assets/javascripts/discourse/components/user-stream-item.js.es6 +++ b/app/assets/javascripts/discourse/components/user-stream-item.js.es6 @@ -11,5 +11,5 @@ export default Ember.Component.extend({ ], moderatorAction: propertyEqual("item.post_type", "site.post_types.moderator_action"), - actionDescription: actionDescription("item.action_code", "item.created_at", "item.username"), + actionDescription: actionDescription("item.action_code", "item.created_at", "item.action_code_who"), }); diff --git a/app/models/user_action.rb b/app/models/user_action.rb index 20a3acde6c..f36c034b0e 100644 --- a/app/models/user_action.rb +++ b/app/models/user_action.rb @@ -212,6 +212,7 @@ SQL p.hidden, p.post_type, p.action_code, + pc.value AS action_code_who, p.edit_reason, t.category_id FROM user_actions as a @@ -222,6 +223,7 @@ SQL JOIN users pu on pu.id = COALESCE(p.user_id, t.user_id) JOIN users au on au.id = a.user_id LEFT JOIN categories c on c.id = t.category_id + LEFT JOIN post_custom_fields pc ON pc.post_id = a.target_post_id AND pc.name = 'action_code_who' /*where*/ /*order_by*/ /*offset*/ diff --git a/app/serializers/user_action_serializer.rb b/app/serializers/user_action_serializer.rb index c6abea6a5c..2aec75ceed 100644 --- a/app/serializers/user_action_serializer.rb +++ b/app/serializers/user_action_serializer.rb @@ -26,6 +26,7 @@ class UserActionSerializer < ApplicationSerializer :hidden, :post_type, :action_code, + :action_code_who, :edit_reason, :category_id, :closed, @@ -79,4 +80,12 @@ class UserActionSerializer < ApplicationSerializer object.topic_archived end + def include_action_code_who? + action_code_who.present? + end + + def action_code_who + object.action_code_who + end + end diff --git a/spec/models/user_action_spec.rb b/spec/models/user_action_spec.rb index 1988fecdf6..6ab8a1537a 100644 --- a/spec/models/user_action_spec.rb +++ b/spec/models/user_action_spec.rb @@ -9,7 +9,7 @@ describe UserAction do it { is_expected.to validate_presence_of :action_type } it { is_expected.to validate_presence_of :user_id } - describe 'lists' do + describe '#stream' do let(:public_post) { Fabricate(:post) } let(:public_topic) { public_post.topic } @@ -46,8 +46,8 @@ describe UserAction do UserAction.stats(user.id, Guardian.new(viewer)).map { |r| r["action_type"].to_i }.sort end - def stream_count(viewer = nil) - UserAction.stream(user_id: user.id, guardian: Guardian.new(viewer)).count + def stream(viewer = nil) + UserAction.stream(user_id: user.id, guardian: Guardian.new(viewer)) end it 'includes the events correctly' do @@ -56,38 +56,38 @@ describe UserAction do mystats = stats_for_user(user) expecting = [UserAction::NEW_TOPIC, UserAction::NEW_PRIVATE_MESSAGE, UserAction::GOT_PRIVATE_MESSAGE, UserAction::BOOKMARK].sort expect(mystats).to eq(expecting) - expect(stream_count(user)).to eq(4) + + expect(stream(user).map(&:action_type)) + .to contain_exactly(*expecting) other_stats = stats_for_user expecting = [UserAction::NEW_TOPIC] - expect(stream_count).to eq(1) - + expect(stream.map(&:action_type)).to contain_exactly(*expecting) expect(other_stats).to eq(expecting) public_topic.trash!(user) expect(stats_for_user).to eq([]) - expect(stream_count).to eq(0) + expect(stream).to eq([]) # groups category = Fabricate(:category, read_restricted: true) public_topic.recover! - public_topic.category = category - public_topic.save + public_topic.update!(category: category) expect(stats_for_user).to eq([]) - expect(stream_count).to eq(0) + expect(stream).to eq([]) group = Fabricate(:group) u = Fabricate(:coding_horror) group.add(u) - group.save category.set_permissions(group => :full) - category.save + category.save! - expect(stats_for_user(u)).to eq([UserAction::NEW_TOPIC]) - expect(stream_count(u)).to eq(1) + expecting = [UserAction::NEW_TOPIC] + expect(stats_for_user(u)).to eq(expecting) + expect(stream(u).map(&:action_type)).to contain_exactly(*expecting) # duplicate should not exception out log_test_action @@ -103,6 +103,29 @@ describe UserAction do end end + describe 'assignments' do + let(:stream) do + UserAction.stream(user_id: user.id, guardian: Guardian.new(user)) + end + + before do + log_test_action(action_type: UserAction::ASSIGNED) + private_post.custom_fields ||= {} + private_post.custom_fields["action_code_who"] = 'testing' + private_post.custom_fields["random_field"] = 'random_value' + private_post.save! + end + + it 'should include the right attributes in the stream' do + expect(stream.count).to eq(1) + + user_action_row = stream.first + + expect(user_action_row.action_type).to eq(UserAction::ASSIGNED) + expect(user_action_row.action_code_who).to eq('testing') + end + end + describe "mentions" do before do log_test_action(action_type: UserAction::MENTION) @@ -116,7 +139,8 @@ describe UserAction do end it "is returned by the stream" do - expect(stream).to be_present + expect(stream.count).to eq(1) + expect(stream.first.action_type).to eq(UserAction::MENTION) end it "isn't returned when mentions aren't enabled" do From 003b7f06adf3bb625e7e99d61fae2b2a8f1653ee Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Fri, 18 May 2018 09:52:16 +0530 Subject: [PATCH 032/278] FIX: rescue specific error --- app/controllers/tags_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index d5c086d2ae..09ad79c44f 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -283,7 +283,7 @@ class TagsController < ::ApplicationController else # :next public_send(method, opts.merge(next_page_params(opts))) end - rescue + rescue ActionController::UrlGenerationError raise Discourse::NotFound end url.sub('.json?', '?') From c42b65df5f03a3472e34e96b56f484fe2cad730b Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 18 May 2018 13:36:41 +0800 Subject: [PATCH 033/278] `find` raises an error if the record is missing. --- app/jobs/regular/emit_web_hook_event.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/jobs/regular/emit_web_hook_event.rb b/app/jobs/regular/emit_web_hook_event.rb index 5ec6f4fa68..6116fabb33 100644 --- a/app/jobs/regular/emit_web_hook_event.rb +++ b/app/jobs/regular/emit_web_hook_event.rb @@ -51,25 +51,25 @@ module Jobs end def setup_group(args) - group = Group.find(args[:group_id]) + group = Group.find_by(args[:group_id]) return if group.blank? args[:payload] = WebHookGroupSerializer.new(group, scope: guardian, root: false).as_json end def setup_category(args) - category = Category.find(args[:category_id]) + category = Category.find_by(args[:category_id]) return if category.blank? args[:payload] = WebHookCategorySerializer.new(category, scope: guardian, root: false).as_json end def setup_tag(args) - tag = Tag.find(args[:tag_id]) + tag = Tag.find_by(args[:tag_id]) return if tag.blank? args[:payload] = TagSerializer.new(tag, scope: guardian, root: false).as_json end def setup_flag(args) - flag = PostAction.find(args[:flag_id]) + flag = PostAction.find_by(args[:flag_id]) return if flag.blank? args[:payload] = WebHookFlagSerializer.new(flag, scope: guardian, root: false).as_json end From 531baec5b585a7a82365a5733e8be5298fa1019d Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 18 May 2018 13:37:39 +0800 Subject: [PATCH 034/278] Remove unused fabricator. --- spec/fabricators/web_hook_fabricator.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/spec/fabricators/web_hook_fabricator.rb b/spec/fabricators/web_hook_fabricator.rb index f44952891e..9f9ac23a94 100644 --- a/spec/fabricators/web_hook_fabricator.rb +++ b/spec/fabricators/web_hook_fabricator.rb @@ -21,9 +21,6 @@ Fabricator(:wildcard_web_hook, from: :web_hook) do wildcard_web_hook true end -Fabricator(:post_web_hook, from: :web_hook) do -end - Fabricator(:topic_web_hook, from: :web_hook) do transient topic_hook: WebHookEventType.find_by(name: 'topic') From 1ff767559df19c929c68a7d5164190aa7728750d Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 18 May 2018 14:05:21 +0800 Subject: [PATCH 035/278] Fix incorrect param. --- app/jobs/regular/emit_web_hook_event.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/jobs/regular/emit_web_hook_event.rb b/app/jobs/regular/emit_web_hook_event.rb index 6116fabb33..0b5b1adf89 100644 --- a/app/jobs/regular/emit_web_hook_event.rb +++ b/app/jobs/regular/emit_web_hook_event.rb @@ -51,25 +51,25 @@ module Jobs end def setup_group(args) - group = Group.find_by(args[:group_id]) + group = Group.find_by(id: args[:group_id]) return if group.blank? args[:payload] = WebHookGroupSerializer.new(group, scope: guardian, root: false).as_json end def setup_category(args) - category = Category.find_by(args[:category_id]) + category = Category.find_by(id: args[:category_id]) return if category.blank? args[:payload] = WebHookCategorySerializer.new(category, scope: guardian, root: false).as_json end def setup_tag(args) - tag = Tag.find_by(args[:tag_id]) + tag = Tag.find_by(id: args[:tag_id]) return if tag.blank? args[:payload] = TagSerializer.new(tag, scope: guardian, root: false).as_json end def setup_flag(args) - flag = PostAction.find_by(args[:flag_id]) + flag = PostAction.find_by(id: args[:flag_id]) return if flag.blank? args[:payload] = WebHookFlagSerializer.new(flag, scope: guardian, root: false).as_json end From b9dfb0ba10cd6b5e66f3a26c138ba61e4223f062 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 18 May 2018 15:01:36 +0800 Subject: [PATCH 036/278] Partially revert https://github.com/discourse/discourse/commit/531baec5b585a7a82365a5733e8be5298fa1019d. --- spec/fabricators/web_hook_fabricator.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/fabricators/web_hook_fabricator.rb b/spec/fabricators/web_hook_fabricator.rb index 9f9ac23a94..c7632cd2d0 100644 --- a/spec/fabricators/web_hook_fabricator.rb +++ b/spec/fabricators/web_hook_fabricator.rb @@ -29,6 +29,14 @@ Fabricator(:topic_web_hook, from: :web_hook) do end end +Fabricator(:post_web_hook, from: :web_hook) do + transient topic_hook: WebHookEventType.find_by(name: 'post') + + after_build do |web_hook, transients| + web_hook.web_hook_event_types = [transients[:post_hook]] + end +end + Fabricator(:user_web_hook, from: :web_hook) do transient user_hook: WebHookEventType.find_by(name: 'user') From c168639be21857a928db44f66148b81268107e42 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Fri, 18 May 2018 09:09:21 +0100 Subject: [PATCH 037/278] Add plugin outlet in admin theme list (#5837) This will be used by discourse-theme-creator to add avatars next to each theme. --- app/assets/javascripts/admin/templates/customize-themes.hbs | 1 + app/models/theme.rb | 1 + app/serializers/theme_serializer.rb | 2 ++ 3 files changed, 4 insertions(+) diff --git a/app/assets/javascripts/admin/templates/customize-themes.hbs b/app/assets/javascripts/admin/templates/customize-themes.hbs index e2f9f8b99a..9164492264 100644 --- a/app/assets/javascripts/admin/templates/customize-themes.hbs +++ b/app/assets/javascripts/admin/templates/customize-themes.hbs @@ -5,6 +5,7 @@ {{#each sortedThemes as |theme|}}
  • {{#link-to 'adminCustomizeThemes.show' theme replace=true}} + {{plugin-outlet name="admin-customize-themes-list-item" connectorTagName='span' args=(hash theme=theme)}} {{theme.name}} {{#if theme.user_selectable}} {{d-icon "user"}} diff --git a/app/models/theme.rb b/app/models/theme.rb index 024218ae93..5dfcd54b3e 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -8,6 +8,7 @@ class Theme < ActiveRecord::Base @cache = DistributedCache.new('theme') + belongs_to :user belongs_to :color_scheme has_many :theme_fields, dependent: :destroy has_many :theme_settings, dependent: :destroy diff --git a/app/serializers/theme_serializer.rb b/app/serializers/theme_serializer.rb index 8cf183b4a8..4938b5fac9 100644 --- a/app/serializers/theme_serializer.rb +++ b/app/serializers/theme_serializer.rb @@ -58,6 +58,8 @@ end class ThemeSerializer < ChildThemeSerializer attributes :color_scheme, :color_scheme_id, :user_selectable, :remote_theme_id, :settings + has_one :user, serializer: UserNameSerializer, embed: :object + has_many :theme_fields, serializer: ThemeFieldSerializer, embed: :objects has_many :child_themes, serializer: ChildThemeSerializer, embed: :objects has_one :remote_theme, serializer: RemoteThemeSerializer, embed: :objects From 0800098f1a895e6dfdc56c351233b01b83dfe619 Mon Sep 17 00:00:00 2001 From: OsamaSayegh Date: Fri, 18 May 2018 11:11:08 +0300 Subject: [PATCH 038/278] FIX: don't allow duplicate watched words (#5844) We already have logic in place for server side, this'll just display a little message that says the word already exists --- .../admin/components/watched-word-form.js.es6 | 22 ++++++++++++++----- .../components/watched-word-form.hbs | 4 ++-- .../admin/templates/watched-words-action.hbs | 1 + config/locales/client.en.yml | 1 + 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/admin/components/watched-word-form.js.es6 b/app/assets/javascripts/admin/components/watched-word-form.js.es6 index 9b2213de61..e7ead641f2 100644 --- a/app/assets/javascripts/admin/components/watched-word-form.js.es6 +++ b/app/assets/javascripts/admin/components/watched-word-form.js.es6 @@ -5,7 +5,7 @@ export default Ember.Component.extend({ classNames: ['watched-word-form'], formSubmitted: false, actionKey: null, - showSuccessMessage: false, + showMessage: false, @computed('regularExpressions') placeholderKey(regularExpressions) { @@ -14,21 +14,33 @@ export default Ember.Component.extend({ }, @observes('word') - removeSuccessMessage() { - if (this.get('showSuccessMessage') && !Ember.isEmpty(this.get('word'))) { - this.set('showSuccessMessage', false); + removeMessage() { + if (this.get('showMessage') && !Ember.isEmpty(this.get('word'))) { + this.set('showMessage', false); } }, + @computed('word') + isUniqueWord(word) { + const words = this.get("filteredContent") || []; + const filtered = words.filter(content => content.action === this.get("actionKey")); + return filtered.every(content => content.word.toLowerCase() !== word.toLowerCase()); + }, + actions: { submit() { + if (!this.get("isUniqueWord")) { + this.setProperties({ showMessage: true, message: I18n.t('admin.watched_words.form.exists') }); + return; + } + if (!this.get('formSubmitted')) { this.set('formSubmitted', true); const watchedWord = WatchedWord.create({ word: this.get('word'), action: this.get('actionKey') }); watchedWord.save().then(result => { - this.setProperties({ word: '', formSubmitted: false, showSuccessMessage: true }); + this.setProperties({ word: '', formSubmitted: false, showMessage: true, message: I18n.t('admin.watched_words.form.success') }); this.sendAction('action', WatchedWord.create(result)); Ember.run.schedule('afterRender', () => this.$('.watched-word-input').focus()); }).catch(e => { diff --git a/app/assets/javascripts/admin/templates/components/watched-word-form.hbs b/app/assets/javascripts/admin/templates/components/watched-word-form.hbs index 7d2b460aa0..e4b2fab801 100644 --- a/app/assets/javascripts/admin/templates/components/watched-word-form.hbs +++ b/app/assets/javascripts/admin/templates/components/watched-word-form.hbs @@ -2,6 +2,6 @@ {{text-field value=word disabled=formSubmitted class="watched-word-input" autocorrect="off" autocapitalize="off" placeholderKey=placeholderKey}} {{d-button action="submit" disabled=formSubmitted label="admin.watched_words.form.add"}} -{{#if showSuccessMessage}} - {{i18n 'admin.watched_words.form.success'}} +{{#if showMessage}} + {{message}} {{/if}} diff --git a/app/assets/javascripts/admin/templates/watched-words-action.hbs b/app/assets/javascripts/admin/templates/watched-words-action.hbs index ac0bc4175b..1cf0f77cd9 100644 --- a/app/assets/javascripts/admin/templates/watched-words-action.hbs +++ b/app/assets/javascripts/admin/templates/watched-words-action.hbs @@ -5,6 +5,7 @@ {{watched-word-form actionKey=actionNameKey action="recordAdded" + filteredContent=filteredContent regularExpressions=adminWatchedWords.regularExpressions}} {{watched-word-uploader uploading=uploading actionKey=actionNameKey done="uploadComplete"}} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 65f78257c4..76ba48799a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3474,6 +3474,7 @@ en: placeholder_regexp: "regular expression" add: 'Add' success: 'Success' + exists: 'Already exists' upload: "Upload" upload_successful: "Upload successful. Words have been added." From 3287f47f07dfab030e4d32bb5de49c07f3c528f0 Mon Sep 17 00:00:00 2001 From: Orlando Del Aguila Date: Fri, 18 May 2018 03:12:40 -0500 Subject: [PATCH 039/278] nodebb importer (#5842) --- script/import_scripts/nodebb/nodebb.rb | 527 +++++++++++++++++++++++++ script/import_scripts/nodebb/redis.rb | 109 +++++ 2 files changed, 636 insertions(+) create mode 100644 script/import_scripts/nodebb/nodebb.rb create mode 100644 script/import_scripts/nodebb/redis.rb diff --git a/script/import_scripts/nodebb/nodebb.rb b/script/import_scripts/nodebb/nodebb.rb new file mode 100644 index 0000000000..1c9acfef22 --- /dev/null +++ b/script/import_scripts/nodebb/nodebb.rb @@ -0,0 +1,527 @@ +require_relative '../base.rb' +require_relative './redis' + +class ImportScripts::NodeBB < ImportScripts::Base + # CHANGE THESE BEFORE RUNNING THE IMPORTER + # ATTACHMENT_DIR needs to be absolute, not relative path + ATTACHMENT_DIR = '/Users/orlando/www/orlando/NodeBB/public/uploads' + BATCH_SIZE = 2000 + + def initialize + super + + adapter = NodeBB::Redis + + @client = adapter.new( + host: "localhost", + port: "6379", + db: 0 + ) + + load_merged_posts + end + + def load_merged_posts + puts 'loading merged posts with topics...' + + # we keep here the posts that were merged + # as topics + # + # { post_id: discourse_post_id } + @merged_posts_map = {} + + PostCustomField.where(name: 'import_merged_post_id').pluck(:post_id, :value).each do |post_id, import_id| + post = Post.find(post_id) + topic_id = post.topic_id + nodebb_post_id = post.custom_fields['import_merged_post_id'] + + @merged_posts_map[nodebb_post_id] = topic_id + end + end + + def execute + import_groups + import_categories + import_users + add_users_to_groups + import_topics + import_posts + import_attachments + post_process_posts + end + + def import_groups + puts '', 'importing groups' + + groups = @client.groups + total_count = groups.count + progress_count = 0 + start_time = Time.now + + create_groups(groups) do |group| + { + id: group["name"], + name: group["slug"] + } + end + end + + def import_categories + puts "", "importing top level categories..." + + category_map = @client.categories + category_ids = category_map.keys + categories = category_map.values + + top_level_categories = categories.select { |c| c["parentCid"] == "0" } + + create_categories(top_level_categories) do |category| + { + id: category["cid"], + name: category["name"], + position: category["order"], + description: category["description"], + } + end + + puts "", "importing child categories..." + + children_categories = categories.select { |c| c["parentCid"] != "0" } + top_level_category_ids = Set.new(top_level_categories.map { |c| c["cid"] }) + + # cut down the tree to only 2 levels of categories + children_categories.each do |cc| + while !top_level_category_ids.include?(cc["parentCid"]) + cc["parentCid"] = categories.detect { |c| c["cid"] == cc["parentCid"] }["parentCid"] + end + end + + create_categories(children_categories) do |category| + { + id: category["cid"], + name: category["name"], + position: category["order"], + description: category["description"], + parent_category_id: category_id_from_imported_category_id(category["parentCid"]) + } + end + end + + def import_users + puts "", "importing users" + + users = @client.users + user_count = users.count + + # we use this group to grant admin to users + admin_group = @client.group("administrators") + + create_users(users, total: user_count) do |user| + username = user["username"] + email = user["email"] + + # skip users without username + next unless username + + # fake email for users without email + email = fake_email if email.blank? + + # use user.suspended to handle banned users + if user["banned"] == "1" + suspended_at = Time.now + suspended_till = Time.now + 100.years + end + + { + id: user["uid"], + name: user["fullname"], + username: username, + email: email, + admin: admin_group["member_ids"].include?(user["uid"]), + website: user["website"], + location: user["location"], + suspended_at: suspended_at, + suspended_till: suspended_till, + primary_group_id: group_id_from_imported_group_id(user["groupTitle"]), + created_at: user["joindate"], + custom_fields: { + import_pass: user["password"] + }, + post_create_action: proc do |u| + import_profile_picture(user, u) + import_profile_background(user, u) + end + } + end + end + + def import_profile_picture(old_user, imported_user) + picture = old_user["picture"] + + return if picture.blank? + + # URI.scheme returns nil for internal URLs + uri = URI.parse(picture) + is_external = uri.scheme + + if is_external + # download external image + begin + string_io = open(picture, read_timeout: 5) + rescue Net::ReadTimeout + puts "timeout downloading avatar for user #{imported_user.id}" + return nil + end + + # continue if download failed + return unless string_io + + # try to get filename from headers + if string_io.meta["content-disposition"] + filename = string_io.meta["content-disposition"].match(/filename=(\"?)(.+)\1/)[2] + end + + # try to get it from path + filename = File.basename(picture) unless filename + + # can't determine filename, skip upload + if !filename + puts "Can't determine filename, skipping avatar upload for user #{imported_user.id}" + return + end + + # write tmp file + file = Tempfile.new(filename, encoding: 'ascii-8bit') + file.write string_io.read + file.rewind + + upload = UploadCreator.new(file, filename).create_for(imported_user.id) + else + # remove "/assets/uploads/" from attachment + picture = picture.gsub("/assets/uploads", "") + filepath = File.join(ATTACHMENT_DIR, picture) + filename = File.basename(picture) + + unless File.exists?(filepath) + puts "Avatar file doesn't exist: #{filename}" + return nil + end + + upload = create_upload(imported_user.id, filepath, filename) + end + + return if !upload.persisted? + + imported_user.create_user_avatar + imported_user.user_avatar.update(custom_upload_id: upload.id) + imported_user.update(uploaded_avatar_id: upload.id) + ensure + string_io.close rescue nil + file.close rescue nil + file.unlind rescue nil + end + + def import_profile_background(old_user, imported_user) + picture = old_user["cover:url"] + + return if picture.blank? + + # URI returns nil for invalid URLs + uri = URI.parse(picture) + is_external = uri.scheme + + if is_external + begin + string_io = open(picture, read_timeout: 5) + rescue Net::ReadTimeout + return nil + end + + if string_io.meta["content-disposition"] + filename = string_io.meta["content-disposition"].match(/filename=(\"?)(.+)\1/)[2] + end + + filename = File.basename(picture) unless filename + + # can't determine filename, skip upload + if !filename + puts "Can't determine filename, skipping background upload for user #{imported_user.id}" + return + end + + # write tmp file + file = Tempfile.new(filename, encoding: 'ascii-8bit') + file.write string_io.read + file.rewind + + upload = UploadCreator.new(file, filename).create_for(imported_user.id) + else + # remove "/assets/uploads/" from attachment + picture = picture.gsub("/assets/uploads", "") + filepath = File.join(ATTACHMENT_DIR, picture) + filename = File.basename(picture) + + unless File.exists?(filepath) + puts "Background file doesn't exist: #{filename}" + return nil + end + + upload = create_upload(imported_user.id, filepath, filename) + end + + return if !upload.persisted? + + imported_user.user_profile.update(profile_background: upload.url) + ensure + string_io.close rescue nil + file.close rescue nil + file.unlink rescue nil + end + + def add_users_to_groups + puts "", "adding users to groups..." + + groups = @client.groups + total_count = groups.count + progress_count = 0 + start_time = Time.now + + @client.groups.each do |group| + dgroup = find_group_by_import_id(group["name"]) + + # do thing if we migrated this group already + next if dgroup.custom_fields['import_users_added'] + + group_member_ids = group["member_ids"].map { |uid| user_id_from_imported_user_id(uid) } + group_owner_ids = group["owner_ids"].map { |uid| user_id_from_imported_user_id(uid) } + + # add members + dgroup.bulk_add(group_member_ids) + + # reload group + dgroup.reload + + # add owners + owners = User.find(group_owner_ids) + owners.each { |owner| dgroup.add_owner(owner) } + + dgroup.custom_fields['import_users_added'] = true + dgroup.save + + progress_count += 1 + print_status(progress_count, total_count, start_time) + end + end + + def import_topics + puts "", "importing topics..." + + topic_count = @client.topic_count + + batches(BATCH_SIZE) do |offset| + topics = @client.topics(offset, BATCH_SIZE) + + break if topics.size < 1 + + create_posts(topics, total: topic_count, offset: offset) do |topic| + # skip if is deleted + if topic["deleted"] == "1" + puts "Topic with id #{topic["tid"]} was deleted, skipping" + next + end + + topic_id = "t#{topic["tid"]}" + raw = topic["mainpost"]["content"] + + data = { + id: topic_id, + user_id: user_id_from_imported_user_id(topic["uid"]) || Discourse::SYSTEM_USER_ID, + title: topic["title"], + category: category_id_from_imported_category_id(topic["cid"]), + raw: raw, + created_at: topic["timestamp"], + views: topic["viewcount"], + closed: topic["locked"] == "1", + post_create_action: proc do |p| + # keep track of this to use in import_posts + p.custom_fields["import_merged_post_id"] = topic["mainPid"] + p.save + @merged_posts_map[topic["mainPid"]] = p.id + end + } + + data[:pinned_at] = data[:created_at] if topic["pinned"] == "1" + + data + end + end + end + + def import_posts + puts "", "importing posts..." + + post_count = @client.post_count + + batches(BATCH_SIZE) do |offset| + posts = @client.posts(offset, BATCH_SIZE) + + break if posts.size < 1 + + create_posts(posts, total: post_count, offset: offset) do |post| + # skip if it's merged_post + next if @merged_posts_map[post["pid"]] + + # skip if it's deleted + next if post["deleted"] == "1" + + raw = post["content"] + post_id = "p#{post["pid"]}" + + next if raw.blank? + topic = topic_lookup_from_imported_post_id("t#{post["tid"]}") + + unless topic + puts "Topic with id #{post["tid"]} not found, skipping" + next + end + + data = { + id: post_id, + user_id: user_id_from_imported_user_id(post["uid"]) || Discourse::SYSTEM_USER_ID, + topic_id: topic[:topic_id], + raw: raw, + created_at: post["timestamp"], + post_create_action: proc do |p| + post["upvoted_by"].each do |upvoter_id| + user = User.new + user.id = user_id_from_imported_user_id(upvoter_id) || Discourse::SYSTEM_USER_ID + + begin + PostAction.act(user, p, PostActionType.types[:like]) + rescue PostAction::AlreadyActed + end + end + end + } + + if post['toPid'] + # Look reply to topic + parent_id = topic_lookup_from_imported_post_id("t#{post['toPid']}").try(:[], :post_number) + + # Look reply post if topic is missing + parent_id ||= topic_lookup_from_imported_post_id("p#{post['toPid']}").try(:[], :post_number) + + if parent_id + data[:reply_to_post_number] = parent_id + else + puts "Post with id #{post["toPid"]} not found for reply" + end + end + + data + end + end + end + + def post_process_posts + puts "", "Postprocessing posts..." + + current = 0 + max = Post.count + start_time = Time.now + + Post.find_each do |post| + begin + next if post.custom_fields['import_post_processing'] + + new_raw = postprocess_post(post) + if new_raw != post.raw + post.raw = new_raw + post.custom_fields['import_post_processing'] = true + post.save + end + ensure + print_status(current += 1, max, start_time) + end + end + end + + def import_attachments + puts '', 'importing attachments...' + + current = 0 + max = Post.count + start_time = Time.now + + Post.find_each do |post| + current += 1 + print_status(current, max, start_time) + + new_raw = post.raw.dup + new_raw.gsub!(/\[(.*)\]\((\/assets\/uploads\/files\/.*)\)/) do + image_md = Regexp.last_match[0] + text, filepath = $1, $2 + filepath = filepath.gsub("/assets/uploads", ATTACHMENT_DIR) + + # if file exists + # upload attachment and return html for it + if File.exists?(filepath) + filename = File.basename(filepath) + upload = create_upload(post.user_id, filepath, filename) + + html_for_upload(upload, filename) + else + puts "File with path #{filepath} not found for post #{post.id}, upload will be broken" + image_md + end + end + + if new_raw != post.raw + PostRevisor.new(post).revise!(post.user, { raw: new_raw }, bypass_bump: true, edit_reason: 'Import attachments from NodeBB') + end + end + end + + def postprocess_post(post) + raw = post.raw + + # [link to post](/post/:id) + raw = raw.gsub(/\[(.*)\]\(\/post\/(\d+).*\)/) do + text, post_id = $1, $2 + + if topic_lookup = topic_lookup_from_imported_post_id("p#{post_id}") + url = topic_lookup[:url] + "[#{text}](#{url})" + else + "/404" + end + end + + # [link to topic](/topic/:id) + raw = raw.gsub(/\[(.*)\]\(\/topic\/(\d+).*\)/) do + text, topic_id = $1, $2 + + if topic_lookup = topic_lookup_from_imported_post_id("t#{topic_id}") + url = topic_lookup[:url] + "[#{text}](#{url})" + else + "/404" + end + end + + # @username with dash to underscore + raw = raw.gsub(/@([a-zA-Z0-9-]+)/) do + username = $1 + + username.gsub('-', '_') + end + + raw + end + + def fake_email + SecureRandom.hex << "@domain.com" + end +end + +ImportScripts::NodeBB.new.perform diff --git a/script/import_scripts/nodebb/redis.rb b/script/import_scripts/nodebb/redis.rb new file mode 100644 index 0000000000..e77e33f357 --- /dev/null +++ b/script/import_scripts/nodebb/redis.rb @@ -0,0 +1,109 @@ +require 'redis' + +module NodeBB + class Redis + attr_reader :redis + + def initialize(params) + @redis = ::Redis.new(params) + end + + def groups + group_keys = redis.zrange('groups:visible:createtime', 0, -1) + + group_keys.map { |group_key| group(group_key) } + end + + def group(id) + group = redis.hgetall("group:#{id}") + group["createtime"] = timestamp_to_date(group["createtime"]) + group["member_ids"] = redis.zrange("group:#{id}:members", 0, -1) + group["owner_ids"] = redis.smembers("group:#{id}:owners") + + group + end + + def users + user_keys = redis.zrange('users:joindate', 0, -1) + + user_keys.map { |user_key| user(user_key) } + end + + def user(id) + user = redis.hgetall("user:#{id}") + + user["joindate"] = timestamp_to_date(user["joindate"]) + user["lastonline"] = timestamp_to_date(user["lastonline"]) + + user + end + + def categories + category_keys = redis.zrange('categories:cid', 0, -1) + + {}.tap do |categories| + category_keys.each do |category_key| + category = redis.hgetall("category:#{category_key}") + + categories[category['cid']] = category + end + end + end + + def topics(offset = 0, page_size = 2000) + # redis get keys inclusive + # so we move the offset a bit to continue in the next item + offset = offset + 1 unless offset == 0 + from = offset + to = page_size + offset + + topic_keys = redis.zrange('topics:tid', from, to) + + topic_keys.map { |topic_key| topic(topic_key) } + end + + def topic(id) + topic = redis.hgetall("topic:#{id}") + + topic["lastposttime"] = timestamp_to_date(topic["lastposttime"]) + topic["timestamp"] = timestamp_to_date(topic["timestamp"]) + topic["mainpost"] = post(topic["mainPid"]) + + topic + end + + def topic_count + redis.zcard('topics:tid') + end + + def posts(offset = 0, page_size = 2000) + # redis get keys inclusive + # so we move the offset a bit to continue in the next item + offset = offset + 1 unless offset == 0 + from = offset + to = page_size + offset + + post_keys = redis.zrange('posts:pid', from, to) + + post_keys.map { |post_key| post(post_key) } + end + + def post(id) + post = redis.hgetall("post:#{id}") + post["timestamp"] = timestamp_to_date(post["timestamp"]) + post["upvoted_by"] = redis.smembers("pid:#{id}:upvote") + + post + end + + def post_count + redis.zcard('posts:pid') + end + + private + + def timestamp_to_date(createtime) + Time.at(createtime[0..-4].to_i).utc if createtime + end + end +end From 2fecb4b46e7fea0e2e3cb2adbbfe83b6a77f1a23 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 18 May 2018 16:30:59 +0800 Subject: [PATCH 040/278] FIX: Client not setting `TopicTimer#based_on_last_post`. https://meta.discourse.org/t/incorrect-message-key-in-temporarily-closed-topic/87773 --- .../discourse/components/edit-topic-timer-form.js.es6 | 9 ++++++++- .../discourse/components/topic-timer-info.js.es6 | 1 - .../templates/components/edit-topic-timer-form.hbs | 4 ++-- .../components/future-date-input-selector.js.es6 | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/components/edit-topic-timer-form.js.es6 b/app/assets/javascripts/discourse/components/edit-topic-timer-form.js.es6 index 421972b834..4b853e541d 100644 --- a/app/assets/javascripts/discourse/components/edit-topic-timer-form.js.es6 +++ b/app/assets/javascripts/discourse/components/edit-topic-timer-form.js.es6 @@ -46,5 +46,12 @@ export default Ember.Component.extend({ } this.set("topicTimer.updateTime", time); - } + }, + + @observes("selection") + _updateBasedOnLastPost() { + if (!this.get('autoClose')) { + this.set('topicTimer.based_on_last_post', false); + } + }, }); diff --git a/app/assets/javascripts/discourse/components/topic-timer-info.js.es6 b/app/assets/javascripts/discourse/components/topic-timer-info.js.es6 index 98c9ad1c30..e0e8fb9142 100644 --- a/app/assets/javascripts/discourse/components/topic-timer-info.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-timer-info.js.es6 @@ -18,7 +18,6 @@ export default Ember.Component.extend(bufferedRender({ if (!this.get('executeAt')) return; let statusUpdateAt = moment(this.get('executeAt')); - if (statusUpdateAt < new Date()) return; let duration = moment.duration(statusUpdateAt - moment()); let minutesLeft = duration.asMinutes(); diff --git a/app/assets/javascripts/discourse/templates/components/edit-topic-timer-form.hbs b/app/assets/javascripts/discourse/templates/components/edit-topic-timer-form.hbs index f90534931b..52f618fec9 100644 --- a/app/assets/javascripts/discourse/templates/components/edit-topic-timer-form.hbs +++ b/app/assets/javascripts/discourse/templates/components/edit-topic-timer-form.hbs @@ -9,7 +9,7 @@ input=topicTimer.updateTime statusType=selection includeWeekend=true - basedOnLastPost=false}} + basedOnLastPost=topicTimer.based_on_last_post}} {{else if publishToCategory}}
    @@ -23,7 +23,7 @@ statusType=selection includeWeekend=true categoryId=topicTimer.category_id - basedOnLastPost=false}} + basedOnLastPost=topicTimer.based_on_last_post}} {{else if autoClose}} {{future-date-input input=topicTimer.updateTime diff --git a/app/assets/javascripts/select-kit/components/future-date-input-selector.js.es6 b/app/assets/javascripts/select-kit/components/future-date-input-selector.js.es6 index 580c351bb4..66b378d891 100644 --- a/app/assets/javascripts/select-kit/components/future-date-input-selector.js.es6 +++ b/app/assets/javascripts/select-kit/components/future-date-input-selector.js.es6 @@ -155,7 +155,7 @@ export default ComboBoxComponent.extend(DatetimeMixin, { }, mutateValue(value) { - if (this.get("isCustom") || this.get("isBasedOnLastPost")) { + if (value === 'pick_date_and_time' || this.get("isBasedOnLastPost")) { this.set("value", value); } else { let input = null; From 9512796ef658dc8d4edf78d3b604dca572a052a3 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Fri, 18 May 2018 16:59:40 +0530 Subject: [PATCH 041/278] FIX: check for blank response when polling feed --- app/jobs/scheduled/poll_feed.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/jobs/scheduled/poll_feed.rb b/app/jobs/scheduled/poll_feed.rb index 17f210f240..590241f298 100644 --- a/app/jobs/scheduled/poll_feed.rb +++ b/app/jobs/scheduled/poll_feed.rb @@ -87,8 +87,9 @@ module Jobs private def parsed_feed - raw_feed = fetch_rss.encode("UTF-8", invalid: :replace, undef: :replace, replace: "") + raw_feed = fetch_rss return nil if raw_feed.blank? + raw_feed.encode!("UTF-8", invalid: :replace, undef: :replace, replace: "") if SiteSetting.embed_username_key_from_feed.present? FeedElementInstaller.install(SiteSetting.embed_username_key_from_feed, raw_feed) From 9d4d6276b7bb44d3383e5b3a1e5f449f116c07d6 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Fri, 18 May 2018 17:11:20 +0530 Subject: [PATCH 042/278] Import user profile fields and avatars --- script/import_scripts/lithium.rb | 85 ++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/script/import_scripts/lithium.rb b/script/import_scripts/lithium.rb index 0b15b58bd1..9650b7e2ca 100644 --- a/script/import_scripts/lithium.rb +++ b/script/import_scripts/lithium.rb @@ -27,12 +27,26 @@ class ImportScripts::Lithium < ImportScripts::Base # CHANGE THESE BEFORE RUNNING THE IMPORTER DATABASE = "wd" PASSWORD = "password" + AVATARS_DIR = '/tmp/avatars' UPLOAD_DIR = '/tmp/uploads' OLD_DOMAIN = 'community.wd.com' TEMP = "" + USER_CUSTOM_FIELDS = [ + { name: "sso_id", user: "sso_id" }, + { name: "user_field_1", profile: "jobtitle" }, + { name: "user_field_2", profile: "company" }, + { name: "user_field_3", profile: "industry" }, + ] + + LITHIUM_PROFILE_FIELDS = "'profile.jobtitle', 'profile.company', 'profile.industry', 'profile.location'" + + USERNAME_MAPPINGS = { + "admins": "admin_user" + }.with_indifferent_access + def initialize super @@ -87,10 +101,11 @@ class ImportScripts::Lithium < ImportScripts::Base puts "", "importing users" user_count = mysql_query("SELECT COUNT(*) count FROM users").first["count"] + avatar_files = Dir.entries(AVATARS_DIR) batches(BATCH_SIZE) do |offset| users = mysql_query <<-SQL - SELECT id, nlogin, login_canon, email, registration_time + SELECT id, nlogin, login_canon, email, registration_time, sso_id FROM users ORDER BY id LIMIT #{BATCH_SIZE} @@ -101,25 +116,87 @@ class ImportScripts::Lithium < ImportScripts::Base next if all_records_exist? :users, users.map { |u| u["id"].to_i } + users = users.to_a + first_id = users.first["id"] + last_id = users.last["id"] + + profiles = mysql_query <<-SQL + SELECT user_id, param, nvalue + FROM user_profile + WHERE nvalue IS NOT NULL AND param IN (#{LITHIUM_PROFILE_FIELDS}) AND user_id >= #{first_id} AND user_id <= #{last_id} + ORDER BY user_id + SQL + create_users(users, total: user_count, offset: offset) do |user| + profile = profiles.select { |p| p["user_id"] == user["id"] } + result = profile.select { |p| p["param"] == "profile.location" } + location = result.count > 0 ? result.first["nvalue"] : nil + username = user["login_canon"] + username = USERNAME_MAPPINGS[username] if USERNAME_MAPPINGS[username].present? { id: user["id"], name: user["nlogin"], - username: user["login-canon"], + username: username, email: user["email"].presence || fake_email, + location: location, + custom_fields: user_custom_fields(user, profile), # website: user["homepage"].strip, # title: @htmlentities.decode(user["usertitle"]).strip, # primary_group_id: group_id_from_imported_group_id(user["usergroupid"]), created_at: unix_time(user["registration_time"]), post_create_action: proc do |u| - @old_username_to_new_usernames[user["username"]] = u.username + @old_username_to_new_usernames[user["login_canon"]] = u.username + + # import user avatar + sso_id = u.custom_fields["sso_id"] + if sso_id.present? + prefix = "#{UPLOAD_DIR}/#{sso_id}_" + file = get_file(prefix + "actual.jpeg") + file ||= get_file(prefix + "profile.jpeg") + + if file.present? + upload = UploadCreator.new(file, file.path, type: "avatar").create_for(user.id) + user.create_user_avatar unless user.user_avatar + + if !user.user_avatar.contains_upload?(upload.id) + user.user_avatar.update_columns(custom_upload_id: upload.id) + + if user.uploaded_avatar_id.nil? || + !user.user_avatar.contains_upload?(user.uploaded_avatar_id) + user.update_columns(uploaded_avatar_id: upload.id) + end + end + end + end end } end end end + def user_custom_fields(user, profile) + fields = Hash.new + + USER_CUSTOM_FIELDS.each do |attr| + name = attr[:name] + + if attr[:user].present? + fields[name] = user[attr[:user]] + elsif attr[:profile].present? && profile.count > 0 + result = profile.select { |p| p["param"] == "profile.#{attr[:profile]}" } + fields[name] = result.first["nvalue"] if result.count > 0 + end + end + + fields + end + + def get_file(path) + return File.open(path) if File.exist?(path) + nil + end + def unix_time(t) Time.at(t / 1000.0) end @@ -265,7 +342,7 @@ class ImportScripts::Lithium < ImportScripts::Base SELECT id, subject, body, deleted, user_id, post_date, views, node_id, unique_id FROM message2 - WHERE id = root_id #{TEMP} AND deleted = 0 + WHERE id = root_id #{TEMP} ORDER BY node_id, id LIMIT #{BATCH_SIZE} OFFSET #{offset} From 290ee312e673c81f0b7ff3c1433a0ecb9801044b Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Fri, 18 May 2018 18:01:36 +0530 Subject: [PATCH 043/278] FIX: handle invalid mailto links --- app/models/upload.rb | 2 +- spec/models/upload_spec.rb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/upload.rb b/app/models/upload.rb index a990795492..754e5d7f69 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -85,7 +85,7 @@ class Upload < ActiveRecord::Base # always try to get the path uri = begin URI(URI.unescape(url)) - rescue URI::InvalidURIError + rescue URI::InvalidURIError, URI::InvalidComponentError end url = uri.path if uri.try(:scheme) diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb index a7c0d4ab0e..32b86bbc18 100644 --- a/spec/models/upload_spec.rb +++ b/spec/models/upload_spec.rb @@ -88,6 +88,7 @@ describe Upload do it "doesn't blow up with an invalid URI" do expect { Upload.get_from_url("http://ip:port/index.html") }.not_to raise_error expect { Upload.get_from_url("mailto:admin%40example.com") }.not_to raise_error + expect { Upload.get_from_url("mailto:example") }.not_to raise_error end describe "s3 store" do From 9f92fdded0830b7973e38a6e73a41e6d48beb335 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Fri, 18 May 2018 18:53:18 +0530 Subject: [PATCH 044/278] Improvements in lithium topic and post import --- script/import_scripts/lithium.rb | 54 +++++++++++++++++++------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/script/import_scripts/lithium.rb b/script/import_scripts/lithium.rb index 9650b7e2ca..b6d915b15f 100644 --- a/script/import_scripts/lithium.rb +++ b/script/import_scripts/lithium.rb @@ -340,7 +340,7 @@ class ImportScripts::Lithium < ImportScripts::Base batches(BATCH_SIZE) do |offset| topics = mysql_query <<-SQL SELECT id, subject, body, deleted, user_id, - post_date, views, node_id, unique_id + post_date, views, node_id, unique_id, row_version FROM message2 WHERE id = root_id #{TEMP} ORDER BY node_id, id @@ -355,10 +355,10 @@ class ImportScripts::Lithium < ImportScripts::Base create_posts(topics, total: topic_count, offset: offset) do |topic| category_id = category_id_from_imported_category_id(topic["node_id"]) - + deleted_at = topic["deleted"] == 1 ? topic["row_version"] : nil raw = topic["body"] - if category_id + if category_id.present? && raw.present? { id: "#{topic["node_id"]} #{topic["id"]}", user_id: user_id_from_imported_user_id(topic["user_id"]) || Discourse::SYSTEM_USER_ID, @@ -366,11 +366,16 @@ class ImportScripts::Lithium < ImportScripts::Base category: category_id, raw: raw, created_at: unix_time(topic["post_date"]), + deleted_at: deleted_at, views: topic["views"], custom_fields: { import_unique_id: topic["unique_id"] }, import_mode: true } else + message = "Unknown" + message = "Category '#{category_id}' not exist" if category_id.blank? + message = "Topic 'body' is empty" if raw.blank? + PluginStoreRow.create(plugin_name: "topic_import_log", key: topic["unique_id"].to_s, value: message) nil end @@ -388,9 +393,9 @@ class ImportScripts::Lithium < ImportScripts::Base batches(BATCH_SIZE) do |offset| posts = mysql_query <<-SQL SELECT id, body, deleted, user_id, - post_date, parent_id, root_id, node_id, unique_id + post_date, parent_id, root_id, node_id, unique_id, row_version FROM message2 - WHERE id <> root_id #{TEMP} AND deleted = 0 + WHERE id <> root_id #{TEMP} ORDER BY node_id, root_id, id LIMIT #{BATCH_SIZE} OFFSET #{offset} @@ -404,27 +409,32 @@ class ImportScripts::Lithium < ImportScripts::Base raw = post["raw"] next unless topic = topic_lookup_from_imported_post_id("#{post["node_id"]} #{post["root_id"]}") + deleted_at = topic["deleted"] == 1 ? topic["row_version"] : nil raw = post["body"] - new_post = { - id: "#{post["node_id"]} #{post["root_id"]} #{post["id"]}", - user_id: user_id_from_imported_user_id(post["user_id"]) || Discourse::SYSTEM_USER_ID, - topic_id: topic[:topic_id], - raw: raw, - created_at: unix_time(post["post_date"]), - custom_fields: { import_unique_id: post["unique_id"] }, - import_mode: true - } + if raw.present? + new_post = { + id: "#{post["node_id"]} #{post["root_id"]} #{post["id"]}", + user_id: user_id_from_imported_user_id(post["user_id"]) || Discourse::SYSTEM_USER_ID, + topic_id: topic[:topic_id], + raw: raw, + created_at: unix_time(post["post_date"]), + deleted_at: deleted_at, + custom_fields: { import_unique_id: post["unique_id"] }, + import_mode: true + } - if post["deleted"] > 0 - new_post["deleted_at"] = Time.now + if parent = topic_lookup_from_imported_post_id("#{post["node_id"]} #{post["root_id"]} #{post["parent_id"]}") + new_post[:reply_to_post_number] = parent[:post_number] + end + + new_post + else + message = "Unknown" + message = "Post 'body' is empty" if raw.blank? + PluginStoreRow.create(plugin_name: "post_import_log", key: post["unique_id"].to_s, value: message) + nil end - - if parent = topic_lookup_from_imported_post_id("#{post["node_id"]} #{post["root_id"]} #{post["parent_id"]}") - new_post[:reply_to_post_number] = parent[:post_number] - end - - new_post end end end From 9a5aa397404e3d68b93568b548c1ec78628b649d Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 18 May 2018 15:35:37 +0200 Subject: [PATCH 045/278] FIX: improves discourse-local-dates rendering in emails --- .../discourse-markdown/discourse-local-dates.js.es6 | 12 +++++++----- plugins/discourse-local-dates/plugin.rb | 8 ++++++++ .../spec/lib/pretty_text_spec.rb | 12 ++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/discourse-local-dates.js.es6 b/plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/discourse-local-dates.js.es6 index ef46c5ca22..b6bc29ba2f 100644 --- a/plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/discourse-local-dates.js.es6 +++ b/plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/discourse-local-dates.js.es6 @@ -18,7 +18,7 @@ function addLocalDate(buffer, matches, state) { config.format = parsed.attrs.format || config.format; config.timezones = parsed.attrs.timezones || config.timezones; - token = new state.Token('a_open', 'a', 1); + token = new state.Token('span_open', 'span', 1); token.attrs = [ ['class', 'discourse-local-date'], ['data-date', config.date], @@ -46,19 +46,21 @@ function addLocalDate(buffer, matches, state) { } }); + token.attrs.push(['data-email-preview', previews[0]]); + token = new state.Token('text', '', 0); token.content = previews.join(", "); buffer.push(token); - token = new state.Token('a_close', 'a', -1); + token = new state.Token('span_close', 'span', -1); buffer.push(token); } export function setup(helper) { helper.whiteList([ - 'a.discourse-local-date', - 'a[data-*]', - 'a[title]' + 'span.discourse-local-date', + 'span[data-*]', + 'span[title]' ]); helper.registerOptions((opts, siteSettings) => { diff --git a/plugins/discourse-local-dates/plugin.rb b/plugins/discourse-local-dates/plugin.rb index e5e3348fe0..9b3f426a57 100644 --- a/plugins/discourse-local-dates/plugin.rb +++ b/plugins/discourse-local-dates/plugin.rb @@ -11,4 +11,12 @@ register_asset "moment-timezone.js", :vendored_core_pretty_text enabled_site_setting :discourse_local_dates_enabled +after_initialize do + on(:reduce_cooked) do |fragment| + container = fragment.children[0].children[0] + preview = container.attributes["data-email-preview"].value + container.content = preview + end +end + load File.expand_path('../lib/discourse_local_dates/engine.rb', __FILE__) diff --git a/plugins/discourse-local-dates/spec/lib/pretty_text_spec.rb b/plugins/discourse-local-dates/spec/lib/pretty_text_spec.rb index 7df61b7f07..15a9bd8dde 100644 --- a/plugins/discourse-local-dates/spec/lib/pretty_text_spec.rb +++ b/plugins/discourse-local-dates/spec/lib/pretty_text_spec.rb @@ -14,4 +14,16 @@ describe PrettyText do expect(cooked).to include('May 8, 2018 3:00 PM (America: Los Angeles)') expect(cooked).to include('May 9, 2018 12:00 AM (Europe: Paris)') end + + it 'uses a simplified syntax in emails' do + freeze_time + cooked = PrettyText.cook <<~MD + [date=2018-05-08 time=22:00 format=LLL timezones="Europe/Paris|America/Los_Angeles"] + MD + cooked_mail = <<~HTML +

    May 9, 2018 12:00 AM (Europe: Paris)

    + HTML + + expect(PrettyText.format_for_email(cooked)).to match_html(cooked_mail) + end end From d57a17879a76203530f12c59da506d917d5802de Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 18 May 2018 17:13:58 +0200 Subject: [PATCH 046/278] fix spec and simplify code --- plugins/discourse-local-dates/plugin.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/discourse-local-dates/plugin.rb b/plugins/discourse-local-dates/plugin.rb index 9b3f426a57..8aa5b05baa 100644 --- a/plugins/discourse-local-dates/plugin.rb +++ b/plugins/discourse-local-dates/plugin.rb @@ -13,9 +13,12 @@ enabled_site_setting :discourse_local_dates_enabled after_initialize do on(:reduce_cooked) do |fragment| - container = fragment.children[0].children[0] - preview = container.attributes["data-email-preview"].value - container.content = preview + container = fragment.css(".discourse-local-date").first + + if container + preview = container.attributes["data-email-preview"].value + container.content = preview + end end end From ba0dd5889d94f7de74fdf53c31feb5b9333e70f5 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Fri, 18 May 2018 22:57:15 +0530 Subject: [PATCH 047/278] Improvements in importing the lithium pms --- script/import_scripts/lithium.rb | 39 +++++++++++++++++--------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/script/import_scripts/lithium.rb b/script/import_scripts/lithium.rb index b6d915b15f..de12dd98f0 100644 --- a/script/import_scripts/lithium.rb +++ b/script/import_scripts/lithium.rb @@ -430,9 +430,7 @@ class ImportScripts::Lithium < ImportScripts::Base new_post else - message = "Unknown" - message = "Post 'body' is empty" if raw.blank? - PluginStoreRow.create(plugin_name: "post_import_log", key: post["unique_id"].to_s, value: message) + PluginStoreRow.create(plugin_name: "post_import_log", key: post["unique_id"].to_s, value: "Post 'body' is empty") nil end end @@ -467,7 +465,7 @@ class ImportScripts::Lithium < ImportScripts::Base def import_likes puts "\nimporting likes..." - sql = "select source_id user_id, target_id post_id, row_version created_at from wd.tag_events_score_message" + sql = "select source_id user_id, target_id post_id, row_version created_at from tag_events_score_message" results = mysql_query(sql) puts "loading unique id map" @@ -697,23 +695,28 @@ class ImportScripts::Lithium < ImportScripts::Base raw = topic["body"] - msg = { - id: "pm_#{topic["note_id"]}", - user_id: user_id, - raw: raw, - created_at: unix_time(topic["sent_time"]), - import_mode: true - } + if raw.present? + msg = { + id: "pm_#{topic["note_id"]}", + user_id: user_id, + raw: raw, + created_at: unix_time(topic["sent_time"]), + import_mode: true + } - unless topic_id - msg[:title] = @htmlentities.decode(topic["subject"]).strip[0...255] - msg[:archetype] = Archetype.private_message - msg[:target_usernames] = usernames.join(',') + unless topic_id + msg[:title] = @htmlentities.decode(topic["subject"]).strip[0...255] + msg[:archetype] = Archetype.private_message + msg[:target_usernames] = usernames.join(',') + else + msg[:topic_id] = topic_id + end + + msg else - msg[:topic_id] = topic_id + PluginStoreRow.create(plugin_name: "pm_import_log", key: topic["note_id"].to_s, value: "PM 'body' is empty") + nil end - - msg end end From e209faa6cf0987ccab7903403782bd5763e0ee77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Fri, 18 May 2018 19:32:10 +0200 Subject: [PATCH 048/278] UX: ensure whitespace isn't removed when playing with quotes --- .../javascripts/discourse/components/quote-button.js.es6 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/components/quote-button.js.es6 b/app/assets/javascripts/discourse/components/quote-button.js.es6 index 5d41b049cc..49ee520e95 100644 --- a/app/assets/javascripts/discourse/components/quote-button.js.es6 +++ b/app/assets/javascripts/discourse/components/quote-button.js.es6 @@ -78,7 +78,10 @@ export default Ember.Component.extend({ const $quoteButton = this.$(); // remove the marker - markerElement.parentNode.removeChild(markerElement); + const parent = markerElement.parentNode; + parent.removeChild(markerElement); + // merge back all text nodes so they don't get messed up + parent.normalize(); // work around Safari that would sometimes lose the selection if (isSafari) { From 8b96d9a52d771397d328c3126ba7f717c0528290 Mon Sep 17 00:00:00 2001 From: Kris Date: Fri, 18 May 2018 15:18:10 -0400 Subject: [PATCH 049/278] fixing group filter --- app/assets/javascripts/discourse/templates/group-index.hbs | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/group-index.hbs b/app/assets/javascripts/discourse/templates/group-index.hbs index 1d9caddcd8..d5a880b8be 100644 --- a/app/assets/javascripts/discourse/templates/group-index.hbs +++ b/app/assets/javascripts/discourse/templates/group-index.hbs @@ -1,9 +1,7 @@
    - {{#if hasMembers}} {{text-field value=filterInput placeholderKey=filterPlaceholder class="group-username-filter no-blur"}} - {{/if}}
    {{#if canManageGroup}} From 4329b484e865898f3d9b32168aa4f4980ceed0d9 Mon Sep 17 00:00:00 2001 From: Jeff Atwood Date: Fri, 18 May 2018 16:04:33 -0700 Subject: [PATCH 050/278] minor copyedit on dashboard chart title --- config/locales/server.en.yml | 6 +++--- test/javascripts/fixtures/signups.js.es6 | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 35354b561f..236c11d036 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -843,10 +843,10 @@ en: xaxis: "Day" yaxis: "Number of visits" signups: - title: "New Users" + title: "Signups" xaxis: "Day" - yaxis: "Number of new users" - description: "New registrations for this period" + yaxis: "Number of signups" + description: "New account registrations for this period" new_contributors: title: "New Contributors" xaxis: "Day" diff --git a/test/javascripts/fixtures/signups.js.es6 b/test/javascripts/fixtures/signups.js.es6 index dafe3909d0..c4ccb1a9bb 100644 --- a/test/javascripts/fixtures/signups.js.es6 +++ b/test/javascripts/fixtures/signups.js.es6 @@ -2,7 +2,7 @@ export default { "/admin/reports/signups": { "report": { "type": "signups", - "title": "New Users", + "title": "Signups", "xaxis": "Day", "yaxis": "Number of new users", "data": [{ From f3385a74cb2b7f990ecedd3e1b1cf4790a792529 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Sat, 19 May 2018 11:24:48 +0530 Subject: [PATCH 051/278] Importing lithium topic tags --- script/import_scripts/lithium.rb | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/script/import_scripts/lithium.rb b/script/import_scripts/lithium.rb index de12dd98f0..9c76a730f2 100644 --- a/script/import_scripts/lithium.rb +++ b/script/import_scripts/lithium.rb @@ -332,10 +332,20 @@ class ImportScripts::Lithium < ImportScripts::Base end + def staff_guardian + @_staff_guardian ||= Guardian.new(Discourse.system_user) + end + def import_topics puts "", "importing topics..." + SiteSetting.tagging_enabled = true + default_max_tags_per_topic = SiteSetting.max_tags_per_topic + default_max_tag_length = SiteSetting.max_tag_length + SiteSetting.max_tags_per_topic = 10 + SiteSetting.max_tag_length = 100 topic_count = mysql_query("SELECT COUNT(*) count FROM message2 where id = root_id").first["count"] + topic_tags = mysql_query("SELECT e.target_id, GROUP_CONCAT(l.tag_text SEPARATOR ',') tags FROM tag_events_label_message e LEFT JOIN tags_label l ON e.tag_id = l.tag_id GROUP BY e.target_id") batches(BATCH_SIZE) do |offset| topics = mysql_query <<-SQL @@ -369,18 +379,28 @@ class ImportScripts::Lithium < ImportScripts::Base deleted_at: deleted_at, views: topic["views"], custom_fields: { import_unique_id: topic["unique_id"] }, - import_mode: true + import_mode: true, + post_create_action: proc do |post| + result = topic_tags.select { |t| t["target_id"] == topic["unique_id"] } + if result.count > 0 + tag_names = result.first["tags"].split(",") + DiscourseTagging.tag_topic_by_names(post.topic, staff_guardian, tag_names) + end + end } else message = "Unknown" message = "Category '#{category_id}' not exist" if category_id.blank? message = "Topic 'body' is empty" if raw.blank? - PluginStoreRow.create(plugin_name: "topic_import_log", key: topic["unique_id"].to_s, value: message) + PluginStoreRow.find_or_create_by(plugin_name: "topic_import_log", key: topic["unique_id"].to_s, value: message, type_name: 'String') nil end end end + + SiteSetting.max_tags_per_topic = default_max_tags_per_topic + SiteSetting.max_tag_length = default_max_tag_length end def import_posts @@ -430,7 +450,7 @@ class ImportScripts::Lithium < ImportScripts::Base new_post else - PluginStoreRow.create(plugin_name: "post_import_log", key: post["unique_id"].to_s, value: "Post 'body' is empty") + PluginStoreRow.find_or_create_by(plugin_name: "post_import_log", key: post["unique_id"].to_s, value: "Post 'body' is empty", type_name: 'String') nil end end @@ -714,7 +734,7 @@ class ImportScripts::Lithium < ImportScripts::Base msg else - PluginStoreRow.create(plugin_name: "pm_import_log", key: topic["note_id"].to_s, value: "PM 'body' is empty") + PluginStoreRow.find_or_create_by(plugin_name: "pm_import_log", key: topic["note_id"].to_s, value: "PM 'body' is empty", type_name: 'String') nil end end From 9f422c93f67f156c3216a57cdf1afc0c5fc94e28 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Sat, 19 May 2018 11:45:57 +0530 Subject: [PATCH 052/278] FIX: restrict updates on `confirm_old_email` email templates --- .../admin/email_templates_controller.rb | 2 +- .../admin/site_texts_controller.rb | 8 +- ...te_confirm_old_email_template_overrides.rb | 11 +++ .../admin/site_texts_controller_spec.rb | 74 +++++++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20180519053933_delete_confirm_old_email_template_overrides.rb create mode 100644 spec/requests/admin/site_texts_controller_spec.rb diff --git a/app/controllers/admin/email_templates_controller.rb b/app/controllers/admin/email_templates_controller.rb index 33e3c67f8a..feae4de5d1 100644 --- a/app/controllers/admin/email_templates_controller.rb +++ b/app/controllers/admin/email_templates_controller.rb @@ -23,7 +23,7 @@ class Admin::EmailTemplatesController < Admin::AdminController "system_messages.unsilenced", "system_messages.user_automatically_silenced", "system_messages.welcome_invite", "system_messages.welcome_user", "test_mailer", "user_notifications.account_created", "user_notifications.admin_login", - "user_notifications.confirm_new_email", "user_notifications.confirm_old_email", + "user_notifications.confirm_new_email", "user_notifications.notify_old_email", "user_notifications.forgot_password", "user_notifications.set_password", "user_notifications.signup", "user_notifications.signup_after_approval", diff --git a/app/controllers/admin/site_texts_controller.rb b/app/controllers/admin/site_texts_controller.rb index ebb99aa8c5..33e636926d 100644 --- a/app/controllers/admin/site_texts_controller.rb +++ b/app/controllers/admin/site_texts_controller.rb @@ -7,6 +7,12 @@ class Admin::SiteTextsController < Admin::AdminController 'login_required.welcome_message'] end + def self.restricted_keys + ['user_notifications.confirm_old_email.title', + 'user_notifications.confirm_old_email.subject_template', + 'user_notifications.confirm_old_email.text_body_template'] + end + def index overridden = params[:overridden] == 'true' extras = {} @@ -80,7 +86,7 @@ class Admin::SiteTextsController < Admin::AdminController end def find_site_text - raise Discourse::NotFound unless I18n.exists?(params[:id]) + raise Discourse::NotFound unless I18n.exists?(params[:id]) && !self.class.restricted_keys.include?(params[:id]) record_for(params[:id]) end diff --git a/db/migrate/20180519053933_delete_confirm_old_email_template_overrides.rb b/db/migrate/20180519053933_delete_confirm_old_email_template_overrides.rb new file mode 100644 index 0000000000..f1383030db --- /dev/null +++ b/db/migrate/20180519053933_delete_confirm_old_email_template_overrides.rb @@ -0,0 +1,11 @@ +class DeleteConfirmOldEmailTemplateOverrides < ActiveRecord::Migration[5.1] + def up + execute "DELETE FROM translation_overrides WHERE translation_key = 'user_notifications.confirm_old_email.title'" + execute "DELETE FROM translation_overrides WHERE translation_key = 'user_notifications.confirm_old_email.subject_template'" + execute "DELETE FROM translation_overrides WHERE translation_key = 'user_notifications.confirm_old_email.text_body_template'" + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/spec/requests/admin/site_texts_controller_spec.rb b/spec/requests/admin/site_texts_controller_spec.rb new file mode 100644 index 0000000000..c7fb685fb8 --- /dev/null +++ b/spec/requests/admin/site_texts_controller_spec.rb @@ -0,0 +1,74 @@ +require 'rails_helper' + +RSpec.describe Admin::SiteTextsController do + let(:admin) { Fabricate(:admin) } + let(:user) { Fabricate(:user) } + let(:headers) { { ACCEPT: 'application/json' } } + + after do + TranslationOverride.delete_all + I18n.reload! + end + + context "#update" do + it "raises an error if you aren't logged in" do + put '/admin/customize/site_texts/some_key', params: { + site_text: { value: 'foo' } + }, headers: headers + expect(response.status).to eq(404) + end + + it "raises an error if you aren't an admin" do + sign_in(user) + put '/admin/customize/site_texts/some_key', params: { + site_text: { value: 'foo' } + }, headers: headers + expect(response.status).to eq(404) + end + + context "when logged in as admin" do + before do + sign_in(admin) + end + + it "returns 'not found' when an unknown key is used" do + put '/admin/customize/site_texts/some_key', params: { + site_text: { value: 'foo' } + }, headers: headers + + expect(response).not_to be_success + + json = ::JSON.parse(response.body) + expect(json['error_type']).to eq('not_found') + end + + it "works as expectd with correct keys" do + put '/admin/customize/site_texts/title', params: { + site_text: { value: 'foo' } + }, headers: headers + + expect(response).to be_success + + json = ::JSON.parse(response.body) + expect(json).to be_present + + site_text = json['site_text'] + expect(site_text).to be_present + + expect(site_text['id']).to eq('title') + expect(site_text['value']).to eq('foo') + end + + it "does not update restricted keys" do + put '/admin/customize/site_texts/user_notifications.confirm_old_email.title', params: { + site_text: { value: 'foo' } + }, headers: headers + + expect(response).not_to be_success + + json = ::JSON.parse(response.body) + expect(json['error_type']).to eq('not_found') + end + end + end +end From 10dfdd7d2a854bb0793f9c59833122ca8c59e132 Mon Sep 17 00:00:00 2001 From: Joshua Rosenfeld Date: Sat, 19 May 2018 19:53:39 -0400 Subject: [PATCH 053/278] Correct BBCode plugin name --- lib/plugin/metadata.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plugin/metadata.rb b/lib/plugin/metadata.rb index f9dfabc622..447337ef6f 100644 --- a/lib/plugin/metadata.rb +++ b/lib/plugin/metadata.rb @@ -27,7 +27,7 @@ class Plugin::Metadata "discourse-oauth2-basic", "discourse-math", "discourse-bbcode-color", - "discourse-bbcode", + "BBCode", "discourse-affiliate", "discourse-translator", "discourse-patreon", From be01491c04a283547573e27f507f579abe9b1e60 Mon Sep 17 00:00:00 2001 From: jomaxro Date: Sat, 19 May 2018 20:03:49 -0400 Subject: [PATCH 054/278] Revert "Correct BBCode plugin name" This reverts commit 10dfdd7d2a854bb0793f9c59833122ca8c59e132. --- lib/plugin/metadata.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plugin/metadata.rb b/lib/plugin/metadata.rb index 447337ef6f..f9dfabc622 100644 --- a/lib/plugin/metadata.rb +++ b/lib/plugin/metadata.rb @@ -27,7 +27,7 @@ class Plugin::Metadata "discourse-oauth2-basic", "discourse-math", "discourse-bbcode-color", - "BBCode", + "discourse-bbcode", "discourse-affiliate", "discourse-translator", "discourse-patreon", From 1841dd48dc5c15025dac8b6054c35045334a2a55 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Sun, 20 May 2018 17:50:36 +0530 Subject: [PATCH 055/278] FIX: revert utf-8 encode changes --- app/jobs/scheduled/poll_feed.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/jobs/scheduled/poll_feed.rb b/app/jobs/scheduled/poll_feed.rb index 590241f298..494fcf2a70 100644 --- a/app/jobs/scheduled/poll_feed.rb +++ b/app/jobs/scheduled/poll_feed.rb @@ -89,7 +89,6 @@ module Jobs def parsed_feed raw_feed = fetch_rss return nil if raw_feed.blank? - raw_feed.encode!("UTF-8", invalid: :replace, undef: :replace, replace: "") if SiteSetting.embed_username_key_from_feed.present? FeedElementInstaller.install(SiteSetting.embed_username_key_from_feed, raw_feed) From 2ceb1070743f2d0081def11cc06bc3a9d8e7dd0e Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 21 May 2018 09:49:46 +0800 Subject: [PATCH 056/278] Refactor tests to use the json extension instead of headers. --- config/routes.rb | 6 ++-- .../admin/site_texts_controller_spec.rb | 33 +++++++++---------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index cebaf579dc..5636b1a352 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -213,9 +213,9 @@ Discourse::Application.routes.draw do # They have periods in their URLs often: get 'site_texts' => 'site_texts#index' - get 'site_texts/(:id)' => 'site_texts#show', constraints: { id: /[\w.\-\+]+/i } - put 'site_texts/(:id)' => 'site_texts#update', constraints: { id: /[\w.\-\+]+/i } - delete 'site_texts/(:id)' => 'site_texts#revert', constraints: { id: /[\w.\-\+]+/i } + get 'site_texts/:id' => 'site_texts#show', constraints: { id: /[\w.\-\+]+/i } + put 'site_texts/:id.json' => 'site_texts#update', constraints: { id: /[\w.\-\+]+/i } + delete 'site_texts/:id' => 'site_texts#revert', constraints: { id: /[\w.\-\+]+/i } get 'email_templates' => 'email_templates#index' get 'email_templates/(:id)' => 'email_templates#show', constraints: { id: /[0-9a-z_.]+/ } diff --git a/spec/requests/admin/site_texts_controller_spec.rb b/spec/requests/admin/site_texts_controller_spec.rb index c7fb685fb8..7df5e849b5 100644 --- a/spec/requests/admin/site_texts_controller_spec.rb +++ b/spec/requests/admin/site_texts_controller_spec.rb @@ -3,7 +3,6 @@ require 'rails_helper' RSpec.describe Admin::SiteTextsController do let(:admin) { Fabricate(:admin) } let(:user) { Fabricate(:user) } - let(:headers) { { ACCEPT: 'application/json' } } after do TranslationOverride.delete_all @@ -12,17 +11,20 @@ RSpec.describe Admin::SiteTextsController do context "#update" do it "raises an error if you aren't logged in" do - put '/admin/customize/site_texts/some_key', params: { + put '/admin/customize/site_texts/some_key.json', params: { site_text: { value: 'foo' } - }, headers: headers + } + expect(response.status).to eq(404) end it "raises an error if you aren't an admin" do sign_in(user) + put '/admin/customize/site_texts/some_key', params: { site_text: { value: 'foo' } - }, headers: headers + } + expect(response.status).to eq(404) end @@ -32,39 +34,36 @@ RSpec.describe Admin::SiteTextsController do end it "returns 'not found' when an unknown key is used" do - put '/admin/customize/site_texts/some_key', params: { + put '/admin/customize/site_texts/some_key.json', params: { site_text: { value: 'foo' } - }, headers: headers + } - expect(response).not_to be_success + expect(response.status).to eq(404) - json = ::JSON.parse(response.body) + json = JSON.parse(response.body) expect(json['error_type']).to eq('not_found') end it "works as expectd with correct keys" do - put '/admin/customize/site_texts/title', params: { + put '/admin/customize/site_texts/title.json', params: { site_text: { value: 'foo' } - }, headers: headers + } - expect(response).to be_success + expect(response.status).to eq(200) json = ::JSON.parse(response.body) - expect(json).to be_present - site_text = json['site_text'] - expect(site_text).to be_present expect(site_text['id']).to eq('title') expect(site_text['value']).to eq('foo') end it "does not update restricted keys" do - put '/admin/customize/site_texts/user_notifications.confirm_old_email.title', params: { + put '/admin/customize/site_texts/user_notifications.confirm_old_email.title.json', params: { site_text: { value: 'foo' } - }, headers: headers + } - expect(response).not_to be_success + expect(response.status).to eq(404) json = ::JSON.parse(response.body) expect(json['error_type']).to eq('not_found') From cd4250737aa4d18484c5366e2ba89e3b549a8b90 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 21 May 2018 12:20:23 +1000 Subject: [PATCH 057/278] SECURITY: remove alert dialog from local dates --- .../assets/javascripts/discourse-local-dates.js | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js b/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js index d5459e82dc..f64c694e01 100644 --- a/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js +++ b/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js @@ -49,7 +49,6 @@ $element .html(html) .attr("title", previews.join("\n")) - .attr("onclick", "alert('" + previews.join("\\n") + "');return false;") .addClass("cooked"); if (repeat) { From 8407c210c1b1041f7ceda558e48fd0908077986b Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 21 May 2018 14:31:30 +1000 Subject: [PATCH 058/278] DEV: Update our test matrix We are preparing to drop support for Ruby 2.3 --- .travis.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 6692c0e3e0..ba9f8a2c07 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,9 +23,8 @@ matrix: fast_finish: true rvm: - - 2.5.0 - - 2.4.2 - - 2.3.4 + - 2.5.1 + - 2.4.4 services: - redis-server From db23e10efa94368835b00e9ecef830fcdd472698 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 21 May 2018 15:19:10 +1000 Subject: [PATCH 059/278] DEV: watch plugin.rb in autospec --- lib/autospec/rspec_runner.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/autospec/rspec_runner.rb b/lib/autospec/rspec_runner.rb index d09acd5450..34e4043df3 100644 --- a/lib/autospec/rspec_runner.rb +++ b/lib/autospec/rspec_runner.rb @@ -23,6 +23,7 @@ module Autospec watch(%r{^plugins/.*/discourse-markdown/.*\.js\.es6$}) { "spec/components/pretty_text_spec.rb" } watch(%r{^plugins/.*/spec/.*\.rb}) + watch(%r{^(plugins/.*/)plugin\.rb}) { |m| "#{m[1]}spec" } watch(%r{^(plugins/.*)/(lib|app)}) { |m| "#{m[1]}/spec/integration" } watch(%r{^(plugins/.*)/lib/(.*)\.rb}) { |m| "#{m[1]}/spec/lib/#{m[2]}_spec.rb" } From b7b08b41732bce065e5b338a6f6134a461ffaa25 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 21 May 2018 13:25:56 +0800 Subject: [PATCH 060/278] Fix regression introduced in https://github.com/discourse/discourse/commit/2ceb1070743f2d0081def11cc06bc3a9d8e7dd0e. --- config/routes.rb | 2 +- spec/requests/admin/site_texts_controller_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index 5636b1a352..bc35b838fb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -214,7 +214,7 @@ Discourse::Application.routes.draw do # They have periods in their URLs often: get 'site_texts' => 'site_texts#index' get 'site_texts/:id' => 'site_texts#show', constraints: { id: /[\w.\-\+]+/i } - put 'site_texts/:id.json' => 'site_texts#update', constraints: { id: /[\w.\-\+]+/i } + put 'site_texts/:id' => 'site_texts#update', constraints: { id: /[\w.\-\+]+[^.json]/i } delete 'site_texts/:id' => 'site_texts#revert', constraints: { id: /[\w.\-\+]+/i } get 'email_templates' => 'email_templates#index' diff --git a/spec/requests/admin/site_texts_controller_spec.rb b/spec/requests/admin/site_texts_controller_spec.rb index 7df5e849b5..f19c6015c8 100644 --- a/spec/requests/admin/site_texts_controller_spec.rb +++ b/spec/requests/admin/site_texts_controller_spec.rb @@ -45,7 +45,7 @@ RSpec.describe Admin::SiteTextsController do end it "works as expectd with correct keys" do - put '/admin/customize/site_texts/title.json', params: { + put '/admin/customize/site_texts/login_required.welcome_message.json', params: { site_text: { value: 'foo' } } @@ -54,7 +54,7 @@ RSpec.describe Admin::SiteTextsController do json = ::JSON.parse(response.body) site_text = json['site_text'] - expect(site_text['id']).to eq('title') + expect(site_text['id']).to eq('login_required.welcome_message') expect(site_text['value']).to eq('foo') end From e78f1d7589ffa25f901e925cfb0a7d8d71d18a1f Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 21 May 2018 14:35:59 +0800 Subject: [PATCH 061/278] Fix the build take 2. --- config/routes.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/routes.rb b/config/routes.rb index bc35b838fb..71bcbaab24 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -214,7 +214,8 @@ Discourse::Application.routes.draw do # They have periods in their URLs often: get 'site_texts' => 'site_texts#index' get 'site_texts/:id' => 'site_texts#show', constraints: { id: /[\w.\-\+]+/i } - put 'site_texts/:id' => 'site_texts#update', constraints: { id: /[\w.\-\+]+[^.json]/i } + put 'site_texts/:id.json' => 'site_texts#update', constraints: { id: /[\w.\-\+]+/i } + put 'site_texts/:id' => 'site_texts#update', constraints: { id: /[\w.\-\+]+/i } delete 'site_texts/:id' => 'site_texts#revert', constraints: { id: /[\w.\-\+]+/i } get 'email_templates' => 'email_templates#index' From ec337bbcb34e538032fea8c46ab4e1045dd28c30 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 21 May 2018 17:01:30 +1000 Subject: [PATCH 062/278] DEV: attempt to report last exception as the "cause" for failures This allows our request specs to report exceptions so we can debug May have a few false positives but generally should be quiet TODO only wire magic in for request specs, currently happens for all --- config/environments/test.rb | 4 ++++ spec/rails_helper.rb | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/config/environments/test.rb b/config/environments/test.rb index 8a0fc8c6c9..7a6f94612a 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -50,6 +50,10 @@ Discourse::Application.configure do config.log_level = :fatal end + if defined? RspecErrorTracker + config.middleware.insert_after ActionDispatch::Flash, RspecErrorTracker + end + config.after_initialize do SiteSetting.defaults.tap do |s| s.set_regardless_of_locale(:s3_upload_bucket, 'bucket') diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index d2b575f294..fd977925cd 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -14,6 +14,31 @@ require 'mocha/api' require 'certified' require 'webmock/rspec' +class RspecErrorTracker + + def self.last_exception=(ex) + @ex = ex + end + + def self.last_exception + @ex + end + + def initialize(app, config = {}) + @app = app + end + + def call(env) + begin + @app.call(env) + rescue => e + RspecErrorTracker.last_exception = e + raise e + end + ensure + end +end + ENV["RAILS_ENV"] ||= 'test' require File.expand_path("../../config/environment", __FILE__) require 'rspec/rails' @@ -96,6 +121,18 @@ RSpec.configure do |config| end end + config.after :each do |x| + if x.exception && ex = RspecErrorTracker.last_exception + # magic in a cause if we have none + unless x.exception.cause + class << x.exception + attr_accessor :cause + end + x.exception.cause = ex + end + end + end + config.before :each do |x| # TODO not sure about this, we could use a mock redis implementation here: # this gives us really clean "flush" semantics, howere the side-effect is that @@ -126,6 +163,8 @@ RSpec.configure do |config| I18n.locale = :en + RspecErrorTracker.last_exception = nil + if $test_cleanup_callbacks $test_cleanup_callbacks.reverse_each(&:call) $test_cleanup_callbacks = nil From c9c3a832617cc28e2a4c2492d41ebe247cf1145a Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Mon, 21 May 2018 13:34:52 +0530 Subject: [PATCH 063/278] Importing lithium post images and attachments --- script/import_scripts/lithium.rb | 171 +++++++++++++++++++------------ 1 file changed, 108 insertions(+), 63 deletions(-) diff --git a/script/import_scripts/lithium.rb b/script/import_scripts/lithium.rb index 9c76a730f2..8c0b4eb842 100644 --- a/script/import_scripts/lithium.rb +++ b/script/import_scripts/lithium.rb @@ -27,7 +27,8 @@ class ImportScripts::Lithium < ImportScripts::Base # CHANGE THESE BEFORE RUNNING THE IMPORTER DATABASE = "wd" PASSWORD = "password" - AVATARS_DIR = '/tmp/avatars' + AVATAR_DIR = '/tmp/avatars' + ATTACHMENT_DIR = '/tmp/attachments' UPLOAD_DIR = '/tmp/uploads' OLD_DOMAIN = 'community.wd.com' @@ -101,7 +102,7 @@ class ImportScripts::Lithium < ImportScripts::Base puts "", "importing users" user_count = mysql_query("SELECT COUNT(*) count FROM users").first["count"] - avatar_files = Dir.entries(AVATARS_DIR) + avatar_files = Dir.entries(AVATAR_DIR) batches(BATCH_SIZE) do |offset| users = mysql_query <<-SQL @@ -796,27 +797,15 @@ SQL end - # find the uploaded file information from the db - def find_upload(post, attachment_id) - sql = "SELECT a.attachmentid attachment_id, a.userid user_id, a.filedataid file_id, a.filename filename, - a.caption caption - FROM attachment a - WHERE a.attachmentid = #{attachment_id}" - results = mysql_query(sql) + def find_upload(user_id, attachment_id, real_filename) + filename = File.join(ATTACHMENT_DIR, "#{attachment_id.to_s.rjust(4, "0")}.dat") - unless (row = results.first) - puts "Couldn't find attachment record for post.id = #{post.id}, import_id = #{post.custom_fields['import_id']}" - return nil - end - - filename = File.join(ATTACHMENT_DIR, row['user_id'].to_s.split('').join('/'), "#{row['file_id']}.attach") unless File.exists?(filename) puts "Attachment file doesn't exist: #{filename}" return nil end - real_filename = row['filename'] real_filename.prepend SecureRandom.hex if real_filename[0] == '.' - upload = create_upload(post.user.id, filename, real_filename) + upload = create_upload(user_id, filename, real_filename) if upload.nil? || !upload.valid? puts "Upload not valid :(" @@ -825,20 +814,21 @@ SQL end return upload, real_filename - rescue Mysql2::Error => e - puts "SQL Error" - puts e.message - puts sql - return nil end def post_process_posts puts "", "Postprocessing posts..." + default_extensions = SiteSetting.authorized_extensions + default_max_att_size = SiteSetting.max_attachment_size_kb + SiteSetting.authorized_extensions = "*" + SiteSetting.max_attachment_size_kb = 307200 + current = 0 max = Post.count mysql_query("create index idxUniqueId on message2(unique_id)") rescue nil + attachments = mysql_query("SELECT a.attachment_id, a.file_name, m.message_uid FROM tblia_attachment a INNER JOIN tblia_message_attachments m ON a.attachment_id = m.attachment_id") Post.where('id > ?', @max_start_id).find_each do |post| begin @@ -850,8 +840,16 @@ SQL next end new_raw = postprocess_post_raw(raw, post.user_id) - post.raw = new_raw - post.save + files = attachments.select { |a| a["message_uid"].to_s == id } + new_raw << html_for_attachments(post.user_id, files) + unless post.raw == new_raw + post.raw = new_raw + post.cooked = post.cook(new_raw) + cpp = CookedPostProcessor.new(post) + cpp.keep_reverse_index_up_to_date + post.custom_fields["import_post_process"] = true + post.save + end rescue PrettyText::JavaScriptError puts "GOT A JS error on post: #{post.id}" nil @@ -859,56 +857,89 @@ SQL print_status(current += 1, max) end end + + SiteSetting.authorized_extensions = default_extensions + SiteSetting.max_attachment_size_kb = default_max_att_size end def postprocess_post_raw(raw, user_id) doc = Nokogiri::HTML.fragment(raw) - doc.css("a,img").each do |l| - uri = URI.parse(l["href"] || l["src"]) rescue nil - if uri && uri.hostname == OLD_DOMAIN - uri.hostname = nil + doc.css("a,img,li-image").each do |l| + upload_name, image, linked_upload = [nil] * 3 + + if l.name == "li-image" && l["id"] + upload_name = l["id"] + else + uri = URI.parse(l["href"] || l["src"]) rescue nil + uri.hostname = nil if uri && uri.hostname == OLD_DOMAIN + + if uri && !uri.hostname + if l["href"] + l["href"] = uri.path + # we have an internal link, lets see if we can remap it? + permalink = Permalink.find_by_url(uri.path) rescue nil + + if l["href"] + if permalink && permalink.target_url + l["href"] = permalink.target_url + elsif l["href"] =~ /^\/gartner\/attachments\/gartner\/([^.]*).(\w*)/ + linked_upload = "#{$1}.#{$2}" + end + end + elsif l["src"] + + # we need an upload here + upload_name = $1 if uri.path =~ /image-id\/([^\/]+)/ + end + end end - if uri && !uri.hostname - if l["href"] - l["href"] = uri.path - # we have an internal link, lets see if we can remap it? - permalink = Permalink.find_by_url(uri.path) rescue nil - if l["href"] && permalink && permalink.target_url - l["href"] = permalink.target_url - end - elsif l["src"] - - # we need an upload here - upload_name = $1 if uri.path =~ /image-id\/([^\/]+)/ - if upload_name - png = UPLOAD_DIR + "/" + upload_name + ".png" - jpg = UPLOAD_DIR + "/" + upload_name + ".jpg" - gif = UPLOAD_DIR + "/" + upload_name + ".gif" - - # check to see if we have it - if File.exist?(png) - image = png - elsif File.exists?(jpg) - image = jpg - elsif File.exists?(gif) - image = gif - end - end - - if image - File.open(image) do |file| - upload = UploadCreator.new(file, "image." + (image.ends_with?(".png") ? "png" : "jpg")).create_for(user_id) - l["src"] = upload.url - end - else - puts "image was missing #{l["src"]}" - end + if upload_name + png = UPLOAD_DIR + "/" + upload_name + ".png" + jpg = UPLOAD_DIR + "/" + upload_name + ".jpg" + gif = UPLOAD_DIR + "/" + upload_name + ".gif" + # check to see if we have it + if File.exist?(png) + image = png + elsif File.exists?(jpg) + image = jpg + elsif File.exists?(gif) + image = gif end + if image + File.open(image) do |file| + upload = UploadCreator.new(file, "image." + (image.ends_with?(".png") ? "png" : "jpg")).create_for(user_id) + l.name = "img" if l.name == "li-image" + l["src"] = upload.url + end + else + puts "image was missing #{l["src"]}" + end + elsif linked_upload + segments = linked_upload.match(/\/(\d*)\/(\d)\/([^.]*).(\w*)$/) + + if segments.present? + lithium_post_id = segments[1] + attachment_number = segments[2] + + result = mysql_query("select a.attachment_id, f.file_name from tblia_message_attachments a + INNER JOIN message2 m ON a.message_uid = m.unique_id + INNER JOIN tblia_attachment f ON a.attachment_id = f.attachment_id + where m.id = #{lithium_post_id} AND a.attach_num = #{attachment_number} limit 0, 1") + + result.each do |row| + upload, filename = find_upload(user_id, row["attachment_id"], row["file_name"]) + if upload.present? + l["href"] = upload.url + else + puts "attachment was missing #{l["href"]}" + end + end + end end end @@ -925,6 +956,20 @@ SQL raw end + def html_for_attachments(user_id, files) + html = ""; + + files.each do |file| + upload, filename = find_upload(user_id, file["attachment_id"], file["file_name"]) + if upload.present? + html << "\n" if html.present? + html << html_for_upload(upload, filename) + end + end + + html + end + def fake_email SecureRandom.hex << "@domain.com" end From 09151190f9d3901d40b299d9d392d18f3a1535f7 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Mon, 21 May 2018 13:43:23 +0530 Subject: [PATCH 064/278] FIX: Use avatar_dir to import user avatars --- script/import_scripts/lithium.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/import_scripts/lithium.rb b/script/import_scripts/lithium.rb index 8c0b4eb842..f59c72fe16 100644 --- a/script/import_scripts/lithium.rb +++ b/script/import_scripts/lithium.rb @@ -152,7 +152,7 @@ class ImportScripts::Lithium < ImportScripts::Base # import user avatar sso_id = u.custom_fields["sso_id"] if sso_id.present? - prefix = "#{UPLOAD_DIR}/#{sso_id}_" + prefix = "#{AVATAR_DIR}/#{sso_id}_" file = get_file(prefix + "actual.jpeg") file ||= get_file(prefix + "profile.jpeg") From b229c112f620935d3aa756144fda4de9d8d574f1 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Mon, 21 May 2018 13:47:30 +0530 Subject: [PATCH 065/278] FIX: variable name typo --- script/import_scripts/lithium.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/script/import_scripts/lithium.rb b/script/import_scripts/lithium.rb index f59c72fe16..25fd75fb35 100644 --- a/script/import_scripts/lithium.rb +++ b/script/import_scripts/lithium.rb @@ -157,15 +157,15 @@ class ImportScripts::Lithium < ImportScripts::Base file ||= get_file(prefix + "profile.jpeg") if file.present? - upload = UploadCreator.new(file, file.path, type: "avatar").create_for(user.id) - user.create_user_avatar unless user.user_avatar + upload = UploadCreator.new(file, file.path, type: "avatar").create_for(u.id) + u.create_user_avatar unless u.user_avatar - if !user.user_avatar.contains_upload?(upload.id) - user.user_avatar.update_columns(custom_upload_id: upload.id) + if !u.user_avatar.contains_upload?(upload.id) + u.user_avatar.update_columns(custom_upload_id: upload.id) - if user.uploaded_avatar_id.nil? || - !user.user_avatar.contains_upload?(user.uploaded_avatar_id) - user.update_columns(uploaded_avatar_id: upload.id) + if u.uploaded_avatar_id.nil? || + !u.user_avatar.contains_upload?(u.uploaded_avatar_id) + u.update_columns(uploaded_avatar_id: upload.id) end end end From 3ff0074e9dfa930853675a925e102a4dc214b931 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 21 May 2018 10:28:12 +0800 Subject: [PATCH 066/278] Trim `WebHookTopicSerializer` further. --- app/serializers/topic_view_serializer.rb | 8 ++++++-- .../web_hook_topic_view_serializer.rb | 17 +++++++++++++++++ .../web_hook_topic_view_serializer_spec.rb | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index 7ca94fd87a..44479aa7e0 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -36,7 +36,6 @@ class TopicViewSerializer < ApplicationSerializer :deleted_at, :pending_posts_count, :user_id, - :pm_with_non_human_user?, :featured_link, :featured_link_root_domain, :pinned_globally, @@ -67,7 +66,8 @@ class TopicViewSerializer < ApplicationSerializer :unicode_title, :message_bus_last_id, :participant_count, - :destination_category_id + :destination_category_id, + :pm_with_non_human_user, # TODO: Split off into proper object / serializer def details @@ -281,6 +281,10 @@ class TopicViewSerializer < ApplicationSerializer private_message?(object.topic) end + def pm_with_non_human_user + object.topic.pm_with_non_human_user? + end + def participant_count object.participant_count end diff --git a/app/serializers/web_hook_topic_view_serializer.rb b/app/serializers/web_hook_topic_view_serializer.rb index 8c1aa0a3a7..1aa71ccfd1 100644 --- a/app/serializers/web_hook_topic_view_serializer.rb +++ b/app/serializers/web_hook_topic_view_serializer.rb @@ -1,6 +1,8 @@ require_dependency 'pinned_check' class WebHookTopicViewSerializer < TopicViewSerializer + attributes :created_by, + :last_poster %i{ post_stream @@ -11,9 +13,24 @@ class WebHookTopicViewSerializer < TopicViewSerializer draft_sequence message_bus_last_id suggested_topics + has_summary + actions_summary + current_post_number + chunk_size + topic_timer + private_topic_timer + details }.each do |attr| define_method("include_#{attr}?") do false end end + + def created_by + BasicUserSerializer.new(object.topic.user, scope: scope, root: false) + end + + def last_poster + BasicUserSerializer.new(object.topic.last_poster, scope: scope, root: false) + end end diff --git a/spec/serializers/web_hook_topic_view_serializer_spec.rb b/spec/serializers/web_hook_topic_view_serializer_spec.rb index 2f120b2656..d7ee100555 100644 --- a/spec/serializers/web_hook_topic_view_serializer_spec.rb +++ b/spec/serializers/web_hook_topic_view_serializer_spec.rb @@ -13,7 +13,7 @@ RSpec.describe WebHookTopicViewSerializer do it 'should only include the required keys' do count = serializer.as_json.keys.count - difference = count - 35 + difference = count - 30 expect(difference).to eq(0), lambda { message = "" From 60e7b13f59e8c78fecbf98f669ffbfb1a594e17c Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 21 May 2018 10:52:57 +0800 Subject: [PATCH 067/278] Trim `WebHookPostSerializer` further. --- app/serializers/post_serializer.rb | 4 ++++ app/serializers/web_hook_post_serializer.rb | 6 ++++++ spec/serializers/web_hook_post_serializer_spec.rb | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index f12ad4fcca..e66fa720fd 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -374,6 +374,10 @@ class PostSerializer < BasicPostSerializer object.revisions.size > 0 end + def include_hidden_reason_id? + object.hidden + end + private def topic diff --git a/app/serializers/web_hook_post_serializer.rb b/app/serializers/web_hook_post_serializer.rb index 31e214cecc..455c1383e7 100644 --- a/app/serializers/web_hook_post_serializer.rb +++ b/app/serializers/web_hook_post_serializer.rb @@ -12,6 +12,12 @@ class WebHookPostSerializer < PostSerializer can_delete can_recover can_wiki + actions_summary + can_view_edit_history + yours + primary_group_flair_url + primary_group_flair_bg_color + primary_group_flair_color }.each do |attr| define_method("include_#{attr}?") do false diff --git a/spec/serializers/web_hook_post_serializer_spec.rb b/spec/serializers/web_hook_post_serializer_spec.rb index 51cd450746..2d56df376c 100644 --- a/spec/serializers/web_hook_post_serializer_spec.rb +++ b/spec/serializers/web_hook_post_serializer_spec.rb @@ -10,7 +10,7 @@ RSpec.describe WebHookPostSerializer do it 'should only include the required keys' do count = serialized_for_user(admin).keys.count - difference = count - 41 + difference = count - 34 expect(difference).to eq(0), lambda { message = "" From 00c6b078e36b81b3b892eca782c9bfe7f7d6ba4a Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 21 May 2018 11:25:01 +0800 Subject: [PATCH 068/278] Trim `WebHookUserSerializer`. --- app/serializers/user_serializer.rb | 26 ++++++++++++++----- app/serializers/web_hook_user_serializer.rb | 25 ++++++++++++++++++ .../web_hook_user_serializer_spec.rb | 17 ++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index b93aa2ee78..f7b56ebf4b 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -339,22 +339,36 @@ class UserSerializer < BasicUserSerializer User.system_avatar_template(object.username) end + def include_gravatar_avatar_upload_id? + object.user_avatar&.gravatar_upload_id + end + def gravatar_avatar_upload_id - object.user_avatar.try(:gravatar_upload_id) + object.user_avatar.gravatar_upload_id + end + + def include_gravatar_avatar_template? + include_gravatar_avatar_upload_id? end def gravatar_avatar_template - return unless gravatar_upload_id = object.user_avatar.try(:gravatar_upload_id) - User.avatar_template(object.username, gravatar_upload_id) + User.avatar_template(object.username, object.user_avatar.gravatar_upload_id) + end + + def include_custom_avatar_upload_id? + object.user_avatar&.custom_upload_id end def custom_avatar_upload_id - object.user_avatar.try(:custom_upload_id) + object.user_avatar.custom_upload_id + end + + def include_custom_avatar_template? + include_custom_avatar_upload_id? end def custom_avatar_template - return unless custom_upload_id = object.user_avatar.try(:custom_upload_id) - User.avatar_template(object.username, custom_upload_id) + User.avatar_template(object.username, object.user_avatar.custom_upload_id) end def has_title_badges diff --git a/app/serializers/web_hook_user_serializer.rb b/app/serializers/web_hook_user_serializer.rb index e964174bad..d715e4b025 100644 --- a/app/serializers/web_hook_user_serializer.rb +++ b/app/serializers/web_hook_user_serializer.rb @@ -5,6 +5,31 @@ class WebHookUserSerializer < UserSerializer def staff_attributes(*attrs) end + %i{ + can_edit + can_edit_username + can_edit_email + can_edit_name + can_send_private_messages + can_send_private_message_to_user + uploaded_avatar_id + has_title_badges + bio_cooked + custom_fields + can_be_deleted + can_delete_all_posts + system_avatar_upload_id + gravatar_avatar_upload_id + custom_avatar_upload_id + can_change_bio + user_api_keys + group_users + }.each do |attr| + define_method("include_#{attr}?") do + false + end + end + def include_email? scope.is_admin? end diff --git a/spec/serializers/web_hook_user_serializer_spec.rb b/spec/serializers/web_hook_user_serializer_spec.rb index 3734587bb3..eb5e417b14 100644 --- a/spec/serializers/web_hook_user_serializer_spec.rb +++ b/spec/serializers/web_hook_user_serializer_spec.rb @@ -18,4 +18,21 @@ RSpec.describe WebHookUserSerializer do expect(payload[:email]).to eq(user.email) expect(payload[:external_id]).to eq('12345') end + + it 'should only include the required keys' do + count = serializer.as_json.keys.count + difference = count - 42 + + expect(difference).to eq(0), lambda { + message = "" + + if difference < 0 + message << "#{difference * -1} key(s) have been removed from this serializer." + else + message << "#{difference} key(s) have been added to this serializer." + end + + message << "\nPlease verify if those key(s) are required as part of the web hook's payload." + } + end end From f0024f5ec7f5b64400dc6b5db2e745a07d086b9c Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 21 May 2018 11:32:54 +0800 Subject: [PATCH 069/278] Inherit from `BasicGroupSerializer` for `WebHookGroupSerializer`. --- app/serializers/web_hook_group_serializer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/serializers/web_hook_group_serializer.rb b/app/serializers/web_hook_group_serializer.rb index c580451db8..66264abcaf 100644 --- a/app/serializers/web_hook_group_serializer.rb +++ b/app/serializers/web_hook_group_serializer.rb @@ -1,4 +1,4 @@ -class WebHookGroupSerializer < GroupShowSerializer +class WebHookGroupSerializer < BasicGroupSerializer %i{ is_group_user From cba3942850bea96effeaa6dec19bcf21fac7dc20 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 21 May 2018 11:37:16 +0800 Subject: [PATCH 070/278] Refactor `WebHookFlagSerializer`. --- app/serializers/web_hook_flag_serializer.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/serializers/web_hook_flag_serializer.rb b/app/serializers/web_hook_flag_serializer.rb index 67674fef8e..847ae810e3 100644 --- a/app/serializers/web_hook_flag_serializer.rb +++ b/app/serializers/web_hook_flag_serializer.rb @@ -8,7 +8,7 @@ class WebHookFlagSerializer < ApplicationSerializer :resolved_by def post - BasicPostSerializer.new(object.post, scope: scope, root: false).as_json + WebHookPostSerializer.new(object.post, scope: scope, root: false).as_json end def flag_type @@ -32,9 +32,7 @@ class WebHookFlagSerializer < ApplicationSerializer end def resolved_by - if object.disposed_by_id.present? - User.find(object.disposed_by_id).username - end + User.find(object.disposed_by_id).username end def include_resolved_by? From bf84037f79ed8f2f5b5db0e22e2a17fbc7d38077 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 21 May 2018 16:23:09 +0800 Subject: [PATCH 071/278] FIX: Payload for webhooks should be current as of the time the event was triggered. https://meta.discourse.org/t/group-category-tag-user-deleted-webhooks-not-firing/87752 --- app/jobs/regular/emit_web_hook_event.rb | 60 ++----- app/models/web_hook.rb | 69 ++++++-- config/initializers/012-web_hook_events.rb | 36 +++-- spec/jobs/emit_web_hook_event_spec.rb | 97 ++++++----- spec/models/web_hook_spec.rb | 179 +++++++++++++-------- spec/services/web_hook_enqueuer_spec.rb | 28 ++++ 6 files changed, 288 insertions(+), 181 deletions(-) create mode 100644 spec/services/web_hook_enqueuer_spec.rb diff --git a/app/jobs/regular/emit_web_hook_event.rb b/app/jobs/regular/emit_web_hook_event.rb index 0b5b1adf89..4a96851367 100644 --- a/app/jobs/regular/emit_web_hook_event.rb +++ b/app/jobs/regular/emit_web_hook_event.rb @@ -2,8 +2,14 @@ require 'excon' module Jobs class EmitWebHookEvent < Jobs::Base + PING_EVENT = 'ping'.freeze + def execute(args) - [:web_hook_id, :event_type].each do |key| + %i{ + web_hook_id + event_type + payload + }.each do |key| raise Discourse::InvalidParameters.new(key) unless args[key].present? end @@ -19,8 +25,7 @@ module Jobs return if web_hook.category_ids.present? && (!args[:category_id].present? || !web_hook.category_ids.include?(args[:category_id])) - event_type = args[:event_type].to_s - return unless self.send("setup_#{event_type}", args) + args[:payload] = JSON.parse(args[:payload]) end web_hook_request(args, web_hook) @@ -32,50 +37,8 @@ module Jobs Guardian.new(Discourse.system_user) end - def setup_post(args) - post = Post.with_deleted.find_by(id: args[:post_id]) - return if post.blank? - args[:payload] = WebHookPostSerializer.new(post, scope: guardian, root: false).as_json - end - - def setup_topic(args) - topic_view = TopicView.new(args[:topic_id], Discourse.system_user) - return if topic_view.blank? - args[:payload] = WebHookTopicViewSerializer.new(topic_view, scope: guardian, root: false).as_json - end - - def setup_user(args) - user = User.find_by(id: args[:user_id]) - return if user.blank? - args[:payload] = WebHookUserSerializer.new(user, scope: guardian, root: false).as_json - end - - def setup_group(args) - group = Group.find_by(id: args[:group_id]) - return if group.blank? - args[:payload] = WebHookGroupSerializer.new(group, scope: guardian, root: false).as_json - end - - def setup_category(args) - category = Category.find_by(id: args[:category_id]) - return if category.blank? - args[:payload] = WebHookCategorySerializer.new(category, scope: guardian, root: false).as_json - end - - def setup_tag(args) - tag = Tag.find_by(id: args[:tag_id]) - return if tag.blank? - args[:payload] = TagSerializer.new(tag, scope: guardian, root: false).as_json - end - - def setup_flag(args) - flag = PostAction.find_by(id: args[:flag_id]) - return if flag.blank? - args[:payload] = WebHookFlagSerializer.new(flag, scope: guardian, root: false).as_json - end - def ping_event?(event_type) - event_type.to_s == 'ping'.freeze + PING_EVENT == event_type.to_s end def build_web_hook_body(args, web_hook) @@ -89,7 +52,6 @@ module Jobs end new_body = Plugin::Filter.apply(:after_build_web_hook_body, self, body) - MultiJson.dump(new_body) end @@ -120,7 +82,7 @@ module Jobs 'Content-Length' => body.bytesize, 'Content-Type' => content_type, 'Host' => uri.host, - 'User-Agent' => "Discourse/" + Discourse::VERSION::STRING, + 'User-Agent' => "Discourse/#{Discourse::VERSION::STRING}", 'X-Discourse-Instance' => Discourse.base_url, 'X-Discourse-Event-Id' => web_hook_event.id, 'X-Discourse-Event-Type' => args[:event_type] @@ -129,7 +91,7 @@ module Jobs headers['X-Discourse-Event'] = args[:event_name].to_s if args[:event_name].present? if web_hook.secret.present? - headers['X-Discourse-Event-Signature'] = "sha256=" + OpenSSL::HMAC.hexdigest("sha256", web_hook.secret, body) + headers['X-Discourse-Event-Signature'] = "sha256=#{OpenSSL::HMAC.hexdigest("sha256", web_hook.secret, body)}" end now = Time.zone.now diff --git a/app/models/web_hook.rb b/app/models/web_hook.rb index dd8df00b67..20f22311e2 100644 --- a/app/models/web_hook.rb +++ b/app/models/web_hook.rb @@ -30,30 +30,79 @@ class WebHook < ActiveRecord::Base [WebHookEventType.find(WebHookEventType::POST)] end - def self.find_by_type(type) + def strip_url + self.payload_url = (payload_url || "").strip.presence + end + + def self.active_web_hooks(type) WebHook.where(active: true) .joins(:web_hook_event_types) .where("web_hooks.wildcard_web_hook = ? OR web_hook_event_types.name = ?", true, type.to_s) .uniq end - def self.enqueue_hooks(type, opts = {}) - find_by_type(type).each do |w| - Jobs.enqueue(:emit_web_hook_event, opts.merge(web_hook_id: w.id, event_type: type.to_s)) + def self.enqueue_hooks(type, opts = {}, web_hooks = nil) + (web_hooks || active_web_hooks(type)).each do |web_hook| + Jobs.enqueue(:emit_web_hook_event, opts.merge( + web_hook_id: web_hook.id, event_type: type.to_s + )) end end - def self.enqueue_topic_hooks(event, topic, user = nil) - WebHook.enqueue_hooks(:topic, topic_id: topic.id, category_id: topic&.category_id, event_name: event.to_s) + def self.enqueue_object_hooks(type, object, event, serializer = nil) + Scheduler::Defer.later("Enqueue User Webhook") do + web_hooks = active_web_hooks(type) + return if web_hooks.empty? + serializer ||= "WebHook#{type.capitalize}Serializer".constantize + + WebHook.enqueue_hooks(type, { + event_name: event.to_s, + payload: serializer.new(object, + scope: self.guardian, + root: false + ).to_json + }, web_hooks) + end end - def self.enqueue_post_hooks(event, post, user = nil) - WebHook.enqueue_hooks(:post, post_id: post.id, category_id: post&.topic&.category_id, event_name: event.to_s) + def self.enqueue_topic_hooks(event, topic) + Scheduler::Defer.later("Enqueue Topic Webhook") do + web_hooks = active_web_hooks('topic') + return if web_hooks.empty? + topic_view = TopicView.new(topic.id, Discourse.system_user) + + WebHook.enqueue_hooks(:topic, { + category_id: topic&.category_id, + event_name: event.to_s, + payload: WebHookTopicViewSerializer.new(topic_view, + scope: self.guardian, + root: false + ).to_json + }, web_hooks) + end end - def strip_url - self.payload_url = (payload_url || "").strip.presence + def self.enqueue_post_hooks(event, post) + Scheduler::Defer.later("Enqueue Post Webhook") do + web_hooks = active_web_hooks('post') + return if web_hooks.empty? + + WebHook.enqueue_hooks(:post, { + category_id: post&.topic&.category_id, + event_name: event.to_s, + payload: WebHookPostSerializer.new(post, + scope: self.guardian, + root: false + ).to_json + }, web_hooks) + end end + + private + + def self.guardian + @guardian ||= Guardian.new(Discourse.system_user) + end end # == Schema Information diff --git a/config/initializers/012-web_hook_events.rb b/config/initializers/012-web_hook_events.rb index 17ab7ad0dd..731792717f 100644 --- a/config/initializers/012-web_hook_events.rb +++ b/config/initializers/012-web_hook_events.rb @@ -1,6 +1,9 @@ -%i(topic_destroyed topic_recovered).each do |event| - DiscourseEvent.on(event) do |topic, user| - WebHook.enqueue_topic_hooks(event, topic, user) +%i( + topic_destroyed + topic_recovered +).each do |event| + DiscourseEvent.on(event) do |topic, _| + WebHook.enqueue_topic_hooks(event, topic) end end @@ -8,16 +11,17 @@ DiscourseEvent.on(:topic_status_updated) do |topic, status| WebHook.enqueue_topic_hooks("topic_#{status}_status_updated", topic) end -DiscourseEvent.on(:topic_created) do |topic, _, user| - WebHook.enqueue_topic_hooks(:topic_created, topic, user) +DiscourseEvent.on(:topic_created) do |topic, _, _| + WebHook.enqueue_topic_hooks(:topic_created, topic) end -%i(post_created - post_destroyed - post_recovered).each do |event| - - DiscourseEvent.on(event) do |post, _, user| - WebHook.enqueue_post_hooks(event, post, user) +%i( + post_created + post_destroyed + post_recovered +).each do |event| + DiscourseEvent.on(event) do |post, _, _| + WebHook.enqueue_post_hooks(event, post) end end @@ -39,7 +43,7 @@ end user_updated ).each do |event| DiscourseEvent.on(event) do |user| - WebHook.enqueue_hooks(:user, user_id: user.id, event_name: event.to_s) + WebHook.enqueue_object_hooks(:user, user, event) end end @@ -49,7 +53,7 @@ end group_destroyed ).each do |event| DiscourseEvent.on(event) do |group| - WebHook.enqueue_hooks(:group, group_id: group.id, event_name: event.to_s) + WebHook.enqueue_object_hooks(:group, group, event) end end @@ -59,7 +63,7 @@ end category_destroyed ).each do |event| DiscourseEvent.on(event) do |category| - WebHook.enqueue_hooks(:category, category_id: category.id, event_name: event.to_s) + WebHook.enqueue_object_hooks(:category, category, event) end end @@ -69,7 +73,7 @@ end tag_destroyed ).each do |event| DiscourseEvent.on(event) do |tag| - WebHook.enqueue_hooks(:tag, tag_id: tag.id, event_name: event.to_s) + WebHook.enqueue_object_hooks(:tag, tag, event) end end @@ -80,6 +84,6 @@ end flag_deferred ).each do |event| DiscourseEvent.on(event) do |flag| - WebHook.enqueue_hooks(:flag, flag_id: flag.id, event_name: event.to_s) + WebHook.enqueue_object_hooks(:flag, flag, event, WebHookFlagSerializer) end end diff --git a/spec/jobs/emit_web_hook_event_spec.rb b/spec/jobs/emit_web_hook_event_spec.rb index 29be1e6925..7354d78b94 100644 --- a/spec/jobs/emit_web_hook_event_spec.rb +++ b/spec/jobs/emit_web_hook_event_spec.rb @@ -7,21 +7,41 @@ describe Jobs::EmitWebHookEvent do let(:user) { Fabricate(:user) } it 'raises an error when there is no web hook record' do - expect { subject.execute(event_type: 'post') }.to raise_error(Discourse::InvalidParameters) + expect do + subject.execute(event_type: 'post', payload: {}) + end.to raise_error(Discourse::InvalidParameters) end it 'raises an error when there is no event type' do - expect { subject.execute(web_hook_id: 1) }.to raise_error(Discourse::InvalidParameters) + expect do + subject.execute(web_hook_id: 1, payload: {}) + end.to raise_error(Discourse::InvalidParameters) + end + + it 'raises an error when there is no payload' do + expect do + subject.execute(web_hook_id: 1, event_type: 'post') + end.to raise_error(Discourse::InvalidParameters) end it "doesn't emit when the hook is inactive" do - Jobs::EmitWebHookEvent.any_instance.expects(:web_hook_request).never - subject.execute(web_hook_id: inactive_hook.id, event_type: 'post', post_id: post.id) + subject.execute( + web_hook_id: inactive_hook.id, + event_type: 'post', + payload: { test: "some payload" }.to_json + ) end it 'emits normally with sufficient arguments' do - Jobs::EmitWebHookEvent.any_instance.expects(:web_hook_request).once - subject.execute(web_hook_id: post_hook.id, event_type: 'post', post_id: post.id) + stub_request(:post, "https://meta.discourse.org/webhook_listener") + .with(body: "{\"post\":{\"test\":\"some payload\"}}") + .to_return(body: 'OK', status: 200) + + subject.execute( + web_hook_id: post_hook.id, + event_type: 'post', + payload: { test: "some payload" }.to_json + ) end context 'with category filters' do @@ -31,69 +51,64 @@ describe Jobs::EmitWebHookEvent do let(:topic_hook) { Fabricate(:topic_web_hook, categories: [category]) } it "doesn't emit when event is not related with defined categories" do - Jobs::EmitWebHookEvent.any_instance.expects(:web_hook_request).never - - subject.execute(web_hook_id: topic_hook.id, - event_type: 'topic', - topic_id: topic.id, - user_id: user.id, - category_id: topic.category.id) + subject.execute( + web_hook_id: topic_hook.id, + event_type: 'topic', + category_id: topic.category.id, + payload: { test: "some payload" }.to_json + ) end it 'emit when event is related with defined categories' do - Jobs::EmitWebHookEvent.any_instance.expects(:web_hook_request).once + stub_request(:post, "https://meta.discourse.org/webhook_listener") + .with(body: "{\"topic\":{\"test\":\"some payload\"}}") + .to_return(body: 'OK', status: 200) - subject.execute(web_hook_id: topic_hook.id, - event_type: 'topic', - topic_id: topic_with_category.id, - user_id: user.id, - category_id: topic_with_category.category.id) + subject.execute( + web_hook_id: topic_hook.id, + event_type: 'topic', + category_id: topic_with_category.category.id, + payload: { test: "some payload" }.to_json + ) end end - describe '.web_hook_request' do + describe '#web_hook_request' do it 'creates delivery event record' do stub_request(:post, "https://meta.discourse.org/webhook_listener") .to_return(body: 'OK', status: 200) WebHookEventType.all.pluck(:name).each do |name| web_hook_id = Fabricate("#{name}_web_hook").id - object_id = Fabricate(name).id expect do - subject.execute(web_hook_id: web_hook_id, event_type: name, "#{name}_id": object_id) + subject.execute( + web_hook_id: web_hook_id, + event_type: name, + payload: { test: "some payload" }.to_json + ) end.to change(WebHookEvent, :count).by(1) end end - it 'skips silently on missing post' do - expect do - subject.execute(web_hook_id: post_hook.id, event_type: 'post', post_id: (Post.maximum(:id).to_i + 1)) - end.not_to raise_error - end - - it 'should not skip trashed post' do - stub_request(:post, "https://meta.discourse.org/webhook_listener") - .to_return(body: 'OK', status: 200) - - expect do - post.trash! - subject.execute(web_hook_id: post_hook.id, event_type: 'post', post_id: post.id) - end.to change(WebHookEvent, :count).by(1) - end - it 'sets up proper request headers' do stub_request(:post, "https://meta.discourse.org/webhook_listener") .to_return(headers: { test: 'string' }, body: 'OK', status: 200) - subject.execute(web_hook_id: post_hook.id, event_type: 'ping', event_name: 'ping') + subject.execute( + web_hook_id: post_hook.id, + event_type: described_class::PING_EVENT, + event_name: described_class::PING_EVENT, + payload: { test: "some payload" }.to_json + ) + event = WebHookEvent.last headers = MultiJson.load(event.headers) expect(headers['Content-Length']).to eq(13) expect(headers['Host']).to eq("meta.discourse.org") expect(headers['X-Discourse-Event-Id']).to eq(event.id) - expect(headers['X-Discourse-Event-Type']).to eq('ping') - expect(headers['X-Discourse-Event']).to eq('ping') + expect(headers['X-Discourse-Event-Type']).to eq(described_class::PING_EVENT) + expect(headers['X-Discourse-Event']).to eq(described_class::PING_EVENT) expect(headers['X-Discourse-Event-Signature']).to eq('sha256=162f107f6b5022353274eb1a7197885cfd35744d8d08e5bcea025d309386b7d6') expect(event.payload).to eq(MultiJson.dump(ping: 'OK')) expect(event.status).to eq(200) diff --git a/spec/models/web_hook_spec.rb b/spec/models/web_hook_spec.rb index 3b951a8f09..e18f31f270 100644 --- a/spec/models/web_hook_spec.rb +++ b/spec/models/web_hook_spec.rb @@ -42,69 +42,76 @@ describe WebHook do expect(post_hook.payload_url).to eq("https://example.com") end - describe '#find_by_type' do + describe '#active_web_hooks' do it "returns unique hooks" do post_hook.web_hook_event_types << WebHookEventType.find_by(name: 'topic') post_hook.update!(wildcard_web_hook: true) - expect(WebHook.find_by_type(:post)).to eq([post_hook]) + expect(WebHook.active_web_hooks(:post)).to eq([post_hook]) end it 'find relevant hooks' do - expect(WebHook.find_by_type(:post)).to eq([post_hook]) - expect(WebHook.find_by_type(:topic)).to eq([topic_hook]) + expect(WebHook.active_web_hooks(:post)).to eq([post_hook]) + expect(WebHook.active_web_hooks(:topic)).to eq([topic_hook]) end it 'excludes inactive hooks' do - post_hook.update_attributes!(active: false) + post_hook.update!(active: false) - expect(WebHook.find_by_type(:post)).to eq([]) - expect(WebHook.find_by_type(:topic)).to eq([topic_hook]) + expect(WebHook.active_web_hooks(:post)).to eq([]) + expect(WebHook.active_web_hooks(:topic)).to eq([topic_hook]) + end + + describe 'wildcard web hooks' do + let!(:wildcard_hook) { Fabricate(:wildcard_web_hook) } + + it 'should include wildcard hooks' do + expect(WebHook.active_web_hooks(:wildcard)).to eq([wildcard_hook]) + + expect(WebHook.active_web_hooks(:post)).to contain_exactly( + post_hook, wildcard_hook + ) + + expect(WebHook.active_web_hooks(:topic)).to contain_exactly( + topic_hook, wildcard_hook + ) + end end end describe '#enqueue_hooks' do - it 'enqueues hooks with id and name' do - Jobs.expects(:enqueue).with(:emit_web_hook_event, web_hook_id: post_hook.id, event_type: 'post') - - WebHook.enqueue_hooks(:post) + before do + SiteSetting.queue_jobs = true end it 'accepts additional parameters' do - Jobs.expects(:enqueue).with(:emit_web_hook_event, web_hook_id: post_hook.id, post_id: 1, event_type: 'post') + payload = { test: 'some payload' }.to_json + WebHook.enqueue_hooks(:post, payload: payload) - WebHook.enqueue_hooks(:post, post_id: 1) - end - end + job_args = Jobs::EmitWebHookEvent.jobs.first["args"].first - context 'includes wildcard hooks' do - let!(:wildcard_hook) { Fabricate(:wildcard_web_hook) } - - describe '#find_by_type' do - it 'can find wildcard hooks' do - expect(WebHook.find_by_type(:wildcard)).to eq([wildcard_hook]) - end - - it 'can include wildcard hooks' do - expect(WebHook.find_by_type(:post).sort_by(&:id)).to eq([post_hook, wildcard_hook]) - expect(WebHook.find_by_type(:topic).sort_by(&:id)).to eq([topic_hook, wildcard_hook]) - - end + expect(job_args["web_hook_id"]).to eq(post_hook.id) + expect(job_args["event_type"]).to eq('post') + expect(job_args["payload"]).to eq(payload) end - describe '#enqueue_hooks' do - it 'enqueues hooks with ids' do - Jobs.expects(:enqueue).with(:emit_web_hook_event, web_hook_id: post_hook.id, event_type: 'post') - Jobs.expects(:enqueue).with(:emit_web_hook_event, web_hook_id: wildcard_hook.id, event_type: 'post') + context 'includes wildcard hooks' do + let!(:wildcard_hook) { Fabricate(:wildcard_web_hook) } - WebHook.enqueue_hooks(:post) - end + describe '#enqueue_hooks' do + it 'enqueues hooks with ids' do + WebHook.enqueue_hooks(:post) - it 'accepts additional parameters' do - Jobs.expects(:enqueue).with(:emit_web_hook_event, web_hook_id: post_hook.id, post_id: 1, event_type: 'post') - Jobs.expects(:enqueue).with(:emit_web_hook_event, web_hook_id: wildcard_hook.id, post_id: 1, event_type: 'post') + job_args = Jobs::EmitWebHookEvent.jobs.first["args"].first - WebHook.enqueue_hooks(:post, post_id: 1) + expect(job_args["web_hook_id"]).to eq(post_hook.id) + expect(job_args["event_type"]).to eq('post') + + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["web_hook_id"]).to eq(wildcard_hook.id) + expect(job_args["event_type"]).to eq('post') + end end end end @@ -115,39 +122,51 @@ describe WebHook do let(:admin) { Fabricate(:admin) } let(:topic) { Fabricate(:topic, user: user) } let(:post) { Fabricate(:post, topic: topic, user: user) } + let(:topic_web_hook) { Fabricate(:topic_web_hook) } before do SiteSetting.queue_jobs = true + topic_web_hook + end + + describe 'when there are no active hooks' do + it 'should not enqueue anything' do + topic_web_hook.destroy! + post = PostCreator.create(user, raw: 'post', title: 'topic', skip_validations: true) + expect(Jobs::EmitWebHookEvent.jobs.length).to eq(0) + end end it 'should enqueue the right hooks for topic events' do - Fabricate(:topic_web_hook) - post = PostCreator.create(user, raw: 'post', title: 'topic', skip_validations: true) - topic_id = post.topic_id + topic_id = post.topic.id job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("topic_created") - expect(job_args["topic_id"]).to eq(topic_id) + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(topic_id) PostDestroyer.new(user, post).destroy job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("topic_destroyed") - expect(job_args["topic_id"]).to eq(topic_id) + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(topic_id) PostDestroyer.new(user, post).recover job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("topic_recovered") - expect(job_args["topic_id"]).to eq(topic_id) + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(topic_id) %w{archived closed visible}.each do |status| post.topic.update_status(status, true, topic.user) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("topic_#{status}_status_updated") - expect(job_args["topic_id"]).to eq(topic_id) + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(topic_id) end end @@ -172,10 +191,7 @@ describe WebHook do it 'should enqueue the right hooks for post events' do Fabricate(:web_hook) - user - topic - - post = PostCreator.create(user, + post = PostCreator.create!(user, raw: 'post', topic_id: topic.id, reply_to_post_number: 1, @@ -183,30 +199,57 @@ describe WebHook do ) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first - Sidekiq::Worker.clear_all expect(job_args["event_name"]).to eq("post_created") - expect(job_args["post_id"]).to eq(post.id) + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(post.id) + + Jobs::EmitWebHookEvent.jobs.clear # post destroy or recover triggers a moderator post expect { PostDestroyer.new(user, post).destroy } - .to change { Jobs::EmitWebHookEvent.jobs.count }.by(2) + .to change { Jobs::EmitWebHookEvent.jobs.count }.by(3) - job_args = Jobs::EmitWebHookEvent.jobs.first["args"].first + job_args = Jobs::EmitWebHookEvent.jobs[0]["args"].first expect(job_args["event_name"]).to eq("post_edited") - expect(job_args["post_id"]).to eq(post.id) + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(post.id) - job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + job_args = Jobs::EmitWebHookEvent.jobs[1]["args"].first expect(job_args["event_name"]).to eq("post_destroyed") - expect(job_args["post_id"]).to eq(post.id) + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(post.id) - PostDestroyer.new(user, post).recover - job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + job_args = Jobs::EmitWebHookEvent.jobs[2]["args"].first + + expect(job_args["event_name"]).to eq("topic_destroyed") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(post.topic.id) + + Jobs::EmitWebHookEvent.jobs.clear + + expect { PostDestroyer.new(user, post).recover } + .to change { Jobs::EmitWebHookEvent.jobs.count }.by(3) + + job_args = Jobs::EmitWebHookEvent.jobs[0]["args"].first + + expect(job_args["event_name"]).to eq("post_edited") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(post.id) + + job_args = Jobs::EmitWebHookEvent.jobs[1]["args"].first expect(job_args["event_name"]).to eq("post_recovered") - expect(job_args["post_id"]).to eq(post.id) + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(post.id) + + job_args = Jobs::EmitWebHookEvent.jobs[2]["args"].first + + expect(job_args["event_name"]).to eq("topic_recovered") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(post.topic.id) end it 'should enqueue the right hooks for user events' do @@ -216,37 +259,43 @@ describe WebHook do job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_created") - expect(job_args["user_id"]).to eq(user.id) + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(user.id) admin job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_created") - expect(job_args["user_id"]).to eq(admin.id) + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(admin.id) user.approve(admin) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_approved") - expect(job_args["user_id"]).to eq(user.id) + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(user.id) UserUpdater.new(admin, user).update(username: 'testing123') job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_updated") - expect(job_args["user_id"]).to eq(user.id) + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(user.id) user.logged_out job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_logged_out") - expect(job_args["user_id"]).to eq(user.id) + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(user.id) user.logged_in job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_logged_in") - expect(job_args["user_id"]).to eq(user.id) + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(user.id) end end end diff --git a/spec/services/web_hook_enqueuer_spec.rb b/spec/services/web_hook_enqueuer_spec.rb new file mode 100644 index 0000000000..1141128b47 --- /dev/null +++ b/spec/services/web_hook_enqueuer_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +RSpec.describe WebHookEnqueuer do + describe '#find_by_type' do + let(:enqueuer) { WebHookEnqueuer.new } + let!(:post_hook) { Fabricate(:web_hook, payload_url: " https://example.com ") } + let!(:topic_hook) { Fabricate(:topic_web_hook) } + + it "returns unique hooks" do + post_hook.web_hook_event_types << WebHookEventType.find_by(name: 'topic') + post_hook.update!(wildcard_web_hook: true) + + expect(enqueuer.find_by_type(:post)).to eq([post_hook]) + end + + it 'find relevant hooks' do + expect(enqueuer.find_by_type(:post)).to eq([post_hook]) + expect(enqueuer.find_by_type(:topic)).to eq([topic_hook]) + end + + it 'excludes inactive hooks' do + post_hook.update!(active: false) + + expect(enqueuer.find_by_type(:post)).to eq([]) + expect(enqueuer.find_by_type(:topic)).to eq([topic_hook]) + end + end +end From 467d91347a7e5c42b68ef2d963863ddaddbb12b1 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 21 May 2018 17:29:19 +0800 Subject: [PATCH 072/278] Missing specs for `Group`, `Tag`, `Category` and `Flag` web hooks. --- app/controllers/categories_controller.rb | 2 - app/controllers/groups_controller.rb | 2 - app/controllers/tags_controller.rb | 1 - app/models/category.rb | 18 ++-- app/models/group.rb | 18 ++-- app/models/tag.rb | 18 ++-- config/initializers/012-web_hook_events.rb | 4 +- spec/models/post_action_spec.rb | 2 +- spec/models/web_hook_spec.rb | 118 +++++++++++++++++++++ 9 files changed, 151 insertions(+), 32 deletions(-) diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 027a5ddf62..f9216fc56b 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -150,7 +150,6 @@ class CategoriesController < ApplicationController old_permissions = cat.permissions_params if result = cat.update(category_params) - DiscourseEvent.trigger(:category_updated, cat) Scheduler::Defer.later "Log staff action change category settings" do @staff_action_logger.log_category_settings_change(@category, category_params, old_permissions) end @@ -167,7 +166,6 @@ class CategoriesController < ApplicationController custom_slug = params[:slug].to_s if custom_slug.present? && @category.update_attributes(slug: custom_slug) - DiscourseEvent.trigger(:category_updated, @category) render json: success_json else render_json_error(@category) diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 54906c2d61..40dab0ff3e 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -136,8 +136,6 @@ class GroupsController < ApplicationController if group.update(group_params(automatic: group.automatic)) GroupActionLogger.new(current_user, group).log_change_group_settings - DiscourseEvent.trigger(:group_updated, group) - render json: success_json else render_json_error(group) diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 09ad79c44f..a673bb06d0 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -111,7 +111,6 @@ class TagsController < ::ApplicationController tag.name = new_tag_name if tag.save StaffActionLogger.new(current_user).log_custom('renamed_tag', previous_value: params[:tag_id], new_value: new_tag_name) - DiscourseEvent.trigger(:tag_updated, tag) render json: { tag: { id: new_tag_name } } else render_json_error tag.errors.full_messages diff --git a/app/models/category.rb b/app/models/category.rb index 432509ded6..4d042500af 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -62,6 +62,7 @@ class Category < ActiveRecord::Base after_update :create_category_permalink, if: :saved_change_to_slug? after_commit :trigger_category_created_event, on: :create + after_commit :trigger_category_updated_event, on: :update after_commit :trigger_category_destroyed_event, on: :destroy belongs_to :parent_category, class_name: 'Category' @@ -512,14 +513,15 @@ SQL subcategory_list_style.end_with?("with_featured_topics") end - def trigger_category_created_event - DiscourseEvent.trigger(:category_created, self) - true - end - - def trigger_category_destroyed_event - DiscourseEvent.trigger(:category_destroyed, self) - true + %i{ + category_created + category_updated + category_destroyed + }.each do |event| + define_method("trigger_#{event}_event") do + DiscourseEvent.trigger(event, self) + true + end end end diff --git a/app/models/group.rb b/app/models/group.rb index 53f9d6a3ca..1a895775de 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -36,6 +36,7 @@ class Group < ActiveRecord::Base after_destroy :expire_cache after_commit :trigger_group_created_event, on: :create + after_commit :trigger_group_updated_event, on: :update after_commit :trigger_group_destroyed_event, on: :destroy def expire_cache @@ -584,14 +585,15 @@ class Group < ActiveRecord::Base self.member_of(groups, user).where("gu.owner") end - def trigger_group_created_event - DiscourseEvent.trigger(:group_created, self) - true - end - - def trigger_group_destroyed_event - DiscourseEvent.trigger(:group_destroyed, self) - true + %i{ + group_created + group_updated + group_destroyed + }.each do |event| + define_method("trigger_#{event}_event") do + DiscourseEvent.trigger(event, self) + true + end end protected diff --git a/app/models/tag.rb b/app/models/tag.rb index 62aee017d3..46ef77bde7 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -17,6 +17,7 @@ class Tag < ActiveRecord::Base after_save :index_search after_commit :trigger_tag_created_event, on: :create + after_commit :trigger_tag_updated_event, on: :update after_commit :trigger_tag_destroyed_event, on: :destroy def self.ensure_consistency! @@ -124,14 +125,15 @@ class Tag < ActiveRecord::Base SearchIndexer.index(self) end - def trigger_tag_created_event - DiscourseEvent.trigger(:tag_created, self) - true - end - - def trigger_tag_destroyed_event - DiscourseEvent.trigger(:tag_destroyed, self) - true + %i{ + tag_created + tag_updated + tag_destroyed + }.each do |event| + define_method("trigger_#{event}_event") do + DiscourseEvent.trigger(event, self) + true + end end end diff --git a/config/initializers/012-web_hook_events.rb b/config/initializers/012-web_hook_events.rb index 731792717f..671fbd56b7 100644 --- a/config/initializers/012-web_hook_events.rb +++ b/config/initializers/012-web_hook_events.rb @@ -73,7 +73,7 @@ end tag_destroyed ).each do |event| DiscourseEvent.on(event) do |tag| - WebHook.enqueue_object_hooks(:tag, tag, event) + WebHook.enqueue_object_hooks(:tag, tag, event, TagSerializer) end end @@ -84,6 +84,6 @@ end flag_deferred ).each do |event| DiscourseEvent.on(event) do |flag| - WebHook.enqueue_object_hooks(:flag, flag, event, WebHookFlagSerializer) + WebHook.enqueue_object_hooks(:flag, flag, event) end end diff --git a/spec/models/post_action_spec.rb b/spec/models/post_action_spec.rb index 7a10f34648..a8be0634a5 100644 --- a/spec/models/post_action_spec.rb +++ b/spec/models/post_action_spec.rb @@ -721,7 +721,7 @@ describe PostAction do end end - describe "triggers webhook events" do + describe "triggers Discourse events" do let(:post) { Fabricate(:post) } it 'flag created' do diff --git a/spec/models/web_hook_spec.rb b/spec/models/web_hook_spec.rb index e18f31f270..7057d26852 100644 --- a/spec/models/web_hook_spec.rb +++ b/spec/models/web_hook_spec.rb @@ -297,5 +297,123 @@ describe WebHook do payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(user.id) end + + it 'should enqueue the right hooks for category events' do + Fabricate(:category_web_hook) + category = Fabricate(:category) + + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("category_created") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(category.id) + + category.update!(slug: 'testing') + + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("category_updated") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(category.id) + expect(payload["slug"]).to eq('testing') + + category.destroy! + + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("category_destroyed") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(category.id) + end + + it 'should enqueue the right hooks for group events' do + Fabricate(:group_web_hook) + group = Fabricate(:group) + + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("group_created") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(group.id) + + group.update!(full_name: 'testing') + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("group_updated") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(group.id) + expect(payload["full_name"]).to eq('testing') + + group.destroy! + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("group_destroyed") + payload = JSON.parse(job_args["payload"]) + expect(payload["full_name"]).to eq('testing') + end + + it 'should enqueue the right hooks for tag events' do + Fabricate(:tag_web_hook) + tag = Fabricate(:tag) + + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("tag_created") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(tag.id) + + tag.update!(name: 'testing') + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("tag_updated") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(tag.id) + expect(payload["name"]).to eq('testing') + + tag.destroy! + + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("tag_destroyed") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(tag.id) + end + + it 'should enqueue the right hooks for flag events' do + post = Fabricate(:post) + admin = Fabricate(:admin) + moderator = Fabricate(:moderator) + Fabricate(:flag_web_hook) + + post_action = PostAction.act(admin, post, PostActionType.types[:spam]) + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("flag_created") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(post_action.id) + + PostAction.agree_flags!(post, moderator) + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("flag_agreed") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(post_action.id) + + PostAction.clear_flags!(post, moderator) + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("flag_disagreed") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(post_action.id) + + post = Fabricate(:post) + post_action = PostAction.act(admin, post, PostActionType.types[:spam]) + PostAction.defer_flags!(post, moderator) + job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first + + expect(job_args["event_name"]).to eq("flag_deferred") + payload = JSON.parse(job_args["payload"]) + expect(payload["id"]).to eq(post_action.id) + end end end From aae5575d331150da9eceac53066556ba311abb48 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 21 May 2018 17:35:44 +0800 Subject: [PATCH 073/278] Remove spec file that was accidentally added. --- spec/services/web_hook_enqueuer_spec.rb | 28 ------------------------- 1 file changed, 28 deletions(-) delete mode 100644 spec/services/web_hook_enqueuer_spec.rb diff --git a/spec/services/web_hook_enqueuer_spec.rb b/spec/services/web_hook_enqueuer_spec.rb deleted file mode 100644 index 1141128b47..0000000000 --- a/spec/services/web_hook_enqueuer_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -require 'rails_helper' - -RSpec.describe WebHookEnqueuer do - describe '#find_by_type' do - let(:enqueuer) { WebHookEnqueuer.new } - let!(:post_hook) { Fabricate(:web_hook, payload_url: " https://example.com ") } - let!(:topic_hook) { Fabricate(:topic_web_hook) } - - it "returns unique hooks" do - post_hook.web_hook_event_types << WebHookEventType.find_by(name: 'topic') - post_hook.update!(wildcard_web_hook: true) - - expect(enqueuer.find_by_type(:post)).to eq([post_hook]) - end - - it 'find relevant hooks' do - expect(enqueuer.find_by_type(:post)).to eq([post_hook]) - expect(enqueuer.find_by_type(:topic)).to eq([topic_hook]) - end - - it 'excludes inactive hooks' do - post_hook.update!(active: false) - - expect(enqueuer.find_by_type(:post)).to eq([]) - expect(enqueuer.find_by_type(:topic)).to eq([topic_hook]) - end - end -end From 37ad2dd93687adb4ecc42c84065f6d9fb0852a8b Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 21 May 2018 17:59:47 +0800 Subject: [PATCH 074/278] Fix the build. --- spec/serializers/web_hook_user_serializer_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/serializers/web_hook_user_serializer_spec.rb b/spec/serializers/web_hook_user_serializer_spec.rb index eb5e417b14..6b6d3b1a62 100644 --- a/spec/serializers/web_hook_user_serializer_spec.rb +++ b/spec/serializers/web_hook_user_serializer_spec.rb @@ -21,7 +21,7 @@ RSpec.describe WebHookUserSerializer do it 'should only include the required keys' do count = serializer.as_json.keys.count - difference = count - 42 + difference = count - 43 expect(difference).to eq(0), lambda { message = "" From bb12fa3fdcaf6c26362a9a07f66b052bb75f4e98 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Mon, 21 May 2018 18:19:22 +0530 Subject: [PATCH 075/278] Migrate user mentions in lithium import --- script/import_scripts/lithium.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/script/import_scripts/lithium.rb b/script/import_scripts/lithium.rb index 25fd75fb35..108fec921e 100644 --- a/script/import_scripts/lithium.rb +++ b/script/import_scripts/lithium.rb @@ -944,6 +944,21 @@ SQL end + # for user mentions + doc.css("li-user").each do |l| + uid = l["uid"] + + if uid.present? + user = UserCustomField.find_by(name: 'import_id', value: uid).try(:user) + if user.present? + username = user.username + span = doc.create_element "span" + span.inner_html = "@#{username}" + l.replace span + end + end + end + raw = ReverseMarkdown.convert(doc.to_s) raw.gsub!(/^\s* \s*$/, "") # ugly quotes From ae3a7ca08de64efa6fb6566d1f848daee4cc2685 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 21 May 2018 22:28:30 +0800 Subject: [PATCH 076/278] Fix unexpected return error. --- app/models/web_hook.rb | 59 ++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/app/models/web_hook.rb b/app/models/web_hook.rb index 20f22311e2..257357c4ea 100644 --- a/app/models/web_hook.rb +++ b/app/models/web_hook.rb @@ -52,49 +52,52 @@ class WebHook < ActiveRecord::Base def self.enqueue_object_hooks(type, object, event, serializer = nil) Scheduler::Defer.later("Enqueue User Webhook") do web_hooks = active_web_hooks(type) - return if web_hooks.empty? - serializer ||= "WebHook#{type.capitalize}Serializer".constantize + unless web_hooks.empty? + serializer ||= "WebHook#{type.capitalize}Serializer".constantize - WebHook.enqueue_hooks(type, { - event_name: event.to_s, - payload: serializer.new(object, - scope: self.guardian, - root: false - ).to_json - }, web_hooks) + WebHook.enqueue_hooks(type, { + event_name: event.to_s, + payload: serializer.new(object, + scope: self.guardian, + root: false + ).to_json + }, web_hooks) + end end end def self.enqueue_topic_hooks(event, topic) Scheduler::Defer.later("Enqueue Topic Webhook") do web_hooks = active_web_hooks('topic') - return if web_hooks.empty? - topic_view = TopicView.new(topic.id, Discourse.system_user) + unless web_hooks.empty? + topic_view = TopicView.new(topic.id, Discourse.system_user) - WebHook.enqueue_hooks(:topic, { - category_id: topic&.category_id, - event_name: event.to_s, - payload: WebHookTopicViewSerializer.new(topic_view, - scope: self.guardian, - root: false - ).to_json - }, web_hooks) + WebHook.enqueue_hooks(:topic, { + category_id: topic&.category_id, + event_name: event.to_s, + payload: WebHookTopicViewSerializer.new(topic_view, + scope: self.guardian, + root: false + ).to_json + }, web_hooks) + end end end def self.enqueue_post_hooks(event, post) Scheduler::Defer.later("Enqueue Post Webhook") do web_hooks = active_web_hooks('post') - return if web_hooks.empty? - WebHook.enqueue_hooks(:post, { - category_id: post&.topic&.category_id, - event_name: event.to_s, - payload: WebHookPostSerializer.new(post, - scope: self.guardian, - root: false - ).to_json - }, web_hooks) + unless web_hooks.empty? + WebHook.enqueue_hooks(:post, { + category_id: post&.topic&.category_id, + event_name: event.to_s, + payload: WebHookPostSerializer.new(post, + scope: self.guardian, + root: false + ).to_json + }, web_hooks) + end end end From 788ca1f11203c3c07b094f7ba4386a1a3373aa8a Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 22 May 2018 09:06:46 +1000 Subject: [PATCH 077/278] FIX: stop adding email to unsubscribe url Instead of adding email to unsubscribe url store it in redis for 1 hour rate limit calls to unsubscribe endpoint to ensure there is no risk of bloating redis Also move controller to request specs --- app/controllers/email_controller.rb | 17 ++- spec/controllers/email_controller_spec.rb | 129 --------------------- spec/requests/email_controller_spec.rb | 135 +++++++++++++++++++++- 3 files changed, 145 insertions(+), 136 deletions(-) diff --git a/app/controllers/email_controller.rb b/app/controllers/email_controller.rb index cff20ff7c0..644876248f 100644 --- a/app/controllers/email_controller.rb +++ b/app/controllers/email_controller.rb @@ -41,6 +41,8 @@ class EmailController < ApplicationController end def perform_unsubscribe + RateLimiter.new(nil, "unsubscribe_#{request.ip}", 10, 1.minute).performed! + key = UnsubscribeKey.find_by(key: params[:key]) raise Discourse::NotFound unless key && key.user @@ -99,19 +101,24 @@ class EmailController < ApplicationController unless updated redirect_back fallback_location: path("/") else + + key = "unsub_#{SecureRandom.hex}" + $redis.setex key, 1.hour, user.email + + url = path("/email/unsubscribed?key=#{key}") if topic - redirect_to path("/email/unsubscribed?topic_id=#{topic.id}&email=#{user.email}") - else - redirect_to path("/email/unsubscribed?email=#{user.email}") + url += "&topic_id=#{topic.id}" end + + redirect_to url end end def unsubscribed - @email = params[:email] + @email = $redis.get(params[:key]) @topic_id = params[:topic_id] - user = User.find_by_email(params[:email]) + user = User.find_by_email(@email) raise Discourse::NotFound unless user topic = Topic.find_by(id: params[:topic_id].to_i) if @topic_id @topic = topic if topic && Guardian.new(nil).can_see?(topic) diff --git a/spec/controllers/email_controller_spec.rb b/spec/controllers/email_controller_spec.rb index 8f112c8334..4fdab4cca6 100644 --- a/spec/controllers/email_controller_spec.rb +++ b/spec/controllers/email_controller_spec.rb @@ -20,135 +20,6 @@ describe EmailController do end - context '.perform unsubscribe' do - it 'raises not found on invalid key' do - post :perform_unsubscribe, params: { key: "123" }, format: :json - expect(response.status).to eq(404) - end - - it 'can fully unsubscribe' do - user = Fabricate(:user) - key = UnsubscribeKey.create_key_for(user, "all") - - user.user_option.update_columns(email_always: true, - email_digests: true, - email_direct: true, - email_private_messages: true) - - post :perform_unsubscribe, - params: { key: key, unsubscribe_all: "1" }, - format: :json - - expect(response.status).to eq(302) - - user.user_option.reload - - expect(user.user_option.email_always).to eq(false) - expect(user.user_option.email_digests).to eq(false) - expect(user.user_option.email_direct).to eq(false) - expect(user.user_option.email_private_messages).to eq(false) - - end - - it 'can disable mailing list' do - user = Fabricate(:user) - key = UnsubscribeKey.create_key_for(user, "all") - - user.user_option.update_columns(mailing_list_mode: true) - - post :perform_unsubscribe, - params: { key: key, disable_mailing_list: "1" }, - format: :json - - expect(response.status).to eq(302) - - user.user_option.reload - - expect(user.user_option.mailing_list_mode).to eq(false) - end - - it 'can disable digest' do - user = Fabricate(:user) - key = UnsubscribeKey.create_key_for(user, "all") - - user.user_option.update_columns(email_digests: true) - - post :perform_unsubscribe, - params: { key: key, disable_digest_emails: "1" }, - format: :json - - expect(response.status).to eq(302) - - user.user_option.reload - - expect(user.user_option.email_digests).to eq(false) - end - - it 'can unwatch topic' do - p = Fabricate(:post) - key = UnsubscribeKey.create_key_for(p.user, p) - - TopicUser.change(p.user_id, p.topic_id, notification_level: TopicUser.notification_levels[:watching]) - - post :perform_unsubscribe, - params: { key: key, unwatch_topic: "1" }, - format: :json - - expect(response.status).to eq(302) - - expect(TopicUser.get(p.topic, p.user).notification_level).to eq(TopicUser.notification_levels[:tracking]) - end - - it 'can mute topic' do - p = Fabricate(:post) - key = UnsubscribeKey.create_key_for(p.user, p) - - TopicUser.change(p.user_id, p.topic_id, notification_level: TopicUser.notification_levels[:watching]) - - post :perform_unsubscribe, - params: { key: key, mute_topic: "1" }, - format: :json - - expect(response.status).to eq(302) - - expect(TopicUser.get(p.topic, p.user).notification_level).to eq(TopicUser.notification_levels[:muted]) - end - - it 'can unwatch category' do - p = Fabricate(:post) - key = UnsubscribeKey.create_key_for(p.user, p) - - cu = CategoryUser.create!(user_id: p.user.id, - category_id: p.topic.category_id, - notification_level: CategoryUser.notification_levels[:watching]) - - post :perform_unsubscribe, - params: { key: key, unwatch_category: "1" }, - format: :json - - expect(response.status).to eq(302) - - expect(CategoryUser.find_by(id: cu.id)).to eq(nil) - end - - it 'can unwatch first post from category' do - p = Fabricate(:post) - key = UnsubscribeKey.create_key_for(p.user, p) - - cu = CategoryUser.create!(user_id: p.user.id, - category_id: p.topic.category_id, - notification_level: CategoryUser.notification_levels[:watching_first_post]) - - post :perform_unsubscribe, - params: { key: key, unwatch_category: "1" }, - format: :json - - expect(response.status).to eq(302) - - expect(CategoryUser.find_by(id: cu.id)).to eq(nil) - end - end - context '.unsubscribe' do render_views diff --git a/spec/requests/email_controller_spec.rb b/spec/requests/email_controller_spec.rb index c51aa276f2..196b3b277a 100644 --- a/spec/requests/email_controller_spec.rb +++ b/spec/requests/email_controller_spec.rb @@ -5,6 +5,133 @@ RSpec.describe EmailController do let(:topic) { Fabricate(:topic) } let(:private_topic) { Fabricate(:private_message_topic) } + context '.perform unsubscribe' do + it 'raises not found on invalid key' do + post "/email/unsubscribe/123.json" + expect(response.status).to eq(404) + end + + it 'can fully unsubscribe' do + user = Fabricate(:user) + key = UnsubscribeKey.create_key_for(user, "all") + + user.user_option.update_columns(email_always: true, + email_digests: true, + email_direct: true, + email_private_messages: true) + + post "/email/unsubscribe/#{key}.json", + params: { unsubscribe_all: "1" } + + expect(response.status).to eq(302) + + get response.redirect_url + + # cause it worked ... yay + expect(body).to include(user.email) + + user.user_option.reload + + expect(user.user_option.email_always).to eq(false) + expect(user.user_option.email_digests).to eq(false) + expect(user.user_option.email_direct).to eq(false) + expect(user.user_option.email_private_messages).to eq(false) + + end + + it 'can disable mailing list' do + user = Fabricate(:user) + key = UnsubscribeKey.create_key_for(user, "all") + + user.user_option.update_columns(mailing_list_mode: true) + + post "/email/unsubscribe/#{key}.json", + params: { disable_mailing_list: "1" } + + expect(response.status).to eq(302) + + user.user_option.reload + + expect(user.user_option.mailing_list_mode).to eq(false) + end + + it 'can disable digest' do + user = Fabricate(:user) + key = UnsubscribeKey.create_key_for(user, "all") + + user.user_option.update_columns(email_digests: true) + + post "/email/unsubscribe/#{key}.json", + params: { disable_digest_emails: "1" } + + expect(response.status).to eq(302) + + user.user_option.reload + + expect(user.user_option.email_digests).to eq(false) + end + + it 'can unwatch topic' do + p = Fabricate(:post) + key = UnsubscribeKey.create_key_for(p.user, p) + + TopicUser.change(p.user_id, p.topic_id, notification_level: TopicUser.notification_levels[:watching]) + + post "/email/unsubscribe/#{key}.json", + params: { unwatch_topic: "1" } + + expect(response.status).to eq(302) + + expect(TopicUser.get(p.topic, p.user).notification_level).to eq(TopicUser.notification_levels[:tracking]) + end + + it 'can mute topic' do + p = Fabricate(:post) + key = UnsubscribeKey.create_key_for(p.user, p) + + TopicUser.change(p.user_id, p.topic_id, notification_level: TopicUser.notification_levels[:watching]) + + post "/email/unsubscribe/#{key}.json", + params: { mute_topic: "1" } + + expect(response.status).to eq(302) + + expect(TopicUser.get(p.topic, p.user).notification_level).to eq(TopicUser.notification_levels[:muted]) + end + + it 'can unwatch category' do + p = Fabricate(:post) + key = UnsubscribeKey.create_key_for(p.user, p) + + cu = CategoryUser.create!(user_id: p.user.id, + category_id: p.topic.category_id, + notification_level: CategoryUser.notification_levels[:watching]) + + post "/email/unsubscribe/#{key}.json", + params: { unwatch_category: "1" } + + expect(response.status).to eq(302) + + expect(CategoryUser.find_by(id: cu.id)).to eq(nil) + end + + it 'can unwatch first post from category' do + p = Fabricate(:post) + key = UnsubscribeKey.create_key_for(p.user, p) + + cu = CategoryUser.create!(user_id: p.user.id, + category_id: p.topic.category_id, + notification_level: CategoryUser.notification_levels[:watching_first_post]) + + post "/email/unsubscribe/#{key}.json", + params: { unwatch_category: "1" } + + expect(response.status).to eq(302) + + expect(CategoryUser.find_by(id: cu.id)).to eq(nil) + end + end + describe '#unsubscribed' do describe 'when email is invalid' do it 'should return the right response' do @@ -15,7 +142,9 @@ RSpec.describe EmailController do describe 'when topic is public' do it 'should return the right response' do - get '/email/unsubscribed', params: { email: user.email, topic_id: topic.id } + key = SecureRandom.hex + $redis.set(key, user.email) + get '/email/unsubscribed', params: { key: key, topic_id: topic.id } expect(response).to be_success expect(response.body).to include(topic.title) end @@ -23,7 +152,9 @@ RSpec.describe EmailController do describe 'when topic is private' do it 'should return the right response' do - get '/email/unsubscribed', params: { email: user.email, topic_id: private_topic.id } + key = SecureRandom.hex + $redis.set(key, user.email) + get '/email/unsubscribed', params: { key: key, topic_id: private_topic.id } expect(response).to be_success expect(response.body).to_not include(private_topic.title) end From 39bfd836c6d97e8c9768b386c188169ceccbf1ff Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 22 May 2018 09:21:47 +1000 Subject: [PATCH 078/278] FEATURE: do not boot Ruby if not on 2.4 or up --- config/application.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/config/application.rb b/config/application.rb index ca68d22681..93efe88702 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,5 +1,16 @@ # frozen_string_literal: true +begin + if !RUBY_VERSION.match?(/^2\.[456]/) + STDERR.puts "Discourse requires Ruby 2.4.0 or up" + exit 1 + end +rescue + # no String#match? + STDERR.puts "Discourse requires Ruby 2.4.0 or up" + exit 1 +end + require File.expand_path('../boot', __FILE__) require 'rails/all' From 661d89f489eb0cfcde4f02d0a0a971536209a62c Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 22 May 2018 09:42:50 +1000 Subject: [PATCH 079/278] skip erratic spec --- spec/services/username_changer_spec.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/services/username_changer_spec.rb b/spec/services/username_changer_spec.rb index 01d788bc68..1057813707 100644 --- a/spec/services/username_changer_spec.rb +++ b/spec/services/username_changer_spec.rb @@ -224,6 +224,8 @@ describe UsernameChanger do end it 'replaces mentions within revisions' do + skip("Erratically fails here raw is nil") + revisions = [{ raw: "Hello Foo" }, { title: "new topic title" }, { raw: "Hello @foo!" }, { raw: "Hello @foo!!" }] post = create_post_and_change_username(raw: "Hello @foo", revisions: revisions) @@ -232,6 +234,7 @@ describe UsernameChanger do expect(post.revisions.count).to eq(4) + # fails here sometimes with raw is nil expect(post.revisions[0].modifications["raw"][0]).to eq("Hello @bar") expect(post.revisions[0].modifications["raw"][1]).to eq("Hello Foo") expect(post.revisions[0].modifications["cooked"][0]).to eq(%Q(

    Hello @bar

    )) From 3e06def856c5b9ce1beb62d2c795e1f1fed77281 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 22 May 2018 12:10:59 +1000 Subject: [PATCH 080/278] FIX: If we have no logo defined use sketch in manifest --- app/controllers/metadata_controller.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/controllers/metadata_controller.rb b/app/controllers/metadata_controller.rb index 0ddb212ebd..e123b7c535 100644 --- a/app/controllers/metadata_controller.rb +++ b/app/controllers/metadata_controller.rb @@ -14,6 +14,9 @@ class MetadataController < ApplicationController def default_manifest logo = SiteSetting.large_icon_url.presence || SiteSetting.logo_small_url.presence || SiteSetting.apple_touch_icon_url.presence + if !logo + logo = path('/images/d-logo-sketch-small.png') + end file_info = get_file_info(logo) manifest = { From 99088f4d774f6bb767ebc0693dc92b120659aec4 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 22 May 2018 13:03:26 +1000 Subject: [PATCH 081/278] skip slow erratic test --- test/javascripts/acceptance/composer-actions-test.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/javascripts/acceptance/composer-actions-test.js.es6 b/test/javascripts/acceptance/composer-actions-test.js.es6 index e8bc10d8be..77a44761f8 100644 --- a/test/javascripts/acceptance/composer-actions-test.js.es6 +++ b/test/javascripts/acceptance/composer-actions-test.js.es6 @@ -107,7 +107,7 @@ QUnit.test('shared draft', assert => { }); }); -QUnit.test('interactions', assert => { +QUnit.skip('interactions', assert => { const composerActions = selectKit('.composer-actions'); const quote = 'Life is like riding a bicycle.'; From 209a8d5e316bac645f5baeebb2c8cc34a13c2d93 Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 21 May 2018 23:29:36 -0400 Subject: [PATCH 082/278] Reucing vertical spacing for github onebox pre --- app/assets/stylesheets/common/base/onebox.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index 25fcd39168..7a306a96d8 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -329,7 +329,7 @@ pre.onebox code li{ pre.onebox code ol.lines{ position:relative; - margin-left: 40px; + margin: 0 0 0 40px; } pre.onebox code ol.lines li { From 450a60072182168910a6a719a378bac9b7c512e2 Mon Sep 17 00:00:00 2001 From: OsamaSayegh Date: Tue, 22 May 2018 06:40:50 +0300 Subject: [PATCH 083/278] REFACTOR: about & badge controllers => requests --- .../{controllers => requests}/about_controller_spec.rb | 8 ++++---- .../badges_controller_spec.rb | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) rename spec/{controllers => requests}/about_controller_spec.rb (87%) rename spec/{controllers => requests}/badges_controller_spec.rb (82%) diff --git a/spec/controllers/about_controller_spec.rb b/spec/requests/about_controller_spec.rb similarity index 87% rename from spec/controllers/about_controller_spec.rb rename to spec/requests/about_controller_spec.rb index 647e50c497..e4e4b01a0a 100644 --- a/spec/controllers/about_controller_spec.rb +++ b/spec/requests/about_controller_spec.rb @@ -6,22 +6,22 @@ describe AboutController do it "should display the about page for anonymous user when login_required is false" do SiteSetting.login_required = false - get :index + get "/about" expect(response).to be_success end it 'should redirect to login page for anonymous user when login_required is true' do SiteSetting.login_required = true - get :index + get "/about" expect(response).to redirect_to '/login' end it "should display the about page for logged in user when login_required is true" do SiteSetting.login_required = true - log_in - get :index + sign_in(Fabricate(:user)) + get "/about" expect(response).to be_success end diff --git a/spec/controllers/badges_controller_spec.rb b/spec/requests/badges_controller_spec.rb similarity index 82% rename from spec/controllers/badges_controller_spec.rb rename to spec/requests/badges_controller_spec.rb index de894666c6..2b03b23e38 100644 --- a/spec/controllers/badges_controller_spec.rb +++ b/spec/requests/badges_controller_spec.rb @@ -10,7 +10,7 @@ describe BadgesController do context 'index' do it 'should return a list of all badges' do - get :index, format: :json + get "/badges.json" expect(response.status).to eq(200) parsed = JSON.parse(response.body) @@ -20,22 +20,22 @@ describe BadgesController do context 'show' do it "should return a badge" do - get :show, params: { id: badge.id }, format: :json + get "/badges/#{badge.id}.json" expect(response.status).to eq(200) parsed = JSON.parse(response.body) expect(parsed["badge"]).to be_present end it "should mark the notification as viewed" do - log_in_user(user) + sign_in(user) user_badge = BadgeGranter.grant(badge, user) expect(user_badge.notification.read).to eq(false) - get :show, params: { id: badge.id } + get "/badges/#{badge.id}.json" expect(user_badge.notification.reload.read).to eq(true) end it 'renders rss feed of a badge' do - get :show, params: { id: badge.id }, format: :rss + get "/badges/#{badge.id}.rss" expect(response.status).to eq(200) expect(response.content_type).to eq('application/rss+xml') end From 9e2131ace8902dee3173f613412f6a6c6901f10d Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 22 May 2018 13:56:11 +1000 Subject: [PATCH 084/278] improve erratic test --- .../acceptance/composer-actions-test.js.es6 | 20 +++++++++---------- test/javascripts/helpers/select-kit-helper.js | 4 ++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/test/javascripts/acceptance/composer-actions-test.js.es6 b/test/javascripts/acceptance/composer-actions-test.js.es6 index 77a44761f8..ca0cffdba1 100644 --- a/test/javascripts/acceptance/composer-actions-test.js.es6 +++ b/test/javascripts/acceptance/composer-actions-test.js.es6 @@ -37,19 +37,19 @@ QUnit.test('replying to post - reply_as_private_message', assert => { }); }); -QUnit.test('replying to post - reply_to_topic', assert => { +QUnit.test('replying to post - reply_to_topic', async assert => { const composerActions = selectKit('.composer-actions'); - visit('/t/internationalization-localization/280'); - click('article#post_3 button.reply'); - fillIn('.d-editor-input', 'test replying to topic when initially replied to post'); - composerActions.expand().selectRowByValue('reply_to_topic'); + await visit('/t/internationalization-localization/280'); + await click('article#post_3 button.reply'); + await fillIn('.d-editor-input', 'test replying to topic when initially replied to post'); - andThen(() => { - assert.equal(find('.action-title .topic-link').text().trim(), 'Internationalization / localization'); - assert.equal(find('.action-title .topic-link').attr("href"), '/t/internationalization-localization/280'); - assert.equal(find('.d-editor-input').val(), 'test replying to topic when initially replied to post'); - }); + await composerActions.expandAwait(); + await composerActions.selectRowByValueAwait('reply_to_topic'); + + assert.equal(find('.action-title .topic-link').text().trim(), 'Internationalization / localization'); + assert.equal(find('.action-title .topic-link').attr("href"), '/t/internationalization-localization/280'); + assert.equal(find('.d-editor-input').val(), 'test replying to topic when initially replied to post'); }); QUnit.test('replying to post - toggle_whisper', assert => { diff --git a/test/javascripts/helpers/select-kit-helper.js b/test/javascripts/helpers/select-kit-helper.js index 8bdceccd7c..c54c8435dd 100644 --- a/test/javascripts/helpers/select-kit-helper.js +++ b/test/javascripts/helpers/select-kit-helper.js @@ -124,6 +124,10 @@ function selectKit(selector) { // eslint-disable-line no-unused-vars return selectKit(selector); }, + selectRowByValueAwait: function(value) { + return selectKitSelectRowByValue(value, selector); + }, + selectRowByValue: function(value) { selectKitSelectRowByValue(value, selector); return selectKit(selector); From 24abf38d38fe6414a65cd41f34587b8c9cbb8ad3 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 22 May 2018 15:20:55 +1000 Subject: [PATCH 085/278] PERF: lower the priority on user emails User emails can wait behind other regular jobs, they are usually somewhat slower as they involve smtp transactions --- app/jobs/regular/user_email.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/jobs/regular/user_email.rb b/app/jobs/regular/user_email.rb index 0eeea9ecbb..ac7e1d58d0 100644 --- a/app/jobs/regular/user_email.rb +++ b/app/jobs/regular/user_email.rb @@ -6,6 +6,8 @@ module Jobs # Asynchronously send an email to a user class UserEmail < Jobs::Base + sidekiq_options queue: 'low' + def execute(args) raise Discourse::InvalidParameters.new(:user_id) unless args[:user_id].present? raise Discourse::InvalidParameters.new(:type) unless args[:type].present? From 9b0efe9c845c7bb8f9e9fd809660cc758ab18db6 Mon Sep 17 00:00:00 2001 From: riking Date: Mon, 21 May 2018 12:04:55 -0700 Subject: [PATCH 086/278] FIX: TopicLinkClick: do not log IP of logged in users --- app/models/topic_link_click.rb | 4 ++-- .../20180521190040_allow_null_ip_topic_link_click.rb | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20180521190040_allow_null_ip_topic_link_click.rb diff --git a/app/models/topic_link_click.rb b/app/models/topic_link_click.rb index ce0f7fcbe1..422c974bfd 100644 --- a/app/models/topic_link_click.rb +++ b/app/models/topic_link_click.rb @@ -7,7 +7,6 @@ class TopicLinkClick < ActiveRecord::Base belongs_to :user validates_presence_of :topic_link_id - validates_presence_of :ip_address WHITELISTED_REDIRECT_HOSTNAMES = Set.new(%W{www.youtube.com youtu.be}) @@ -108,6 +107,7 @@ class TopicLinkClick < ActiveRecord::Base rate_key = "link-clicks:#{link.id}:#{args[:user_id] || args[:ip]}" if $redis.setnx(rate_key, "1") $redis.expire(rate_key, 1.day.to_i) + args[:ip] = nil if args[:user_id] create!(topic_link_id: link.id, user_id: args[:user_id], ip_address: args[:ip]) end @@ -125,7 +125,7 @@ end # user_id :integer # created_at :datetime not null # updated_at :datetime not null -# ip_address :inet not null +# ip_address :inet # # Indexes # diff --git a/db/migrate/20180521190040_allow_null_ip_topic_link_click.rb b/db/migrate/20180521190040_allow_null_ip_topic_link_click.rb new file mode 100644 index 0000000000..b9f29e1aa8 --- /dev/null +++ b/db/migrate/20180521190040_allow_null_ip_topic_link_click.rb @@ -0,0 +1,10 @@ +class AllowNullIpTopicLinkClick < ActiveRecord::Migration[5.1] + def up + begin + Migration::SafeMigrate.disable! + change_column :topic_link_clicks, :ip_address, :inet, null: true + ensure + Migration::SafeMigrate.enable! + end + end +end From d4639bc734e3e7f414014b424a0bf02b9c0e9951 Mon Sep 17 00:00:00 2001 From: riking Date: Mon, 21 May 2018 18:11:49 -0700 Subject: [PATCH 087/278] TEST: Add TopicLinkClick spec for ip logging --- spec/models/topic_link_click_spec.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spec/models/topic_link_click_spec.rb b/spec/models/topic_link_click_spec.rb index f4953f014d..5b440f7265 100644 --- a/spec/models/topic_link_click_spec.rb +++ b/spec/models/topic_link_click_spec.rb @@ -73,6 +73,22 @@ describe TopicLinkClick do end + context 'while logged in' do + let(:other_user) { Fabricate(:user) } + before do + @url = TopicLinkClick.create_from(url: @topic_link.url, post_id: @post.id, ip: '127.0.0.1', user_id: other_user.id) + @click = TopicLinkClick.last + end + + it 'creates a click without an IP' do + expect(@click).to be_present + expect(@click.topic_link).to eq(@topic_link) + expect(@click.user_id).to eq(other_user.id) + expect(@click.ip_address).to eq(nil) + end + + end + context "relative urls" do let(:host) { URI.parse(Discourse.base_url).host } From bcfd9cf8b53072f5041ff188c982024b7d772835 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 22 May 2018 16:15:06 +1000 Subject: [PATCH 088/278] attempt to stabilize spec --- spec/components/scheduler/manager_spec.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/spec/components/scheduler/manager_spec.rb b/spec/components/scheduler/manager_spec.rb index 6ae829c558..4e96014726 100644 --- a/spec/components/scheduler/manager_spec.rb +++ b/spec/components/scheduler/manager_spec.rb @@ -116,6 +116,9 @@ describe Scheduler::Manager do describe 'per host jobs' do it "correctly schedules on multiple hosts" do + + freeze_time + Testing::PerHostJob.runs = 0 hosts = ['a', 'b', 'c'] @@ -126,7 +129,7 @@ describe Scheduler::Manager do manager.ensure_schedule!(Testing::PerHostJob) info = manager.schedule_info(Testing::PerHostJob) - info.next_run = Time.now.to_i - 1 + info.next_run = Time.now.to_i - 10 info.write! manager @@ -137,6 +140,7 @@ describe Scheduler::Manager do manager.stop! end + expect(Testing::PerHostJob.runs).to eq(3) end end From 1ac1ee428721b5c4e1cc741dbdcaaf8d33955c5c Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 22 May 2018 16:48:39 +1000 Subject: [PATCH 089/278] FEATURE: allow registration of an array custom field --- app/models/concerns/has_custom_fields.rb | 26 +++++++++++++------ .../concern/has_custom_fields_spec.rb | 9 +++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/app/models/concerns/has_custom_fields.rb b/app/models/concerns/has_custom_fields.rb index b33b4fa213..a2bc01b254 100644 --- a/app/models/concerns/has_custom_fields.rb +++ b/app/models/concerns/has_custom_fields.rb @@ -6,7 +6,7 @@ module HasCustomFields def self.append_field(target, key, value, types) if target.has_key?(key) target[key] = [target[key]] if !target[key].is_a? Array - target[key] << cast_custom_field(key, value, types) + target[key] << cast_custom_field(key, value, types, _return_array = false) else target[key] = cast_custom_field(key, value, types) end @@ -28,16 +28,26 @@ module HasCustomFields types[key] end - def self.cast_custom_field(key, value, types) + def self.cast_custom_field(key, value, types, return_array = true) return value unless type = get_custom_field_type(types, key) - case type - when :boolean then !!CUSTOM_FIELD_TRUE.include?(value) - when :integer then value.to_i - when :json then ::JSON.parse(value) - else - value + array = nil + + if Array === type + type = type[0] + array = true if return_array end + + result = + case type + when :boolean then !!CUSTOM_FIELD_TRUE.include?(value) + when :integer then value.to_i + when :json then ::JSON.parse(value) + else + value + end + + array ? [result] : result end end diff --git a/spec/components/concern/has_custom_fields_spec.rb b/spec/components/concern/has_custom_fields_spec.rb index 2097583a4a..035e0260c5 100644 --- a/spec/components/concern/has_custom_fields_spec.rb +++ b/spec/components/concern/has_custom_fields_spec.rb @@ -98,6 +98,15 @@ describe HasCustomFields do end it "handles arrays properly" do + + CustomFieldsTestItem.register_custom_field_type "array", [:integer] + test_item = CustomFieldsTestItem.new + test_item.custom_fields = { "array" => ["1"] } + test_item.save + + db_item = CustomFieldsTestItem.find(test_item.id) + expect(db_item.custom_fields).to eq("array" => [1]) + test_item = CustomFieldsTestItem.new test_item.custom_fields = { "a" => ["b", "c", "d"] } test_item.save From 3ef3b5b67a692e24c6a0d4b6f8b3bd4dbc2f826c Mon Sep 17 00:00:00 2001 From: Joe <33972521+hnb-ku@users.noreply.github.com> Date: Tue, 22 May 2018 16:02:50 +0800 Subject: [PATCH 090/278] FIX: both icon and image fields used the same string. Added new string for image field and edited the current string for the icon field. --- config/locales/client.en.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 76ba48799a..72b629bb83 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3823,7 +3823,8 @@ en: enabled: Enable badge icon: Icon image: Image - icon_help: "Use either a Font Awesome class or URL to an image" + icon_help: "Use a Font Awesome class" + image_help: "Enter the URL of the image" query: Badge Query (SQL) target_posts: Query targets posts auto_revoke: Run revocation query daily From 48c4f192e97e5dcdeacad19792ff262c0a4dcc34 Mon Sep 17 00:00:00 2001 From: Joe <33972521+hnb-ku@users.noreply.github.com> Date: Tue, 22 May 2018 16:06:25 +0800 Subject: [PATCH 091/278] FIX: make the image field use its own help string --- app/assets/javascripts/admin/templates/badges-show.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/admin/templates/badges-show.hbs b/app/assets/javascripts/admin/templates/badges-show.hbs index 6fa158d0f3..7436c48898 100644 --- a/app/assets/javascripts/admin/templates/badges-show.hbs +++ b/app/assets/javascripts/admin/templates/badges-show.hbs @@ -18,7 +18,7 @@
    {{input type="text" name="image" value=buffered.image}} -

    {{i18n 'admin.badges.icon_help'}}

    +

    {{i18n 'admin.badges.image_help'}}

    From a0c5b48558a9a4ecae761c9542005d502adbd5bc Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Tue, 22 May 2018 10:50:54 +0200 Subject: [PATCH 092/278] fix failing spec --- .../acceptance/composer-actions-test.js.es6 | 93 +++++++++---------- 1 file changed, 44 insertions(+), 49 deletions(-) diff --git a/test/javascripts/acceptance/composer-actions-test.js.es6 b/test/javascripts/acceptance/composer-actions-test.js.es6 index ca0cffdba1..55b6736c85 100644 --- a/test/javascripts/acceptance/composer-actions-test.js.es6 +++ b/test/javascripts/acceptance/composer-actions-test.js.es6 @@ -40,9 +40,9 @@ QUnit.test('replying to post - reply_as_private_message', assert => { QUnit.test('replying to post - reply_to_topic', async assert => { const composerActions = selectKit('.composer-actions'); - await visit('/t/internationalization-localization/280'); - await click('article#post_3 button.reply'); - await fillIn('.d-editor-input', 'test replying to topic when initially replied to post'); + visit('/t/internationalization-localization/280'); + click('article#post_3 button.reply'); + fillIn('.d-editor-input', 'test replying to topic when initially replied to post'); await composerActions.expandAwait(); await composerActions.selectRowByValueAwait('reply_to_topic'); @@ -107,62 +107,57 @@ QUnit.test('shared draft', assert => { }); }); -QUnit.skip('interactions', assert => { +QUnit.test('interactions', async assert => { const composerActions = selectKit('.composer-actions'); const quote = 'Life is like riding a bicycle.'; - visit('/t/internationalization-localization/280'); - click('article#post_3 button.reply'); - fillIn('.d-editor-input', quote); - composerActions.expand().selectRowByValue('reply_to_topic'); + await visit('/t/internationalization-localization/280'); + await click('article#post_3 button.reply'); + await fillIn('.d-editor-input', quote); - andThen(() => { - assert.equal(find('.action-title').text().trim(), "Internationalization / localization"); - assert.equal(find('.d-editor-input').val(), quote); - }); + await composerActions.expandAwait(); + await composerActions.selectRowByValueAwait('reply_to_topic'); - composerActions.expand(); + assert.equal(find('.action-title').text().trim(), "Internationalization / localization"); + assert.equal(find('.d-editor-input').val(), quote); - andThen(() => { - assert.equal(composerActions.rowByIndex(0).value(), 'reply_as_new_topic'); - assert.equal(composerActions.rowByIndex(1).value(), 'reply_to_post'); - assert.equal(composerActions.rowByIndex(2).value(), 'reply_as_private_message'); - assert.equal(composerActions.rowByIndex(3).value(), 'toggle_whisper'); - assert.equal(composerActions.rowByIndex(4).value(), undefined); - }); + await composerActions.expandAwait(); - composerActions.selectRowByValue('reply_to_post').expand(); + assert.equal(composerActions.rowByIndex(0).value(), 'reply_as_new_topic'); + assert.equal(composerActions.rowByIndex(1).value(), 'reply_to_post'); + assert.equal(composerActions.rowByIndex(2).value(), 'reply_as_private_message'); + assert.equal(composerActions.rowByIndex(3).value(), 'toggle_whisper'); + assert.equal(composerActions.rowByIndex(4).value(), undefined); - andThen(() => { - assert.ok(exists(find('.action-title img.avatar'))); - assert.equal(find('.action-title .user-link').text().trim(), "codinghorror"); - assert.equal(find('.d-editor-input').val(), quote); - assert.equal(composerActions.rowByIndex(0).value(), 'reply_as_new_topic'); - assert.equal(composerActions.rowByIndex(1).value(), 'reply_as_private_message'); - assert.equal(composerActions.rowByIndex(2).value(), 'reply_to_topic'); - assert.equal(composerActions.rowByIndex(3).value(), 'toggle_whisper'); - assert.equal(composerActions.rowByIndex(4).value(), undefined); - }); + await composerActions.selectRowByValueAwait('reply_to_post'); + await composerActions.expandAwait(); - composerActions.selectRowByValue('reply_as_new_topic').expand(); + assert.ok(exists(find('.action-title img.avatar'))); + assert.equal(find('.action-title .user-link').text().trim(), "codinghorror"); + assert.equal(find('.d-editor-input').val(), quote); + assert.equal(composerActions.rowByIndex(0).value(), 'reply_as_new_topic'); + assert.equal(composerActions.rowByIndex(1).value(), 'reply_as_private_message'); + assert.equal(composerActions.rowByIndex(2).value(), 'reply_to_topic'); + assert.equal(composerActions.rowByIndex(3).value(), 'toggle_whisper'); + assert.equal(composerActions.rowByIndex(4).value(), undefined); - andThen(() => { - assert.equal(find('.action-title').text().trim(), I18n.t("topic.create_long")); - assert.ok(find('.d-editor-input').val().includes(quote)); - assert.equal(composerActions.rowByIndex(0).value(), 'reply_to_post'); - assert.equal(composerActions.rowByIndex(1).value(), 'reply_as_private_message'); - assert.equal(composerActions.rowByIndex(2).value(), 'reply_to_topic'); - assert.equal(composerActions.rowByIndex(3).value(), 'shared_draft'); - }); + await composerActions.selectRowByValueAwait('reply_as_new_topic'); + await composerActions.expandAwait(); - composerActions.selectRowByValue('reply_as_private_message').expand(); + assert.equal(find('.action-title').text().trim(), I18n.t("topic.create_long")); + assert.ok(find('.d-editor-input').val().includes(quote)); + assert.equal(composerActions.rowByIndex(0).value(), 'reply_to_post'); + assert.equal(composerActions.rowByIndex(1).value(), 'reply_as_private_message'); + assert.equal(composerActions.rowByIndex(2).value(), 'reply_to_topic'); + assert.equal(composerActions.rowByIndex(3).value(), 'shared_draft'); - andThen(() => { - assert.equal(find('.action-title').text().trim(), I18n.t("topic.private_message")); - assert.ok(find('.d-editor-input').val().indexOf("Continuing the discussion") === 0); - assert.equal(composerActions.rowByIndex(0).value(), 'reply_as_new_topic'); - assert.equal(composerActions.rowByIndex(1).value(), 'reply_to_post'); - assert.equal(composerActions.rowByIndex(2).value(), 'reply_to_topic'); - assert.equal(composerActions.rowByIndex(3).value(), undefined); - }); + await composerActions.selectRowByValueAwait('reply_as_private_message'); + await composerActions.expandAwait(); + + assert.equal(find('.action-title').text().trim(), I18n.t("topic.private_message")); + assert.ok(find('.d-editor-input').val().indexOf("Continuing the discussion") === 0); + assert.equal(composerActions.rowByIndex(0).value(), 'reply_as_new_topic'); + assert.equal(composerActions.rowByIndex(1).value(), 'reply_to_post'); + assert.equal(composerActions.rowByIndex(2).value(), 'reply_to_topic'); + assert.equal(composerActions.rowByIndex(3).value(), undefined); }); From 95db5f0c8cf83f2db781b1a9491aba323fc38af5 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Tue, 22 May 2018 13:41:32 +0200 Subject: [PATCH 093/278] FIX: Do not replace wrong avatars when renaming user --- app/jobs/regular/update_username.rb | 24 ++++++++++++++-- spec/services/username_changer_spec.rb | 40 +++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/app/jobs/regular/update_username.rb b/app/jobs/regular/update_username.rb index e8a45d9366..ae877a44e1 100644 --- a/app/jobs/regular/update_username.rb +++ b/app/jobs/regular/update_username.rb @@ -133,14 +133,32 @@ module Jobs a["href"] = a["href"].gsub(@cooked_mention_user_path_regex, "/u/#{@new_username}") if a["href"] end - doc.css("aside.quote > div.title").each do |div| + doc.css("aside.quote").each do |aside| + next unless div = aside.at_css("div.title") + + username_replaced = false + div.children.each do |child| - child.content = child.content.gsub(@cooked_quote_username_regex, @new_username) if child.text? + if child.text? + content = child.content + username_replaced = content.gsub!(@cooked_quote_username_regex, @new_username).present? + child.content = content if username_replaced + end + end + + if username_replaced || quotes_correct_user?(aside) + div.at_css("img.avatar")&.replace(@avatar_img) end - div.at_css("img.avatar")&.replace(@avatar_img) end doc.to_html end + + def quotes_correct_user?(aside) + Post.where( + topic_id: aside["data-topic"], + post_number: aside["data-post"] + ).pluck(:user_id).first == @user_id + end end end diff --git a/spec/services/username_changer_spec.rb b/spec/services/username_changer_spec.rb index 1057813707..92bdb27a57 100644 --- a/spec/services/username_changer_spec.rb +++ b/spec/services/username_changer_spec.rb @@ -405,12 +405,18 @@ describe UsernameChanger do context 'oneboxes' do let(:quoted_post) { create_post(user: user, topic: topic, post_number: 1, raw: "quoted post") } - let(:avatar_url) { user.avatar_template.gsub("{size}", "40") } + let(:avatar_url) { user_avatar_url(user) } + let(:evil_trout) { Fabricate(:evil_trout) } + let(:another_quoted_post) { create_post(user: evil_trout, topic: topic, post_number: 2, raw: "evil post") } def protocol_relative_url(url) url.sub(/^https?:/, '') end + def user_avatar_url(u) + u.avatar_template.gsub("{size}", "40") + end + it 'updates avatar for linked topics and posts' do raw = "#{quoted_post.full_url}\n#{quoted_post.topic.url}" post = create_post_and_change_username(raw: raw) @@ -442,6 +448,38 @@ describe UsernameChanger do

    HTML end + + it 'does not update the wrong avatar' do + raw = "#{quoted_post.full_url}\n#{another_quoted_post.full_url}" + post = create_post_and_change_username(raw: raw) + + expect(post.raw).to eq(raw) + + expect(post.cooked).to match_html(<<~HTML) +

    +
    + +

    + HTML + end end it 'updates username in small action posts' do From 7285e7fbbad2d5f7b9ff510e102f2ec6a02f8371 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Tue, 22 May 2018 13:58:06 +0200 Subject: [PATCH 094/278] FIX: uses tooltip to display local dates previews --- .../assets/javascripts/discourse-local-dates.js | 5 ++++- .../javascripts/initializers/discourse-local-dates.js.es6 | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js b/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js index f64c694e01..c68c51a461 100644 --- a/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js +++ b/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js @@ -46,9 +46,12 @@ html += relativeTime.replace("TZ", _formatTimezone(moment.tz.guess()).join(": ")); html += ""; + var joinedPreviews = previews.join("\n"); + $element .html(html) - .attr("title", previews.join("\n")) + .attr("title", joinedPreviews) + .attr("data-tooltip", joinedPreviews) .addClass("cooked"); if (repeat) { diff --git a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 index b79a2a648a..395a776fb7 100644 --- a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 +++ b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 @@ -1,9 +1,11 @@ import { withPluginApi } from "discourse/lib/plugin-api"; import showModal from "discourse/lib/show-modal"; +import { registerTooltip } from "discourse/lib/tooltip"; function initializeDiscourseLocalDates(api) { api.decorateCooked($elem => { $(".discourse-local-date", $elem).applyLocalDates(); + registerTooltip($(".discourse-local-date", $elem)); }); api.addToolbarPopupMenuOptionsCallback(() => { From 508b65b76a560ded941589e2aa447cd06950f579 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Tue, 22 May 2018 16:47:23 +0200 Subject: [PATCH 095/278] UX: refactoring/refining tables of new dashboard --- .../components/dashboard-inline-table.js.es6 | 23 +--- .../components/dashboard-mini-chart.js.es6 | 12 +-- .../admin/components/dashboard-table.js.es6 | 19 ++++ .../admin/mixins/async-report.js.es6 | 23 +++- .../components/dashboard-inline-table.hbs | 51 ++++----- .../templates/components/dashboard-table.hbs | 36 +++++++ .../admin/templates/dashboard_next.hbs | 102 ++++++++---------- .../common/admin/dashboard_next.scss | 28 ++++- app/models/report.rb | 16 +-- 9 files changed, 184 insertions(+), 126 deletions(-) create mode 100644 app/assets/javascripts/admin/components/dashboard-table.js.es6 create mode 100644 app/assets/javascripts/admin/templates/components/dashboard-table.hbs diff --git a/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 b/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 index 82eceb6691..8fae065f7b 100644 --- a/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 +++ b/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 @@ -1,32 +1,13 @@ import { ajax } from "discourse/lib/ajax"; -import Report from "admin/models/report"; import AsyncReport from "admin/mixins/async-report"; export default Ember.Component.extend(AsyncReport, { - classNames: ["dashboard-table", "dashboard-inline-table", "fixed"], - help: null, - helpPage: null, - - loadReport(report_json) { - return Report.create(report_json); - }, + classNames: ["dashboard-inline-table"], fetchReport() { this._super(); - let payload = { data: { cache: true, facets: ["total", "prev30Days"] } }; - - if (this.get("startDate")) { - payload.data.start_date = this.get("startDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ"); - } - - if (this.get("endDate")) { - payload.data.end_date = this.get("endDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ"); - } - - if (this.get("limit")) { - payload.data.limit = this.get("limit"); - } + let payload = this.buildPayload(["total", "prev30Days"]); return Ember.RSVP.Promise.all(this.get("dataSources").map(dataSource => { return ajax(dataSource, payload) diff --git a/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 b/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 index 0ac44e2bbb..2dae747fad 100644 --- a/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 +++ b/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 @@ -52,17 +52,7 @@ export default Ember.Component.extend(AsyncReport, { fetchReport() { this._super(); - let payload = { - data: { cache: true, facets: ["prev_period"] } - }; - - if (this.get("startDate")) { - payload.data.start_date = this.get("startDate").locale('en').format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ'); - } - - if (this.get("endDate")) { - payload.data.end_date = this.get("endDate").locale('en').format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ'); - } + let payload = this.buildPayload(["prev_period"]); if (this._chart) { this._chart.destroy(); diff --git a/app/assets/javascripts/admin/components/dashboard-table.js.es6 b/app/assets/javascripts/admin/components/dashboard-table.js.es6 new file mode 100644 index 0000000000..96b74e6e15 --- /dev/null +++ b/app/assets/javascripts/admin/components/dashboard-table.js.es6 @@ -0,0 +1,19 @@ +import { ajax } from "discourse/lib/ajax"; +import AsyncReport from "admin/mixins/async-report"; + +export default Ember.Component.extend(AsyncReport, { + classNames: ["dashboard-table"], + + fetchReport() { + this._super(); + + let payload = this.buildPayload(["total", "prev30Days"]); + + return Ember.RSVP.Promise.all(this.get("dataSources").map(dataSource => { + return ajax(dataSource, payload) + .then(response => { + this.get("reports").pushObject(this.loadReport(response.report)); + }); + })); + } +}); diff --git a/app/assets/javascripts/admin/mixins/async-report.js.es6 b/app/assets/javascripts/admin/mixins/async-report.js.es6 index df225f42f6..760a205948 100644 --- a/app/assets/javascripts/admin/mixins/async-report.js.es6 +++ b/app/assets/javascripts/admin/mixins/async-report.js.es6 @@ -1,4 +1,5 @@ import computed from "ember-addons/ember-computed-decorators"; +import Report from "admin/models/report"; export default Ember.Mixin.create({ classNameBindings: ["isLoading", "dataSourceNames"], @@ -17,6 +18,24 @@ export default Ember.Mixin.create({ return dataSourceNames.split(",").map(source => `/admin/reports/${source}`); }, + buildPayload(facets) { + let payload = { data: { cache: true, facets } }; + + if (this.get("startDate")) { + payload.data.start_date = this.get("startDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ"); + } + + if (this.get("endDate")) { + payload.data.end_date = this.get("endDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ"); + } + + if (this.get("limit")) { + payload.data.limit = this.get("limit"); + } + + return payload; + }, + @computed("reports.[]", "startDate", "endDate", "dataSourceNames") reportsForPeriod(reports, startDate, endDate, dataSourceNames) { // on a slow network fetchReport could be called multiple times between @@ -69,7 +88,9 @@ export default Ember.Mixin.create({ this.set("isLoading", false); }, - loadReport() {}, + loadReport(jsonReport) { + return Report.create(jsonReport); + }, fetchReport() { this.set("reports", []); diff --git a/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs b/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs index f6a44b7b53..76f04a2175 100644 --- a/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs +++ b/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs @@ -1,40 +1,31 @@ {{#conditional-loading-section isLoading=isLoading}}

    {{title}}

    - - {{#if help}} - {{i18n help}} - {{/if}}
    {{#each reportsForPeriod as |report|}}
    - - - - {{#if report.labels}} - {{#each report.labels as |label|}} - - {{/each}} - {{else}} - {{#each report.data as |data|}} - - {{/each}} - {{/if}} - - - - {{#unless hasBlock}} - {{#each report.data as |data|}} - - - - {{/each}} - {{else}} - {{yield (hash report=report)}} - {{/unless}} - -
    {{label}}{{data.x}}
    {{number data.y}}
    + {{#unless hasBlock}} + {{#each report.data as |data|}} +
    + + {{#if data.icon}} + {{d-icon data.icon}} + {{/if}} + {{data.x}}: + + + {{#if data.url}} + {{number data.y}} + {{else}} + {{number data.y}} + {{/if}} + +
    + {{/each}} + {{else}} + {{yield (hash report=report)}} + {{/unless}}
    {{/each}} {{/conditional-loading-section}} diff --git a/app/assets/javascripts/admin/templates/components/dashboard-table.hbs b/app/assets/javascripts/admin/templates/components/dashboard-table.hbs new file mode 100644 index 0000000000..29ab8332cb --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/dashboard-table.hbs @@ -0,0 +1,36 @@ +{{#conditional-loading-section isLoading=isLoading}} +
    +

    {{title}}

    +
    + + {{#each reportsForPeriod as |report|}} +
    + + + + {{#if report.labels}} + {{#each report.labels as |label|}} + + {{/each}} + {{else}} + {{#each report.data as |data|}} + + {{/each}} + {{/if}} + + + + {{#unless hasBlock}} + {{#each report.data as |data|}} + + + + {{/each}} + {{else}} + {{yield (hash report=report)}} + {{/unless}} + +
    {{label}}{{data.x}}
    {{number data.y}}
    +
    + {{/each}} +{{/conditional-loading-section}} diff --git a/app/assets/javascripts/admin/templates/dashboard_next.hbs b/app/assets/javascripts/admin/templates/dashboard_next.hbs index ec1e52e4ba..9e75443d40 100644 --- a/app/assets/javascripts/admin/templates/dashboard_next.hbs +++ b/app/assets/javascripts/admin/templates/dashboard_next.hbs @@ -80,17 +80,9 @@ {{/conditional-loading-section}}
    - {{#dashboard-inline-table dataSourceNames="users_by_type,users_by_trust_level" lastRefreshedAt=lastRefreshedAt as |context|}} - - {{#each context.report.data as |data|}} - - - {{number data.y}} - - - {{/each}} - - {{/dashboard-inline-table}} + {{dashboard-inline-table dataSourceNames="users_by_type" lastRefreshedAt=lastRefreshedAt}} + + {{dashboard-inline-table dataSourceNames="users_by_trust_level" lastRefreshedAt=lastRefreshedAt}} {{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.backups")}}
    @@ -116,7 +108,6 @@
    -

    {{i18n "admin.dashboard.last_updated"}}

    @@ -128,58 +119,57 @@
    -

    - {{i18n 'admin.dashboard.find_old'}} {{#link-to 'admin.dashboard'}}{{i18n "admin.dashboard.old_link"}}{{/link-to}} -

    - +

    + {{i18n 'admin.dashboard.find_old'}} {{#link-to 'admin.dashboard'}}{{i18n "admin.dashboard.old_link"}}{{/link-to}} +

    {{/conditional-loading-section}}
    - {{#dashboard-inline-table - dataSourceNames="top_referred_topics" - lastRefreshedAt=lastRefreshedAt - limit=8 - as |context|}} - {{#each context.report.data as |data|}} - - - - {{data.topic_title}} - - - - {{data.num_clicks}} - - - {{/each}} - {{/dashboard-inline-table}} + {{#dashboard-table + dataSourceNames="top_referred_topics" + lastRefreshedAt=lastRefreshedAt + limit=8 + as |context|}} + {{#each context.report.data as |data|}} + + + + {{data.topic_title}} + + + + {{data.num_clicks}} + + + {{/each}} + {{/dashboard-table}}
    +
  • diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index d534d85561..2d63c4b49b 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -370,6 +370,32 @@ } } +.dashboard-inline-table { + margin-bottom: 1em; + + .table-title { + border-bottom: 1px solid $primary-low; + margin-bottom: 1em; + padding-bottom: .5em; + } + + .table-container { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + } + + .table-cell { + margin-right: .5em; + display: flex; + + .label { + font-weight: 700; + margin-right: .5em; + } + } +} + .dashboard-table.activity-metrics { table { @media screen and (min-width: 400px) { @@ -381,7 +407,7 @@ .d-icon { color: $primary-low-mid; min-width: 14px; - text-align: center; + text-align: center; } @media screen and (max-width: 400px) { .d-icon { display: none; } diff --git a/app/models/report.rb b/app/models/report.rb index d0ebb790e9..0aa88ffa83 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -341,8 +341,11 @@ class Report def self.report_users_by_trust_level(report) report.data = [] + User.real.group('trust_level').count.sort.each do |level, count| - report.data << { key: TrustLevel.levels[level.to_i], x: level.to_i, y: count } + key = TrustLevel.levels[level.to_i] + url = Proc.new { |key| "/admin/users/list/#{key}" } + report.data << { url: url.call(key), key: key, x: level.to_i, y: count } end end @@ -416,19 +419,20 @@ class Report def self.report_users_by_type(report) report.data = [] - label = Proc.new { |key| I18n.t("reports.users_by_type.xaxis_labels.#{key}") } + label = Proc.new { |x| I18n.t("reports.users_by_type.xaxis_labels.#{x}") } + url = Proc.new { |key| "/admin/users/list/#{key}" } admins = User.real.admins.count - report.data << { key: "admins", x: label.call("admin"), y: admins } if admins > 0 + report.data << { url: url.call("admins"), icon: "shield", key: "admins", x: label.call("admin"), y: admins } if admins > 0 moderators = User.real.moderators.count - report.data << { key: "moderators", x: label.call("moderator"), y: moderators } if moderators > 0 + report.data << { url: url.call("moderators"), icon: "shield", key: "moderators", x: label.call("moderator"), y: moderators } if moderators > 0 suspended = User.real.suspended.count - report.data << { key: "suspended", x: label.call("suspended"), y: suspended } if suspended > 0 + report.data << { url: url.call("suspended"), icon: "ban", key: "suspended", x: label.call("suspended"), y: suspended } if suspended > 0 silenced = User.real.silenced.count - report.data << { key: "silenced", x: label.call("silenced"), y: silenced } if silenced > 0 + report.data << { url: url.call("silenced"), icon: "ban", key: "silenced", x: label.call("silenced"), y: silenced } if silenced > 0 end def self.report_top_referred_topics(report) From d86bd483978f81b4ad9e16967dfa8fd677086f15 Mon Sep 17 00:00:00 2001 From: Joshua Rosenfeld Date: Tue, 22 May 2018 11:14:16 -0400 Subject: [PATCH 096/278] Copyedit --- 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 72b629bb83..7ec24ab64f 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3061,7 +3061,7 @@ en: label: "Backup" title: "Create a backup" confirm: "Do you want to start a new backup?" - without_uploads: "Yes (do not include files)" + without_uploads: "Yes (do not include uploads)" download: label: "Download" title: "Send email with download link" From cd8d5124299793db5b8f02787fc0d0ca03dc7bdc Mon Sep 17 00:00:00 2001 From: Joshua Rosenfeld Date: Tue, 22 May 2018 11:37:18 -0400 Subject: [PATCH 097/278] Fix user tag preferences icon --- app/assets/javascripts/discourse/templates/preferences/tags.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/templates/preferences/tags.hbs b/app/assets/javascripts/discourse/templates/preferences/tags.hbs index 1bf4043b2b..ef1dcc541f 100644 --- a/app/assets/javascripts/discourse/templates/preferences/tags.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/tags.hbs @@ -16,7 +16,7 @@
    {{i18n 'user.watched_tags_instructions'}}
    - + {{tag-chooser tags=model.tracked_tags blacklist=selectedTags From 79d202a73c4fa2026d539e61d26375f5ab53b16c Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Tue, 22 May 2018 12:00:27 -0400 Subject: [PATCH 098/278] FIX: update Indonesian translations to fix date translations --- config/locales/client.id.yml | 28 ++++++++++++++-------------- config/locales/server.id.yml | 2 -- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/config/locales/client.id.yml b/config/locales/client.id.yml index 52b2ac260a..4faea15b75 100644 --- a/config/locales/client.id.yml +++ b/config/locales/client.id.yml @@ -25,20 +25,20 @@ id: thousands: "{{number}}k" millions: "{{number}}M" dates: - time: "j:mm a" + time: "h:mm a" timeline_date: "MMM YYYY" - long_no_year: "BBB H j:mm a" - long_no_year_no_time: "BBB H" + long_no_year: "BBB D h:mm a" + long_no_year_no_time: "MMM D" full_no_year_no_time: "MMMM Do" - long_with_year: "BBB H, TTTT j:mm a" - long_with_year_no_time: "BBB H, TTTT" + long_with_year: "MMM D, YYYY h:mm a" + long_with_year_no_time: "MMM D, YYYY" full_with_year_no_time: "MMMM Do, YYYY" - long_date_with_year: "BBB H, 'TT LT" - long_date_without_year: "BBB H, LT" - long_date_with_year_without_time: "BBB H, 'TT" - long_date_without_year_with_linebreak: "BBB H
    LT" - long_date_with_year_with_linebreak: "BBB H, 'TT
    LT" - wrap_ago: "%{date} lalu" + long_date_with_year: "MMM D, 'YY LT" + long_date_without_year: "MMM D, LT" + long_date_with_year_without_time: "MMM D, 'YY" + long_date_without_year_with_linebreak: "MMM D
    LT" + long_date_with_year_with_linebreak: "MMM D, 'YY
    LT" + wrap_ago: "%{date} yang lalu" tiny: half_a_minute: "< 1m" less_than_x_seconds: @@ -61,8 +61,8 @@ id: other: '> %{count}y' almost_x_years: other: '%{count}y' - date_month: "BBB H" - date_year: "BBB 'TT" + date_month: "MMM D" + date_year: "MMM 'YY" medium: x_minutes: other: '%{count} menit' @@ -70,7 +70,7 @@ id: other: '%{count} jam' x_days: other: '%{count} hari' - date_year: "BBB H, 'TT" + date_year: "MMM D, 'YY" medium_with_ago: x_minutes: other: '%{count} menit yang lalu' diff --git a/config/locales/server.id.yml b/config/locales/server.id.yml index e938a17e86..72259317ab 100644 --- a/config/locales/server.id.yml +++ b/config/locales/server.id.yml @@ -310,9 +310,7 @@ id: xaxis: "Haru" yaxis: "Jumlah kunjungan" signups: - title: "Pengguna Baru" xaxis: "Hari" - yaxis: "Jumlah pengguna baru" profile_views: xaxis: "Hari" topics: From d19d491d65259924ca78e490338194ea32b8ddd0 Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 22 May 2018 15:33:06 -0400 Subject: [PATCH 099/278] Styling dashboard user tables --- .../admin/templates/dashboard_next.hbs | 4 +-- .../common/admin/dashboard_next.scss | 28 +++++++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/admin/templates/dashboard_next.hbs b/app/assets/javascripts/admin/templates/dashboard_next.hbs index 9e75443d40..1608d6238c 100644 --- a/app/assets/javascripts/admin/templates/dashboard_next.hbs +++ b/app/assets/javascripts/admin/templates/dashboard_next.hbs @@ -79,11 +79,11 @@
    {{/conditional-loading-section}}
    - +
    {{dashboard-inline-table dataSourceNames="users_by_type" lastRefreshedAt=lastRefreshedAt}} {{dashboard-inline-table dataSourceNames="users_by_trust_level" lastRefreshedAt=lastRefreshedAt}} - +
    {{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.backups")}}
    diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index 2d63c4b49b..a2a2dac1fe 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -370,28 +370,44 @@ } } +.user-metrics { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + margin-left: -5%; + margin: 2em 0 .75em -5%; // Negative margin allows for a margin when in 2-columns, + .dashboard-inline-table { // and "hides" margin when the item spans 100% width + flex: 1 0 auto; + max-width: 95%; + } +} + .dashboard-inline-table { - margin-bottom: 1em; + margin-left: 5%; + margin-bottom: 1.25em; .table-title { border-bottom: 1px solid $primary-low; margin-bottom: 1em; - padding-bottom: .5em; } .table-container { display: flex; - justify-content: space-between; flex-wrap: wrap; + flex: 1 1 auto; } .table-cell { - margin-right: .5em; display: flex; - + flex: 0 1 auto; + margin: 0 2em .5em 0; .label { - font-weight: 700; + display: flex; + align-items: center; margin-right: .5em; + .d-icon { + margin-right: 5px; + } } } } From beed676b046a32ee028816db7f9edc0fb4cf8323 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Tue, 22 May 2018 21:25:52 +0200 Subject: [PATCH 100/278] FIX: Check group names when checking username availability --- app/models/user.rb | 3 ++- spec/models/user_spec.rb | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/models/user.rb b/app/models/user.rb index 1621ddee87..cb26bba3e2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -208,7 +208,8 @@ class User < ActiveRecord::Base def self.username_available?(username, email = nil) lower = username.downcase return false if reserved_username?(lower) - return true if !User.exists?(username_lower: lower) + return true if User.exec_sql(User::USERNAME_EXISTS_SQL, username: lower).count == 0 + # staged users can use the same username since they will take over the account email.present? && User.joins(:user_emails).exists?(staged: true, username_lower: lower, user_emails: { primary: true, email: email }) end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index e2b0cf3e72..6d84bab4e2 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -554,6 +554,11 @@ describe User do user = Fabricate(:user, email: "bar@foo.com") expect(User.username_available?(user.username, user.primary_email.email)).to eq(false) end + + it 'returns false when a username equals an existing group name' do + Fabricate(:group, name: 'foo') + expect(User.username_available?('Foo')).to eq(false) + end end describe '.reserved_username?' do From eceeef8413d3579c403153af88d84b4d58cfa264 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Tue, 22 May 2018 21:27:25 +0200 Subject: [PATCH 101/278] Imported categories use colors from settings instead of brown --- script/import_scripts/base.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/script/import_scripts/base.rb b/script/import_scripts/base.rb index 03a62dcc22..c8d41e2932 100644 --- a/script/import_scripts/base.rb +++ b/script/import_scripts/base.rb @@ -442,7 +442,7 @@ class ImportScripts::Base user_id: opts[:user_id] || opts[:user].try(:id) || Discourse::SYSTEM_USER_ID, position: opts[:position], parent_category_id: opts[:parent_category_id], - color: opts[:color] || "AB9364", + color: opts[:color] || category_color, text_color: opts[:text_color] || "FFF", read_restricted: opts[:read_restricted] || false, ) @@ -463,6 +463,15 @@ class ImportScripts::Base new_category end + def category_color + @category_colors ||= SiteSetting.category_colors.split('|') + + index = @next_category_color_index.presence || 0 + @next_category_color_index = index + 1 >= @category_colors.count ? 0 : index + 1 + + @category_colors[index] + end + def created_post(post) # override if needed end From 2f0e230dba2cd245293b1c536913d05a9f274eb4 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Tue, 22 May 2018 21:42:49 +0200 Subject: [PATCH 102/278] Adds import script for Zendesk It also adds a generic SQLite database that can be used when the data needs some transformation before the actual import. --- .../import_scripts/base/generic_database.rb | 220 ++++++++++++++++ script/import_scripts/zendesk.rb | 248 ++++++++++++++++++ 2 files changed, 468 insertions(+) create mode 100644 script/import_scripts/base/generic_database.rb create mode 100644 script/import_scripts/zendesk.rb diff --git a/script/import_scripts/base/generic_database.rb b/script/import_scripts/base/generic_database.rb new file mode 100644 index 0000000000..5b7df59cf4 --- /dev/null +++ b/script/import_scripts/base/generic_database.rb @@ -0,0 +1,220 @@ +require 'sqlite3' + +module ImportScripts + class GenericDatabase + def initialize(directory, batch_size:, recreate: false) + filename = "#{directory}/index.db" + File.delete(filename) if recreate && File.exists?(filename) + + @db = SQLite3::Database.new(filename, results_as_hash: true) + @batch_size = batch_size + + configure_database + create_category_table + create_user_table + create_topic_table + create_post_table + end + + def insert_category(category) + @db.execute(<<-SQL, prepare(category)) + INSERT OR REPLACE INTO category (id, name, description, position, url) + VALUES (:id, :name, :description, :position, :url) + SQL + end + + def insert_user(user) + @db.execute(<<-SQL, prepare(user)) + INSERT OR REPLACE INTO user (id, email, username, name, created_at, last_seen_at, active) + VALUES (:id, :email, :username, :name, :created_at, :last_seen_at, :active) + SQL + end + + def insert_topic(topic) + @db.execute(<<-SQL, prepare(topic)) + INSERT OR REPLACE INTO topic (id, title, raw, category_id, closed, user_id, created_at, url) + VALUES (:id, :title, :raw, :category_id, :closed, :user_id, :created_at, :url) + SQL + end + + def insert_post(post) + @db.execute(<<-SQL, prepare(post)) + INSERT OR REPLACE INTO post (id, raw, topic_id, user_id, created_at, reply_to_post_id, url) + VALUES (:id, :raw, :topic_id, :user_id, :created_at, :reply_to_post_id, :url) + SQL + end + + def sort_posts_by_created_at + @db.execute 'DELETE FROM post_order' + + @db.execute <<-SQL + INSERT INTO post_order (id) + SELECT id + FROM post + ORDER BY created_at, topic_id, id + SQL + end + + def fetch_categories + @db.execute(<<-SQL) + SELECT * + FROM category + ORDER BY position, name + SQL + end + + def count_users + @db.get_first_value(<<-SQL) + SELECT COUNT(*) + FROM user + SQL + end + + def fetch_users(last_id) + rows = @db.execute(<<-SQL, last_id) + SELECT * + FROM user + WHERE id > :last_id + ORDER BY id + LIMIT #{@batch_size} + SQL + + add_last_column_value(rows, 'id') + end + + def count_topics + @db.get_first_value(<<-SQL) + SELECT COUNT(*) + FROM topic + SQL + end + + def fetch_topics(last_id) + rows = @db.execute(<<-SQL, last_id) + SELECT * + FROM topic + WHERE id > :last_id + ORDER BY id + LIMIT #{@batch_size} + SQL + + add_last_column_value(rows, 'id') + end + + def count_posts + @db.get_first_value(<<-SQL) + SELECT COUNT(*) + FROM post + SQL + end + + def fetch_posts(last_row_id) + rows = @db.execute(<<-SQL, last_row_id) + SELECT o.ROWID, p.* + FROM post p + JOIN post_order o USING (id) + WHERE o.ROWID > :last_row_id + ORDER BY o.ROWID + LIMIT #{@batch_size} + SQL + + add_last_column_value(rows, 'rowid') + end + + def execute_sql(sql) + @db.execute(sql) + end + + private + + def configure_database + @db.execute 'PRAGMA journal_mode = OFF' + @db.execute 'PRAGMA locking_mode = EXCLUSIVE' + end + + def create_category_table + @db.execute <<-SQL + CREATE TABLE IF NOT EXISTS category ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + position INTEGER, + url TEXT + ) + SQL + end + + def create_user_table + @db.execute <<-SQL + CREATE TABLE IF NOT EXISTS user ( + id TEXT NOT NULL PRIMARY KEY, + email TEXT, + username TEXT, + name TEXT, + created_at DATETIME, + last_seen_at DATETIME, + active BOOLEAN NOT NULL DEFAULT true + ) + SQL + end + + def create_topic_table + @db.execute <<-SQL + CREATE TABLE IF NOT EXISTS topic ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT, + raw TEXT, + category_id TEXT NOT NULL, + closed BOOLEAN NOT NULL DEFAULT false, + user_id TEXT NOT NULL, + created_at DATETIME, + url TEXT + ) + SQL + + @db.execute 'CREATE INDEX IF NOT EXISTS topic_by_user_id ON topic (user_id)' + end + + def create_post_table + @db.execute <<-SQL + CREATE TABLE IF NOT EXISTS post ( + id TEXT NOT NULL PRIMARY KEY, + raw TEXT, + topic_id TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at DATETIME, + reply_to_post_id TEXT, + url TEXT + ) + SQL + + @db.execute 'CREATE INDEX IF NOT EXISTS post_by_user_id ON post (user_id)' + + @db.execute <<-SQL + CREATE TABLE IF NOT EXISTS post_order ( + id TEXT NOT NULL PRIMARY KEY + ) + SQL + end + + def prepare(hash) + hash.each do |key, value| + if value.is_a?(TrueClass) || value.is_a?(FalseClass) + hash[key] = value ? 1 : 0 + elsif value.is_a?(Date) + hash[key] = value.to_s + end + end + end + + def add_last_column_value(rows, *last_columns) + return rows if last_columns.empty? + + result = [rows] + last_row = rows.last + + last_columns.each { |column| result.push(last_row ? last_row[column] : nil) } + result + end + end +end diff --git a/script/import_scripts/zendesk.rb b/script/import_scripts/zendesk.rb new file mode 100644 index 0000000000..305431c7a5 --- /dev/null +++ b/script/import_scripts/zendesk.rb @@ -0,0 +1,248 @@ +# Zendesk importer +# +# You will need a bunch of CSV files: +# +# - users.csv +# - topics.csv (topics in Zendesk are categories in Discourse) +# - posts.csv (posts in Zendesk are topics in Discourse) +# - comments.csv (comments in Zendesk are posts in Discourse) + +require 'csv' +require 'reverse_markdown' +require_relative 'base' +require_relative 'base/generic_database' + +# Call it like this: +# RAILS_ENV=production bundle exec ruby script/import_scripts/zendesk.rb DIRNAME +class ImportScripts::Zendesk < ImportScripts::Base + OLD_DOMAIN = "https://support.example.com" + BATCH_SIZE = 1000 + + def initialize(path) + super() + + @path = path + @db = ImportScripts::GenericDatabase.new(@path, batch_size: BATCH_SIZE, recreate: true) + end + + def execute + read_csv_files + + import_categories + import_users + import_topics + import_posts + end + + def read_csv_files + puts "", "reading CSV files" + + csv_parse("topics") do |row| + @db.insert_category( + id: row[:id], + name: row[:name], + description: row[:description], + position: row[:position], + url: row[:htmlurl] + ) + end + + csv_parse("users") do |row| + @db.insert_user( + id: row[:id], + email: row[:email], + name: row[:name], + created_at: parse_datetime(row[:createdat]), + last_seen_at: parse_datetime(row[:lastloginat]), + active: true + ) + end + + csv_parse("posts") do |row| + @db.insert_topic( + id: row[:id], + title: row[:title], + raw: row[:details], + category_id: row[:topicid], + closed: row[:closed] == "TRUE", + user_id: row[:authorid], + created_at: parse_datetime(row[:createdat]), + url: row[:htmlurl] + ) + end + + csv_parse("comments") do |row| + @db.insert_post( + id: row[:id], + raw: row[:body], + topic_id: row[:postid], + user_id: row[:authorid], + created_at: parse_datetime(row[:createdat]), + url: row[:htmlurl] + ) + end + + @db.execute_sql(<<~SQL) + DELETE FROM user + WHERE NOT EXISTS( + SELECT 1 + FROM topic + WHERE topic.user_id = user.id + ) AND NOT EXISTS( + SELECT 1 + FROM post + WHERE post.user_id = user.id + ) + SQL + + @db.sort_posts_by_created_at + end + + def parse_datetime(text) + return nil if text.blank? || text == "null" + DateTime.parse(text) + end + + def import_categories + puts "", "creating categories" + rows = @db.fetch_categories + + create_categories(rows) do |row| + { + id: row['id'], + name: row['name'], + description: row['description'], + position: row['position'], + post_create_action: proc do |category| + url = remove_domain(row['url']) + Permalink.create(url: url, category_id: category.id) unless permalink_exists?(url) + end + } + end + end + + def batches + super(BATCH_SIZE) + end + + def import_users + puts "", "creating users" + total_count = @db.count_users + last_id = '' + + batches do |offset| + rows, last_id = @db.fetch_users(last_id) + break if rows.empty? + + next if all_records_exist?(:users, rows.map { |row| row['id'] }) + + create_users(rows, total: total_count, offset: offset) do |row| + { + id: row['id'], + email: row['email'], + name: row['name'], + created_at: row['created_at'], + last_seen_at: row['last_seen_at'], + active: row['active'] == 1 + } + end + end + end + + def import_topics + puts "", "creating topics" + total_count = @db.count_topics + last_id = '' + + batches do |offset| + rows, last_id = @db.fetch_topics(last_id) + break if rows.empty? + + next if all_records_exist?(:posts, rows.map { |row| import_topic_id(row['id']) }) + + create_posts(rows, total: total_count, offset: offset) do |row| + { + id: import_topic_id(row['id']), + title: row['title'].present? ? row['title'].strip[0...255] : "Topic title missing", + raw: normalize_raw(row['raw']), + category: category_id_from_imported_category_id(row['category_id']), + user_id: user_id_from_imported_user_id(row['user_id']) || Discourse.system_user.id, + created_at: row['created_at'], + closed: row['closed'] == 1, + post_create_action: proc do |post| + url = remove_domain(row['url']) + Permalink.create(url: url, topic_id: post.topic.id) unless permalink_exists?(url) + end + } + end + end + end + + def import_topic_id(topic_id) + "T#{topic_id}" + end + + def import_posts + puts "", "creating posts" + total_count = @db.count_posts + last_row_id = 0 + + batches do |offset| + rows, last_row_id = @db.fetch_posts(last_row_id) + break if rows.empty? + + next if all_records_exist?(:posts, rows.map { |row| row['id'] }) + + create_posts(rows, total: total_count, offset: offset) do |row| + topic = topic_lookup_from_imported_post_id(import_topic_id(row['topic_id'])) + + if topic.nil? + p "MISSING TOPIC #{row['topic_id']}" + p row + next + end + + { + id: import_topic_id(row['id']), + raw: normalize_raw(row['raw']), + user_id: user_id_from_imported_user_id(row['user_id']) || Discourse.system_user.id, + topic_id: topic[:topic_id], + created_at: row['created_at'], + post_create_action: proc do |post| + url = remove_domain(row['url']) + Permalink.create(url: url, post_id: post.id) unless permalink_exists?(url) + end + } + end + end + end + + def normalize_raw(raw) + raw = raw.gsub('\n', '') + raw = ReverseMarkdown.convert(raw) + raw + end + + def remove_domain(url) + url.sub(OLD_DOMAIN, "") + end + + def permalink_exists?(url) + Permalink.find_by(url: url) + end + + def csv_parse(table_name) + CSV.foreach(File.join(@path, "#{table_name}.csv"), + headers: true, + header_converters: :symbol, + skip_blanks: true, + encoding: 'bom|utf-8') { |row| yield row } + end +end + +unless ARGV[0] && Dir.exist?(ARGV[0]) + puts "", "Usage:", "", "bundle exec ruby script/import_scripts/zendesk.rb DIRNAME", "" + exit 1 +end + +ImportScripts::Zendesk.new(ARGV[0]).perform From 8e9531657a1606b959e9d4fcd5d0a6f5355dc007 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 May 2018 08:28:00 +1000 Subject: [PATCH 103/278] skip erratic test --- test/javascripts/acceptance/composer-actions-test.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/javascripts/acceptance/composer-actions-test.js.es6 b/test/javascripts/acceptance/composer-actions-test.js.es6 index 55b6736c85..82b873ddbe 100644 --- a/test/javascripts/acceptance/composer-actions-test.js.es6 +++ b/test/javascripts/acceptance/composer-actions-test.js.es6 @@ -107,7 +107,7 @@ QUnit.test('shared draft', assert => { }); }); -QUnit.test('interactions', async assert => { +QUnit.skip('interactions', async assert => { const composerActions = selectKit('.composer-actions'); const quote = 'Life is like riding a bicycle.'; From 45f65859c90ecbb71027f84654ff9e921c7a596f Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 May 2018 08:39:15 +1000 Subject: [PATCH 104/278] improve erraticly failing spec --- app/services/random_topic_selector.rb | 4 ++++ spec/serializers/topic_view_serializer_spec.rb | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/services/random_topic_selector.rb b/app/services/random_topic_selector.rb index 1ac2d8500a..a484c91be1 100644 --- a/app/services/random_topic_selector.rb +++ b/app/services/random_topic_selector.rb @@ -91,4 +91,8 @@ class RandomTopicSelector "random_topic_cache_#{category&.id}" end + def self.clear_cache! + $redis.delete_prefixed(cache_key) + end + end diff --git a/spec/serializers/topic_view_serializer_spec.rb b/spec/serializers/topic_view_serializer_spec.rb index 8a6e27b056..5b50522b89 100644 --- a/spec/serializers/topic_view_serializer_spec.rb +++ b/spec/serializers/topic_view_serializer_spec.rb @@ -3,7 +3,12 @@ require 'rails_helper' describe TopicViewSerializer do def serialize_topic(topic, user_arg) topic_view = TopicView.new(topic.id, user_arg) - described_class.new(topic_view, scope: Guardian.new(user_arg), root: false).as_json + TopicViewSerializer.new(topic_view, scope: Guardian.new(user_arg), root: false).as_json + end + + before do + # ensure no suggested ids are cached cause that can muck up suggested + RandomTopicSelector.clear_cache! end let(:topic) { Fabricate(:topic) } From 609804f5ef571a1b331b14ac00043b2da1b73749 Mon Sep 17 00:00:00 2001 From: OsamaSayegh Date: Wed, 23 May 2018 00:39:05 +0300 Subject: [PATCH 105/278] REFACTOR: merge posts controller specs into request specs --- spec/controllers/posts_controller_spec.rb | 1217 -------------------- spec/requests/posts_controller_spec.rb | 1258 +++++++++++++++++++-- 2 files changed, 1194 insertions(+), 1281 deletions(-) delete mode 100644 spec/controllers/posts_controller_spec.rb diff --git a/spec/controllers/posts_controller_spec.rb b/spec/controllers/posts_controller_spec.rb deleted file mode 100644 index 073dbe58a4..0000000000 --- a/spec/controllers/posts_controller_spec.rb +++ /dev/null @@ -1,1217 +0,0 @@ -require 'rails_helper' - -shared_examples 'finding and showing post' do - let(:user) { log_in } - let(:post) { Fabricate(:post, user: user) } - - it 'ensures the user can see the post' do - Guardian.any_instance.expects(:can_see?).with(post).returns(false) - get action, params: params, format: :json - expect(response).to be_forbidden - end - - it 'succeeds' do - get action, params: params, format: :json - expect(response).to be_success - end - - context "deleted post" do - before do - post.trash!(user) - end - - it "can't find deleted posts as an anonymous user" do - get action, params: params, format: :json - expect(response.status).to eq(404) - end - - it "can't find deleted posts as a regular user" do - log_in(:user) - get action, params: params, format: :json - expect(response.status).to eq(404) - end - - it "can find posts as a moderator" do - log_in(:moderator) - get action, params: params, format: :json - expect(response).to be_success - end - - it "can find posts as a admin" do - log_in(:admin) - get action, params: params, format: :json - expect(response).to be_success - end - end -end - -shared_examples 'action requires login' do |method, action, params| - it 'raises an exception when not logged in' do - options = { format: :json } - options.merge!(params: params) if params - self.public_send(method, action, options) - expect(response.status).to eq(403) - end -end - -describe PostsController do - - describe 'latest' do - let(:user) { log_in } - let!(:public_topic) { Fabricate(:topic) } - let!(:post) { Fabricate(:post, user: user, topic: public_topic) } - let!(:private_topic) { Fabricate(:topic, archetype: Archetype.private_message, category: nil) } - let!(:private_post) { Fabricate(:post, user: user, topic: private_topic) } - let!(:topicless_post) { Fabricate(:post, user: user, raw: '

    Car 54, where are you?

    ') } - - context "public posts" do - before do - topicless_post.update topic_id: -100 - end - - it 'returns public posts with topic for json' do - get :latest, params: { id: "latest_posts" }, format: :json - expect(response).to be_success - json = ::JSON.parse(response.body) - post_ids = json['latest_posts'].map { |p| p['id'] } - expect(post_ids).to include post.id - expect(post_ids).to_not include private_post.id - expect(post_ids).to_not include topicless_post.id - end - end - - context 'private posts' do - before do - Guardian.any_instance.expects(:can_see?).with(private_post).returns(true) - end - - it 'returns private posts for json' do - get :latest, params: { id: "private_posts" }, format: :json - expect(response).to be_success - json = ::JSON.parse(response.body) - post_ids = json['private_posts'].map { |p| p['id'] } - expect(post_ids).to include private_post.id - expect(post_ids).to_not include post.id - end - end - end - - describe 'cooked' do - before do - post = Post.new(cooked: 'wat') - PostsController.any_instance.expects(:find_post_from_params).returns(post) - end - - it 'returns the cooked conent' do - get :cooked, params: { id: 1234 }, format: :json - expect(response).to be_success - json = ::JSON.parse(response.body) - expect(json).to be_present - expect(json['cooked']).to eq('wat') - end - end - - describe 'raw_email' do - include_examples "action requires login", :get, :raw_email, id: 2 - - describe "when logged in" do - let(:user) { log_in } - let(:post) { Fabricate(:post, deleted_at: 2.hours.ago, user: user, raw_email: 'email_content') } - - it "raises an error if the user doesn't have permission to view raw email" do - Guardian.any_instance.expects(:can_view_raw_email?).returns(false) - - get :raw_email, params: { id: post.id }, format: :json - - expect(response).to be_forbidden - end - - it "can view raw email" do - Guardian.any_instance.expects(:can_view_raw_email?).returns(true) - - get :raw_email, params: { id: post.id }, format: :json - - expect(response).to be_success - json = ::JSON.parse(response.body) - expect(json['raw_email']).to eq('email_content') - end - - end - - end - - describe 'show' do - include_examples 'finding and showing post' do - let(:action) { :show } - let(:params) { { id: post.id } } - end - - it 'gets all the expected fields' do - # non fabricated test - new_post = create_post - get :show, params: { id: new_post.id }, format: :json - parsed = JSON.parse(response.body) - expect(parsed["topic_slug"]).to eq(new_post.topic.slug) - expect(parsed["moderator"]).to eq(false) - expect(parsed["username"]).to eq(new_post.user.username) - expect(parsed["cooked"]).to eq(new_post.cooked) - end - end - - describe 'by_number' do - include_examples 'finding and showing post' do - let(:action) { :by_number } - let(:params) { { topic_id: post.topic_id, post_number: post.post_number } } - end - end - - describe 'reply_history' do - include_examples 'finding and showing post' do - let(:action) { :reply_history } - let(:params) { { id: post.id } } - end - - it 'asks post for reply history' do - Post.any_instance.expects(:reply_history) - get :reply_history, params: { id: post.id }, format: :json - end - end - - describe 'replies' do - include_examples 'finding and showing post' do - let(:action) { :replies } - let(:params) { { post_id: post.id } } - end - - it 'asks post for replies' do - p1 = Fabricate(:post) - get :replies, params: { post_id: p1.id }, format: :json - expect(response.status).to eq(200) - end - end - - describe 'delete a post' do - include_examples 'action requires login', :delete, :destroy, id: 123 - - describe 'when logged in' do - - let(:user) { log_in(:moderator) } - let(:post) { Fabricate(:post, user: user, post_number: 2) } - - it 'does not allow to destroy when edit time limit expired' do - Guardian.any_instance.stubs(:can_delete_post?).with(post).returns(false) - Post.any_instance.stubs(:edit_time_limit_expired?).returns(true) - - delete :destroy, params: { id: post.id }, format: :json - - expect(response.status).to eq(422) - expect(JSON.parse(response.body)['errors']).to include(I18n.t('too_late_to_edit')) - end - - it "raises an error when the user doesn't have permission to see the post" do - Guardian.any_instance.expects(:can_delete?).with(post).returns(false) - delete :destroy, params: { id: post.id }, format: :json - expect(response).to be_forbidden - end - - it "uses a PostDestroyer" do - destroyer = mock - PostDestroyer.expects(:new).returns(destroyer) - destroyer.expects(:destroy) - delete :destroy, params: { id: post.id }, format: :json - end - - end - end - - describe 'recover a post' do - include_examples 'action requires login', :put, :recover, post_id: 123 - - describe 'when logged in' do - - let(:user) { log_in(:moderator) } - let(:post) { Fabricate(:post, user: user, post_number: 2) } - - it "raises an error when the user doesn't have permission to see the post" do - Guardian.any_instance.expects(:can_recover_post?).with(post).returns(false) - put :recover, params: { post_id: post.id }, format: :json - expect(response).to be_forbidden - end - - it "recovers a post correctly" do - topic_id = create_post.topic_id - post = create_post(topic_id: topic_id) - - PostDestroyer.new(user, post).destroy - put :recover, params: { post_id: post.id }, format: :json - post.reload - expect(post.deleted_at).to eq(nil) - end - - end - end - - describe 'destroy_many' do - include_examples 'action requires login', :delete, :destroy_many, post_ids: [123, 345] - - describe 'when logged in' do - - let!(:poster) { log_in(:moderator) } - let!(:post1) { Fabricate(:post, user: poster, post_number: 2) } - let!(:post2) { Fabricate(:post, topic_id: post1.topic_id, user: poster, post_number: 3, reply_to_post_number: post1.post_number) } - - it "raises invalid parameters no post_ids" do - expect do - delete :destroy_many, format: :json - end.to raise_error(ActionController::ParameterMissing) - end - - it "raises invalid parameters with missing ids" do - delete :destroy_many, params: { post_ids: [12345] }, format: :json - expect(response.status).to eq(400) - end - - it "raises an error when the user doesn't have permission to delete the posts" do - Guardian.any_instance.expects(:can_delete?).with(instance_of(Post)).returns(false) - delete :destroy_many, params: { post_ids: [post1.id, post2.id] }, format: :json - expect(response).to be_forbidden - end - - it "deletes the post" do - PostDestroyer.any_instance.expects(:destroy).twice - delete :destroy_many, params: { post_ids: [post1.id, post2.id] }, format: :json - end - - it "updates the highest read data for the forum" do - Topic.expects(:reset_highest).twice - delete :destroy_many, params: { post_ids: [post1.id, post2.id] }, format: :json - end - - describe "can delete replies" do - - before do - PostReply.create(post_id: post1.id, reply_id: post2.id) - end - - it "deletes the post and the reply to it" do - PostDestroyer.any_instance.expects(:destroy).twice - delete :destroy_many, - params: { post_ids: [post1.id], reply_post_ids: [post1.id] }, - format: :json - end - - end - - end - - end - - describe 'edit a post' do - - include_examples 'action requires login', :put, :update, id: 2 - - let(:post) { Fabricate(:post, user: logged_in_as) } - let(:update_params) do - { - id: post.id, - post: { raw: 'edited body', edit_reason: 'typo' }, - image_sizes: { 'http://image.com/image.jpg' => { 'width' => 123, 'height' => 456 } }, - } - end - let(:moderator) { Fabricate(:moderator) } - - describe 'when logged in as a regular user' do - let(:logged_in_as) { log_in } - - it 'does not allow to update when edit time limit expired' do - Guardian.any_instance.stubs(:can_edit?).with(post).returns(false) - Post.any_instance.stubs(:edit_time_limit_expired?).returns(true) - - put :update, params: update_params, format: :json - - expect(response.status).to eq(422) - expect(JSON.parse(response.body)['errors']).to include(I18n.t('too_late_to_edit')) - end - - it 'passes the image sizes through' do - Post.any_instance.expects(:image_sizes=) - put :update, params: update_params, format: :json - end - - it 'passes the edit reason through' do - Post.any_instance.expects(:edit_reason=) - put :update, params: update_params, format: :json - end - - it "raises an error when the post parameter is missing" do - update_params.delete(:post) - expect { - put :update, params: update_params, format: :json - }.to raise_error(ActionController::ParameterMissing) - end - - it "raises an error when the user doesn't have permission to see the post" do - Guardian.any_instance.expects(:can_edit?).with(post).at_least_once.returns(false) - put :update, params: update_params, format: :json - expect(response).to be_forbidden - end - - it "calls revise with valid parameters" do - PostRevisor.any_instance.expects(:revise!).with(post.user, { raw: 'edited body' , edit_reason: 'typo' }, anything) - put :update, params: update_params, format: :json - end - - it "extracts links from the new body" do - param = update_params - param[:post][:raw] = 'I just visited this https://google.com so many cool links' - - put :update, params: param, format: :json - - expect(response).to be_success - expect(TopicLink.count).to eq(1) - end - - it "doesn't allow updating of deleted posts" do - first_post = post.topic.ordered_posts.first - PostDestroyer.new(moderator, first_post).destroy - - put :update, params: update_params, format: :json - expect(response).not_to be_success - end - end - - describe "when logged in as staff" do - let(:logged_in_as) { log_in(:moderator) } - - it "supports updating posts in deleted topics" do - first_post = post.topic.ordered_posts.first - PostDestroyer.new(moderator, first_post).destroy - - put :update, params: update_params, format: :json - expect(response).to be_success - - post.reload - expect(post.raw).to eq('edited body') - end - end - - end - - describe 'bookmark a post' do - - include_examples 'action requires login', :put, :bookmark, post_id: 2 - - describe 'when logged in' do - let(:user) { log_in } - let(:post) { Fabricate(:post, user: user) } - let(:private_message) { Fabricate(:private_message_post) } - - it "raises an error if the user doesn't have permission to see the post" do - post - - put :bookmark, - params: { post_id: private_message.id, bookmarked: 'true' }, - format: :json - - expect(response).to be_forbidden - end - - it 'creates a bookmark' do - put :bookmark, - params: { post_id: post.id, bookmarked: 'true' }, - format: :json - - post_action = PostAction.find_by(user: user, post: post) - - expect(post_action.post_action_type_id).to eq(PostActionType.types[:bookmark]) - end - - context "removing a bookmark" do - let(:post_action) { PostAction.act(user, post, PostActionType.types[:bookmark]) } - let(:admin) { Fabricate(:admin) } - - it "returns the right response when post is not bookmarked" do - put :bookmark, - params: { post_id: Fabricate(:post, user: user).id }, - format: :json - - expect(response.status).to eq(404) - end - - it 'should be able to remove a bookmark' do - post_action - put :bookmark, params: { post_id: post.id }, format: :json - - expect(PostAction.find_by(id: post_action.id)).to eq(nil) - end - - describe "when user doesn't have permission to see bookmarked post" do - it "should still be able to remove a bookmark" do - post_action - post = post_action.post - topic = post.topic - topic.convert_to_private_message(admin) - topic.remove_allowed_user(admin, user.username) - - expect(Guardian.new(user).can_see_post?(post.reload)).to eq(false) - - put :bookmark, params: { post_id: post.id }, format: :json - - expect(PostAction.find_by(id: post_action.id)).to eq(nil) - end - end - - describe "when post has been deleted" do - it "should still be able to remove a bookmark" do - post = post_action.post - post.trash! - - put :bookmark, params: { post_id: post.id }, format: :json - - expect(PostAction.find_by(id: post_action.id)).to eq(nil) - end - end - end - - end - - end - - describe "wiki" do - - include_examples "action requires login", :put, :wiki, post_id: 2 - - describe "when logged in" do - let(:user) { log_in } - let(:post) { Fabricate(:post, user: user) } - - it "raises an error if the user doesn't have permission to wiki the post" do - Guardian.any_instance.expects(:can_wiki?).with(post).returns(false) - - put :wiki, - params: { post_id: post.id, wiki: 'true' }, - format: :json - - expect(response).to be_forbidden - end - - it "toggle wiki status should create a new version" do - _admin = log_in(:admin) - another_user = Fabricate(:user) - another_post = Fabricate(:post, user: another_user) - - expect do - put :wiki, - params: { post_id: another_post.id, wiki: 'true' }, - format: :json - end.to change { another_post.reload.version }.by(1) - - expect do - put :wiki, - params: { post_id: another_post.id, wiki: 'false' }, - format: :json - end.to change { another_post.reload.version }.by(-1) - - _another_admin = log_in(:admin) - - expect do - put :wiki, - params: { post_id: another_post.id, wiki: 'true' }, - format: :json - end.to change { another_post.reload.version }.by(1) - end - - it "can wiki a post" do - Guardian.any_instance.expects(:can_wiki?).with(post).returns(true) - - put :wiki, params: { post_id: post.id, wiki: 'true' }, format: :json - - post.reload - expect(post.wiki).to eq(true) - end - - it "can unwiki a post" do - wikied_post = Fabricate(:post, user: user, wiki: true) - Guardian.any_instance.expects(:can_wiki?).with(wikied_post).returns(true) - - put :wiki, params: { post_id: wikied_post.id, wiki: 'false' }, format: :json - - wikied_post.reload - expect(wikied_post.wiki).to eq(false) - end - - end - - end - - describe "post_type" do - - include_examples "action requires login", :put, :post_type, post_id: 2 - - describe "when logged in" do - let(:user) { log_in } - let(:post) { Fabricate(:post, user: user) } - - it "raises an error if the user doesn't have permission to change the post type" do - Guardian.any_instance.expects(:can_change_post_type?).returns(false) - - put :post_type, params: { post_id: post.id, post_type: 2 }, format: :json - - expect(response).to be_forbidden - end - - it "can change the post type" do - Guardian.any_instance.expects(:can_change_post_type?).returns(true) - - put :post_type, params: { post_id: post.id, post_type: 2 }, format: :json - - post.reload - expect(post.post_type).to eq(2) - end - - end - - end - - describe "rebake" do - - include_examples "action requires login", :put, :rebake, post_id: 2 - - describe "when logged in" do - let(:user) { log_in } - let(:post) { Fabricate(:post, user: user) } - - it "raises an error if the user doesn't have permission to rebake the post" do - Guardian.any_instance.expects(:can_rebake?).returns(false) - - put :rebake, params: { post_id: post.id }, format: :json - - expect(response).to be_forbidden - end - - it "can rebake the post" do - Guardian.any_instance.expects(:can_rebake?).returns(true) - - put :rebake, params: { post_id: post.id }, format: :json - - expect(response).to be_success - end - - end - - end - - describe 'creating a post' do - - before do - SiteSetting.min_first_post_typing_time = 0 - end - - include_examples 'action requires login', :post, :create - - context 'api' do - it 'memoizes duplicate requests' do - raw = "this is a test post 123 #{SecureRandom.hash}" - title = "this is a title #{SecureRandom.hash}" - - user = Fabricate(:user) - master_key = ApiKey.create_master_key.key - - post :create, params: { - api_username: user.username, - api_key: master_key, - raw: raw, - title: title, - wpid: 1 - }, format: :json - - expect(response).to be_success - original = response.body - - post :create, params: { - api_username: user.username_lower, - api_key: master_key, - raw: raw, - title: title, - wpid: 2 - }, format: :json - - expect(response).to be_success - expect(response.body).to eq(original) - end - - it 'allows to create posts in import_mode' do - NotificationEmailer.enable - post_1 = Fabricate(:post) - user = Fabricate(:user) - master_key = ApiKey.create_master_key.key - - post :create, params: { - api_username: user.username, - api_key: master_key, - raw: 'this is test reply 1', - topic_id: post_1.topic.id, - reply_to_post_number: 1 - }, format: :json - - expect(response).to be_success - expect(post_1.topic.user.notifications.count).to eq(1) - post_1.topic.user.notifications.destroy_all - - post :create, params: { - api_username: user.username, - api_key: master_key, - raw: 'this is test reply 2', - topic_id: post_1.topic.id, - reply_to_post_number: 1, - import_mode: true - }, format: :json - - expect(response).to be_success - expect(post_1.topic.user.notifications.count).to eq(0) - - post :create, params: { - api_username: user.username, - api_key: master_key, - raw: 'this is test reply 3', - topic_id: post_1.topic.id, - reply_to_post_number: 1, - import_mode: false - } - - expect(response).to be_success - expect(post_1.topic.user.notifications.count).to eq(1) - end - end - - describe 'when logged in' do - - let!(:user) { log_in } - let(:moderator) { log_in(:moderator) } - let(:new_post) { Fabricate.build(:post, user: user) } - - context "fast typing" do - before do - SiteSetting.min_first_post_typing_time = 3000 - SiteSetting.auto_silence_fast_typers_max_trust_level = 1 - end - - it 'queues the post if min_first_post_typing_time is not met' do - post :create, params: { - raw: 'this is the test content', - title: 'this is the test title for the topic' - }, format: :json - - expect(response).to be_success - parsed = ::JSON.parse(response.body) - - expect(parsed["action"]).to eq("enqueued") - - user.reload - expect(user).to be_silenced - - qp = QueuedPost.first - - mod = Fabricate(:moderator) - qp.approve!(mod) - - user.reload - expect(user).not_to be_silenced - end - - it "doesn't enqueue replies when the topic is closed" do - topic = Fabricate(:closed_topic) - - post :create, params: { - raw: 'this is the test content', - title: 'this is the test title for the topic', - topic_id: topic.id - }, format: :json - - expect(response).not_to be_success - parsed = ::JSON.parse(response.body) - expect(parsed["action"]).not_to eq("enqueued") - end - - it "doesn't enqueue replies when the post is too long" do - SiteSetting.max_post_length = 10 - - post :create, params: { - raw: 'this is the test content', - title: 'this is the test title for the topic' - }, format: :json - - expect(response).not_to be_success - parsed = ::JSON.parse(response.body) - expect(parsed["action"]).not_to eq("enqueued") - end - end - - it 'silences correctly based on auto_silence_first_post_regex' do - SiteSetting.auto_silence_first_post_regex = "I love candy|i eat s[1-5]" - - post :create, params: { - raw: 'this is the test content', - title: 'when I eat s3 sometimes when not looking' - }, format: :json - - expect(response).to be_success - parsed = ::JSON.parse(response.body) - - expect(parsed["action"]).to eq("enqueued") - - user.reload - expect(user).to be_silenced - end - - it "can send a message to a group" do - - group = Group.create(name: 'test_group', messageable_level: Group::ALIAS_LEVELS[:nobody]) - user1 = Fabricate(:user) - group.add(user1) - - post :create, params: { - raw: 'I can haz a test', - title: 'I loves my test', - target_usernames: group.name, - archetype: Archetype.private_message - }, format: :json - - expect(response).not_to be_success - - # allow pm to this group - group.update_columns(messageable_level: Group::ALIAS_LEVELS[:everyone]) - - post :create, params: { - raw: 'I can haz a test', - title: 'I loves my test', - target_usernames: group.name, - archetype: Archetype.private_message - }, format: :json - - expect(response).to be_success - - parsed = ::JSON.parse(response.body) - post = Post.find(parsed['id']) - - expect(post.topic.topic_allowed_users.length).to eq(1) - expect(post.topic.topic_allowed_groups.length).to eq(1) - end - - it "returns the nested post with a param" do - post :create, params: { - raw: 'this is the test content', - title: 'this is the test title for the topic', - nested_post: true - }, format: :json - - expect(response).to be_success - parsed = ::JSON.parse(response.body) - expect(parsed['post']).to be_present - expect(parsed['post']['cooked']).to be_present - end - - it 'protects against dupes' do - raw = "this is a test post 123 #{SecureRandom.hash}" - title = "this is a title #{SecureRandom.hash}" - - post :create, params: { raw: raw, title: title, wpid: 1 }, format: :json - expect(response).to be_success - - post :create, params: { raw: raw, title: title, wpid: 2 }, format: :json - expect(response).not_to be_success - end - - context "errors" do - - let(:post_with_errors) { Fabricate.build(:post, user: user) } - - before do - post_with_errors.errors.add(:base, I18n.t(:spamming_host)) - PostCreator.any_instance.stubs(:errors).returns(post_with_errors.errors) - PostCreator.any_instance.expects(:create).returns(post_with_errors) - end - - it "does not succeed" do - post :create, params: { raw: 'test' }, format: :json - User.any_instance.expects(:flag_linked_posts_as_spam).never - expect(response).not_to be_success - end - - it "it triggers flag_linked_posts_as_spam when the post creator returns spam" do - PostCreator.any_instance.expects(:spam?).returns(true) - User.any_instance.expects(:flag_linked_posts_as_spam) - post :create, params: { raw: 'test' }, format: :json - end - end - end - end - - describe "revisions" do - - let(:post) { Fabricate(:post, version: 2) } - let(:post_revision) { Fabricate(:post_revision, post: post) } - - it "throws an exception when revision is < 2" do - get :revisions, params: { - post_id: post_revision.post_id, revision: 1 - }, format: :json - expect(response.status).to eq(400) - end - - context "when edit history is not visible to the public" do - - before { SiteSetting.edit_history_visible_to_public = false } - - it "ensures anonymous cannot see the revisions" do - get :revisions, params: { - post_id: post_revision.post_id, revision: post_revision.number - }, format: :json - - expect(response).to be_forbidden - end - - it "ensures regular user cannot see the revisions" do - log_in(:user) - get :revisions, params: { - post_id: post_revision.post_id, revision: post_revision.number - }, format: :json - expect(response).to be_forbidden - end - - it "ensures staff can see the revisions" do - log_in(:admin) - get :revisions, params: { - post_id: post_revision.post_id, revision: post_revision.number - }, format: :json - expect(response).to be_success - end - - it "ensures poster can see the revisions" do - user = log_in(:active_user) - post = Fabricate(:post, user: user, version: 3) - pr = Fabricate(:post_revision, user: user, post: post) - get :revisions, params: { - post_id: pr.post_id, revision: pr.number - }, format: :json - expect(response).to be_success - end - - it "ensures trust level 4 can see the revisions" do - log_in(:trust_level_4) - get :revisions, params: { - post_id: post_revision.post_id, revision: post_revision.number - }, format: :json - expect(response).to be_success - end - - end - - context "when edit history is visible to everyone" do - - before { SiteSetting.edit_history_visible_to_public = true } - - it "ensures anyone can see the revisions" do - get :revisions, params: { - post_id: post_revision.post_id, revision: post_revision.number - }, format: :json - expect(response).to be_success - end - - end - - context "deleted post" do - let(:admin) { log_in(:admin) } - let(:deleted_post) { Fabricate(:post, user: admin, version: 3) } - let(:deleted_post_revision) { Fabricate(:post_revision, user: admin, post: deleted_post) } - - before { deleted_post.trash!(admin) } - - it "also work on deleted post" do - get :revisions, params: { - post_id: deleted_post_revision.post_id, revision: deleted_post_revision.number - }, format: :json - expect(response).to be_success - end - end - - context "deleted topic" do - let(:admin) { log_in(:admin) } - let(:deleted_topic) { Fabricate(:topic, user: admin) } - let(:post) { Fabricate(:post, user: admin, topic: deleted_topic, version: 3) } - let(:post_revision) { Fabricate(:post_revision, user: admin, post: post) } - - before { deleted_topic.trash!(admin) } - - it "also work on deleted topic" do - get :revisions, params: { - post_id: post_revision.post_id, revision: post_revision.number - } - expect(response).to be_success - end - end - - end - - describe 'revert post to a specific revision' do - include_examples 'action requires login', :put, :revert, post_id: 123, revision: 2 - - let(:post) { Fabricate(:post, user: logged_in_as, raw: "Lorem ipsum dolor sit amet, cu nam libris tractatos, ancillae senserit ius ex") } - let(:post_revision) { Fabricate(:post_revision, post: post, modifications: { "raw" => ["this is original post body.", "this is edited post body."] }) } - let(:blank_post_revision) { Fabricate(:post_revision, post: post, modifications: { "edit_reason" => ["edit reason #1", "edit reason #2"] }) } - let(:same_post_revision) { Fabricate(:post_revision, post: post, modifications: { "raw" => ["Lorem ipsum dolor sit amet, cu nam libris tractatos, ancillae senserit ius ex", "this is edited post body."] }) } - - let(:revert_params) do - { - post_id: post.id, - revision: post_revision.number - } - end - let(:moderator) { Fabricate(:moderator) } - - describe 'when logged in as a regular user' do - let(:logged_in_as) { log_in } - - it "does not work" do - put :revert, params: revert_params, format: :json - expect(response).to_not be_success - end - end - - describe "when logged in as staff" do - let(:logged_in_as) { log_in(:moderator) } - - it "fails when revision is < 2" do - put :revert, params: { post_id: post.id, revision: 1 }, format: :json - expect(response.status).to eq(400) - end - - it "fails when post_revision record is not found" do - put :revert, params: { - post_id: post.id, revision: post_revision.number + 1 - }, format: :json - expect(response).to_not be_success - end - - it "fails when post record is not found" do - put :revert, params: { - post_id: post.id + 1, revision: post_revision.number - }, format: :json - expect(response).to_not be_success - end - - it "fails when revision is blank" do - put :revert, params: { - post_id: post.id, revision: blank_post_revision.number - }, format: :json - - expect(response.status).to eq(422) - expect(JSON.parse(response.body)['errors']).to include(I18n.t('revert_version_same')) - end - - it "fails when revised version is same as current version" do - put :revert, params: { - post_id: post.id, revision: same_post_revision.number - }, format: :json - - expect(response.status).to eq(422) - expect(JSON.parse(response.body)['errors']).to include(I18n.t('revert_version_same')) - end - - it "works!" do - put :revert, params: revert_params, format: :json - expect(response).to be_success - end - - it "supports reverting posts in deleted topics" do - first_post = post.topic.ordered_posts.first - PostDestroyer.new(moderator, first_post).destroy - - put :revert, params: revert_params, format: :json - expect(response).to be_success - end - end - end - - describe 'expandable embedded posts' do - let(:post) { Fabricate(:post) } - - it "raises an error when you can't see the post" do - Guardian.any_instance.expects(:can_see?).with(post).returns(false) - get :expand_embed, params: { id: post.id }, format: :json - expect(response).not_to be_success - end - - it "retrieves the body when you can see the post" do - Guardian.any_instance.expects(:can_see?).with(post).returns(true) - TopicEmbed.expects(:expanded_for).with(post).returns("full content") - get :expand_embed, params: { id: post.id }, format: :json - expect(response).to be_success - expect(::JSON.parse(response.body)['cooked']).to eq("full content") - end - end - - describe "flagged posts" do - - include_examples "action requires login", :get, :flagged_posts, username: "system" - - describe "when logged in" do - before { log_in } - - it "raises an error if the user doesn't have permission to see the flagged posts" do - Guardian.any_instance.expects(:can_see_flagged_posts?).returns(false) - get :flagged_posts, params: { username: "system" }, format: :json - expect(response).to be_forbidden - end - - it "can see the flagged posts when authorized" do - Guardian.any_instance.expects(:can_see_flagged_posts?).returns(true) - get :flagged_posts, params: { username: "system" }, format: :json - expect(response).to be_success - end - - it "only shows agreed and deferred flags" do - user = Fabricate(:user) - post_agreed = create_post(user: user) - post_deferred = create_post(user: user) - post_disagreed = create_post(user: user) - - moderator = Fabricate(:moderator) - PostAction.act(moderator, post_agreed, PostActionType.types[:spam]) - PostAction.act(moderator, post_deferred, PostActionType.types[:off_topic]) - PostAction.act(moderator, post_disagreed, PostActionType.types[:inappropriate]) - - admin = Fabricate(:admin) - PostAction.agree_flags!(post_agreed, admin) - PostAction.defer_flags!(post_deferred, admin) - PostAction.clear_flags!(post_disagreed, admin) - - Guardian.any_instance.expects(:can_see_flagged_posts?).returns(true) - get :flagged_posts, params: { username: user.username }, format: :json - expect(response).to be_success - - expect(JSON.parse(response.body).length).to eq(2) - end - - end - - end - - describe "deleted posts" do - - include_examples "action requires login", :get, :deleted_posts, username: "system" - - describe "when logged in" do - before { log_in } - - it "raises an error if the user doesn't have permission to see the deleted posts" do - Guardian.any_instance.expects(:can_see_deleted_posts?).returns(false) - get :deleted_posts, params: { username: "system" }, format: :json - expect(response).to be_forbidden - end - - it "can see the deleted posts when authorized" do - Guardian.any_instance.expects(:can_see_deleted_posts?).returns(true) - get :deleted_posts, params: { username: "system" }, format: :json - expect(response).to be_success - end - - it "doesn't return secured categories for moderators if they don't have access" do - user = Fabricate(:user) - admin = Fabricate(:admin) - Fabricate(:moderator) - - group = Fabricate(:group) - group.add_owner(user) - - secured_category = Fabricate(:private_category, group: group) - secured_post = create_post(user: user, category: secured_category) - PostDestroyer.new(admin, secured_post).destroy - - log_in(:moderator) - get :deleted_posts, params: { username: user.username }, format: :json - expect(response).to be_success - - data = JSON.parse(response.body) - expect(data.length).to eq(0) - end - - it "doesn't return PMs for moderators" do - user = Fabricate(:user) - admin = Fabricate(:admin) - Fabricate(:moderator) - - pm_post = create_post(user: user, archetype: 'private_message', target_usernames: [admin.username]) - PostDestroyer.new(admin, pm_post).destroy - - log_in(:moderator) - get :deleted_posts, params: { username: user.username }, format: :json - expect(response).to be_success - - data = JSON.parse(response.body) - expect(data.length).to eq(0) - end - - it "only shows posts deleted by other users" do - user = Fabricate(:user) - admin = Fabricate(:admin) - - create_post(user: user) - post_deleted_by_user = create_post(user: user) - post_deleted_by_admin = create_post(user: user) - - PostDestroyer.new(user, post_deleted_by_user).destroy - PostDestroyer.new(admin, post_deleted_by_admin).destroy - - Guardian.any_instance.expects(:can_see_deleted_posts?).returns(true) - get :deleted_posts, params: { username: user.username }, format: :json - expect(response).to be_success - - data = JSON.parse(response.body) - expect(data.length).to eq(1) - expect(data[0]["id"]).to eq(post_deleted_by_admin.id) - expect(data[0]["deleted_by"]["id"]).to eq(admin.id) - end - - end - - end - - describe "view raw" do - describe "by ID" do - it "can be viewed by anonymous" do - post = Fabricate(:post, raw: "123456789") - get :markdown_id, params: { id: post.id }, format: :json - expect(response).to be_success - expect(response.body).to eq("123456789") - end - end - - describe "by post number" do - it "can be viewed by anonymous" do - topic = Fabricate(:topic) - post = Fabricate(:post, topic: topic, post_number: 1, raw: "123456789") - post.save - get :markdown_num, params: { topic_id: topic.id, post_number: 1 }, format: :json - expect(response).to be_success - expect(response.body).to eq("123456789") - end - end - end - - describe "short link" do - let(:topic) { Fabricate(:topic) } - let(:post) { Fabricate(:post, topic: topic) } - - it "redirects to the topic" do - get :short_link, params: { post_id: post.id }, format: :json - expect(response).to be_redirect - end - - it "returns a 403 when access is denied" do - Guardian.any_instance.stubs(:can_see?).returns(false) - get :short_link, params: { post_id: post.id }, format: :json - expect(response).to be_forbidden - end - end -end diff --git a/spec/requests/posts_controller_spec.rb b/spec/requests/posts_controller_spec.rb index f239bd79fa..6ad5f32dd9 100644 --- a/spec/requests/posts_controller_spec.rb +++ b/spec/requests/posts_controller_spec.rb @@ -1,10 +1,65 @@ require 'rails_helper' -RSpec.describe PostsController do +shared_examples 'finding and showing post' do + let(:user) { Fabricate(:user) } + let(:post) { Fabricate(:post, user: user) } + + it "ensures the user can't see the post" do + topic = post.topic + topic.convert_to_private_message(Discourse.system_user) + topic.remove_allowed_user(Discourse.system_user, user.username) + get url + expect(response).to be_forbidden + end + + it 'succeeds' do + get url + expect(response).to be_success + end + + context "deleted post" do + before do + post.trash!(user) + end + + it "can't find deleted posts as an anonymous user" do + get url + expect(response.status).to eq(404) + end + + it "can't find deleted posts as a regular user" do + sign_in(user) + get url + expect(response.status).to eq(404) + end + + it "can find posts as a moderator" do + sign_in(Fabricate(:moderator)) + get url + expect(response).to be_success + end + + it "can find posts as a admin" do + sign_in(Fabricate(:admin)) + get url + expect(response).to be_success + end + end +end + +shared_examples 'action requires login' do |method, url, params = {}| + it 'raises an exception when not logged in' do + self.public_send(method, url, params) + expect(response.status).to eq(403) + end +end + +describe PostsController do let(:user) { Fabricate(:user) } let(:category) { Fabricate(:category) } let(:topic) { Fabricate(:topic) } let(:public_post) { Fabricate(:post, user: user, topic: topic) } + let(:topicless_post) { Fabricate(:post, user: user, raw: '

    Car 54, where are you?

    ') } let(:private_topic) do Fabricate(:topic, archetype: Archetype.private_message, category: nil) @@ -12,7 +67,275 @@ RSpec.describe PostsController do let(:private_post) { Fabricate(:post, user: user, topic: private_topic) } + describe '#show' do + include_examples 'finding and showing post' do + let(:url) { "/posts/#{post.id}.json" } + end + + it 'gets all the expected fields' do + # non fabricated test + new_post = create_post + + get "/posts/#{new_post.id}.json" + parsed = JSON.parse(response.body) + + expect(parsed["topic_slug"]).to eq(new_post.topic.slug) + expect(parsed["moderator"]).to eq(false) + expect(parsed["username"]).to eq(new_post.user.username) + expect(parsed["cooked"]).to eq(new_post.cooked) + end + end + + describe '#by_number' do + include_examples 'finding and showing post' do + let(:url) { "/posts/by_number/#{post.topic_id}/#{post.post_number}.json" } + end + end + + describe '#reply_history' do + include_examples 'finding and showing post' do + let(:url) { "/posts/#{post.id}/reply-history.json" } + end + + it 'asks post for reply history' do + post = Fabricate(:post) + get "/posts/#{post.id}/reply-history.json" + expect(response.status).to eq(200) + end + end + + describe '#replies' do + include_examples 'finding and showing post' do + let(:url) { "/posts/#{post.id}/replies.json" } + end + + it 'asks post for replies' do + p1 = Fabricate(:post) + get "/posts/#{p1.id}/replies.json" + expect(response.status).to eq(200) + end + end + + describe '#destroy' do + include_examples 'action requires login', :delete, "/posts/123.json" + + describe 'when logged in' do + let(:topic) { Fabricate(:topic) } + let(:user) { Fabricate(:user) } + let(:moderator) { Fabricate(:moderator) } + + it 'does not allow to destroy when edit time limit expired' do + SiteSetting.post_edit_time_limit = 5 + + post = Fabricate(:post, topic_id: topic.id, created_at: 10.minutes.ago, user_id: user.id, post_number: 3) + sign_in(user) + + delete "/posts/#{post.id}.json" + + expect(response.status).to eq(422) + expect(JSON.parse(response.body)['errors']).to include(I18n.t('too_late_to_edit')) + end + + it "raises an error when the user doesn't have permission to see the post" do + pm = Fabricate(:private_message_topic) + post = Fabricate(:post, topic: pm, post_number: 3) + + sign_in(user) + + delete "/posts/#{post.id}.json" + expect(response).to be_forbidden + end + + it "uses a PostDestroyer" do + post = Fabricate(:post, topic_id: topic.id, post_number: 3) + sign_in(moderator) + + destroyer = mock + PostDestroyer.expects(:new).returns(destroyer) + destroyer.expects(:destroy) + + delete "/posts/#{post.id}.json" + end + end + end + + describe '#destroy_many' do + include_examples 'action requires login', :delete, "/posts/destroy_many.json", params: { post_ids: [123, 345] } + + describe 'when logged in' do + let(:poster) { Fabricate(:moderator) } + let(:post1) { Fabricate(:post, user: poster, post_number: 2) } + let(:post2) { Fabricate(:post, topic_id: post1.topic_id, user: poster, post_number: 3, reply_to_post_number: post1.post_number) } + + it "raises invalid parameters no post_ids" do + sign_in(poster) + delete "/posts/destroy_many.json" + expect(response.status).to eq(400) + expect(response.message.downcase).to eq("bad request") + end + + it "raises invalid parameters with missing ids" do + sign_in(poster) + delete "/posts/destroy_many.json", params: { post_ids: [12345] } + expect(response.status).to eq(400) + end + + it "raises an error when the user doesn't have permission to delete the posts" do + sign_in(Fabricate(:user)) + delete "/posts/destroy_many.json", params: { post_ids: [post1.id, post2.id] } + expect(response).to be_forbidden + end + + it "deletes the post" do + sign_in(poster) + PostDestroyer.any_instance.expects(:destroy).twice + delete "/posts/destroy_many.json", params: { post_ids: [post1.id, post2.id] } + expect(response.status).to eq(200) + end + + it "updates the highest read data for the forum" do + sign_in(poster) + Topic.expects(:reset_highest).twice + delete "/posts/destroy_many.json", params: { post_ids: [post1.id, post2.id] } + end + + describe "can delete replies" do + before do + PostReply.create(post_id: post1.id, reply_id: post2.id) + end + + it "deletes the post and the reply to it" do + sign_in(poster) + PostDestroyer.any_instance.expects(:destroy).twice + delete "/posts/destroy_many.json", params: { post_ids: [post1.id], reply_post_ids: [post1.id] } + end + end + end + end + + describe '#recover' do + include_examples 'action requires login', :put, "/posts/123/recover.json" + + describe 'when logged in' do + let(:user) { Fabricate(:user) } + let(:post) { Fabricate(:post, user: user, post_number: 2) } + + it "raises an error when the user doesn't have permission to see the post" do + post = Fabricate(:post, topic: Fabricate(:private_message_topic), post_number: 3) + sign_in(user) + + put "/posts/#{post.id}/recover.json" + expect(response).to be_forbidden + end + + it "recovers a post correctly" do + topic_id = create_post.topic_id + post = create_post(topic_id: topic_id) + sign_in(user) + + PostDestroyer.new(user, post).destroy + put "/posts/#{post.id}/recover.json" + post.reload + expect(post.trashed?).to be_falsey + end + end + end + describe '#update' do + include_examples 'action requires login', :put, "/posts/2.json" + + let(:user) { Fabricate(:user) } + let(:post) { Fabricate(:post, user: user) } + let(:update_params) do + { + post: { raw: 'edited body', edit_reason: 'typo' }, + image_sizes: { 'http://image.com/image.jpg' => { 'width' => 123, 'height' => 456 } }, + } + end + + let(:moderator) { Fabricate(:moderator) } + + describe 'when logged in as a regular user' do + before do + sign_in(user) + end + + it 'does not allow to update when edit time limit expired' do + SiteSetting.post_edit_time_limit = 5 + post = Fabricate(:post, created_at: 10.minutes.ago, user: user) + + put "/posts/#{post.id}.json", params: update_params + + expect(response.status).to eq(422) + expect(JSON.parse(response.body)['errors']).to include(I18n.t('too_late_to_edit')) + end + + it 'passes the image sizes through' do + Post.any_instance.expects(:image_sizes=) + put "/posts/#{post.id}.json", params: update_params + end + + it 'passes the edit reason through' do + put "/posts/#{post.id}.json", params: update_params + expect(response).to be_success + post.reload + expect(post.edit_reason).to eq("typo") + expect(post.raw).to eq("edited body") + end + + it "raises an error when the post parameter is missing" do + update_params.delete(:post) + put "/posts/#{post.id}.json", params: update_params + expect(response.status).to eq(400) + expect(response.message.downcase).to eq("bad request") + end + + it "raises an error when the user doesn't have permission to see the post" do + post = Fabricate(:private_message_post, post_number: 3) + put "/posts/#{post.id}.json", params: update_params + expect(response).to be_forbidden + end + + it "calls revise with valid parameters" do + PostRevisor.any_instance.expects(:revise!).with(post.user, { raw: 'edited body' , edit_reason: 'typo' }, anything) + put "/posts/#{post.id}.json", params: update_params + end + + it "extracts links from the new body" do + param = update_params + param[:post][:raw] = 'I just visited this https://google.com so many cool links' + + put "/posts/#{post.id}.json", params: param + + expect(response).to be_success + expect(TopicLink.count).to eq(1) + end + + it "doesn't allow updating of deleted posts" do + first_post = post.topic.ordered_posts.first + PostDestroyer.new(moderator, first_post).destroy + + put "/posts/#{first_post.id}.json", params: update_params + expect(response).not_to be_success + end + end + + describe "when logged in as staff" do + before do + sign_in(moderator) + end + + it "supports updating posts in deleted topics" do + first_post = post.topic.ordered_posts.first + PostDestroyer.new(moderator, first_post).destroy + + put "/posts/#{first_post.id}.json", params: update_params + expect(response).to be_success + + post.reload + expect(post.raw).to eq('edited body') + end + end it 'can not change category to a disallowed category' do post = create_post @@ -29,94 +352,503 @@ RSpec.describe PostsController do expect(response.status).not_to eq(200) expect(post.topic.category_id).not_to eq(category.id) end + end + describe '#bookmark' do + include_examples 'action requires login', :put, "/posts/2/bookmark.json" + + describe 'when logged in' do + before do + sign_in(user) + end + + let(:user) { Fabricate(:user) } + let(:post) { Fabricate(:post, user: user) } + let(:private_message) { Fabricate(:private_message_post) } + + it "raises an error if the user doesn't have permission to see the post" do + put "/posts/#{private_message.id}/bookmark.json", params: { bookmarked: "true" } + expect(response).to be_forbidden + end + + it 'creates a bookmark' do + put "/posts/#{post.id}/bookmark.json", params: { bookmarked: "true" } + expect(response).to be_success + + post_action = PostAction.find_by(user: user, post: post) + expect(post_action.post_action_type_id).to eq(PostActionType.types[:bookmark]) + end + + context "removing a bookmark" do + let(:post_action) { PostAction.act(user, post, PostActionType.types[:bookmark]) } + let(:admin) { Fabricate(:admin) } + + it "returns the right response when post is not bookmarked" do + put "/posts/#{Fabricate(:post, user: user).id}/bookmark.json" + expect(response.status).to eq(404) + end + + it "should be able to remove a bookmark" do + post_action + put "/posts/#{post.id}/bookmark.json" + + expect(PostAction.find_by(id: post_action.id)).to eq(nil) + end + + describe "when user doesn't have permission to see bookmarked post" do + it "should still be able to remove a bookmark" do + post_action + post = post_action.post + topic = post.topic + topic.convert_to_private_message(admin) + topic.remove_allowed_user(admin, user.username) + + expect(Guardian.new(user).can_see_post?(post.reload)).to eq(false) + + put "/posts/#{post.id}/bookmark.json" + + expect(PostAction.find_by(id: post_action.id)).to eq(nil) + end + end + + describe "when post has been deleted" do + it "should still be able to remove a bookmark" do + post = post_action.post + post.trash! + + put "/posts/#{post.id}/bookmark.json" + + expect(PostAction.find_by(id: post_action.id)).to eq(nil) + end + end + end + end + end + + describe '#wiki' do + include_examples "action requires login", :put, "/posts/2/wiki.json" + + describe "when logged in" do + before do + sign_in(user) + end + + let(:user) { Fabricate(:user) } + let(:post) { Fabricate(:post, user: user) } + + it "raises an error if the user doesn't have permission to wiki the post" do + put "/posts/#{post.id}/wiki.json", params: { wiki: 'true' } + expect(response).to be_forbidden + end + + it "toggle wiki status should create a new version" do + sign_in(Fabricate(:admin)) + another_user = Fabricate(:user) + another_post = Fabricate(:post, user: another_user) + + expect do + put "/posts/#{another_post.id}/wiki.json", params: { wiki: 'true' } + end.to change { another_post.reload.version }.by(1) + + expect do + put "/posts/#{another_post.id}/wiki.json", params: { wiki: 'false' } + end.to change { another_post.reload.version }.by(-1) + + sign_in(Fabricate(:admin)) + + expect do + put "/posts/#{another_post.id}/wiki.json", params: { wiki: 'true' } + end.to change { another_post.reload.version }.by(1) + end + + it "can wiki a post" do + sign_in(Fabricate(:admin)) + put "/posts/#{post.id}/wiki.json", params: { wiki: 'true' } + + post.reload + expect(post.wiki).to eq(true) + end + + it "can unwiki a post" do + wikied_post = Fabricate(:post, user: user, wiki: true) + sign_in(Fabricate(:admin)) + + put "/posts/#{wikied_post.id}/wiki.json", params: { wiki: 'false' } + + wikied_post.reload + expect(wikied_post.wiki).to eq(false) + end + end + end + + describe '#post_type' do + include_examples "action requires login", :put, "/posts/2/post_type.json" + + describe "when logged in" do + before do + sign_in(user) + end + + let(:user) { Fabricate(:user) } + let(:post) { Fabricate(:post, user: user) } + + it "raises an error if the user doesn't have permission to change the post type" do + put "/posts/#{post.id}/post_type.json", params: { post_type: 2 } + expect(response).to be_forbidden + end + + it "can change the post type" do + sign_in(Fabricate(:moderator)) + put "/posts/#{post.id}/post_type.json", params: { post_type: 2 } + + post.reload + expect(post.post_type).to eq(2) + end + end + end + + describe '#rebake' do + include_examples "action requires login", :put, "/posts/2/rebake.json" + + describe "when logged in" do + let(:post) { Fabricate(:post, user: user) } + + it "raises an error if the user doesn't have permission to rebake the post" do + sign_in(Fabricate(:user)) + put "/posts/#{post.id}/rebake.json" + expect(response).to be_forbidden + end + + it "can rebake the post" do + sign_in(Fabricate(:moderator)) + put "/posts/#{post.id}/rebake.json" + expect(response).to be_success + end + end end describe '#create' do + include_examples 'action requires login', :post, "/posts.json" + before do - sign_in(user) + SiteSetting.min_first_post_typing_time = 0 end - it 'can not create a post in a disallowed category' do - category.set_permissions(staff: :full) - category.save! + let(:user) { Fabricate(:user) } - post "/posts.json", params: { - raw: 'this is the test content', - title: 'this is the test title for the topic', - category: category.id, - meta_data: { xyz: 'abc' } - } + context 'api' do + it 'memoizes duplicate requests' do + raw = "this is a test post 123 #{SecureRandom.hash}" + title = "this is a title #{SecureRandom.hash}" - expect(response.status).to eq(403) + user = Fabricate(:user) + master_key = ApiKey.create_master_key.key + + post "/posts.json", params: { + api_username: user.username, + api_key: master_key, + raw: raw, + title: title, + wpid: 1 + } + + expect(response).to be_success + original = response.body + + post "/posts.json", params: { + api_username: user.username_lower, + api_key: master_key, + raw: raw, + title: title, + wpid: 2 + } + + expect(response).to be_success + expect(response.body).to eq(original) + end + + it 'allows to create posts in import_mode' do + NotificationEmailer.enable + post_1 = Fabricate(:post) + user = Fabricate(:user) + master_key = ApiKey.create_master_key.key + + post "/posts.json", params: { + api_username: user.username, + api_key: master_key, + raw: 'this is test reply 1', + topic_id: post_1.topic.id, + reply_to_post_number: 1 + } + + expect(response).to be_success + expect(post_1.topic.user.notifications.count).to eq(1) + post_1.topic.user.notifications.destroy_all + + post "/posts.json", params: { + api_username: user.username, + api_key: master_key, + raw: 'this is test reply 2', + topic_id: post_1.topic.id, + reply_to_post_number: 1, + import_mode: true + } + + expect(response).to be_success + expect(post_1.topic.user.notifications.count).to eq(0) + + post "/posts.json", params: { + api_username: user.username, + api_key: master_key, + raw: 'this is test reply 3', + topic_id: post_1.topic.id, + reply_to_post_number: 1, + import_mode: false + } + + expect(response).to be_success + expect(post_1.topic.user.notifications.count).to eq(1) + end end - it 'creates the post' do - post "/posts.json", params: { - raw: 'this is the test content', - title: 'this is the test title for the topic', - category: category.id, - meta_data: { xyz: 'abc' } - } + describe "when logged in" do + before do + sign_in(user) + end - expect(response).to be_success + context "fast typing" do + before do + SiteSetting.min_first_post_typing_time = 3000 + SiteSetting.auto_silence_fast_typers_max_trust_level = 1 + end - new_post = Post.last - topic = new_post.topic + it 'queues the post if min_first_post_typing_time is not met' do + post "/posts.json", params: { + raw: 'this is the test content', + title: 'this is the test title for the topic' + } - expect(new_post.user).to eq(user) - expect(new_post.raw).to eq('this is the test content') - expect(topic.title).to eq('This is the test title for the topic') - expect(topic.category).to eq(category) - expect(topic.meta_data).to eq("xyz" => 'abc') - end + expect(response).to be_success + parsed = ::JSON.parse(response.body) - it 'can create a reply to a post' do - SiteSetting.queue_jobs = true + expect(parsed["action"]).to eq("enqueued") - topic = Fabricate(:private_message_post, user: user).topic - post_2 = Fabricate(:private_message_post, user: user, topic: topic) + user.reload + expect(user).to be_silenced - post "/posts.json", params: { - raw: 'this is the test content', - topic_id: topic.id, - reply_to_post_number: post_2.post_number, - image_sizes: { width: '100', height: '200' } - } + qp = QueuedPost.first - expect(response).to be_success + mod = Fabricate(:moderator) + qp.approve!(mod) - new_post = Post.last - topic = new_post.topic + user.reload + expect(user).not_to be_silenced + end - expect(new_post.user).to eq(user) - expect(new_post.raw).to eq('this is the test content') - expect(new_post.reply_to_post_number).to eq(post_2.post_number) + it "doesn't enqueue replies when the topic is closed" do + topic = Fabricate(:closed_topic) - job_args = Jobs::ProcessPost.jobs.first["args"].first + post "/posts.json", params: { + raw: 'this is the test content', + title: 'this is the test title for the topic', + topic_id: topic.id + } - expect(job_args["image_sizes"]).to eq("width" => '100', "height" => '200') - end + expect(response).not_to be_success + parsed = ::JSON.parse(response.body) + expect(parsed["action"]).not_to eq("enqueued") + end - it 'creates a private post' do - user_2 = Fabricate(:user) - user_3 = Fabricate(:user) + it "doesn't enqueue replies when the post is too long" do + SiteSetting.max_post_length = 10 - post "/posts.json", params: { - raw: 'this is the test content', - archetype: 'private_message', - title: "this is some post", - target_usernames: "#{user_2.username},#{user_3.username}" - } + post "/posts.json", params: { + raw: 'this is the test content', + title: 'this is the test title for the topic' + } - expect(response).to be_success + expect(response).not_to be_success + parsed = ::JSON.parse(response.body) + expect(parsed["action"]).not_to eq("enqueued") + end + end - new_post = Post.last - new_topic = Topic.last + it 'silences correctly based on auto_silence_first_post_regex' do + SiteSetting.auto_silence_first_post_regex = "I love candy|i eat s[1-5]" - expect(new_post.user).to eq(user) - expect(new_topic.private_message?).to eq(true) - expect(new_topic.allowed_users).to contain_exactly(user, user_2, user_3) + post "/posts.json", params: { + raw: 'this is the test content', + title: 'when I eat s3 sometimes when not looking' + } + + expect(response).to be_success + parsed = ::JSON.parse(response.body) + + expect(parsed["action"]).to eq("enqueued") + + user.reload + expect(user).to be_silenced + end + + it "can send a message to a group" do + group = Group.create(name: 'test_group', messageable_level: Group::ALIAS_LEVELS[:nobody]) + user1 = Fabricate(:user) + group.add(user1) + + post "/posts.json", params: { + raw: 'I can haz a test', + title: 'I loves my test', + target_usernames: group.name, + archetype: Archetype.private_message + } + + expect(response).not_to be_success + + # allow pm to this group + group.update_columns(messageable_level: Group::ALIAS_LEVELS[:everyone]) + + post "/posts.json", params: { + raw: 'I can haz a test', + title: 'I loves my test', + target_usernames: group.name, + archetype: Archetype.private_message + } + + expect(response).to be_success + + parsed = ::JSON.parse(response.body) + post = Post.find(parsed['id']) + + expect(post.topic.topic_allowed_users.length).to eq(1) + expect(post.topic.topic_allowed_groups.length).to eq(1) + end + + it "returns the nested post with a param" do + post "/posts.json", params: { + raw: 'this is the test content', + title: 'this is the test title for the topic', + nested_post: true + } + + expect(response).to be_success + parsed = ::JSON.parse(response.body) + expect(parsed['post']).to be_present + expect(parsed['post']['cooked']).to be_present + end + + it 'protects against dupes' do + raw = "this is a test post 123 #{SecureRandom.hash}" + title = "this is a title #{SecureRandom.hash}" + + post "/posts.json", params: { raw: raw, title: title, wpid: 1 } + expect(response).to be_success + + post "/posts.json", params: { raw: raw, title: title, wpid: 2 } + expect(response).not_to be_success + end + + it 'can not create a post in a disallowed category' do + category.set_permissions(staff: :full) + category.save! + + post "/posts.json", params: { + raw: 'this is the test content', + title: 'this is the test title for the topic', + category: category.id, + meta_data: { xyz: 'abc' } + } + + expect(response.status).to eq(403) + end + + it 'creates the post' do + post "/posts.json", params: { + raw: 'this is the test content', + title: 'this is the test title for the topic', + category: category.id, + meta_data: { xyz: 'abc' } + } + + expect(response).to be_success + + new_post = Post.last + topic = new_post.topic + + expect(new_post.user).to eq(user) + expect(new_post.raw).to eq('this is the test content') + expect(topic.title).to eq('This is the test title for the topic') + expect(topic.category).to eq(category) + expect(topic.meta_data).to eq("xyz" => 'abc') + end + + it 'can create a reply to a post' do + SiteSetting.queue_jobs = true + + topic = Fabricate(:private_message_post, user: user).topic + post_2 = Fabricate(:private_message_post, user: user, topic: topic) + + post "/posts.json", params: { + raw: 'this is the test content', + topic_id: topic.id, + reply_to_post_number: post_2.post_number, + image_sizes: { width: '100', height: '200' } + } + + expect(response).to be_success + + new_post = Post.last + topic = new_post.topic + + expect(new_post.user).to eq(user) + expect(new_post.raw).to eq('this is the test content') + expect(new_post.reply_to_post_number).to eq(post_2.post_number) + + job_args = Jobs::ProcessPost.jobs.first["args"].first + + expect(job_args["image_sizes"]).to eq("width" => '100', "height" => '200') + end + + it 'creates a private post' do + user_2 = Fabricate(:user) + user_3 = Fabricate(:user) + + post "/posts.json", params: { + raw: 'this is the test content', + archetype: 'private_message', + title: "this is some post", + target_usernames: "#{user_2.username},#{user_3.username}" + } + + expect(response).to be_success + + new_post = Post.last + new_topic = Topic.last + + expect(new_post.user).to eq(user) + expect(new_topic.private_message?).to eq(true) + expect(new_topic.allowed_users).to contain_exactly(user, user_2, user_3) + end + + context "errors" do + it "does not succeed" do + post "/posts.json", params: { raw: 'test' } + expect(response).not_to be_success + expect(response.status).to eq(422) + end + + it "it triggers flag_linked_posts_as_spam when the post creator returns spam" do + SiteSetting.newuser_spam_host_threshold = 1 + sign_in(Fabricate(:user, trust_level: 0)) + + post "/posts.json", params: { + raw: 'this is the test content http://fakespamwebsite.com http://fakespamwebsite.com/spam http://fakespamwebsite.com/spammy', + title: 'this is the test title for the topic', + meta_data: { xyz: 'abc' } + } + + expect(JSON.parse(response.body)["errors"]).to include(I18n.t(:spamming_host)) + end + end end describe 'shared draft' do @@ -167,9 +899,7 @@ RSpec.describe PostsController do expect(topic.shared_draft.category_id).to eq(destination_category.id) end end - end - end describe 'warnings' do @@ -217,6 +947,7 @@ RSpec.describe PostsController do context 'as a normal user' do it 'should not be able to mark a topic as warning' do + sign_in(user) post "/posts.json", params: { raw: 'this is the test content', archetype: 'private_message', @@ -236,6 +967,334 @@ RSpec.describe PostsController do end end + describe '#revisions' do + let(:post) { Fabricate(:post, version: 2) } + let(:post_revision) { Fabricate(:post_revision, post: post) } + + it "throws an exception when revision is < 2" do + get "/posts/#{post.id}/revisions/1.json" + expect(response.status).to eq(400) + end + + context "when edit history is not visible to the public" do + + before { SiteSetting.edit_history_visible_to_public = false } + + it "ensures anonymous cannot see the revisions" do + get "/posts/#{post.id}/revisions/#{post_revision.number}.json" + expect(response).to be_forbidden + end + + it "ensures regular user cannot see the revisions" do + sign_in(Fabricate(:user)) + get "/posts/#{post.id}/revisions/#{post_revision.number}.json" + expect(response).to be_forbidden + end + + it "ensures staff can see the revisions" do + sign_in(Fabricate(:admin)) + get "/posts/#{post.id}/revisions/#{post_revision.number}.json" + expect(response).to be_success + end + + it "ensures poster can see the revisions" do + user = Fabricate(:active_user) + sign_in(user) + + post = Fabricate(:post, user: user, version: 3) + pr = Fabricate(:post_revision, user: user, post: post) + + get "/posts/#{pr.post_id}/revisions/#{pr.number}.json" + expect(response).to be_success + end + + it "ensures trust level 4 can see the revisions" do + sign_in(Fabricate(:user, trust_level: 4)) + get "/posts/#{post_revision.post_id}/revisions/#{post_revision.number}.json" + expect(response).to be_success + end + end + + context "when edit history is visible to everyone" do + + before { SiteSetting.edit_history_visible_to_public = true } + + it "ensures anyone can see the revisions" do + get "/posts/#{post_revision.post_id}/revisions/#{post_revision.number}.json" + expect(response).to be_success + end + end + + context "deleted post" do + let(:admin) { Fabricate(:admin) } + let(:deleted_post) { Fabricate(:post, user: admin, version: 3) } + let(:deleted_post_revision) { Fabricate(:post_revision, user: admin, post: deleted_post) } + + before { deleted_post.trash!(admin) } + + it "also work on deleted post" do + sign_in(admin) + get "/posts/#{deleted_post_revision.post_id}/revisions/#{deleted_post_revision.number}.json" + expect(response).to be_success + end + end + + context "deleted topic" do + let(:admin) { Fabricate(:admin) } + let(:deleted_topic) { Fabricate(:topic, user: admin) } + let(:post) { Fabricate(:post, user: admin, topic: deleted_topic, version: 3) } + let(:post_revision) { Fabricate(:post_revision, user: admin, post: post) } + + before { deleted_topic.trash!(admin) } + + it "also work on deleted topic" do + sign_in(admin) + get "/posts/#{post_revision.post_id}/revisions/#{post_revision.number}.json" + expect(response).to be_success + end + end + end + + describe '#revert' do + include_examples 'action requires login', :put, "/posts/123/revisions/2/revert.json" + + let(:post) { Fabricate(:post, user: Fabricate(:user), raw: "Lorem ipsum dolor sit amet, cu nam libris tractatos, ancillae senserit ius ex") } + let(:post_revision) { Fabricate(:post_revision, post: post, modifications: { "raw" => ["this is original post body.", "this is edited post body."] }) } + let(:blank_post_revision) { Fabricate(:post_revision, post: post, modifications: { "edit_reason" => ["edit reason #1", "edit reason #2"] }) } + let(:same_post_revision) { Fabricate(:post_revision, post: post, modifications: { "raw" => ["Lorem ipsum dolor sit amet, cu nam libris tractatos, ancillae senserit ius ex", "this is edited post body."] }) } + + let(:post_id) { post.id } + let(:revision_id) { post_revision.number } + let(:moderator) { Fabricate(:moderator) } + + describe 'when logged in as a regular user' do + it "does not work" do + sign_in(Fabricate(:user)) + put "/posts/#{post_id}/revisions/#{revision_id}/revert.json" + expect(response).to_not be_success + end + end + + describe "when logged in as staff" do + before do + sign_in(moderator) + end + + it "fails when revision is < 2" do + put "/posts/#{post_id}/revisions/1/revert.json" + expect(response.status).to eq(400) + end + + it "fails when post_revision record is not found" do + put "/posts/#{post_id}/revisions/#{revision_id + 1}/revert.json" + expect(response).to_not be_success + end + + it "fails when post record is not found" do + put "/posts/#{post_id + 1}/revisions/#{revision_id}/revert.json" + expect(response).to_not be_success + end + + it "fails when revision is blank" do + put "/posts/#{post_id}/revisions/#{blank_post_revision.number}/revert.json" + expect(response.status).to eq(422) + expect(JSON.parse(response.body)['errors']).to include(I18n.t('revert_version_same')) + end + + it "fails when revised version is same as current version" do + put "/posts/#{post_id}/revisions/#{same_post_revision.number}/revert.json" + expect(response.status).to eq(422) + expect(JSON.parse(response.body)['errors']).to include(I18n.t('revert_version_same')) + end + + it "works!" do + put "/posts/#{post_id}/revisions/#{revision_id}/revert.json" + expect(response).to be_success + end + + it "supports reverting posts in deleted topics" do + first_post = post.topic.ordered_posts.first + PostDestroyer.new(moderator, first_post).destroy + + put "/posts/#{post_id}/revisions/#{revision_id}/revert.json" + expect(response).to be_success + end + end + end + + describe '#expand_embed' do + before do + sign_in(Fabricate(:user)) + end + + let(:post) { Fabricate(:post) } + + it "raises an error when you can't see the post" do + post = Fabricate(:private_message_post) + get "/posts/#{post.id}/expand-embed.json" + expect(response).not_to be_success + end + + it "retrieves the body when you can see the post" do + TopicEmbed.expects(:expanded_for).with(post).returns("full content") + get "/posts/#{post.id}/expand-embed.json" + expect(response).to be_success + expect(::JSON.parse(response.body)['cooked']).to eq("full content") + end + end + + describe '#flagged_posts' do + include_examples "action requires login", :get, "/posts/system/flagged.json" + + describe "when logged in" do + it "raises an error if the user doesn't have permission to see the flagged posts" do + sign_in(Fabricate(:user)) + get "/posts/system/flagged.json" + expect(response).to be_forbidden + end + + it "can see the flagged posts when authorized" do + sign_in(Fabricate(:moderator)) + get "/posts/system/flagged.json" + expect(response).to be_success + end + + it "only shows agreed and deferred flags" do + user = Fabricate(:user) + post_agreed = create_post(user: user) + post_deferred = create_post(user: user) + post_disagreed = create_post(user: user) + + moderator = Fabricate(:moderator) + PostAction.act(moderator, post_agreed, PostActionType.types[:spam]) + PostAction.act(moderator, post_deferred, PostActionType.types[:off_topic]) + PostAction.act(moderator, post_disagreed, PostActionType.types[:inappropriate]) + + admin = Fabricate(:admin) + PostAction.agree_flags!(post_agreed, admin) + PostAction.defer_flags!(post_deferred, admin) + PostAction.clear_flags!(post_disagreed, admin) + + sign_in(Fabricate(:moderator)) + get "/posts/#{user.username}/flagged.json" + expect(response).to be_success + + expect(JSON.parse(response.body).length).to eq(2) + end + end + end + + describe '#deleted_posts' do + include_examples "action requires login", :get, "/posts/system/deleted.json" + + describe "when logged in" do + it "raises an error if the user doesn't have permission to see the deleted posts" do + sign_in(Fabricate(:user)) + get "/posts/system/deleted.json" + expect(response).to be_forbidden + end + + it "can see the deleted posts when authorized" do + sign_in(Fabricate(:moderator)) + get "/posts/system/deleted.json" + expect(response).to be_success + end + + it "doesn't return secured categories for moderators if they don't have access" do + user = Fabricate(:user) + admin = Fabricate(:admin) + Fabricate(:moderator) + + group = Fabricate(:group) + group.add_owner(user) + + secured_category = Fabricate(:private_category, group: group) + secured_post = create_post(user: user, category: secured_category) + PostDestroyer.new(admin, secured_post).destroy + + sign_in(Fabricate(:moderator)) + get "/posts/#{user.username}/deleted.json" + expect(response).to be_success + + data = JSON.parse(response.body) + expect(data.length).to eq(0) + end + + it "doesn't return PMs for moderators" do + user = Fabricate(:user) + admin = Fabricate(:admin) + Fabricate(:moderator) + + pm_post = create_post(user: user, archetype: 'private_message', target_usernames: [admin.username]) + PostDestroyer.new(admin, pm_post).destroy + + sign_in(Fabricate(:moderator)) + get "/posts/#{user.username}/deleted.json" + expect(response).to be_success + + data = JSON.parse(response.body) + expect(data.length).to eq(0) + end + + it "only shows posts deleted by other users" do + user = Fabricate(:user) + admin = Fabricate(:admin) + + create_post(user: user) + post_deleted_by_user = create_post(user: user) + post_deleted_by_admin = create_post(user: user) + + PostDestroyer.new(user, post_deleted_by_user).destroy + PostDestroyer.new(admin, post_deleted_by_admin).destroy + + sign_in(Fabricate(:admin)) + get "/posts/#{user.username}/deleted.json" + expect(response).to be_success + + data = JSON.parse(response.body) + expect(data.length).to eq(1) + expect(data[0]["id"]).to eq(post_deleted_by_admin.id) + expect(data[0]["deleted_by"]["id"]).to eq(admin.id) + end + end + end + + describe '#markdown_id' do + it "can be viewed by anonymous" do + post = Fabricate(:post, raw: "123456789") + get "/posts/#{post.id}/raw.json" + expect(response).to be_success + expect(response.body).to eq("123456789") + end + end + + describe '#markdown_num' do + it "can be viewed by anonymous" do + topic = Fabricate(:topic) + post = Fabricate(:post, topic: topic, post_number: 1, raw: "123456789") + post.save + get "/raw/#{topic.id}/1.json" + expect(response).to be_success + expect(response.body).to eq("123456789") + end + end + + describe '#short_link' do + let(:topic) { Fabricate(:topic) } + let(:post) { Fabricate(:post, topic: topic) } + + it "redirects to the topic" do + get "/p/#{post.id}.json" + expect(response).to be_redirect + end + + it "returns a 403 when access is denied" do + post = Fabricate(:private_message_post) + get "/p/#{post.id}.json" + expect(response).to be_forbidden + end + end + describe '#user_posts_feed' do it 'returns public posts rss feed' do public_post @@ -268,6 +1327,21 @@ RSpec.describe PostsController do expect(body).to include(private_post.url) expect(body).to_not include(public_post.url) end + + it 'returns private posts for json' do + sign_in(Fabricate(:admin)) + + public_post + private_post + get "/private-posts.json" + expect(response).to be_success + + json = ::JSON.parse(response.body) + post_ids = json['private_posts'].map { |p| p['id'] } + + expect(post_ids).to include private_post.id + expect(post_ids).to_not include public_post.id + end end context 'public posts' do @@ -284,6 +1358,62 @@ RSpec.describe PostsController do expect(body).to include(public_post.url) expect(body).to_not include(private_post.url) end + + it 'returns public posts with topic for json' do + topicless_post.update topic_id: -100 + + public_post + private_post + topicless_post + + get "/posts.json" + expect(response).to be_success + + json = ::JSON.parse(response.body) + post_ids = json['latest_posts'].map { |p| p['id'] } + + expect(post_ids).to include public_post.id + expect(post_ids).to_not include private_post.id + expect(post_ids).to_not include topicless_post.id + end + end + end + + describe '#cooked' do + it 'returns the cooked conent' do + post = Fabricate(:post, cooked: "WAt") + get "/posts/#{post.id}/cooked.json" + + expect(response).to be_success + json = ::JSON.parse(response.body) + + expect(json).to be_present + expect(json['cooked']).to eq('WAt') + end + end + + describe '#raw_email' do + include_examples "action requires login", :get, "/posts/2/raw-email.json" + + describe "when logged in" do + let(:post) { Fabricate(:post, deleted_at: 2.hours.ago, user: Fabricate(:user), raw_email: 'email_content') } + + it "raises an error if the user doesn't have permission to view raw email" do + sign_in(Fabricate(:user)) + + get "/posts/#{post.id}/raw-email.json" + expect(response).to be_forbidden + end + + it "can view raw email" do + sign_in(Fabricate(:moderator)) + + get "/posts/#{post.id}/raw-email.json" + expect(response).to be_success + + json = ::JSON.parse(response.body) + expect(json['raw_email']).to eq('email_content') + end end end From 3edca8b1041d5e2ede118a65181b121d6b26ff1d Mon Sep 17 00:00:00 2001 From: Blake Erickson Date: Tue, 22 May 2018 16:17:44 -0600 Subject: [PATCH 106/278] Return a 403 instead of 200 when trying to delete a user with posts See [this commit][1] for more info [1]: https://github.com/discourse/discourse/commit/bd352a17bff9019db21196d70cd9829e678a33be --- app/controllers/admin/users_controller.rb | 2 +- spec/controllers/admin/users_controller_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index b9a24b38fd..c43a3e8f05 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -382,7 +382,7 @@ class Admin::UsersController < Admin::AdminController render json: { deleted: false, message: "User #{user.username} has #{user.post_count} posts, so they can't be deleted." - } + }, status: 403 end end end diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 9fb6329fc7..d026d7bfb5 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -531,7 +531,7 @@ describe Admin::UsersController do it "returns an api response that the user can't be deleted because it has posts" do delete :destroy, params: { id: delete_me.id }, format: :json - expect(response).to be_success + expect(response).to be_forbidden json = ::JSON.parse(response.body) expect(json['deleted']).to eq(false) end From f6d412465b425b2098c278be4040540a9b3e653d Mon Sep 17 00:00:00 2001 From: OsamaSayegh Date: Wed, 23 May 2018 02:26:07 +0300 Subject: [PATCH 107/278] FIX: apply automatic group rules when using social login providers --- app/services/user_authenticator.rb | 14 ++++- lib/auth/google_oauth2_authenticator.rb | 4 -- .../auth/google_oauth2_authenticator_spec.rb | 31 ---------- spec/services/user_authenticator_spec.rb | 60 +++++++++++++++++++ 4 files changed, 72 insertions(+), 37 deletions(-) create mode 100644 spec/services/user_authenticator_spec.rb diff --git a/app/services/user_authenticator.rb b/app/services/user_authenticator.rb index cd75e93122..b53c5de8b9 100644 --- a/app/services/user_authenticator.rb +++ b/app/services/user_authenticator.rb @@ -21,7 +21,10 @@ class UserAuthenticator end def finish - authenticator.after_create_account(@user, @session) if authenticator + if authenticator + authenticator.after_create_account(@user, @session) + confirm_email + end @session = nil end @@ -30,11 +33,18 @@ class UserAuthenticator end def authenticated? - @session && @session[:email] == @user.email && @session[:email_valid] + @session && @session[:email]&.downcase == @user.email.downcase && @session[:email_valid].to_s == "true" end private + def confirm_email + if authenticated? + EmailToken.confirm(@user.email_tokens.first.token) + @user.set_automatic_groups + end + end + def authenticator if authenticator_name @authenticator ||= @authenticator_finder.find_authenticator(authenticator_name) diff --git a/lib/auth/google_oauth2_authenticator.rb b/lib/auth/google_oauth2_authenticator.rb index a280408193..e477c55edc 100644 --- a/lib/auth/google_oauth2_authenticator.rb +++ b/lib/auth/google_oauth2_authenticator.rb @@ -44,10 +44,6 @@ class Auth::GoogleOAuth2Authenticator < Auth::Authenticator def after_create_account(user, auth) data = auth[:extra_data] GoogleUserInfo.create({ user_id: user.id }.merge(data)) - if auth[:email_valid].to_s == 'true' && data[:email]&.downcase == user.email - EmailToken.confirm(user.email_tokens.first.token) - user.set_automatic_groups - end end def register_middleware(omniauth) diff --git a/spec/components/auth/google_oauth2_authenticator_spec.rb b/spec/components/auth/google_oauth2_authenticator_spec.rb index e3af7278ba..525d65f9aa 100644 --- a/spec/components/auth/google_oauth2_authenticator_spec.rb +++ b/spec/components/auth/google_oauth2_authenticator_spec.rb @@ -81,35 +81,4 @@ describe Auth::GoogleOAuth2Authenticator do expect(result.extra_data[:name]).to eq("Jane Doe") end end - - context 'after_create_account' do - it 'confirms email' do - authenticator = Auth::GoogleOAuth2Authenticator.new - user = Fabricate(:user, email: 'realgoogleuser@gmail.com') - session = { - email_valid: "true", - extra_data: { - google_user_id: 1, - email: 'realgoogleuser@gmail.com' - } - } - authenticator.after_create_account(user, session) - expect(user.email_confirmed?).to eq(true) - end - - it "doesn't confirm email if it was changed" do - authenticator = Auth::GoogleOAuth2Authenticator.new - user = Fabricate(:user, email: 'changed@gmail.com') - session = { - email_valid: "true", - extra_data: { - google_user_id: 1, - email: 'realgoogleuser@gmail.com' - } - } - authenticator.after_create_account(user, session) - expect(user.email_confirmed?).to eq(false) - end - end - end diff --git a/spec/services/user_authenticator_spec.rb b/spec/services/user_authenticator_spec.rb new file mode 100644 index 0000000000..3590711a32 --- /dev/null +++ b/spec/services/user_authenticator_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' +require_dependency 'user_authenticator' + +def github_auth(email_valid) + { + email: "user53@discourse.org", + username: "joedoe546", + email_valid: email_valid, + omit_username: nil, + name: "Joe Doe 546", + authenticator_name: "github", + extra_data: { + github_user_id: "100", + github_screen_name: "joedoe546" + }, + skip_email_validation: false + } +end + +describe UserAuthenticator do + context "#finish" do + let(:authenticator) { Auth::GithubAuthenticator.new } + let(:group) { Fabricate(:group, automatic_membership_email_domains: "discourse.org") } + + before do + SiteSetting.enable_github_logins = true + end + + it "confirms email and adds the user to appropraite groups based on email" do + user = Fabricate(:user, email: "user53@discourse.org") + expect(group.usernames).not_to include(user.username) + + authentication = github_auth(true) + + UserAuthenticator.new(user, authentication: authentication).finish + expect(user.email_confirmed?).to be_truthy + expect(group.usernames).to include(user.username) + end + + it "doesn't confirm email if email is invalid" do + user = Fabricate(:user, email: "user53@discourse.org") + + authentication = github_auth(false) + + UserAuthenticator.new(user, authentication: authentication).finish + expect(user.email_confirmed?).to be_falsey + expect(group.usernames).not_to include(user.username) + end + + it "doesn't confirm email if it was changed" do + user = Fabricate(:user, email: "changed@discourse.org") + + authentication = github_auth(true) + + UserAuthenticator.new(user, authentication: authentication).finish + expect(user.email_confirmed?).to be_falsey + expect(group.usernames).not_to include(user.username) + end + end +end From 41d37fd4981426307eec41252c9bee183c86b662 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 May 2018 10:16:23 +1000 Subject: [PATCH 108/278] improve erratic test --- .../acceptance/composer-actions-test.js.es6 | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/test/javascripts/acceptance/composer-actions-test.js.es6 b/test/javascripts/acceptance/composer-actions-test.js.es6 index 82b873ddbe..711be5b294 100644 --- a/test/javascripts/acceptance/composer-actions-test.js.es6 +++ b/test/javascripts/acceptance/composer-actions-test.js.es6 @@ -23,18 +23,17 @@ QUnit.test('replying to post', async assert => { }); -QUnit.test('replying to post - reply_as_private_message', assert => { +QUnit.test('replying to post - reply_as_private_message', async assert => { const composerActions = selectKit('.composer-actions'); - visit('/t/internationalization-localization/280'); - click('article#post_3 button.reply'); + await visit('/t/internationalization-localization/280'); + await click('article#post_3 button.reply'); - composerActions.expand().selectRowByValue('reply_as_private_message'); + await composerActions.expandAwait(); + await composerActions.selectRowByValueAwait('reply_as_private_message'); - andThen(() => { - assert.equal(find('.users-input .item:eq(0)').text(), 'codinghorror'); - assert.ok(find('.d-editor-input').val().indexOf('Continuing the discussion') >= 0); - }); + assert.equal(find('.users-input .item:eq(0)').text(), 'codinghorror'); + assert.ok(find('.d-editor-input').val().indexOf('Continuing the discussion') >= 0); }); QUnit.test('replying to post - reply_to_topic', async assert => { From ff15d95983e3555208cd6deed0d61b686bb3b9e4 Mon Sep 17 00:00:00 2001 From: Andrew Schleifer Date: Tue, 22 May 2018 16:30:32 -0500 Subject: [PATCH 109/278] FIX s3_helper.list for buckets with folders s3_bucket_folder_path does not contain a trailing slash so it was smashingstufftogether --- lib/s3_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/s3_helper.rb b/lib/s3_helper.rb index 9dadd71de3..cd9c839e80 100644 --- a/lib/s3_helper.rb +++ b/lib/s3_helper.rb @@ -131,7 +131,7 @@ class S3Helper end def list(prefix = "") - s3_bucket.objects(prefix: @s3_bucket_folder_path.to_s + prefix) + s3_bucket.objects(prefix: "#{@s3_bucket_folder_path}/#{prefix}") end def tag_file(key, tags) From 6974b7d6a879c415ed9898685c8e50375b6ce331 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 May 2018 12:05:17 +1000 Subject: [PATCH 110/278] FIX: run deferred jobs inline in sidekiq --- config/initializers/100-sidekiq.rb | 3 +++ lib/scheduler/defer.rb | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/config/initializers/100-sidekiq.rb b/config/initializers/100-sidekiq.rb index a45ec085f7..f361c9fdee 100644 --- a/config/initializers/100-sidekiq.rb +++ b/config/initializers/100-sidekiq.rb @@ -13,6 +13,9 @@ Sidekiq.configure_server do |config| end if Sidekiq.server? + # defer queue should simply run in sidekiq + Scheduler::Defer.async = false + # warm up AR RailsMultisite::ConnectionManagement.each_connection do (ActiveRecord::Base.connection.tables - %w[schema_migrations]).each do |table| diff --git a/lib/scheduler/defer.rb b/lib/scheduler/defer.rb index 37116f58ed..f8f37090bb 100644 --- a/lib/scheduler/defer.rb +++ b/lib/scheduler/defer.rb @@ -21,7 +21,7 @@ module Scheduler @paused = false end - # for test + # for test and sidekiq def async=(val) @async = val end From 930ebb5684d8500a14fd728c1dc99a0f4ef9c9ed Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 23 May 2018 13:26:42 +1000 Subject: [PATCH 111/278] skip failing test --- test/javascripts/acceptance/composer-actions-test.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/javascripts/acceptance/composer-actions-test.js.es6 b/test/javascripts/acceptance/composer-actions-test.js.es6 index 711be5b294..305b10394c 100644 --- a/test/javascripts/acceptance/composer-actions-test.js.es6 +++ b/test/javascripts/acceptance/composer-actions-test.js.es6 @@ -36,7 +36,7 @@ QUnit.test('replying to post - reply_as_private_message', async assert => { assert.ok(find('.d-editor-input').val().indexOf('Continuing the discussion') >= 0); }); -QUnit.test('replying to post - reply_to_topic', async assert => { +QUnit.skip('replying to post - reply_to_topic', async assert => { const composerActions = selectKit('.composer-actions'); visit('/t/internationalization-localization/280'); From fac4bf2f8527fe60bf4de050e39a078d7bcac4af Mon Sep 17 00:00:00 2001 From: Ryan Mulligan Date: Wed, 23 May 2018 01:04:45 -0700 Subject: [PATCH 112/278] ignore emails that are from the reply by email addresses (#5843) --- config/locales/server.en.yml | 1 + lib/email/processor.rb | 1 + lib/email/receiver.rb | 16 +++++++++++++--- spec/components/email/processor_spec.rb | 14 ++++++++++++++ spec/components/email/receiver_spec.rb | 14 +++++++++----- .../emails/from_reply_by_email_address.eml | 10 ++++++++++ 6 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 spec/fixtures/emails/from_reply_by_email_address.eml diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 236c11d036..43d12c2588 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -89,6 +89,7 @@ en: auto_generated_email_error: "Happens when the 'precedence' header is set to: list, junk, bulk or auto_reply, or when any other header contains: auto-submitted, auto-replied or auto-generated." no_body_detected_error: "Happens when we couldn't extract a body and there were no attachments." no_sender_detected_error: "Happens when we couldn't find a valid email address in the From header." + from_reply_by_address_error: "Happens when the From header matches the reply by email address." inactive_user_error: "Happens when the sender is not active." silenced_user_error: "Happens when the sender has been silenced." bad_destination_address: "Happens when none of the email addresses in To/Cc/Bcc fields matched a configured incoming email address." diff --git a/lib/email/processor.rb b/lib/email/processor.rb index b996dae629..6bac32fe3a 100644 --- a/lib/email/processor.rb +++ b/lib/email/processor.rb @@ -36,6 +36,7 @@ module Email def handle_failure(mail_string, e) message_template = case e when Email::Receiver::NoSenderDetectedError then return nil + when Email::Receiver::FromReplyByAddressError then return nil when Email::Receiver::EmptyEmailError then :email_reject_empty when Email::Receiver::NoBodyDetectedError then :email_reject_empty when Email::Receiver::UserNotFoundError then :email_reject_user_not_found diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb index 06c9cb6761..09d6ca52fb 100644 --- a/lib/email/receiver.rb +++ b/lib/email/receiver.rb @@ -21,6 +21,7 @@ module Email class BouncedEmailError < ProcessingError; end class NoBodyDetectedError < ProcessingError; end class NoSenderDetectedError < ProcessingError; end + class FromReplyByAddressError < ProcessingError; end class InactiveUserError < ProcessingError; end class SilencedUserError < ProcessingError; end class BadDestinationAddress < ProcessingError; end @@ -107,6 +108,7 @@ module Email def process_internal raise BouncedEmailError if is_bounce? raise NoSenderDetectedError if @from_email.blank? + raise FromReplyByAddressError if is_from_reply_by_email_address? raise ScreenedEmailError if ScreenedEmail.should_block?(@from_email) user = find_user(@from_email) @@ -203,6 +205,10 @@ module Email true end + def is_from_reply_by_email_address? + Email::Receiver.reply_by_email_address_regex.match(@from_email) + end + def verp @verp ||= all_destinations.select { |to| to[/\+verp-\h{32}@/] }.first end @@ -723,9 +729,13 @@ module Email reply_addresses.flatten! reply_addresses.select!(&:present?) reply_addresses.map! { |a| Regexp.escape(a) } - reply_addresses.map! { |a| a.gsub(Regexp.escape("%{reply_key}"), "(\\h{32})") } - - /#{reply_addresses.join("|")}/ + reply_addresses.map! { |a| a.gsub("\+", "\+?") } + reply_addresses.map! { |a| a.gsub(Regexp.escape("%{reply_key}"), "(\\h{32})?") } + if reply_addresses.empty? + /$a/ # a regex that can never match + else + /#{reply_addresses.join("|")}/ + end end def group_incoming_emails_regex diff --git a/spec/components/email/processor_spec.rb b/spec/components/email/processor_spec.rb index d7c5113f9b..727ea23bb5 100644 --- a/spec/components/email/processor_spec.rb +++ b/spec/components/email/processor_spec.rb @@ -87,6 +87,20 @@ describe Email::Processor do end + context "from reply to email address" do + + let(:mail) { "From: reply@bar.com\nTo: reply@bar.com\nSubject: FOO BAR\n\nFoo foo bar bar?" } + + it "ignores the email" do + Email::Receiver.any_instance.stubs(:process_internal).raises(Email::Receiver::FromReplyByAddressError.new) + + expect { + Email::Processor.process!(mail) + }.to change { EmailLog.count }.by(0) + end + + end + context "mailinglist mirror" do before do SiteSetting.email_in = true diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb index 9ded4025a7..d0d2048edc 100644 --- a/spec/components/email/receiver_spec.rb +++ b/spec/components/email/receiver_spec.rb @@ -165,6 +165,10 @@ describe Email::Receiver do expect { process(:reply_user_not_matching) }.to raise_error(Email::Receiver::ReplyUserNotMatchingError) end + it "raises a FromReplyByAddressError when the email is from the reply by email address" do + expect { process(:from_reply_by_email_address) }.to raise_error(Email::Receiver::FromReplyByAddressError) + end + it "raises a TopicNotFoundError when the topic was deleted" do topic.update_columns(deleted_at: 1.day.ago) expect { process(:reply_user_matching) }.to raise_error(Email::Receiver::TopicNotFoundError) @@ -679,24 +683,24 @@ describe Email::Receiver do SiteSetting.alternative_reply_by_email_addresses = nil end - it "is empty by default" do - expect(Email::Receiver.reply_by_email_address_regex).to eq(//) + it "it maches nothing if there is not reply_by_email_address" do + expect(Email::Receiver.reply_by_email_address_regex).to eq(/$a/) end it "uses 'reply_by_email_address' site setting" do SiteSetting.reply_by_email_address = "foo+%{reply_key}@bar.com" - expect(Email::Receiver.reply_by_email_address_regex).to eq(/foo\+(\h{32})@bar\.com/) + expect(Email::Receiver.reply_by_email_address_regex).to eq(/foo\+?(\h{32})?@bar\.com/) end it "uses 'alternative_reply_by_email_addresses' site setting" do SiteSetting.alternative_reply_by_email_addresses = "alt.foo+%{reply_key}@bar.com" - expect(Email::Receiver.reply_by_email_address_regex).to eq(/alt\.foo\+(\h{32})@bar\.com/) + expect(Email::Receiver.reply_by_email_address_regex).to eq(/alt\.foo\+?(\h{32})?@bar\.com/) end it "combines both 'reply_by_email' settings" do SiteSetting.reply_by_email_address = "foo+%{reply_key}@bar.com" SiteSetting.alternative_reply_by_email_addresses = "alt.foo+%{reply_key}@bar.com" - expect(Email::Receiver.reply_by_email_address_regex).to eq(/foo\+(\h{32})@bar\.com|alt\.foo\+(\h{32})@bar\.com/) + expect(Email::Receiver.reply_by_email_address_regex).to eq(/foo\+?(\h{32})?@bar\.com|alt\.foo\+?(\h{32})?@bar\.com/) end end diff --git a/spec/fixtures/emails/from_reply_by_email_address.eml b/spec/fixtures/emails/from_reply_by_email_address.eml new file mode 100644 index 0000000000..4dfbd9f7d4 --- /dev/null +++ b/spec/fixtures/emails/from_reply_by_email_address.eml @@ -0,0 +1,10 @@ +Return-Path: +From: Reply Bar +To: reply+4f97315cc828096c9cb34c6f1a0d6fe8@bar.com +Date: Fri, 15 Jan 2016 00:12:43 +0100 +Message-ID: <10@foo.bar.mail> +Mime-Version: 1.0 +Content-Type: text/plain +Content-Transfer-Encoding: 7bit + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. From dd76ba90b394f582ed652a7ef571d471d3b0dc4a Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 23 May 2018 11:55:02 +0200 Subject: [PATCH 113/278] FIX: bbcode regex was greedy and preventing quotes --- .../engines/discourse-markdown/bbcode-block.js.es6 | 2 +- test/javascripts/lib/bbcode-test.js.es6 | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 test/javascripts/lib/bbcode-test.js.es6 diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-block.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-block.js.es6 index b35d716189..b7348d0262 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-block.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-block.js.es6 @@ -11,7 +11,7 @@ function trailingSpaceOnly(src, start, max) { return true; } -const ATTR_REGEX = /^\s*=(.+)$|((([a-z0-9]*)\s*)=)(["“”'].*["“”']|\S+)/ig; +const ATTR_REGEX = /^\s*=(.+)$|((([a-z0-9]*)\s*)=)(["“”'].*?["“”']|\S+)/ig; // parse a tag [test a=1 b=2] to a data structure // {tag: "test", attrs={a: "1", b: "2"} diff --git a/test/javascripts/lib/bbcode-test.js.es6 b/test/javascripts/lib/bbcode-test.js.es6 new file mode 100644 index 0000000000..3f30687774 --- /dev/null +++ b/test/javascripts/lib/bbcode-test.js.es6 @@ -0,0 +1,11 @@ +import { parseBBCodeTag } from 'pretty-text/engines/discourse-markdown/bbcode-block'; + +QUnit.module("lib:pretty-text:bbcode"); + +QUnit.test("block with multiple quoted attributes", assert => { + const parsed = parseBBCodeTag('[test one="foo" two="bar bar"]', 0, 30); + + assert.equal(parsed.tag, "test"); + assert.equal(parsed.attrs.one, "foo"); + assert.equal(parsed.attrs.two, "bar bar"); +}); From 391ee4dfbbfc2089350a817e35774c08f09d18fa Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 23 May 2018 13:44:39 +0200 Subject: [PATCH 114/278] FIX: makes sure email-preview is set --- plugins/discourse-local-dates/plugin.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/discourse-local-dates/plugin.rb b/plugins/discourse-local-dates/plugin.rb index 8aa5b05baa..e63ac26c9f 100644 --- a/plugins/discourse-local-dates/plugin.rb +++ b/plugins/discourse-local-dates/plugin.rb @@ -15,7 +15,7 @@ after_initialize do on(:reduce_cooked) do |fragment| container = fragment.css(".discourse-local-date").first - if container + if container && container.attributes["data-email-preview"] preview = container.attributes["data-email-preview"].value container.content = preview end From 1c61117cbce26a7b810b67aa513fd285e7e1c2ec Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Wed, 23 May 2018 17:44:01 +0530 Subject: [PATCH 115/278] bump onebox version --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index da3752b1b8..c6fbb1299f 100644 --- a/Gemfile +++ b/Gemfile @@ -34,7 +34,7 @@ gem 'redis-namespace' gem 'active_model_serializers', '~> 0.8.3' -gem 'onebox', '1.8.47' +gem 'onebox', '1.8.48' gem 'http_accept_language', '~>2.0.5', require: false diff --git a/Gemfile.lock b/Gemfile.lock index a9c7d695bc..b08d9d356f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -225,7 +225,7 @@ GEM omniauth-twitter (1.3.0) omniauth-oauth (~> 1.1) rack - onebox (1.8.47) + onebox (1.8.48) htmlentities (~> 4.3) moneta (~> 1.0) multi_json (~> 1.11) @@ -461,7 +461,7 @@ DEPENDENCIES omniauth-oauth2 omniauth-openid omniauth-twitter - onebox (= 1.8.47) + onebox (= 1.8.48) openid-redis-store pg (~> 0.21.0) pry-nav From 9df6b2c00bb2e6d7e445e6d5519ee82c210b26f1 Mon Sep 17 00:00:00 2001 From: Joe <33972521+hnb-ku@users.noreply.github.com> Date: Wed, 23 May 2018 21:34:16 +0800 Subject: [PATCH 116/278] FIX: clarify badge image field help text --- 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 7ec24ab64f..75606667ae 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3824,7 +3824,7 @@ en: icon: Icon image: Image icon_help: "Use a Font Awesome class" - image_help: "Enter the URL of the image" + image_help: "Enter the URL of the image (overrides icon field if both are set)" query: Badge Query (SQL) target_posts: Query targets posts auto_revoke: Run revocation query daily From d2efd338e6620964d2d7224ffc47f23e817c670a Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 23 May 2018 15:52:15 +0200 Subject: [PATCH 117/278] rewrites all composer actions tests using await --- .../acceptance/composer-actions-test.js.es6 | 98 +++++++++---------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/test/javascripts/acceptance/composer-actions-test.js.es6 b/test/javascripts/acceptance/composer-actions-test.js.es6 index 305b10394c..291c1cb599 100644 --- a/test/javascripts/acceptance/composer-actions-test.js.es6 +++ b/test/javascripts/acceptance/composer-actions-test.js.es6 @@ -10,9 +10,8 @@ acceptance('Composer Actions', { QUnit.test('replying to post', async assert => { const composerActions = selectKit('.composer-actions'); - visit('/t/internationalization-localization/280'); - click('article#post_3 button.reply'); - + await visit('/t/internationalization-localization/280'); + await click('article#post_3 button.reply'); await composerActions.expandAwait(); assert.equal(composerActions.rowByIndex(0).value(), 'reply_as_new_topic'); @@ -20,7 +19,6 @@ QUnit.test('replying to post', async assert => { assert.equal(composerActions.rowByIndex(2).value(), 'reply_to_topic'); assert.equal(composerActions.rowByIndex(3).value(), 'toggle_whisper'); assert.equal(composerActions.rowByIndex(4).value(), undefined); - }); QUnit.test('replying to post - reply_as_private_message', async assert => { @@ -36,12 +34,12 @@ QUnit.test('replying to post - reply_as_private_message', async assert => { assert.ok(find('.d-editor-input').val().indexOf('Continuing the discussion') >= 0); }); -QUnit.skip('replying to post - reply_to_topic', async assert => { +QUnit.test('replying to post - reply_to_topic', async assert => { const composerActions = selectKit('.composer-actions'); - visit('/t/internationalization-localization/280'); - click('article#post_3 button.reply'); - fillIn('.d-editor-input', 'test replying to topic when initially replied to post'); + await visit('/t/internationalization-localization/280'); + await click('article#post_3 button.reply'); + await fillIn('.d-editor-input', 'test replying to topic when initially replied to post'); await composerActions.expandAwait(); await composerActions.selectRowByValueAwait('reply_to_topic'); @@ -51,69 +49,68 @@ QUnit.skip('replying to post - reply_to_topic', async assert => { assert.equal(find('.d-editor-input').val(), 'test replying to topic when initially replied to post'); }); -QUnit.test('replying to post - toggle_whisper', assert => { +QUnit.test('replying to post - toggle_whisper', async assert => { const composerActions = selectKit('.composer-actions'); - visit('/t/internationalization-localization/280'); - click('article#post_3 button.reply'); - fillIn('.d-editor-input', 'test replying as whisper to topic when initially not a whisper'); - composerActions.expand().selectRowByValue('toggle_whisper'); + await visit('/t/internationalization-localization/280'); + await click('article#post_3 button.reply'); + await fillIn('.d-editor-input', 'test replying as whisper to topic when initially not a whisper'); - andThen(() => { - assert.ok( - find('.composer-fields .whisper').text().indexOf(I18n.t("composer.whisper")) > 0 - ); - }); + await composerActions.expandAwait(); + await composerActions.selectRowByValueAwait('toggle_whisper'); + + assert.ok( + find('.composer-fields .whisper').text().indexOf(I18n.t("composer.whisper")) > 0 + ); }); -QUnit.test('replying to post - reply_as_new_topic', assert => { +QUnit.test('replying to post - reply_as_new_topic', async assert => { const composerActions = selectKit('.composer-actions'); const categoryChooser = selectKit('.title-wrapper .category-chooser'); const categoryChooserReplyArea = selectKit('.reply-area .category-chooser'); const quote = 'test replying as new topic when initially replied to post'; - visit('/t/internationalization-localization/280'); + await visit('/t/internationalization-localization/280'); - click('#topic-title .d-icon-pencil'); - categoryChooser.expand().selectRowByValue(4); - click('#topic-title .submit-edit'); + await click('#topic-title .d-icon-pencil'); + await categoryChooser.expandAwait(); + await categoryChooser.selectRowByValueAwait(4); + await click('#topic-title .submit-edit'); - click('article#post_3 button.reply'); - fillIn('.d-editor-input', quote); - composerActions.expand().selectRowByValue('reply_as_new_topic'); + await click('article#post_3 button.reply'); + await fillIn('.d-editor-input', quote); - andThen(() => { - assert.equal(categoryChooserReplyArea.header().name(), 'faq'); - assert.equal(find('.action-title').text().trim(), I18n.t("topic.create_long")); - assert.ok(find('.d-editor-input').val().includes(quote)); - }); + await composerActions.expandAwait(); + await composerActions.selectRowByValueAwait('reply_as_new_topic'); + + assert.equal(categoryChooserReplyArea.header().name(), 'faq'); + assert.equal(find('.action-title').text().trim(), I18n.t("topic.create_long")); + assert.ok(find('.d-editor-input').val().includes(quote)); }); -QUnit.test('shared draft', assert => { - let composerActions = selectKit('.composer-actions'); +QUnit.test('shared draft', async assert => { + const composerActions = selectKit('.composer-actions'); - visit("/"); - click('#create-topic'); - andThen(() => { - composerActions.expand().selectRowByValue('shared_draft'); - }); - andThen(() => { - assert.equal( - find('#reply-control .btn-primary.create .d-button-label').text(), - I18n.t('composer.create_shared_draft') - ); - assert.ok(find('#reply-control.composing-shared-draft').length === 1); - }); + await visit("/"); + await click('#create-topic'); + + await composerActions.expandAwait(); + await composerActions.selectRowByValueAwait('shared_draft'); + + assert.equal( + find('#reply-control .btn-primary.create .d-button-label').text(), + I18n.t('composer.create_shared_draft') + ); + assert.ok(find('#reply-control.composing-shared-draft').length === 1); }); -QUnit.skip('interactions', async assert => { +QUnit.test('interactions', async assert => { const composerActions = selectKit('.composer-actions'); const quote = 'Life is like riding a bicycle.'; await visit('/t/internationalization-localization/280'); await click('article#post_3 button.reply'); await fillIn('.d-editor-input', quote); - await composerActions.expandAwait(); await composerActions.selectRowByValueAwait('reply_to_topic'); @@ -126,7 +123,7 @@ QUnit.skip('interactions', async assert => { assert.equal(composerActions.rowByIndex(1).value(), 'reply_to_post'); assert.equal(composerActions.rowByIndex(2).value(), 'reply_as_private_message'); assert.equal(composerActions.rowByIndex(3).value(), 'toggle_whisper'); - assert.equal(composerActions.rowByIndex(4).value(), undefined); + assert.equal(composerActions.rows().length, 4); await composerActions.selectRowByValueAwait('reply_to_post'); await composerActions.expandAwait(); @@ -138,7 +135,7 @@ QUnit.skip('interactions', async assert => { assert.equal(composerActions.rowByIndex(1).value(), 'reply_as_private_message'); assert.equal(composerActions.rowByIndex(2).value(), 'reply_to_topic'); assert.equal(composerActions.rowByIndex(3).value(), 'toggle_whisper'); - assert.equal(composerActions.rowByIndex(4).value(), undefined); + assert.equal(composerActions.rows().length, 4); await composerActions.selectRowByValueAwait('reply_as_new_topic'); await composerActions.expandAwait(); @@ -149,6 +146,7 @@ QUnit.skip('interactions', async assert => { assert.equal(composerActions.rowByIndex(1).value(), 'reply_as_private_message'); assert.equal(composerActions.rowByIndex(2).value(), 'reply_to_topic'); assert.equal(composerActions.rowByIndex(3).value(), 'shared_draft'); + assert.equal(composerActions.rows().length, 4); await composerActions.selectRowByValueAwait('reply_as_private_message'); await composerActions.expandAwait(); @@ -158,5 +156,5 @@ QUnit.skip('interactions', async assert => { assert.equal(composerActions.rowByIndex(0).value(), 'reply_as_new_topic'); assert.equal(composerActions.rowByIndex(1).value(), 'reply_to_post'); assert.equal(composerActions.rowByIndex(2).value(), 'reply_to_topic'); - assert.equal(composerActions.rowByIndex(3).value(), undefined); + assert.equal(composerActions.rows().length, 3); }); From 7232a6e5a9287018a85ba622223aed3481b8db51 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 23 May 2018 18:25:58 +0200 Subject: [PATCH 118/278] FIX: do not show composer actions if irrelevant --- .../select-kit/components/composer-actions.js.es6 | 3 ++- test/javascripts/acceptance/composer-actions-test.js.es6 | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/select-kit/components/composer-actions.js.es6 b/app/assets/javascripts/select-kit/components/composer-actions.js.es6 index ff587c2bdc..5a3479f751 100644 --- a/app/assets/javascripts/select-kit/components/composer-actions.js.es6 +++ b/app/assets/javascripts/select-kit/components/composer-actions.js.es6 @@ -28,6 +28,7 @@ export default DropdownSelectBoxComponent.extend({ allowInitialValueMutation: false, allowAutoSelectFirst: false, showFullTitle: false, + isHidden: Ember.computed.empty("content"), didReceiveAttrs() { this._super(); @@ -68,7 +69,7 @@ export default DropdownSelectBoxComponent.extend({ content(options, canWhisper, action) { let items = []; - if (action !== CREATE_TOPIC && action !== CREATE_SHARED_DRAFT) { + if (action !== CREATE_TOPIC && action !== CREATE_SHARED_DRAFT && _topicSnapshot) { items.push({ name: I18n.t("composer.composer_actions.reply_as_new_topic.label"), description: I18n.t("composer.composer_actions.reply_as_new_topic.desc"), diff --git a/test/javascripts/acceptance/composer-actions-test.js.es6 b/test/javascripts/acceptance/composer-actions-test.js.es6 index 291c1cb599..690696cf0c 100644 --- a/test/javascripts/acceptance/composer-actions-test.js.es6 +++ b/test/javascripts/acceptance/composer-actions-test.js.es6 @@ -104,6 +104,15 @@ QUnit.test('shared draft', async assert => { assert.ok(find('#reply-control.composing-shared-draft').length === 1); }); +QUnit.test('hide component if no content', async assert => { + const composerActions = selectKit('.composer-actions'); + + await visit('/u/eviltrout/messages'); + await click('.new-private-message'); + + assert.ok(composerActions.el().hasClass("is-hidden")); +}); + QUnit.test('interactions', async assert => { const composerActions = selectKit('.composer-actions'); const quote = 'Life is like riding a bicycle.'; From a45d8a69f951936bd6fcd429d90ff5ae2b60c9d2 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 23 May 2018 19:04:26 +0200 Subject: [PATCH 119/278] FIX: prevents &hellip from being displayed in title attribute --- .../components/select-kit/select-kit-header.js.es6 | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/select-kit/components/select-kit/select-kit-header.js.es6 b/app/assets/javascripts/select-kit/components/select-kit/select-kit-header.js.es6 index d719c1ca66..207de3259f 100644 --- a/app/assets/javascripts/select-kit/components/select-kit/select-kit-header.js.es6 +++ b/app/assets/javascripts/select-kit/components/select-kit/select-kit-header.js.es6 @@ -9,7 +9,7 @@ export default Ember.Component.extend({ "tabindex", "ariaLabel:aria-label", "ariaHasPopup:aria-haspopup", - "title", + "sanitizedTitle:title", "value:data-value", "name:data-name", ], @@ -18,7 +18,7 @@ export default Ember.Component.extend({ ariaHasPopup: true, - ariaLabel: Ember.computed.or("computedContent.ariaLabel", "title"), + ariaLabel: Ember.computed.or("computedContent.ariaLabel", "sanitizedTitle"), @computed("computedContent.title", "name") title(computedContentTitle, name) { @@ -28,6 +28,13 @@ export default Ember.Component.extend({ return null; }, + // this might need a more advanced solution + // but atm it's the only case we have to handle + @computed("title") + sanitizedTitle(title) { + return String(title).replace("…", ""); + }, + label: Ember.computed.or("computedContent.label", "title", "name"), name: Ember.computed.alias("computedContent.name"), From ae004d8b2b4af441f7b7a0e38a4d01a9fa7fa9a2 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 23 May 2018 19:57:12 +0200 Subject: [PATCH 120/278] attempts of making composer actions test more resilient --- test/javascripts/acceptance/composer-actions-test.js.es6 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/javascripts/acceptance/composer-actions-test.js.es6 b/test/javascripts/acceptance/composer-actions-test.js.es6 index 690696cf0c..cf9ad80e0e 100644 --- a/test/javascripts/acceptance/composer-actions-test.js.es6 +++ b/test/javascripts/acceptance/composer-actions-test.js.es6 @@ -1,10 +1,14 @@ import { acceptance } from 'helpers/qunit-helpers'; +import { _clearSnapshots } from 'select-kit/components/composer-actions'; acceptance('Composer Actions', { loggedIn: true, settings: { enable_whispers: true - } + }, + beforeEach() { + _clearSnapshots(); + }, }); QUnit.test('replying to post', async assert => { From db91d71bf1ce489a94a75a1bf053439bec9808a3 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 23 May 2018 21:07:39 +0200 Subject: [PATCH 121/278] fix category chooser spec --- test/javascripts/components/category-chooser-test.js.es6 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/javascripts/components/category-chooser-test.js.es6 b/test/javascripts/components/category-chooser-test.js.es6 index ad94f675b0..62df66fade 100644 --- a/test/javascripts/components/category-chooser-test.js.es6 +++ b/test/javascripts/components/category-chooser-test.js.es6 @@ -54,7 +54,7 @@ componentTest('with allowUncategorized=null', { test(assert) { andThen(() => { assert.equal(this.get('subject').header().value(), null); - assert.equal(this.get('subject').header().title(), "Select a category…"); + assert.equal(this.get('subject').header().title(), "Select a category"); }); } }); @@ -69,7 +69,7 @@ componentTest('with allowUncategorized=null rootNone=true', { test(assert) { andThen(() => { assert.equal(this.get('subject').header().value(), null); - assert.equal(this.get('subject').header().title(), 'Select a category…'); + assert.equal(this.get('subject').header().title(), 'Select a category'); }); } }); @@ -85,7 +85,7 @@ componentTest('with disallowed uncategorized, rootNone and rootNoneLabel', { test(assert) { andThen(() => { assert.equal(this.get('subject').header().value(), null); - assert.equal(this.get('subject').header().title(), 'Select a category…'); + assert.equal(this.get('subject').header().title(), 'Select a category'); }); } }); From ea7ffac17e508464233308f18582459832d3c681 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Wed, 23 May 2018 16:47:09 +0200 Subject: [PATCH 122/278] FIX: Paths used by PrettyText were not always initialized --- lib/pretty_text.rb | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb index f157800e38..83001ce837 100644 --- a/lib/pretty_text.rb +++ b/lib/pretty_text.rb @@ -142,25 +142,13 @@ module PrettyText protect do context = v8 - paths = { - baseUri: Discourse::base_uri, - CDN: Rails.configuration.action_controller.asset_host, - } - - if SiteSetting.Upload.enable_s3_uploads - if SiteSetting.Upload.s3_cdn_url.present? - paths[:S3CDN] = SiteSetting.Upload.s3_cdn_url - end - paths[:S3BaseUrl] = Discourse.store.absolute_base_url - end - custom_emoji = {} Emoji.custom.map { |e| custom_emoji[e.name] = e.url } buffer = <<~JS __optInput = {}; __optInput.siteSettings = #{SiteSetting.client_settings_json}; - __paths = #{paths.to_json}; + __paths = #{paths_json}; __optInput.getURL = __getURL; __optInput.getCurrentUser = __getCurrentUser; __optInput.lookupAvatar = __lookupAvatar; @@ -215,10 +203,29 @@ module PrettyText baked end + def self.paths_json + paths = { + baseUri: Discourse::base_uri, + CDN: Rails.configuration.action_controller.asset_host, + } + + if SiteSetting.Upload.enable_s3_uploads + if SiteSetting.Upload.s3_cdn_url.present? + paths[:S3CDN] = SiteSetting.Upload.s3_cdn_url + end + paths[:S3BaseUrl] = Discourse.store.absolute_base_url + end + + paths.to_json + end + # leaving this here, cause it invokes v8, don't want to implement twice def self.avatar_img(avatar_template, size) protect do - v8.eval("__utils.avatarImg({size: #{size.inspect}, avatarTemplate: #{avatar_template.inspect}}, __getURL);") + v8.eval(<<~JS) + __paths = #{paths_json}; + __utils.avatarImg({size: #{size.inspect}, avatarTemplate: #{avatar_template.inspect}}, __getURL); + JS end end @@ -227,7 +234,10 @@ module PrettyText set = SiteSetting.emoji_set.inspect protect do - v8.eval("__performEmojiUnescape(#{title.inspect}, { getURL: __getURL, emojiSet: #{set} })") + v8.eval(<<~JS) + __paths = #{paths_json}; + __performEmojiUnescape(#{title.inspect}, { getURL: __getURL, emojiSet: #{set} }); + JS end end From 4be0e31459861dcaa938f86043cc7e33977f6319 Mon Sep 17 00:00:00 2001 From: Andrew Schleifer Date: Tue, 22 May 2018 16:21:52 -0500 Subject: [PATCH 123/278] fix s3_cdn_url when the s3 bucket contains a folder --- lib/file_store/s3_store.rb | 3 ++- lib/s3_helper.rb | 2 +- spec/components/pretty_text_spec.rb | 23 +++++++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/file_store/s3_store.rb b/lib/file_store/s3_store.rb index 838e8ffbc6..483b41e02f 100644 --- a/lib/file_store/s3_store.rb +++ b/lib/file_store/s3_store.rb @@ -85,7 +85,8 @@ module FileStore def cdn_url(url) return url if SiteSetting.Upload.s3_cdn_url.blank? schema = url[/^(https?:)?\/\//, 1] - url.sub("#{schema}#{absolute_base_url}", SiteSetting.Upload.s3_cdn_url) + folder = @s3_helper.s3_bucket_folder_path.nil? ? "" : "#{@s3_helper.s3_bucket_folder_path}/" + url.sub("#{schema}#{absolute_base_url}/#{folder}", "#{SiteSetting.Upload.s3_cdn_url}/") end def cache_avatar(avatar, user_id) diff --git a/lib/s3_helper.rb b/lib/s3_helper.rb index cd9c839e80..8ead961b20 100644 --- a/lib/s3_helper.rb +++ b/lib/s3_helper.rb @@ -4,7 +4,7 @@ class S3Helper class SettingMissing < StandardError; end - attr_reader :s3_bucket_name + attr_reader :s3_bucket_name, :s3_bucket_folder_path def initialize(s3_bucket_name, tombstone_prefix = '', options = {}) @s3_options = default_s3_options.merge(options) diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb index 07cb22915b..62a02b74fc 100644 --- a/spec/components/pretty_text_spec.rb +++ b/spec/components/pretty_text_spec.rb @@ -725,6 +725,29 @@ describe PrettyText do test_s3_cdn end + + def test_s3_with_subfolder_cdn + raw = <<~RAW + + RAW + + html = <<~HTML +

    + HTML + + expect(PrettyText.cook(raw)).to eq(html.strip) + end + + it 'can substitute s3 with subfolder cdn when added via global setting' do + + global_setting :s3_access_key_id, 'XXX' + global_setting :s3_secret_access_key, 'XXX' + global_setting :s3_bucket, 'XXX/subfolder' + global_setting :s3_region, 'XXX' + global_setting :s3_cdn_url, 'https://awesome.cdn/subfolder' + + test_s3_with_subfolder_cdn + end end describe "emoji" do From b519beb314c5e68ef5e82d25671fe2df32180777 Mon Sep 17 00:00:00 2001 From: Kris Date: Wed, 23 May 2018 16:55:57 -0400 Subject: [PATCH 124/278] Turning admin dashboard user counts into badges --- .../components/dashboard-inline-table.hbs | 4 +- .../common/admin/dashboard_next.scss | 67 +++++++++++++++---- .../stylesheets/common/base/user-badges.scss | 6 +- .../common/foundation/variables.scss | 7 ++ 4 files changed, 65 insertions(+), 19 deletions(-) diff --git a/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs b/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs index 76f04a2175..764963a00e 100644 --- a/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs +++ b/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs @@ -7,12 +7,12 @@
    {{#unless hasBlock}} {{#each report.data as |data|}} -
    +
    {{#if data.icon}} {{d-icon data.icon}} {{/if}} - {{data.x}}: + {{data.x}} {{#if data.url}} diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index a2a2dac1fe..0a9f93ca20 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -382,6 +382,59 @@ } } + .table-cell { + display: flex; + flex: 0 1 auto; + margin: 0 1em .5em 0; + padding: 0 .5em 0 0; + border: 1px solid $primary-low; + border-radius: 10px; + + .label { + display: flex; + align-items: center; + margin-right: .25em; + color: $primary; + background: $primary-very-low; + padding: 0 .5em; + justify-content: center; + border-radius: 10px 0 0 10px; + .d-icon { + margin-right: 5px; + font-size: $font-down-1; + } + } + &.user-newuser{ + .label { + color: $primary-high; + } + } + &.user-basic , &.user-member { + border-color: $bronze; + .label { + border-color: $bronze; + background: $bronze; + color: $secondary; + } + } + &.user-regular { + border-color: $silver; + .label { + border-color: $silver; + background: $silver; + color: $secondary; + } + } + &.user-leader { + border-color: $gold; + .label { + background: $gold; + border-color: $gold; + color: $secondary; + } + } + } + .dashboard-inline-table { margin-left: 5%; margin-bottom: 1.25em; @@ -396,20 +449,6 @@ flex-wrap: wrap; flex: 1 1 auto; } - - .table-cell { - display: flex; - flex: 0 1 auto; - margin: 0 2em .5em 0; - .label { - display: flex; - align-items: center; - margin-right: .5em; - .d-icon { - margin-right: 5px; - } - } - } } .dashboard-table.activity-metrics { diff --git a/app/assets/stylesheets/common/base/user-badges.scss b/app/assets/stylesheets/common/base/user-badges.scss index 63a31d2a15..98c47475ff 100644 --- a/app/assets/stylesheets/common/base/user-badges.scss +++ b/app/assets/stylesheets/common/base/user-badges.scss @@ -20,15 +20,15 @@ } &.badge-type-gold .fa { - color: rgb(231, 195, 0) !important; + color: $gold !important; } &.badge-type-silver .fa { - color: #c0c0c0 !important; + color: $silver !important; } &.badge-type-bronze .fa { - color: #cd7f32 !important; + color: $bronze !important; } &.disabled { diff --git a/app/assets/stylesheets/common/foundation/variables.scss b/app/assets/stylesheets/common/foundation/variables.scss index 1bd5900407..6a91f1006a 100644 --- a/app/assets/stylesheets/common/foundation/variables.scss +++ b/app/assets/stylesheets/common/foundation/variables.scss @@ -22,6 +22,13 @@ $twitter: #00bced !default; $yahoo: #810293 !default; $github: #6d6d6d !default; +// Badge color variables +// -------------------------------------------------- + +$gold: rgb(231, 195, 0) !default; +$silver: #c0c0c0 !default; +$bronze: #cd7f32 !default; + // Fonts // -------------------------------------------------- From 3db1032bfdbc545d1e25fa2623c4a6d4b687692a Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Wed, 23 May 2018 16:58:47 -0400 Subject: [PATCH 125/278] FIX: not found page shouldn't include the Google search form for sites with login_required enabled --- app/controllers/application_controller.rb | 1 + spec/requests/application_controller_spec.rb | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index fa8e02832a..92d639f65a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -681,6 +681,7 @@ class ApplicationController < ActionController::Base @slug = params[:slug].class == String ? params[:slug] : '' @slug = (params[:id].class == String ? params[:id] : '') if @slug.blank? @slug.tr!('-', ' ') + @hide_google = true if SiteSetting.login_required render_to_string status: status, layout: layout, formats: [:html], template: '/exceptions/not_found' end diff --git a/spec/requests/application_controller_spec.rb b/spec/requests/application_controller_spec.rb index 2896d6e22e..3e19b5f504 100644 --- a/spec/requests/application_controller_spec.rb +++ b/spec/requests/application_controller_spec.rb @@ -14,4 +14,22 @@ RSpec.describe ApplicationController do expect(response).to redirect_to('/login?authComplete=true') end end + + describe 'build_not_found_page' do + describe 'topic not found' do + it 'should return 404 and show Google search' do + get "/t/nope-nope/99999999" + expect(response.status).to eq(404) + expect(response.body).to include(I18n.t('page_not_found.search_google')) + end + + it 'should not include Google search if login_required is enabled' do + SiteSetting.login_required = true + sign_in(Fabricate(:user)) + get "/t/nope-nope/99999999" + expect(response.status).to eq(404) + expect(response.body).to_not include('google.com/search') + end + end + end end From 232ed6486572ba37efd0107016604f4a3b9ad293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Wed, 23 May 2018 23:05:34 +0200 Subject: [PATCH 126/278] UX: Add category & tags in mobile topic timeline --- .../discourse/widgets/topic-timeline.js.es6 | 31 +++++++++++++++++++ app/assets/stylesheets/mobile/topic.scss | 3 ++ 2 files changed, 34 insertions(+) diff --git a/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6 b/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6 index a82525157f..7cff52d3f8 100644 --- a/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6 +++ b/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6 @@ -4,6 +4,8 @@ import { h } from 'virtual-dom'; import { relativeAge } from 'discourse/lib/formatter'; import { iconNode } from 'discourse-common/lib/icon-library'; import RawHtml from 'discourse/widgets/raw-html'; +import renderTags from 'discourse/lib/render-tags'; +import renderTopicFeaturedLink from 'discourse/lib/render-topic-featured-link'; const SCROLLER_HEIGHT = 50; const LAST_READ_HEIGHT = 20; @@ -382,6 +384,7 @@ export default createWidget('topic-timeline', { const createdAt = new Date(topic.created_at); const stream = attrs.topic.get('postStream.stream'); const { currentUser } = this; + const { tagging_enabled, topic_featured_link_enabled } = this.siteSettings; attrs["currentUser"] = currentUser; @@ -399,6 +402,34 @@ export default createWidget('topic-timeline', { action: 'jumpTop' }))]; + // duplicate of the {{topic-category}} component + let category = []; + + if (!topic.get("isPrivateMessage")) { + if (topic.category.parentCategory) { + category.push(this.attach("category-link", { category: topic.category.parentCategory })); + } + category.push(this.attach("category-link", { category: topic.category })); + } + + const showTags = tagging_enabled && topic.tags && topic.tags.length > 0; + + if (showTags || topic_featured_link_enabled) { + let extras = []; + if (showTags) { + const tagsHtml = new RawHtml({ html: renderTags(topic, { mode: "list" }) }); + extras.push(h("div.list-tags", tagsHtml)); + } + if (topic_featured_link_enabled) { + extras.push(new RawHtml({ html: renderTopicFeaturedLink(topic) })); + } + category.push(h("div.topic-header-extra", extras)); + } + + if (category.length > 0) { + elems.push(h("div.topic-category", category)); + } + if (this.state.excerpt) { elems.push(new RawHtml({ html: `
    ${this.state.excerpt}
    ` diff --git a/app/assets/stylesheets/mobile/topic.scss b/app/assets/stylesheets/mobile/topic.scss index df79b7f6f3..4281d322b8 100644 --- a/app/assets/stylesheets/mobile/topic.scss +++ b/app/assets/stylesheets/mobile/topic.scss @@ -196,6 +196,9 @@ sup sup, sub sup, sup sub, sub sub { top: 0; } font-size: $font-up-1; padding: 5px; } + .topic-category { + margin-bottom: .5rem; + } } .edit-topic-timer-modal { From 15f7d06108602397e0e3b31a3328de2c561fc795 Mon Sep 17 00:00:00 2001 From: Kris Date: Wed, 23 May 2018 18:46:25 -0400 Subject: [PATCH 127/278] User metric badge alignment fix --- .../components/dashboard-inline-table.hbs | 8 ++------ .../common/admin/dashboard_next.scss | 17 +++++++++-------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs b/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs index 764963a00e..9e976f46d2 100644 --- a/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs +++ b/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs @@ -7,7 +7,7 @@
    {{#unless hasBlock}} {{#each report.data as |data|}} - + {{/each}} {{else}} {{yield (hash report=report)}} diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index 0a9f93ca20..ffc6026e58 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -380,30 +380,30 @@ flex: 1 0 auto; max-width: 95%; } -} - .table-cell { display: flex; flex: 0 1 auto; - margin: 0 1em .5em 0; - padding: 0 .5em 0 0; + margin: 0 10px 5px 0; border: 1px solid $primary-low; border-radius: 10px; - .label { display: flex; align-items: center; - margin-right: .25em; color: $primary; background: $primary-very-low; - padding: 0 .5em; justify-content: center; - border-radius: 10px 0 0 10px; + border-radius: 9px 0 0 9px; + padding: 0 5px 0 8px; + .d-icon { margin-right: 5px; font-size: $font-down-1; } } + + .value { + padding: 0 8px 0 5px; + } &.user-newuser{ .label { color: $primary-high; @@ -434,6 +434,7 @@ } } } +} .dashboard-inline-table { margin-left: 5%; From d366f8d888f5260baf55d3397c0f147fe09f5cf7 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 24 May 2018 10:48:16 +1000 Subject: [PATCH 128/278] remove hack that destabliazed tese suite --- spec/components/auth/github_authenticator_spec.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/spec/components/auth/github_authenticator_spec.rb b/spec/components/auth/github_authenticator_spec.rb index 02bcb987c7..50c5cdf0f2 100644 --- a/spec/components/auth/github_authenticator_spec.rb +++ b/spec/components/auth/github_authenticator_spec.rb @@ -1,11 +1,5 @@ require 'rails_helper' -# In the ghetto ... getting the spec to run in autospec -# thing is we need to load up all auth really early pre-fork -# it means that the require is not going to get a new copy -Auth.send(:remove_const, :GithubAuthenticator) -load 'auth/github_authenticator.rb' - def auth_token_for(user) { extra: { From ebd966fbdb9cb56e1e8e1c8a6991a11fefb18a23 Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Wed, 23 May 2018 18:51:08 -0700 Subject: [PATCH 129/278] Feature: Add warning banner in email settings when mailing list mode enabled --- .../javascripts/discourse/templates/preferences/emails.hbs | 7 +++++++ app/assets/stylesheets/common/base/user.scss | 5 +++++ config/locales/client.en.yml | 1 + 3 files changed, 13 insertions(+) diff --git a/app/assets/javascripts/discourse/templates/preferences/emails.hbs b/app/assets/javascripts/discourse/templates/preferences/emails.hbs index b73cffa4d8..d877bf7f82 100644 --- a/app/assets/javascripts/discourse/templates/preferences/emails.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/emails.hbs @@ -1,3 +1,10 @@ +{{#unless siteSettings.disable_mailing_list_mode}} +
    + {{#if model.user_option.mailing_list_mode}} +
    {{i18n 'user.mailing_list_mode.warning'}}
    + {{/if}} +
    +{{/unless}}
    diff --git a/app/assets/stylesheets/common/base/user.scss b/app/assets/stylesheets/common/base/user.scss index 749dbab90a..b5543ccec6 100644 --- a/app/assets/stylesheets/common/base/user.scss +++ b/app/assets/stylesheets/common/base/user.scss @@ -541,6 +541,11 @@ width: 520px; } + .warning-wrap { + height: 30px; + margin-bottom: 10px; + } + .category-notifications .category-controls, .tag-notifications .tag-controls { margin-top: 24px; diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 75606667ae..15c0898f5d 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -659,6 +659,7 @@ en: individual_no_echo: "Send an email for every new post except my own" many_per_day: "Send me an email for every new post (about {{dailyEmailEstimate}} per day)" few_per_day: "Send me an email for every new post (about 2 per day)" + warning: "Mailing list mode enabled. Email notification settings are overridden." tag_settings: "Tags" watched_tags: "Watched" watched_tags_instructions: "You will automatically watch all topics with these tags. You will be notified of all new posts and topics, and a count of new posts will also appear next to the topic." From be49da9105ae4b33ec6d88cc1234ca5168df994e Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 24 May 2018 10:00:15 +0800 Subject: [PATCH 130/278] FIX: Don't silently fail if notification fails to create. --- app/services/post_alerter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 66648a69ee..d5539e34c6 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -379,7 +379,7 @@ class PostAlerter end # Create the notification - created = user.notifications.create( + created = user.notifications.create!( notification_type: type, topic_id: post.topic_id, post_number: post.post_number, From 2aad91d4a29a5196cc40f26e86e6e77b1a8f6935 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 24 May 2018 10:02:26 +0800 Subject: [PATCH 131/278] PERF: Don't bloat the Sidekiq queue with `Jobs::SendPushNotification`. --- app/services/post_alerter.rb | 4 ++++ config/initializers/100-push-notifications.rb | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index d5539e34c6..240d817c59 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -415,6 +415,10 @@ class PostAlerter end def push_notification(user, payload) + if user.push_subscriptions.exists? + Jobs.enqueue(:send_push_notification, user_id: user.id, payload: payload) + end + if SiteSetting.allow_user_api_key_scopes.split("|").include?("push") && SiteSetting.allowed_user_api_push_urls.present? clients = user.user_api_keys .where("('push' = ANY(scopes) OR 'notifications' = ANY(scopes)) AND push_url IS NOT NULL AND position(push_url in ?) > 0 AND revoked_at IS NULL", diff --git a/config/initializers/100-push-notifications.rb b/config/initializers/100-push-notifications.rb index 8cb0542cab..50f580c388 100644 --- a/config/initializers/100-push-notifications.rb +++ b/config/initializers/100-push-notifications.rb @@ -8,10 +8,6 @@ end SiteSetting.vapid_public_key_bytes = Base64.urlsafe_decode64(SiteSetting.vapid_public_key).bytes.join("|") -DiscourseEvent.on(:post_notification_alert) do |user, payload| - Jobs.enqueue(:send_push_notification, user_id: user.id, payload: payload) -end - DiscourseEvent.on(:user_logged_out) do |user| PushNotificationPusher.clear_subscriptions(user) end From 11b544ef4ec512e943b4e9ca1b6a31bdf862822a Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 24 May 2018 12:10:22 +1000 Subject: [PATCH 132/278] DEV: add support for custom post message callbacks Allows for cleaner subscription to changes by plugins, polls will move to it. --- .../discourse/controllers/topic.js.es6 | 17 ++++++++++++++- .../discourse/lib/plugin-api.js.es6 | 21 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 46448d8447..7a4bc0535d 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -14,6 +14,16 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; import { spinnerHTML } from 'discourse/helpers/loading-spinner'; import { userPath } from 'discourse/lib/url'; +const customPostMessageCallbacks = {}; + +export function registerCustomPostMessageCallback(type, callback) { + if (customPostMessageCallbacks[type]) { + throw `Error ${type} is an already registered post message!`; + } + + customPostMessageCallbacks[type] = callback; +} + export default Ember.Controller.extend(BufferedContent, { composer: Ember.inject.controller(), application: Ember.inject.controller(), @@ -935,7 +945,12 @@ export default Ember.Controller.extend(BufferedContent, { break; } default: { - Em.Logger.warn("unknown topic bus message type", data); + let callback = customPostMessageCallbacks[data.type]; + if (callback) { + callback(this, data); + } else { + Em.Logger.warn("unknown topic bus message type", data); + } } } diff --git a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 index ce78480770..3222c2ddd2 100644 --- a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 +++ b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 @@ -25,9 +25,10 @@ import { modifySelectKit } from "select-kit/mixins/plugin-api"; import { addGTMPageChangedCallback } from 'discourse/lib/page-tracker'; import { registerCustomAvatarHelper } from 'discourse/helpers/user-avatar'; import { disableNameSuppression } from 'discourse/widgets/poster-name'; +import { registerCustomPostMessageCallback as registerCustomPostMessageCallback1 } from 'discourse/controllers/topic'; // If you add any methods to the API ensure you bump up this number -const PLUGIN_API_VERSION = '0.8.21'; +const PLUGIN_API_VERSION = '0.8.22'; class PluginApi { constructor(version, container) { @@ -426,6 +427,24 @@ class PluginApi { disableNameSuppression(); } + /** + * Registers a callback that will be invoked when the server calls + * Post#publish_change_to_clients! please ensure your type does not + * match acted,revised,rebaked,recovered, created,move_to_inbox or archived + * + * callback will be called with topicController and Message + * + * Example: + * + * api.registerCustomPostMessageCallback("applied_color", (topicController, message) => { + * let stream = topicController.get("model.postStream"); + * // etc + * }); + */ + registerCustomPostMessageCallback(type, callback) { + registerCustomPostMessageCallback1(type, callback); + } + /** * Changes a setting associated with a widget. For example, if * you wanted small avatars in the post stream: From ed08545d1fd6dd24e8727e76547dfa1293ff36bb Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 24 May 2018 12:34:23 +1000 Subject: [PATCH 133/278] FEATURE: allow searching in title in advanced search Add UI for matching in title only in advanced search options --- .../components/search-advanced-options.js.es6 | 48 +++++++------------ .../components/search-advanced-options.hbs | 1 + config/locales/client.en.yml | 1 + 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 index a171083523..22976d76d3 100644 --- a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 +++ b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 @@ -18,6 +18,7 @@ const REGEXP_TAGS_REPLACE = /(^(tags?:|#(?=[a-z0-9\-]+::tag))|::tag\s?$ const REGEXP_IN_MATCH = /^(in|with):(posted|watching|tracking|bookmarks|first|pinned|unpinned|wiki|unseen|image)/ig; const REGEXP_SPECIAL_IN_LIKES_MATCH = /^in:likes/ig; +const REGEXP_SPECIAL_IN_TITLE_MATCH = /^in:title/ig; const REGEXP_SPECIAL_IN_PRIVATE_MATCH = /^in:private/ig; const REGEXP_SPECIAL_IN_SEEN_MATCH = /^in:seen/ig; @@ -81,6 +82,7 @@ export default Em.Component.extend({ in: '', special: { in: { + title: false, likes: false, private: false, seen: false @@ -111,6 +113,7 @@ export default Em.Component.extend({ this.setSearchedTermValueForTags(); this.setSearchedTermValue('searchedTerms.in', REGEXP_IN_PREFIX, REGEXP_IN_MATCH); this.setSearchedTermSpecialInValue('searchedTerms.special.in.likes', REGEXP_SPECIAL_IN_LIKES_MATCH); + this.setSearchedTermSpecialInValue('searchedTerms.special.in.title', REGEXP_SPECIAL_IN_TITLE_MATCH); this.setSearchedTermSpecialInValue('searchedTerms.special.in.private', REGEXP_SPECIAL_IN_PRIVATE_MATCH); this.setSearchedTermSpecialInValue('searchedTerms.special.in.seen', REGEXP_SPECIAL_IN_SEEN_MATCH); this.setSearchedTermValue('searchedTerms.status', REGEXP_STATUS_PREFIX); @@ -424,15 +427,14 @@ export default Em.Component.extend({ } }, - @observes('searchedTerms.special.in.likes') - updateSearchTermForSpecialInLikes() { - const match = this.filterBlocks(REGEXP_SPECIAL_IN_LIKES_MATCH); - const inFilter = this.get('searchedTerms.special.in.likes'); + updateInRegex(regex, filter) { + const match = this.filterBlocks(regex); + const inFilter = this.get('searchedTerms.special.in.' + filter); let searchTerm = this.get('searchTerm') || ''; if (inFilter) { if (match.length === 0) { - searchTerm += ` in:likes`; + searchTerm += ` in:${filter}`; this.set('searchTerm', searchTerm.trim()); } } else if (match.length !== 0) { @@ -441,38 +443,24 @@ export default Em.Component.extend({ } }, + @observes('searchedTerms.special.in.likes') + updateSearchTermForSpecialInLikes() { + this.updateInRegex(REGEXP_SPECIAL_IN_LIKES_MATCH, 'likes'); + }, + @observes('searchedTerms.special.in.private') updateSearchTermForSpecialInPrivate() { - const match = this.filterBlocks(REGEXP_SPECIAL_IN_PRIVATE_MATCH); - const inFilter = this.get('searchedTerms.special.in.private'); - let searchTerm = this.get('searchTerm') || ''; - - if (inFilter) { - if (match.length === 0) { - searchTerm += ` in:private`; - this.set('searchTerm', searchTerm.trim()); - } - } else if (match.length !== 0) { - searchTerm = searchTerm.replace(match, ''); - this.set('searchTerm', searchTerm.trim()); - } + this.updateInRegex(REGEXP_SPECIAL_IN_PRIVATE_MATCH, 'private'); }, @observes('searchedTerms.special.in.seen') updateSearchTermForSpecialInSeen() { - const match = this.filterBlocks(REGEXP_SPECIAL_IN_SEEN_MATCH); - const inFilter = this.get('searchedTerms.special.in.seen'); - let searchTerm = this.get('searchTerm') || ''; + this.updateInRegex(REGEXP_SPECIAL_IN_SEEN_MATCH, 'seen'); + }, - if (inFilter) { - if (match.length === 0) { - searchTerm += ` in:seen`; - this.set('searchTerm', searchTerm.trim()); - } - } else if (match.length !== 0) { - searchTerm = searchTerm.replace(match, ''); - this.set('searchTerm', searchTerm.trim()); - } + @observes('searchedTerms.special.in.title') + updateSearchTermForSpecialInTitle() { + this.updateInRegex(REGEXP_SPECIAL_IN_TITLE_MATCH, 'title'); }, @observes('searchedTerms.status') 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 3ce9b3d84f..fd330828a9 100644 --- a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs +++ b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs @@ -60,6 +60,7 @@
    {{#if currentUser}}
    + diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 15c0898f5d..e936696bf8 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1508,6 +1508,7 @@ en: label: Tagged filters: label: Only return topics/posts... + title: Matching in title only likes: I liked posted: I posted in watching: I'm watching From c684860b68c3b0783eb602b5a21e0af68c4a06f6 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 24 May 2018 12:46:41 +1000 Subject: [PATCH 134/278] only build master in travis no need to build any other branches --- .travis.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.travis.yml b/.travis.yml index ba9f8a2c07..61cc6fc02c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,9 @@ language: ruby +branches: + only: + - master + env: global: - DISCOURSE_HOSTNAME=www.example.com From 6a01a371eea87fd810938d1b436349daed87ebab Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 24 May 2018 12:48:51 +1000 Subject: [PATCH 135/278] add a couple more branches --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 61cc6fc02c..d8413fcb97 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,8 @@ language: ruby branches: only: - master + - beta + - stable env: global: From 4194886b62192c138877051603f8194e825b7a6d Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 24 May 2018 13:02:20 +1000 Subject: [PATCH 136/278] correct failing test --- .../components/search-advanced-options.hbs | 2 +- .../acceptance/search-full-test.js.es6 | 23 ++++++++++++------- vendor/assets/javascripts/run-qunit.js | 2 +- 3 files changed, 17 insertions(+), 10 deletions(-) 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 fd330828a9..371a1c3849 100644 --- a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs +++ b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs @@ -60,7 +60,7 @@
    {{#if currentUser}}
    - + diff --git a/test/javascripts/acceptance/search-full-test.js.es6 b/test/javascripts/acceptance/search-full-test.js.es6 index eb528a7ed6..61566c00bd 100644 --- a/test/javascripts/acceptance/search-full-test.js.es6 +++ b/test/javascripts/acceptance/search-full-test.js.es6 @@ -175,16 +175,23 @@ QUnit.test("update category through advanced search ui", assert => { // }); // }); // }); +// +QUnit.test("update in:title filter through advanced search ui", async assert => { + await visit("/search"); + await fillIn('.search-query', 'none'); + await click('.search-advanced-options .in-title'); -QUnit.test("update in:likes filter through advanced search ui", assert => { - visit("/search"); - fillIn('.search-query', 'none'); - click('.search-advanced-options .in-likes'); + assert.ok(exists('.search-advanced-options .in-title:checked'), 'has "in title" populated'); + assert.equal(find('.search-query').val(), "none in:title", 'has updated search term to "none in:title"'); +}); - andThen(() => { - assert.ok(exists('.search-advanced-options .in-likes:checked'), 'has "I liked" populated'); - assert.equal(find('.search-query').val(), "none in:likes", 'has updated search term to "none in:likes"'); - }); +QUnit.test("update in:likes filter through advanced search ui", async assert => { + await visit("/search"); + await fillIn('.search-query', 'none'); + await click('.search-advanced-options .in-likes'); + + assert.ok(exists('.search-advanced-options .in-likes:checked'), 'has "I liked" populated'); + assert.equal(find('.search-query').val(), "none in:likes", 'has updated search term to "none in:likes"'); }); QUnit.test("update in:private filter through advanced search ui", assert => { diff --git a/vendor/assets/javascripts/run-qunit.js b/vendor/assets/javascripts/run-qunit.js index daf747e3d7..9295969312 100644 --- a/vendor/assets/javascripts/run-qunit.js +++ b/vendor/assets/javascripts/run-qunit.js @@ -57,7 +57,7 @@ async function runAllTests() { // If it's a simple test result, write without newline if(message === "." || message === "F"){ process.stdout.write(message); - } else if (message.startsWith("AUTOSPEC:")) { + } else if (message && message.startsWith("AUTOSPEC:")) { fs.appendFileSync(QUNIT_RESULT, `${message.slice(10)}\n`); } else { console.log(message); From 70179290f3f3baa91891eb7d3a3308a90d7e8dbc Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 24 May 2018 15:28:54 +1000 Subject: [PATCH 137/278] clean up callbacks during acceptance tests --- app/assets/javascripts/discourse/controllers/topic.js.es6 | 4 ++++ test/javascripts/helpers/qunit-helpers.js.es6 | 2 ++ 2 files changed, 6 insertions(+) diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 7a4bc0535d..11f4fe4cba 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -16,6 +16,10 @@ import { userPath } from 'discourse/lib/url'; const customPostMessageCallbacks = {}; +export function resetCustomPostMessageCallbacks() { + customPostMessageCallbacks = {}; +} + export function registerCustomPostMessageCallback(type, callback) { if (customPostMessageCallbacks[type]) { throw `Error ${type} is an already registered post message!`; diff --git a/test/javascripts/helpers/qunit-helpers.js.es6 b/test/javascripts/helpers/qunit-helpers.js.es6 index 1a267c8c43..50bf08bef0 100644 --- a/test/javascripts/helpers/qunit-helpers.js.es6 +++ b/test/javascripts/helpers/qunit-helpers.js.es6 @@ -10,6 +10,7 @@ import { flushMap } from 'discourse/models/store'; import { clearRewrites } from 'discourse/lib/url'; import { initSearchData } from 'discourse/widgets/search-menu'; import { resetDecorators } from 'discourse/widgets/widget'; +import { resetCustomPostMessageCallbacks } from 'discourse/controllers/topic'; export function currentUser() { return Discourse.User.create(sessionFixtures['/session/current.json'].current_user); @@ -107,6 +108,7 @@ export function acceptance(name, options) { clearRewrites(); initSearchData(); resetDecorators(); + resetCustomPostMessageCallbacks(); Discourse.reset(); } }); From 53b97b28f082c835f408fe0a1fb51820f51d7d9e Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 24 May 2018 15:38:33 +1000 Subject: [PATCH 138/278] FIX: in rare conditions post timing would miss the user --- app/controllers/topics_controller.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index b9334d490d..fd20607f9e 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -622,9 +622,12 @@ class TopicsController < ApplicationController topic_time = allowed_params[:topic_time].to_i timings = allowed_params[:timings].to_h || {} + # ensure we capture current user for the block + user = current_user + hijack do PostTiming.process_timings( - current_user, + user, topic_id, topic_time, timings.map { |post_number, t| [post_number.to_i, t.to_i] }, From 4fef9e95d2429e1a1a779d5e6c3fdc738cf1ac7b Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 24 May 2018 15:42:00 +1000 Subject: [PATCH 139/278] should not be a const --- app/assets/javascripts/discourse/controllers/topic.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 11f4fe4cba..9a464c463c 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -14,7 +14,7 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; import { spinnerHTML } from 'discourse/helpers/loading-spinner'; import { userPath } from 'discourse/lib/url'; -const customPostMessageCallbacks = {}; +let customPostMessageCallbacks = {}; export function resetCustomPostMessageCallbacks() { customPostMessageCallbacks = {}; From 8a1aab4e8a3fbab93d6854c0c5e2258fe83a518e Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 24 May 2018 14:56:40 +0800 Subject: [PATCH 140/278] PERF: Select distinct active web hooks at the db level. --- app/models/web_hook.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/web_hook.rb b/app/models/web_hook.rb index 257357c4ea..a5cda0ce03 100644 --- a/app/models/web_hook.rb +++ b/app/models/web_hook.rb @@ -38,7 +38,7 @@ class WebHook < ActiveRecord::Base WebHook.where(active: true) .joins(:web_hook_event_types) .where("web_hooks.wildcard_web_hook = ? OR web_hook_event_types.name = ?", true, type.to_s) - .uniq + .distinct end def self.enqueue_hooks(type, opts = {}, web_hooks = nil) From b3860c82da57084411500f024d96ecbd850f6b82 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 24 May 2018 15:01:46 +0800 Subject: [PATCH 141/278] FIX: Don't enqueue web hooks inside a deferred queue. * The deferred queue is meant for short lived jobs and does not guarantee execution. We need to ensure that web hooks are always run. --- app/models/web_hook.rb | 70 ++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 40 deletions(-) diff --git a/app/models/web_hook.rb b/app/models/web_hook.rb index a5cda0ce03..c2c016741e 100644 --- a/app/models/web_hook.rb +++ b/app/models/web_hook.rb @@ -41,8 +41,8 @@ class WebHook < ActiveRecord::Base .distinct end - def self.enqueue_hooks(type, opts = {}, web_hooks = nil) - (web_hooks || active_web_hooks(type)).each do |web_hook| + def self.enqueue_hooks(type, opts = {}) + active_web_hooks(type).each do |web_hook| Jobs.enqueue(:emit_web_hook_event, opts.merge( web_hook_id: web_hook.id, event_type: type.to_s )) @@ -50,54 +50,44 @@ class WebHook < ActiveRecord::Base end def self.enqueue_object_hooks(type, object, event, serializer = nil) - Scheduler::Defer.later("Enqueue User Webhook") do - web_hooks = active_web_hooks(type) - unless web_hooks.empty? - serializer ||= "WebHook#{type.capitalize}Serializer".constantize + if active_web_hooks(type).exists? + serializer ||= "WebHook#{type.capitalize}Serializer".constantize - WebHook.enqueue_hooks(type, { - event_name: event.to_s, - payload: serializer.new(object, - scope: self.guardian, - root: false - ).to_json - }, web_hooks) - end + WebHook.enqueue_hooks(type, + event_name: event.to_s, + payload: serializer.new(object, + scope: self.guardian, + root: false + ).to_json + ) end end def self.enqueue_topic_hooks(event, topic) - Scheduler::Defer.later("Enqueue Topic Webhook") do - web_hooks = active_web_hooks('topic') - unless web_hooks.empty? - topic_view = TopicView.new(topic.id, Discourse.system_user) + if active_web_hooks('topic').exists? + topic_view = TopicView.new(topic.id, Discourse.system_user) - WebHook.enqueue_hooks(:topic, { - category_id: topic&.category_id, - event_name: event.to_s, - payload: WebHookTopicViewSerializer.new(topic_view, - scope: self.guardian, - root: false - ).to_json - }, web_hooks) - end + WebHook.enqueue_hooks(:topic, + category_id: topic&.category_id, + event_name: event.to_s, + payload: WebHookTopicViewSerializer.new(topic_view, + scope: self.guardian, + root: false + ).to_json + ) end end def self.enqueue_post_hooks(event, post) - Scheduler::Defer.later("Enqueue Post Webhook") do - web_hooks = active_web_hooks('post') - - unless web_hooks.empty? - WebHook.enqueue_hooks(:post, { - category_id: post&.topic&.category_id, - event_name: event.to_s, - payload: WebHookPostSerializer.new(post, - scope: self.guardian, - root: false - ).to_json - }, web_hooks) - end + if active_web_hooks('post').exists? + WebHook.enqueue_hooks(:post, + category_id: post&.topic&.category_id, + event_name: event.to_s, + payload: WebHookPostSerializer.new(post, + scope: self.guardian, + root: false + ).to_json + ) end end From 43f7cb05c97ba249fdd5aa01e6ac7c6238816f61 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 24 May 2018 15:16:52 +0800 Subject: [PATCH 142/278] FIX: Broken ping event for web hooks due to missing payload. --- app/jobs/regular/emit_web_hook_event.rb | 2 +- spec/jobs/emit_web_hook_event_spec.rb | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/jobs/regular/emit_web_hook_event.rb b/app/jobs/regular/emit_web_hook_event.rb index 4a96851367..db90dd0401 100644 --- a/app/jobs/regular/emit_web_hook_event.rb +++ b/app/jobs/regular/emit_web_hook_event.rb @@ -8,7 +8,6 @@ module Jobs %i{ web_hook_id event_type - payload }.each do |key| raise Discourse::InvalidParameters.new(key) unless args[key].present? end @@ -25,6 +24,7 @@ module Jobs return if web_hook.category_ids.present? && (!args[:category_id].present? || !web_hook.category_ids.include?(args[:category_id])) + raise Discourse::InvalidParameters.new(:payload) unless args[:payload].present? args[:payload] = JSON.parse(args[:payload]) end diff --git a/spec/jobs/emit_web_hook_event_spec.rb b/spec/jobs/emit_web_hook_event_spec.rb index 7354d78b94..97a86f6683 100644 --- a/spec/jobs/emit_web_hook_event_spec.rb +++ b/spec/jobs/emit_web_hook_event_spec.rb @@ -24,6 +24,16 @@ describe Jobs::EmitWebHookEvent do end.to raise_error(Discourse::InvalidParameters) end + it 'does not raise an error for a ping event without payload' do + stub_request(:post, "https://meta.discourse.org/webhook_listener") + .to_return(body: 'OK', status: 200) + + subject.execute( + web_hook_id: post_hook.id, + event_type: described_class::PING_EVENT + ) + end + it "doesn't emit when the hook is inactive" do subject.execute( web_hook_id: inactive_hook.id, @@ -99,7 +109,7 @@ describe Jobs::EmitWebHookEvent do web_hook_id: post_hook.id, event_type: described_class::PING_EVENT, event_name: described_class::PING_EVENT, - payload: { test: "some payload" }.to_json + payload: { test: "this payload shouldn't appear" }.to_json ) event = WebHookEvent.last From 1f27c919243cecda49780a6312e97486792f7707 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 24 May 2018 15:36:41 +0800 Subject: [PATCH 143/278] DEV: Better diagnostic when topic tracking state tests fail. --- spec/models/topic_tracking_state_spec.rb | 56 +++++++++++++++++------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/spec/models/topic_tracking_state_spec.rb b/spec/models/topic_tracking_state_spec.rb index 6b35ff55a6..bbeefa368f 100644 --- a/spec/models/topic_tracking_state_spec.rb +++ b/spec/models/topic_tracking_state_spec.rb @@ -108,10 +108,16 @@ describe TopicTrackingState do ) end - expect(messages.count).to eq(3) + expect(messages.map(&:channel)).to contain_exactly( + '/private-messages/inbox', + "/private-messages/group/#{group1.name}", + "/private-messages/group/#{group2.name}" + ) + + message = messages.find do |message| + message.channel == '/private-messages/inbox' + end - message = messages.first - expect(message.channel).to eq('/private-messages/inbox') expect(message.data["topic_id"]).to eq(private_message_topic.id) expect(message.user_ids).to eq(private_message_topic.allowed_users.map(&:id)) @@ -134,10 +140,18 @@ describe TopicTrackingState do ) end - expect(messages.count).to eq(5) + expect(messages.map(&:channel)).to contain_exactly( + '/private-messages/inbox', + "/private-messages/group/#{group1.name}", + "/private-messages/group/#{group1.name}/archive", + "/private-messages/group/#{group2.name}", + "/private-messages/group/#{group2.name}/archive", + ) + + message = messages.find do |message| + message.channel == '/private-messages/inbox' + end - message = messages.first - expect(message.channel).to eq('/private-messages/inbox') expect(message.data["topic_id"]).to eq(private_message_topic.id) expect(message.user_ids).to eq(private_message_topic.allowed_users.map(&:id)) @@ -149,7 +163,7 @@ describe TopicTrackingState do "#{group_channel}/archive" ].each do |channel| message = messages.find do |message| - message.channel == "/private-messages/group/#{group.name}" + message.channel == channel end expect(message.data["topic_id"]).to eq(private_message_topic.id) @@ -184,13 +198,19 @@ describe TopicTrackingState do ) end - expect(messages.count).to eq(3) + expected_channels = [ + '/private-messages/inbox', + '/private-messages/sent', + "/private-messages/group/#{group.name}" + ] - [ - ['/private-messages/inbox', private_message_topic.allowed_users.map(&:id)], - ['/private-messages/sent', [user.id]], - ["/private-messages/group/#{group.name}", [group.users.first.id]] - ].each do |channel, user_ids| + expect(messages.map(&:channel)).to contain_exactly(*expected_channels) + + expected_channels.zip([ + private_message_topic.allowed_users.map(&:id), + [user.id], + [group.users.first.id] + ]).each do |channel, user_ids| message = messages.find do |message| message.channel == channel end @@ -210,13 +230,15 @@ describe TopicTrackingState do ) end - expect(messages.count).to eq(3) - - [ + expected_channels = [ "/private-messages/archive", "/private-messages/inbox", "/private-messages/sent", - ].each do |channel| + ] + + expect(messages.map(&:channel)).to eq(expected_channels) + + expected_channels.each do |channel| message = messages.find do |message| message.channel = channel end From 5ec896d163b71125fa11c7e07de6994441cd2b46 Mon Sep 17 00:00:00 2001 From: Joe <33972521+hnb-ku@users.noreply.github.com> Date: Thu, 24 May 2018 16:15:57 +0800 Subject: [PATCH 144/278] FIX: align categories with menu panel links --- app/assets/stylesheets/common/base/menu-panel.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss index 013a94da3a..c724aea82d 100644 --- a/app/assets/stylesheets/common/base/menu-panel.scss +++ b/app/assets/stylesheets/common/base/menu-panel.scss @@ -76,8 +76,9 @@ float: left; background-color: transparent; display: inline-flex; - margin: 0.25em 0.5em; - width: 43%; + padding: 0.25em 0.5em; + width: 50%; + box-sizing: border-box; .badge-notification { color: dark-light-choose($primary-medium, $secondary-medium); background-color: transparent; From 3bfd9698c720ad5c3fed76dc2a38a8a899f4b758 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Thu, 24 May 2018 16:41:51 +0800 Subject: [PATCH 145/278] PERF: Avoid running the same query twice in `TopicViewSerializer#details`. --- app/models/topic.rb | 5 +++++ lib/guardian/topic_guardian.rb | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/models/topic.rb b/app/models/topic.rb index 369d0e3367..ede59422bd 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -446,6 +446,7 @@ class Topic < ActiveRecord::Base @post_numbers = nil @public_topic_timer = nil @private_topic_timer = nil + @is_category_topic = nil super(options) end @@ -1347,6 +1348,10 @@ SQL private_messages.with_subtype(topic_subtype).where('topics.created_at >= ? AND topics.created_at <= ?', start_date, end_date).group('date(topics.created_at)').order('date(topics.created_at)').count end + def is_category_topic? + @is_category_topic ||= Category.exists?(topic_id: self.id.to_i) + end + private def update_category_topic_count_by(num) diff --git a/lib/guardian/topic_guardian.rb b/lib/guardian/topic_guardian.rb index 0d75e290d8..d3b6052180 100644 --- a/lib/guardian/topic_guardian.rb +++ b/lib/guardian/topic_guardian.rb @@ -86,15 +86,15 @@ module TopicGuardian def can_delete_topic?(topic) !topic.trashed? && is_staff? && - !(Category.exists?(topic_id: topic.id)) && + !(topic.is_category_topic?) && !Discourse.static_doc_topic_ids.include?(topic.id) end def can_convert_topic?(topic) return false unless SiteSetting.enable_personal_messages? return false if topic.blank? - return false if topic && topic.trashed? - return false if Category.where("topic_id = ?", topic.id).exists? + return false if topic.trashed? + return false if topic.is_category_topic? return true if is_admin? is_moderator? && can_create_post?(topic) end From 54a9073d9476bce18d575a61be047a4abc27c733 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 24 May 2018 10:56:04 +0200 Subject: [PATCH 146/278] FIX: do not cook local date without attributes --- .../lib/discourse-markdown/discourse-local-dates.js.es6 | 2 +- .../discourse-local-dates/spec/lib/pretty_text_spec.rb | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/discourse-local-dates.js.es6 b/plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/discourse-local-dates.js.es6 index b6bc29ba2f..888bb747b7 100644 --- a/plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/discourse-local-dates.js.es6 +++ b/plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/discourse-local-dates.js.es6 @@ -69,7 +69,7 @@ export function setup(helper) { helper.registerPlugin(md => { const rule = { - matcher: /\[date(.*?)\]/, + matcher: /\[date(.+?)\]/, onMatch: addLocalDate }; diff --git a/plugins/discourse-local-dates/spec/lib/pretty_text_spec.rb b/plugins/discourse-local-dates/spec/lib/pretty_text_spec.rb index 15a9bd8dde..f81f8084f9 100644 --- a/plugins/discourse-local-dates/spec/lib/pretty_text_spec.rb +++ b/plugins/discourse-local-dates/spec/lib/pretty_text_spec.rb @@ -26,4 +26,12 @@ describe PrettyText do expect(PrettyText.format_for_email(cooked)).to match_html(cooked_mail) end + + it 'needs attributes to convert to a local date' do + cooked = PrettyText.cook <<~MD + [date] + MD + + expect(cooked).to include("

    [date]

    ") + end end From cafd1241b97b27226a7af49c859c72fcd8adfde7 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Thu, 24 May 2018 17:14:08 +0530 Subject: [PATCH 147/278] FIX: make report data export more resilient to inconsistent date format The DAU/MAU report export was failing because of date being a string and not a valid Date value --- app/jobs/regular/export_csv_file.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/jobs/regular/export_csv_file.rb b/app/jobs/regular/export_csv_file.rb index a95e43a235..2ebfb75df7 100644 --- a/app/jobs/regular/export_csv_file.rb +++ b/app/jobs/regular/export_csv_file.rb @@ -183,11 +183,11 @@ module Jobs report_hash = {} Report.find(@extra[:name], @extra).data.each do |row| - report_hash[row[:x].to_s(:db)] = row[:y].to_s(:db) + report_hash[row[:x].to_s] = row[:y].to_s end (@extra[:start_date].to_date..@extra[:end_date].to_date).each do |date| - yield [date.to_s(:db), report_hash.fetch(date.to_s(:db), 0)] + yield [date.to_s(:db), report_hash.fetch(date.to_s, 0)] end end From c5dbfe18862764a1ffc92bc4824f0f5daff22b99 Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 24 May 2018 11:08:13 -0400 Subject: [PATCH 148/278] liked icon should use d-icon --- app/assets/javascripts/discourse/widgets/post-menu.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/widgets/post-menu.js.es6 b/app/assets/javascripts/discourse/widgets/post-menu.js.es6 index 7b177a39e6..7a0f643fa2 100644 --- a/app/assets/javascripts/discourse/widgets/post-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-menu.js.es6 @@ -69,7 +69,7 @@ registerButton('like-count', attrs => { const title = attrs.liked ? count === 1 ? 'post.has_likes_title_only_you' : 'post.has_likes_title_you' : 'post.has_likes_title'; - const icon = attrs.yours ? 'heart' : ''; + const icon = attrs.yours ? 'd-liked' : ''; const additionalClass = attrs.yours ? 'my-likes' : 'regular-likes'; return { action: 'toggleWhoLiked', From 71f66cd679fdeba28567c10bee064dc1b97dfd0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Thu, 24 May 2018 17:27:43 +0200 Subject: [PATCH 149/278] FIX: ensure PostAlerter is always run in sidekiq --- app/jobs/regular/notify_category_change.rb | 15 +++++ app/jobs/regular/post_alert.rb | 9 ++- app/models/topic.rb | 19 ++----- app/services/post_action_notifier.rb | 3 - app/services/post_alerter.rb | 65 ++++++++++------------ lib/post_jobs_enqueuer.rb | 6 +- lib/post_revisor.rb | 2 +- 7 files changed, 60 insertions(+), 59 deletions(-) create mode 100644 app/jobs/regular/notify_category_change.rb diff --git a/app/jobs/regular/notify_category_change.rb b/app/jobs/regular/notify_category_change.rb new file mode 100644 index 0000000000..ed8a55c7f7 --- /dev/null +++ b/app/jobs/regular/notify_category_change.rb @@ -0,0 +1,15 @@ +require_dependency "post_alerter" + +module Jobs + class NotifyCategoryChange < Jobs::Base + def execute(args) + post = Post.find_by(id: args[:post_id]) + + if post&.topic + post_alerter = PostAlerter.new + post_alerter.notify_post_users(post, User.where(id: args[:notified_user_ids])) + post_alerter.notify_first_post_watchers(post, post_alerter.category_watchers(post.topic)) + end + end + end +end diff --git a/app/jobs/regular/post_alert.rb b/app/jobs/regular/post_alert.rb index 6fb5fb0616..6f5fc2523c 100644 --- a/app/jobs/regular/post_alert.rb +++ b/app/jobs/regular/post_alert.rb @@ -4,9 +4,12 @@ module Jobs class PostAlert < Jobs::Base def execute(args) - # maybe it was removed by the time we are making the post - post = Post.where(id: args[:post_id]).first - PostAlerter.post_created(post, args[:options] || {}) if post && post.topic + post = Post.find_by(id: args[:post_id]) + if post&.topic + opts = args[:options] || {} + new_record = true == args[:new_record] + PostAlerter.new(opts).after_save_post(post, new_record) + end end end diff --git a/app/models/topic.rb b/app/models/topic.rb index ede59422bd..78cc39584e 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -675,19 +675,9 @@ SQL CategoryUser.auto_watch(category_id: new_category.id, topic_id: self.id) CategoryUser.auto_track(category_id: new_category.id, topic_id: self.id) - post = self.ordered_posts.first - - if post - post_alerter = PostAlerter.new - - post_alerter.notify_post_users( - post, - [post.user, post.last_editor].uniq - ) - - post_alerter.notify_first_post_watchers( - post, post_alerter.category_watchers(self) - ) + if post = self.ordered_posts.first + notified_user_ids = [post.user_id, post.last_editor_id].uniq + Jobs.enqueue(:notify_category_change, post_id: post.id, notified_user_ids: notified_user_ids) end end @@ -788,8 +778,7 @@ SQL last_post = posts.order('post_number desc').where('not hidden AND posts.deleted_at IS NULL').first if last_post - # ensure all the notifications are out - PostAlerter.new.after_save_post(last_post) + Jobs.enqueue(:post_alert, post_id: last_post.id) add_small_action(user, "invited_group", group.name) group_id = group.id diff --git a/app/services/post_action_notifier.rb b/app/services/post_action_notifier.rb index a25524b543..cc42feec42 100644 --- a/app/services/post_action_notifier.rb +++ b/app/services/post_action_notifier.rb @@ -41,7 +41,6 @@ class PostActionNotifier end def self.post_action_deleted(post_action) - return if @disabled # We only care about deleting post actions for now @@ -69,7 +68,6 @@ class PostActionNotifier end def self.post_action_created(post_action) - return if @disabled # We only notify on likes for now @@ -89,7 +87,6 @@ class PostActionNotifier end def self.after_create_post_revision(post_revision) - return if @disabled post = post_revision.post diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 240d817c59..f05754357d 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -134,11 +134,9 @@ class PostAlerter user_ids -= [post.user_id] users = User.where(id: user_ids) - Scheduler::Defer.later("Notify First Post Watchers") do - DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) - users.each do |user| - create_notification(user, Notification.types[:watching_first_post], post) - end + DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) + users.each do |user| + create_notification(user, Notification.types[:watching_first_post], post) end end @@ -406,6 +404,7 @@ class PostAlerter DiscourseEvent.trigger(:post_notification_alert, user, payload) end end + created.id ? created : nil end @@ -486,11 +485,9 @@ class PostAlerter users = [users] unless users.is_a?(Array) users = users.reject { |u| u.staged? } if post.topic&.private_message? - Scheduler::Defer.later("Notify Users") do - DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) - users.each do |u| - create_notification(u, Notification.types[type], post, opts) - end + DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) + users.each do |u| + create_notification(u, Notification.types[type], post, opts) end users @@ -499,28 +496,26 @@ class PostAlerter def notify_pm_users(post, reply_to_user, notified) return unless post.topic - Scheduler::Defer.later("Notify PM Users") do - # users that aren't part of any mentioned groups - users = directly_targeted_users(post).reject { |u| notified.include?(u) } - DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) - users.each do |user| - notification_level = TopicUser.get(post.topic, user)&.notification_level - if reply_to_user == user || notification_level == TopicUser.notification_levels[:watching] || user.staged? - create_notification(user, Notification.types[:private_message], post) - end + # users that aren't part of any mentioned groups + users = directly_targeted_users(post).reject { |u| notified.include?(u) } + DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) + users.each do |user| + notification_level = TopicUser.get(post.topic, user)&.notification_level + if reply_to_user == user || notification_level == TopicUser.notification_levels[:watching] || user.staged? + create_notification(user, Notification.types[:private_message], post) end + end - # users that are part of all mentionned groups - users = indirectly_targeted_users(post).reject { |u| notified.include?(u) } - DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) - users.each do |user| - case TopicUser.get(post.topic, user)&.notification_level - when TopicUser.notification_levels[:watching] - # only create a notification when watching the group - create_notification(user, Notification.types[:private_message], post) - when TopicUser.notification_levels[:tracking] - notify_group_summary(user, post) - end + # users that are part of all mentionned groups + users = indirectly_targeted_users(post).reject { |u| notified.include?(u) } + DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) + users.each do |user| + case TopicUser.get(post.topic, user)&.notification_level + when TopicUser.notification_levels[:watching] + # only create a notification when watching the group + create_notification(user, Notification.types[:private_message], post) + when TopicUser.notification_levels[:tracking] + notify_group_summary(user, post) end end end @@ -575,12 +570,10 @@ class PostAlerter exclude_user_ids = notified.map(&:id) notify = notify.where("id NOT IN (?)", exclude_user_ids) if exclude_user_ids.present? - Scheduler::Defer.later("Notify Post Users") do - DiscourseEvent.trigger(:before_create_notifications_for_users, notify, post) - notify.pluck(:id).each do |user_id| - user = User.find_by(id: user_id) - create_notification(user, Notification.types[:posted], post) - end + DiscourseEvent.trigger(:before_create_notifications_for_users, notify, post) + notify.pluck(:id).each do |user_id| + user = User.find_by(id: user_id) + create_notification(user, Notification.types[:posted], post) end end diff --git a/lib/post_jobs_enqueuer.rb b/lib/post_jobs_enqueuer.rb index 898743e44a..2c4ac7218b 100644 --- a/lib/post_jobs_enqueuer.rb +++ b/lib/post_jobs_enqueuer.rb @@ -26,7 +26,11 @@ class PostJobsEnqueuer private def enqueue_post_alerts - Jobs.enqueue(:post_alert, post_id: @post.id, options: @opts[:post_alert_options]) + Jobs.enqueue(:post_alert, + post_id: @post.id, + new_record: true, + options: @opts[:post_alert_options], + ) end def feature_topic_users diff --git a/lib/post_revisor.rb b/lib/post_revisor.rb index efa718787d..81ab76494a 100644 --- a/lib/post_revisor.rb +++ b/lib/post_revisor.rb @@ -577,7 +577,7 @@ class PostRevisor def alert_users return if @editor.id == Discourse::SYSTEM_USER_ID - PostAlerter.new.after_save_post(@post) + Jobs.enqueue(:post_alert, post_id: @post.id) end def publish_changes From bb8f0087f417d873f4cef5155753730326ee0598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Thu, 24 May 2018 17:52:59 +0200 Subject: [PATCH 150/278] Add a warning when PostAlerter isn't running in sidekiq --- app/services/post_alerter.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index f05754357d..3f151f875c 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -130,6 +130,8 @@ class PostAlerter return if user_ids.blank? user_ids.uniq! + warn_if_not_sidekiq + # Don't notify the OP user_ids -= [post.user_id] users = User.where(id: user_ids) @@ -485,6 +487,8 @@ class PostAlerter users = [users] unless users.is_a?(Array) users = users.reject { |u| u.staged? } if post.topic&.private_message? + warn_if_not_sidekiq + DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) users.each do |u| create_notification(u, Notification.types[type], post, opts) @@ -496,6 +500,8 @@ class PostAlerter def notify_pm_users(post, reply_to_user, notified) return unless post.topic + warn_if_not_sidekiq + # users that aren't part of any mentioned groups users = directly_targeted_users(post).reject { |u| notified.include?(u) } DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) @@ -523,6 +529,8 @@ class PostAlerter def notify_post_users(post, notified) return unless post.topic + warn_if_not_sidekiq + condition = <<~SQL id IN ( SELECT user_id @@ -577,4 +585,8 @@ class PostAlerter end end + def warn_if_not_sidekiq + Rails.logger.warn("PostAlerter.#{caller_locations(1, 1)[0].label} was called outside of sidekiq") unless Sidekiq.server? + end + end From a31a458a67f712542ee1711d087f2cace501b2e3 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 24 May 2018 12:29:34 -0400 Subject: [PATCH 151/278] Trivially fix some odd whitespace, indentation --- .../discourse/templates/group-activity-posts.hbs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/group-activity-posts.hbs b/app/assets/javascripts/discourse/templates/group-activity-posts.hbs index 8aea9bf7c4..7599c31cf2 100644 --- a/app/assets/javascripts/discourse/templates/group-activity-posts.hbs +++ b/app/assets/javascripts/discourse/templates/group-activity-posts.hbs @@ -1,11 +1,10 @@ {{#load-more selector=".user-stream-item" action="loadMore"}}
    - {{#each model as |post|}} - {{group-post post=post class="user-stream-item item"}} - {{else}} -
    {{i18n emptyText}}
    - {{/each}} + {{#each model as |post|}} + {{group-post post=post class="user-stream-item item"}} + {{else}} +
    {{i18n emptyText}}
    + {{/each}}
    {{conditional-loading-spinner condition=loading}} {{/load-more}} - From 30fbf6fe8181426ff8cab81d10a26c61f635f4d6 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Thu, 24 May 2018 14:39:28 -0400 Subject: [PATCH 152/278] Add min and max to digest topic and post settings. Email clients may truncate messages that are too long. --- config/site_settings.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/config/site_settings.yml b/config/site_settings.yml index def3802eaf..d0a6bae3dc 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -717,8 +717,15 @@ email: digest_topics: default: 5 min: 1 - digest_posts: 3 - digest_other_topics: 5 + max: 20 + digest_posts: + default: 5 + min: 0 + max: 20 + digest_other_topics: + default: 5 + min: 0 + max: 20 suppress_digest_email_after_days: default: 365 digest_suppress_categories: From 1be76d066c99ef0b91cd01a3bebec4310dc35b0f Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 24 May 2018 23:41:39 +0200 Subject: [PATCH 153/278] FIX: forces boolean when content is only "true" && "false" --- .../components/edit-category-settings.hbs | 2 +- .../select-kit/components/multi-select.js.es6 | 2 +- .../select-kit/components/select-kit.js.es6 | 3 ++- .../components/single-select.js.es6 | 2 +- .../select-kit/mixins/utils.js.es6 | 13 +++++++++ .../components/single-select-test.js.es6 | 27 +++++++++++++++++++ 6 files changed, 45 insertions(+), 4 deletions(-) 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 2e657aa156..36e6bfc0b4 100644 --- a/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs +++ b/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs @@ -69,7 +69,7 @@
    {{combo-box valueAttribute="value" content=availableSorts value=category.sort_order none="category.sort_options.default"}} {{#unless isDefaultSortOrder}} - {{combo-box valueAttribute="value" content=sortAscendingOptions value=category.sort_ascending none="category.sort_options.default"}} + {{combo-box castBoolean=true valueAttribute="value" content=sortAscendingOptions value=category.sort_ascending none="category.sort_options.default"}} {{/unless}}
    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 9a9453445b..8e7622e1e3 100644 --- a/app/assets/javascripts/select-kit/components/multi-select.js.es6 +++ b/app/assets/javascripts/select-kit/components/multi-select.js.es6 @@ -79,7 +79,7 @@ export default SelectKitComponent.extend({ shouldDisplayFilter() { return true; }, _beforeWillComputeValues(values) { - return values.map(v => this._castInteger(v === "" ? null : v)); + return values.map(v => this._cast(v === "" ? null : v)); }, willComputeValues(values) { return values; }, computeValues(values) { return values; }, 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 a45da128fd..172bd9e4b1 100644 --- a/app/assets/javascripts/select-kit/components/select-kit.js.es6 +++ b/app/assets/javascripts/select-kit/components/select-kit.js.es6 @@ -62,6 +62,7 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi horizontalOffset: 0, fullWidthOnMobile: false, castInteger: false, + castBoolean: false, allowAny: false, allowInitialValueMutation: false, content: null, @@ -169,7 +170,7 @@ export default Ember.Component.extend(UtilsMixin, PluginApiMixin, DomHelpersMixi } let computedContentItem = { - value: this._castInteger(this.valueForContentItem(contentItem)), + value: this._cast(this.valueForContentItem(contentItem)), name: name || this._nameForContent(contentItem), locked: false, created: options.created || false, 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 65e73840ad..e10ae018aa 100644 --- a/app/assets/javascripts/select-kit/components/single-select.js.es6 +++ b/app/assets/javascripts/select-kit/components/single-select.js.es6 @@ -63,7 +63,7 @@ export default SelectKitComponent.extend({ switch (typeof value) { case "string": case "number": - return this._castInteger(value === "" ? null : value); + return this._cast(value === "" ? null : value); default: return value; } diff --git a/app/assets/javascripts/select-kit/mixins/utils.js.es6 b/app/assets/javascripts/select-kit/mixins/utils.js.es6 index 11f4418cf4..ad61c7926b 100644 --- a/app/assets/javascripts/select-kit/mixins/utils.js.es6 +++ b/app/assets/javascripts/select-kit/mixins/utils.js.es6 @@ -27,6 +27,19 @@ export default Ember.Mixin.create({ return !isNaN(parseFloat(input)) && isFinite(input); }, + _cast(value) { + if (value === this.noneValue) return value; + return this._castInteger(this._castBoolean(value)); + }, + + _castBoolean(value) { + if (this.get("castBoolean") && Ember.isPresent(value) && typeof(value) === "string") { + return value === "true"; + } + + return value; + }, + _castInteger(value) { if (this.get("castInteger") && Ember.isPresent(value) && this._isNumeric(value)) { return parseInt(value, 10); diff --git a/test/javascripts/components/single-select-test.js.es6 b/test/javascripts/components/single-select-test.js.es6 index e7a1080290..671896bc18 100644 --- a/test/javascripts/components/single-select-test.js.es6 +++ b/test/javascripts/components/single-select-test.js.es6 @@ -232,6 +232,33 @@ componentTest('supports converting select value to integer', { } }); +componentTest('supports converting string as boolean to boolean', { + template: '{{single-select value=value content=content castBoolean=true}}', + + beforeEach() { + this.set('value', true); + this.set('content', [{ id: 'true', name: 'ASC'}, {id: 'false', name: 'DESC' }]); + }, + + test(assert) { + this.get('subject').expand(); + + andThen(() => assert.equal(this.get('subject').selectedRow().name(), 'ASC') ); + + andThen(() => { + this.set('value', false); + }); + + andThen(() => { + assert.equal( + this.get('subject').selectedRow().name(), + 'DESC', + 'it works with dynamic content' + ); + }); + } +}); + componentTest('supports keyboard events', { template: '{{single-select content=content filterable=true}}', From 58fbe74b6f40685f172b2a658f8252feba53c74f Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 25 May 2018 09:56:35 +0800 Subject: [PATCH 154/278] Remove assertion `to_not raise_error`. If an error is raised the test would fail anyway. --- spec/jobs/export_csv_file_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/jobs/export_csv_file_spec.rb b/spec/jobs/export_csv_file_spec.rb index d1ffb5edc1..1852556da2 100644 --- a/spec/jobs/export_csv_file_spec.rb +++ b/spec/jobs/export_csv_file_spec.rb @@ -11,7 +11,8 @@ describe Jobs::ExportCsvFile do it 'works' do begin - expect { Jobs::ExportCsvFile.new.execute(user_id: user.id, entity: "user_archive") }.to_not raise_error + Jobs::ExportCsvFile.new.execute(user_id: user.id, entity: "user_archive") + expect(user.topics_allowed.last.title).to eq(I18n.t( "system_messages.csv_export_succeeded.subject_template", export_title: "User Archive" From bac0482061fd6eb2101a2ee3898de5f74e73f2ec Mon Sep 17 00:00:00 2001 From: OsamaSayegh Date: Fri, 25 May 2018 05:04:25 +0300 Subject: [PATCH 155/278] REFACTOR: users contollers specs => request specs --- spec/controllers/users_controller_spec.rb | 2466 --------------------- spec/requests/users_controller_spec.rb | 2399 +++++++++++++++++++- 2 files changed, 2362 insertions(+), 2503 deletions(-) delete mode 100644 spec/controllers/users_controller_spec.rb diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb deleted file mode 100644 index 3b5e60c131..0000000000 --- a/spec/controllers/users_controller_spec.rb +++ /dev/null @@ -1,2466 +0,0 @@ -require 'rails_helper' - -describe UsersController do - - describe '.show' do - - context "anon" do - - let(:user) { Discourse.system_user } - - it "returns success" do - get :show, params: { username: user.username }, format: :json - expect(response).to be_success - end - - it "should redirect to login page for anonymous user when profiles are hidden" do - SiteSetting.hide_user_profiles_from_public = true - get :show, params: { username: user.username }, format: :json - expect(response).to redirect_to '/login' - end - - end - - context "logged in" do - - let(:user) { log_in } - - it 'returns success' do - get :show, params: { username: user.username, format: :json }, format: :json - expect(response).to be_success - json = JSON.parse(response.body) - - expect(json["user"]["has_title_badges"]).to eq(false) - end - - it "returns not found when the username doesn't exist" do - get :show, params: { username: 'madeuppity' }, format: :json - expect(response).not_to be_success - end - - it 'returns not found when the user is inactive' do - inactive = Fabricate(:user, active: false) - get :show, params: { username: inactive.username }, format: :json - expect(response).not_to be_success - end - - it 'returns success when show_inactive_accounts is true and user is logged in' do - SiteSetting.show_inactive_accounts = true - log_in_user(user) - inactive = Fabricate(:user, active: false) - get :show, params: { username: inactive.username }, format: :json - expect(response).to be_success - end - - it "raises an error on invalid access" do - Guardian.any_instance.expects(:can_see?).with(user).returns(false) - get :show, params: { username: user.username }, format: :json - expect(response).to be_forbidden - end - - describe "user profile views" do - let(:other_user) { Fabricate(:user) } - - it "should track a user profile view for a signed in user" do - UserProfileView.expects(:add).with(other_user.user_profile.id, request.remote_ip, user.id) - get :show, params: { username: other_user.username }, format: :json - end - - it "should not track a user profile view for a user viewing his own profile" do - UserProfileView.expects(:add).never - get :show, params: { username: user.username }, format: :json - end - - it "should track a user profile view for an anon user" do - UserProfileView.expects(:add).with(other_user.user_profile.id, request.remote_ip, nil) - get :show, params: { username: other_user.username }, format: :json - end - - it "skips tracking" do - UserProfileView.expects(:add).never - get :show, params: { username: user.username, skip_track_visit: true }, format: :json - end - end - - context "fetching a user by external_id" do - before { user.create_single_sign_on_record(external_id: '997', last_payload: '') } - - it "returns fetch for a matching external_id" do - get :show, params: { external_id: '997' }, format: :json - expect(response).to be_success - end - - it "returns not found when external_id doesn't match" do - get :show, params: { external_id: '99' }, format: :json - expect(response).not_to be_success - end - end - - describe "include_post_count_for" do - - let(:admin) { Fabricate(:admin) } - let(:topic) { Fabricate(:topic) } - - before do - Fabricate(:post, user: user, topic: topic) - Fabricate(:post, user: admin, topic: topic) - Fabricate(:post, user: admin, topic: topic, post_type: Post.types[:whisper]) - end - - it "includes only visible posts" do - get :show, - params: { username: admin.username, include_post_count_for: topic.id }, - format: :json - - topic_post_count = JSON.parse(response.body).dig("user", "topic_post_count") - expect(topic_post_count[topic.id.to_s]).to eq(1) - end - - it "includes all post types for staff members" do - log_in_user(admin) - - get :show, - params: { username: admin.username, include_post_count_for: topic.id }, - format: :json - - topic_post_count = JSON.parse(response.body).dig("user", "topic_post_count") - expect(topic_post_count[topic.id.to_s]).to eq(2) - end - end - end - end - - describe '.activate_account' do - before do - UsersController.any_instance.stubs(:honeypot_or_challenge_fails?).returns(false) - end - - context 'invalid token' do - - it 'return success' do - EmailToken.expects(:confirm).with('asdfasdf').returns(nil) - put :perform_account_activation, params: { token: 'asdfasdf' } - expect(response).to be_success - expect(flash[:error]).to be_present - end - end - - context 'valid token' do - let(:user) { Fabricate(:user) } - - context 'welcome message' do - before do - EmailToken.expects(:confirm).with('asdfasdf').returns(user) - end - - it 'enqueues a welcome message if the user object indicates so' do - user.send_welcome_message = true - user.expects(:enqueue_welcome_message).with('welcome_user') - - put :perform_account_activation, params: { token: 'asdfasdf' } - end - - it "doesn't enqueue the welcome message if the object returns false" do - user.send_welcome_message = false - user.expects(:enqueue_welcome_message).with('welcome_user').never - - put :perform_account_activation, params: { token: 'asdfasdf' } - end - end - - context "honeypot" do - it "raises an error if the honeypot is invalid" do - UsersController.any_instance.stubs(:honeypot_or_challenge_fails?).returns(true) - put :perform_account_activation, params: { token: 'asdfasdf' }, format: :json - expect(response).not_to be_success - end - end - - context 'response' do - render_views - - before do - Guardian.any_instance.expects(:can_access_forum?).returns(true) - EmailToken.expects(:confirm).with('asdfasdf').returns(user) - end - - it 'correctly logs on user' do - events = DiscourseEvent.track_events do - put :perform_account_activation, params: { token: 'asdfasdf' } - end - - expect(events.map { |event| event[:event_name] }).to include( - :user_logged_in, :user_first_logged_in - ) - - expect(response).to be_success - expect(flash[:error]).to be_blank - expect(session[:current_user_id]).to be_present - - expect(response).to be_success - - expect(CGI.unescapeHTML(response.body)) - .to_not include(I18n.t('activation.approval_required')) - end - - end - - context 'user is not approved' do - render_views - - before do - SiteSetting.must_approve_users = true - EmailToken.expects(:confirm).with('asdfasdf').returns(user) - put :perform_account_activation, params: { token: 'asdfasdf' } - end - - it 'should return the right response' do - expect(response).to be_success - - expect(CGI.unescapeHTML(response.body)) - .to include(I18n.t('activation.approval_required')) - - expect(response.body).to_not have_tag(:script, with: { - src: '/assets/application.js' - }) - - expect(flash[:error]).to be_blank - expect(session[:current_user_id]).to be_blank - end - end - - end - end - - describe '#perform_account_activation' do - describe 'when cookies contains a destination URL' do - let(:token) { 'asdadwewq' } - let(:user) { Fabricate(:user) } - - before do - UsersController.any_instance.stubs(:honeypot_or_challenge_fails?).returns(false) - EmailToken.expects(:confirm).with(token).returns(user) - end - - it 'should redirect to the URL' do - destination_url = 'http://thisisasite.com/somepath' - request.cookies[:destination_url] = destination_url - - put :perform_account_activation, params: { token: token } - - expect(response).to redirect_to(destination_url) - end - end - end - - describe '.password_reset' do - let(:user) { Fabricate(:user) } - - context "you can view it even if login is required" do - it "returns success" do - SiteSetting.login_required = true - get :password_reset, params: { token: 'asdfasdf' } - expect(response).to be_success - end - end - - context 'missing token' do - render_views - - before do - get :password_reset, params: { token: SecureRandom.hex } - end - - it 'disallows login' do - expect(response).to be_success - - expect(CGI.unescapeHTML(response.body)) - .to include(I18n.t('password_reset.no_token')) - - expect(response.body).to_not have_tag(:script, with: { - src: '/assets/application.js' - }) - - expect(session[:current_user_id]).to be_blank - end - end - - context 'invalid token' do - render_views - - it 'disallows login' do - get :password_reset, params: { token: "evil_trout!" } - - expect(response).to be_success - - expect(CGI.unescapeHTML(response.body)) - .to include(I18n.t('password_reset.no_token')) - - expect(response.body).to_not have_tag(:script, with: { - src: '/assets/application.js' - }) - - expect(session[:current_user_id]).to be_blank - end - - it "responds with proper error message" do - put :password_reset, params: { - token: "evil_trout!", password: "awesomeSecretPassword" - }, format: :json - - expect(response).to be_success - expect(JSON.parse(response.body)["message"]).to eq(I18n.t('password_reset.no_token')) - expect(session[:current_user_id]).to be_blank - end - end - - context 'valid token' do - render_views - - context 'when rendered' do - it 'renders referrer never on get requests' do - user = Fabricate(:user) - token = user.email_tokens.create(email: user.email).token - get :password_reset, params: { token: token } - - expect(response.body).to include('') - end - end - - it 'returns success' do - user = Fabricate(:user) - user_auth_token = UserAuthToken.generate!(user_id: user.id) - token = user.email_tokens.create(email: user.email).token - get :password_reset, params: { token: token } - - events = DiscourseEvent.track_events do - put :password_reset, - params: { token: token, password: 'hg9ow8yhg98o' } - end - - expect(events.map { |event| event[:event_name] }).to include( - :user_logged_in, :user_first_logged_in - ) - - expect(response).to be_success - expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":false}') - - user.reload - - expect(session["password-#{token}"]).to be_blank - expect(UserAuthToken.where(id: user_auth_token.id).count).to eq(0) - end - - it 'disallows double password reset' do - user = Fabricate(:user) - token = user.email_tokens.create(email: user.email).token - - get :password_reset, params: { token: token } - - put :password_reset, - params: { token: token, password: 'hg9ow8yHG32O' } - - put :password_reset, - params: { token: token, password: 'test123987AsdfXYZ' } - - user.reload - expect(user.confirm_password?('hg9ow8yHG32O')).to eq(true) - - # logged in now - expect(user.user_auth_tokens.count).to eq(1) - end - - it "doesn't redirect to wizard on get" do - user = Fabricate(:admin) - UserAuthToken.generate!(user_id: user.id) - - token = user.email_tokens.create(email: user.email).token - get :password_reset, params: { token: token }, format: :json - expect(response).not_to redirect_to(wizard_path) - end - - it "redirects to the wizard if you're the first admin" do - user = Fabricate(:admin) - UserAuthToken.generate!(user_id: user.id) - - token = user.email_tokens.create(email: user.email).token - get :password_reset, params: { token: token } - - put :password_reset, params: { - token: token, password: 'hg9ow8yhg98oadminlonger' - } - - expect(response).to redirect_to(wizard_path) - end - - it "doesn't invalidate the token when loading the page" do - user = Fabricate(:user) - user_token = UserAuthToken.generate!(user_id: user.id) - - email_token = user.email_tokens.create(email: user.email) - - get :password_reset, params: { token: email_token.token }, format: :json - - email_token.reload - - expect(email_token.confirmed).to eq(false) - expect(UserAuthToken.where(id: user_token.id).count).to eq(1) - 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 - - 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 - let(:token) { EmailToken.generate_token } - - before do - EmailToken.expects(:confirm).with(token).returns(user) - end - - it "fails when the password is blank" do - put :password_reset, params: { - token: token, password: '' - }, format: :json - - expect(response).to be_success - expect(JSON.parse(response.body)["errors"]).to be_present - expect(session[:current_user_id]).to be_blank - end - - it "fails when the password is too long" do - put :password_reset, params: { - token: token, password: ('x' * (User.max_password_length + 1)) - }, format: :json - - expect(response).to be_success - expect(JSON.parse(response.body)["errors"]).to be_present - expect(session[:current_user_id]).to be_blank - end - - it "logs in the user" do - put :password_reset, params: { - token: token, password: 'ksjafh928r' - }, format: :json - - expect(response).to be_success - expect(JSON.parse(response.body)["errors"]).to be_blank - expect(session[:current_user_id]).to be_present - end - - it "doesn't log in the user when not approved" do - SiteSetting.must_approve_users = true - put :password_reset, params: { - token: token, password: 'ksjafh928r' - }, format: :json - - expect(JSON.parse(response.body)["errors"]).to be_blank - expect(session[:current_user_id]).to be_blank - end - end - end - - describe '.confirm_email_token' do - let(:user) { Fabricate(:user) } - - it "token doesn't match any records" do - email_token = user.email_tokens.create(email: user.email) - get :confirm_email_token, params: { token: SecureRandom.hex }, format: :json - expect(response).to be_success - expect(email_token.reload.confirmed).to eq(false) - end - - it "token matches" do - email_token = user.email_tokens.create(email: user.email) - get :confirm_email_token, params: { token: email_token.token }, format: :json - expect(response).to be_success - expect(email_token.reload.confirmed).to eq(true) - end - end - - describe '#admin_login' do - let(:admin) { Fabricate(:admin) } - let(:user) { Fabricate(:user) } - - context 'enqueues mail' do - it 'enqueues mail with admin email and sso enabled' do - Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :admin_login, user_id: admin.id)) - put :admin_login, params: { email: admin.email } - end - end - - context 'when email is incorrect' do - render_views - - it 'should return the right response' do - put :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" - SiteSetting.enable_sso = true - get :admin_login, params: { token: "invalid" } - expect(session[:current_user_id]).to be_blank - end - - context 'valid token' do - it 'does log in admin with SSO disabled' do - SiteSetting.enable_sso = false - token = admin.email_tokens.create(email: admin.email).token - - get :admin_login, params: { token: token } - expect(response).to redirect_to('/') - expect(session[:current_user_id]).to eq(admin.id) - end - - it 'logs in admin with SSO enabled' do - SiteSetting.sso_url = "https://www.example.com/sso" - SiteSetting.enable_sso = true - token = admin.email_tokens.create(email: admin.email).token - - get :admin_login, params: { token: token } - expect(response).to redirect_to('/') - expect(session[:current_user_id]).to eq(admin.id) - end - end - - 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 - 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 - - 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: email_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 - - describe '#toggle_anon' do - it 'allows you to toggle anon if enabled' do - SiteSetting.allow_anonymous_posting = true - - user = log_in - user.trust_level = 1 - user.save - - post :toggle_anon, format: :json - expect(response).to be_success - expect(session[:current_user_id]).to eq(AnonymousShadowCreator.get(user).id) - - post :toggle_anon, format: :json - expect(response).to be_success - expect(session[:current_user_id]).to eq(user.id) - - end - end - - describe '#create' do - - before do - UsersController.any_instance.stubs(:honeypot_value).returns(nil) - UsersController.any_instance.stubs(:challenge_value).returns(nil) - SiteSetting.allow_new_registrations = true - @user = Fabricate.build(:user) - @user.password = "strongpassword" - end - - let(:post_user_params) do - { name: @user.name, - username: @user.username, - password: "strongpassword", - email: @user.email } - end - - def post_user - post :create, params: post_user_params, format: :json - end - - context 'when email params is missing' do - it 'should raise the right error' do - expect do - post :create, params: { - name: @user.name, - username: @user.username, - passsword: 'tesing12352343' - }, format: :json - end.to raise_error(ActionController::ParameterMissing) - end - end - - context 'when creating a user' do - it 'sets the user locale to I18n.locale' do - SiteSetting.default_locale = 'en' - I18n.stubs(:locale).returns(:fr) - post_user - expect(User.find_by(username: @user.username).locale).to eq('fr') - end - end - - context 'when creating a non active user (unconfirmed email)' do - - it 'returns a 500 when local logins are disabled' do - SiteSetting.enable_local_logins = false - post_user - - expect(response.status).to eq(500) - end - - it 'returns an error when new registrations are disabled' do - SiteSetting.allow_new_registrations = false - post_user - json = JSON.parse(response.body) - expect(json['success']).to eq(false) - expect(json['message']).to be_present - end - - it 'creates a user correctly' do - Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :signup)) - User.any_instance.expects(:enqueue_welcome_message).with('welcome_user').never - - post_user - - expect(JSON.parse(response.body)['active']).to be_falsey - - # should save user_created_message in session - expect(session["user_created_message"]).to be_present - expect(session[SessionController::ACTIVATE_USER_KEY]).to be_present - end - - context "`must approve users` site setting is enabled" do - before { SiteSetting.must_approve_users = true } - - it 'creates a user correctly' do - Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :signup)) - User.any_instance.expects(:enqueue_welcome_message).with('welcome_user').never - - post_user - - expect(JSON.parse(response.body)['active']).to be_falsey - - # should save user_created_message in session - expect(session["user_created_message"]).to be_present - expect(session[SessionController::ACTIVATE_USER_KEY]).to be_present - end - end - - context 'users already exists with given email' do - let!(:existing) { Fabricate(:user, email: post_user_params[:email]) } - - it 'returns an error if hide_email_address_taken is disabled' do - SiteSetting.hide_email_address_taken = false - post_user - json = JSON.parse(response.body) - expect(json['success']).to eq(false) - expect(json['message']).to be_present - end - - it 'returns success if hide_email_address_taken is enabled' do - SiteSetting.hide_email_address_taken = true - expect { - post_user - }.to_not change { User.count } - json = JSON.parse(response.body) - expect(json['active']).to be_falsey - expect(session["user_created_message"]).to be_present - end - end - end - - context "creating as active" do - it "won't create the user as active" do - post :create, params: post_user_params.merge(active: true), format: :json - expect(JSON.parse(response.body)['active']).to be_falsey - end - - context "with a regular api key" do - let(:user) { Fabricate(:user) } - let(:api_key) { Fabricate(:api_key, user: user) } - - it "won't create the user as active with a regular key" do - post :create, - params: post_user_params.merge(active: true, api_key: api_key.key), - format: :json - - expect(JSON.parse(response.body)['active']).to be_falsey - end - end - - context "with an admin api key" do - let(:admin) { Fabricate(:admin) } - let(:api_key) { Fabricate(:api_key, user: admin) } - - it "creates the user as active with a regular key" do - SiteSetting.queue_jobs = true - SiteSetting.send_welcome_message = true - SiteSetting.must_approve_users = true - - Sidekiq::Client.expects(:enqueue).never - - post :create, - params: post_user_params.merge(approved: true, active: true, api_key: api_key.key), - format: :json - - json = JSON.parse(response.body) - - new_user = User.find(json["user_id"]) - - expect(json['active']).to be_truthy - - expect(new_user.active).to eq(true) - expect(new_user.approved).to eq(true) - expect(new_user.approved_by_id).to eq(admin.id) - expect(new_user.approved_at).to_not eq(nil) - end - - it "won't create the developer as active" do - UsernameCheckerService.expects(:is_developer?).returns(true) - - post :create, - params: post_user_params.merge(active: true, api_key: api_key.key), - format: :json - - expect(JSON.parse(response.body)['active']).to be_falsy - end - end - end - - context "creating as staged" do - it "won't create the user as staged" do - post :create, - params: post_user_params.merge(staged: true), - format: :json - - new_user = User.where(username: post_user_params[:username]).first - expect(new_user.staged?).to eq(false) - end - - context "with a regular api key" do - let(:user) { Fabricate(:user) } - let(:api_key) { Fabricate(:api_key, user: user) } - - it "won't create the user as staged with a regular key" do - post :create, - params: post_user_params.merge(staged: true, api_key: api_key.key), - format: :json - - new_user = User.where(username: post_user_params[:username]).first - expect(new_user.staged?).to eq(false) - end - end - - context "with an admin api key" do - let(:user) { Fabricate(:admin) } - let(:api_key) { Fabricate(:api_key, user: user) } - - it "creates the user as staged with a regular key" do - post :create, - params: post_user_params.merge(staged: true, api_key: api_key.key), - format: :json - - new_user = User.where(username: post_user_params[:username]).first - expect(new_user.staged?).to eq(true) - end - - it "won't create the developer as staged" do - UsernameCheckerService.expects(:is_developer?).returns(true) - post :create, - params: post_user_params.merge(staged: true, api_key: api_key.key), - format: :json - - new_user = User.where(username: post_user_params[:username]).first - expect(new_user.staged?).to eq(false) - end - end - end - - context 'when creating an active user (confirmed email)' do - before { User.any_instance.stubs(:active?).returns(true) } - - it 'enqueues a welcome email' do - User.any_instance.expects(:enqueue_welcome_message).with('welcome_user') - post_user - - # should save user_created_message in session - expect(session["user_created_message"]).to be_present - expect(session[SessionController::ACTIVATE_USER_KEY]).to be_present - end - - it "shows the 'active' message" do - User.any_instance.expects(:enqueue_welcome_message) - post_user - expect(JSON.parse(response.body)['message']).to eq( - I18n.t 'login.active' - ) - end - - it "should be logged in" do - User.any_instance.expects(:enqueue_welcome_message) - post_user - expect(session[:current_user_id]).to be_present - end - - it 'indicates the user is active in the response' do - User.any_instance.expects(:enqueue_welcome_message) - post_user - expect(JSON.parse(response.body)['active']).to be_truthy - end - - it 'returns 500 status when new registrations are disabled' do - SiteSetting.allow_new_registrations = false - - post_user - - json = JSON.parse(response.body) - expect(json['success']).to eq(false) - expect(json['message']).to be_present - end - - context 'authentication records for' do - - it 'should create twitter user info if required' do - SiteSetting.must_approve_users = true - SiteSetting.enable_twitter_logins = true - twitter_auth = { twitter_user_id: 42, twitter_screen_name: "bruce" } - auth = session[:authentication] = {} - auth[:authenticator_name] = 'twitter' - auth[:extra_data] = twitter_auth - - post_user - - expect(TwitterUserInfo.count).to eq(1) - end - end - - it "returns an error when email has been changed from the validated email address" do - auth = session[:authentication] = {} - auth[:email_valid] = 'true' - auth[:email] = 'therealone@gmail.com' - post_user - json = JSON.parse(response.body) - expect(json['success']).to eq(false) - expect(json['message']).to be_present - end - - it "will create the user successfully if email validation is required" do - auth = session[:authentication] = {} - auth[:email] = post_user_params[:email] - post_user - json = JSON.parse(response.body) - expect(json['success']).to eq(true) - end - end - - context 'after success' do - before { post_user } - - it 'should succeed' do - is_expected.to respond_with(:success) - end - - it 'has the proper JSON' do - json = JSON::parse(response.body) - expect(json["success"]).to eq(true) - end - - it 'should not result in an active account' do - expect(User.find_by(username: @user.username).active).to eq(false) - end - end - - shared_examples 'honeypot fails' do - it 'should not create a new user' do - expect { - post :create, params: create_params, format: :json - }.to_not change { User.count } - end - - it 'should not send an email' do - User.any_instance.expects(:enqueue_welcome_message).never - post :create, params: create_params, format: :json - end - - it 'should say it was successful' do - post :create, params: create_params, format: :json - json = JSON::parse(response.body) - expect(json["success"]).to eq(true) - - # should not change the session - expect(session["user_created_message"]).to be_blank - expect(session[SessionController::ACTIVATE_USER_KEY]).to be_blank - end - end - - context 'when honeypot value is wrong' do - before do - UsersController.any_instance.stubs(:honeypot_value).returns('abc') - end - let(:create_params) { { name: @user.name, username: @user.username, password: "strongpassword", email: @user.email, password_confirmation: 'wrong' } } - include_examples 'honeypot fails' - end - - context 'when challenge answer is wrong' do - before do - UsersController.any_instance.stubs(:challenge_value).returns('abc') - end - let(:create_params) { { name: @user.name, username: @user.username, password: "strongpassword", email: @user.email, challenge: 'abc' } } - include_examples 'honeypot fails' - end - - context "when 'invite only' setting is enabled" do - before { SiteSetting.invite_only = true } - - let(:create_params) { { - name: @user.name, - username: @user.username, - password: 'strongpassword', - email: @user.email - }} - - include_examples 'honeypot fails' - end - - shared_examples 'failed signup' do - it 'should not create a new User' do - expect { post :create, params: create_params, format: :json }.to_not change { User.count } - end - - it 'should report failed' do - post :create, params: create_params, format: :json - json = JSON::parse(response.body) - expect(json["success"]).not_to eq(true) - - # should not change the session - expect(session["user_created_message"]).to be_blank - expect(session[SessionController::ACTIVATE_USER_KEY]).to be_blank - end - end - - context 'when password is blank' do - let(:create_params) { { name: @user.name, username: @user.username, password: "", email: @user.email } } - include_examples 'failed signup' - end - - context 'when password is too long' do - let(:create_params) { { name: @user.name, username: @user.username, password: "x" * (User.max_password_length + 1), email: @user.email } } - include_examples 'failed signup' - end - - context 'when password param is missing' do - let(:create_params) { { name: @user.name, username: @user.username, email: @user.email } } - include_examples 'failed signup' - end - - context 'with a reserved username' do - let(:create_params) { { name: @user.name, username: 'Reserved', email: @user.email, password: "x" * 20 } } - before { SiteSetting.reserved_usernames = 'a|reserved|b' } - after { SiteSetting.reserved_usernames = nil } - include_examples 'failed signup' - end - - context 'when an Exception is raised' do - before { User.any_instance.stubs(:save).raises(ActiveRecord::StatementInvalid.new('Oh no')) } - - let(:create_params) { - { name: @user.name, username: @user.username, - password: "strongpassword", email: @user.email } - } - - include_examples 'failed signup' - end - - context "with custom fields" do - let!(:user_field) { Fabricate(:user_field) } - let!(:another_field) { Fabricate(:user_field) } - let!(:optional_field) { Fabricate(:user_field, required: false) } - - context "without a value for the fields" do - let(:create_params) { { name: @user.name, password: 'watwatwat', username: @user.username, email: @user.email } } - include_examples 'failed signup' - end - - context "with values for the fields" do - let(:create_params) { { - name: @user.name, - password: 'suChS3cuRi7y', - username: @user.username, - email: @user.email, - user_fields: { - user_field.id.to_s => 'value1', - another_field.id.to_s => 'value2', - } - } } - - it "should succeed without the optional field" do - post :create, params: create_params, format: :json - expect(response).to be_success - inserted = User.find_by_email(@user.email) - expect(inserted).to be_present - expect(inserted.custom_fields).to be_present - expect(inserted.custom_fields["user_field_#{user_field.id}"]).to eq('value1') - expect(inserted.custom_fields["user_field_#{another_field.id}"]).to eq('value2') - expect(inserted.custom_fields["user_field_#{optional_field.id}"]).to be_blank - end - - it "should succeed with the optional field" do - create_params[:user_fields][optional_field.id.to_s] = 'value3' - post :create, params: create_params.merge(create_params), format: :json - expect(response).to be_success - inserted = User.find_by_email(@user.email) - expect(inserted).to be_present - expect(inserted.custom_fields).to be_present - expect(inserted.custom_fields["user_field_#{user_field.id}"]).to eq('value1') - expect(inserted.custom_fields["user_field_#{another_field.id}"]).to eq('value2') - expect(inserted.custom_fields["user_field_#{optional_field.id}"]).to eq('value3') - end - - it "trims excessively long fields" do - create_params[:user_fields][optional_field.id.to_s] = ('x' * 3000) - post :create, params: create_params.merge(create_params), format: :json - expect(response).to be_success - inserted = User.find_by_email(@user.email) - - val = inserted.custom_fields["user_field_#{optional_field.id}"] - expect(val.length).to eq(UserField.max_length) - end - end - end - - context "with only optional custom fields" do - let!(:user_field) { Fabricate(:user_field, required: false) } - - context "without values for the fields" do - let(:create_params) { { - name: @user.name, - password: 'suChS3cuRi7y', - username: @user.username, - email: @user.email, - } } - - it "should succeed" do - post :create, params: create_params, format: :json - expect(response).to be_success - inserted = User.find_by_email(@user.email) - expect(inserted).to be_present - expect(inserted.custom_fields).not_to be_present - expect(inserted.custom_fields["user_field_#{user_field.id}"]).to be_blank - end - end - end - - end - - context '#username' do - it 'raises an error when not logged in' do - put :username, params: { username: 'somename' }, format: :json - expect(response.status).to eq(403) - end - - context 'while logged in' do - let(:old_username) { "OrigUsrname" } - let(:new_username) { "#{old_username}1234" } - let(:user) { Fabricate(:user, username: old_username) } - - before do - user.username = old_username - log_in_user(user) - end - - it 'raises an error without a new_username param' do - expect do - put :username, params: { username: user.username }, format: :json - end.to raise_error(ActionController::ParameterMissing) - - expect(user.reload.username).to eq(old_username) - end - - it 'raises an error when you don\'t have permission to change the username' do - Guardian.any_instance.expects(:can_edit_username?).with(user).returns(false) - - put :username, params: { - username: user.username, new_username: new_username - }, format: :json - - expect(response).to be_forbidden - expect(user.reload.username).to eq(old_username) - end - - it 'raises an error when change_username fails' do - put :username, - params: { username: user.username, new_username: '@' }, - format: :json - - expect(response).to_not be_success - - body = JSON.parse(response.body) - - expect(body['errors'].first).to include(I18n.t( - 'user.username.short', min: User.username_length.begin - )) - - expect(user.reload.username).to eq(old_username) - end - - it 'should succeed in normal circumstances' do - put :username, - params: { username: user.username, new_username: new_username }, - format: :json - - expect(response).to be_success - expect(user.reload.username).to eq(new_username) - end - - it 'should fail if the user is old' do - # Older than the change period and >1 post - user.created_at = Time.now - (SiteSetting.username_change_period + 1).days - PostCreator.new(user, - title: 'This is a test topic', - raw: 'This is a test this is a test' - ).create - - put :username, params: { - username: user.username, new_username: new_username - }, format: :json - - expect(response).to be_forbidden - expect(user.reload.username).to eq(old_username) - end - - it 'should create a staff action log when a staff member changes the username' do - acting_user = Fabricate(:admin) - log_in_user(acting_user) - - put :username, params: { - username: user.username, new_username: new_username - }, format: :json - - expect(response).to be_success - expect(UserHistory.where(action: UserHistory.actions[:change_username], target_user_id: user.id, acting_user_id: acting_user.id)).to be_present - expect(user.reload.username).to eq(new_username) - end - - it 'should return a JSON response with the updated username' do - put :username, params: { - username: user.username, new_username: new_username - }, format: :json - - expect(::JSON.parse(response.body)['username']).to eq(new_username) - end - - end - end - - context '.check_username' do - it 'raises an error without any parameters' do - expect do - get :check_username, format: :json - end.to raise_error(ActionController::ParameterMissing) - end - - shared_examples 'when username is unavailable' do - it 'should return success' do - expect(response).to be_success - end - - it 'should return available as false in the JSON' do - expect(::JSON.parse(response.body)['available']).to eq(false) - end - - it 'should return a suggested username' do - expect(::JSON.parse(response.body)['suggestion']).to be_present - end - end - - shared_examples 'when username is available' do - it 'should return success' do - expect(response).to be_success - end - - it 'should return available in the JSON' do - expect(::JSON.parse(response.body)['available']).to eq(true) - end - end - - it 'returns nothing when given an email param but no username' do - get :check_username, params: { email: 'dood@example.com' }, format: :json - expect(response).to be_success - end - - context 'username is available' do - before do - get :check_username, params: { username: 'BruceWayne' }, format: :json - end - include_examples 'when username is available' - end - - context 'username is unavailable' do - let!(:user) { Fabricate(:user) } - before do - get :check_username, params: { username: user.username }, format: :json - end - include_examples 'when username is unavailable' - end - - shared_examples 'checking an invalid username' do - it 'should return success' do - expect(response).to be_success - end - - it 'should not return an available key' do - expect(::JSON.parse(response.body)['available']).to eq(nil) - end - - it 'should return an error message' do - expect(::JSON.parse(response.body)['errors']).not_to be_empty - end - end - - context 'has invalid characters' do - before do - get :check_username, params: { - username: 'bad username' - }, format: :json - end - include_examples 'checking an invalid username' - - it 'should return the invalid characters message' do - expect(::JSON.parse(response.body)['errors']).to include(I18n.t(:'user.username.characters')) - end - end - - context 'is too long' do - before do - get :check_username, params: { - username: generate_username(User.username_length.last + 1) - }, format: :json - end - include_examples 'checking an invalid username' - - it 'should return the "too long" message' do - expect(::JSON.parse(response.body)['errors']).to include(I18n.t(:'user.username.long', max: User.username_length.end)) - end - end - - describe 'different case of existing username' do - context "it's my username" do - let!(:user) { Fabricate(:user, username: 'hansolo') } - before do - log_in_user(user) - - get :check_username, params: { - username: 'HanSolo' - }, format: :json - end - include_examples 'when username is available' - end - - context "it's someone else's username" do - let!(:user) { Fabricate(:user, username: 'hansolo') } - before do - log_in - - get :check_username, params: { - username: 'HanSolo' - }, format: :json - end - include_examples 'when username is unavailable' - end - - context "an admin changing it for someone else" do - let!(:user) { Fabricate(:user, username: 'hansolo') } - before do - log_in_user(Fabricate(:admin)) - - get :check_username, params: { - username: 'HanSolo', for_user_id: user.id - }, format: :json - end - include_examples 'when username is available' - end - end - end - - describe '#invited' do - it 'returns success' do - user = Fabricate(:user) - get :invited, params: { username: user.username }, format: :json - - expect(response).to be_success - end - - it 'filters by email' do - inviter = Fabricate(:user) - invitee = Fabricate(:user) - _invite = Fabricate( - :invite, - email: 'billybob@example.com', - invited_by: inviter, - user: invitee - ) - Fabricate( - :invite, - email: 'jimtom@example.com', - invited_by: inviter, - user: invitee - ) - - get :invited, params: { - username: inviter.username, search: 'billybob' - }, format: :json - - invites = JSON.parse(response.body)['invites'] - expect(invites.size).to eq(1) - expect(invites.first).to include('email' => 'billybob@example.com') - end - - it 'filters by username' do - inviter = Fabricate(:user) - invitee = Fabricate(:user, username: 'billybob') - _invite = Fabricate( - :invite, - invited_by: inviter, - email: 'billybob@example.com', - user: invitee - ) - Fabricate( - :invite, - invited_by: inviter, - user: Fabricate(:user, username: 'jimtom') - ) - - get :invited, params: { - username: inviter.username, search: 'billybob' - }, format: :json - - invites = JSON.parse(response.body)['invites'] - expect(invites.size).to eq(1) - expect(invites.first).to include('email' => 'billybob@example.com') - end - - context 'with guest' do - context 'with pending invites' do - it 'does not return invites' do - inviter = Fabricate(:user) - Fabricate(:invite, invited_by: inviter) - - get :invited, - params: { username: inviter.username, filter: 'pending' }, - format: :json - - invites = JSON.parse(response.body)['invites'] - expect(invites).to be_empty - end - end - - context 'with redeemed invites' do - it 'returns invites' do - inviter = Fabricate(:user) - invitee = Fabricate(:user) - invite = Fabricate(:invite, invited_by: inviter, user: invitee) - - get :invited, - params: { username: inviter.username }, - format: :json - - invites = JSON.parse(response.body)['invites'] - expect(invites.size).to eq(1) - expect(invites.first).to include('email' => invite.email) - end - end - end - - context 'with authenticated user' do - context 'with pending invites' do - context 'with permission to see pending invites' do - it 'returns invites' do - user = log_in - inviter = Fabricate(:user) - invite = Fabricate(:invite, invited_by: inviter) - stub_guardian(user) do |guardian| - guardian.stubs(:can_see_invite_details?). - with(inviter).returns(true) - end - - get :invited, params: { - username: inviter.username, filter: 'pending' - }, format: :json - - invites = JSON.parse(response.body)['invites'] - expect(invites.size).to eq(1) - expect(invites.first).to include("email" => invite.email) - end - end - - context 'without permission to see pending invites' do - it 'does not return invites' do - user = log_in - inviter = Fabricate(:user) - _invitee = Fabricate(:user) - Fabricate(:invite, invited_by: inviter) - stub_guardian(user) do |guardian| - guardian.stubs(:can_see_invite_details?). - with(inviter).returns(false) - end - - get :invited, params: { - username: inviter.username, filter: 'pending' - }, format: :json - - json = JSON.parse(response.body)['invites'] - expect(json).to be_empty - end - end - end - - context 'with redeemed invites' do - it 'returns invites' do - _user = log_in - inviter = Fabricate(:user) - invitee = Fabricate(:user) - invite = Fabricate(:invite, invited_by: inviter, user: invitee) - - get :invited, params: { username: inviter.username }, format: :json - - invites = JSON.parse(response.body)['invites'] - expect(invites.size).to eq(1) - expect(invites.first).to include('email' => invite.email) - end - end - end - end - - describe '#update' do - context 'with guest' do - it 'raises an error' do - put :update, params: { username: 'guest' }, format: :json - expect(response.status).to eq(403) - end - end - - context "as a staff user" do - let!(:user) { log_in(:admin) } - - context "uneditable field" do - let!(:user_field) { Fabricate(:user_field, editable: false) } - - it "allows staff to edit the field" do - put :update, params: { - username: user.username, - name: 'Jim Tom', - title: "foobar", - user_fields: { user_field.id.to_s => 'happy' } - }, format: :json - - expect(response).to be_success - - user.reload - - expect(user.user_fields[user_field.id.to_s]).to eq('happy') - expect(user.title).to eq("foobar") - end - end - - end - - context 'with authenticated user' do - context 'with permission to update' do - let!(:user) { log_in(:user) } - - it 'allows the update' do - user2 = Fabricate(:user) - user3 = Fabricate(:user) - tags = [Fabricate(:tag), Fabricate(:tag)] - - put :update, params: { - username: user.username, - name: 'Jim Tom', - custom_fields: { test: :it }, - muted_usernames: "#{user2.username},#{user3.username}", - watched_tags: "#{tags[0].name},#{tags[1].name}" - }, format: :json - - expect(response).to be_success - - user.reload - - expect(user.name).to eq 'Jim Tom' - expect(user.custom_fields['test']).to eq 'it' - expect(user.muted_users.pluck(:username).sort).to eq [user2.username, user3.username].sort - expect(TagUser.where( - user: user, - notification_level: TagUser.notification_levels[:watching] - ).pluck(:tag_id)).to contain_exactly(tags[0].id, tags[1].id) - - theme = Theme.create(name: "test", user_selectable: true, user_id: -1) - - put :update, params: { - username: user.username, - muted_usernames: "", - theme_key: theme.key, - email_direct: false - }, format: :json - - user.reload - - expect(user.muted_users.pluck(:username).sort).to be_empty - expect(user.user_option.theme_key).to eq(theme.key) - expect(user.user_option.email_direct).to eq(false) - end - - context 'a locale is chosen that differs from I18n.locale' do - it "updates the user's locale" do - I18n.stubs(:locale).returns('fr') - - put :update, params: { - username: user.username, - locale: :fa_IR - }, format: :json - - expect(User.find_by(username: user.username).locale).to eq('fa_IR') - end - - end - - context "with user fields" do - context "an editable field" do - let!(:user_field) { Fabricate(:user_field) } - let!(:optional_field) { Fabricate(:user_field, required: false) } - - it "should update the user field" do - put :update, params: { - username: user.username, name: 'Jim Tom', user_fields: { user_field.id.to_s => 'happy' } - }, format: :json - - expect(response).to be_success - expect(user.user_fields[user_field.id.to_s]).to eq 'happy' - end - - it "cannot be updated to blank" do - put :update, params: { - username: user.username, name: 'Jim Tom', user_fields: { user_field.id.to_s => '' } - }, format: :json - - expect(response).not_to be_success - expect(user.user_fields[user_field.id.to_s]).not_to eq('happy') - end - - it "trims excessively large fields" do - put :update, params: { - username: user.username, name: 'Jim Tom', user_fields: { user_field.id.to_s => ('x' * 3000) } - }, format: :json - - expect(user.user_fields[user_field.id.to_s].size).to eq(UserField.max_length) - end - - it "should retain existing user fields" do - put :update, params: { - username: user.username, name: 'Jim Tom', user_fields: { user_field.id.to_s => 'happy', optional_field.id.to_s => 'feet' } - }, format: :json - - expect(response).to be_success - expect(user.user_fields[user_field.id.to_s]).to eq('happy') - expect(user.user_fields[optional_field.id.to_s]).to eq('feet') - - put :update, params: { - username: user.username, name: 'Jim Tom', user_fields: { user_field.id.to_s => 'sad' } - }, format: :json - - expect(response).to be_success - - user.reload - - expect(user.user_fields[user_field.id.to_s]).to eq('sad') - expect(user.user_fields[optional_field.id.to_s]).to eq('feet') - end - end - - context "uneditable field" do - let!(:user_field) { Fabricate(:user_field, editable: false) } - - it "does not update the user field" do - put :update, params: { - username: user.username, name: 'Jim Tom', user_fields: { user_field.id.to_s => 'happy' } - }, format: :json - - expect(response).to be_success - expect(user.user_fields[user_field.id.to_s]).to be_blank - end - end - - end - - it 'returns user JSON' do - put :update, params: { username: user.username }, format: :json - - json = JSON.parse(response.body) - expect(json['user']['id']).to eq user.id - end - - end - - context 'without permission to update' do - it 'does not allow the update' do - user = Fabricate(:user, name: 'Billy Bob') - log_in_user(user) - Guardian.any_instance.expects(:can_edit?).with(user).returns(false) - - put :update, - params: { username: user.username, name: 'Jim Tom' }, - format: :json - - expect(response).to be_forbidden - expect(user.reload.name).not_to eq 'Jim Tom' - end - end - end - end - - describe "badge_title" do - let(:user) { Fabricate(:user) } - let(:badge) { Fabricate(:badge) } - let(:user_badge) { BadgeGranter.grant(badge, user) } - - it "sets the user's title to the badge name if it is titleable" do - log_in_user user - - put :badge_title, params: { - user_badge_id: user_badge.id, username: user.username - }, format: :json - - expect(user.reload.title).not_to eq(badge.display_name) - badge.update_attributes allow_title: true - - put :badge_title, params: { - user_badge_id: user_badge.id, username: user.username - }, format: :json - - expect(user.reload.title).to eq(badge.display_name) - expect(user.user_profile.badge_granted_title).to eq(true) - - user.title = "testing" - user.save - user.user_profile.reload - expect(user.user_profile.badge_granted_title).to eq(false) - - end - end - - describe "badge_title with overrided name" do - let(:user) { Fabricate(:user) } - let(:badge) { Fabricate(:badge, name: 'Demogorgon', allow_title: true) } - let(:user_badge) { BadgeGranter.grant(badge, user) } - - before do - TranslationOverride.upsert!('en', 'badges.demogorgon.name', 'Boss') - end - - after do - TranslationOverride.revert!('en', ['badges.demogorgon.name']) - end - - it "uses the badge display name as user title" do - log_in_user user - - put :badge_title, params: { - user_badge_id: user_badge.id, username: user.username - }, format: :json - - expect(user.reload.title).to eq(badge.display_name) - end - end - - describe 'send_activation_email' do - context 'for an existing user' do - let(:user) { Fabricate(:user, active: false) } - - context 'for an activated account with email confirmed' do - it 'fails' do - active_user = Fabricate(:user, active: true) - email_token = active_user.email_tokens.create(email: active_user.email).token - EmailToken.confirm(email_token) - session[SessionController::ACTIVATE_USER_KEY] = active_user.id - - post :send_activation_email, params: { - username: active_user.username - }, format: :json - - expect(response.status).to eq(409) - - expect(JSON.parse(response.body)['errors']).to include(I18n.t( - 'activation.activated' - )) - - expect(session[SessionController::ACTIVATE_USER_KEY]).to eq(nil) - end - end - - context 'for an activated account with unconfirmed email' do - it 'should send an email' do - unconfirmed_email_user = Fabricate(:user, active: true) - unconfirmed_email_user.email_tokens.create(email: unconfirmed_email_user.email) - session[SessionController::ACTIVATE_USER_KEY] = unconfirmed_email_user.id - Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :signup, to_address: unconfirmed_email_user.email)) - - post :send_activation_email, params: { - username: unconfirmed_email_user.username - }, format: :json - - expect(response.status).to eq(200) - - expect(session[SessionController::ACTIVATE_USER_KEY]).to eq(nil) - end - end - - context "approval is enabled" do - before do - SiteSetting.must_approve_users = true - end - - it "should raise an error" do - unconfirmed_email_user = Fabricate(:user, active: true) - unconfirmed_email_user.email_tokens.create(email: unconfirmed_email_user.email) - session[SessionController::ACTIVATE_USER_KEY] = unconfirmed_email_user.id - post :send_activation_email, params: { - username: unconfirmed_email_user.username - }, format: :json - - expect(response.status).to eq(403) - end - end - - describe 'when user does not have a valid session' do - it 'should not be valid' do - user = Fabricate(:user) - post :send_activation_email, params: { - username: user.username - }, format: :json - - expect(response.status).to eq(403) - end - - it 'should allow staff regardless' do - log_in :admin - user = Fabricate(:user, active: false) - - post :send_activation_email, params: { - username: user.username - }, format: :json - - expect(response.status).to eq(200) - end - end - - context 'with a valid email_token' do - it 'should send the activation email' do - session[SessionController::ACTIVATE_USER_KEY] = user.id - Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :signup)) - - post :send_activation_email, params: { - username: user.username - }, format: :json - - expect(session[SessionController::ACTIVATE_USER_KEY]).to eq(nil) - end - end - - context 'without an existing email_token' do - before do - user.email_tokens.each { |t| t.destroy } - user.reload - end - - it 'should generate a new token' do - expect { - session[SessionController::ACTIVATE_USER_KEY] = user.id - - post :send_activation_email, - params: { username: user.username }, - format: :json - }.to change { user.reload.email_tokens.count }.by(1) - end - - it 'should send an email' do - session[SessionController::ACTIVATE_USER_KEY] = user.id - Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :signup)) - - post :send_activation_email, - params: { username: user.username }, - format: :json - - expect(session[SessionController::ACTIVATE_USER_KEY]).to eq(nil) - end - end - end - - context 'when username does not exist' do - it 'should not send an email' do - Jobs.expects(:enqueue).never - - post :send_activation_email, - params: { username: 'nopenopenopenope' }, - format: :json - end - end - end - - describe '.pick_avatar' do - - it 'raises an error when not logged in' do - put :pick_avatar, params: { - username: 'asdf', avatar_id: 1, type: "custom" - }, format: :json - expect(response.status).to eq(403) - end - - context 'while logged in' do - - let!(:user) { log_in } - let(:upload) { Fabricate(:upload) } - - it "raises an error when you don't have permission to toggle the avatar" do - another_user = Fabricate(:user) - put :pick_avatar, params: { - username: another_user.username, upload_id: upload.id, type: "custom" - }, format: :json - - expect(response).to be_forbidden - end - - it "raises an error when sso_overrides_avatar is disabled" do - SiteSetting.sso_overrides_avatar = true - put :pick_avatar, params: { - username: user.username, upload_id: upload.id, type: "custom" - }, format: :json - - expect(response).to_not be_success - end - - it "raises an error when selecting the custom/uploaded avatar and allow_uploaded_avatars is disabled" do - SiteSetting.allow_uploaded_avatars = false - put :pick_avatar, params: { - username: user.username, upload_id: upload.id, type: "custom" - }, format: :json - - expect(response).to_not be_success - end - - it 'can successfully pick the system avatar' do - put :pick_avatar, params: { - username: user.username - }, format: :json - - expect(response).to be_success - expect(user.reload.uploaded_avatar_id).to eq(nil) - end - - it 'can successfully pick a gravatar' do - put :pick_avatar, params: { - username: user.username, upload_id: upload.id, type: "gravatar" - }, format: :json - - expect(response).to be_success - expect(user.reload.uploaded_avatar_id).to eq(upload.id) - expect(user.user_avatar.reload.gravatar_upload_id).to eq(upload.id) - end - - it 'can successfully pick a custom avatar' do - put :pick_avatar, params: { - username: user.username, upload_id: upload.id, type: "custom" - }, format: :json - - expect(response).to be_success - expect(user.reload.uploaded_avatar_id).to eq(upload.id) - expect(user.user_avatar.reload.custom_upload_id).to eq(upload.id) - end - - end - - end - - describe '.destroy_user_image' do - - it 'raises an error when not logged in' do - delete :destroy_user_image, - params: { type: 'profile_background', username: 'asdf' }, - format: :json - expect(response.status).to eq(403) - end - - context 'while logged in' do - - let!(:user) { log_in } - - it 'raises an error when you don\'t have permission to clear the profile background' do - Guardian.any_instance.expects(:can_edit?).with(user).returns(false) - - delete :destroy_user_image, - params: { username: user.username, type: 'profile_background' }, - format: :json - - expect(response).to be_forbidden - end - - it "requires the `type` param" do - expect do - delete :destroy_user_image, params: { username: user.username }, format: :json - end.to raise_error(ActionController::ParameterMissing) - end - - it "only allows certain `types`" do - delete :destroy_user_image, - params: { username: user.username, type: 'wat' }, - format: :json - expect(response.status).to eq(400) - end - - it 'can clear the profile background' do - delete :destroy_user_image, params: { - type: 'profile_background', username: user.username - }, format: :json - - expect(user.reload.user_profile.profile_background).to eq("") - expect(response).to be_success - end - - end - end - - describe '.destroy' do - it 'raises an error when not logged in' do - delete :destroy, params: { username: 'nobody' }, format: :json - expect(response.status).to eq(403) - end - - context 'while logged in' do - let!(:user) { log_in } - - it 'raises an error when you cannot delete your account' do - Guardian.any_instance.stubs(:can_delete_user?).returns(false) - UserDestroyer.any_instance.expects(:destroy).never - delete :destroy, params: { username: user.username }, format: :json - expect(response).to be_forbidden - end - - it "raises an error when you try to delete someone else's account" do - UserDestroyer.any_instance.expects(:destroy).never - delete :destroy, params: { username: Fabricate(:user).username }, format: :json - expect(response).to be_forbidden - end - - it "deletes your account when you're allowed to" do - Guardian.any_instance.stubs(:can_delete_user?).returns(true) - UserDestroyer.any_instance.expects(:destroy).with(user, anything).returns(user) - delete :destroy, params: { username: user.username }, format: :json - expect(response).to be_success - end - end - end - - describe '.my_redirect' do - - it "redirects if the user is not logged in" do - get :my_redirect, params: { path: "wat" }, format: :json - expect(response).not_to be_success - expect(response).to be_redirect - end - - context "when the user is logged in" do - let!(:user) { log_in } - - it "will not redirect to an invalid path" do - get :my_redirect, params: { path: "wat/..password.txt" }, format: :json - expect(response).not_to be_redirect - end - - it "will redirect to an valid path" do - get :my_redirect, params: { path: "preferences" }, format: :json - expect(response).to be_redirect - end - - it "permits forward slashes" do - get :my_redirect, params: { path: "activity/posts" }, format: :json - expect(response).to be_redirect - end - end - end - - describe '.check_emails' do - - it 'raises an error when not logged in' do - put :check_emails, params: { username: 'zogstrip' }, format: :json - expect(response.status).to eq(403) - end - - context 'while logged in' do - let!(:user) { log_in } - - it "raises an error when you aren't allowed to check emails" do - Guardian.any_instance.expects(:can_check_emails?).returns(false) - - put :check_emails, - params: { username: Fabricate(:user).username }, - format: :json - - expect(response).to be_forbidden - end - - it "returns both email and associated_accounts when you're allowed to see them" do - Guardian.any_instance.expects(:can_check_emails?).returns(true) - - put :check_emails, - params: { username: Fabricate(:user).username }, - format: :json - - expect(response).to be_success - json = JSON.parse(response.body) - expect(json["email"]).to be_present - expect(json["associated_accounts"]).to be_present - end - - it "works on inactive users" do - inactive_user = Fabricate(:user, active: false) - Guardian.any_instance.expects(:can_check_emails?).returns(true) - - put :check_emails, params: { - username: inactive_user.username - }, format: :json - - expect(response).to be_success - json = JSON.parse(response.body) - expect(json["email"]).to be_present - expect(json["associated_accounts"]).to be_present - end - - end - - end - - describe ".is_local_username" do - - let(:user) { Fabricate(:user) } - let(:group) { Fabricate(:group, name: "Discourse") } - let(:topic) { Fabricate(:topic) } - let(:allowed_user) { Fabricate(:user) } - let(:private_topic) { Fabricate(:private_message_topic, user: allowed_user) } - - it "finds the user" do - get :is_local_username, params: { - username: user.username - }, format: :json - - expect(response).to be_success - json = JSON.parse(response.body) - expect(json["valid"][0]).to eq(user.username) - end - - it "finds the group" do - get :is_local_username, params: { - username: group.name - }, format: :json - - expect(response).to be_success - json = JSON.parse(response.body) - expect(json["valid_groups"][0]).to eq(group.name) - end - - it "supports multiples usernames" do - get :is_local_username, params: { - usernames: [user.username, "system"] - }, format: :json - - expect(response).to be_success - json = JSON.parse(response.body) - expect(json["valid"].size).to eq(2) - end - - it "never includes staged accounts" do - staged = Fabricate(:user, staged: true) - - get :is_local_username, params: { - usernames: [staged.username] - }, format: :json - - expect(response).to be_success - json = JSON.parse(response.body) - expect(json["valid"].size).to eq(0) - end - - it "returns user who cannot see topic" do - Guardian.any_instance.expects(:can_see?).with(topic).returns(false) - - get :is_local_username, params: { - usernames: [user.username], topic_id: topic.id - }, format: :json - - expect(response).to be_success - json = JSON.parse(response.body) - expect(json["cannot_see"].size).to eq(1) - end - - it "never returns a user who can see the topic" do - Guardian.any_instance.expects(:can_see?).with(topic).returns(true) - - get :is_local_username, params: { - usernames: [user.username], topic_id: topic.id - }, format: :json - - expect(response).to be_success - json = JSON.parse(response.body) - expect(json["cannot_see"].size).to eq(0) - end - - it "returns user who cannot see a private topic" do - Guardian.any_instance.expects(:can_see?).with(private_topic).returns(false) - - get :is_local_username, params: { - usernames: [user.username], topic_id: private_topic.id - }, format: :json - - expect(response).to be_success - json = JSON.parse(response.body) - expect(json["cannot_see"].size).to eq(1) - end - - it "never returns a user who can see the topic" do - Guardian.any_instance.expects(:can_see?).with(private_topic).returns(true) - - get :is_local_username, params: { - usernames: [allowed_user.username], topic_id: private_topic.id - }, format: :json - - expect(response).to be_success - json = JSON.parse(response.body) - expect(json["cannot_see"].size).to eq(0) - end - - end - - describe '.topic_tracking_state' do - let(:user) { Fabricate(:user) } - - context 'anon' do - it "raises an error on anon for topic_tracking_state" do - get :topic_tracking_state, params: { username: user.username }, format: :json - expect(response.status).to eq(403) - end - end - - context 'logged on' do - it "detects new topic" do - log_in_user(user) - - topic = Fabricate(:topic) - get :topic_tracking_state, params: { username: user.username }, format: :json - - states = JSON.parse(response.body) - - expect(states[0]["topic_id"]).to eq(topic.id) - end - end - end - - describe '.summary' do - - it "generates summary info" do - user = Fabricate(:user) - create_post(user: user) - - get :summary, params: { username: user.username_lower }, format: :json - expect(response).to be_success - json = JSON.parse(response.body) - - expect(json["user_summary"]["topic_count"]).to eq(1) - expect(json["user_summary"]["post_count"]).to eq(0) - end - end - - describe ".confirm_admin" do - it "fails without a valid token" do - expect { - get :confirm_admin, params: { token: 'invalid-token' }, format: :json - }.to raise_error(ActionController::UrlGenerationError) - end - - it "fails with a missing token" do - get :confirm_admin, params: { token: 'a0a0a0a0a0' }, format: :json - expect(response).to_not be_success - end - - it "succeeds with a valid code as anonymous" do - user = Fabricate(:user) - ac = AdminConfirmation.new(user, Fabricate(:admin)) - ac.create_confirmation - get :confirm_admin, params: { token: ac.token } - expect(response).to be_success - - user.reload - expect(user.admin?).to eq(false) - end - - it "succeeds with a valid code when logged in as that user" do - admin = log_in(:admin) - user = Fabricate(:user) - - ac = AdminConfirmation.new(user, admin) - ac.create_confirmation - get :confirm_admin, params: { token: ac.token } - expect(response).to be_success - - user.reload - expect(user.admin?).to eq(false) - end - - it "fails if you're logged in as a different account" do - log_in(:admin) - user = Fabricate(:user) - - ac = AdminConfirmation.new(user, Fabricate(:admin)) - ac.create_confirmation - get :confirm_admin, params: { token: ac.token }, format: :json - expect(response).to_not be_success - - user.reload - expect(user.admin?).to eq(false) - end - - describe "post" do - it "gives the user admin access when POSTed" do - user = Fabricate(:user) - ac = AdminConfirmation.new(user, Fabricate(:admin)) - ac.create_confirmation - post :confirm_admin, params: { token: ac.token } - expect(response).to be_success - - user.reload - expect(user.admin?).to eq(true) - end - end - - end - - describe '.update_activation_email' do - - context "with a session variable" do - - it "raises an error with an invalid session value" do - session[SessionController::ACTIVATE_USER_KEY] = 1234 - - put :update_activation_email, params: { - email: 'updatedemail@example.com' - }, format: :json - - expect(response).to_not be_success - end - - it "raises an error for an active user" do - user = Fabricate(:walter_white) - session[SessionController::ACTIVATE_USER_KEY] = user.id - - put :update_activation_email, params: { - email: 'updatedemail@example.com' - }, format: :json - - expect(response).to_not be_success - end - - it "raises an error when logged in" do - moderator = log_in(:moderator) - session[SessionController::ACTIVATE_USER_KEY] = moderator.id - - put :update_activation_email, params: { - email: 'updatedemail@example.com' - }, format: :json - - expect(response).to_not be_success - end - - it "raises an error when the new email is taken" do - active_user = Fabricate(:user) - user = Fabricate(:inactive_user) - session[SessionController::ACTIVATE_USER_KEY] = user.id - - put :update_activation_email, params: { - email: active_user.email - }, format: :json - - expect(response).to_not be_success - end - - it "raises an error when the email is blacklisted" do - user = Fabricate(:inactive_user) - SiteSetting.email_domains_blacklist = 'example.com' - session[SessionController::ACTIVATE_USER_KEY] = user.id - put :update_activation_email, params: { email: 'test@example.com' }, format: :json - expect(response).to_not be_success - end - - it "can be updated" do - user = Fabricate(:inactive_user) - token = user.email_tokens.first - - session[SessionController::ACTIVATE_USER_KEY] = user.id - - put :update_activation_email, params: { - email: 'updatedemail@example.com' - }, format: :json - - expect(response).to be_success - - user.reload - expect(user.email).to eq('updatedemail@example.com') - expect(user.email_tokens.where(email: 'updatedemail@example.com', expired: false)).to be_present - - token.reload - expect(token.expired?).to eq(true) - end - end - - context "with a username and password" do - it "raises an error with an invalid username" do - put :update_activation_email, params: { - username: 'eviltrout', - password: 'invalid-password', - email: 'updatedemail@example.com' - }, format: :json - - expect(response).to_not be_success - end - - it "raises an error with an invalid password" do - put :update_activation_email, params: { - username: Fabricate(:inactive_user).username, - password: 'invalid-password', - email: 'updatedemail@example.com' - }, format: :json - - expect(response).to_not be_success - end - - it "raises an error for an active user" do - put :update_activation_email, params: { - username: Fabricate(:walter_white).username, - password: 'letscook', - email: 'updatedemail@example.com' - }, format: :json - - expect(response).to_not be_success - end - - it "raises an error when logged in" do - log_in(:moderator) - - put :update_activation_email, params: { - username: Fabricate(:inactive_user).username, - password: 'qwerqwer123', - email: 'updatedemail@example.com' - }, format: :json - - expect(response).to_not be_success - end - - it "raises an error when the new email is taken" do - user = Fabricate(:user) - - put :update_activation_email, params: { - username: Fabricate(:inactive_user).username, - password: 'qwerqwer123', - email: user.email - }, format: :json - - expect(response).to_not be_success - end - - it "can be updated" do - user = Fabricate(:inactive_user) - token = user.email_tokens.first - - put :update_activation_email, params: { - username: user.username, - password: 'qwerqwer123', - email: 'updatedemail@example.com' - }, format: :json - - expect(response).to be_success - - user.reload - expect(user.email).to eq('updatedemail@example.com') - expect(user.email_tokens.where(email: 'updatedemail@example.com', expired: false)).to be_present - - token.reload - expect(token.expired?).to eq(true) - end - end - end -end diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index 6f8be029cf..8414e90a0d 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -1,19 +1,978 @@ require 'rails_helper' -RSpec.describe UsersController do +describe UsersController do let(:user) { Fabricate(:user) } - def honeypot_magic(params) - get '/u/hp.json' - json = JSON.parse(response.body) - params[:password_confirmation] = json["value"] - params[:challenge] = json["challenge"].reverse - params + describe '#activate_account' do + let(:token) { "asdfasdf" } + before do + UsersController.any_instance.stubs(:honeypot_or_challenge_fails?).returns(false) + end + + context 'invalid token' do + it 'return success' do + put "/u/activate-account/#{token}" + expect(response).to be_success + expect(flash[:error]).to be_present + end + end + + context 'valid token' do + let(:user) { Fabricate(:user) } + + context 'welcome message' do + before do + EmailToken.expects(:confirm).with("#{token}").returns(user) + end + + it 'enqueues a welcome message if the user object indicates so' do + user.send_welcome_message = true + user.expects(:enqueue_welcome_message).with('welcome_user') + + put "/u/activate-account/#{token}" + end + + it "doesn't enqueue the welcome message if the object returns false" do + user.send_welcome_message = false + user.expects(:enqueue_welcome_message).with('welcome_user').never + + put "/u/activate-account/#{token}" + end + end + + context "honeypot" do + it "raises an error if the honeypot is invalid" do + UsersController.any_instance.stubs(:honeypot_or_challenge_fails?).returns(true) + put "/u/activate-account/#{token}" + expect(response).not_to be_success + end + end + + context 'response' do + before do + Guardian.any_instance.expects(:can_access_forum?).returns(true) + EmailToken.expects(:confirm).with("#{token}").returns(user) + end + + it 'correctly logs on user' do + events = DiscourseEvent.track_events do + put "/u/activate-account/#{token}" + end + + expect(events.map { |event| event[:event_name] }).to include( + :user_logged_in, :user_first_logged_in + ) + + expect(response).to be_success + expect(flash[:error]).to be_blank + expect(session[:current_user_id]).to be_present + + expect(response).to be_success + + expect(CGI.unescapeHTML(response.body)) + .to_not include(I18n.t('activation.approval_required')) + end + end + + context 'user is not approved' do + before do + SiteSetting.must_approve_users = true + EmailToken.expects(:confirm).with("#{token}").returns(user) + put "/u/activate-account/#{token}" + end + + it 'should return the right response' do + expect(response).to be_success + + expect(CGI.unescapeHTML(response.body)) + .to include(I18n.t('activation.approval_required')) + + expect(response.body).to_not have_tag(:script, with: { + src: '/assets/application.js' + }) + + expect(flash[:error]).to be_blank + expect(session[:current_user_id]).to be_blank + end + end + end + end + + describe '#perform_account_activation' do + describe 'when cookies contains a destination URL' do + let(:token) { 'asdadwewq' } + let(:user) { Fabricate(:user) } + + before do + UsersController.any_instance.stubs(:honeypot_or_challenge_fails?).returns(false) + EmailToken.expects(:confirm).with(token).returns(user) + end + + it 'should redirect to the URL' do + destination_url = 'http://thisisasite.com/somepath' + cookies[:destination_url] = destination_url + + put "/u/activate-account/#{token}" + + expect(response).to redirect_to(destination_url) + end + end + end + + describe '#password_reset' do + let(:user) { Fabricate(:user) } + let(:token) { SecureRandom.hex } + + context "you can view it even if login is required" do + it "returns success" do + SiteSetting.login_required = true + get "/u/password-reset/#{token}" + expect(response).to be_success + end + end + + context 'missing token' do + before do + get "/u/password-reset/#{token}" + end + + it 'disallows login' do + expect(response).to be_success + + expect(CGI.unescapeHTML(response.body)) + .to include(I18n.t('password_reset.no_token')) + + expect(response.body).to_not have_tag(:script, with: { + src: '/assets/application.js' + }) + + expect(session[:current_user_id]).to be_blank + end + end + + context 'invalid token' do + it 'disallows login' do + get "/u/password-reset/ev!l_trout@!" + + expect(response).to be_success + + expect(CGI.unescapeHTML(response.body)) + .to include(I18n.t('password_reset.no_token')) + + expect(response.body).to_not have_tag(:script, with: { + src: '/assets/application.js' + }) + + expect(session[:current_user_id]).to be_blank + end + + it "responds with proper error message" do + put "/u/password-reset/evil_trout!.json", params: { password: "awesomeSecretPassword" } + + expect(response).to be_success + expect(JSON.parse(response.body)["message"]).to eq(I18n.t('password_reset.no_token')) + expect(session[:current_user_id]).to be_blank + end + end + + context 'valid token' do + context 'when rendered' do + it 'renders referrer never on get requests' do + user = Fabricate(:user) + token = user.email_tokens.create(email: user.email).token + get "/u/password-reset/#{token}" + + expect(response.body).to include('') + end + end + + it 'returns success' do + user = Fabricate(:user) + user_auth_token = UserAuthToken.generate!(user_id: user.id) + token = user.email_tokens.create(email: user.email).token + get "/u/password-reset/#{token}" + + events = DiscourseEvent.track_events do + put "/u/password-reset/#{token}", params: { password: 'hg9ow8yhg98o' } + end + + expect(events.map { |event| event[:event_name] }).to include( + :user_logged_in, :user_first_logged_in + ) + + expect(response).to be_success + expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":false}') + + user.reload + + expect(session["password-#{token}"]).to be_blank + expect(UserAuthToken.where(id: user_auth_token.id).count).to eq(0) + end + + it 'disallows double password reset' do + user = Fabricate(:user) + token = user.email_tokens.create(email: user.email).token + + get "/u/password-reset/#{token}" + + put "/u/password-reset/#{token}", params: { password: 'hg9ow8yHG32O' } + + put "/u/password-reset/#{token}", params: { password: 'test123987AsdfXYZ' } + + user.reload + expect(user.confirm_password?('hg9ow8yHG32O')).to eq(true) + + # logged in now + expect(user.user_auth_tokens.count).to eq(1) + end + + it "doesn't redirect to wizard on get" do + user = Fabricate(:admin) + UserAuthToken.generate!(user_id: user.id) + + token = user.email_tokens.create(email: user.email).token + get "/u/password-reset/#{token}.json" + expect(response).not_to redirect_to(wizard_path) + end + + it "redirects to the wizard if you're the first admin" do + user = Fabricate(:admin) + UserAuthToken.generate!(user_id: user.id) + + token = user.email_tokens.create(email: user.email).token + get "/u/password-reset/#{token}" + + put "/u/password-reset/#{token}", params: { password: 'hg9ow8yhg98oadminlonger' } + + expect(response).to redirect_to(wizard_path) + end + + it "doesn't invalidate the token when loading the page" do + user = Fabricate(:user) + user_token = UserAuthToken.generate!(user_id: user.id) + + email_token = user.email_tokens.create(email: user.email) + + get "/u/password-reset/#{email_token.token}.json" + + email_token.reload + + expect(email_token.confirmed).to eq(false) + expect(UserAuthToken.where(id: user_token.id).count).to eq(1) + 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 + + get "/u/password-reset/#{token}" + + expect(response.body).to include('{"is_developer":false,"admin":false,"second_factor_required":true}') + + put "/u/password-reset/#{token}", params: { 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 "/u/password-reset/#{token}" + + put "/u/password-reset/#{token}", params: { + 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 + let(:token) { EmailToken.generate_token } + + before do + EmailToken.expects(:confirm).with(token).returns(user) + end + + it "fails when the password is blank" do + put "/u/password-reset/#{token}.json", params: { password: '' } + + expect(response).to be_success + expect(JSON.parse(response.body)["errors"]).to be_present + expect(session[:current_user_id]).to be_blank + end + + it "fails when the password is too long" do + put "/u/password-reset/#{token}.json", params: { password: ('x' * (User.max_password_length + 1)) } + + expect(response).to be_success + expect(JSON.parse(response.body)["errors"]).to be_present + expect(session[:current_user_id]).to be_blank + end + + it "logs in the user" do + put "/u/password-reset/#{token}.json", params: { password: 'ksjafh928r' } + + expect(response).to be_success + expect(JSON.parse(response.body)["errors"]).to be_blank + expect(session[:current_user_id]).to be_present + end + + it "doesn't log in the user when not approved" do + SiteSetting.must_approve_users = true + put "/u/password-reset/#{token}.json", params: { password: 'ksjafh928r' } + + expect(JSON.parse(response.body)["errors"]).to be_blank + expect(session[:current_user_id]).to be_blank + end + end + end + + describe '#confirm_email_token' do + let(:user) { Fabricate(:user) } + + it "token doesn't match any records" do + email_token = user.email_tokens.create(email: user.email) + get "/u/confirm-email-token/#{SecureRandom.hex}.json" + expect(response).to be_success + expect(email_token.reload.confirmed).to eq(false) + end + + it "token matches" do + email_token = user.email_tokens.create(email: user.email) + get "/u/confirm-email-token/#{email_token.token}.json" + expect(response).to be_success + expect(email_token.reload.confirmed).to eq(true) + end + end + + describe '#admin_login' do + let(:admin) { Fabricate(:admin) } + let(:user) { Fabricate(:user) } + + context 'enqueues mail' do + it 'enqueues mail with admin email and sso enabled' do + Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :admin_login, user_id: admin.id)) + put "/u/admin-login", params: { email: admin.email } + end + end + + context 'when email is incorrect' do + it 'should return the right response' do + put "/u/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" + SiteSetting.enable_sso = true + get "/u/admin-login/invalid" + expect(session[:current_user_id]).to be_blank + end + + context 'valid token' do + it 'does log in admin with SSO disabled' do + SiteSetting.enable_sso = false + token = admin.email_tokens.create(email: admin.email).token + + get "/u/admin-login/#{token}" + expect(response).to redirect_to('/') + expect(session[:current_user_id]).to eq(admin.id) + end + + it 'logs in admin with SSO enabled' do + SiteSetting.sso_url = "https://www.example.com/sso" + SiteSetting.enable_sso = true + token = admin.email_tokens.create(email: admin.email).token + + get "/u/admin-login/#{token}" + expect(response).to redirect_to('/') + expect(session[:current_user_id]).to eq(admin.id) + end + end + + 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) } + + it 'does not log in when token required' do + second_factor + get "/u/admin-login/#{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 + + describe 'invalid 2 factor token' do + it 'should display the right error' do + second_factor + + put "/u/admin-login/#{email_token.token}", params: { 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 "/u/admin-login/#{email_token.token}", params: { 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 + + describe '#toggle_anon' do + it 'allows you to toggle anon if enabled' do + SiteSetting.allow_anonymous_posting = true + + user = sign_in(Fabricate(:user)) + user.trust_level = 1 + user.save! + + post "/u/toggle-anon.json" + expect(response).to be_success + expect(session[:current_user_id]).to eq(AnonymousShadowCreator.get(user).id) + + post "/u/toggle-anon.json" + expect(response).to be_success + expect(session[:current_user_id]).to eq(user.id) + end end describe '#create' do + def honeypot_magic(params) + get '/u/hp.json' + json = JSON.parse(response.body) + params[:password_confirmation] = json["value"] + params[:challenge] = json["challenge"].reverse + params + end + + before do + UsersController.any_instance.stubs(:honeypot_value).returns(nil) + UsersController.any_instance.stubs(:challenge_value).returns(nil) + SiteSetting.allow_new_registrations = true + @user = Fabricate.build(:user) + @user.password = "strongpassword" + end + + let(:post_user_params) do + { name: @user.name, + username: @user.username, + password: "strongpassword", + email: @user.email } + end + + def post_user + post "/u.json", params: post_user_params + end + + context 'when email params is missing' do + it 'should raise the right error' do + post "/u.json", params: { + name: @user.name, + username: @user.username, + passsword: 'tesing12352343' + } + expect(response.status).to eq(400) + end + end + + context 'when creating a user' do + it 'sets the user locale to I18n.locale' do + SiteSetting.default_locale = 'en' + I18n.stubs(:locale).returns(:fr) + post_user + expect(User.find_by(username: @user.username).locale).to eq('fr') + end + end + + context 'when creating a non active user (unconfirmed email)' do + it 'returns a 500 when local logins are disabled' do + SiteSetting.enable_local_logins = false + post_user + + expect(response.status).to eq(500) + end + + it 'returns an error when new registrations are disabled' do + SiteSetting.allow_new_registrations = false + post_user + json = JSON.parse(response.body) + expect(json['success']).to eq(false) + expect(json['message']).to be_present + end + + it 'creates a user correctly' do + Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :signup)) + User.any_instance.expects(:enqueue_welcome_message).with('welcome_user').never + + post_user + + expect(JSON.parse(response.body)['active']).to be_falsey + + # should save user_created_message in session + expect(session["user_created_message"]).to be_present + expect(session[SessionController::ACTIVATE_USER_KEY]).to be_present + end + + context "`must approve users` site setting is enabled" do + before { SiteSetting.must_approve_users = true } + + it 'creates a user correctly' do + Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :signup)) + User.any_instance.expects(:enqueue_welcome_message).with('welcome_user').never + + post_user + + expect(JSON.parse(response.body)['active']).to be_falsey + + # should save user_created_message in session + expect(session["user_created_message"]).to be_present + expect(session[SessionController::ACTIVATE_USER_KEY]).to be_present + end + end + + context 'users already exists with given email' do + let!(:existing) { Fabricate(:user, email: post_user_params[:email]) } + + it 'returns an error if hide_email_address_taken is disabled' do + SiteSetting.hide_email_address_taken = false + post_user + json = JSON.parse(response.body) + expect(json['success']).to eq(false) + expect(json['message']).to be_present + end + + it 'returns success if hide_email_address_taken is enabled' do + SiteSetting.hide_email_address_taken = true + expect { + post_user + }.to_not change { User.count } + json = JSON.parse(response.body) + expect(json['active']).to be_falsey + expect(session["user_created_message"]).to be_present + end + end + end + + context "creating as active" do + it "won't create the user as active" do + post "/u.json", params: post_user_params.merge(active: true) + expect(JSON.parse(response.body)['active']).to be_falsey + end + + context "with a regular api key" do + let(:user) { Fabricate(:user) } + let(:api_key) { Fabricate(:api_key, user: user) } + + it "won't create the user as active with a regular key" do + post "/u.json", + params: post_user_params.merge(active: true, api_key: api_key.key) + + expect(JSON.parse(response.body)['active']).to be_falsey + end + end + + context "with an admin api key" do + let(:admin) { Fabricate(:admin) } + let(:api_key) { Fabricate(:api_key, user: admin) } + + it "creates the user as active with a regular key" do + SiteSetting.queue_jobs = true + SiteSetting.send_welcome_message = true + SiteSetting.must_approve_users = true + + Sidekiq::Client.expects(:enqueue).never + + post "/u.json", params: post_user_params.merge(approved: true, active: true, api_key: api_key.key) + + json = JSON.parse(response.body) + + new_user = User.find(json["user_id"]) + + expect(json['active']).to be_truthy + + expect(new_user.active).to eq(true) + expect(new_user.approved).to eq(true) + expect(new_user.approved_by_id).to eq(admin.id) + expect(new_user.approved_at).to_not eq(nil) + end + + it "won't create the developer as active" do + UsernameCheckerService.expects(:is_developer?).returns(true) + + post "/u.json", params: post_user_params.merge(active: true, api_key: api_key.key) + + expect(JSON.parse(response.body)['active']).to be_falsy + end + end + end + + context "creating as staged" do + it "won't create the user as staged" do + post "/u.json", params: post_user_params.merge(staged: true) + + new_user = User.where(username: post_user_params[:username]).first + expect(new_user.staged?).to eq(false) + end + + context "with a regular api key" do + let(:user) { Fabricate(:user) } + let(:api_key) { Fabricate(:api_key, user: user) } + + it "won't create the user as staged with a regular key" do + post "/u.json", params: post_user_params.merge(staged: true, api_key: api_key.key) + + new_user = User.where(username: post_user_params[:username]).first + expect(new_user.staged?).to eq(false) + end + end + + context "with an admin api key" do + let(:user) { Fabricate(:admin) } + let(:api_key) { Fabricate(:api_key, user: user) } + + it "creates the user as staged with a regular key" do + post "/u.json", params: post_user_params.merge(staged: true, api_key: api_key.key) + + new_user = User.where(username: post_user_params[:username]).first + expect(new_user.staged?).to eq(true) + end + + it "won't create the developer as staged" do + UsernameCheckerService.expects(:is_developer?).returns(true) + post "/u.json", params: post_user_params.merge(staged: true, api_key: api_key.key) + + new_user = User.where(username: post_user_params[:username]).first + expect(new_user.staged?).to eq(false) + end + end + end + + context 'when creating an active user (confirmed email)' do + before { User.any_instance.stubs(:active?).returns(true) } + + it 'enqueues a welcome email' do + User.any_instance.expects(:enqueue_welcome_message).with('welcome_user') + post_user + + # should save user_created_message in session + expect(session["user_created_message"]).to be_present + expect(session[SessionController::ACTIVATE_USER_KEY]).to be_present + end + + it "shows the 'active' message" do + User.any_instance.expects(:enqueue_welcome_message) + post_user + expect(JSON.parse(response.body)['message']).to eq( + I18n.t 'login.active' + ) + end + + it "should be logged in" do + User.any_instance.expects(:enqueue_welcome_message) + post_user + expect(session[:current_user_id]).to be_present + end + + it 'indicates the user is active in the response' do + User.any_instance.expects(:enqueue_welcome_message) + post_user + expect(JSON.parse(response.body)['active']).to be_truthy + end + + it 'returns 500 status when new registrations are disabled' do + SiteSetting.allow_new_registrations = false + + post_user + + json = JSON.parse(response.body) + expect(json['success']).to eq(false) + expect(json['message']).to be_present + end + + context 'authentication records for' do + let(:user) { Fabricate(:user) } + before do + OmniAuth.config.mock_auth[:twitter] = OmniAuth::AuthHash.new( + provider: 'twitter', + uid: '123545', + info: OmniAuth::AuthHash::InfoHash.new( + email: "osama@mail.com", + nickname: "testosama" + ) + ) + + Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:twitter] + SiteSetting.enable_twitter_logins = true + get "/auth/twitter/callback.json" + end + + it 'should create twitter user info if required' do + post "/u.json", params: { + name: "Test Osama", + username: "testosama", + password: "strongpassword", + email: "osama@mail.com" + } + + expect(TwitterUserInfo.count).to eq(1) + end + + it "returns an error when email has been changed from the validated email address" do + post "/u.json", params: { + name: "Test Osama", + username: "testosama", + password: "strongpassword", + email: "unvalidatedemail@mail.com" + } + + json = JSON.parse(response.body) + expect(json['success']).to eq(false) + expect(json['message']).to be_present + end + + it "will create the user successfully if email validation is required" do + post "/u.json", params: { + name: "Test Osama", + username: "testosama", + password: "strongpassword", + email: "osama@mail.com" + } + + json = JSON.parse(response.body) + expect(json['success']).to eq(true) + end + end + end + + context 'after success' do + before { post_user } + + it 'should succeed' do + expect(response).to be_success + #is_expected.to respond_with(:success) + end + + it 'has the proper JSON' do + json = JSON::parse(response.body) + expect(json["success"]).to eq(true) + end + + it 'should not result in an active account' do + expect(User.find_by(username: @user.username).active).to eq(false) + end + end + + shared_examples 'honeypot fails' do + it 'should not create a new user' do + expect { + post "/u.json", params: create_params + }.to_not change { User.count } + end + + it 'should not send an email' do + User.any_instance.expects(:enqueue_welcome_message).never + post "/u.json", params: create_params + end + + it 'should say it was successful' do + post "/u.json", params: create_params + json = JSON::parse(response.body) + expect(json["success"]).to eq(true) + + # should not change the session + expect(session["user_created_message"]).to be_blank + expect(session[SessionController::ACTIVATE_USER_KEY]).to be_blank + end + end + + context 'when honeypot value is wrong' do + before do + UsersController.any_instance.stubs(:honeypot_value).returns('abc') + end + let(:create_params) { { name: @user.name, username: @user.username, password: "strongpassword", email: @user.email, password_confirmation: 'wrong' } } + include_examples 'honeypot fails' + end + + context 'when challenge answer is wrong' do + before do + UsersController.any_instance.stubs(:challenge_value).returns('abc') + end + let(:create_params) { { name: @user.name, username: @user.username, password: "strongpassword", email: @user.email, challenge: 'abc' } } + include_examples 'honeypot fails' + end + + context "when 'invite only' setting is enabled" do + before { SiteSetting.invite_only = true } + + let(:create_params) { { + name: @user.name, + username: @user.username, + password: 'strongpassword', + email: @user.email + }} + + include_examples 'honeypot fails' + end + + shared_examples 'failed signup' do + it 'should not create a new User' do + expect { post "/u.json", params: create_params }.to_not change { User.count } + end + + it 'should report failed' do + post "/u.json", params: create_params + json = JSON::parse(response.body) + expect(json["success"]).not_to eq(true) + + # should not change the session + expect(session["user_created_message"]).to be_blank + expect(session[SessionController::ACTIVATE_USER_KEY]).to be_blank + end + end + + context 'when password is blank' do + let(:create_params) { { name: @user.name, username: @user.username, password: "", email: @user.email } } + include_examples 'failed signup' + end + + context 'when password is too long' do + let(:create_params) { { name: @user.name, username: @user.username, password: "x" * (User.max_password_length + 1), email: @user.email } } + include_examples 'failed signup' + end + + context 'when password param is missing' do + let(:create_params) { { name: @user.name, username: @user.username, email: @user.email } } + include_examples 'failed signup' + end + + context 'with a reserved username' do + let(:create_params) { { name: @user.name, username: 'Reserved', email: @user.email, password: "x" * 20 } } + before { SiteSetting.reserved_usernames = 'a|reserved|b' } + after { SiteSetting.reserved_usernames = nil } + include_examples 'failed signup' + end + + context 'when an Exception is raised' do + before { User.any_instance.stubs(:save).raises(ActiveRecord::StatementInvalid.new('Oh no')) } + + let(:create_params) { + { name: @user.name, username: @user.username, + password: "strongpassword", email: @user.email } + } + + include_examples 'failed signup' + end + + context "with custom fields" do + let!(:user_field) { Fabricate(:user_field) } + let!(:another_field) { Fabricate(:user_field) } + let!(:optional_field) { Fabricate(:user_field, required: false) } + + context "without a value for the fields" do + let(:create_params) { { name: @user.name, password: 'watwatwat', username: @user.username, email: @user.email } } + include_examples 'failed signup' + end + + context "with values for the fields" do + let(:create_params) { { + name: @user.name, + password: 'suChS3cuRi7y', + username: @user.username, + email: @user.email, + user_fields: { + user_field.id.to_s => 'value1', + another_field.id.to_s => 'value2', + } + } } + + it "should succeed without the optional field" do + post "/u.json", params: create_params + expect(response).to be_success + inserted = User.find_by_email(@user.email) + expect(inserted).to be_present + expect(inserted.custom_fields).to be_present + expect(inserted.custom_fields["user_field_#{user_field.id}"]).to eq('value1') + expect(inserted.custom_fields["user_field_#{another_field.id}"]).to eq('value2') + expect(inserted.custom_fields["user_field_#{optional_field.id}"]).to be_blank + end + + it "should succeed with the optional field" do + create_params[:user_fields][optional_field.id.to_s] = 'value3' + post "/u.json", params: create_params.merge(create_params) + expect(response).to be_success + inserted = User.find_by_email(@user.email) + expect(inserted).to be_present + expect(inserted.custom_fields).to be_present + expect(inserted.custom_fields["user_field_#{user_field.id}"]).to eq('value1') + expect(inserted.custom_fields["user_field_#{another_field.id}"]).to eq('value2') + expect(inserted.custom_fields["user_field_#{optional_field.id}"]).to eq('value3') + end + + it "trims excessively long fields" do + create_params[:user_fields][optional_field.id.to_s] = ('x' * 3000) + post "/u.json", params: create_params.merge(create_params) + expect(response).to be_success + inserted = User.find_by_email(@user.email) + + val = inserted.custom_fields["user_field_#{optional_field.id}"] + expect(val.length).to eq(UserField.max_length) + end + end + end + + context "with only optional custom fields" do + let!(:user_field) { Fabricate(:user_field, required: false) } + + context "without values for the fields" do + let(:create_params) { { + name: @user.name, + password: 'suChS3cuRi7y', + username: @user.username, + email: @user.email, + } } + + it "should succeed" do + post "/u.json", params: create_params + expect(response).to be_success + inserted = User.find_by_email(@user.email) + expect(inserted).to be_present + expect(inserted.custom_fields).not_to be_present + expect(inserted.custom_fields["user_field_#{user_field.id}"]).to be_blank + end + end + end context "when taking over a staged account" do + before do + UsersController.any_instance.stubs(:honeypot_value).returns("abc") + UsersController.any_instance.stubs(:challenge_value).returns("efg") + end + let!(:staged) { Fabricate(:staged, email: "staged@account.com", active: true) } it "succeeds" do @@ -42,10 +1001,1402 @@ RSpec.describe UsersController do expect(response.status).not_to eq(200) end end + end + describe '#username' do + it 'raises an error when not logged in' do + put "/u/somename/preferences/username.json" + expect(response.status).to eq(403) + end + + context 'while logged in' do + let(:old_username) { "OrigUsrname" } + let(:new_username) { "#{old_username}1234" } + let(:user) { Fabricate(:user, username: old_username) } + + before do + user.username = old_username + sign_in(user) + end + + it 'raises an error without a new_username param' do + put "/u/#{user.username}/preferences/username.json", params: { username: user.username } + expect(response).not_to be_success + expect(user.reload.username).to eq(old_username) + end + + it 'raises an error when you don\'t have permission to change the username' do + Guardian.any_instance.expects(:can_edit_username?).with(user).returns(false) + + put "/u/#{user.username}/preferences/username.json", params: { new_username: new_username } + + expect(response).to be_forbidden + expect(user.reload.username).to eq(old_username) + end + + it 'raises an error when change_username fails' do + put "/u/#{user.username}/preferences/username.json", params: { new_username: '@' } + + expect(response).to_not be_success + + body = JSON.parse(response.body) + + expect(body['errors'].first).to include(I18n.t( + 'user.username.short', min: User.username_length.begin + )) + + expect(user.reload.username).to eq(old_username) + end + + it 'should succeed in normal circumstances' do + put "/u/#{user.username}/preferences/username.json", params: { new_username: new_username } + + expect(response).to be_success + expect(user.reload.username).to eq(new_username) + end + + it 'should fail if the user is old' do + # Older than the change period and >1 post + user.created_at = Time.now - (SiteSetting.username_change_period + 1).days + PostCreator.new(user, + title: 'This is a test topic', + raw: 'This is a test this is a test' + ).create + + put "/u/#{user.username}/preferences/username.json", params: { new_username: new_username } + + expect(response).to be_forbidden + expect(user.reload.username).to eq(old_username) + end + + it 'should create a staff action log when a staff member changes the username' do + acting_user = Fabricate(:admin) + sign_in(acting_user) + + put "/u/#{user.username}/preferences/username.json", params: { new_username: new_username } + + expect(response).to be_success + expect(UserHistory.where(action: UserHistory.actions[:change_username], target_user_id: user.id, acting_user_id: acting_user.id)).to be_present + expect(user.reload.username).to eq(new_username) + end + + it 'should return a JSON response with the updated username' do + put "/u/#{user.username}/preferences/username.json", params: { new_username: new_username } + + expect(::JSON.parse(response.body)['username']).to eq(new_username) + end + end + end + + describe '#check_username' do + it 'raises an error without any parameters' do + get "/u/check_username.json" + expect(response).not_to be_success + end + + shared_examples 'when username is unavailable' do + it 'should return success' do + expect(response).to be_success + end + + it 'should return available as false in the JSON' do + expect(::JSON.parse(response.body)['available']).to eq(false) + end + + it 'should return a suggested username' do + expect(::JSON.parse(response.body)['suggestion']).to be_present + end + end + + shared_examples 'when username is available' do + it 'should return success' do + expect(response).to be_success + end + + it 'should return available in the JSON' do + expect(::JSON.parse(response.body)['available']).to eq(true) + end + end + + it 'returns nothing when given an email param but no username' do + get "/u/check_username.json", params: { email: 'dood@example.com' } + expect(response).to be_success + end + + context 'username is available' do + before do + get "/u/check_username.json", params: { username: 'BruceWayne' } + end + include_examples 'when username is available' + end + + context 'username is unavailable' do + let!(:user) { Fabricate(:user) } + before do + get "/u/check_username.json", params: { username: user.username } + end + include_examples 'when username is unavailable' + end + + shared_examples 'checking an invalid username' do + it 'should return success' do + expect(response).to be_success + end + + it 'should not return an available key' do + expect(::JSON.parse(response.body)['available']).to eq(nil) + end + + it 'should return an error message' do + expect(::JSON.parse(response.body)['errors']).not_to be_empty + end + end + + context 'has invalid characters' do + before do + get "/u/check_username.json", params: { username: 'bad username' } + end + include_examples 'checking an invalid username' + + it 'should return the invalid characters message' do + expect(::JSON.parse(response.body)['errors']).to include(I18n.t(:'user.username.characters')) + end + end + + context 'is too long' do + before do + get "/u/check_username.json", params: { username: generate_username(User.username_length.last + 1) } + end + include_examples 'checking an invalid username' + + it 'should return the "too long" message' do + expect(::JSON.parse(response.body)['errors']).to include(I18n.t(:'user.username.long', max: User.username_length.end)) + end + end + + describe 'different case of existing username' do + context "it's my username" do + let!(:user) { Fabricate(:user, username: 'hansolo') } + before do + sign_in(user) + + get "/u/check_username.json", params: { username: 'HanSolo' } + end + include_examples 'when username is available' + end + + context "it's someone else's username" do + let!(:user) { Fabricate(:user, username: 'hansolo') } + before do + sign_in(Fabricate(:user)) + + get "/u/check_username.json", params: { username: 'HanSolo' } + end + include_examples 'when username is unavailable' + end + + context "an admin changing it for someone else" do + let!(:user) { Fabricate(:user, username: 'hansolo') } + before do + sign_in(Fabricate(:admin)) + + get "/u/check_username.json", params: { username: 'HanSolo', for_user_id: user.id } + end + include_examples 'when username is available' + end + end + end + + describe '#invited' do + it 'returns success' do + user = Fabricate(:user) + get "/u/#{user.username}/invited.json", params: { username: user.username } + + expect(response).to be_success + end + + it 'filters by email' do + inviter = Fabricate(:user) + invitee = Fabricate(:user) + _invite = Fabricate( + :invite, + email: 'billybob@example.com', + invited_by: inviter, + user: invitee + ) + Fabricate( + :invite, + email: 'jimtom@example.com', + invited_by: inviter, + user: invitee + ) + + get "/u/#{inviter.username}/invited.json", params: { search: 'billybob' } + + invites = JSON.parse(response.body)['invites'] + expect(invites.size).to eq(1) + expect(invites.first).to include('email' => 'billybob@example.com') + end + + it 'filters by username' do + inviter = Fabricate(:user) + invitee = Fabricate(:user, username: 'billybob') + _invite = Fabricate( + :invite, + invited_by: inviter, + email: 'billybob@example.com', + user: invitee + ) + Fabricate( + :invite, + invited_by: inviter, + user: Fabricate(:user, username: 'jimtom') + ) + + get "/u/#{inviter.username}/invited.json", params: { search: 'billybob' } + + invites = JSON.parse(response.body)['invites'] + expect(invites.size).to eq(1) + expect(invites.first).to include('email' => 'billybob@example.com') + end + + context 'with guest' do + context 'with pending invites' do + it 'does not return invites' do + inviter = Fabricate(:user) + Fabricate(:invite, invited_by: inviter) + + get "/u/#{user.username}/invited/pending.json" + + invites = JSON.parse(response.body)['invites'] + expect(invites).to be_empty + end + end + + context 'with redeemed invites' do + it 'returns invites' do + inviter = Fabricate(:user) + invitee = Fabricate(:user) + invite = Fabricate(:invite, invited_by: inviter, user: invitee) + + get "/u/#{inviter.username}/invited.json" + + invites = JSON.parse(response.body)['invites'] + expect(invites.size).to eq(1) + expect(invites.first).to include('email' => invite.email) + end + end + end + + context 'with authenticated user' do + context 'with pending invites' do + context 'with permission to see pending invites' do + it 'returns invites' do + inviter = Fabricate(:user) + invite = Fabricate(:invite, invited_by: inviter) + sign_in(inviter) + + get "/u/#{inviter.username}/invited/pending.json" + + invites = JSON.parse(response.body)['invites'] + expect(invites.size).to eq(1) + expect(invites.first).to include("email" => invite.email) + end + end + + context 'without permission to see pending invites' do + it 'does not return invites' do + user = sign_in(Fabricate(:user)) + inviter = Fabricate(:user) + _invitee = Fabricate(:user) + Fabricate(:invite, invited_by: inviter) + stub_guardian(user) do |guardian| + guardian.stubs(:can_see_invite_details?). + with(inviter).returns(false) + end + + get "/u/#{inviter.username}/invited/pending.json" + + json = JSON.parse(response.body)['invites'] + expect(json).to be_empty + end + end + end + + context 'with redeemed invites' do + it 'returns invites' do + _user = sign_in(Fabricate(:user)) + inviter = Fabricate(:user) + invitee = Fabricate(:user) + invite = Fabricate(:invite, invited_by: inviter, user: invitee) + + get "/u/#{inviter.username}/invited.json" + + invites = JSON.parse(response.body)['invites'] + expect(invites.size).to eq(1) + expect(invites.first).to include('email' => invite.email) + end + end + end + end + + describe '#update' do + context 'with guest' do + it 'raises an error' do + put "/u/guest.json" + expect(response.status).to eq(403) + end + end + + context "when username contains a period" do + before do + sign_in(user) + end + let(:user) { Fabricate(:user) } + + it "should be able to update a user" do + put "/u/#{user.username}.json", params: { name: 'test.test' } + + expect(response).to be_success + expect(user.reload.name).to eq('test.test') + end + + it "should be able to update a user" do + put "/u/#{user.username}.json", params: { name: 'testing123' } + + expect(response).to be_success + expect(user.reload.name).to eq('testing123') + end + end + + context "as a staff user" do + context "uneditable field" do + let!(:user_field) { Fabricate(:user_field, editable: false) } + + it "allows staff to edit the field" do + sign_in(Fabricate(:admin)) + user = Fabricate(:user) + put "/u/#{user.username}.json", params: { + name: 'Jim Tom', + title: "foobar", + user_fields: { user_field.id.to_s => 'happy' } + } + + expect(response).to be_success + + user.reload + + expect(user.user_fields[user_field.id.to_s]).to eq('happy') + expect(user.title).to eq("foobar") + end + end + end + + context 'with authenticated user' do + context 'with permission to update' do + let!(:user) { sign_in(Fabricate(:user)) } + + it 'allows the update' do + user2 = Fabricate(:user) + user3 = Fabricate(:user) + tags = [Fabricate(:tag), Fabricate(:tag)] + + put "/u/#{user.username}.json", params: { + name: 'Jim Tom', + custom_fields: { test: :it }, + muted_usernames: "#{user2.username},#{user3.username}", + watched_tags: "#{tags[0].name},#{tags[1].name}" + } + + expect(response).to be_success + + user.reload + + expect(user.name).to eq 'Jim Tom' + expect(user.custom_fields['test']).to eq 'it' + expect(user.muted_users.pluck(:username).sort).to eq [user2.username, user3.username].sort + expect(TagUser.where( + user: user, + notification_level: TagUser.notification_levels[:watching] + ).pluck(:tag_id)).to contain_exactly(tags[0].id, tags[1].id) + + theme = Theme.create(name: "test", user_selectable: true, user_id: -1) + + put "/u/#{user.username}.json", params: { + muted_usernames: "", + theme_key: theme.key, + email_direct: false + } + + user.reload + + expect(user.muted_users.pluck(:username).sort).to be_empty + expect(user.user_option.theme_key).to eq(theme.key) + expect(user.user_option.email_direct).to eq(false) + end + + context 'a locale is chosen that differs from I18n.locale' do + it "updates the user's locale" do + I18n.stubs(:locale).returns('fr') + put "/u/#{user.username}.json", params: { locale: :fa_IR } + expect(User.find_by(username: user.username).locale).to eq('fa_IR') + end + end + + context "with user fields" do + context "an editable field" do + let!(:user_field) { Fabricate(:user_field) } + let!(:optional_field) { Fabricate(:user_field, required: false) } + + it "should update the user field" do + put "/u/#{user.username}.json", params: { name: 'Jim Tom', user_fields: { user_field.id.to_s => 'happy' } } + + expect(response).to be_success + expect(user.user_fields[user_field.id.to_s]).to eq 'happy' + end + + it "cannot be updated to blank" do + put "/u/#{user.username}.json", params: { name: 'Jim Tom', user_fields: { user_field.id.to_s => '' } } + + expect(response).not_to be_success + expect(user.user_fields[user_field.id.to_s]).not_to eq('happy') + end + + it "trims excessively large fields" do + put "/u/#{user.username}.json", params: { name: 'Jim Tom', user_fields: { user_field.id.to_s => ('x' * 3000) } } + + expect(user.user_fields[user_field.id.to_s].size).to eq(UserField.max_length) + end + + it "should retain existing user fields" do + put "/u/#{user.username}.json", params: { name: 'Jim Tom', user_fields: { user_field.id.to_s => 'happy', optional_field.id.to_s => 'feet' } } + + expect(response).to be_success + expect(user.user_fields[user_field.id.to_s]).to eq('happy') + expect(user.user_fields[optional_field.id.to_s]).to eq('feet') + + put "/u/#{user.username}.json", params: { name: 'Jim Tom', user_fields: { user_field.id.to_s => 'sad' } } + + expect(response).to be_success + + user.reload + + expect(user.user_fields[user_field.id.to_s]).to eq('sad') + expect(user.user_fields[optional_field.id.to_s]).to eq('feet') + end + end + + context "uneditable field" do + let!(:user_field) { Fabricate(:user_field, editable: false) } + + it "does not update the user field" do + put "/u/#{user.username}.json", params: { name: 'Jim Tom', user_fields: { user_field.id.to_s => 'happy' } } + + expect(response).to be_success + expect(user.user_fields[user_field.id.to_s]).to be_blank + end + end + end + + it 'returns user JSON' do + put "/u/#{user.username}.json" + + json = JSON.parse(response.body) + expect(json['user']['id']).to eq user.id + end + end + + context 'without permission to update' do + it 'does not allow the update' do + user = Fabricate(:user, name: 'Billy Bob') + sign_in(Fabricate(:user)) + #Guardian.any_instance.expects(:can_edit?).with(user).returns(false) + + put "/u/#{user.username}.json", params: { name: 'Jim Tom' } + + expect(response).to be_forbidden + expect(user.reload.name).not_to eq 'Jim Tom' + end + end + end + end + + describe '#badge_title' do + let(:user) { Fabricate(:user) } + let(:badge) { Fabricate(:badge) } + let(:user_badge) { BadgeGranter.grant(badge, user) } + + it "sets the user's title to the badge name if it is titleable" do + sign_in(user) + + put "/u/#{user.username}/preferences/badge_title.json", params: { user_badge_id: user_badge.id } + + expect(user.reload.title).not_to eq(badge.display_name) + badge.update_attributes allow_title: true + + put "/u/#{user.username}/preferences/badge_title.json", params: { user_badge_id: user_badge.id } + + expect(user.reload.title).to eq(badge.display_name) + expect(user.user_profile.badge_granted_title).to eq(true) + + user.title = "testing" + user.save + user.user_profile.reload + expect(user.user_profile.badge_granted_title).to eq(false) + end + + context "with overrided name" do + let(:badge) { Fabricate(:badge, name: 'Demogorgon', allow_title: true) } + let(:user_badge) { BadgeGranter.grant(badge, user) } + + before do + TranslationOverride.upsert!('en', 'badges.demogorgon.name', 'Boss') + end + + after do + TranslationOverride.revert!('en', ['badges.demogorgon.name']) + end + + it "uses the badge display name as user title" do + sign_in(user) + + put "/u/#{user.username}/preferences/badge_title.json", params: { user_badge_id: user_badge.id } + expect(user.reload.title).to eq(badge.display_name) + end + end + end + + describe '#send_activation_email' do + before do + UsersController.any_instance.stubs(:honeypot_value).returns(nil) + UsersController.any_instance.stubs(:challenge_value).returns(nil) + end + + let(:post_user) do + post "/u.json", params: { + username: "osamatest", + password: "strongpassword", + email: "dsdsds@sasa.com" + } + User.where(username: "osamatest").first + end + + context 'for an existing user' do + context 'for an activated account with email confirmed' do + it 'fails' do + user = post_user + email_token = user.email_tokens.create(email: user.email).token + EmailToken.confirm(email_token) + + post "/u/action/send_activation_email.json", params: { username: user.username } + + expect(response.status).to eq(409) + expect(JSON.parse(response.body)['errors']).to include(I18n.t( + 'activation.activated' + )) + expect(session[SessionController::ACTIVATE_USER_KEY]).to eq(nil) + end + end + + context 'for an activated account with unconfirmed email' do + it 'should send an email' do + user = post_user + user.update(active: true) + user.save! + user.email_tokens.create(email: user.email) + Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :signup, to_address: user.email)) + + post "/u/action/send_activation_email.json", params: { + username: user.username + } + + expect(response.status).to eq(200) + + expect(session[SessionController::ACTIVATE_USER_KEY]).to eq(nil) + end + end + + context "approval is enabled" do + before do + SiteSetting.must_approve_users = true + end + + it "should raise an error" do + user = post_user + user.update(active: true) + user.save! + user.email_tokens.create(email: user.email) + post "/u/action/send_activation_email.json", params: { + username: user.username + } + + expect(response.status).to eq(403) + end + end + + describe 'when user does not have a valid session' do + it 'should not be valid' do + user = Fabricate(:user) + post "/u/action/send_activation_email.json", params: { + username: user.username + } + expect(response.status).to eq(403) + end + + it 'should allow staff regardless' do + sign_in(Fabricate(:admin)) + user = Fabricate(:user, active: false) + post "/u/action/send_activation_email.json", params: { + username: user.username + } + expect(response.status).to eq(200) + end + end + + context 'with a valid email_token' do + it 'should send the activation email' do + user = post_user + Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :signup)) + post "/u/action/send_activation_email.json", params: { + username: user.username + } + expect(session[SessionController::ACTIVATE_USER_KEY]).to eq(nil) + end + end + + context 'without an existing email_token' do + let(:user) { post_user } + before do + user.email_tokens.each { |t| t.destroy } + user.reload + end + + it 'should generate a new token' do + expect { + post "/u/action/send_activation_email.json", params: { username: user.username } + }.to change { user.reload.email_tokens.count }.by(1) + end + + it 'should send an email' do + Jobs.expects(:enqueue).with(:critical_user_email, has_entries(type: :signup)) + post "/u/action/send_activation_email.json", params: { username: user.username } + expect(session[SessionController::ACTIVATE_USER_KEY]).to eq(nil) + end + end + end + + context 'when username does not exist' do + it 'should not send an email' do + Jobs.expects(:enqueue).never + + post "/u/action/send_activation_email.json", params: { username: 'nopenopenopenope' } + end + end + end + + describe '#pick_avatar' do + it 'raises an error when not logged in' do + put "/u/asdf/preferences/avatar/pick.json", params: { avatar_id: 1, type: "custom" } + expect(response.status).to eq(403) + end + + context 'while logged in' do + + let!(:user) { sign_in(Fabricate(:user)) } + let(:upload) { Fabricate(:upload) } + + it "raises an error when you don't have permission to toggle the avatar" do + another_user = Fabricate(:user) + put "/u/#{another_user.username}/preferences/avatar/pick.json", params: { + upload_id: upload.id, type: "custom" + } + + expect(response).to be_forbidden + end + + it "raises an error when sso_overrides_avatar is disabled" do + SiteSetting.sso_overrides_avatar = true + put "/u/#{user.username}/preferences/avatar/pick.json", params: { + upload_id: upload.id, type: "custom" + } + + expect(response).to_not be_success + end + + it "raises an error when selecting the custom/uploaded avatar and allow_uploaded_avatars is disabled" do + SiteSetting.allow_uploaded_avatars = false + put "/u/#{user.username}/preferences/avatar/pick.json", params: { + upload_id: upload.id, type: "custom" + } + + expect(response).to_not be_success + end + + it 'can successfully pick the system avatar' do + put "/u/#{user.username}/preferences/avatar/pick.json" + + expect(response).to be_success + expect(user.reload.uploaded_avatar_id).to eq(nil) + end + + it 'can successfully pick a gravatar' do + put "/u/#{user.username}/preferences/avatar/pick.json", params: { + upload_id: upload.id, type: "gravatar" + } + + expect(response).to be_success + expect(user.reload.uploaded_avatar_id).to eq(upload.id) + expect(user.user_avatar.reload.gravatar_upload_id).to eq(upload.id) + end + + it 'can successfully pick a custom avatar' do + put "/u/#{user.username}/preferences/avatar/pick.json", params: { + upload_id: upload.id, type: "custom" + } + + expect(response).to be_success + expect(user.reload.uploaded_avatar_id).to eq(upload.id) + expect(user.user_avatar.reload.custom_upload_id).to eq(upload.id) + end + end + end + + describe '#destroy_user_image' do + + it 'raises an error when not logged in' do + delete "/u/asdf/preferences/user_image.json", params: { type: 'profile_background' } + expect(response.status).to eq(403) + end + + context 'while logged in' do + let(:another_user) { Fabricate(:user) } + let(:user) { Fabricate(:user) } + before do + sign_in(user) + end + + it 'raises an error when you don\'t have permission to clear the profile background' do + delete "/u/#{another_user.username}/preferences/user_image.json", params: { type: 'profile_background' } + expect(response).to be_forbidden + end + + it "requires the `type` param" do + delete "/u/#{user.username}/preferences/user_image.json" + expect(response.status).to eq(400) + end + + it "only allows certain `types`" do + delete "/u/#{user.username}/preferences/user_image.json", params: { type: 'wat' } + expect(response.status).to eq(400) + end + + it 'can clear the profile background' do + delete "/u/#{user.username}/preferences/user_image.json", params: { type: 'profile_background' } + + expect(user.reload.user_profile.profile_background).to eq("") + expect(response).to be_success + end + end + end + + describe '#destroy' do + it 'raises an error when not logged in' do + delete "/u/nobody.json" + expect(response.status).to eq(403) + end + + context 'while logged in' do + let(:user) { Fabricate(:user) } + let(:another_user) { Fabricate(:user) } + before do + sign_in(user) + end + + it 'raises an error when you cannot delete your account' do + UserDestroyer.any_instance.expects(:destroy).never + stat = user.user_stat + stat.post_count = 3 + stat.save! + delete "/u/#{user.username}.json" + expect(response).to be_forbidden + end + + it "raises an error when you try to delete someone else's account" do + UserDestroyer.any_instance.expects(:destroy).never + delete "/u/#{another_user.username}.json" + expect(response).to be_forbidden + end + + it "deletes your account when you're allowed to" do + UserDestroyer.any_instance.expects(:destroy).with(user, anything).returns(user) + delete "/u/#{user.username}.json" + expect(response).to be_success + end + end + end + + describe '#my_redirect' do + it "redirects if the user is not logged in" do + get "/my/wat.json" + expect(response).not_to be_success + expect(response).to be_redirect + end + + context "when the user is logged in" do + let!(:user) { sign_in(Fabricate(:user)) } + + it "will not redirect to an invalid path" do + get "/my/wat/..password.txt" + expect(response).not_to be_redirect + end + + it "will redirect to an valid path" do + get "/my/preferences.json" + expect(response).to be_redirect + end + + it "permits forward slashes" do + get "/my/activity/posts.json" + expect(response).to be_redirect + end + end + end + + describe '#check_emails' do + it 'raises an error when not logged in' do + get "/u/zogstrip/emails.json" + expect(response.status).to eq(403) + end + + context 'while logged in' do + let(:sign_in_admin) { sign_in(Fabricate(:admin)) } + + it "raises an error when you aren't allowed to check emails" do + sign_in(Fabricate(:user)) + get "/u/#{Fabricate(:user).username}/emails.json" + expect(response).to be_forbidden + end + + it "returns both email and associated_accounts when you're allowed to see them" do + sign_in_admin + + get "/u/#{Fabricate(:user).username}/emails.json" + + expect(response).to be_success + json = JSON.parse(response.body) + expect(json["email"]).to be_present + expect(json["associated_accounts"]).to be_present + end + + it "works on inactive users" do + inactive_user = Fabricate(:user, active: false) + sign_in_admin + + get "/u/#{inactive_user.username}/emails.json" + + expect(response).to be_success + json = JSON.parse(response.body) + expect(json["email"]).to be_present + expect(json["associated_accounts"]).to be_present + end + end + end + + describe '#is_local_username' do + let(:user) { Fabricate(:user) } + let(:group) { Fabricate(:group, name: "Discourse") } + let(:topic) { Fabricate(:topic) } + let(:allowed_user) { Fabricate(:user) } + let(:private_topic) { Fabricate(:private_message_topic, user: allowed_user) } + + it "finds the user" do + get "/u/is_local_username.json", params: { username: user.username } + + expect(response).to be_success + json = JSON.parse(response.body) + expect(json["valid"][0]).to eq(user.username) + end + + it "finds the group" do + get "/u/is_local_username.json", params: { username: group.name } + + expect(response).to be_success + json = JSON.parse(response.body) + expect(json["valid_groups"][0]).to eq(group.name) + end + + it "supports multiples usernames" do + get "/u/is_local_username.json", params: { usernames: [user.username, "system"] } + + expect(response).to be_success + json = JSON.parse(response.body) + expect(json["valid"].size).to eq(2) + end + + it "never includes staged accounts" do + staged = Fabricate(:user, staged: true) + + get "/u/is_local_username.json", params: { usernames: [staged.username] } + + expect(response).to be_success + json = JSON.parse(response.body) + expect(json["valid"].size).to eq(0) + end + + it "returns user who cannot see topic" do + Guardian.any_instance.expects(:can_see?).with(topic).returns(false) + + get "/u/is_local_username.json", params: { + usernames: [user.username], topic_id: topic.id + } + + expect(response).to be_success + json = JSON.parse(response.body) + expect(json["cannot_see"].size).to eq(1) + end + + it "never returns a user who can see the topic" do + Guardian.any_instance.expects(:can_see?).with(topic).returns(true) + + get "/u/is_local_username.json", params: { + usernames: [user.username], topic_id: topic.id + } + + expect(response).to be_success + json = JSON.parse(response.body) + expect(json["cannot_see"].size).to eq(0) + end + + it "returns user who cannot see a private topic" do + Guardian.any_instance.expects(:can_see?).with(private_topic).returns(false) + + get "/u/is_local_username.json", params: { + usernames: [user.username], topic_id: private_topic.id + } + + expect(response).to be_success + json = JSON.parse(response.body) + expect(json["cannot_see"].size).to eq(1) + end + + it "never returns a user who can see the topic" do + Guardian.any_instance.expects(:can_see?).with(private_topic).returns(true) + + get "/u/is_local_username.json", params: { + usernames: [allowed_user.username], topic_id: private_topic.id + } + + expect(response).to be_success + json = JSON.parse(response.body) + expect(json["cannot_see"].size).to eq(0) + end + end + + describe '#topic_tracking_state' do + let(:user) { Fabricate(:user) } + + context 'anon' do + it "raises an error on anon for topic_tracking_state" do + get "/u/#{user.username}/topic-tracking-state.json" + expect(response.status).to eq(403) + end + end + + context 'logged on' do + it "detects new topic" do + sign_in(user) + + topic = Fabricate(:topic) + get "/u/#{user.username}/topic-tracking-state.json" + + states = JSON.parse(response.body) + expect(states[0]["topic_id"]).to eq(topic.id) + end + end + end + + describe '#summary' do + it "generates summary info" do + user = Fabricate(:user) + create_post(user: user) + + get "/u/#{user.username_lower}/summary.json" + expect(response).to be_success + json = JSON.parse(response.body) + + expect(json["user_summary"]["topic_count"]).to eq(1) + expect(json["user_summary"]["post_count"]).to eq(0) + end + end + + describe '#confirm_admin' do + it "fails without a valid token" do + get "/u/confirm-admin/invalid-token.josn" + expect(response).not_to be_success + end + + it "fails with a missing token" do + get "/u/confirm-admin/a0a0a0a0a0.josn" + expect(response).to_not be_success + end + + it "succeeds with a valid code as anonymous" do + user = Fabricate(:user) + ac = AdminConfirmation.new(user, Fabricate(:admin)) + ac.create_confirmation + get "/u/confirm-admin/#{ac.token}.josn" + expect(response).to be_success + + user.reload + expect(user.admin?).to eq(false) + end + + it "succeeds with a valid code when logged in as that user" do + admin = sign_in(Fabricate(:admin)) + user = Fabricate(:user) + + ac = AdminConfirmation.new(user, admin) + ac.create_confirmation + get "/u/confirm-admin/#{ac.token}.josn", params: { token: ac.token } + expect(response).to be_success + + user.reload + expect(user.admin?).to eq(false) + end + + it "fails if you're logged in as a different account" do + sign_in(Fabricate(:admin)) + user = Fabricate(:user) + + ac = AdminConfirmation.new(user, Fabricate(:admin)) + ac.create_confirmation + get "/u/confirm-admin/#{ac.token}.josn" + expect(response).to_not be_success + + user.reload + expect(user.admin?).to eq(false) + end + + describe "post" do + it "gives the user admin access when POSTed" do + user = Fabricate(:user) + ac = AdminConfirmation.new(user, Fabricate(:admin)) + ac.create_confirmation + post "/u/confirm-admin/#{ac.token}.josn" + expect(response).to be_success + + user.reload + expect(user.admin?).to eq(true) + end + end + end + + describe '#update_activation_email' do + before do + UsersController.any_instance.stubs(:honeypot_value).returns(nil) + UsersController.any_instance.stubs(:challenge_value).returns(nil) + end + + let(:post_user) do + post "/u.json", params: { + username: "osamatest", + password: "strongpassword", + email: "osama@example.com" + } + user = User.where(username: "osamatest").first + user.active = false + user.save! + user + end + + context "with a session variable" do + it "raises an error with an invalid session value" do + post_user + + post "/u.json", params: { + username: "osamatest2", + password: "strongpassword2", + email: "osama22@example.com" + } + user = User.where(username: "osamatest2").first + user.destroy + + put "/u/update-activation-email.json", params: { + email: 'osamaupdated@example.com' + } + + expect(response.status).to eq(403) + end + + it "raises an error for an active user" do + user = post_user + user.update(active: true) + user.save! + + put "/u/update-activation-email.json", params: { + email: 'osama@example.com' + } + + expect(response.status).to eq(403) + end + + it "raises an error when logged in" do + moderator = sign_in(Fabricate(:moderator)) + post_user + + put "/u/update-activation-email.json", params: { + email: 'updatedemail@example.com' + } + + expect(response.status).to eq(403) + end + + it "raises an error when the new email is taken" do + active_user = Fabricate(:user) + user = post_user + + put "/u/update-activation-email.json", params: { + email: active_user.email + } + + expect(response.status).to eq(422) + end + + it "raises an error when the email is blacklisted" do + user = post_user + SiteSetting.email_domains_blacklist = 'example.com' + put "/u/update-activation-email.json", params: { email: 'test@example.com' } + expect(response.status).to eq(422) + end + + it "can be updated" do + user = post_user + token = user.email_tokens.first + + put "/u/update-activation-email.json", params: { + email: 'updatedemail@example.com' + } + + expect(response).to be_success + + user.reload + expect(user.email).to eq('updatedemail@example.com') + expect(user.email_tokens.where(email: 'updatedemail@example.com', expired: false)).to be_present + + token.reload + expect(token.expired?).to eq(true) + end + end + + context "with a username and password" do + it "raises an error with an invalid username" do + put "/u/update-activation-email.json", params: { + username: 'eviltrout', + password: 'invalid-password', + email: 'updatedemail@example.com' + } + + expect(response).to_not be_success + end + + it "raises an error with an invalid password" do + put "/u/update-activation-email.json", params: { + username: Fabricate(:inactive_user).username, + password: 'invalid-password', + email: 'updatedemail@example.com' + } + + expect(response).to_not be_success + end + + it "raises an error for an active user" do + put "/u/update-activation-email.json", params: { + username: Fabricate(:walter_white).username, + password: 'letscook', + email: 'updatedemail@example.com' + } + + expect(response).to_not be_success + end + + it "raises an error when logged in" do + sign_in(Fabricate(:moderator)) + + put "/u/update-activation-email.json", params: { + username: Fabricate(:inactive_user).username, + password: 'qwerqwer123', + email: 'updatedemail@example.com' + } + + expect(response).to_not be_success + end + + it "raises an error when the new email is taken" do + user = Fabricate(:user) + + put "/u/update-activation-email.json", params: { + username: Fabricate(:inactive_user).username, + password: 'qwerqwer123', + email: user.email + } + + expect(response).to_not be_success + end + + it "can be updated" do + user = Fabricate(:inactive_user) + token = user.email_tokens.first + + put "/u/update-activation-email.json", params: { + username: user.username, + password: 'qwerqwer123', + email: 'updatedemail@example.com' + } + + expect(response).to be_success + + user.reload + expect(user.email).to eq('updatedemail@example.com') + expect(user.email_tokens.where(email: 'updatedemail@example.com', expired: false)).to be_present + + token.reload + expect(token.expired?).to eq(true) + end + end end describe '#show' do + context "anon" do + let(:user) { Discourse.system_user } + let(:other_user) { Fabricate(:user) } + + it "returns success" do + get "/u/#{user.username}.json" + expect(response).to be_success + expect(JSON.parse(response.body)["user"]["username"]).to eq(user.username) + end + + it "should redirect to login page for anonymous user when profiles are hidden" do + SiteSetting.hide_user_profiles_from_public = true + get "/u/#{user.username}.json" + expect(response).to redirect_to '/login' + end + + describe "user profile views" do + let(:other_user) { Fabricate(:user) } + + it "should track a user profile view for an anon user" do + get "/" + UserProfileView.expects(:add).with(other_user.user_profile.id, request.remote_ip, nil) + get "/u/#{other_user.username}.json" + end + + it "skips tracking" do + UserProfileView.expects(:add).never + get "/u/#{user.username}.json", params: { skip_track_visit: true } + end + end + end + + context "logged in" do + before do + sign_in(user) + end + + let(:user) { Fabricate(:user) } + + it 'returns success' do + get "/u/#{user.username}.json" + expect(response).to be_success + json = JSON.parse(response.body) + + expect(json["user"]["has_title_badges"]).to eq(false) + end + + it "returns not found when the username doesn't exist" do + get "/u/madeuppity.json" + expect(response).not_to be_success + end + + it 'returns not found when the user is inactive' do + inactive = Fabricate(:user, active: false) + get "/u/#{inactive.username}.json" + expect(response).not_to be_success + end + + it 'returns success when show_inactive_accounts is true and user is logged in' do + SiteSetting.show_inactive_accounts = true + inactive = Fabricate(:user, active: false) + get "/u/#{inactive.username}.json" + expect(response).to be_success + end + + it "raises an error on invalid access" do + Guardian.any_instance.expects(:can_see?).with(user).returns(false) + get "/u/#{user.username}.json" + expect(response).to be_forbidden + end + + describe "user profile views" do + let(:other_user) { Fabricate(:user) } + + it "should track a user profile view for a signed in user" do + UserProfileView.expects(:add).with(other_user.user_profile.id, request.remote_ip, user.id) + get "/u/#{other_user.username}.json" + end + + it "should not track a user profile view for a user viewing his own profile" do + UserProfileView.expects(:add).never + get "/u/#{user.username}.json" + end + + it "skips tracking" do + UserProfileView.expects(:add).never + get "/u/#{user.username}.json", params: { skip_track_visit: true } + end + end + + context "fetching a user by external_id" do + before { user.create_single_sign_on_record(external_id: '997', last_payload: '') } + + it "returns fetch for a matching external_id" do + get "/u/by-external/997.json" + expect(response).to be_success + expect(JSON.parse(response.body)["user"]["username"]).to eq(user.username) + end + + it "returns not found when external_id doesn't match" do + get "/u/by-external/99.json" + expect(response).not_to be_success + end + end + + describe "include_post_count_for" do + + let(:admin) { Fabricate(:admin) } + let(:topic) { Fabricate(:topic) } + + before do + Fabricate(:post, user: user, topic: topic) + Fabricate(:post, user: admin, topic: topic) + Fabricate(:post, user: admin, topic: topic, post_type: Post.types[:whisper]) + end + + it "includes only visible posts" do + get "/u/#{admin.username}.json", params: { include_post_count_for: topic.id } + topic_post_count = JSON.parse(response.body).dig("user", "topic_post_count") + expect(topic_post_count[topic.id.to_s]).to eq(1) + end + + it "includes all post types for staff members" do + sign_in(admin) + + get "/u/#{admin.username}.json", params: { include_post_count_for: topic.id } + topic_post_count = JSON.parse(response.body).dig("user", "topic_post_count") + expect(topic_post_count[topic.id.to_s]).to eq(2) + end + end + end it "should be able to view a user" do get "/u/#{user.username}" @@ -68,7 +2419,7 @@ RSpec.describe UsersController do end end - describe "#badges" do + describe '#badges' do it "renders fine by default" do get "/u/#{user.username}/badges" expect(response).to be_success @@ -81,33 +2432,7 @@ RSpec.describe UsersController do end end - describe "updating a user" do - before do - sign_in(user) - end - - it "should be able to update a user" do - put "/u/#{user.username}.json", params: { name: 'test.test' } - - expect(response).to be_success - expect(user.reload.name).to eq('test.test') - end - - describe 'when username contains a period' do - before do - user.update!(username: 'test.test') - end - - it "should be able to update a user" do - put "/u/#{user.username}.json", params: { name: 'testing123' } - - expect(response).to be_success - expect(user.reload.name).to eq('testing123') - end - end - end - - describe "#account_created" do + describe '#account_created' do it "returns a message when no session is present" do get "/u/account-created" @@ -141,7 +2466,7 @@ RSpec.describe UsersController do end end - describe "search_users" do + describe '#search_users' do let(:topic) { Fabricate :topic } let(:user) { Fabricate :user, username: "joecabot", name: "Lawrence Tierney" } let(:post1) { Fabricate(:post, user: user, topic: topic) } @@ -328,7 +2653,7 @@ RSpec.describe UsersController do end end - describe '.user_preferences_redirect' do + describe '#user_preferences_redirect' do it 'requires the user to be logged in' do get '/user_preferences' expect(response.status).to eq(404) From d43895e2a0010ccb7c94213814baeb56c01de064 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 25 May 2018 10:11:16 +0800 Subject: [PATCH 156/278] Don't log 404s for `FinalDestination`. * We can't do anything about 404s --- lib/final_destination.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/final_destination.rb b/lib/final_destination.rb index c9e183dee6..24e96b244a 100644 --- a/lib/final_destination.rb +++ b/lib/final_destination.rb @@ -283,8 +283,7 @@ class FinalDestination end def log(log_level, message) - # blacklist 404 on gravatar.com - return if @status_code == 404 && @uri.hostname["gravatar.com"] + return if @status_code == 404 Rails.logger.public_send( log_level, From 80adc1ee800ace4dc8192ee975ce4aa70aa59b90 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 25 May 2018 12:15:45 +1000 Subject: [PATCH 157/278] DEV: stabilize site setting spec side effects could cause specs to fail in rare conditions --- lib/site_setting_extension.rb | 7 ++- .../components/site_setting_extension_spec.rb | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/lib/site_setting_extension.rb b/lib/site_setting_extension.rb index 8ea79430c9..412ded8bfd 100644 --- a/lib/site_setting_extension.rb +++ b/lib/site_setting_extension.rb @@ -12,7 +12,10 @@ module SiteSettingExtension def_delegator :defaults, :has_setting? def_delegators 'SiteSettings::TypeSupervisor', :types, :supported_types - # part 1 of refactor, centralizing the dependency here + def listen_for_changes=(val) + @listen_for_changes = val + end + def provider=(val) @provider = val refresh! @@ -190,6 +193,8 @@ module SiteSettingExtension end def ensure_listen_for_changes + return if @listen_for_changes == false + unless @subscribed MessageBus.subscribe("/site_settings") do |message| process_message(message) diff --git a/spec/components/site_setting_extension_spec.rb b/spec/components/site_setting_extension_spec.rb index 8dba4429fa..4171b9db4c 100644 --- a/spec/components/site_setting_extension_spec.rb +++ b/spec/components/site_setting_extension_spec.rb @@ -4,6 +4,24 @@ require_dependency 'site_settings/local_process_provider' describe SiteSettingExtension do + # We disable message bus here to avoid a large amount + # of uneeded messaging, tests are careful to call refresh + # when they need to. + # + # DistributedCache used by locale handler can under certain + # cases take a tiny bit to stabalize. + # + # TODO: refactor SiteSettingExtension not to rely on statics in + # DefaultsProvider + # + before do + MessageBus.off + end + + after do + MessageBus.on + end + describe '#types' do context "verify enum sequence" do before do @@ -25,8 +43,11 @@ describe SiteSettingExtension do end def new_settings(provider) + # we want to avoid leaking a big pile of MessageBus subscriptions here (1 per class) + # so we set listen_for_changes to false Class.new do extend SiteSettingExtension + self.listen_for_changes = false self.provider = provider end end @@ -39,6 +60,30 @@ describe SiteSettingExtension do new_settings(provider_local) end + it "Does not leak state cause changes are not linked" do + t1 = Thread.new do + 5.times do + settings = new_settings(SiteSettings::LocalProcessProvider.new) + settings.setting(:title, 'test') + settings.title = 'title1' + expect(settings.title).to eq 'title1' + + end + end + + t2 = Thread.new do + 5.times do + settings = new_settings(SiteSettings::LocalProcessProvider.new) + settings.setting(:title, 'test') + settings.title = 'title2' + expect(settings.title).to eq 'title2' + end + end + + t1.join + t2.join + end + describe "refresh!" do it "will reset to default if provider vanishes" do From 755b511b5c547da20f22245c8b01119cc18291a3 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 25 May 2018 11:59:29 +0800 Subject: [PATCH 158/278] PERF: Destroy collapsed notifications in 1 query instead of 3. --- app/services/post_alerter.rb | 13 +++++++------ spec/services/post_alerter_spec.rb | 9 ++++++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 3f151f875c..748d4d0b2d 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -174,12 +174,15 @@ class PostAlerter unread_posts(user, topic).count end - def destroy_notifications(user, type, topic) + def destroy_notifications(user, types, topic) return if user.blank? return unless Guardian.new(user).can_see?(topic) - user.notifications.where(notification_type: type, - topic_id: topic.id).destroy_all + user.notifications.where( + notification_type: types, + topic_id: topic.id + ).destroy_all + # HACK so notification counts sync up correctly user.reload end @@ -326,9 +329,7 @@ class PostAlerter collapsed = false if COLLAPSED_NOTIFICATION_TYPES.include?(type) - COLLAPSED_NOTIFICATION_TYPES.each do |t| - destroy_notifications(user, t, post.topic) - end + destroy_notifications(user, COLLAPSED_NOTIFICATION_TYPES, post.topic) collapsed = true end diff --git a/spec/services/post_alerter_spec.rb b/spec/services/post_alerter_spec.rb index 20b6e8a778..c7abefee98 100644 --- a/spec/services/post_alerter_spec.rb +++ b/spec/services/post_alerter_spec.rb @@ -267,8 +267,15 @@ describe PostAlerter do end it 'notifies a user by username' do + topic = Fabricate(:topic) + expect { - create_post_with_alerts(raw: '[quote="EvilTrout, post:1"]whatup[/quote]') + 2.times do + create_post_with_alerts( + raw: '[quote="EvilTrout, post:1"]whatup[/quote]', + topic: topic + ) + end }.to change(evil_trout.notifications, :count).by(1) end From 9d307a99773edb36360e2632b92365aed4d7dc70 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 25 May 2018 12:00:13 +0800 Subject: [PATCH 159/278] FIX: Destroy notifications and sync notifications count in transaction. * Seeing errors where the user is destroyed right when the call to reload is made. --- app/services/post_alerter.rb | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 748d4d0b2d..05e72dab60 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -178,13 +178,15 @@ class PostAlerter return if user.blank? return unless Guardian.new(user).can_see?(topic) - user.notifications.where( - notification_type: types, - topic_id: topic.id - ).destroy_all + User.transaction do + user.notifications.where( + notification_type: types, + topic_id: topic.id + ).destroy_all - # HACK so notification counts sync up correctly - user.reload + # Reload so notification counts sync up correctly + user.reload + end end NOTIFIABLE_TYPES = [:mentioned, :replied, :quoted, :posted, :linked, :private_message, :group_mentioned].map { |t| From be1b8fc3a8512c6fb8718cf0e0902c8741625304 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 25 May 2018 14:22:31 +1000 Subject: [PATCH 160/278] DEV: ensure specs run in consistent times --- spec/models/report_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index f2507b3515..1f36ce5ff3 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -95,7 +95,7 @@ describe Report do context "with #{pluralized}" do before(:each) do - freeze_time + freeze_time DateTime.parse('2017-03-01 12:00') fabricator = case arg when :signup :user @@ -140,7 +140,7 @@ describe Report do context "with #{request_type}" do before(:each) do - freeze_time + freeze_time DateTime.parse('2017-03-01 12:00') ApplicationRequest.create(date: 35.days.ago.to_time, req_type: ApplicationRequest.req_types[request_type.to_s], count: 35) ApplicationRequest.create(date: 7.days.ago.to_time, req_type: ApplicationRequest.req_types[request_type.to_s], count: 8) ApplicationRequest.create(date: Time.now, req_type: ApplicationRequest.req_types[request_type.to_s], count: 1) @@ -365,7 +365,7 @@ describe Report do context "with different users/visits" do before do - freeze_time + freeze_time DateTime.parse('2017-03-01 12:00') arpit = Fabricate(:user) arpit.user_visits.create(visited_at: 1.day.ago) @@ -402,7 +402,7 @@ describe Report do context "with different activities" do before do - freeze_time + freeze_time DateTime.parse('2017-03-01 12:00') UserActionCreator.enable From 720a12ce76746a0761f3ebde89aa75d90a244ebb Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 25 May 2018 14:26:05 +1000 Subject: [PATCH 161/278] correct time dependent spec --- spec/services/user_merger_spec.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spec/services/user_merger_spec.rb b/spec/services/user_merger_spec.rb index 5d5c1f6693..2d81ad8e3b 100644 --- a/spec/services/user_merger_spec.rb +++ b/spec/services/user_merger_spec.rb @@ -877,6 +877,8 @@ describe UserMerger do end it "merges user visits" do + freeze_time DateTime.parse('2010-01-01 12:00') + UserVisit.create!(user_id: source_user.id, visited_at: 2.days.ago, posts_read: 22, mobile: false, time_read: 400) UserVisit.create!(user_id: source_user.id, visited_at: Date.yesterday, posts_read: 8, mobile: false, time_read: 100) UserVisit.create!(user_id: target_user.id, visited_at: Date.yesterday, posts_read: 12, mobile: true, time_read: 270) From 610bfec73e97f476c69e51aa3996ae80fe1a5ff6 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 25 May 2018 14:29:11 +1000 Subject: [PATCH 162/278] DEV: correct fragile spec --- spec/components/site_settings/defaults_provider_spec.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/spec/components/site_settings/defaults_provider_spec.rb b/spec/components/site_settings/defaults_provider_spec.rb index 7587748a34..e19b369f2b 100644 --- a/spec/components/site_settings/defaults_provider_spec.rb +++ b/spec/components/site_settings/defaults_provider_spec.rb @@ -6,9 +6,18 @@ describe SiteSettings::DefaultsProvider do SiteSettings::LocalProcessProvider.new end + before do + MessageBus.off + end + + after do + MessageBus.on + end + def new_settings(provider) Class.new do extend SiteSettingExtension + self.listen_for_changes = false self.provider = provider end end From 69050f8a1463953e99a54ac3b65c555d68a109f0 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 25 May 2018 14:39:52 +1000 Subject: [PATCH 163/278] upgrade PG, remove malloc limit add larger method cache --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index d8413fcb97..6e4715682d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,7 +9,7 @@ branches: env: global: - DISCOURSE_HOSTNAME=www.example.com - - RUBY_GC_MALLOC_LIMIT=50000000 + - RUBY_GLOBAL_METHOD_CACHE_SIZE=131072 matrix: - "RAILS_MASTER=0 QUNIT_RUN=0 RUN_LINT=0" - "RAILS_MASTER=0 QUNIT_RUN=1 RUN_LINT=0" @@ -17,7 +17,7 @@ env: addons: chrome: stable - postgresql: 9.5 + postgresql: 10 apt: packages: - gifsicle From 9c91c2509e8062bb6c07022285f9e323ea2460e6 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 25 May 2018 15:16:40 +1000 Subject: [PATCH 164/278] improve spec stability --- spec/components/distributed_cache_spec.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/spec/components/distributed_cache_spec.rb b/spec/components/distributed_cache_spec.rb index e1256346ac..a1b442edf1 100644 --- a/spec/components/distributed_cache_spec.rb +++ b/spec/components/distributed_cache_spec.rb @@ -17,12 +17,16 @@ describe DistributedCache do DistributedCache.new(name, manager: @manager) end + let :cache_name do + SecureRandom.hex + end + let! :cache1 do - cache("test") + cache(cache_name) end let! :cache2 do - cache("test") + cache(cache_name) end it 'allows us to store Set' do From 7e8ef5d6e11d8f1e3113451897b6643703a7da1d Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 25 May 2018 15:46:28 +1000 Subject: [PATCH 165/278] move back to pg 9.6 cause 10 is not working --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 6e4715682d..5e637cd0cd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,7 +17,7 @@ env: addons: chrome: stable - postgresql: 10 + postgresql: 9.6 apt: packages: - gifsicle From 501bc0e9af49b7037724676f90ad74af4dd5a623 Mon Sep 17 00:00:00 2001 From: riking Date: Mon, 21 May 2018 12:40:15 -0700 Subject: [PATCH 166/278] FIX: UserProfileView: Do not log IP of logged-in users --- app/models/user_profile_view.rb | 8 +++--- ...1191418_allow_null_ip_user_profile_view.rb | 25 +++++++++++++++++++ spec/models/user_profile_view_spec.rb | 2 ++ 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20180521191418_allow_null_ip_user_profile_view.rb diff --git a/app/models/user_profile_view.rb b/app/models/user_profile_view.rb index 716b26c07c..237318a101 100644 --- a/app/models/user_profile_view.rb +++ b/app/models/user_profile_view.rb @@ -1,5 +1,5 @@ class UserProfileView < ActiveRecord::Base - validates_presence_of :user_profile_id, :ip_address, :viewed_at + validates_presence_of :user_profile_id, :viewed_at belongs_to :user_profile @@ -9,6 +9,7 @@ class UserProfileView < ActiveRecord::Base if user_id return if user_id < 1 redis_key << ":user-#{user_id}" + ip = nil else redis_key << ":ip-#{ip}" end @@ -59,13 +60,12 @@ end # id :integer not null, primary key # user_profile_id :integer not null # viewed_at :datetime not null -# ip_address :inet not null +# ip_address :inet # user_id :integer # # Indexes # # index_user_profile_views_on_user_id (user_id) # index_user_profile_views_on_user_profile_id (user_profile_id) -# unique_profile_view_ip (viewed_at,ip_address,user_profile_id) UNIQUE -# unique_profile_view_user (viewed_at,user_id,user_profile_id) UNIQUE +# unique_profile_view_user_or_ip (viewed_at,user_id,ip_address,user_profile_id) UNIQUE # diff --git a/db/migrate/20180521191418_allow_null_ip_user_profile_view.rb b/db/migrate/20180521191418_allow_null_ip_user_profile_view.rb new file mode 100644 index 0000000000..0f5b3795c1 --- /dev/null +++ b/db/migrate/20180521191418_allow_null_ip_user_profile_view.rb @@ -0,0 +1,25 @@ +class AllowNullIpUserProfileView < ActiveRecord::Migration[5.1] + def up + begin + Migration::SafeMigrate.disable! + change_column :user_profile_views, :ip_address, :inet, null: true + ensure + Migration::SafeMigrate.enable! + end + + remove_index :user_profile_views, + column: [:viewed_at, :ip_address, :user_profile_id], + name: :unique_profile_view_ip, + unique: true + remove_index :user_profile_views, + column: [:viewed_at, :user_id, :user_profile_id], + name: :unique_profile_view_user, + unique: true + add_index :user_profile_views, [:viewed_at, :user_id, :ip_address, :user_profile_id], + name: :unique_profile_view_user_or_ip, + unique: true + end + + def down + end +end diff --git a/spec/models/user_profile_view_spec.rb b/spec/models/user_profile_view_spec.rb index de82b167c7..108a5a7a5f 100644 --- a/spec/models/user_profile_view_spec.rb +++ b/spec/models/user_profile_view_spec.rb @@ -34,6 +34,8 @@ RSpec.describe UserProfileView do ['1.1.1.1', '2.2.2.2'].each do |ip| add(user_profile_id, ip, other_user.id, time) expect(described_class.count).to eq(1) + # should not actually log IPs + expect(UserProfileView.where(user_id: other_user.id).count(:ip_address)).to eq(0) end end From 12e4dd59b75d4571c708c4cedbb61af58e5e107b Mon Sep 17 00:00:00 2001 From: riking Date: Mon, 21 May 2018 18:04:54 -0700 Subject: [PATCH 167/278] Style fix for test --- spec/models/user_profile_view_spec.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/spec/models/user_profile_view_spec.rb b/spec/models/user_profile_view_spec.rb index 108a5a7a5f..75c235a3b2 100644 --- a/spec/models/user_profile_view_spec.rb +++ b/spec/models/user_profile_view_spec.rb @@ -28,14 +28,13 @@ RSpec.describe UserProfileView do end end - it "should not create duplicated profile view for signed in user" do + it "should not create duplicated profile view or log IP for signed in user" do time = Time.zone.now ['1.1.1.1', '2.2.2.2'].each do |ip| add(user_profile_id, ip, other_user.id, time) expect(described_class.count).to eq(1) - # should not actually log IPs - expect(UserProfileView.where(user_id: other_user.id).count(:ip_address)).to eq(0) + expect(UserProfileView.find_by(user_id: other_user.id).ip_address).to eq(nil) end end From 9f873fa66cda23c4905e142beb6b3d771df1f2ba Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Fri, 25 May 2018 16:01:37 +0800 Subject: [PATCH 168/278] FIX: Invalid setex expire time in `DiscourseNarrativeBot::Actions#reset_rate_limits`. --- .../lib/discourse_narrative_bot/actions.rb | 19 ++++++++++--------- .../new_user_narrative_spec.rb | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/actions.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/actions.rb index d8b6c47b1a..349aedd523 100644 --- a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/actions.rb +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/actions.rb @@ -32,22 +32,23 @@ module DiscourseNarrativeBot def reset_rate_limits(post) user = post.user - data = DiscourseNarrativeBot::Store.get(user.id.to_s) + duration = + if user && user.new_user? + SiteSetting.rate_limit_new_user_create_post + else + SiteSetting.rate_limit_create_post + end + + return unless duration > 0 + + data = DiscourseNarrativeBot::Store.get(user.id.to_s) return unless data key = "#{DiscourseNarrativeBot::PLUGIN_NAME}:reset-rate-limit:#{post.topic_id}:#{data['state']}" if !(count = $redis.get(key)) count = 0 - - duration = - if user && user.new_user? - SiteSetting.rate_limit_new_user_create_post - else - SiteSetting.rate_limit_create_post - end - $redis.setex(key, duration, count) end diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb index 1fe809178b..03d550f18f 100644 --- a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb +++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb @@ -211,6 +211,22 @@ describe DiscourseNarrativeBot::NewUserNarrative do expect(narrative.get_data(user)[:state].to_sym).to eq(:tutorial_bookmark) end + describe 'when rate_limit_new_user_create_post site setting is disabled' do + before do + SiteSetting.rate_limit_new_user_create_post = 0 + end + + it 'should create the right reply' do + narrative.input(:reply, user, post: post) + new_post = Post.last + + expect(new_post.raw).to eq(I18n.t( + 'discourse_narrative_bot.new_user_narrative.bookmark.not_found', + base_uri: '' + )) + end + end + describe 'when reply contains the skip trigger' do it 'should create the right reply' do post.update!(raw: "@#{discobot_user.username} #{skip_trigger.upcase}") From 20b94bc71432b1987bfd3e55d08c62e66967133a Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Fri, 25 May 2018 11:31:45 +0200 Subject: [PATCH 169/278] FIX: Extraction of quoted posts failed in some cases * It stored only oneboxed "quotes" when [quote] and links to topics or posts were mixed. * Revising a post didn't add or remove records from the quoted_posts table. --- app/models/quoted_post.rb | 31 +++++++++-------------- lib/post_revisor.rb | 2 +- spec/models/quoted_post_spec.rb | 34 ++++++++++++++++++++------ spec/services/username_changer_spec.rb | 13 ---------- 4 files changed, 40 insertions(+), 40 deletions(-) diff --git a/app/models/quoted_post.rb b/app/models/quoted_post.rb index ecfe19e73d..c6d24675f5 100644 --- a/app/models/quoted_post.rb +++ b/app/models/quoted_post.rb @@ -10,7 +10,8 @@ class QuotedPost < ActiveRecord::Base doc = Nokogiri::HTML.fragment(post.cooked) uniq = {} - ids = [] + + exec_sql("DELETE FROM quoted_posts WHERE post_id = :post_id", post_id: post.id) doc.css("aside.quote[data-topic]").each do |a| topic_id = a['data-topic'].to_i @@ -22,34 +23,26 @@ class QuotedPost < ActiveRecord::Base begin # It would be so much nicer if we used post_id in quotes - results = exec_sql "INSERT INTO quoted_posts(post_id, quoted_post_id, created_at, updated_at) - SELECT :post_id, p.id, current_timestamp, current_timestamp - FROM posts p - LEFT JOIN quoted_posts q on q.post_id = :post_id AND q.quoted_post_id = p.id - WHERE post_number = :post_number AND - topic_id = :topic_id AND - q.id IS NULL - RETURNING quoted_post_id - ", post_id: post.id, post_number: post_number, topic_id: topic_id - - results = results.to_a - ids << results[0]["quoted_post_id"].to_i if results.length > 0 + exec_sql(<<~SQL, post_id: post.id, post_number: post_number, topic_id: topic_id) + INSERT INTO quoted_posts (post_id, quoted_post_id, created_at, updated_at) + SELECT :post_id, p.id, current_timestamp, current_timestamp + FROM posts p + LEFT JOIN quoted_posts q on q.post_id = :post_id AND q.quoted_post_id = p.id + WHERE post_number = :post_number AND + topic_id = :topic_id AND + q.id IS NULL + SQL rescue ActiveRecord::RecordNotUnique, PG::UniqueViolation # it's fine end end - if ids.length > 0 - exec_sql "DELETE FROM quoted_posts WHERE post_id = :post_id AND quoted_post_id NOT IN (:ids)", - post_id: post.id, ids: ids - end - # simplest place to add this code reply_quoted = false if post.reply_to_post_number reply_post_id = Post.where(topic_id: post.topic_id, post_number: post.reply_to_post_number).pluck(:id).first - reply_quoted = !!(reply_post_id && ids.include?(reply_post_id)) + reply_quoted = reply_post_id.present? && QuotedPost.where(post_id: post.id, quoted_post_id: reply_post_id).count > 0 end if reply_quoted != post.reply_quoted diff --git a/lib/post_revisor.rb b/lib/post_revisor.rb index 81ab76494a..d74e50dd95 100644 --- a/lib/post_revisor.rb +++ b/lib/post_revisor.rb @@ -190,6 +190,7 @@ class PostRevisor # WARNING: do not pull this into the transaction # it can fire events in sidekiq before the post is done saving # leading to corrupt state + QuotedPost.extract_from(@post) post_process_post update_topic_word_counts @@ -198,7 +199,6 @@ class PostRevisor grant_badge TopicLink.extract_from(@post) - QuotedPost.extract_from(@post) successfully_saved_post_and_topic end diff --git a/spec/models/quoted_post_spec.rb b/spec/models/quoted_post_spec.rb index 4c61952c69..0e7c6b0ef6 100644 --- a/spec/models/quoted_post_spec.rb +++ b/spec/models/quoted_post_spec.rb @@ -1,14 +1,34 @@ require 'rails_helper' describe QuotedPost do - it 'correctly extracts quotes in integration test' do - post1 = create_post - post2 = create_post(topic_id: post1.topic_id, - raw: "[quote=\"#{post1.user.username}, post: 1, topic:#{post1.topic_id}\"]\ntest\n[/quote]\nthis is a test post", - reply_to_post_number: 1) + it 'correctly extracts quotes' do + topic = Fabricate(:topic) + post1 = create_post(topic: topic, post_number: 1, raw: "foo bar") + post2 = create_post(topic: topic, post_number: 2, raw: "lorem ipsum") + post3 = create_post(topic: topic, post_number: 3, raw: "test post") - expect(QuotedPost.find_by(post_id: post2.id, quoted_post_id: post1.id)).not_to eq(nil) - expect(post2.reply_quoted).to eq(true) + raw = <<~RAW + #{post1.full_url} + + [quote="#{post2.user.username}, post:#{post2.post_number}, topic:#{post2.topic.id}"] + lorem + [/quote] + + this is a test post + + #{post3.full_url} + RAW + + post4 = create_post(topic: topic, raw: raw, post_number: 4, reply_to_post_number: post3.post_number) + + expect(QuotedPost.where(post_id: post4.id).pluck(:quoted_post_id)).to contain_exactly(post1.id, post2.id, post3.id) + expect(post4.reload.reply_quoted).to eq(true) + + SiteSetting.editing_grace_period = 1.minute.to_i + post5 = create_post(topic: topic, post_number: 5, raw: "post 5") + raw.sub!(post3.full_url, post5.full_url) + post4.revise(post4.user, { raw: raw }, revised_at: post4.updated_at + 2.minutes) + expect(QuotedPost.where(post_id: post4.id).pluck(:quoted_post_id)).to contain_exactly(post1.id, post2.id, post5.id) end it 'correctly handles deltas' do diff --git a/spec/services/username_changer_spec.rb b/spec/services/username_changer_spec.rb index 92bdb27a57..2ceeb66fda 100644 --- a/spec/services/username_changer_spec.rb +++ b/spec/services/username_changer_spec.rb @@ -387,19 +387,6 @@ describe UsernameChanger do expect(post.raw).to eq(expected_raw) expect(post.cooked).to match_html(expected_cooked) end - - it 'replaces the username in quote tags when the post is marked as deleted' do - post = create_post_and_change_username(raw: raw) do |p| - PostDestroyer.new(p.user, p).destroy - end - - expect(post.raw).to_not include("foo") - expect(post.cooked).to_not include("foo") - expect(post.revisions.count).to eq(1) - - expect(post.revisions[0].modifications["raw"][0]).to eq(expected_raw) - expect(post.revisions[0].modifications["cooked"][0]).to match_html(expected_cooked) - end end end From e67ac93e6cbcc66369e877d5a94e2641d2730e0d Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 25 May 2018 12:09:30 +0200 Subject: [PATCH 170/278] UX: makes table report trend icons as charts This commit will also reverse icon orientation if higher_is_better = false --- .../javascripts/admin/models/report.js.es6 | 48 +++++++++++++------ .../components/admin-report-counts.hbs | 6 +-- .../common/admin/dashboard_next.scss | 35 +++++--------- 3 files changed, 48 insertions(+), 41 deletions(-) diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6 index d7fc55ed1f..db46a0814b 100644 --- a/app/assets/javascripts/admin/models/report.js.es6 +++ b/app/assets/javascripts/admin/models/report.js.es6 @@ -80,20 +80,25 @@ const Report = Discourse.Model.extend({ return Ember.makeArray(data).length === 0 ? 0 : parseFloat((total / parseFloat(data.length)).toFixed(1)); }, - @computed("trend") - trendIcon(trend) { - switch (trend) { - case "trending-up": - return "angle-up"; - case "trending-down": - return "angle-down"; - case "high-trending-up": - return "angle-double-up"; - case "high-trending-down": - return "angle-double-down"; - default: - return null; - } + @computed("trend", "higher_is_better") + trendIcon(trend, higherIsBetter) { + return this._iconForTrend(trend, higherIsBetter); + }, + + @computed("sevenDaysTrend", "higher_is_better") + sevenDaysTrendIcon(sevenDaysTrend, higherIsBetter) { + return this._iconForTrend(sevenDaysTrend, higherIsBetter); + }, + + @computed("thirtyDaysTrend", "higher_is_better") + thirtyDaysTrendIcon(thirtyDaysTrend, higherIsBetter) { + return this._iconForTrend(thirtyDaysTrend, higherIsBetter); + }, + + @computed("yesterdayTrend", "higher_is_better") + yesterdayTrendIcon(yesterdayTrend, higherIsBetter) { + console.log("yesterdayTrendIcon", yesterdayTrend, higherIsBetter, this._iconForTrend(yesterdayTrend, higherIsBetter)) + return this._iconForTrend(yesterdayTrend, higherIsBetter); }, @computed("prev_period", "currentTotal", "currentAverage") @@ -216,6 +221,21 @@ const Report = Discourse.Model.extend({ } else if (change < 0) { return higherIsBetter ? "trending-down" : "trending-up"; } + }, + + _iconForTrend(trend, higherIsBetter) { + switch (trend) { + case "trending-up": + return higherIsBetter ? "angle-up" : "angle-down"; + case "trending-down": + return higherIsBetter ? "angle-down" : "angle-up"; + case "high-trending-up": + return higherIsBetter ? "angle-double-up" : "angle-double-down"; + case "high-trending-down": + return higherIsBetter ? "angle-double-down" : "angle-double-up"; + default: + return null; + } } }); diff --git a/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs b/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs index 2dc0600e44..4b4a406c59 100644 --- a/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs +++ b/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs @@ -8,15 +8,15 @@ {{number report.todayCount}} - {{number report.yesterdayCount}} {{d-icon "caret-up" class="up"}} {{d-icon "caret-down" class="down"}} + {{number report.yesterdayCount}} {{d-icon report.yesterdayTrendIcon}} - {{number report.lastSevenDaysCount}} {{d-icon "caret-up" class="up"}} {{d-icon "caret-down" class="down"}} + {{number report.lastSevenDaysCount}} {{d-icon report.sevenDaysTrendIcon}} - {{number report.lastThirtyDaysCount}} {{d-icon "caret-up" class="up"}} {{d-icon "caret-down" class="down"}} + {{number report.lastThirtyDaysCount}} {{d-icon report.thirtyDaysTrendIcon}} {{#if allTime}} diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index ffc6026e58..815ed101b4 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -339,7 +339,6 @@ padding: 8px 12px 8px; } i { - display: none; margin-right: -12px; // align on caret @media screen and (max-width: 650px) { margin-right: -9px; @@ -347,22 +346,10 @@ } &.high-trending-up, &.trending-up { - i.up { - color: $success; - display: inline; - } + i { color: $success; } } &.high-trending-down, &.trending-down { - i.down { - color: $danger; - display: inline; - } - } - &.no-change { - i.down { - display: inline; - visibility: hidden; - } + i { color: $danger; } } } } @@ -371,11 +358,11 @@ } .user-metrics { - display: flex; + display: flex; flex-wrap: wrap; justify-content: space-between; margin-left: -5%; - margin: 2em 0 .75em -5%; // Negative margin allows for a margin when in 2-columns, + margin: 2em 0 .75em -5%; // Negative margin allows for a margin when in 2-columns, .dashboard-inline-table { // and "hides" margin when the item spans 100% width flex: 1 0 auto; max-width: 95%; @@ -394,7 +381,7 @@ justify-content: center; border-radius: 9px 0 0 9px; padding: 0 5px 0 8px; - + .d-icon { margin-right: 5px; font-size: $font-down-1; @@ -412,24 +399,24 @@ &.user-basic , &.user-member { border-color: $bronze; .label { - border-color: $bronze; + border-color: $bronze; background: $bronze; color: $secondary; - } + } } &.user-regular { border-color: $silver; .label { - border-color: $silver; + border-color: $silver; background: $silver; color: $secondary; - } + } } - &.user-leader { + &.user-leader { border-color: $gold; .label { background: $gold; - border-color: $gold; + border-color: $gold; color: $secondary; } } From acc5e42066177fa404852c8bf01b8c7172b4fdd6 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 25 May 2018 12:12:28 +0200 Subject: [PATCH 171/278] removes logging --- app/assets/javascripts/admin/models/report.js.es6 | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6 index db46a0814b..d52d3ad06e 100644 --- a/app/assets/javascripts/admin/models/report.js.es6 +++ b/app/assets/javascripts/admin/models/report.js.es6 @@ -97,7 +97,6 @@ const Report = Discourse.Model.extend({ @computed("yesterdayTrend", "higher_is_better") yesterdayTrendIcon(yesterdayTrend, higherIsBetter) { - console.log("yesterdayTrendIcon", yesterdayTrend, higherIsBetter, this._iconForTrend(yesterdayTrend, higherIsBetter)) return this._iconForTrend(yesterdayTrend, higherIsBetter); }, From a8901efafe30aadd21b3feb9b99fb9db3486e6ed Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 25 May 2018 12:59:39 +0200 Subject: [PATCH 172/278] FIX: flags report is now a lower is better kind of report --- app/models/report.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/report.rb b/app/models/report.rb index 0aa88ffa83..87452154cb 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -351,6 +351,8 @@ class Report # Post action counts: def self.report_flags(report) + report.higher_is_better = false + basic_report_about report, PostAction, :flag_count_by_date, report.start_date, report.end_date, report.category_id countable = PostAction.where(post_action_type_id: PostActionType.flag_types_without_custom.values) countable = countable.joins(post: :topic).where("topics.category_id = ?", report.category_id) if report.category_id From 9e546e9ddc6b26ad7281a4470c52f784ff88ec19 Mon Sep 17 00:00:00 2001 From: Joe <33972521+hnb-ku@users.noreply.github.com> Date: Fri, 25 May 2018 20:11:22 +0800 Subject: [PATCH 173/278] FIX: Move tips into instructions div --- .../templates/modal/create-account.hbs | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/modal/create-account.hbs b/app/assets/javascripts/discourse/templates/modal/create-account.hbs index 033e31368c..f538f9ef13 100644 --- a/app/assets/javascripts/discourse/templates/modal/create-account.hbs +++ b/app/assets/javascripts/discourse/templates/modal/create-account.hbs @@ -1,13 +1,9 @@ {{#create-account email=accountEmail disabled=submitDisabled action="createAccount"}} - {{#unless complete}} {{#d-modal-body title="create_account.title"}} - {{#unless hasAuthOptions}} - {{login-buttons externalLogin="externalLogin"}} - {{/unless}} - + {{#if showCreateForm}} -
    +