From 8e133de83118200f46a21b0e4c3a931e1cd14954 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Mon, 15 Jul 2019 17:32:03 +0300 Subject: [PATCH 001/441] FIX: Ensure suppressed categories do not produce any featured topics. (#7863) --- app/models/category.rb | 5 +++++ app/models/category_featured_topic.rb | 13 ++++++++++++- spec/models/category_featured_topic_spec.rb | 14 ++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/models/category.rb b/app/models/category.rb index 9ba9d914b5..6009164df8 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -72,6 +72,7 @@ class Category < ActiveRecord::Base after_save :clear_url_cache after_save :index_search after_save :update_reviewables + after_save :clear_featured_cache after_destroy :reset_topic_ids_cache after_destroy :publish_category_deletion @@ -568,6 +569,10 @@ class Category < ActiveRecord::Base @@url_cache.clear end + def clear_featured_cache + CategoryFeaturedTopic.clear_exclude_category_ids + end + def full_slug(separator = "-") start_idx = "#{Discourse.base_uri}/c/".length url[start_idx..-1].gsub("/", separator) diff --git a/app/models/category_featured_topic.rb b/app/models/category_featured_topic.rb index 9ffb283b18..fef24c0751 100644 --- a/app/models/category_featured_topic.rb +++ b/app/models/category_featured_topic.rb @@ -38,6 +38,16 @@ class CategoryFeaturedTopic < ActiveRecord::Base end end + @@exclude_category_ids = DistributedCache.new('excluded_category_ids_from_featured') + + def self.cached_exclude_category_ids + @@exclude_category_ids['ids'] ||= Category.where(suppress_from_latest: true).pluck(:id) + end + + def self.clear_exclude_category_ids + @@exclude_category_ids.clear + end + def self.clear_batch! $redis.del(NEXT_CATEGORY_ID_KEY) end @@ -49,7 +59,8 @@ class CategoryFeaturedTopic < ActiveRecord::Base per_page: c.num_featured_topics, except_topic_ids: [c.topic_id], visible: true, - no_definitions: true + no_definitions: true, + exclude_category_ids: CategoryFeaturedTopic.cached_exclude_category_ids } # It may seem a bit odd that we are running 2 queries here, when admin diff --git a/spec/models/category_featured_topic_spec.rb b/spec/models/category_featured_topic_spec.rb index 0812237863..f994fe5371 100644 --- a/spec/models/category_featured_topic_spec.rb +++ b/spec/models/category_featured_topic_spec.rb @@ -53,6 +53,20 @@ describe CategoryFeaturedTopic do expect(CategoryFeaturedTopic.count).to be(1) end + it 'should not include topics from suppressed categories' do + CategoryFeaturedTopic.feature_topics_for(category) + expect( + CategoryFeaturedTopic.where(category_id: category.id).order('rank asc').pluck(:topic_id) + ).to contain_exactly(category_post.topic.id) + + category.update(suppress_from_latest: true) + + CategoryFeaturedTopic.feature_topics_for(category) + expect( + CategoryFeaturedTopic.where(category_id: category.id).order('rank asc').pluck(:topic_id) + ).to_not contain_exactly(category_post.topic.id) + end + it 'should feature stuff in the correct order' do category = Fabricate(:category, num_featured_topics: 2) _t5 = Fabricate(:topic, category_id: category.id, bumped_at: 12.minutes.ago) From 839916aa4901ab62fb84bcaf7d91c4354091f506 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Mon, 15 Jul 2019 20:22:54 +0530 Subject: [PATCH 002/441] DEV: Debundle plugin javascript assets and don't load if disabled (#7566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit And don't load javascript assets if plugin is disabled. * precompile auto generated plugin js assets * SPEC: remove spec test functions * remove plugin js from test_helper Co-Authored-By: Régis Hanol * DEV: using equality is slightly easier to read than inequality Co-Authored-By: Régis Hanol * DEV: use `select` method instead of `find_all` for readability Co-Authored-By: Régis Hanol --- .gitignore | 3 ++ .../javascripts/plugin-third-party.js.erb | 15 -------- app/assets/javascripts/plugin.js.erb | 16 --------- app/views/layouts/application.html.erb | 8 ++--- config/application.rb | 6 ++-- lib/discourse.rb | 16 +++++++++ lib/plugin/instance.rb | 34 +++++++++++++++++++ spec/requests/list_controller_spec.rb | 16 --------- test/javascripts/test_helper.js | 1 - 9 files changed, 59 insertions(+), 56 deletions(-) delete mode 100644 app/assets/javascripts/plugin-third-party.js.erb delete mode 100644 app/assets/javascripts/plugin.js.erb diff --git a/.gitignore b/.gitignore index 2b3d1fa723..bf341e7426 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ vendor/bundle/* # Vagrant .vagrant + +# ignore auto-generated plugin js assets +/app/assets/javascripts/plugins/* diff --git a/app/assets/javascripts/plugin-third-party.js.erb b/app/assets/javascripts/plugin-third-party.js.erb deleted file mode 100644 index f5cd239d59..0000000000 --- a/app/assets/javascripts/plugin-third-party.js.erb +++ /dev/null @@ -1,15 +0,0 @@ -<% -# Include plugin javascripts/handlebars templates -Discourse.unofficial_plugins.each do |plugin| - plugin.javascript_includes.each { |js| require_asset(js) } - plugin.handlebars_includes.each { |hb| require_asset(hb) } - - plugin.each_globbed_asset do |f, is_dir| - if is_dir - depend_on(f) - else - require_asset(f) - end - end -end -%> diff --git a/app/assets/javascripts/plugin.js.erb b/app/assets/javascripts/plugin.js.erb deleted file mode 100644 index 19e879b2ed..0000000000 --- a/app/assets/javascripts/plugin.js.erb +++ /dev/null @@ -1,16 +0,0 @@ -<% -# Include plugin javascripts/handlebars templates -Discourse.official_plugins.each do |plugin| - plugin.javascript_includes.each { |js| require_asset(js) } - plugin.handlebars_includes.each { |hb| require_asset(hb) } - - plugin.each_globbed_asset do |f, is_dir| - if is_dir - depend_on(f) - else - require_asset(f) - end - end -end - -%> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 51d5746f97..0678a010cb 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -25,13 +25,9 @@ <%= preload_script "vendor" %> <%= preload_script "pretty-text-bundle" %> <%= preload_script "application" %> - <%- if allow_plugins? %> - <%= preload_script "plugin" %> + <%- Discourse.find_plugin_js_assets(include_official: allow_plugins?, include_unofficial: allow_third_party_plugins?).each do |file| %> + <%= preload_script file %> <%- end %> - <%- if allow_third_party_plugins? %> - <%= preload_script "plugin-third-party" %> - <%- end %> - <%- if staff? %> <%= preload_script "admin" %> diff --git a/config/application.rb b/config/application.rb index cd2a9f85aa..2d946b30ec 100644 --- a/config/application.rb +++ b/config/application.rb @@ -132,8 +132,6 @@ module Discourse pretty-text-bundle.js wizard-application.js wizard-vendor.js - plugin.js - plugin-third-party.js markdown-it-bundle.js service-worker.js google-tag-manager.js @@ -261,6 +259,10 @@ module Discourse Discourse.activate_plugins! end + Discourse.find_plugin_js_assets(include_disabled: true).each do |file| + config.assets.precompile << "#{file}.js" + end + require_dependency 'stylesheet/manager' require_dependency 'svg_sprite/svg_sprite' diff --git a/lib/discourse.rb b/lib/discourse.rb index f1adb928c2..d413c1a6af 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -204,6 +204,22 @@ module Discourse plugins.find_all { |p| !p.metadata.official? } end + def self.find_plugins(args) + plugins.select do |plugin| + next if args[:include_official] == false && plugin.metadata.official? + next if args[:include_unofficial] == false && !plugin.metadata.official? + next if args[:include_disabled] == false && !plugin.enabled? + + true + end + end + + def self.find_plugin_js_assets(args) + self.find_plugins(args).find_all do |plugin| + plugin.js_asset_exists? + end.map { |plugin| "plugins/#{plugin.asset_name}" } + end + def self.assets_digest @assets_digest ||= begin digest = Digest::MD5.hexdigest(ActionView::Base.assets_manifest.assets.values.sort.join) diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index 33da7defb7..9849d0847c 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -529,6 +529,24 @@ class Plugin::Instance Discourse::Utils.execute_command('rm', '-f', target) Discourse::Utils.execute_command('ln', '-s', public_data, target) end + + ensure_directory(Plugin::Instance.js_path) + + contents = [] + handlebars_includes.each { |hb| contents << "require_asset('#{hb}')" } + javascript_includes.each { |js| contents << "require_asset('#{js}')" } + + each_globbed_asset do |f, is_dir| + contents << (is_dir ? "depend_on('#{f}')" : "require_asset('#{f}')") + end + + File.delete(js_file_path) if js_asset_exists? + + if contents.present? + contents.insert(0, "<%") + contents << "%>" + write_asset(js_file_path, contents.join("\n")) + end end def auth_provider(opts) @@ -629,8 +647,24 @@ class Plugin::Instance end end + def asset_name + @asset_name ||= File.dirname(path).split("/").last + end + + def js_asset_exists? + File.exists?(js_file_path) + end + protected + def self.js_path + File.expand_path "#{Rails.root}/app/assets/javascripts/plugins" + end + + def js_file_path + @file_path ||= "#{Plugin::Instance.js_path}/#{asset_name}.js.erb" + end + def register_assets! assets.each do |asset, opts| DiscoursePluginRegistry.register_asset(asset, opts) diff --git a/spec/requests/list_controller_spec.rb b/spec/requests/list_controller_spec.rb index 2cdb862806..7f85be9283 100644 --- a/spec/requests/list_controller_spec.rb +++ b/spec/requests/list_controller_spec.rb @@ -646,20 +646,4 @@ RSpec.describe ListController do expect(topic_titles).to include(topic_in_sub_category.title) end end - - describe "safe mode" do - it "handles safe mode" do - get "/latest" - expect(response.body).to match(/plugin\.js/) - expect(response.body).to match(/plugin-third-party\.js/) - - get "/latest", params: { safe_mode: "no_plugins" } - expect(response.body).not_to match(/plugin\.js/) - expect(response.body).not_to match(/plugin-third-party\.js/) - - get "/latest", params: { safe_mode: "only_official" } - expect(response.body).to match(/plugin\.js/) - expect(response.body).not_to match(/plugin-third-party\.js/) - end - end end diff --git a/test/javascripts/test_helper.js b/test/javascripts/test_helper.js index 678b0b5399..2d3b233478 100644 --- a/test/javascripts/test_helper.js +++ b/test/javascripts/test_helper.js @@ -24,7 +24,6 @@ //= require pretty-text-bundle //= require markdown-it-bundle //= require application -//= require plugin //= require admin //= require sinon/pkg/sinon From 1221d342849f54dd20f58391a4b1d542398d9116 Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Mon, 15 Jul 2019 13:05:55 -0300 Subject: [PATCH 003/441] FEATURE: Make Discourse work offline with WorkboxJS (#7870) --- .../register-service-worker.js.es6 | 4 +- .../javascripts/discourse/lib/ajax.js.es6 | 2 +- app/assets/javascripts/service-worker.js.erb | 126 +- lib/tasks/javascript.rake | 20 + package.json | 7 +- .../javascripts/workbox/workbox-core.dev.js | 1712 +++++++++++++++++ .../workbox/workbox-core.dev.js.map | 1 + .../javascripts/workbox/workbox-core.prod.js | 2 + .../workbox/workbox-core.prod.js.map | 1 + .../workbox/workbox-expiration.dev.js | 652 +++++++ .../workbox/workbox-expiration.dev.js.map | 1 + .../workbox/workbox-expiration.prod.js | 2 + .../workbox/workbox-expiration.prod.js.map | 1 + .../workbox/workbox-routing.dev.js | 1020 ++++++++++ .../workbox/workbox-routing.dev.js.map | 1 + .../workbox/workbox-routing.prod.js | 2 + .../workbox/workbox-routing.prod.js.map | 1 + .../workbox/workbox-strategies.dev.js | 1138 +++++++++++ .../workbox/workbox-strategies.dev.js.map | 1 + .../workbox/workbox-strategies.prod.js | 2 + .../workbox/workbox-strategies.prod.js.map | 1 + public/javascripts/workbox/workbox-sw.js | 2 + public/javascripts/workbox/workbox-sw.js.map | 1 + yarn.lock | 31 + 24 files changed, 4617 insertions(+), 114 deletions(-) create mode 100644 public/javascripts/workbox/workbox-core.dev.js create mode 100644 public/javascripts/workbox/workbox-core.dev.js.map create mode 100644 public/javascripts/workbox/workbox-core.prod.js create mode 100644 public/javascripts/workbox/workbox-core.prod.js.map create mode 100644 public/javascripts/workbox/workbox-expiration.dev.js create mode 100644 public/javascripts/workbox/workbox-expiration.dev.js.map create mode 100644 public/javascripts/workbox/workbox-expiration.prod.js create mode 100644 public/javascripts/workbox/workbox-expiration.prod.js.map create mode 100644 public/javascripts/workbox/workbox-routing.dev.js create mode 100644 public/javascripts/workbox/workbox-routing.dev.js.map create mode 100644 public/javascripts/workbox/workbox-routing.prod.js create mode 100644 public/javascripts/workbox/workbox-routing.prod.js.map create mode 100644 public/javascripts/workbox/workbox-strategies.dev.js create mode 100644 public/javascripts/workbox/workbox-strategies.dev.js.map create mode 100644 public/javascripts/workbox/workbox-strategies.prod.js create mode 100644 public/javascripts/workbox/workbox-strategies.prod.js.map create mode 100644 public/javascripts/workbox/workbox-sw.js create mode 100644 public/javascripts/workbox/workbox-sw.js.map 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 b78044e11f..92ae988625 100644 --- a/app/assets/javascripts/discourse/initializers/register-service-worker.js.es6 +++ b/app/assets/javascripts/discourse/initializers/register-service-worker.js.es6 @@ -9,9 +9,7 @@ export default { const isSupported = isSecured && "serviceWorker" in navigator; if (isSupported) { - const isApple = !!navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i); - - if (Discourse.ServiceWorkerURL && !isApple) { + if (Discourse.ServiceWorkerURL) { navigator.serviceWorker.getRegistrations().then(registrations => { for (let registration of registrations) { if ( diff --git a/app/assets/javascripts/discourse/lib/ajax.js.es6 b/app/assets/javascripts/discourse/lib/ajax.js.es6 index e2f28ce32e..e4ca8df52e 100644 --- a/app/assets/javascripts/discourse/lib/ajax.js.es6 +++ b/app/assets/javascripts/discourse/lib/ajax.js.es6 @@ -140,7 +140,7 @@ export function ajax() { } if (args.type === "GET" && args.cache !== true) { - args.cache = false; + args.cache = true; // Disable JQuery cache busting param, which was created to deal with IE8 } ajaxObj = $.ajax(Discourse.getURL(url), args); diff --git a/app/assets/javascripts/service-worker.js.erb b/app/assets/javascripts/service-worker.js.erb index 50fb942d41..56d5eb7bc1 100644 --- a/app/assets/javascripts/service-worker.js.erb +++ b/app/assets/javascripts/service-worker.js.erb @@ -1,117 +1,25 @@ 'use strict'; +importScripts("<%= ::UrlHelper.absolute("/javascripts/workbox/workbox-sw.js") %>"); -// Special offline and fetch interception is restricted to Android only -// we have had a large amount of pain supporting this on Firefox / Safari -// it is only strongly required on Android, when PWA gets better on iOS -// we can unlock it there as well, for Desktop we can consider unlocking it -// if we start supporting offline browsing for laptops -if (/(android)/i.test(navigator.userAgent)) { +workbox.setConfig({ + modulePathPrefix: "<%= ::UrlHelper.absolute("/javascripts/workbox") %>" +}); - // Incrementing CACHE_VERSION will kick off the install event and force previously cached - // resources to be cached again. - const CACHE_VERSION = 1; +const cacheVersion = "1"; - const CURRENT_CACHES = { - offline: 'offline-v' + CACHE_VERSION - }; - - const OFFLINE_URL = 'offline.html'; - - const createCacheBustedRequest = function(url) { - var headers = new Headers({ - 'Discourse-Track-View': '0' - }); - - var request = new Request(url, {cache: 'reload', headers: headers}); - // See https://fetch.spec.whatwg.org/#concept-request-mode - // This is not yet supported in Chrome as of M48, so we need to explicitly check to see - // if the cache: 'reload' option had any effect. - if ('cache' in request) { - return request; - } - - // If {cache: 'reload'} didn't have any effect, append a cache-busting URL parameter instead. - var bustedUrl = new URL(url, self.location.href); - bustedUrl.search += (bustedUrl.search ? '&' : '') + 'cachebust=' + Date.now(); - return new Request(bustedUrl, {headers: headers}); - } - - self.addEventListener('install', function(event) { - event.waitUntil( - // We can't use cache.add() here, since we want OFFLINE_URL to be the cache key, but - // the actual URL we end up requesting might include a cache-busting parameter. - fetch(createCacheBustedRequest(OFFLINE_URL)).then(function(response) { - return caches.open(CURRENT_CACHES.offline).then(function(cache) { - return cache.put(OFFLINE_URL, response); - }); - }).then(function(cache) { - self.skipWaiting(); - }) - ); - }); - - self.addEventListener('activate', function(event) { - // Delete all caches that aren't named in CURRENT_CACHES. - // While there is only one cache in this example, the same logic will handle the case where - // there are multiple versioned caches. - var expectedCacheNames = Object.keys(CURRENT_CACHES).map(function(key) { - return CURRENT_CACHES[key]; - }); - - event.waitUntil( - caches.keys().then(function(cacheNames) { - return Promise.all( - cacheNames.map(function(cacheName) { - if (expectedCacheNames.indexOf(cacheName) === -1) { - // If this cache name isn't present in the array of "expected" cache names, - // then delete it. - return caches.delete(cacheName); - } - }) - ); - }).then(function() { - self.clients.claim() - }) - ); - }); - - 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 - // versions older than 49, so we need to include a less precise fallback, - // which checks for a GET request with an Accept: text/html header. - if (event.request.mode === 'navigate' || - (event.request.method === 'GET' && - event.request.headers.get('accept').includes('text/html'))) { - event.respondWith( - fetch(event.request).catch(function(error) { - // The catch is only triggered if fetch() throws an exception, which will most likely - // happen due to the server being unreachable. - // If fetch() returns a valid HTTP response with an response code in the 4xx or 5xx - // range, the catch() will NOT be called. If you need custom handling for 4xx or 5xx - // errors, see https://github.com/GoogleChrome/samples/tree/gh-pages/service-worker/fallback-response - if (!navigator.onLine) { - return caches.match(OFFLINE_URL); - } else { - throw new Error(error); - } - }) - ); - } - - // If our if() condition is false, then this fetch handler won't intercept the request. - // If there are any other fetch handlers registered, they will get a chance to call - // event.respondWith(). If no fetch handlers call event.respondWith(), the request will be - // handled by the browser as if there were no service worker involvement. - }); - -} +// Cache all GET requests, so Discourse can be used while offline +workbox.routing.registerRoute( + new RegExp('.*?'), // Matches all, GET is implicit + new workbox.strategies.NetworkFirst({ // This will only use the cache when a network request fails + cacheName: "discourse-" + cacheVersion, + plugins: [ + new workbox.expiration.Plugin({ + maxAgeSeconds: 7* 24 * 60 * 60, // 7 days + }), + ], + }) +); const idleThresholdTime = 1000 * 10; // 10 seconds var lastAction = -1; diff --git a/lib/tasks/javascript.rake b/lib/tasks/javascript.rake index f933c4bd31..f66c6afa39 100644 --- a/lib/tasks/javascript.rake +++ b/lib/tasks/javascript.rake @@ -90,6 +90,26 @@ task 'javascript:update' do }, { # TODO: drop when we eventually drop IE11, this will land in iOS in version 13 source: 'intersection-observer/intersection-observer.js' + }, { + source: 'workbox-sw/build/.', + destination: 'workbox', + public: true + }, { + source: 'workbox-routing/build/.', + destination: 'workbox', + public: true + }, { + source: 'workbox-core/build/.', + destination: 'workbox', + public: true + }, { + source: 'workbox-strategies/build/.', + destination: 'workbox', + public: true + }, { + source: 'workbox-expiration/build/.', + destination: 'workbox', + public: true } ] diff --git a/package.json b/package.json index 43aee39155..c4bce5ac06 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,12 @@ "mousetrap": "https://github.com/discourse/mousetrap#firefox-alt-key", "pikaday": "1.8.0", "resumablejs": "1.1.0", - "spectrum-colorpicker": "1.8.0" + "spectrum-colorpicker": "1.8.0", + "workbox-core": "^4.3.1", + "workbox-expiration": "^4.3.1", + "workbox-routing": "^4.3.1", + "workbox-strategies": "^4.3.1", + "workbox-sw": "^4.3.1" }, "devDependencies": { "@arkweid/lefthook": "^0.5.6", diff --git a/public/javascripts/workbox/workbox-core.dev.js b/public/javascripts/workbox/workbox-core.dev.js new file mode 100644 index 0000000000..18b8b85f19 --- /dev/null +++ b/public/javascripts/workbox/workbox-core.dev.js @@ -0,0 +1,1712 @@ +this.workbox = this.workbox || {}; +this.workbox.core = (function (exports) { + 'use strict'; + + try { + self['workbox:core:4.3.1'] && _(); + } catch (e) {} // eslint-disable-line + + /* + Copyright 2019 Google LLC + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const logger = (() => { + let inGroup = false; + const methodToColorMap = { + debug: `#7f8c8d`, + // Gray + log: `#2ecc71`, + // Green + warn: `#f39c12`, + // Yellow + error: `#c0392b`, + // Red + groupCollapsed: `#3498db`, + // Blue + groupEnd: null // No colored prefix on groupEnd + + }; + + const print = function (method, args) { + if (method === 'groupCollapsed') { + // Safari doesn't print all console.groupCollapsed() arguments: + // https://bugs.webkit.org/show_bug.cgi?id=182754 + if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) { + console[method](...args); + return; + } + } + + const styles = [`background: ${methodToColorMap[method]}`, `border-radius: 0.5em`, `color: white`, `font-weight: bold`, `padding: 2px 0.5em`]; // When in a group, the workbox prefix is not displayed. + + const logPrefix = inGroup ? [] : ['%cworkbox', styles.join(';')]; + console[method](...logPrefix, ...args); + + if (method === 'groupCollapsed') { + inGroup = true; + } + + if (method === 'groupEnd') { + inGroup = false; + } + }; + + const api = {}; + + for (const method of Object.keys(methodToColorMap)) { + api[method] = (...args) => { + print(method, args); + }; + } + + return api; + })(); + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const messages = { + 'invalid-value': ({ + paramName, + validValueDescription, + value + }) => { + if (!paramName || !validValueDescription) { + throw new Error(`Unexpected input to 'invalid-value' error.`); + } + + return `The '${paramName}' parameter was given a value with an ` + `unexpected value. ${validValueDescription} Received a value of ` + `${JSON.stringify(value)}.`; + }, + 'not-in-sw': ({ + moduleName + }) => { + if (!moduleName) { + throw new Error(`Unexpected input to 'not-in-sw' error.`); + } + + return `The '${moduleName}' must be used in a service worker.`; + }, + 'not-an-array': ({ + moduleName, + className, + funcName, + paramName + }) => { + if (!moduleName || !className || !funcName || !paramName) { + throw new Error(`Unexpected input to 'not-an-array' error.`); + } + + return `The parameter '${paramName}' passed into ` + `'${moduleName}.${className}.${funcName}()' must be an array.`; + }, + 'incorrect-type': ({ + expectedType, + paramName, + moduleName, + className, + funcName + }) => { + if (!expectedType || !paramName || !moduleName || !funcName) { + throw new Error(`Unexpected input to 'incorrect-type' error.`); + } + + return `The parameter '${paramName}' passed into ` + `'${moduleName}.${className ? className + '.' : ''}` + `${funcName}()' must be of type ${expectedType}.`; + }, + 'incorrect-class': ({ + expectedClass, + paramName, + moduleName, + className, + funcName, + isReturnValueProblem + }) => { + if (!expectedClass || !moduleName || !funcName) { + throw new Error(`Unexpected input to 'incorrect-class' error.`); + } + + if (isReturnValueProblem) { + return `The return value from ` + `'${moduleName}.${className ? className + '.' : ''}${funcName}()' ` + `must be an instance of class ${expectedClass.name}.`; + } + + return `The parameter '${paramName}' passed into ` + `'${moduleName}.${className ? className + '.' : ''}${funcName}()' ` + `must be an instance of class ${expectedClass.name}.`; + }, + 'missing-a-method': ({ + expectedMethod, + paramName, + moduleName, + className, + funcName + }) => { + if (!expectedMethod || !paramName || !moduleName || !className || !funcName) { + throw new Error(`Unexpected input to 'missing-a-method' error.`); + } + + return `${moduleName}.${className}.${funcName}() expected the ` + `'${paramName}' parameter to expose a '${expectedMethod}' method.`; + }, + 'add-to-cache-list-unexpected-type': ({ + entry + }) => { + return `An unexpected entry was passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' The entry ` + `'${JSON.stringify(entry)}' isn't supported. You must supply an array of ` + `strings with one or more characters, objects with a url property or ` + `Request objects.`; + }, + 'add-to-cache-list-conflicting-entries': ({ + firstEntry, + secondEntry + }) => { + if (!firstEntry || !secondEntry) { + throw new Error(`Unexpected input to ` + `'add-to-cache-list-duplicate-entries' error.`); + } + + return `Two of the entries passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' had the URL ` + `${firstEntry._entryId} but different revision details. Workbox is ` + `is unable to cache and version the asset correctly. Please remove one ` + `of the entries.`; + }, + 'plugin-error-request-will-fetch': ({ + thrownError + }) => { + if (!thrownError) { + throw new Error(`Unexpected input to ` + `'plugin-error-request-will-fetch', error.`); + } + + return `An error was thrown by a plugins 'requestWillFetch()' method. ` + `The thrown error message was: '${thrownError.message}'.`; + }, + 'invalid-cache-name': ({ + cacheNameId, + value + }) => { + if (!cacheNameId) { + throw new Error(`Expected a 'cacheNameId' for error 'invalid-cache-name'`); + } + + return `You must provide a name containing at least one character for ` + `setCacheDeatils({${cacheNameId}: '...'}). Received a value of ` + `'${JSON.stringify(value)}'`; + }, + 'unregister-route-but-not-found-with-method': ({ + method + }) => { + if (!method) { + throw new Error(`Unexpected input to ` + `'unregister-route-but-not-found-with-method' error.`); + } + + return `The route you're trying to unregister was not previously ` + `registered for the method type '${method}'.`; + }, + 'unregister-route-route-not-registered': () => { + return `The route you're trying to unregister was not previously ` + `registered.`; + }, + 'queue-replay-failed': ({ + name + }) => { + return `Replaying the background sync queue '${name}' failed.`; + }, + 'duplicate-queue-name': ({ + name + }) => { + return `The Queue name '${name}' is already being used. ` + `All instances of backgroundSync.Queue must be given unique names.`; + }, + 'expired-test-without-max-age': ({ + methodName, + paramName + }) => { + return `The '${methodName}()' method can only be used when the ` + `'${paramName}' is used in the constructor.`; + }, + 'unsupported-route-type': ({ + moduleName, + className, + funcName, + paramName + }) => { + return `The supplied '${paramName}' parameter was an unsupported type. ` + `Please check the docs for ${moduleName}.${className}.${funcName} for ` + `valid input types.`; + }, + 'not-array-of-class': ({ + value, + expectedClass, + moduleName, + className, + funcName, + paramName + }) => { + return `The supplied '${paramName}' parameter must be an array of ` + `'${expectedClass}' objects. Received '${JSON.stringify(value)},'. ` + `Please check the call to ${moduleName}.${className}.${funcName}() ` + `to fix the issue.`; + }, + 'max-entries-or-age-required': ({ + moduleName, + className, + funcName + }) => { + return `You must define either config.maxEntries or config.maxAgeSeconds` + `in ${moduleName}.${className}.${funcName}`; + }, + 'statuses-or-headers-required': ({ + moduleName, + className, + funcName + }) => { + return `You must define either config.statuses or config.headers` + `in ${moduleName}.${className}.${funcName}`; + }, + 'invalid-string': ({ + moduleName, + className, + funcName, + paramName + }) => { + if (!paramName || !moduleName || !funcName) { + throw new Error(`Unexpected input to 'invalid-string' error.`); + } + + return `When using strings, the '${paramName}' parameter must start with ` + `'http' (for cross-origin matches) or '/' (for same-origin matches). ` + `Please see the docs for ${moduleName}.${funcName}() for ` + `more info.`; + }, + 'channel-name-required': () => { + return `You must provide a channelName to construct a ` + `BroadcastCacheUpdate instance.`; + }, + 'invalid-responses-are-same-args': () => { + return `The arguments passed into responsesAreSame() appear to be ` + `invalid. Please ensure valid Responses are used.`; + }, + 'expire-custom-caches-only': () => { + return `You must provide a 'cacheName' property when using the ` + `expiration plugin with a runtime caching strategy.`; + }, + 'unit-must-be-bytes': ({ + normalizedRangeHeader + }) => { + if (!normalizedRangeHeader) { + throw new Error(`Unexpected input to 'unit-must-be-bytes' error.`); + } + + return `The 'unit' portion of the Range header must be set to 'bytes'. ` + `The Range header provided was "${normalizedRangeHeader}"`; + }, + 'single-range-only': ({ + normalizedRangeHeader + }) => { + if (!normalizedRangeHeader) { + throw new Error(`Unexpected input to 'single-range-only' error.`); + } + + return `Multiple ranges are not supported. Please use a single start ` + `value, and optional end value. The Range header provided was ` + `"${normalizedRangeHeader}"`; + }, + 'invalid-range-values': ({ + normalizedRangeHeader + }) => { + if (!normalizedRangeHeader) { + throw new Error(`Unexpected input to 'invalid-range-values' error.`); + } + + return `The Range header is missing both start and end values. At least ` + `one of those values is needed. The Range header provided was ` + `"${normalizedRangeHeader}"`; + }, + 'no-range-header': () => { + return `No Range header was found in the Request provided.`; + }, + 'range-not-satisfiable': ({ + size, + start, + end + }) => { + return `The start (${start}) and end (${end}) values in the Range are ` + `not satisfiable by the cached response, which is ${size} bytes.`; + }, + 'attempt-to-cache-non-get-request': ({ + url, + method + }) => { + return `Unable to cache '${url}' because it is a '${method}' request and ` + `only 'GET' requests can be cached.`; + }, + 'cache-put-with-no-response': ({ + url + }) => { + return `There was an attempt to cache '${url}' but the response was not ` + `defined.`; + }, + 'no-response': ({ + url, + error + }) => { + let message = `The strategy could not generate a response for '${url}'.`; + + if (error) { + message += ` The underlying error is ${error}.`; + } + + return message; + }, + 'bad-precaching-response': ({ + url, + status + }) => { + return `The precaching request for '${url}' failed with an HTTP ` + `status of ${status}.`; + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + + const generatorFunction = (code, ...args) => { + const message = messages[code]; + + if (!message) { + throw new Error(`Unable to find message for code '${code}'.`); + } + + return message(...args); + }; + + const messageGenerator = generatorFunction; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Workbox errors should be thrown with this class. + * This allows use to ensure the type easily in tests, + * helps developers identify errors from workbox + * easily and allows use to optimise error + * messages correctly. + * + * @private + */ + + class WorkboxError extends Error { + /** + * + * @param {string} errorCode The error code that + * identifies this particular error. + * @param {Object=} details Any relevant arguments + * that will help developers identify issues should + * be added as a key on the context object. + */ + constructor(errorCode, details) { + let message = messageGenerator(errorCode, details); + super(message); + this.name = errorCode; + this.details = details; + } + + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /* + * This method returns true if the current context is a service worker. + */ + + const isSWEnv = moduleName => { + if (!('ServiceWorkerGlobalScope' in self)) { + throw new WorkboxError('not-in-sw', { + moduleName + }); + } + }; + /* + * This method throws if the supplied value is not an array. + * The destructed values are required to produce a meaningful error for users. + * The destructed and restructured object is so it's clear what is + * needed. + */ + + + const isArray = (value, { + moduleName, + className, + funcName, + paramName + }) => { + if (!Array.isArray(value)) { + throw new WorkboxError('not-an-array', { + moduleName, + className, + funcName, + paramName + }); + } + }; + + const hasMethod = (object, expectedMethod, { + moduleName, + className, + funcName, + paramName + }) => { + const type = typeof object[expectedMethod]; + + if (type !== 'function') { + throw new WorkboxError('missing-a-method', { + paramName, + expectedMethod, + moduleName, + className, + funcName + }); + } + }; + + const isType = (object, expectedType, { + moduleName, + className, + funcName, + paramName + }) => { + if (typeof object !== expectedType) { + throw new WorkboxError('incorrect-type', { + paramName, + expectedType, + moduleName, + className, + funcName + }); + } + }; + + const isInstance = (object, expectedClass, { + moduleName, + className, + funcName, + paramName, + isReturnValueProblem + }) => { + if (!(object instanceof expectedClass)) { + throw new WorkboxError('incorrect-class', { + paramName, + expectedClass, + moduleName, + className, + funcName, + isReturnValueProblem + }); + } + }; + + const isOneOf = (value, validValues, { + paramName + }) => { + if (!validValues.includes(value)) { + throw new WorkboxError('invalid-value', { + paramName, + value, + validValueDescription: `Valid values are ${JSON.stringify(validValues)}.` + }); + } + }; + + const isArrayOfClass = (value, expectedClass, { + moduleName, + className, + funcName, + paramName + }) => { + const error = new WorkboxError('not-array-of-class', { + value, + expectedClass, + moduleName, + className, + funcName, + paramName + }); + + if (!Array.isArray(value)) { + throw error; + } + + for (let item of value) { + if (!(item instanceof expectedClass)) { + throw error; + } + } + }; + + const finalAssertExports = { + hasMethod, + isArray, + isInstance, + isOneOf, + isSWEnv, + isType, + isArrayOfClass + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + + const quotaErrorCallbacks = new Set(); + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Adds a function to the set of quotaErrorCallbacks that will be executed if + * there's a quota error. + * + * @param {Function} callback + * @memberof workbox.core + */ + + function registerQuotaErrorCallback(callback) { + { + finalAssertExports.isType(callback, 'function', { + moduleName: 'workbox-core', + funcName: 'register', + paramName: 'callback' + }); + } + + quotaErrorCallbacks.add(callback); + + { + logger.log('Registered a callback to respond to quota errors.', callback); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const _cacheNameDetails = { + googleAnalytics: 'googleAnalytics', + precache: 'precache-v2', + prefix: 'workbox', + runtime: 'runtime', + suffix: self.registration.scope + }; + + const _createCacheName = cacheName => { + return [_cacheNameDetails.prefix, cacheName, _cacheNameDetails.suffix].filter(value => value.length > 0).join('-'); + }; + + const cacheNames = { + updateDetails: details => { + Object.keys(_cacheNameDetails).forEach(key => { + if (typeof details[key] !== 'undefined') { + _cacheNameDetails[key] = details[key]; + } + }); + }, + getGoogleAnalyticsName: userCacheName => { + return userCacheName || _createCacheName(_cacheNameDetails.googleAnalytics); + }, + getPrecacheName: userCacheName => { + return userCacheName || _createCacheName(_cacheNameDetails.precache); + }, + getPrefix: () => { + return _cacheNameDetails.prefix; + }, + getRuntimeName: userCacheName => { + return userCacheName || _createCacheName(_cacheNameDetails.runtime); + }, + getSuffix: () => { + return _cacheNameDetails.suffix; + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + + const getFriendlyURL = url => { + const urlObj = new URL(url, location); + + if (urlObj.origin === location.origin) { + return urlObj.pathname; + } + + return urlObj.href; + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Runs all of the callback functions, one at a time sequentially, in the order + * in which they were registered. + * + * @memberof workbox.core + * @private + */ + + async function executeQuotaErrorCallbacks() { + { + logger.log(`About to run ${quotaErrorCallbacks.size} ` + `callbacks to clean up caches.`); + } + + for (const callback of quotaErrorCallbacks) { + await callback(); + + { + logger.log(callback, 'is complete.'); + } + } + + { + logger.log('Finished running callbacks.'); + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const pluginEvents = { + CACHE_DID_UPDATE: 'cacheDidUpdate', + CACHE_KEY_WILL_BE_USED: 'cacheKeyWillBeUsed', + CACHE_WILL_UPDATE: 'cacheWillUpdate', + CACHED_RESPONSE_WILL_BE_USED: 'cachedResponseWillBeUsed', + FETCH_DID_FAIL: 'fetchDidFail', + FETCH_DID_SUCCEED: 'fetchDidSucceed', + REQUEST_WILL_FETCH: 'requestWillFetch' + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const pluginUtils = { + filter: (plugins, callbackName) => { + return plugins.filter(plugin => callbackName in plugin); + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Wrapper around cache.put(). + * + * Will call `cacheDidUpdate` on plugins if the cache was updated, using + * `matchOptions` when determining what the old entry is. + * + * @param {Object} options + * @param {string} options.cacheName + * @param {Request} options.request + * @param {Response} options.response + * @param {Event} [options.event] + * @param {Array} [options.plugins=[]] + * @param {Object} [options.matchOptions] + * + * @private + * @memberof module:workbox-core + */ + + const putWrapper = async ({ + cacheName, + request, + response, + event, + plugins = [], + matchOptions + } = {}) => { + { + if (request.method && request.method !== 'GET') { + throw new WorkboxError('attempt-to-cache-non-get-request', { + url: getFriendlyURL(request.url), + method: request.method + }); + } + } + + const effectiveRequest = await _getEffectiveRequest({ + plugins, + request, + mode: 'write' + }); + + if (!response) { + { + logger.error(`Cannot cache non-existent response for ` + `'${getFriendlyURL(effectiveRequest.url)}'.`); + } + + throw new WorkboxError('cache-put-with-no-response', { + url: getFriendlyURL(effectiveRequest.url) + }); + } + + let responseToCache = await _isResponseSafeToCache({ + event, + plugins, + response, + request: effectiveRequest + }); + + if (!responseToCache) { + { + logger.debug(`Response '${getFriendlyURL(effectiveRequest.url)}' will ` + `not be cached.`, responseToCache); + } + + return; + } + + const cache = await caches.open(cacheName); + const updatePlugins = pluginUtils.filter(plugins, pluginEvents.CACHE_DID_UPDATE); + let oldResponse = updatePlugins.length > 0 ? await matchWrapper({ + cacheName, + matchOptions, + request: effectiveRequest + }) : null; + + { + logger.debug(`Updating the '${cacheName}' cache with a new Response for ` + `${getFriendlyURL(effectiveRequest.url)}.`); + } + + try { + await cache.put(effectiveRequest, responseToCache); + } catch (error) { + // See https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-QuotaExceededError + if (error.name === 'QuotaExceededError') { + await executeQuotaErrorCallbacks(); + } + + throw error; + } + + for (let plugin of updatePlugins) { + await plugin[pluginEvents.CACHE_DID_UPDATE].call(plugin, { + cacheName, + event, + oldResponse, + newResponse: responseToCache, + request: effectiveRequest + }); + } + }; + /** + * This is a wrapper around cache.match(). + * + * @param {Object} options + * @param {string} options.cacheName Name of the cache to match against. + * @param {Request} options.request The Request that will be used to look up + * cache entries. + * @param {Event} [options.event] The event that propted the action. + * @param {Object} [options.matchOptions] Options passed to cache.match(). + * @param {Array} [options.plugins=[]] Array of plugins. + * @return {Response} A cached response if available. + * + * @private + * @memberof module:workbox-core + */ + + + const matchWrapper = async ({ + cacheName, + request, + event, + matchOptions, + plugins = [] + }) => { + const cache = await caches.open(cacheName); + const effectiveRequest = await _getEffectiveRequest({ + plugins, + request, + mode: 'read' + }); + let cachedResponse = await cache.match(effectiveRequest, matchOptions); + + { + if (cachedResponse) { + logger.debug(`Found a cached response in '${cacheName}'.`); + } else { + logger.debug(`No cached response found in '${cacheName}'.`); + } + } + + for (const plugin of plugins) { + if (pluginEvents.CACHED_RESPONSE_WILL_BE_USED in plugin) { + cachedResponse = await plugin[pluginEvents.CACHED_RESPONSE_WILL_BE_USED].call(plugin, { + cacheName, + event, + matchOptions, + cachedResponse, + request: effectiveRequest + }); + + { + if (cachedResponse) { + finalAssertExports.isInstance(cachedResponse, Response, { + moduleName: 'Plugin', + funcName: pluginEvents.CACHED_RESPONSE_WILL_BE_USED, + isReturnValueProblem: true + }); + } + } + } + } + + return cachedResponse; + }; + /** + * This method will call cacheWillUpdate on the available plugins (or use + * status === 200) to determine if the Response is safe and valid to cache. + * + * @param {Object} options + * @param {Request} options.request + * @param {Response} options.response + * @param {Event} [options.event] + * @param {Array} [options.plugins=[]] + * @return {Promise} + * + * @private + * @memberof module:workbox-core + */ + + + const _isResponseSafeToCache = async ({ + request, + response, + event, + plugins + }) => { + let responseToCache = response; + let pluginsUsed = false; + + for (let plugin of plugins) { + if (pluginEvents.CACHE_WILL_UPDATE in plugin) { + pluginsUsed = true; + responseToCache = await plugin[pluginEvents.CACHE_WILL_UPDATE].call(plugin, { + request, + response: responseToCache, + event + }); + + { + if (responseToCache) { + finalAssertExports.isInstance(responseToCache, Response, { + moduleName: 'Plugin', + funcName: pluginEvents.CACHE_WILL_UPDATE, + isReturnValueProblem: true + }); + } + } + + if (!responseToCache) { + break; + } + } + } + + if (!pluginsUsed) { + { + if (!responseToCache.status === 200) { + if (responseToCache.status === 0) { + logger.warn(`The response for '${request.url}' is an opaque ` + `response. The caching strategy that you're using will not ` + `cache opaque responses by default.`); + } else { + logger.debug(`The response for '${request.url}' returned ` + `a status code of '${response.status}' and won't be cached as a ` + `result.`); + } + } + } + + responseToCache = responseToCache.status === 200 ? responseToCache : null; + } + + return responseToCache ? responseToCache : null; + }; + /** + * Checks the list of plugins for the cacheKeyWillBeUsed callback, and + * executes any of those callbacks found in sequence. The final `Request` object + * returned by the last plugin is treated as the cache key for cache reads + * and/or writes. + * + * @param {Object} options + * @param {Request} options.request + * @param {string} options.mode + * @param {Array} [options.plugins=[]] + * @return {Promise} + * + * @private + * @memberof module:workbox-core + */ + + + const _getEffectiveRequest = async ({ + request, + mode, + plugins + }) => { + const cacheKeyWillBeUsedPlugins = pluginUtils.filter(plugins, pluginEvents.CACHE_KEY_WILL_BE_USED); + let effectiveRequest = request; + + for (const plugin of cacheKeyWillBeUsedPlugins) { + effectiveRequest = await plugin[pluginEvents.CACHE_KEY_WILL_BE_USED].call(plugin, { + mode, + request: effectiveRequest + }); + + if (typeof effectiveRequest === 'string') { + effectiveRequest = new Request(effectiveRequest); + } + + { + finalAssertExports.isInstance(effectiveRequest, Request, { + moduleName: 'Plugin', + funcName: pluginEvents.CACHE_KEY_WILL_BE_USED, + isReturnValueProblem: true + }); + } + } + + return effectiveRequest; + }; + + const cacheWrapper = { + put: putWrapper, + match: matchWrapper + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A class that wraps common IndexedDB functionality in a promise-based API. + * It exposes all the underlying power and functionality of IndexedDB, but + * wraps the most commonly used features in a way that's much simpler to use. + * + * @private + */ + + class DBWrapper { + /** + * @param {string} name + * @param {number} version + * @param {Object=} [callback] + * @param {!Function} [callbacks.onupgradeneeded] + * @param {!Function} [callbacks.onversionchange] Defaults to + * DBWrapper.prototype._onversionchange when not specified. + * @private + */ + constructor(name, version, { + onupgradeneeded, + onversionchange = this._onversionchange + } = {}) { + this._name = name; + this._version = version; + this._onupgradeneeded = onupgradeneeded; + this._onversionchange = onversionchange; // If this is null, it means the database isn't open. + + this._db = null; + } + /** + * Returns the IDBDatabase instance (not normally needed). + * + * @private + */ + + + get db() { + return this._db; + } + /** + * Opens a connected to an IDBDatabase, invokes any onupgradedneeded + * callback, and added an onversionchange callback to the database. + * + * @return {IDBDatabase} + * @private + */ + + + async open() { + if (this._db) return; + this._db = await new Promise((resolve, reject) => { + // This flag is flipped to true if the timeout callback runs prior + // to the request failing or succeeding. Note: we use a timeout instead + // of an onblocked handler since there are cases where onblocked will + // never never run. A timeout better handles all possible scenarios: + // https://github.com/w3c/IndexedDB/issues/223 + let openRequestTimedOut = false; + setTimeout(() => { + openRequestTimedOut = true; + reject(new Error('The open request was blocked and timed out')); + }, this.OPEN_TIMEOUT); + const openRequest = indexedDB.open(this._name, this._version); + + openRequest.onerror = () => reject(openRequest.error); + + openRequest.onupgradeneeded = evt => { + if (openRequestTimedOut) { + openRequest.transaction.abort(); + evt.target.result.close(); + } else if (this._onupgradeneeded) { + this._onupgradeneeded(evt); + } + }; + + openRequest.onsuccess = ({ + target + }) => { + const db = target.result; + + if (openRequestTimedOut) { + db.close(); + } else { + db.onversionchange = this._onversionchange.bind(this); + resolve(db); + } + }; + }); + return this; + } + /** + * Polyfills the native `getKey()` method. Note, this is overridden at + * runtime if the browser supports the native method. + * + * @param {string} storeName + * @param {*} query + * @return {Array} + * @private + */ + + + async getKey(storeName, query) { + return (await this.getAllKeys(storeName, query, 1))[0]; + } + /** + * Polyfills the native `getAll()` method. Note, this is overridden at + * runtime if the browser supports the native method. + * + * @param {string} storeName + * @param {*} query + * @param {number} count + * @return {Array} + * @private + */ + + + async getAll(storeName, query, count) { + return await this.getAllMatching(storeName, { + query, + count + }); + } + /** + * Polyfills the native `getAllKeys()` method. Note, this is overridden at + * runtime if the browser supports the native method. + * + * @param {string} storeName + * @param {*} query + * @param {number} count + * @return {Array} + * @private + */ + + + async getAllKeys(storeName, query, count) { + return (await this.getAllMatching(storeName, { + query, + count, + includeKeys: true + })).map(({ + key + }) => key); + } + /** + * Supports flexible lookup in an object store by specifying an index, + * query, direction, and count. This method returns an array of objects + * with the signature . + * + * @param {string} storeName + * @param {Object} [opts] + * @param {string} [opts.index] The index to use (if specified). + * @param {*} [opts.query] + * @param {IDBCursorDirection} [opts.direction] + * @param {number} [opts.count] The max number of results to return. + * @param {boolean} [opts.includeKeys] When true, the structure of the + * returned objects is changed from an array of values to an array of + * objects in the form {key, primaryKey, value}. + * @return {Array} + * @private + */ + + + async getAllMatching(storeName, { + index, + query = null, + // IE errors if query === `undefined`. + direction = 'next', + count, + includeKeys + } = {}) { + return await this.transaction([storeName], 'readonly', (txn, done) => { + const store = txn.objectStore(storeName); + const target = index ? store.index(index) : store; + const results = []; + + target.openCursor(query, direction).onsuccess = ({ + target + }) => { + const cursor = target.result; + + if (cursor) { + const { + primaryKey, + key, + value + } = cursor; + results.push(includeKeys ? { + primaryKey, + key, + value + } : value); + + if (count && results.length >= count) { + done(results); + } else { + cursor.continue(); + } + } else { + done(results); + } + }; + }); + } + /** + * Accepts a list of stores, a transaction type, and a callback and + * performs a transaction. A promise is returned that resolves to whatever + * value the callback chooses. The callback holds all the transaction logic + * and is invoked with two arguments: + * 1. The IDBTransaction object + * 2. A `done` function, that's used to resolve the promise when + * when the transaction is done, if passed a value, the promise is + * resolved to that value. + * + * @param {Array} storeNames An array of object store names + * involved in the transaction. + * @param {string} type Can be `readonly` or `readwrite`. + * @param {!Function} callback + * @return {*} The result of the transaction ran by the callback. + * @private + */ + + + async transaction(storeNames, type, callback) { + await this.open(); + return await new Promise((resolve, reject) => { + const txn = this._db.transaction(storeNames, type); + + txn.onabort = ({ + target + }) => reject(target.error); + + txn.oncomplete = () => resolve(); + + callback(txn, value => resolve(value)); + }); + } + /** + * Delegates async to a native IDBObjectStore method. + * + * @param {string} method The method name. + * @param {string} storeName The object store name. + * @param {string} type Can be `readonly` or `readwrite`. + * @param {...*} args The list of args to pass to the native method. + * @return {*} The result of the transaction. + * @private + */ + + + async _call(method, storeName, type, ...args) { + const callback = (txn, done) => { + txn.objectStore(storeName)[method](...args).onsuccess = ({ + target + }) => { + done(target.result); + }; + }; + + return await this.transaction([storeName], type, callback); + } + /** + * The default onversionchange handler, which closes the database so other + * connections can open without being blocked. + * + * @private + */ + + + _onversionchange() { + this.close(); + } + /** + * Closes the connection opened by `DBWrapper.open()`. Generally this method + * doesn't need to be called since: + * 1. It's usually better to keep a connection open since opening + * a new connection is somewhat slow. + * 2. Connections are automatically closed when the reference is + * garbage collected. + * The primary use case for needing to close a connection is when another + * reference (typically in another tab) needs to upgrade it and would be + * blocked by the current, open connection. + * + * @private + */ + + + close() { + if (this._db) { + this._db.close(); + + this._db = null; + } + } + + } // Exposed to let users modify the default timeout on a per-instance + // or global basis. + + DBWrapper.prototype.OPEN_TIMEOUT = 2000; // Wrap native IDBObjectStore methods according to their mode. + + const methodsToWrap = { + 'readonly': ['get', 'count', 'getKey', 'getAll', 'getAllKeys'], + 'readwrite': ['add', 'put', 'clear', 'delete'] + }; + + for (const [mode, methods] of Object.entries(methodsToWrap)) { + for (const method of methods) { + if (method in IDBObjectStore.prototype) { + // Don't use arrow functions here since we're outside of the class. + DBWrapper.prototype[method] = async function (storeName, ...args) { + return await this._call(method, storeName, mode, ...args); + }; + } + } + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The Deferred class composes Promises in a way that allows for them to be + * resolved or rejected from outside the constructor. In most cases promises + * should be used directly, but Deferreds can be necessary when the logic to + * resolve a promise must be separate. + * + * @private + */ + + class Deferred { + /** + * Creates a promise and exposes its resolve and reject functions as methods. + */ + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } + + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Deletes the database. + * Note: this is exported separately from the DBWrapper module because most + * usages of IndexedDB in workbox dont need deleting, and this way it can be + * reused in tests to delete databases without creating DBWrapper instances. + * + * @param {string} name The database name. + * @private + */ + + const deleteDatabase = async name => { + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(name); + + request.onerror = ({ + target + }) => { + reject(target.error); + }; + + request.onblocked = () => { + reject(new Error('Delete blocked')); + }; + + request.onsuccess = () => { + resolve(); + }; + }); + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Wrapper around the fetch API. + * + * Will call requestWillFetch on available plugins. + * + * @param {Object} options + * @param {Request|string} options.request + * @param {Object} [options.fetchOptions] + * @param {Event} [options.event] + * @param {Array} [options.plugins=[]] + * @return {Promise} + * + * @private + * @memberof module:workbox-core + */ + + const wrappedFetch = async ({ + request, + fetchOptions, + event, + plugins = [] + }) => { + // We *should* be able to call `await event.preloadResponse` even if it's + // undefined, but for some reason, doing so leads to errors in our Node unit + // tests. To work around that, explicitly check preloadResponse's value first. + if (event && event.preloadResponse) { + const possiblePreloadResponse = await event.preloadResponse; + + if (possiblePreloadResponse) { + { + logger.log(`Using a preloaded navigation response for ` + `'${getFriendlyURL(request.url)}'`); + } + + return possiblePreloadResponse; + } + } + + if (typeof request === 'string') { + request = new Request(request); + } + + { + finalAssertExports.isInstance(request, Request, { + paramName: request, + expectedClass: 'Request', + moduleName: 'workbox-core', + className: 'fetchWrapper', + funcName: 'wrappedFetch' + }); + } + + const failedFetchPlugins = pluginUtils.filter(plugins, pluginEvents.FETCH_DID_FAIL); // If there is a fetchDidFail plugin, we need to save a clone of the + // original request before it's either modified by a requestWillFetch + // plugin or before the original request's body is consumed via fetch(). + + const originalRequest = failedFetchPlugins.length > 0 ? request.clone() : null; + + try { + for (let plugin of plugins) { + if (pluginEvents.REQUEST_WILL_FETCH in plugin) { + request = await plugin[pluginEvents.REQUEST_WILL_FETCH].call(plugin, { + request: request.clone(), + event + }); + + { + if (request) { + finalAssertExports.isInstance(request, Request, { + moduleName: 'Plugin', + funcName: pluginEvents.CACHED_RESPONSE_WILL_BE_USED, + isReturnValueProblem: true + }); + } + } + } + } + } catch (err) { + throw new WorkboxError('plugin-error-request-will-fetch', { + thrownError: err + }); + } // The request can be altered by plugins with `requestWillFetch` making + // the original request (Most likely from a `fetch` event) to be different + // to the Request we make. Pass both to `fetchDidFail` to aid debugging. + + + let pluginFilteredRequest = request.clone(); + + try { + let fetchResponse; // See https://github.com/GoogleChrome/workbox/issues/1796 + + if (request.mode === 'navigate') { + fetchResponse = await fetch(request); + } else { + fetchResponse = await fetch(request, fetchOptions); + } + + { + logger.debug(`Network request for ` + `'${getFriendlyURL(request.url)}' returned a response with ` + `status '${fetchResponse.status}'.`); + } + + for (const plugin of plugins) { + if (pluginEvents.FETCH_DID_SUCCEED in plugin) { + fetchResponse = await plugin[pluginEvents.FETCH_DID_SUCCEED].call(plugin, { + event, + request: pluginFilteredRequest, + response: fetchResponse + }); + + { + if (fetchResponse) { + finalAssertExports.isInstance(fetchResponse, Response, { + moduleName: 'Plugin', + funcName: pluginEvents.FETCH_DID_SUCCEED, + isReturnValueProblem: true + }); + } + } + } + } + + return fetchResponse; + } catch (error) { + { + logger.error(`Network request for ` + `'${getFriendlyURL(request.url)}' threw an error.`, error); + } + + for (const plugin of failedFetchPlugins) { + await plugin[pluginEvents.FETCH_DID_FAIL].call(plugin, { + error, + event, + originalRequest: originalRequest.clone(), + request: pluginFilteredRequest.clone() + }); + } + + throw error; + } + }; + + const fetchWrapper = { + fetch: wrappedFetch + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + + var _private = /*#__PURE__*/Object.freeze({ + assert: finalAssertExports, + cacheNames: cacheNames, + cacheWrapper: cacheWrapper, + DBWrapper: DBWrapper, + Deferred: Deferred, + deleteDatabase: deleteDatabase, + executeQuotaErrorCallbacks: executeQuotaErrorCallbacks, + fetchWrapper: fetchWrapper, + getFriendlyURL: getFriendlyURL, + logger: logger, + WorkboxError: WorkboxError + }); + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Claim any currently available clients once the service worker + * becomes active. This is normally used in conjunction with `skipWaiting()`. + * + * @alias workbox.core.clientsClaim + */ + + const clientsClaim = () => { + addEventListener('activate', () => clients.claim()); + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Get the current cache names and prefix/suffix used by Workbox. + * + * `cacheNames.precache` is used for precached assets, + * `cacheNames.googleAnalytics` is used by `workbox-google-analytics` to + * store `analytics.js`, and `cacheNames.runtime` is used for everything else. + * + * `cacheNames.prefix` can be used to retrieve just the current prefix value. + * `cacheNames.suffix` can be used to retrieve just the current suffix value. + * + * @return {Object} An object with `precache`, `runtime`, `prefix`, and + * `googleAnalytics` properties. + * + * @alias workbox.core.cacheNames + */ + + const cacheNames$1 = { + get googleAnalytics() { + return cacheNames.getGoogleAnalyticsName(); + }, + + get precache() { + return cacheNames.getPrecacheName(); + }, + + get prefix() { + return cacheNames.getPrefix(); + }, + + get runtime() { + return cacheNames.getRuntimeName(); + }, + + get suffix() { + return cacheNames.getSuffix(); + } + + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Modifies the default cache names used by the Workbox packages. + * Cache names are generated as `--`. + * + * @param {Object} details + * @param {Object} [details.prefix] The string to add to the beginning of + * the precache and runtime cache names. + * @param {Object} [details.suffix] The string to add to the end of + * the precache and runtime cache names. + * @param {Object} [details.precache] The cache name to use for precache + * caching. + * @param {Object} [details.runtime] The cache name to use for runtime caching. + * @param {Object} [details.googleAnalytics] The cache name to use for + * `workbox-google-analytics` caching. + * + * @alias workbox.core.setCacheNameDetails + */ + + const setCacheNameDetails = details => { + { + Object.keys(details).forEach(key => { + finalAssertExports.isType(details[key], 'string', { + moduleName: 'workbox-core', + funcName: 'setCacheNameDetails', + paramName: `details.${key}` + }); + }); + + if ('precache' in details && details.precache.length === 0) { + throw new WorkboxError('invalid-cache-name', { + cacheNameId: 'precache', + value: details.precache + }); + } + + if ('runtime' in details && details.runtime.length === 0) { + throw new WorkboxError('invalid-cache-name', { + cacheNameId: 'runtime', + value: details.runtime + }); + } + + if ('googleAnalytics' in details && details.googleAnalytics.length === 0) { + throw new WorkboxError('invalid-cache-name', { + cacheNameId: 'googleAnalytics', + value: details.googleAnalytics + }); + } + } + + cacheNames.updateDetails(details); + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Force a service worker to become active, instead of waiting. This is + * normally used in conjunction with `clientsClaim()`. + * + * @alias workbox.core.skipWaiting + */ + + const skipWaiting = () => { + // We need to explicitly call `self.skipWaiting()` here because we're + // shadowing `skipWaiting` with this local function. + addEventListener('install', () => self.skipWaiting()); + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + + try { + self.workbox.v = self.workbox.v || {}; + } catch (errer) {} // NOOP + + exports._private = _private; + exports.clientsClaim = clientsClaim; + exports.cacheNames = cacheNames$1; + exports.registerQuotaErrorCallback = registerQuotaErrorCallback; + exports.setCacheNameDetails = setCacheNameDetails; + exports.skipWaiting = skipWaiting; + + return exports; + +}({})); +//# sourceMappingURL=workbox-core.dev.js.map diff --git a/public/javascripts/workbox/workbox-core.dev.js.map b/public/javascripts/workbox/workbox-core.dev.js.map new file mode 100644 index 0000000000..879a7658d3 --- /dev/null +++ b/public/javascripts/workbox/workbox-core.dev.js.map @@ -0,0 +1 @@ +{"version":3,"file":"workbox-core.dev.js","sources":["../_version.mjs","../_private/logger.mjs","../models/messages/messages.mjs","../models/messages/messageGenerator.mjs","../_private/WorkboxError.mjs","../_private/assert.mjs","../models/quotaErrorCallbacks.mjs","../registerQuotaErrorCallback.mjs","../_private/cacheNames.mjs","../_private/getFriendlyURL.mjs","../_private/executeQuotaErrorCallbacks.mjs","../models/pluginEvents.mjs","../utils/pluginUtils.mjs","../_private/cacheWrapper.mjs","../_private/DBWrapper.mjs","../_private/Deferred.mjs","../_private/deleteDatabase.mjs","../_private/fetchWrapper.mjs","../_private.mjs","../clientsClaim.mjs","../cacheNames.mjs","../setCacheNameDetails.mjs","../skipWaiting.mjs","../index.mjs"],"sourcesContent":["try{self['workbox:core:4.3.1']&&_()}catch(e){}// eslint-disable-line","/*\n Copyright 2019 Google LLC\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n\nconst logger = process.env.NODE_ENV === 'production' ? null : (() => {\n let inGroup = false;\n\n const methodToColorMap = {\n debug: `#7f8c8d`, // Gray\n log: `#2ecc71`, // Green\n warn: `#f39c12`, // Yellow\n error: `#c0392b`, // Red\n groupCollapsed: `#3498db`, // Blue\n groupEnd: null, // No colored prefix on groupEnd\n };\n\n const print = function(method, args) {\n if (method === 'groupCollapsed') {\n // Safari doesn't print all console.groupCollapsed() arguments:\n // https://bugs.webkit.org/show_bug.cgi?id=182754\n if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) {\n console[method](...args);\n return;\n }\n }\n\n const styles = [\n `background: ${methodToColorMap[method]}`,\n `border-radius: 0.5em`,\n `color: white`,\n `font-weight: bold`,\n `padding: 2px 0.5em`,\n ];\n\n // When in a group, the workbox prefix is not displayed.\n const logPrefix = inGroup ? [] : ['%cworkbox', styles.join(';')];\n\n console[method](...logPrefix, ...args);\n\n if (method === 'groupCollapsed') {\n inGroup = true;\n }\n if (method === 'groupEnd') {\n inGroup = false;\n }\n };\n\n const api = {};\n for (const method of Object.keys(methodToColorMap)) {\n api[method] = (...args) => {\n print(method, args);\n };\n }\n\n return api;\n})();\n\nexport {logger};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../../_version.mjs';\n\n\nexport const messages = {\n 'invalid-value': ({paramName, validValueDescription, value}) => {\n if (!paramName || !validValueDescription) {\n throw new Error(`Unexpected input to 'invalid-value' error.`);\n }\n return `The '${paramName}' parameter was given a value with an ` +\n `unexpected value. ${validValueDescription} Received a value of ` +\n `${JSON.stringify(value)}.`;\n },\n\n 'not-in-sw': ({moduleName}) => {\n if (!moduleName) {\n throw new Error(`Unexpected input to 'not-in-sw' error.`);\n }\n return `The '${moduleName}' must be used in a service worker.`;\n },\n\n 'not-an-array': ({moduleName, className, funcName, paramName}) => {\n if (!moduleName || !className || !funcName || !paramName) {\n throw new Error(`Unexpected input to 'not-an-array' error.`);\n }\n return `The parameter '${paramName}' passed into ` +\n `'${moduleName}.${className}.${funcName}()' must be an array.`;\n },\n\n 'incorrect-type': ({expectedType, paramName, moduleName, className,\n funcName}) => {\n if (!expectedType || !paramName || !moduleName || !funcName) {\n throw new Error(`Unexpected input to 'incorrect-type' error.`);\n }\n return `The parameter '${paramName}' passed into ` +\n `'${moduleName}.${className ? (className + '.') : ''}` +\n `${funcName}()' must be of type ${expectedType}.`;\n },\n\n 'incorrect-class': ({expectedClass, paramName, moduleName, className,\n funcName, isReturnValueProblem}) => {\n if (!expectedClass || !moduleName || !funcName) {\n throw new Error(`Unexpected input to 'incorrect-class' error.`);\n }\n\n if (isReturnValueProblem) {\n return `The return value from ` +\n `'${moduleName}.${className ? (className + '.') : ''}${funcName}()' ` +\n `must be an instance of class ${expectedClass.name}.`;\n }\n\n return `The parameter '${paramName}' passed into ` +\n `'${moduleName}.${className ? (className + '.') : ''}${funcName}()' ` +\n `must be an instance of class ${expectedClass.name}.`;\n },\n\n 'missing-a-method': ({expectedMethod, paramName, moduleName, className,\n funcName}) => {\n if (!expectedMethod || !paramName || !moduleName || !className\n || !funcName) {\n throw new Error(`Unexpected input to 'missing-a-method' error.`);\n }\n return `${moduleName}.${className}.${funcName}() expected the ` +\n `'${paramName}' parameter to expose a '${expectedMethod}' method.`;\n },\n\n 'add-to-cache-list-unexpected-type': ({entry}) => {\n return `An unexpected entry was passed to ` +\n `'workbox-precaching.PrecacheController.addToCacheList()' The entry ` +\n `'${JSON.stringify(entry)}' isn't supported. You must supply an array of ` +\n `strings with one or more characters, objects with a url property or ` +\n `Request objects.`;\n },\n\n 'add-to-cache-list-conflicting-entries': ({firstEntry, secondEntry}) => {\n if (!firstEntry || !secondEntry) {\n throw new Error(`Unexpected input to ` +\n `'add-to-cache-list-duplicate-entries' error.`);\n }\n\n return `Two of the entries passed to ` +\n `'workbox-precaching.PrecacheController.addToCacheList()' had the URL ` +\n `${firstEntry._entryId} but different revision details. Workbox is ` +\n `is unable to cache and version the asset correctly. Please remove one ` +\n `of the entries.`;\n },\n\n 'plugin-error-request-will-fetch': ({thrownError}) => {\n if (!thrownError) {\n throw new Error(`Unexpected input to ` +\n `'plugin-error-request-will-fetch', error.`);\n }\n\n return `An error was thrown by a plugins 'requestWillFetch()' method. ` +\n `The thrown error message was: '${thrownError.message}'.`;\n },\n\n 'invalid-cache-name': ({cacheNameId, value}) => {\n if (!cacheNameId) {\n throw new Error(\n `Expected a 'cacheNameId' for error 'invalid-cache-name'`);\n }\n\n return `You must provide a name containing at least one character for ` +\n `setCacheDeatils({${cacheNameId}: '...'}). Received a value of ` +\n `'${JSON.stringify(value)}'`;\n },\n\n 'unregister-route-but-not-found-with-method': ({method}) => {\n if (!method) {\n throw new Error(`Unexpected input to ` +\n `'unregister-route-but-not-found-with-method' error.`);\n }\n\n return `The route you're trying to unregister was not previously ` +\n `registered for the method type '${method}'.`;\n },\n\n 'unregister-route-route-not-registered': () => {\n return `The route you're trying to unregister was not previously ` +\n `registered.`;\n },\n\n 'queue-replay-failed': ({name}) => {\n return `Replaying the background sync queue '${name}' failed.`;\n },\n\n 'duplicate-queue-name': ({name}) => {\n return `The Queue name '${name}' is already being used. ` +\n `All instances of backgroundSync.Queue must be given unique names.`;\n },\n\n 'expired-test-without-max-age': ({methodName, paramName}) => {\n return `The '${methodName}()' method can only be used when the ` +\n `'${paramName}' is used in the constructor.`;\n },\n\n 'unsupported-route-type': ({moduleName, className, funcName, paramName}) => {\n return `The supplied '${paramName}' parameter was an unsupported type. ` +\n `Please check the docs for ${moduleName}.${className}.${funcName} for ` +\n `valid input types.`;\n },\n\n 'not-array-of-class': ({value, expectedClass,\n moduleName, className, funcName, paramName}) => {\n return `The supplied '${paramName}' parameter must be an array of ` +\n `'${expectedClass}' objects. Received '${JSON.stringify(value)},'. ` +\n `Please check the call to ${moduleName}.${className}.${funcName}() ` +\n `to fix the issue.`;\n },\n\n 'max-entries-or-age-required': ({moduleName, className, funcName}) => {\n return `You must define either config.maxEntries or config.maxAgeSeconds` +\n `in ${moduleName}.${className}.${funcName}`;\n },\n\n 'statuses-or-headers-required': ({moduleName, className, funcName}) => {\n return `You must define either config.statuses or config.headers` +\n `in ${moduleName}.${className}.${funcName}`;\n },\n\n 'invalid-string': ({moduleName, className, funcName, paramName}) => {\n if (!paramName || !moduleName || !funcName) {\n throw new Error(`Unexpected input to 'invalid-string' error.`);\n }\n return `When using strings, the '${paramName}' parameter must start with ` +\n `'http' (for cross-origin matches) or '/' (for same-origin matches). ` +\n `Please see the docs for ${moduleName}.${funcName}() for ` +\n `more info.`;\n },\n\n 'channel-name-required': () => {\n return `You must provide a channelName to construct a ` +\n `BroadcastCacheUpdate instance.`;\n },\n\n 'invalid-responses-are-same-args': () => {\n return `The arguments passed into responsesAreSame() appear to be ` +\n `invalid. Please ensure valid Responses are used.`;\n },\n\n 'expire-custom-caches-only': () => {\n return `You must provide a 'cacheName' property when using the ` +\n `expiration plugin with a runtime caching strategy.`;\n },\n\n 'unit-must-be-bytes': ({normalizedRangeHeader}) => {\n if (!normalizedRangeHeader) {\n throw new Error(`Unexpected input to 'unit-must-be-bytes' error.`);\n }\n return `The 'unit' portion of the Range header must be set to 'bytes'. ` +\n `The Range header provided was \"${normalizedRangeHeader}\"`;\n },\n\n 'single-range-only': ({normalizedRangeHeader}) => {\n if (!normalizedRangeHeader) {\n throw new Error(`Unexpected input to 'single-range-only' error.`);\n }\n return `Multiple ranges are not supported. Please use a single start ` +\n `value, and optional end value. The Range header provided was ` +\n `\"${normalizedRangeHeader}\"`;\n },\n\n 'invalid-range-values': ({normalizedRangeHeader}) => {\n if (!normalizedRangeHeader) {\n throw new Error(`Unexpected input to 'invalid-range-values' error.`);\n }\n return `The Range header is missing both start and end values. At least ` +\n `one of those values is needed. The Range header provided was ` +\n `\"${normalizedRangeHeader}\"`;\n },\n\n 'no-range-header': () => {\n return `No Range header was found in the Request provided.`;\n },\n\n 'range-not-satisfiable': ({size, start, end}) => {\n return `The start (${start}) and end (${end}) values in the Range are ` +\n `not satisfiable by the cached response, which is ${size} bytes.`;\n },\n\n 'attempt-to-cache-non-get-request': ({url, method}) => {\n return `Unable to cache '${url}' because it is a '${method}' request and ` +\n `only 'GET' requests can be cached.`;\n },\n\n 'cache-put-with-no-response': ({url}) => {\n return `There was an attempt to cache '${url}' but the response was not ` +\n `defined.`;\n },\n\n 'no-response': ({url, error}) => {\n let message = `The strategy could not generate a response for '${url}'.`;\n if (error) {\n message += ` The underlying error is ${error}.`;\n }\n return message;\n },\n\n 'bad-precaching-response': ({url, status}) => {\n return `The precaching request for '${url}' failed with an HTTP ` +\n `status of ${status}.`;\n },\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {messages} from './messages.mjs';\nimport '../../_version.mjs';\n\nconst fallback = (code, ...args) => {\n let msg = code;\n if (args.length > 0) {\n msg += ` :: ${JSON.stringify(args)}`;\n }\n return msg;\n};\n\nconst generatorFunction = (code, ...args) => {\n const message = messages[code];\n if (!message) {\n throw new Error(`Unable to find message for code '${code}'.`);\n }\n\n return message(...args);\n};\n\nexport const messageGenerator = (process.env.NODE_ENV === 'production') ?\n fallback : generatorFunction;\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {messageGenerator} from '../models/messages/messageGenerator.mjs';\nimport '../_version.mjs';\n\n/**\n * Workbox errors should be thrown with this class.\n * This allows use to ensure the type easily in tests,\n * helps developers identify errors from workbox\n * easily and allows use to optimise error\n * messages correctly.\n *\n * @private\n */\nclass WorkboxError extends Error {\n /**\n *\n * @param {string} errorCode The error code that\n * identifies this particular error.\n * @param {Object=} details Any relevant arguments\n * that will help developers identify issues should\n * be added as a key on the context object.\n */\n constructor(errorCode, details) {\n let message = messageGenerator(errorCode, details);\n\n super(message);\n\n this.name = errorCode;\n this.details = details;\n }\n}\n\nexport {WorkboxError};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {WorkboxError} from '../_private/WorkboxError.mjs';\nimport '../_version.mjs';\n\n/*\n * This method returns true if the current context is a service worker.\n */\nconst isSWEnv = (moduleName) => {\n if (!('ServiceWorkerGlobalScope' in self)) {\n throw new WorkboxError('not-in-sw', {moduleName});\n }\n};\n\n/*\n * This method throws if the supplied value is not an array.\n * The destructed values are required to produce a meaningful error for users.\n * The destructed and restructured object is so it's clear what is\n * needed.\n */\nconst isArray = (value, {moduleName, className, funcName, paramName}) => {\n if (!Array.isArray(value)) {\n throw new WorkboxError('not-an-array', {\n moduleName,\n className,\n funcName,\n paramName,\n });\n }\n};\n\nconst hasMethod = (object, expectedMethod,\n {moduleName, className, funcName, paramName}) => {\n const type = typeof object[expectedMethod];\n if (type !== 'function') {\n throw new WorkboxError('missing-a-method', {paramName, expectedMethod,\n moduleName, className, funcName});\n }\n};\n\nconst isType = (object, expectedType,\n {moduleName, className, funcName, paramName}) => {\n if (typeof object !== expectedType) {\n throw new WorkboxError('incorrect-type', {paramName, expectedType,\n moduleName, className, funcName});\n }\n};\n\nconst isInstance = (object, expectedClass,\n {moduleName, className, funcName,\n paramName, isReturnValueProblem}) => {\n if (!(object instanceof expectedClass)) {\n throw new WorkboxError('incorrect-class', {paramName, expectedClass,\n moduleName, className, funcName, isReturnValueProblem});\n }\n};\n\nconst isOneOf = (value, validValues, {paramName}) => {\n if (!validValues.includes(value)) {\n throw new WorkboxError('invalid-value', {\n paramName,\n value,\n validValueDescription: `Valid values are ${JSON.stringify(validValues)}.`,\n });\n }\n};\n\nconst isArrayOfClass = (value, expectedClass,\n {moduleName, className, funcName, paramName}) => {\n const error = new WorkboxError('not-array-of-class', {\n value, expectedClass,\n moduleName, className, funcName, paramName,\n });\n if (!Array.isArray(value)) {\n throw error;\n }\n\n for (let item of value) {\n if (!(item instanceof expectedClass)) {\n throw error;\n }\n }\n};\n\nconst finalAssertExports = process.env.NODE_ENV === 'production' ? null : {\n hasMethod,\n isArray,\n isInstance,\n isOneOf,\n isSWEnv,\n isType,\n isArrayOfClass,\n};\n\nexport {finalAssertExports as assert};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n\n// Callbacks to be executed whenever there's a quota error.\nconst quotaErrorCallbacks = new Set();\n\nexport {quotaErrorCallbacks};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {logger} from './_private/logger.mjs';\nimport {assert} from './_private/assert.mjs';\nimport {quotaErrorCallbacks} from './models/quotaErrorCallbacks.mjs';\nimport './_version.mjs';\n\n\n/**\n * Adds a function to the set of quotaErrorCallbacks that will be executed if\n * there's a quota error.\n *\n * @param {Function} callback\n * @memberof workbox.core\n */\nfunction registerQuotaErrorCallback(callback) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(callback, 'function', {\n moduleName: 'workbox-core',\n funcName: 'register',\n paramName: 'callback',\n });\n }\n\n quotaErrorCallbacks.add(callback);\n\n if (process.env.NODE_ENV !== 'production') {\n logger.log('Registered a callback to respond to quota errors.', callback);\n }\n}\n\nexport {registerQuotaErrorCallback};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n\nconst _cacheNameDetails = {\n googleAnalytics: 'googleAnalytics',\n precache: 'precache-v2',\n prefix: 'workbox',\n runtime: 'runtime',\n suffix: self.registration.scope,\n};\n\nconst _createCacheName = (cacheName) => {\n return [_cacheNameDetails.prefix, cacheName, _cacheNameDetails.suffix]\n .filter((value) => value.length > 0)\n .join('-');\n};\n\nexport const cacheNames = {\n updateDetails: (details) => {\n Object.keys(_cacheNameDetails).forEach((key) => {\n if (typeof details[key] !== 'undefined') {\n _cacheNameDetails[key] = details[key];\n }\n });\n },\n getGoogleAnalyticsName: (userCacheName) => {\n return userCacheName || _createCacheName(_cacheNameDetails.googleAnalytics);\n },\n getPrecacheName: (userCacheName) => {\n return userCacheName || _createCacheName(_cacheNameDetails.precache);\n },\n getPrefix: () => {\n return _cacheNameDetails.prefix;\n },\n getRuntimeName: (userCacheName) => {\n return userCacheName || _createCacheName(_cacheNameDetails.runtime);\n },\n getSuffix: () => {\n return _cacheNameDetails.suffix;\n },\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\nconst getFriendlyURL = (url) => {\n const urlObj = new URL(url, location);\n if (urlObj.origin === location.origin) {\n return urlObj.pathname;\n }\n return urlObj.href;\n};\n\nexport {getFriendlyURL};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {logger} from '../_private/logger.mjs';\nimport {quotaErrorCallbacks} from '../models/quotaErrorCallbacks.mjs';\nimport '../_version.mjs';\n\n\n/**\n * Runs all of the callback functions, one at a time sequentially, in the order\n * in which they were registered.\n *\n * @memberof workbox.core\n * @private\n */\nasync function executeQuotaErrorCallbacks() {\n if (process.env.NODE_ENV !== 'production') {\n logger.log(`About to run ${quotaErrorCallbacks.size} ` +\n `callbacks to clean up caches.`);\n }\n\n for (const callback of quotaErrorCallbacks) {\n await callback();\n if (process.env.NODE_ENV !== 'production') {\n logger.log(callback, 'is complete.');\n }\n }\n\n if (process.env.NODE_ENV !== 'production') {\n logger.log('Finished running callbacks.');\n }\n}\n\nexport {executeQuotaErrorCallbacks};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n\nexport const pluginEvents = {\n CACHE_DID_UPDATE: 'cacheDidUpdate',\n CACHE_KEY_WILL_BE_USED: 'cacheKeyWillBeUsed',\n CACHE_WILL_UPDATE: 'cacheWillUpdate',\n CACHED_RESPONSE_WILL_BE_USED: 'cachedResponseWillBeUsed',\n FETCH_DID_FAIL: 'fetchDidFail',\n FETCH_DID_SUCCEED: 'fetchDidSucceed',\n REQUEST_WILL_FETCH: 'requestWillFetch',\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\nexport const pluginUtils = {\n filter: (plugins, callbackName) => {\n return plugins.filter((plugin) => callbackName in plugin);\n },\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {WorkboxError} from './WorkboxError.mjs';\nimport {assert} from './assert.mjs';\nimport {getFriendlyURL} from './getFriendlyURL.mjs';\nimport {logger} from './logger.mjs';\nimport {executeQuotaErrorCallbacks} from './executeQuotaErrorCallbacks.mjs';\nimport {pluginEvents} from '../models/pluginEvents.mjs';\nimport {pluginUtils} from '../utils/pluginUtils.mjs';\nimport '../_version.mjs';\n\n\n/**\n * Wrapper around cache.put().\n *\n * Will call `cacheDidUpdate` on plugins if the cache was updated, using\n * `matchOptions` when determining what the old entry is.\n *\n * @param {Object} options\n * @param {string} options.cacheName\n * @param {Request} options.request\n * @param {Response} options.response\n * @param {Event} [options.event]\n * @param {Array} [options.plugins=[]]\n * @param {Object} [options.matchOptions]\n *\n * @private\n * @memberof module:workbox-core\n */\nconst putWrapper = async ({\n cacheName,\n request,\n response,\n event,\n plugins = [],\n matchOptions,\n} = {}) => {\n if (process.env.NODE_ENV !== 'production') {\n if (request.method && request.method !== 'GET') {\n throw new WorkboxError('attempt-to-cache-non-get-request', {\n url: getFriendlyURL(request.url),\n method: request.method,\n });\n }\n }\n\n const effectiveRequest = await _getEffectiveRequest({\n plugins, request, mode: 'write'});\n\n if (!response) {\n if (process.env.NODE_ENV !== 'production') {\n logger.error(`Cannot cache non-existent response for ` +\n `'${getFriendlyURL(effectiveRequest.url)}'.`);\n }\n\n throw new WorkboxError('cache-put-with-no-response', {\n url: getFriendlyURL(effectiveRequest.url),\n });\n }\n\n let responseToCache = await _isResponseSafeToCache({\n event,\n plugins,\n response,\n request: effectiveRequest,\n });\n\n if (!responseToCache) {\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(`Response '${getFriendlyURL(effectiveRequest.url)}' will ` +\n `not be cached.`, responseToCache);\n }\n return;\n }\n\n const cache = await caches.open(cacheName);\n\n const updatePlugins = pluginUtils.filter(\n plugins, pluginEvents.CACHE_DID_UPDATE);\n\n let oldResponse = updatePlugins.length > 0 ?\n await matchWrapper({cacheName, matchOptions, request: effectiveRequest}) :\n null;\n\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(`Updating the '${cacheName}' cache with a new Response for ` +\n `${getFriendlyURL(effectiveRequest.url)}.`);\n }\n\n try {\n await cache.put(effectiveRequest, responseToCache);\n } catch (error) {\n // See https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-QuotaExceededError\n if (error.name === 'QuotaExceededError') {\n await executeQuotaErrorCallbacks();\n }\n throw error;\n }\n\n for (let plugin of updatePlugins) {\n await plugin[pluginEvents.CACHE_DID_UPDATE].call(plugin, {\n cacheName,\n event,\n oldResponse,\n newResponse: responseToCache,\n request: effectiveRequest,\n });\n }\n};\n\n/**\n * This is a wrapper around cache.match().\n *\n * @param {Object} options\n * @param {string} options.cacheName Name of the cache to match against.\n * @param {Request} options.request The Request that will be used to look up\n * cache entries.\n * @param {Event} [options.event] The event that propted the action.\n * @param {Object} [options.matchOptions] Options passed to cache.match().\n * @param {Array} [options.plugins=[]] Array of plugins.\n * @return {Response} A cached response if available.\n *\n * @private\n * @memberof module:workbox-core\n */\nconst matchWrapper = async ({\n cacheName,\n request,\n event,\n matchOptions,\n plugins = [],\n}) => {\n const cache = await caches.open(cacheName);\n\n const effectiveRequest = await _getEffectiveRequest({\n plugins, request, mode: 'read'});\n\n let cachedResponse = await cache.match(effectiveRequest, matchOptions);\n if (process.env.NODE_ENV !== 'production') {\n if (cachedResponse) {\n logger.debug(`Found a cached response in '${cacheName}'.`);\n } else {\n logger.debug(`No cached response found in '${cacheName}'.`);\n }\n }\n\n for (const plugin of plugins) {\n if (pluginEvents.CACHED_RESPONSE_WILL_BE_USED in plugin) {\n cachedResponse = await plugin[pluginEvents.CACHED_RESPONSE_WILL_BE_USED]\n .call(plugin, {\n cacheName,\n event,\n matchOptions,\n cachedResponse,\n request: effectiveRequest,\n });\n if (process.env.NODE_ENV !== 'production') {\n if (cachedResponse) {\n assert.isInstance(cachedResponse, Response, {\n moduleName: 'Plugin',\n funcName: pluginEvents.CACHED_RESPONSE_WILL_BE_USED,\n isReturnValueProblem: true,\n });\n }\n }\n }\n }\n\n return cachedResponse;\n};\n\n/**\n * This method will call cacheWillUpdate on the available plugins (or use\n * status === 200) to determine if the Response is safe and valid to cache.\n *\n * @param {Object} options\n * @param {Request} options.request\n * @param {Response} options.response\n * @param {Event} [options.event]\n * @param {Array} [options.plugins=[]]\n * @return {Promise}\n *\n * @private\n * @memberof module:workbox-core\n */\nconst _isResponseSafeToCache = async ({request, response, event, plugins}) => {\n let responseToCache = response;\n let pluginsUsed = false;\n for (let plugin of plugins) {\n if (pluginEvents.CACHE_WILL_UPDATE in plugin) {\n pluginsUsed = true;\n responseToCache = await plugin[pluginEvents.CACHE_WILL_UPDATE]\n .call(plugin, {\n request,\n response: responseToCache,\n event,\n });\n\n if (process.env.NODE_ENV !== 'production') {\n if (responseToCache) {\n assert.isInstance(responseToCache, Response, {\n moduleName: 'Plugin',\n funcName: pluginEvents.CACHE_WILL_UPDATE,\n isReturnValueProblem: true,\n });\n }\n }\n\n if (!responseToCache) {\n break;\n }\n }\n }\n\n if (!pluginsUsed) {\n if (process.env.NODE_ENV !== 'production') {\n if (!responseToCache.status === 200) {\n if (responseToCache.status === 0) {\n logger.warn(`The response for '${request.url}' is an opaque ` +\n `response. The caching strategy that you're using will not ` +\n `cache opaque responses by default.`);\n } else {\n logger.debug(`The response for '${request.url}' returned ` +\n `a status code of '${response.status}' and won't be cached as a ` +\n `result.`);\n }\n }\n }\n responseToCache = responseToCache.status === 200 ? responseToCache : null;\n }\n\n return responseToCache ? responseToCache : null;\n};\n\n/**\n * Checks the list of plugins for the cacheKeyWillBeUsed callback, and\n * executes any of those callbacks found in sequence. The final `Request` object\n * returned by the last plugin is treated as the cache key for cache reads\n * and/or writes.\n *\n * @param {Object} options\n * @param {Request} options.request\n * @param {string} options.mode\n * @param {Array} [options.plugins=[]]\n * @return {Promise}\n *\n * @private\n * @memberof module:workbox-core\n */\nconst _getEffectiveRequest = async ({request, mode, plugins}) => {\n const cacheKeyWillBeUsedPlugins = pluginUtils.filter(\n plugins, pluginEvents.CACHE_KEY_WILL_BE_USED);\n\n let effectiveRequest = request;\n for (const plugin of cacheKeyWillBeUsedPlugins) {\n effectiveRequest = await plugin[pluginEvents.CACHE_KEY_WILL_BE_USED].call(\n plugin, {mode, request: effectiveRequest});\n\n if (typeof effectiveRequest === 'string') {\n effectiveRequest = new Request(effectiveRequest);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(effectiveRequest, Request, {\n moduleName: 'Plugin',\n funcName: pluginEvents.CACHE_KEY_WILL_BE_USED,\n isReturnValueProblem: true,\n });\n }\n }\n\n return effectiveRequest;\n};\n\nexport const cacheWrapper = {\n put: putWrapper,\n match: matchWrapper,\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n\n/**\n * A class that wraps common IndexedDB functionality in a promise-based API.\n * It exposes all the underlying power and functionality of IndexedDB, but\n * wraps the most commonly used features in a way that's much simpler to use.\n *\n * @private\n */\nexport class DBWrapper {\n /**\n * @param {string} name\n * @param {number} version\n * @param {Object=} [callback]\n * @param {!Function} [callbacks.onupgradeneeded]\n * @param {!Function} [callbacks.onversionchange] Defaults to\n * DBWrapper.prototype._onversionchange when not specified.\n * @private\n */\n constructor(name, version, {\n onupgradeneeded,\n onversionchange = this._onversionchange,\n } = {}) {\n this._name = name;\n this._version = version;\n this._onupgradeneeded = onupgradeneeded;\n this._onversionchange = onversionchange;\n\n // If this is null, it means the database isn't open.\n this._db = null;\n }\n\n /**\n * Returns the IDBDatabase instance (not normally needed).\n *\n * @private\n */\n get db() {\n return this._db;\n }\n\n /**\n * Opens a connected to an IDBDatabase, invokes any onupgradedneeded\n * callback, and added an onversionchange callback to the database.\n *\n * @return {IDBDatabase}\n * @private\n */\n async open() {\n if (this._db) return;\n\n this._db = await new Promise((resolve, reject) => {\n // This flag is flipped to true if the timeout callback runs prior\n // to the request failing or succeeding. Note: we use a timeout instead\n // of an onblocked handler since there are cases where onblocked will\n // never never run. A timeout better handles all possible scenarios:\n // https://github.com/w3c/IndexedDB/issues/223\n let openRequestTimedOut = false;\n setTimeout(() => {\n openRequestTimedOut = true;\n reject(new Error('The open request was blocked and timed out'));\n }, this.OPEN_TIMEOUT);\n\n const openRequest = indexedDB.open(this._name, this._version);\n openRequest.onerror = () => reject(openRequest.error);\n openRequest.onupgradeneeded = (evt) => {\n if (openRequestTimedOut) {\n openRequest.transaction.abort();\n evt.target.result.close();\n } else if (this._onupgradeneeded) {\n this._onupgradeneeded(evt);\n }\n };\n openRequest.onsuccess = ({target}) => {\n const db = target.result;\n if (openRequestTimedOut) {\n db.close();\n } else {\n db.onversionchange = this._onversionchange.bind(this);\n resolve(db);\n }\n };\n });\n\n return this;\n }\n\n /**\n * Polyfills the native `getKey()` method. Note, this is overridden at\n * runtime if the browser supports the native method.\n *\n * @param {string} storeName\n * @param {*} query\n * @return {Array}\n * @private\n */\n async getKey(storeName, query) {\n return (await this.getAllKeys(storeName, query, 1))[0];\n }\n\n /**\n * Polyfills the native `getAll()` method. Note, this is overridden at\n * runtime if the browser supports the native method.\n *\n * @param {string} storeName\n * @param {*} query\n * @param {number} count\n * @return {Array}\n * @private\n */\n async getAll(storeName, query, count) {\n return await this.getAllMatching(storeName, {query, count});\n }\n\n\n /**\n * Polyfills the native `getAllKeys()` method. Note, this is overridden at\n * runtime if the browser supports the native method.\n *\n * @param {string} storeName\n * @param {*} query\n * @param {number} count\n * @return {Array}\n * @private\n */\n async getAllKeys(storeName, query, count) {\n return (await this.getAllMatching(\n storeName, {query, count, includeKeys: true})).map(({key}) => key);\n }\n\n /**\n * Supports flexible lookup in an object store by specifying an index,\n * query, direction, and count. This method returns an array of objects\n * with the signature .\n *\n * @param {string} storeName\n * @param {Object} [opts]\n * @param {string} [opts.index] The index to use (if specified).\n * @param {*} [opts.query]\n * @param {IDBCursorDirection} [opts.direction]\n * @param {number} [opts.count] The max number of results to return.\n * @param {boolean} [opts.includeKeys] When true, the structure of the\n * returned objects is changed from an array of values to an array of\n * objects in the form {key, primaryKey, value}.\n * @return {Array}\n * @private\n */\n async getAllMatching(storeName, {\n index,\n query = null, // IE errors if query === `undefined`.\n direction = 'next',\n count,\n includeKeys,\n } = {}) {\n return await this.transaction([storeName], 'readonly', (txn, done) => {\n const store = txn.objectStore(storeName);\n const target = index ? store.index(index) : store;\n const results = [];\n\n target.openCursor(query, direction).onsuccess = ({target}) => {\n const cursor = target.result;\n if (cursor) {\n const {primaryKey, key, value} = cursor;\n results.push(includeKeys ? {primaryKey, key, value} : value);\n if (count && results.length >= count) {\n done(results);\n } else {\n cursor.continue();\n }\n } else {\n done(results);\n }\n };\n });\n }\n\n /**\n * Accepts a list of stores, a transaction type, and a callback and\n * performs a transaction. A promise is returned that resolves to whatever\n * value the callback chooses. The callback holds all the transaction logic\n * and is invoked with two arguments:\n * 1. The IDBTransaction object\n * 2. A `done` function, that's used to resolve the promise when\n * when the transaction is done, if passed a value, the promise is\n * resolved to that value.\n *\n * @param {Array} storeNames An array of object store names\n * involved in the transaction.\n * @param {string} type Can be `readonly` or `readwrite`.\n * @param {!Function} callback\n * @return {*} The result of the transaction ran by the callback.\n * @private\n */\n async transaction(storeNames, type, callback) {\n await this.open();\n return await new Promise((resolve, reject) => {\n const txn = this._db.transaction(storeNames, type);\n txn.onabort = ({target}) => reject(target.error);\n txn.oncomplete = () => resolve();\n\n callback(txn, (value) => resolve(value));\n });\n }\n\n /**\n * Delegates async to a native IDBObjectStore method.\n *\n * @param {string} method The method name.\n * @param {string} storeName The object store name.\n * @param {string} type Can be `readonly` or `readwrite`.\n * @param {...*} args The list of args to pass to the native method.\n * @return {*} The result of the transaction.\n * @private\n */\n async _call(method, storeName, type, ...args) {\n const callback = (txn, done) => {\n txn.objectStore(storeName)[method](...args).onsuccess = ({target}) => {\n done(target.result);\n };\n };\n\n return await this.transaction([storeName], type, callback);\n }\n\n /**\n * The default onversionchange handler, which closes the database so other\n * connections can open without being blocked.\n *\n * @private\n */\n _onversionchange() {\n this.close();\n }\n\n /**\n * Closes the connection opened by `DBWrapper.open()`. Generally this method\n * doesn't need to be called since:\n * 1. It's usually better to keep a connection open since opening\n * a new connection is somewhat slow.\n * 2. Connections are automatically closed when the reference is\n * garbage collected.\n * The primary use case for needing to close a connection is when another\n * reference (typically in another tab) needs to upgrade it and would be\n * blocked by the current, open connection.\n *\n * @private\n */\n close() {\n if (this._db) {\n this._db.close();\n this._db = null;\n }\n }\n}\n\n// Exposed to let users modify the default timeout on a per-instance\n// or global basis.\nDBWrapper.prototype.OPEN_TIMEOUT = 2000;\n\n// Wrap native IDBObjectStore methods according to their mode.\nconst methodsToWrap = {\n 'readonly': ['get', 'count', 'getKey', 'getAll', 'getAllKeys'],\n 'readwrite': ['add', 'put', 'clear', 'delete'],\n};\nfor (const [mode, methods] of Object.entries(methodsToWrap)) {\n for (const method of methods) {\n if (method in IDBObjectStore.prototype) {\n // Don't use arrow functions here since we're outside of the class.\n DBWrapper.prototype[method] = async function(storeName, ...args) {\n return await this._call(method, storeName, mode, ...args);\n };\n }\n }\n}\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n\n/**\n * The Deferred class composes Promises in a way that allows for them to be\n * resolved or rejected from outside the constructor. In most cases promises\n * should be used directly, but Deferreds can be necessary when the logic to\n * resolve a promise must be separate.\n *\n * @private\n */\nexport class Deferred {\n /**\n * Creates a promise and exposes its resolve and reject functions as methods.\n */\n constructor() {\n this.promise = new Promise((resolve, reject) => {\n this.resolve = resolve;\n this.reject = reject;\n });\n }\n}\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n\n/**\n * Deletes the database.\n * Note: this is exported separately from the DBWrapper module because most\n * usages of IndexedDB in workbox dont need deleting, and this way it can be\n * reused in tests to delete databases without creating DBWrapper instances.\n *\n * @param {string} name The database name.\n * @private\n */\nexport const deleteDatabase = async (name) => {\n await new Promise((resolve, reject) => {\n const request = indexedDB.deleteDatabase(name);\n request.onerror = ({target}) => {\n reject(target.error);\n };\n request.onblocked = () => {\n reject(new Error('Delete blocked'));\n };\n request.onsuccess = () => {\n resolve();\n };\n });\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {WorkboxError} from './WorkboxError.mjs';\nimport {logger} from './logger.mjs';\nimport {assert} from './assert.mjs';\nimport {getFriendlyURL} from '../_private/getFriendlyURL.mjs';\nimport {pluginEvents} from '../models/pluginEvents.mjs';\nimport {pluginUtils} from '../utils/pluginUtils.mjs';\nimport '../_version.mjs';\n\n/**\n * Wrapper around the fetch API.\n *\n * Will call requestWillFetch on available plugins.\n *\n * @param {Object} options\n * @param {Request|string} options.request\n * @param {Object} [options.fetchOptions]\n * @param {Event} [options.event]\n * @param {Array} [options.plugins=[]]\n * @return {Promise}\n *\n * @private\n * @memberof module:workbox-core\n */\nconst wrappedFetch = async ({\n request,\n fetchOptions,\n event,\n plugins = []}) => {\n // We *should* be able to call `await event.preloadResponse` even if it's\n // undefined, but for some reason, doing so leads to errors in our Node unit\n // tests. To work around that, explicitly check preloadResponse's value first.\n if (event && event.preloadResponse) {\n const possiblePreloadResponse = await event.preloadResponse;\n if (possiblePreloadResponse) {\n if (process.env.NODE_ENV !== 'production') {\n logger.log(`Using a preloaded navigation response for ` +\n `'${getFriendlyURL(request.url)}'`);\n }\n return possiblePreloadResponse;\n }\n }\n\n if (typeof request === 'string') {\n request = new Request(request);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(request, Request, {\n paramName: request,\n expectedClass: 'Request',\n moduleName: 'workbox-core',\n className: 'fetchWrapper',\n funcName: 'wrappedFetch',\n });\n }\n\n const failedFetchPlugins = pluginUtils.filter(\n plugins, pluginEvents.FETCH_DID_FAIL);\n\n // If there is a fetchDidFail plugin, we need to save a clone of the\n // original request before it's either modified by a requestWillFetch\n // plugin or before the original request's body is consumed via fetch().\n const originalRequest = failedFetchPlugins.length > 0 ?\n request.clone() : null;\n\n try {\n for (let plugin of plugins) {\n if (pluginEvents.REQUEST_WILL_FETCH in plugin) {\n request = await plugin[pluginEvents.REQUEST_WILL_FETCH].call(plugin, {\n request: request.clone(),\n event,\n });\n\n if (process.env.NODE_ENV !== 'production') {\n if (request) {\n assert.isInstance(request, Request, {\n moduleName: 'Plugin',\n funcName: pluginEvents.CACHED_RESPONSE_WILL_BE_USED,\n isReturnValueProblem: true,\n });\n }\n }\n }\n }\n } catch (err) {\n throw new WorkboxError('plugin-error-request-will-fetch', {\n thrownError: err,\n });\n }\n\n // The request can be altered by plugins with `requestWillFetch` making\n // the original request (Most likely from a `fetch` event) to be different\n // to the Request we make. Pass both to `fetchDidFail` to aid debugging.\n let pluginFilteredRequest = request.clone();\n\n try {\n let fetchResponse;\n\n // See https://github.com/GoogleChrome/workbox/issues/1796\n if (request.mode === 'navigate') {\n fetchResponse = await fetch(request);\n } else {\n fetchResponse = await fetch(request, fetchOptions);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(`Network request for `+\n `'${getFriendlyURL(request.url)}' returned a response with ` +\n `status '${fetchResponse.status}'.`);\n }\n\n for (const plugin of plugins) {\n if (pluginEvents.FETCH_DID_SUCCEED in plugin) {\n fetchResponse = await plugin[pluginEvents.FETCH_DID_SUCCEED]\n .call(plugin, {\n event,\n request: pluginFilteredRequest,\n response: fetchResponse,\n });\n\n if (process.env.NODE_ENV !== 'production') {\n if (fetchResponse) {\n assert.isInstance(fetchResponse, Response, {\n moduleName: 'Plugin',\n funcName: pluginEvents.FETCH_DID_SUCCEED,\n isReturnValueProblem: true,\n });\n }\n }\n }\n }\n\n return fetchResponse;\n } catch (error) {\n if (process.env.NODE_ENV !== 'production') {\n logger.error(`Network request for `+\n `'${getFriendlyURL(request.url)}' threw an error.`, error);\n }\n\n for (const plugin of failedFetchPlugins) {\n await plugin[pluginEvents.FETCH_DID_FAIL].call(plugin, {\n error,\n event,\n originalRequest: originalRequest.clone(),\n request: pluginFilteredRequest.clone(),\n });\n }\n\n throw error;\n }\n};\n\nconst fetchWrapper = {\n fetch: wrappedFetch,\n};\n\nexport {fetchWrapper};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\n// We either expose defaults or we expose every named export.\nimport {assert} from './_private/assert.mjs';\nimport {cacheNames} from './_private/cacheNames.mjs';\nimport {cacheWrapper} from './_private/cacheWrapper.mjs';\nimport {DBWrapper} from './_private/DBWrapper.mjs';\nimport {Deferred} from './_private/Deferred.mjs';\nimport {deleteDatabase} from './_private/deleteDatabase.mjs';\nimport {executeQuotaErrorCallbacks} from './_private/executeQuotaErrorCallbacks.mjs';\nimport {fetchWrapper} from './_private/fetchWrapper.mjs';\nimport {getFriendlyURL} from './_private/getFriendlyURL.mjs';\nimport {logger} from './_private/logger.mjs';\nimport {WorkboxError} from './_private/WorkboxError.mjs';\n\nimport './_version.mjs';\n\nexport {\n assert,\n cacheNames,\n cacheWrapper,\n DBWrapper,\n Deferred,\n deleteDatabase,\n executeQuotaErrorCallbacks,\n fetchWrapper,\n getFriendlyURL,\n logger,\n WorkboxError,\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport './_version.mjs';\n\n\n/**\n * Claim any currently available clients once the service worker\n * becomes active. This is normally used in conjunction with `skipWaiting()`.\n *\n * @alias workbox.core.clientsClaim\n */\nexport const clientsClaim = () => {\n addEventListener('activate', () => clients.claim());\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {cacheNames as _cacheNames} from './_private/cacheNames.mjs';\nimport './_version.mjs';\n\n\n/**\n * Get the current cache names and prefix/suffix used by Workbox.\n *\n * `cacheNames.precache` is used for precached assets,\n * `cacheNames.googleAnalytics` is used by `workbox-google-analytics` to\n * store `analytics.js`, and `cacheNames.runtime` is used for everything else.\n *\n * `cacheNames.prefix` can be used to retrieve just the current prefix value.\n * `cacheNames.suffix` can be used to retrieve just the current suffix value.\n *\n * @return {Object} An object with `precache`, `runtime`, `prefix`, and\n * `googleAnalytics` properties.\n *\n * @alias workbox.core.cacheNames\n */\nexport const cacheNames = {\n get googleAnalytics() {\n return _cacheNames.getGoogleAnalyticsName();\n },\n get precache() {\n return _cacheNames.getPrecacheName();\n },\n get prefix() {\n return _cacheNames.getPrefix();\n },\n get runtime() {\n return _cacheNames.getRuntimeName();\n },\n get suffix() {\n return _cacheNames.getSuffix();\n },\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from './_private/assert.mjs';\nimport {cacheNames} from './_private/cacheNames.mjs';\nimport {WorkboxError} from './_private/WorkboxError.mjs';\nimport './_version.mjs';\n\n\n/**\n * Modifies the default cache names used by the Workbox packages.\n * Cache names are generated as `--`.\n *\n * @param {Object} details\n * @param {Object} [details.prefix] The string to add to the beginning of\n * the precache and runtime cache names.\n * @param {Object} [details.suffix] The string to add to the end of\n * the precache and runtime cache names.\n * @param {Object} [details.precache] The cache name to use for precache\n * caching.\n * @param {Object} [details.runtime] The cache name to use for runtime caching.\n * @param {Object} [details.googleAnalytics] The cache name to use for\n * `workbox-google-analytics` caching.\n *\n * @alias workbox.core.setCacheNameDetails\n */\nexport const setCacheNameDetails = (details) => {\n if (process.env.NODE_ENV !== 'production') {\n Object.keys(details).forEach((key) => {\n assert.isType(details[key], 'string', {\n moduleName: 'workbox-core',\n funcName: 'setCacheNameDetails',\n paramName: `details.${key}`,\n });\n });\n\n if ('precache' in details && details.precache.length === 0) {\n throw new WorkboxError('invalid-cache-name', {\n cacheNameId: 'precache',\n value: details.precache,\n });\n }\n\n if ('runtime' in details && details.runtime.length === 0) {\n throw new WorkboxError('invalid-cache-name', {\n cacheNameId: 'runtime',\n value: details.runtime,\n });\n }\n\n if ('googleAnalytics' in details && details.googleAnalytics.length === 0) {\n throw new WorkboxError('invalid-cache-name', {\n cacheNameId: 'googleAnalytics',\n value: details.googleAnalytics,\n });\n }\n }\n\n cacheNames.updateDetails(details);\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport './_version.mjs';\n\n\n/**\n * Force a service worker to become active, instead of waiting. This is\n * normally used in conjunction with `clientsClaim()`.\n *\n * @alias workbox.core.skipWaiting\n */\nexport const skipWaiting = () => {\n // We need to explicitly call `self.skipWaiting()` here because we're\n // shadowing `skipWaiting` with this local function.\n addEventListener('install', () => self.skipWaiting());\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {registerQuotaErrorCallback} from './registerQuotaErrorCallback.mjs';\nimport * as _private from './_private.mjs';\nimport {clientsClaim} from './clientsClaim.mjs';\nimport {cacheNames} from './cacheNames.mjs';\nimport {setCacheNameDetails} from './setCacheNameDetails.mjs';\nimport {skipWaiting} from './skipWaiting.mjs';\nimport './_version.mjs';\n\n\n// Give our version strings something to hang off of.\ntry {\n self.workbox.v = self.workbox.v || {};\n} catch (errer) {\n // NOOP\n}\n\n/**\n * All of the Workbox service worker libraries use workbox-core for shared\n * code as well as setting default values that need to be shared (like cache\n * names).\n *\n * @namespace workbox.core\n */\n\nexport {\n _private,\n clientsClaim,\n cacheNames,\n registerQuotaErrorCallback,\n setCacheNameDetails,\n skipWaiting,\n};\n"],"names":["self","_","e","logger","process","inGroup","methodToColorMap","debug","log","warn","error","groupCollapsed","groupEnd","print","method","args","test","navigator","userAgent","console","styles","logPrefix","join","api","Object","keys","messages","paramName","validValueDescription","value","Error","JSON","stringify","moduleName","className","funcName","expectedType","expectedClass","isReturnValueProblem","name","expectedMethod","entry","firstEntry","secondEntry","_entryId","thrownError","message","cacheNameId","methodName","normalizedRangeHeader","size","start","end","url","status","generatorFunction","code","messageGenerator","WorkboxError","constructor","errorCode","details","isSWEnv","isArray","Array","hasMethod","object","type","isType","isInstance","isOneOf","validValues","includes","isArrayOfClass","item","finalAssertExports","quotaErrorCallbacks","Set","registerQuotaErrorCallback","callback","assert","add","_cacheNameDetails","googleAnalytics","precache","prefix","runtime","suffix","registration","scope","_createCacheName","cacheName","filter","length","cacheNames","updateDetails","forEach","key","getGoogleAnalyticsName","userCacheName","getPrecacheName","getPrefix","getRuntimeName","getSuffix","getFriendlyURL","urlObj","URL","location","origin","pathname","href","executeQuotaErrorCallbacks","pluginEvents","CACHE_DID_UPDATE","CACHE_KEY_WILL_BE_USED","CACHE_WILL_UPDATE","CACHED_RESPONSE_WILL_BE_USED","FETCH_DID_FAIL","FETCH_DID_SUCCEED","REQUEST_WILL_FETCH","pluginUtils","plugins","callbackName","plugin","putWrapper","request","response","event","matchOptions","effectiveRequest","_getEffectiveRequest","mode","responseToCache","_isResponseSafeToCache","cache","caches","open","updatePlugins","oldResponse","matchWrapper","put","call","newResponse","cachedResponse","match","Response","pluginsUsed","cacheKeyWillBeUsedPlugins","Request","cacheWrapper","DBWrapper","version","onupgradeneeded","onversionchange","_onversionchange","_name","_version","_onupgradeneeded","_db","db","Promise","resolve","reject","openRequestTimedOut","setTimeout","OPEN_TIMEOUT","openRequest","indexedDB","onerror","evt","transaction","abort","target","result","close","onsuccess","bind","getKey","storeName","query","getAllKeys","getAll","count","getAllMatching","includeKeys","map","index","direction","txn","done","store","objectStore","results","openCursor","cursor","primaryKey","push","continue","storeNames","onabort","oncomplete","_call","prototype","methodsToWrap","methods","entries","IDBObjectStore","Deferred","promise","deleteDatabase","onblocked","wrappedFetch","fetchOptions","preloadResponse","possiblePreloadResponse","failedFetchPlugins","originalRequest","clone","err","pluginFilteredRequest","fetchResponse","fetch","fetchWrapper","clientsClaim","addEventListener","clients","claim","_cacheNames","setCacheNameDetails","skipWaiting","workbox","v","errer"],"mappings":"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"} \ No newline at end of file diff --git a/public/javascripts/workbox/workbox-core.prod.js b/public/javascripts/workbox/workbox-core.prod.js new file mode 100644 index 0000000000..526e32b1d4 --- /dev/null +++ b/public/javascripts/workbox/workbox-core.prod.js @@ -0,0 +1,2 @@ +this.workbox=this.workbox||{},this.workbox.core=function(e){"use strict";try{self["workbox:core:4.3.1"]&&_()}catch(e){}const t=(e,...t)=>{let n=e;return t.length>0&&(n+=` :: ${JSON.stringify(t)}`),n};class n extends Error{constructor(e,n){super(t(e,n)),this.name=e,this.details=n}}const s=new Set;const r={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:self.registration.scope},a=e=>[r.prefix,e,r.suffix].filter(e=>e.length>0).join("-"),i={updateDetails:e=>{Object.keys(r).forEach(t=>{void 0!==e[t]&&(r[t]=e[t])})},getGoogleAnalyticsName:e=>e||a(r.googleAnalytics),getPrecacheName:e=>e||a(r.precache),getPrefix:()=>r.prefix,getRuntimeName:e=>e||a(r.runtime),getSuffix:()=>r.suffix},c=e=>{const t=new URL(e,location);return t.origin===location.origin?t.pathname:t.href};async function o(){for(const e of s)await e()}const l="cacheDidUpdate",u="cacheKeyWillBeUsed",h="cacheWillUpdate",f="cachedResponseWillBeUsed",w="fetchDidFail",g="fetchDidSucceed",d="requestWillFetch",p=(e,t)=>e.filter(e=>t in e),y=async({cacheName:e,request:t,event:n,matchOptions:s,plugins:r=[]})=>{const a=await caches.open(e),i=await q({plugins:r,request:t,mode:"read"});let c=await a.match(i,s);for(const t of r)f in t&&(c=await t[f].call(t,{cacheName:e,event:n,matchOptions:s,cachedResponse:c,request:i}));return c},m=async({request:e,response:t,event:n,plugins:s})=>{let r=t,a=!1;for(let t of s)if(h in t&&(a=!0,!(r=await t[h].call(t,{request:e,response:r,event:n}))))break;return a||(r=200===r.status?r:null),r||null},q=async({request:e,mode:t,plugins:n})=>{const s=p(n,u);let r=e;for(const e of s)"string"==typeof(r=await e[u].call(e,{mode:t,request:r}))&&(r=new Request(r));return r},v={put:async({cacheName:e,request:t,response:s,event:r,plugins:a=[],matchOptions:i}={})=>{const u=await q({plugins:a,request:t,mode:"write"});if(!s)throw new n("cache-put-with-no-response",{url:c(u.url)});let h=await m({event:r,plugins:a,response:s,request:u});if(!h)return;const f=await caches.open(e),w=p(a,l);let g=w.length>0?await y({cacheName:e,matchOptions:i,request:u}):null;try{await f.put(u,h)}catch(e){throw"QuotaExceededError"===e.name&&await o(),e}for(let t of w)await t[l].call(t,{cacheName:e,event:r,oldResponse:g,newResponse:h,request:u})},match:y};class x{constructor(e,t,{onupgradeneeded:n,onversionchange:s=this.t}={}){this.s=e,this.i=t,this.o=n,this.t=s,this.l=null}get db(){return this.l}async open(){if(!this.l)return this.l=await new Promise((e,t)=>{let n=!1;setTimeout(()=>{n=!0,t(new Error("The open request was blocked and timed out"))},this.OPEN_TIMEOUT);const s=indexedDB.open(this.s,this.i);s.onerror=(()=>t(s.error)),s.onupgradeneeded=(e=>{n?(s.transaction.abort(),e.target.result.close()):this.o&&this.o(e)}),s.onsuccess=(({target:t})=>{const s=t.result;n?s.close():(s.onversionchange=this.t.bind(this),e(s))})}),this}async getKey(e,t){return(await this.getAllKeys(e,t,1))[0]}async getAll(e,t,n){return await this.getAllMatching(e,{query:t,count:n})}async getAllKeys(e,t,n){return(await this.getAllMatching(e,{query:t,count:n,includeKeys:!0})).map(({key:e})=>e)}async getAllMatching(e,{index:t,query:n=null,direction:s="next",count:r,includeKeys:a}={}){return await this.transaction([e],"readonly",(i,c)=>{const o=i.objectStore(e),l=t?o.index(t):o,u=[];l.openCursor(n,s).onsuccess=(({target:e})=>{const t=e.result;if(t){const{primaryKey:e,key:n,value:s}=t;u.push(a?{primaryKey:e,key:n,value:s}:s),r&&u.length>=r?c(u):t.continue()}else c(u)})})}async transaction(e,t,n){return await this.open(),await new Promise((s,r)=>{const a=this.l.transaction(e,t);a.onabort=(({target:e})=>r(e.error)),a.oncomplete=(()=>s()),n(a,e=>s(e))})}async u(e,t,n,...s){return await this.transaction([t],n,(n,r)=>{n.objectStore(t)[e](...s).onsuccess=(({target:e})=>{r(e.result)})})}t(){this.close()}close(){this.l&&(this.l.close(),this.l=null)}}x.prototype.OPEN_TIMEOUT=2e3;const b={readonly:["get","count","getKey","getAll","getAllKeys"],readwrite:["add","put","clear","delete"]};for(const[e,t]of Object.entries(b))for(const n of t)n in IDBObjectStore.prototype&&(x.prototype[n]=async function(t,...s){return await this.u(n,t,e,...s)});const D={fetch:async({request:e,fetchOptions:t,event:s,plugins:r=[]})=>{if(s&&s.preloadResponse){const e=await s.preloadResponse;if(e)return e}"string"==typeof e&&(e=new Request(e));const a=p(r,w),i=a.length>0?e.clone():null;try{for(let t of r)d in t&&(e=await t[d].call(t,{request:e.clone(),event:s}))}catch(e){throw new n("plugin-error-request-will-fetch",{thrownError:e})}let c=e.clone();try{let n;n="navigate"===e.mode?await fetch(e):await fetch(e,t);for(const e of r)g in e&&(n=await e[g].call(e,{event:s,request:c,response:n}));return n}catch(e){for(const t of a)await t[w].call(t,{error:e,event:s,originalRequest:i.clone(),request:c.clone()});throw e}}};var E=Object.freeze({assert:null,cacheNames:i,cacheWrapper:v,DBWrapper:x,Deferred:class{constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}},deleteDatabase:async e=>{await new Promise((t,n)=>{const s=indexedDB.deleteDatabase(e);s.onerror=(({target:e})=>{n(e.error)}),s.onblocked=(()=>{n(new Error("Delete blocked"))}),s.onsuccess=(()=>{t()})})},executeQuotaErrorCallbacks:o,fetchWrapper:D,getFriendlyURL:c,logger:null,WorkboxError:n});const N={get googleAnalytics(){return i.getGoogleAnalyticsName()},get precache(){return i.getPrecacheName()},get prefix(){return i.getPrefix()},get runtime(){return i.getRuntimeName()},get suffix(){return i.getSuffix()}};try{self.workbox.v=self.workbox.v||{}}catch(e){}return e._private=E,e.clientsClaim=(()=>{addEventListener("activate",()=>clients.claim())}),e.cacheNames=N,e.registerQuotaErrorCallback=function(e){s.add(e)},e.setCacheNameDetails=(e=>{i.updateDetails(e)}),e.skipWaiting=(()=>{addEventListener("install",()=>self.skipWaiting())}),e}({}); +//# sourceMappingURL=workbox-core.prod.js.map diff --git a/public/javascripts/workbox/workbox-core.prod.js.map b/public/javascripts/workbox/workbox-core.prod.js.map new file mode 100644 index 0000000000..71b89fb5a7 --- /dev/null +++ b/public/javascripts/workbox/workbox-core.prod.js.map @@ -0,0 +1 @@ +{"version":3,"file":"workbox-core.prod.js","sources":["../_version.mjs","../_private/logger.mjs","../models/messages/messageGenerator.mjs","../_private/WorkboxError.mjs","../_private/assert.mjs","../models/quotaErrorCallbacks.mjs","../_private/cacheNames.mjs","../_private/getFriendlyURL.mjs","../_private/executeQuotaErrorCallbacks.mjs","../models/pluginEvents.mjs","../utils/pluginUtils.mjs","../_private/cacheWrapper.mjs","../_private/DBWrapper.mjs","../_private/deleteDatabase.mjs","../_private/fetchWrapper.mjs","../_private/Deferred.mjs","../cacheNames.mjs","../index.mjs","../clientsClaim.mjs","../registerQuotaErrorCallback.mjs","../setCacheNameDetails.mjs","../skipWaiting.mjs"],"sourcesContent":["try{self['workbox:core:4.3.1']&&_()}catch(e){}// eslint-disable-line","/*\n Copyright 2019 Google LLC\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n\nconst logger = process.env.NODE_ENV === 'production' ? null : (() => {\n let inGroup = false;\n\n const methodToColorMap = {\n debug: `#7f8c8d`, // Gray\n log: `#2ecc71`, // Green\n warn: `#f39c12`, // Yellow\n error: `#c0392b`, // Red\n groupCollapsed: `#3498db`, // Blue\n groupEnd: null, // No colored prefix on groupEnd\n };\n\n const print = function(method, args) {\n if (method === 'groupCollapsed') {\n // Safari doesn't print all console.groupCollapsed() arguments:\n // https://bugs.webkit.org/show_bug.cgi?id=182754\n if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) {\n console[method](...args);\n return;\n }\n }\n\n const styles = [\n `background: ${methodToColorMap[method]}`,\n `border-radius: 0.5em`,\n `color: white`,\n `font-weight: bold`,\n `padding: 2px 0.5em`,\n ];\n\n // When in a group, the workbox prefix is not displayed.\n const logPrefix = inGroup ? [] : ['%cworkbox', styles.join(';')];\n\n console[method](...logPrefix, ...args);\n\n if (method === 'groupCollapsed') {\n inGroup = true;\n }\n if (method === 'groupEnd') {\n inGroup = false;\n }\n };\n\n const api = {};\n for (const method of Object.keys(methodToColorMap)) {\n api[method] = (...args) => {\n print(method, args);\n };\n }\n\n return api;\n})();\n\nexport {logger};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {messages} from './messages.mjs';\nimport '../../_version.mjs';\n\nconst fallback = (code, ...args) => {\n let msg = code;\n if (args.length > 0) {\n msg += ` :: ${JSON.stringify(args)}`;\n }\n return msg;\n};\n\nconst generatorFunction = (code, ...args) => {\n const message = messages[code];\n if (!message) {\n throw new Error(`Unable to find message for code '${code}'.`);\n }\n\n return message(...args);\n};\n\nexport const messageGenerator = (process.env.NODE_ENV === 'production') ?\n fallback : generatorFunction;\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {messageGenerator} from '../models/messages/messageGenerator.mjs';\nimport '../_version.mjs';\n\n/**\n * Workbox errors should be thrown with this class.\n * This allows use to ensure the type easily in tests,\n * helps developers identify errors from workbox\n * easily and allows use to optimise error\n * messages correctly.\n *\n * @private\n */\nclass WorkboxError extends Error {\n /**\n *\n * @param {string} errorCode The error code that\n * identifies this particular error.\n * @param {Object=} details Any relevant arguments\n * that will help developers identify issues should\n * be added as a key on the context object.\n */\n constructor(errorCode, details) {\n let message = messageGenerator(errorCode, details);\n\n super(message);\n\n this.name = errorCode;\n this.details = details;\n }\n}\n\nexport {WorkboxError};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {WorkboxError} from '../_private/WorkboxError.mjs';\nimport '../_version.mjs';\n\n/*\n * This method returns true if the current context is a service worker.\n */\nconst isSWEnv = (moduleName) => {\n if (!('ServiceWorkerGlobalScope' in self)) {\n throw new WorkboxError('not-in-sw', {moduleName});\n }\n};\n\n/*\n * This method throws if the supplied value is not an array.\n * The destructed values are required to produce a meaningful error for users.\n * The destructed and restructured object is so it's clear what is\n * needed.\n */\nconst isArray = (value, {moduleName, className, funcName, paramName}) => {\n if (!Array.isArray(value)) {\n throw new WorkboxError('not-an-array', {\n moduleName,\n className,\n funcName,\n paramName,\n });\n }\n};\n\nconst hasMethod = (object, expectedMethod,\n {moduleName, className, funcName, paramName}) => {\n const type = typeof object[expectedMethod];\n if (type !== 'function') {\n throw new WorkboxError('missing-a-method', {paramName, expectedMethod,\n moduleName, className, funcName});\n }\n};\n\nconst isType = (object, expectedType,\n {moduleName, className, funcName, paramName}) => {\n if (typeof object !== expectedType) {\n throw new WorkboxError('incorrect-type', {paramName, expectedType,\n moduleName, className, funcName});\n }\n};\n\nconst isInstance = (object, expectedClass,\n {moduleName, className, funcName,\n paramName, isReturnValueProblem}) => {\n if (!(object instanceof expectedClass)) {\n throw new WorkboxError('incorrect-class', {paramName, expectedClass,\n moduleName, className, funcName, isReturnValueProblem});\n }\n};\n\nconst isOneOf = (value, validValues, {paramName}) => {\n if (!validValues.includes(value)) {\n throw new WorkboxError('invalid-value', {\n paramName,\n value,\n validValueDescription: `Valid values are ${JSON.stringify(validValues)}.`,\n });\n }\n};\n\nconst isArrayOfClass = (value, expectedClass,\n {moduleName, className, funcName, paramName}) => {\n const error = new WorkboxError('not-array-of-class', {\n value, expectedClass,\n moduleName, className, funcName, paramName,\n });\n if (!Array.isArray(value)) {\n throw error;\n }\n\n for (let item of value) {\n if (!(item instanceof expectedClass)) {\n throw error;\n }\n }\n};\n\nconst finalAssertExports = process.env.NODE_ENV === 'production' ? null : {\n hasMethod,\n isArray,\n isInstance,\n isOneOf,\n isSWEnv,\n isType,\n isArrayOfClass,\n};\n\nexport {finalAssertExports as assert};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n\n// Callbacks to be executed whenever there's a quota error.\nconst quotaErrorCallbacks = new Set();\n\nexport {quotaErrorCallbacks};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n\nconst _cacheNameDetails = {\n googleAnalytics: 'googleAnalytics',\n precache: 'precache-v2',\n prefix: 'workbox',\n runtime: 'runtime',\n suffix: self.registration.scope,\n};\n\nconst _createCacheName = (cacheName) => {\n return [_cacheNameDetails.prefix, cacheName, _cacheNameDetails.suffix]\n .filter((value) => value.length > 0)\n .join('-');\n};\n\nexport const cacheNames = {\n updateDetails: (details) => {\n Object.keys(_cacheNameDetails).forEach((key) => {\n if (typeof details[key] !== 'undefined') {\n _cacheNameDetails[key] = details[key];\n }\n });\n },\n getGoogleAnalyticsName: (userCacheName) => {\n return userCacheName || _createCacheName(_cacheNameDetails.googleAnalytics);\n },\n getPrecacheName: (userCacheName) => {\n return userCacheName || _createCacheName(_cacheNameDetails.precache);\n },\n getPrefix: () => {\n return _cacheNameDetails.prefix;\n },\n getRuntimeName: (userCacheName) => {\n return userCacheName || _createCacheName(_cacheNameDetails.runtime);\n },\n getSuffix: () => {\n return _cacheNameDetails.suffix;\n },\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\nconst getFriendlyURL = (url) => {\n const urlObj = new URL(url, location);\n if (urlObj.origin === location.origin) {\n return urlObj.pathname;\n }\n return urlObj.href;\n};\n\nexport {getFriendlyURL};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {logger} from '../_private/logger.mjs';\nimport {quotaErrorCallbacks} from '../models/quotaErrorCallbacks.mjs';\nimport '../_version.mjs';\n\n\n/**\n * Runs all of the callback functions, one at a time sequentially, in the order\n * in which they were registered.\n *\n * @memberof workbox.core\n * @private\n */\nasync function executeQuotaErrorCallbacks() {\n if (process.env.NODE_ENV !== 'production') {\n logger.log(`About to run ${quotaErrorCallbacks.size} ` +\n `callbacks to clean up caches.`);\n }\n\n for (const callback of quotaErrorCallbacks) {\n await callback();\n if (process.env.NODE_ENV !== 'production') {\n logger.log(callback, 'is complete.');\n }\n }\n\n if (process.env.NODE_ENV !== 'production') {\n logger.log('Finished running callbacks.');\n }\n}\n\nexport {executeQuotaErrorCallbacks};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n\nexport const pluginEvents = {\n CACHE_DID_UPDATE: 'cacheDidUpdate',\n CACHE_KEY_WILL_BE_USED: 'cacheKeyWillBeUsed',\n CACHE_WILL_UPDATE: 'cacheWillUpdate',\n CACHED_RESPONSE_WILL_BE_USED: 'cachedResponseWillBeUsed',\n FETCH_DID_FAIL: 'fetchDidFail',\n FETCH_DID_SUCCEED: 'fetchDidSucceed',\n REQUEST_WILL_FETCH: 'requestWillFetch',\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\nexport const pluginUtils = {\n filter: (plugins, callbackName) => {\n return plugins.filter((plugin) => callbackName in plugin);\n },\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {WorkboxError} from './WorkboxError.mjs';\nimport {assert} from './assert.mjs';\nimport {getFriendlyURL} from './getFriendlyURL.mjs';\nimport {logger} from './logger.mjs';\nimport {executeQuotaErrorCallbacks} from './executeQuotaErrorCallbacks.mjs';\nimport {pluginEvents} from '../models/pluginEvents.mjs';\nimport {pluginUtils} from '../utils/pluginUtils.mjs';\nimport '../_version.mjs';\n\n\n/**\n * Wrapper around cache.put().\n *\n * Will call `cacheDidUpdate` on plugins if the cache was updated, using\n * `matchOptions` when determining what the old entry is.\n *\n * @param {Object} options\n * @param {string} options.cacheName\n * @param {Request} options.request\n * @param {Response} options.response\n * @param {Event} [options.event]\n * @param {Array} [options.plugins=[]]\n * @param {Object} [options.matchOptions]\n *\n * @private\n * @memberof module:workbox-core\n */\nconst putWrapper = async ({\n cacheName,\n request,\n response,\n event,\n plugins = [],\n matchOptions,\n} = {}) => {\n if (process.env.NODE_ENV !== 'production') {\n if (request.method && request.method !== 'GET') {\n throw new WorkboxError('attempt-to-cache-non-get-request', {\n url: getFriendlyURL(request.url),\n method: request.method,\n });\n }\n }\n\n const effectiveRequest = await _getEffectiveRequest({\n plugins, request, mode: 'write'});\n\n if (!response) {\n if (process.env.NODE_ENV !== 'production') {\n logger.error(`Cannot cache non-existent response for ` +\n `'${getFriendlyURL(effectiveRequest.url)}'.`);\n }\n\n throw new WorkboxError('cache-put-with-no-response', {\n url: getFriendlyURL(effectiveRequest.url),\n });\n }\n\n let responseToCache = await _isResponseSafeToCache({\n event,\n plugins,\n response,\n request: effectiveRequest,\n });\n\n if (!responseToCache) {\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(`Response '${getFriendlyURL(effectiveRequest.url)}' will ` +\n `not be cached.`, responseToCache);\n }\n return;\n }\n\n const cache = await caches.open(cacheName);\n\n const updatePlugins = pluginUtils.filter(\n plugins, pluginEvents.CACHE_DID_UPDATE);\n\n let oldResponse = updatePlugins.length > 0 ?\n await matchWrapper({cacheName, matchOptions, request: effectiveRequest}) :\n null;\n\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(`Updating the '${cacheName}' cache with a new Response for ` +\n `${getFriendlyURL(effectiveRequest.url)}.`);\n }\n\n try {\n await cache.put(effectiveRequest, responseToCache);\n } catch (error) {\n // See https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-QuotaExceededError\n if (error.name === 'QuotaExceededError') {\n await executeQuotaErrorCallbacks();\n }\n throw error;\n }\n\n for (let plugin of updatePlugins) {\n await plugin[pluginEvents.CACHE_DID_UPDATE].call(plugin, {\n cacheName,\n event,\n oldResponse,\n newResponse: responseToCache,\n request: effectiveRequest,\n });\n }\n};\n\n/**\n * This is a wrapper around cache.match().\n *\n * @param {Object} options\n * @param {string} options.cacheName Name of the cache to match against.\n * @param {Request} options.request The Request that will be used to look up\n * cache entries.\n * @param {Event} [options.event] The event that propted the action.\n * @param {Object} [options.matchOptions] Options passed to cache.match().\n * @param {Array} [options.plugins=[]] Array of plugins.\n * @return {Response} A cached response if available.\n *\n * @private\n * @memberof module:workbox-core\n */\nconst matchWrapper = async ({\n cacheName,\n request,\n event,\n matchOptions,\n plugins = [],\n}) => {\n const cache = await caches.open(cacheName);\n\n const effectiveRequest = await _getEffectiveRequest({\n plugins, request, mode: 'read'});\n\n let cachedResponse = await cache.match(effectiveRequest, matchOptions);\n if (process.env.NODE_ENV !== 'production') {\n if (cachedResponse) {\n logger.debug(`Found a cached response in '${cacheName}'.`);\n } else {\n logger.debug(`No cached response found in '${cacheName}'.`);\n }\n }\n\n for (const plugin of plugins) {\n if (pluginEvents.CACHED_RESPONSE_WILL_BE_USED in plugin) {\n cachedResponse = await plugin[pluginEvents.CACHED_RESPONSE_WILL_BE_USED]\n .call(plugin, {\n cacheName,\n event,\n matchOptions,\n cachedResponse,\n request: effectiveRequest,\n });\n if (process.env.NODE_ENV !== 'production') {\n if (cachedResponse) {\n assert.isInstance(cachedResponse, Response, {\n moduleName: 'Plugin',\n funcName: pluginEvents.CACHED_RESPONSE_WILL_BE_USED,\n isReturnValueProblem: true,\n });\n }\n }\n }\n }\n\n return cachedResponse;\n};\n\n/**\n * This method will call cacheWillUpdate on the available plugins (or use\n * status === 200) to determine if the Response is safe and valid to cache.\n *\n * @param {Object} options\n * @param {Request} options.request\n * @param {Response} options.response\n * @param {Event} [options.event]\n * @param {Array} [options.plugins=[]]\n * @return {Promise}\n *\n * @private\n * @memberof module:workbox-core\n */\nconst _isResponseSafeToCache = async ({request, response, event, plugins}) => {\n let responseToCache = response;\n let pluginsUsed = false;\n for (let plugin of plugins) {\n if (pluginEvents.CACHE_WILL_UPDATE in plugin) {\n pluginsUsed = true;\n responseToCache = await plugin[pluginEvents.CACHE_WILL_UPDATE]\n .call(plugin, {\n request,\n response: responseToCache,\n event,\n });\n\n if (process.env.NODE_ENV !== 'production') {\n if (responseToCache) {\n assert.isInstance(responseToCache, Response, {\n moduleName: 'Plugin',\n funcName: pluginEvents.CACHE_WILL_UPDATE,\n isReturnValueProblem: true,\n });\n }\n }\n\n if (!responseToCache) {\n break;\n }\n }\n }\n\n if (!pluginsUsed) {\n if (process.env.NODE_ENV !== 'production') {\n if (!responseToCache.status === 200) {\n if (responseToCache.status === 0) {\n logger.warn(`The response for '${request.url}' is an opaque ` +\n `response. The caching strategy that you're using will not ` +\n `cache opaque responses by default.`);\n } else {\n logger.debug(`The response for '${request.url}' returned ` +\n `a status code of '${response.status}' and won't be cached as a ` +\n `result.`);\n }\n }\n }\n responseToCache = responseToCache.status === 200 ? responseToCache : null;\n }\n\n return responseToCache ? responseToCache : null;\n};\n\n/**\n * Checks the list of plugins for the cacheKeyWillBeUsed callback, and\n * executes any of those callbacks found in sequence. The final `Request` object\n * returned by the last plugin is treated as the cache key for cache reads\n * and/or writes.\n *\n * @param {Object} options\n * @param {Request} options.request\n * @param {string} options.mode\n * @param {Array} [options.plugins=[]]\n * @return {Promise}\n *\n * @private\n * @memberof module:workbox-core\n */\nconst _getEffectiveRequest = async ({request, mode, plugins}) => {\n const cacheKeyWillBeUsedPlugins = pluginUtils.filter(\n plugins, pluginEvents.CACHE_KEY_WILL_BE_USED);\n\n let effectiveRequest = request;\n for (const plugin of cacheKeyWillBeUsedPlugins) {\n effectiveRequest = await plugin[pluginEvents.CACHE_KEY_WILL_BE_USED].call(\n plugin, {mode, request: effectiveRequest});\n\n if (typeof effectiveRequest === 'string') {\n effectiveRequest = new Request(effectiveRequest);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(effectiveRequest, Request, {\n moduleName: 'Plugin',\n funcName: pluginEvents.CACHE_KEY_WILL_BE_USED,\n isReturnValueProblem: true,\n });\n }\n }\n\n return effectiveRequest;\n};\n\nexport const cacheWrapper = {\n put: putWrapper,\n match: matchWrapper,\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n\n/**\n * A class that wraps common IndexedDB functionality in a promise-based API.\n * It exposes all the underlying power and functionality of IndexedDB, but\n * wraps the most commonly used features in a way that's much simpler to use.\n *\n * @private\n */\nexport class DBWrapper {\n /**\n * @param {string} name\n * @param {number} version\n * @param {Object=} [callback]\n * @param {!Function} [callbacks.onupgradeneeded]\n * @param {!Function} [callbacks.onversionchange] Defaults to\n * DBWrapper.prototype._onversionchange when not specified.\n * @private\n */\n constructor(name, version, {\n onupgradeneeded,\n onversionchange = this._onversionchange,\n } = {}) {\n this._name = name;\n this._version = version;\n this._onupgradeneeded = onupgradeneeded;\n this._onversionchange = onversionchange;\n\n // If this is null, it means the database isn't open.\n this._db = null;\n }\n\n /**\n * Returns the IDBDatabase instance (not normally needed).\n *\n * @private\n */\n get db() {\n return this._db;\n }\n\n /**\n * Opens a connected to an IDBDatabase, invokes any onupgradedneeded\n * callback, and added an onversionchange callback to the database.\n *\n * @return {IDBDatabase}\n * @private\n */\n async open() {\n if (this._db) return;\n\n this._db = await new Promise((resolve, reject) => {\n // This flag is flipped to true if the timeout callback runs prior\n // to the request failing or succeeding. Note: we use a timeout instead\n // of an onblocked handler since there are cases where onblocked will\n // never never run. A timeout better handles all possible scenarios:\n // https://github.com/w3c/IndexedDB/issues/223\n let openRequestTimedOut = false;\n setTimeout(() => {\n openRequestTimedOut = true;\n reject(new Error('The open request was blocked and timed out'));\n }, this.OPEN_TIMEOUT);\n\n const openRequest = indexedDB.open(this._name, this._version);\n openRequest.onerror = () => reject(openRequest.error);\n openRequest.onupgradeneeded = (evt) => {\n if (openRequestTimedOut) {\n openRequest.transaction.abort();\n evt.target.result.close();\n } else if (this._onupgradeneeded) {\n this._onupgradeneeded(evt);\n }\n };\n openRequest.onsuccess = ({target}) => {\n const db = target.result;\n if (openRequestTimedOut) {\n db.close();\n } else {\n db.onversionchange = this._onversionchange.bind(this);\n resolve(db);\n }\n };\n });\n\n return this;\n }\n\n /**\n * Polyfills the native `getKey()` method. Note, this is overridden at\n * runtime if the browser supports the native method.\n *\n * @param {string} storeName\n * @param {*} query\n * @return {Array}\n * @private\n */\n async getKey(storeName, query) {\n return (await this.getAllKeys(storeName, query, 1))[0];\n }\n\n /**\n * Polyfills the native `getAll()` method. Note, this is overridden at\n * runtime if the browser supports the native method.\n *\n * @param {string} storeName\n * @param {*} query\n * @param {number} count\n * @return {Array}\n * @private\n */\n async getAll(storeName, query, count) {\n return await this.getAllMatching(storeName, {query, count});\n }\n\n\n /**\n * Polyfills the native `getAllKeys()` method. Note, this is overridden at\n * runtime if the browser supports the native method.\n *\n * @param {string} storeName\n * @param {*} query\n * @param {number} count\n * @return {Array}\n * @private\n */\n async getAllKeys(storeName, query, count) {\n return (await this.getAllMatching(\n storeName, {query, count, includeKeys: true})).map(({key}) => key);\n }\n\n /**\n * Supports flexible lookup in an object store by specifying an index,\n * query, direction, and count. This method returns an array of objects\n * with the signature .\n *\n * @param {string} storeName\n * @param {Object} [opts]\n * @param {string} [opts.index] The index to use (if specified).\n * @param {*} [opts.query]\n * @param {IDBCursorDirection} [opts.direction]\n * @param {number} [opts.count] The max number of results to return.\n * @param {boolean} [opts.includeKeys] When true, the structure of the\n * returned objects is changed from an array of values to an array of\n * objects in the form {key, primaryKey, value}.\n * @return {Array}\n * @private\n */\n async getAllMatching(storeName, {\n index,\n query = null, // IE errors if query === `undefined`.\n direction = 'next',\n count,\n includeKeys,\n } = {}) {\n return await this.transaction([storeName], 'readonly', (txn, done) => {\n const store = txn.objectStore(storeName);\n const target = index ? store.index(index) : store;\n const results = [];\n\n target.openCursor(query, direction).onsuccess = ({target}) => {\n const cursor = target.result;\n if (cursor) {\n const {primaryKey, key, value} = cursor;\n results.push(includeKeys ? {primaryKey, key, value} : value);\n if (count && results.length >= count) {\n done(results);\n } else {\n cursor.continue();\n }\n } else {\n done(results);\n }\n };\n });\n }\n\n /**\n * Accepts a list of stores, a transaction type, and a callback and\n * performs a transaction. A promise is returned that resolves to whatever\n * value the callback chooses. The callback holds all the transaction logic\n * and is invoked with two arguments:\n * 1. The IDBTransaction object\n * 2. A `done` function, that's used to resolve the promise when\n * when the transaction is done, if passed a value, the promise is\n * resolved to that value.\n *\n * @param {Array} storeNames An array of object store names\n * involved in the transaction.\n * @param {string} type Can be `readonly` or `readwrite`.\n * @param {!Function} callback\n * @return {*} The result of the transaction ran by the callback.\n * @private\n */\n async transaction(storeNames, type, callback) {\n await this.open();\n return await new Promise((resolve, reject) => {\n const txn = this._db.transaction(storeNames, type);\n txn.onabort = ({target}) => reject(target.error);\n txn.oncomplete = () => resolve();\n\n callback(txn, (value) => resolve(value));\n });\n }\n\n /**\n * Delegates async to a native IDBObjectStore method.\n *\n * @param {string} method The method name.\n * @param {string} storeName The object store name.\n * @param {string} type Can be `readonly` or `readwrite`.\n * @param {...*} args The list of args to pass to the native method.\n * @return {*} The result of the transaction.\n * @private\n */\n async _call(method, storeName, type, ...args) {\n const callback = (txn, done) => {\n txn.objectStore(storeName)[method](...args).onsuccess = ({target}) => {\n done(target.result);\n };\n };\n\n return await this.transaction([storeName], type, callback);\n }\n\n /**\n * The default onversionchange handler, which closes the database so other\n * connections can open without being blocked.\n *\n * @private\n */\n _onversionchange() {\n this.close();\n }\n\n /**\n * Closes the connection opened by `DBWrapper.open()`. Generally this method\n * doesn't need to be called since:\n * 1. It's usually better to keep a connection open since opening\n * a new connection is somewhat slow.\n * 2. Connections are automatically closed when the reference is\n * garbage collected.\n * The primary use case for needing to close a connection is when another\n * reference (typically in another tab) needs to upgrade it and would be\n * blocked by the current, open connection.\n *\n * @private\n */\n close() {\n if (this._db) {\n this._db.close();\n this._db = null;\n }\n }\n}\n\n// Exposed to let users modify the default timeout on a per-instance\n// or global basis.\nDBWrapper.prototype.OPEN_TIMEOUT = 2000;\n\n// Wrap native IDBObjectStore methods according to their mode.\nconst methodsToWrap = {\n 'readonly': ['get', 'count', 'getKey', 'getAll', 'getAllKeys'],\n 'readwrite': ['add', 'put', 'clear', 'delete'],\n};\nfor (const [mode, methods] of Object.entries(methodsToWrap)) {\n for (const method of methods) {\n if (method in IDBObjectStore.prototype) {\n // Don't use arrow functions here since we're outside of the class.\n DBWrapper.prototype[method] = async function(storeName, ...args) {\n return await this._call(method, storeName, mode, ...args);\n };\n }\n }\n}\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n\n/**\n * Deletes the database.\n * Note: this is exported separately from the DBWrapper module because most\n * usages of IndexedDB in workbox dont need deleting, and this way it can be\n * reused in tests to delete databases without creating DBWrapper instances.\n *\n * @param {string} name The database name.\n * @private\n */\nexport const deleteDatabase = async (name) => {\n await new Promise((resolve, reject) => {\n const request = indexedDB.deleteDatabase(name);\n request.onerror = ({target}) => {\n reject(target.error);\n };\n request.onblocked = () => {\n reject(new Error('Delete blocked'));\n };\n request.onsuccess = () => {\n resolve();\n };\n });\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {WorkboxError} from './WorkboxError.mjs';\nimport {logger} from './logger.mjs';\nimport {assert} from './assert.mjs';\nimport {getFriendlyURL} from '../_private/getFriendlyURL.mjs';\nimport {pluginEvents} from '../models/pluginEvents.mjs';\nimport {pluginUtils} from '../utils/pluginUtils.mjs';\nimport '../_version.mjs';\n\n/**\n * Wrapper around the fetch API.\n *\n * Will call requestWillFetch on available plugins.\n *\n * @param {Object} options\n * @param {Request|string} options.request\n * @param {Object} [options.fetchOptions]\n * @param {Event} [options.event]\n * @param {Array} [options.plugins=[]]\n * @return {Promise}\n *\n * @private\n * @memberof module:workbox-core\n */\nconst wrappedFetch = async ({\n request,\n fetchOptions,\n event,\n plugins = []}) => {\n // We *should* be able to call `await event.preloadResponse` even if it's\n // undefined, but for some reason, doing so leads to errors in our Node unit\n // tests. To work around that, explicitly check preloadResponse's value first.\n if (event && event.preloadResponse) {\n const possiblePreloadResponse = await event.preloadResponse;\n if (possiblePreloadResponse) {\n if (process.env.NODE_ENV !== 'production') {\n logger.log(`Using a preloaded navigation response for ` +\n `'${getFriendlyURL(request.url)}'`);\n }\n return possiblePreloadResponse;\n }\n }\n\n if (typeof request === 'string') {\n request = new Request(request);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(request, Request, {\n paramName: request,\n expectedClass: 'Request',\n moduleName: 'workbox-core',\n className: 'fetchWrapper',\n funcName: 'wrappedFetch',\n });\n }\n\n const failedFetchPlugins = pluginUtils.filter(\n plugins, pluginEvents.FETCH_DID_FAIL);\n\n // If there is a fetchDidFail plugin, we need to save a clone of the\n // original request before it's either modified by a requestWillFetch\n // plugin or before the original request's body is consumed via fetch().\n const originalRequest = failedFetchPlugins.length > 0 ?\n request.clone() : null;\n\n try {\n for (let plugin of plugins) {\n if (pluginEvents.REQUEST_WILL_FETCH in plugin) {\n request = await plugin[pluginEvents.REQUEST_WILL_FETCH].call(plugin, {\n request: request.clone(),\n event,\n });\n\n if (process.env.NODE_ENV !== 'production') {\n if (request) {\n assert.isInstance(request, Request, {\n moduleName: 'Plugin',\n funcName: pluginEvents.CACHED_RESPONSE_WILL_BE_USED,\n isReturnValueProblem: true,\n });\n }\n }\n }\n }\n } catch (err) {\n throw new WorkboxError('plugin-error-request-will-fetch', {\n thrownError: err,\n });\n }\n\n // The request can be altered by plugins with `requestWillFetch` making\n // the original request (Most likely from a `fetch` event) to be different\n // to the Request we make. Pass both to `fetchDidFail` to aid debugging.\n let pluginFilteredRequest = request.clone();\n\n try {\n let fetchResponse;\n\n // See https://github.com/GoogleChrome/workbox/issues/1796\n if (request.mode === 'navigate') {\n fetchResponse = await fetch(request);\n } else {\n fetchResponse = await fetch(request, fetchOptions);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(`Network request for `+\n `'${getFriendlyURL(request.url)}' returned a response with ` +\n `status '${fetchResponse.status}'.`);\n }\n\n for (const plugin of plugins) {\n if (pluginEvents.FETCH_DID_SUCCEED in plugin) {\n fetchResponse = await plugin[pluginEvents.FETCH_DID_SUCCEED]\n .call(plugin, {\n event,\n request: pluginFilteredRequest,\n response: fetchResponse,\n });\n\n if (process.env.NODE_ENV !== 'production') {\n if (fetchResponse) {\n assert.isInstance(fetchResponse, Response, {\n moduleName: 'Plugin',\n funcName: pluginEvents.FETCH_DID_SUCCEED,\n isReturnValueProblem: true,\n });\n }\n }\n }\n }\n\n return fetchResponse;\n } catch (error) {\n if (process.env.NODE_ENV !== 'production') {\n logger.error(`Network request for `+\n `'${getFriendlyURL(request.url)}' threw an error.`, error);\n }\n\n for (const plugin of failedFetchPlugins) {\n await plugin[pluginEvents.FETCH_DID_FAIL].call(plugin, {\n error,\n event,\n originalRequest: originalRequest.clone(),\n request: pluginFilteredRequest.clone(),\n });\n }\n\n throw error;\n }\n};\n\nconst fetchWrapper = {\n fetch: wrappedFetch,\n};\n\nexport {fetchWrapper};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n\n/**\n * The Deferred class composes Promises in a way that allows for them to be\n * resolved or rejected from outside the constructor. In most cases promises\n * should be used directly, but Deferreds can be necessary when the logic to\n * resolve a promise must be separate.\n *\n * @private\n */\nexport class Deferred {\n /**\n * Creates a promise and exposes its resolve and reject functions as methods.\n */\n constructor() {\n this.promise = new Promise((resolve, reject) => {\n this.resolve = resolve;\n this.reject = reject;\n });\n }\n}\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {cacheNames as _cacheNames} from './_private/cacheNames.mjs';\nimport './_version.mjs';\n\n\n/**\n * Get the current cache names and prefix/suffix used by Workbox.\n *\n * `cacheNames.precache` is used for precached assets,\n * `cacheNames.googleAnalytics` is used by `workbox-google-analytics` to\n * store `analytics.js`, and `cacheNames.runtime` is used for everything else.\n *\n * `cacheNames.prefix` can be used to retrieve just the current prefix value.\n * `cacheNames.suffix` can be used to retrieve just the current suffix value.\n *\n * @return {Object} An object with `precache`, `runtime`, `prefix`, and\n * `googleAnalytics` properties.\n *\n * @alias workbox.core.cacheNames\n */\nexport const cacheNames = {\n get googleAnalytics() {\n return _cacheNames.getGoogleAnalyticsName();\n },\n get precache() {\n return _cacheNames.getPrecacheName();\n },\n get prefix() {\n return _cacheNames.getPrefix();\n },\n get runtime() {\n return _cacheNames.getRuntimeName();\n },\n get suffix() {\n return _cacheNames.getSuffix();\n },\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {registerQuotaErrorCallback} from './registerQuotaErrorCallback.mjs';\nimport * as _private from './_private.mjs';\nimport {clientsClaim} from './clientsClaim.mjs';\nimport {cacheNames} from './cacheNames.mjs';\nimport {setCacheNameDetails} from './setCacheNameDetails.mjs';\nimport {skipWaiting} from './skipWaiting.mjs';\nimport './_version.mjs';\n\n\n// Give our version strings something to hang off of.\ntry {\n self.workbox.v = self.workbox.v || {};\n} catch (errer) {\n // NOOP\n}\n\n/**\n * All of the Workbox service worker libraries use workbox-core for shared\n * code as well as setting default values that need to be shared (like cache\n * names).\n *\n * @namespace workbox.core\n */\n\nexport {\n _private,\n clientsClaim,\n cacheNames,\n registerQuotaErrorCallback,\n setCacheNameDetails,\n skipWaiting,\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport './_version.mjs';\n\n\n/**\n * Claim any currently available clients once the service worker\n * becomes active. This is normally used in conjunction with `skipWaiting()`.\n *\n * @alias workbox.core.clientsClaim\n */\nexport const clientsClaim = () => {\n addEventListener('activate', () => clients.claim());\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {logger} from './_private/logger.mjs';\nimport {assert} from './_private/assert.mjs';\nimport {quotaErrorCallbacks} from './models/quotaErrorCallbacks.mjs';\nimport './_version.mjs';\n\n\n/**\n * Adds a function to the set of quotaErrorCallbacks that will be executed if\n * there's a quota error.\n *\n * @param {Function} callback\n * @memberof workbox.core\n */\nfunction registerQuotaErrorCallback(callback) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(callback, 'function', {\n moduleName: 'workbox-core',\n funcName: 'register',\n paramName: 'callback',\n });\n }\n\n quotaErrorCallbacks.add(callback);\n\n if (process.env.NODE_ENV !== 'production') {\n logger.log('Registered a callback to respond to quota errors.', callback);\n }\n}\n\nexport {registerQuotaErrorCallback};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from './_private/assert.mjs';\nimport {cacheNames} from './_private/cacheNames.mjs';\nimport {WorkboxError} from './_private/WorkboxError.mjs';\nimport './_version.mjs';\n\n\n/**\n * Modifies the default cache names used by the Workbox packages.\n * Cache names are generated as `--`.\n *\n * @param {Object} details\n * @param {Object} [details.prefix] The string to add to the beginning of\n * the precache and runtime cache names.\n * @param {Object} [details.suffix] The string to add to the end of\n * the precache and runtime cache names.\n * @param {Object} [details.precache] The cache name to use for precache\n * caching.\n * @param {Object} [details.runtime] The cache name to use for runtime caching.\n * @param {Object} [details.googleAnalytics] The cache name to use for\n * `workbox-google-analytics` caching.\n *\n * @alias workbox.core.setCacheNameDetails\n */\nexport const setCacheNameDetails = (details) => {\n if (process.env.NODE_ENV !== 'production') {\n Object.keys(details).forEach((key) => {\n assert.isType(details[key], 'string', {\n moduleName: 'workbox-core',\n funcName: 'setCacheNameDetails',\n paramName: `details.${key}`,\n });\n });\n\n if ('precache' in details && details.precache.length === 0) {\n throw new WorkboxError('invalid-cache-name', {\n cacheNameId: 'precache',\n value: details.precache,\n });\n }\n\n if ('runtime' in details && details.runtime.length === 0) {\n throw new WorkboxError('invalid-cache-name', {\n cacheNameId: 'runtime',\n value: details.runtime,\n });\n }\n\n if ('googleAnalytics' in details && details.googleAnalytics.length === 0) {\n throw new WorkboxError('invalid-cache-name', {\n cacheNameId: 'googleAnalytics',\n value: details.googleAnalytics,\n });\n }\n }\n\n cacheNames.updateDetails(details);\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport './_version.mjs';\n\n\n/**\n * Force a service worker to become active, instead of waiting. This is\n * normally used in conjunction with `clientsClaim()`.\n *\n * @alias workbox.core.skipWaiting\n */\nexport const skipWaiting = () => {\n // We need to explicitly call `self.skipWaiting()` here because we're\n // shadowing `skipWaiting` with this local function.\n addEventListener('install', () => self.skipWaiting());\n};\n"],"names":["self","_","e","messageGenerator","code","args","msg","length","JSON","stringify","WorkboxError","Error","constructor","errorCode","details","name","quotaErrorCallbacks","Set","_cacheNameDetails","googleAnalytics","precache","prefix","runtime","suffix","registration","scope","_createCacheName","cacheName","filter","value","join","cacheNames","updateDetails","Object","keys","forEach","key","getGoogleAnalyticsName","userCacheName","getPrecacheName","getPrefix","getRuntimeName","getSuffix","getFriendlyURL","url","urlObj","URL","location","origin","pathname","href","async","executeQuotaErrorCallbacks","callback","pluginEvents","pluginUtils","plugins","callbackName","plugin","matchWrapper","request","event","matchOptions","cache","caches","open","effectiveRequest","_getEffectiveRequest","mode","cachedResponse","match","call","_isResponseSafeToCache","response","responseToCache","pluginsUsed","status","cacheKeyWillBeUsedPlugins","Request","cacheWrapper","put","updatePlugins","oldResponse","error","newResponse","DBWrapper","version","onupgradeneeded","onversionchange","this","_onversionchange","_name","_version","_onupgradeneeded","_db","Promise","resolve","reject","openRequestTimedOut","setTimeout","OPEN_TIMEOUT","openRequest","indexedDB","onerror","evt","transaction","abort","target","result","close","onsuccess","db","bind","storeName","query","getAllKeys","count","getAllMatching","includeKeys","map","index","direction","txn","done","store","objectStore","results","openCursor","cursor","primaryKey","push","continue","storeNames","type","onabort","oncomplete","method","prototype","methodsToWrap","methods","entries","IDBObjectStore","_call","fetchWrapper","fetch","fetchOptions","preloadResponse","possiblePreloadResponse","failedFetchPlugins","originalRequest","clone","err","thrownError","pluginFilteredRequest","fetchResponse","process","promise","deleteDatabase","onblocked","_cacheNames","workbox","v","errer","addEventListener","clients","claim","add","skipWaiting"],"mappings":"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"} \ No newline at end of file diff --git a/public/javascripts/workbox/workbox-expiration.dev.js b/public/javascripts/workbox/workbox-expiration.dev.js new file mode 100644 index 0000000000..cbd068b4f0 --- /dev/null +++ b/public/javascripts/workbox/workbox-expiration.dev.js @@ -0,0 +1,652 @@ +this.workbox = this.workbox || {}; +this.workbox.expiration = (function (exports, DBWrapper_mjs, deleteDatabase_mjs, WorkboxError_mjs, assert_mjs, logger_mjs, cacheNames_mjs, getFriendlyURL_mjs, registerQuotaErrorCallback_mjs) { + 'use strict'; + + try { + self['workbox:expiration:4.3.1'] && _(); + } catch (e) {} // eslint-disable-line + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const DB_NAME = 'workbox-expiration'; + const OBJECT_STORE_NAME = 'cache-entries'; + + const normalizeURL = unNormalizedUrl => { + const url = new URL(unNormalizedUrl, location); + url.hash = ''; + return url.href; + }; + /** + * Returns the timestamp model. + * + * @private + */ + + + class CacheTimestampsModel { + /** + * + * @param {string} cacheName + * + * @private + */ + constructor(cacheName) { + this._cacheName = cacheName; + this._db = new DBWrapper_mjs.DBWrapper(DB_NAME, 1, { + onupgradeneeded: event => this._handleUpgrade(event) + }); + } + /** + * Should perform an upgrade of indexedDB. + * + * @param {Event} event + * + * @private + */ + + + _handleUpgrade(event) { + const db = event.target.result; // TODO(philipwalton): EdgeHTML doesn't support arrays as a keyPath, so we + // have to use the `id` keyPath here and create our own values (a + // concatenation of `url + cacheName`) instead of simply using + // `keyPath: ['url', 'cacheName']`, which is supported in other browsers. + + const objStore = db.createObjectStore(OBJECT_STORE_NAME, { + keyPath: 'id' + }); // TODO(philipwalton): once we don't have to support EdgeHTML, we can + // create a single index with the keyPath `['cacheName', 'timestamp']` + // instead of doing both these indexes. + + objStore.createIndex('cacheName', 'cacheName', { + unique: false + }); + objStore.createIndex('timestamp', 'timestamp', { + unique: false + }); // Previous versions of `workbox-expiration` used `this._cacheName` + // as the IDBDatabase name. + + deleteDatabase_mjs.deleteDatabase(this._cacheName); + } + /** + * @param {string} url + * @param {number} timestamp + * + * @private + */ + + + async setTimestamp(url, timestamp) { + url = normalizeURL(url); + await this._db.put(OBJECT_STORE_NAME, { + url, + timestamp, + cacheName: this._cacheName, + // Creating an ID from the URL and cache name won't be necessary once + // Edge switches to Chromium and all browsers we support work with + // array keyPaths. + id: this._getId(url) + }); + } + /** + * Returns the timestamp stored for a given URL. + * + * @param {string} url + * @return {number} + * + * @private + */ + + + async getTimestamp(url) { + const entry = await this._db.get(OBJECT_STORE_NAME, this._getId(url)); + return entry.timestamp; + } + /** + * Iterates through all the entries in the object store (from newest to + * oldest) and removes entries once either `maxCount` is reached or the + * entry's timestamp is less than `minTimestamp`. + * + * @param {number} minTimestamp + * @param {number} maxCount + * + * @private + */ + + + async expireEntries(minTimestamp, maxCount) { + const entriesToDelete = await this._db.transaction(OBJECT_STORE_NAME, 'readwrite', (txn, done) => { + const store = txn.objectStore(OBJECT_STORE_NAME); + const entriesToDelete = []; + let entriesNotDeletedCount = 0; + + store.index('timestamp').openCursor(null, 'prev').onsuccess = ({ + target + }) => { + const cursor = target.result; + + if (cursor) { + const result = cursor.value; // TODO(philipwalton): once we can use a multi-key index, we + // won't have to check `cacheName` here. + + if (result.cacheName === this._cacheName) { + // Delete an entry if it's older than the max age or + // if we already have the max number allowed. + if (minTimestamp && result.timestamp < minTimestamp || maxCount && entriesNotDeletedCount >= maxCount) { + // TODO(philipwalton): we should be able to delete the + // entry right here, but doing so causes an iteration + // bug in Safari stable (fixed in TP). Instead we can + // store the keys of the entries to delete, and then + // delete the separate transactions. + // https://github.com/GoogleChrome/workbox/issues/1978 + // cursor.delete(); + // We only need to return the URL, not the whole entry. + entriesToDelete.push(cursor.value); + } else { + entriesNotDeletedCount++; + } + } + + cursor.continue(); + } else { + done(entriesToDelete); + } + }; + }); // TODO(philipwalton): once the Safari bug in the following issue is fixed, + // we should be able to remove this loop and do the entry deletion in the + // cursor loop above: + // https://github.com/GoogleChrome/workbox/issues/1978 + + const urlsDeleted = []; + + for (const entry of entriesToDelete) { + await this._db.delete(OBJECT_STORE_NAME, entry.id); + urlsDeleted.push(entry.url); + } + + return urlsDeleted; + } + /** + * Takes a URL and returns an ID that will be unique in the object store. + * + * @param {string} url + * @return {string} + * + * @private + */ + + + _getId(url) { + // Creating an ID from the URL and cache name won't be necessary once + // Edge switches to Chromium and all browsers we support work with + // array keyPaths. + return this._cacheName + '|' + normalizeURL(url); + } + + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The `CacheExpiration` class allows you define an expiration and / or + * limit on the number of responses stored in a + * [`Cache`](https://developer.mozilla.org/en-US/docs/Web/API/Cache). + * + * @memberof workbox.expiration + */ + + class CacheExpiration { + /** + * To construct a new CacheExpiration instance you must provide at least + * one of the `config` properties. + * + * @param {string} cacheName Name of the cache to apply restrictions to. + * @param {Object} config + * @param {number} [config.maxEntries] The maximum number of entries to cache. + * Entries used the least will be removed as the maximum is reached. + * @param {number} [config.maxAgeSeconds] The maximum age of an entry before + * it's treated as stale and removed. + */ + constructor(cacheName, config = {}) { + { + assert_mjs.assert.isType(cacheName, 'string', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'constructor', + paramName: 'cacheName' + }); + + if (!(config.maxEntries || config.maxAgeSeconds)) { + throw new WorkboxError_mjs.WorkboxError('max-entries-or-age-required', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'constructor' + }); + } + + if (config.maxEntries) { + assert_mjs.assert.isType(config.maxEntries, 'number', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'constructor', + paramName: 'config.maxEntries' + }); // TODO: Assert is positive + } + + if (config.maxAgeSeconds) { + assert_mjs.assert.isType(config.maxAgeSeconds, 'number', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'constructor', + paramName: 'config.maxAgeSeconds' + }); // TODO: Assert is positive + } + } + + this._isRunning = false; + this._rerunRequested = false; + this._maxEntries = config.maxEntries; + this._maxAgeSeconds = config.maxAgeSeconds; + this._cacheName = cacheName; + this._timestampModel = new CacheTimestampsModel(cacheName); + } + /** + * Expires entries for the given cache and given criteria. + */ + + + async expireEntries() { + if (this._isRunning) { + this._rerunRequested = true; + return; + } + + this._isRunning = true; + const minTimestamp = this._maxAgeSeconds ? Date.now() - this._maxAgeSeconds * 1000 : undefined; + const urlsExpired = await this._timestampModel.expireEntries(minTimestamp, this._maxEntries); // Delete URLs from the cache + + const cache = await caches.open(this._cacheName); + + for (const url of urlsExpired) { + await cache.delete(url); + } + + { + if (urlsExpired.length > 0) { + logger_mjs.logger.groupCollapsed(`Expired ${urlsExpired.length} ` + `${urlsExpired.length === 1 ? 'entry' : 'entries'} and removed ` + `${urlsExpired.length === 1 ? 'it' : 'them'} from the ` + `'${this._cacheName}' cache.`); + logger_mjs.logger.log(`Expired the following ${urlsExpired.length === 1 ? 'URL' : 'URLs'}:`); + urlsExpired.forEach(url => logger_mjs.logger.log(` ${url}`)); + logger_mjs.logger.groupEnd(); + } else { + logger_mjs.logger.debug(`Cache expiration ran and found no entries to remove.`); + } + } + + this._isRunning = false; + + if (this._rerunRequested) { + this._rerunRequested = false; + this.expireEntries(); + } + } + /** + * Update the timestamp for the given URL. This ensures the when + * removing entries based on maximum entries, most recently used + * is accurate or when expiring, the timestamp is up-to-date. + * + * @param {string} url + */ + + + async updateTimestamp(url) { + { + assert_mjs.assert.isType(url, 'string', { + moduleName: 'workbox-expiration', + className: 'CacheExpiration', + funcName: 'updateTimestamp', + paramName: 'url' + }); + } + + await this._timestampModel.setTimestamp(url, Date.now()); + } + /** + * Can be used to check if a URL has expired or not before it's used. + * + * This requires a look up from IndexedDB, so can be slow. + * + * Note: This method will not remove the cached entry, call + * `expireEntries()` to remove indexedDB and Cache entries. + * + * @param {string} url + * @return {boolean} + */ + + + async isURLExpired(url) { + { + if (!this._maxAgeSeconds) { + throw new WorkboxError_mjs.WorkboxError(`expired-test-without-max-age`, { + methodName: 'isURLExpired', + paramName: 'maxAgeSeconds' + }); + } + } + + const timestamp = await this._timestampModel.getTimestamp(url); + const expireOlderThan = Date.now() - this._maxAgeSeconds * 1000; + return timestamp < expireOlderThan; + } + /** + * Removes the IndexedDB object store used to keep track of cache expiration + * metadata. + */ + + + async delete() { + // Make sure we don't attempt another rerun if we're called in the middle of + // a cache expiration. + this._rerunRequested = false; + await this._timestampModel.expireEntries(Infinity); // Expires all. + } + + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * This plugin can be used in the Workbox APIs to regularly enforce a + * limit on the age and / or the number of cached requests. + * + * Whenever a cached request is used or updated, this plugin will look + * at the used Cache and remove any old or extra requests. + * + * When using `maxAgeSeconds`, requests may be used *once* after expiring + * because the expiration clean up will not have occurred until *after* the + * cached request has been used. If the request has a "Date" header, then + * a light weight expiration check is performed and the request will not be + * used immediately. + * + * When using `maxEntries`, the entry least-recently requested will be removed from the cache first. + * + * @memberof workbox.expiration + */ + + class Plugin { + /** + * @param {Object} config + * @param {number} [config.maxEntries] The maximum number of entries to cache. + * Entries used the least will be removed as the maximum is reached. + * @param {number} [config.maxAgeSeconds] The maximum age of an entry before + * it's treated as stale and removed. + * @param {boolean} [config.purgeOnQuotaError] Whether to opt this cache in to + * automatic deletion if the available storage quota has been exceeded. + */ + constructor(config = {}) { + { + if (!(config.maxEntries || config.maxAgeSeconds)) { + throw new WorkboxError_mjs.WorkboxError('max-entries-or-age-required', { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'constructor' + }); + } + + if (config.maxEntries) { + assert_mjs.assert.isType(config.maxEntries, 'number', { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'constructor', + paramName: 'config.maxEntries' + }); + } + + if (config.maxAgeSeconds) { + assert_mjs.assert.isType(config.maxAgeSeconds, 'number', { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'constructor', + paramName: 'config.maxAgeSeconds' + }); + } + } + + this._config = config; + this._maxAgeSeconds = config.maxAgeSeconds; + this._cacheExpirations = new Map(); + + if (config.purgeOnQuotaError) { + registerQuotaErrorCallback_mjs.registerQuotaErrorCallback(() => this.deleteCacheAndMetadata()); + } + } + /** + * A simple helper method to return a CacheExpiration instance for a given + * cache name. + * + * @param {string} cacheName + * @return {CacheExpiration} + * + * @private + */ + + + _getCacheExpiration(cacheName) { + if (cacheName === cacheNames_mjs.cacheNames.getRuntimeName()) { + throw new WorkboxError_mjs.WorkboxError('expire-custom-caches-only'); + } + + let cacheExpiration = this._cacheExpirations.get(cacheName); + + if (!cacheExpiration) { + cacheExpiration = new CacheExpiration(cacheName, this._config); + + this._cacheExpirations.set(cacheName, cacheExpiration); + } + + return cacheExpiration; + } + /** + * A "lifecycle" callback that will be triggered automatically by the + * `workbox.strategies` handlers when a `Response` is about to be returned + * from a [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache) to + * the handler. It allows the `Response` to be inspected for freshness and + * prevents it from being used if the `Response`'s `Date` header value is + * older than the configured `maxAgeSeconds`. + * + * @param {Object} options + * @param {string} options.cacheName Name of the cache the response is in. + * @param {Response} options.cachedResponse The `Response` object that's been + * read from a cache and whose freshness should be checked. + * @return {Response} Either the `cachedResponse`, if it's + * fresh, or `null` if the `Response` is older than `maxAgeSeconds`. + * + * @private + */ + + + cachedResponseWillBeUsed({ + event, + request, + cacheName, + cachedResponse + }) { + if (!cachedResponse) { + return null; + } + + let isFresh = this._isResponseDateFresh(cachedResponse); // Expire entries to ensure that even if the expiration date has + // expired, it'll only be used once. + + + const cacheExpiration = this._getCacheExpiration(cacheName); + + cacheExpiration.expireEntries(); // Update the metadata for the request URL to the current timestamp, + // but don't `await` it as we don't want to block the response. + + const updateTimestampDone = cacheExpiration.updateTimestamp(request.url); + + if (event) { + try { + event.waitUntil(updateTimestampDone); + } catch (error) { + { + logger_mjs.logger.warn(`Unable to ensure service worker stays alive when ` + `updating cache entry for '${getFriendlyURL_mjs.getFriendlyURL(event.request.url)}'.`); + } + } + } + + return isFresh ? cachedResponse : null; + } + /** + * @param {Response} cachedResponse + * @return {boolean} + * + * @private + */ + + + _isResponseDateFresh(cachedResponse) { + if (!this._maxAgeSeconds) { + // We aren't expiring by age, so return true, it's fresh + return true; + } // Check if the 'date' header will suffice a quick expiration check. + // See https://github.com/GoogleChromeLabs/sw-toolbox/issues/164 for + // discussion. + + + const dateHeaderTimestamp = this._getDateHeaderTimestamp(cachedResponse); + + if (dateHeaderTimestamp === null) { + // Unable to parse date, so assume it's fresh. + return true; + } // If we have a valid headerTime, then our response is fresh iff the + // headerTime plus maxAgeSeconds is greater than the current time. + + + const now = Date.now(); + return dateHeaderTimestamp >= now - this._maxAgeSeconds * 1000; + } + /** + * This method will extract the data header and parse it into a useful + * value. + * + * @param {Response} cachedResponse + * @return {number} + * + * @private + */ + + + _getDateHeaderTimestamp(cachedResponse) { + if (!cachedResponse.headers.has('date')) { + return null; + } + + const dateHeader = cachedResponse.headers.get('date'); + const parsedDate = new Date(dateHeader); + const headerTime = parsedDate.getTime(); // If the Date header was invalid for some reason, parsedDate.getTime() + // will return NaN. + + if (isNaN(headerTime)) { + return null; + } + + return headerTime; + } + /** + * A "lifecycle" callback that will be triggered automatically by the + * `workbox.strategies` handlers when an entry is added to a cache. + * + * @param {Object} options + * @param {string} options.cacheName Name of the cache that was updated. + * @param {string} options.request The Request for the cached entry. + * + * @private + */ + + + async cacheDidUpdate({ + cacheName, + request + }) { + { + assert_mjs.assert.isType(cacheName, 'string', { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'cacheDidUpdate', + paramName: 'cacheName' + }); + assert_mjs.assert.isInstance(request, Request, { + moduleName: 'workbox-expiration', + className: 'Plugin', + funcName: 'cacheDidUpdate', + paramName: 'request' + }); + } + + const cacheExpiration = this._getCacheExpiration(cacheName); + + await cacheExpiration.updateTimestamp(request.url); + await cacheExpiration.expireEntries(); + } + /** + * This is a helper method that performs two operations: + * + * - Deletes *all* the underlying Cache instances associated with this plugin + * instance, by calling caches.delete() on your behalf. + * - Deletes the metadata from IndexedDB used to keep track of expiration + * details for each Cache instance. + * + * When using cache expiration, calling this method is preferable to calling + * `caches.delete()` directly, since this will ensure that the IndexedDB + * metadata is also cleanly removed and open IndexedDB instances are deleted. + * + * Note that if you're *not* using cache expiration for a given cache, calling + * `caches.delete()` and passing in the cache's name should be sufficient. + * There is no Workbox-specific method needed for cleanup in that case. + */ + + + async deleteCacheAndMetadata() { + // Do this one at a time instead of all at once via `Promise.all()` to + // reduce the chance of inconsistency if a promise rejects. + for (const [cacheName, cacheExpiration] of this._cacheExpirations) { + await caches.delete(cacheName); + await cacheExpiration.delete(); + } // Reset this._cacheExpirations to its initial state. + + + this._cacheExpirations = new Map(); + } + + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + + exports.CacheExpiration = CacheExpiration; + exports.Plugin = Plugin; + + return exports; + +}({}, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core)); +//# sourceMappingURL=workbox-expiration.dev.js.map diff --git a/public/javascripts/workbox/workbox-expiration.dev.js.map b/public/javascripts/workbox/workbox-expiration.dev.js.map new file mode 100644 index 0000000000..d0a03072a0 --- /dev/null +++ b/public/javascripts/workbox/workbox-expiration.dev.js.map @@ -0,0 +1 @@ +{"version":3,"file":"workbox-expiration.dev.js","sources":["../_version.mjs","../models/CacheTimestampsModel.mjs","../CacheExpiration.mjs","../Plugin.mjs","../index.mjs"],"sourcesContent":["try{self['workbox:expiration:4.3.1']&&_()}catch(e){}// eslint-disable-line","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {DBWrapper} from 'workbox-core/_private/DBWrapper.mjs';\nimport {deleteDatabase} from 'workbox-core/_private/deleteDatabase.mjs';\nimport '../_version.mjs';\n\n\nconst DB_NAME = 'workbox-expiration';\nconst OBJECT_STORE_NAME = 'cache-entries';\n\nconst normalizeURL = (unNormalizedUrl) => {\n const url = new URL(unNormalizedUrl, location);\n url.hash = '';\n\n return url.href;\n};\n\n\n/**\n * Returns the timestamp model.\n *\n * @private\n */\nclass CacheTimestampsModel {\n /**\n *\n * @param {string} cacheName\n *\n * @private\n */\n constructor(cacheName) {\n this._cacheName = cacheName;\n\n this._db = new DBWrapper(DB_NAME, 1, {\n onupgradeneeded: (event) => this._handleUpgrade(event),\n });\n }\n\n /**\n * Should perform an upgrade of indexedDB.\n *\n * @param {Event} event\n *\n * @private\n */\n _handleUpgrade(event) {\n const db = event.target.result;\n\n // TODO(philipwalton): EdgeHTML doesn't support arrays as a keyPath, so we\n // have to use the `id` keyPath here and create our own values (a\n // concatenation of `url + cacheName`) instead of simply using\n // `keyPath: ['url', 'cacheName']`, which is supported in other browsers.\n const objStore = db.createObjectStore(OBJECT_STORE_NAME, {keyPath: 'id'});\n\n // TODO(philipwalton): once we don't have to support EdgeHTML, we can\n // create a single index with the keyPath `['cacheName', 'timestamp']`\n // instead of doing both these indexes.\n objStore.createIndex('cacheName', 'cacheName', {unique: false});\n objStore.createIndex('timestamp', 'timestamp', {unique: false});\n\n // Previous versions of `workbox-expiration` used `this._cacheName`\n // as the IDBDatabase name.\n deleteDatabase(this._cacheName);\n }\n\n /**\n * @param {string} url\n * @param {number} timestamp\n *\n * @private\n */\n async setTimestamp(url, timestamp) {\n url = normalizeURL(url);\n\n await this._db.put(OBJECT_STORE_NAME, {\n url,\n timestamp,\n cacheName: this._cacheName,\n // Creating an ID from the URL and cache name won't be necessary once\n // Edge switches to Chromium and all browsers we support work with\n // array keyPaths.\n id: this._getId(url),\n });\n }\n\n /**\n * Returns the timestamp stored for a given URL.\n *\n * @param {string} url\n * @return {number}\n *\n * @private\n */\n async getTimestamp(url) {\n const entry = await this._db.get(OBJECT_STORE_NAME, this._getId(url));\n return entry.timestamp;\n }\n\n /**\n * Iterates through all the entries in the object store (from newest to\n * oldest) and removes entries once either `maxCount` is reached or the\n * entry's timestamp is less than `minTimestamp`.\n *\n * @param {number} minTimestamp\n * @param {number} maxCount\n *\n * @private\n */\n async expireEntries(minTimestamp, maxCount) {\n const entriesToDelete = await this._db.transaction(\n OBJECT_STORE_NAME, 'readwrite', (txn, done) => {\n const store = txn.objectStore(OBJECT_STORE_NAME);\n const entriesToDelete = [];\n let entriesNotDeletedCount = 0;\n\n store.index('timestamp')\n .openCursor(null, 'prev')\n .onsuccess = ({target}) => {\n const cursor = target.result;\n if (cursor) {\n const result = cursor.value;\n // TODO(philipwalton): once we can use a multi-key index, we\n // won't have to check `cacheName` here.\n if (result.cacheName === this._cacheName) {\n // Delete an entry if it's older than the max age or\n // if we already have the max number allowed.\n if ((minTimestamp && result.timestamp < minTimestamp) ||\n (maxCount && entriesNotDeletedCount >= maxCount)) {\n // TODO(philipwalton): we should be able to delete the\n // entry right here, but doing so causes an iteration\n // bug in Safari stable (fixed in TP). Instead we can\n // store the keys of the entries to delete, and then\n // delete the separate transactions.\n // https://github.com/GoogleChrome/workbox/issues/1978\n // cursor.delete();\n\n // We only need to return the URL, not the whole entry.\n entriesToDelete.push(cursor.value);\n } else {\n entriesNotDeletedCount++;\n }\n }\n cursor.continue();\n } else {\n done(entriesToDelete);\n }\n };\n });\n\n // TODO(philipwalton): once the Safari bug in the following issue is fixed,\n // we should be able to remove this loop and do the entry deletion in the\n // cursor loop above:\n // https://github.com/GoogleChrome/workbox/issues/1978\n const urlsDeleted = [];\n for (const entry of entriesToDelete) {\n await this._db.delete(OBJECT_STORE_NAME, entry.id);\n urlsDeleted.push(entry.url);\n }\n\n return urlsDeleted;\n }\n\n /**\n * Takes a URL and returns an ID that will be unique in the object store.\n *\n * @param {string} url\n * @return {string}\n *\n * @private\n */\n _getId(url) {\n // Creating an ID from the URL and cache name won't be necessary once\n // Edge switches to Chromium and all browsers we support work with\n // array keyPaths.\n return this._cacheName + '|' + normalizeURL(url);\n }\n}\n\nexport {CacheTimestampsModel};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {CacheTimestampsModel} from './models/CacheTimestampsModel.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\n\nimport './_version.mjs';\n\n/**\n * The `CacheExpiration` class allows you define an expiration and / or\n * limit on the number of responses stored in a\n * [`Cache`](https://developer.mozilla.org/en-US/docs/Web/API/Cache).\n *\n * @memberof workbox.expiration\n */\nclass CacheExpiration {\n /**\n * To construct a new CacheExpiration instance you must provide at least\n * one of the `config` properties.\n *\n * @param {string} cacheName Name of the cache to apply restrictions to.\n * @param {Object} config\n * @param {number} [config.maxEntries] The maximum number of entries to cache.\n * Entries used the least will be removed as the maximum is reached.\n * @param {number} [config.maxAgeSeconds] The maximum age of an entry before\n * it's treated as stale and removed.\n */\n constructor(cacheName, config = {}) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(cacheName, 'string', {\n moduleName: 'workbox-expiration',\n className: 'CacheExpiration',\n funcName: 'constructor',\n paramName: 'cacheName',\n });\n\n if (!(config.maxEntries || config.maxAgeSeconds)) {\n throw new WorkboxError('max-entries-or-age-required', {\n moduleName: 'workbox-expiration',\n className: 'CacheExpiration',\n funcName: 'constructor',\n });\n }\n\n if (config.maxEntries) {\n assert.isType(config.maxEntries, 'number', {\n moduleName: 'workbox-expiration',\n className: 'CacheExpiration',\n funcName: 'constructor',\n paramName: 'config.maxEntries',\n });\n\n // TODO: Assert is positive\n }\n\n if (config.maxAgeSeconds) {\n assert.isType(config.maxAgeSeconds, 'number', {\n moduleName: 'workbox-expiration',\n className: 'CacheExpiration',\n funcName: 'constructor',\n paramName: 'config.maxAgeSeconds',\n });\n\n // TODO: Assert is positive\n }\n }\n\n this._isRunning = false;\n this._rerunRequested = false;\n this._maxEntries = config.maxEntries;\n this._maxAgeSeconds = config.maxAgeSeconds;\n this._cacheName = cacheName;\n this._timestampModel = new CacheTimestampsModel(cacheName);\n }\n\n /**\n * Expires entries for the given cache and given criteria.\n */\n async expireEntries() {\n if (this._isRunning) {\n this._rerunRequested = true;\n return;\n }\n this._isRunning = true;\n\n const minTimestamp = this._maxAgeSeconds ?\n Date.now() - (this._maxAgeSeconds * 1000) : undefined;\n\n const urlsExpired = await this._timestampModel.expireEntries(\n minTimestamp, this._maxEntries);\n\n // Delete URLs from the cache\n const cache = await caches.open(this._cacheName);\n for (const url of urlsExpired) {\n await cache.delete(url);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n if (urlsExpired.length > 0) {\n logger.groupCollapsed(\n `Expired ${urlsExpired.length} ` +\n `${urlsExpired.length === 1 ? 'entry' : 'entries'} and removed ` +\n `${urlsExpired.length === 1 ? 'it' : 'them'} from the ` +\n `'${this._cacheName}' cache.`);\n logger.log(`Expired the following ${urlsExpired.length === 1 ?\n 'URL' : 'URLs'}:`);\n urlsExpired.forEach((url) => logger.log(` ${url}`));\n logger.groupEnd();\n } else {\n logger.debug(`Cache expiration ran and found no entries to remove.`);\n }\n }\n\n this._isRunning = false;\n if (this._rerunRequested) {\n this._rerunRequested = false;\n this.expireEntries();\n }\n }\n\n /**\n * Update the timestamp for the given URL. This ensures the when\n * removing entries based on maximum entries, most recently used\n * is accurate or when expiring, the timestamp is up-to-date.\n *\n * @param {string} url\n */\n async updateTimestamp(url) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(url, 'string', {\n moduleName: 'workbox-expiration',\n className: 'CacheExpiration',\n funcName: 'updateTimestamp',\n paramName: 'url',\n });\n }\n\n await this._timestampModel.setTimestamp(url, Date.now());\n }\n\n /**\n * Can be used to check if a URL has expired or not before it's used.\n *\n * This requires a look up from IndexedDB, so can be slow.\n *\n * Note: This method will not remove the cached entry, call\n * `expireEntries()` to remove indexedDB and Cache entries.\n *\n * @param {string} url\n * @return {boolean}\n */\n async isURLExpired(url) {\n if (process.env.NODE_ENV !== 'production') {\n if (!this._maxAgeSeconds) {\n throw new WorkboxError(`expired-test-without-max-age`, {\n methodName: 'isURLExpired',\n paramName: 'maxAgeSeconds',\n });\n }\n }\n\n const timestamp = await this._timestampModel.getTimestamp(url);\n const expireOlderThan = Date.now() - (this._maxAgeSeconds * 1000);\n return (timestamp < expireOlderThan);\n }\n\n /**\n * Removes the IndexedDB object store used to keep track of cache expiration\n * metadata.\n */\n async delete() {\n // Make sure we don't attempt another rerun if we're called in the middle of\n // a cache expiration.\n this._rerunRequested = false;\n await this._timestampModel.expireEntries(Infinity); // Expires all.\n }\n}\n\nexport {CacheExpiration};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {cacheNames} from 'workbox-core/_private/cacheNames.mjs';\nimport {getFriendlyURL} from 'workbox-core/_private/getFriendlyURL.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\nimport {registerQuotaErrorCallback}\n from 'workbox-core/registerQuotaErrorCallback.mjs';\n\nimport {CacheExpiration} from './CacheExpiration.mjs';\nimport './_version.mjs';\n\n/**\n * This plugin can be used in the Workbox APIs to regularly enforce a\n * limit on the age and / or the number of cached requests.\n *\n * Whenever a cached request is used or updated, this plugin will look\n * at the used Cache and remove any old or extra requests.\n *\n * When using `maxAgeSeconds`, requests may be used *once* after expiring\n * because the expiration clean up will not have occurred until *after* the\n * cached request has been used. If the request has a \"Date\" header, then\n * a light weight expiration check is performed and the request will not be\n * used immediately.\n *\n * When using `maxEntries`, the entry least-recently requested will be removed from the cache first.\n *\n * @memberof workbox.expiration\n */\nclass Plugin {\n /**\n * @param {Object} config\n * @param {number} [config.maxEntries] The maximum number of entries to cache.\n * Entries used the least will be removed as the maximum is reached.\n * @param {number} [config.maxAgeSeconds] The maximum age of an entry before\n * it's treated as stale and removed.\n * @param {boolean} [config.purgeOnQuotaError] Whether to opt this cache in to\n * automatic deletion if the available storage quota has been exceeded.\n */\n constructor(config = {}) {\n if (process.env.NODE_ENV !== 'production') {\n if (!(config.maxEntries || config.maxAgeSeconds)) {\n throw new WorkboxError('max-entries-or-age-required', {\n moduleName: 'workbox-expiration',\n className: 'Plugin',\n funcName: 'constructor',\n });\n }\n\n if (config.maxEntries) {\n assert.isType(config.maxEntries, 'number', {\n moduleName: 'workbox-expiration',\n className: 'Plugin',\n funcName: 'constructor',\n paramName: 'config.maxEntries',\n });\n }\n\n if (config.maxAgeSeconds) {\n assert.isType(config.maxAgeSeconds, 'number', {\n moduleName: 'workbox-expiration',\n className: 'Plugin',\n funcName: 'constructor',\n paramName: 'config.maxAgeSeconds',\n });\n }\n }\n\n this._config = config;\n this._maxAgeSeconds = config.maxAgeSeconds;\n this._cacheExpirations = new Map();\n\n if (config.purgeOnQuotaError) {\n registerQuotaErrorCallback(() => this.deleteCacheAndMetadata());\n }\n }\n\n /**\n * A simple helper method to return a CacheExpiration instance for a given\n * cache name.\n *\n * @param {string} cacheName\n * @return {CacheExpiration}\n *\n * @private\n */\n _getCacheExpiration(cacheName) {\n if (cacheName === cacheNames.getRuntimeName()) {\n throw new WorkboxError('expire-custom-caches-only');\n }\n\n let cacheExpiration = this._cacheExpirations.get(cacheName);\n if (!cacheExpiration) {\n cacheExpiration = new CacheExpiration(cacheName, this._config);\n this._cacheExpirations.set(cacheName, cacheExpiration);\n }\n return cacheExpiration;\n }\n\n /**\n * A \"lifecycle\" callback that will be triggered automatically by the\n * `workbox.strategies` handlers when a `Response` is about to be returned\n * from a [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache) to\n * the handler. It allows the `Response` to be inspected for freshness and\n * prevents it from being used if the `Response`'s `Date` header value is\n * older than the configured `maxAgeSeconds`.\n *\n * @param {Object} options\n * @param {string} options.cacheName Name of the cache the response is in.\n * @param {Response} options.cachedResponse The `Response` object that's been\n * read from a cache and whose freshness should be checked.\n * @return {Response} Either the `cachedResponse`, if it's\n * fresh, or `null` if the `Response` is older than `maxAgeSeconds`.\n *\n * @private\n */\n cachedResponseWillBeUsed({event, request, cacheName, cachedResponse}) {\n if (!cachedResponse) {\n return null;\n }\n\n let isFresh = this._isResponseDateFresh(cachedResponse);\n\n // Expire entries to ensure that even if the expiration date has\n // expired, it'll only be used once.\n const cacheExpiration = this._getCacheExpiration(cacheName);\n cacheExpiration.expireEntries();\n\n // Update the metadata for the request URL to the current timestamp,\n // but don't `await` it as we don't want to block the response.\n const updateTimestampDone = cacheExpiration.updateTimestamp(request.url);\n if (event) {\n try {\n event.waitUntil(updateTimestampDone);\n } catch (error) {\n if (process.env.NODE_ENV !== 'production') {\n logger.warn(`Unable to ensure service worker stays alive when ` +\n `updating cache entry for '${getFriendlyURL(event.request.url)}'.`);\n }\n }\n }\n\n return isFresh ? cachedResponse : null;\n }\n\n /**\n * @param {Response} cachedResponse\n * @return {boolean}\n *\n * @private\n */\n _isResponseDateFresh(cachedResponse) {\n if (!this._maxAgeSeconds) {\n // We aren't expiring by age, so return true, it's fresh\n return true;\n }\n\n // Check if the 'date' header will suffice a quick expiration check.\n // See https://github.com/GoogleChromeLabs/sw-toolbox/issues/164 for\n // discussion.\n const dateHeaderTimestamp = this._getDateHeaderTimestamp(cachedResponse);\n if (dateHeaderTimestamp === null) {\n // Unable to parse date, so assume it's fresh.\n return true;\n }\n\n // If we have a valid headerTime, then our response is fresh iff the\n // headerTime plus maxAgeSeconds is greater than the current time.\n const now = Date.now();\n return dateHeaderTimestamp >= now - (this._maxAgeSeconds * 1000);\n }\n\n /**\n * This method will extract the data header and parse it into a useful\n * value.\n *\n * @param {Response} cachedResponse\n * @return {number}\n *\n * @private\n */\n _getDateHeaderTimestamp(cachedResponse) {\n if (!cachedResponse.headers.has('date')) {\n return null;\n }\n\n const dateHeader = cachedResponse.headers.get('date');\n const parsedDate = new Date(dateHeader);\n const headerTime = parsedDate.getTime();\n\n // If the Date header was invalid for some reason, parsedDate.getTime()\n // will return NaN.\n if (isNaN(headerTime)) {\n return null;\n }\n\n return headerTime;\n }\n\n /**\n * A \"lifecycle\" callback that will be triggered automatically by the\n * `workbox.strategies` handlers when an entry is added to a cache.\n *\n * @param {Object} options\n * @param {string} options.cacheName Name of the cache that was updated.\n * @param {string} options.request The Request for the cached entry.\n *\n * @private\n */\n async cacheDidUpdate({cacheName, request}) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(cacheName, 'string', {\n moduleName: 'workbox-expiration',\n className: 'Plugin',\n funcName: 'cacheDidUpdate',\n paramName: 'cacheName',\n });\n assert.isInstance(request, Request, {\n moduleName: 'workbox-expiration',\n className: 'Plugin',\n funcName: 'cacheDidUpdate',\n paramName: 'request',\n });\n }\n\n const cacheExpiration = this._getCacheExpiration(cacheName);\n await cacheExpiration.updateTimestamp(request.url);\n await cacheExpiration.expireEntries();\n }\n\n\n /**\n * This is a helper method that performs two operations:\n *\n * - Deletes *all* the underlying Cache instances associated with this plugin\n * instance, by calling caches.delete() on your behalf.\n * - Deletes the metadata from IndexedDB used to keep track of expiration\n * details for each Cache instance.\n *\n * When using cache expiration, calling this method is preferable to calling\n * `caches.delete()` directly, since this will ensure that the IndexedDB\n * metadata is also cleanly removed and open IndexedDB instances are deleted.\n *\n * Note that if you're *not* using cache expiration for a given cache, calling\n * `caches.delete()` and passing in the cache's name should be sufficient.\n * There is no Workbox-specific method needed for cleanup in that case.\n */\n async deleteCacheAndMetadata() {\n // Do this one at a time instead of all at once via `Promise.all()` to\n // reduce the chance of inconsistency if a promise rejects.\n for (const [cacheName, cacheExpiration] of this._cacheExpirations) {\n await caches.delete(cacheName);\n await cacheExpiration.delete();\n }\n\n // Reset this._cacheExpirations to its initial state.\n this._cacheExpirations = new Map();\n }\n}\n\nexport {Plugin};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {CacheExpiration} from './CacheExpiration.mjs';\nimport {Plugin} from './Plugin.mjs';\nimport './_version.mjs';\n\n\n/**\n * @namespace workbox.expiration\n */\n\nexport {\n CacheExpiration,\n Plugin,\n};\n"],"names":["self","_","e","DB_NAME","OBJECT_STORE_NAME","normalizeURL","unNormalizedUrl","url","URL","location","hash","href","CacheTimestampsModel","constructor","cacheName","_cacheName","_db","DBWrapper","onupgradeneeded","event","_handleUpgrade","db","target","result","objStore","createObjectStore","keyPath","createIndex","unique","deleteDatabase","setTimestamp","timestamp","put","id","_getId","getTimestamp","entry","get","expireEntries","minTimestamp","maxCount","entriesToDelete","transaction","txn","done","store","objectStore","entriesNotDeletedCount","index","openCursor","onsuccess","cursor","value","push","continue","urlsDeleted","delete","CacheExpiration","config","assert","isType","moduleName","className","funcName","paramName","maxEntries","maxAgeSeconds","WorkboxError","_isRunning","_rerunRequested","_maxEntries","_maxAgeSeconds","_timestampModel","Date","now","undefined","urlsExpired","cache","caches","open","length","logger","groupCollapsed","log","forEach","groupEnd","debug","updateTimestamp","isURLExpired","methodName","expireOlderThan","Infinity","Plugin","_config","_cacheExpirations","Map","purgeOnQuotaError","registerQuotaErrorCallback","deleteCacheAndMetadata","_getCacheExpiration","cacheNames","getRuntimeName","cacheExpiration","set","cachedResponseWillBeUsed","request","cachedResponse","isFresh","_isResponseDateFresh","updateTimestampDone","waitUntil","error","warn","getFriendlyURL","dateHeaderTimestamp","_getDateHeaderTimestamp","headers","has","dateHeader","parsedDate","headerTime","getTime","isNaN","cacheDidUpdate","isInstance","Request"],"mappings":"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"} \ No newline at end of file diff --git a/public/javascripts/workbox/workbox-expiration.prod.js b/public/javascripts/workbox/workbox-expiration.prod.js new file mode 100644 index 0000000000..7c8f84040e --- /dev/null +++ b/public/javascripts/workbox/workbox-expiration.prod.js @@ -0,0 +1,2 @@ +this.workbox=this.workbox||{},this.workbox.expiration=function(t,e,s,i,a,n){"use strict";try{self["workbox:expiration:4.3.1"]&&_()}catch(t){}const h="workbox-expiration",c="cache-entries",r=t=>{const e=new URL(t,location);return e.hash="",e.href};class o{constructor(t){this.t=t,this.s=new e.DBWrapper(h,1,{onupgradeneeded:t=>this.i(t)})}i(t){const e=t.target.result.createObjectStore(c,{keyPath:"id"});e.createIndex("cacheName","cacheName",{unique:!1}),e.createIndex("timestamp","timestamp",{unique:!1}),s.deleteDatabase(this.t)}async setTimestamp(t,e){t=r(t),await this.s.put(c,{url:t,timestamp:e,cacheName:this.t,id:this.h(t)})}async getTimestamp(t){return(await this.s.get(c,this.h(t))).timestamp}async expireEntries(t,e){const s=await this.s.transaction(c,"readwrite",(s,i)=>{const a=s.objectStore(c),n=[];let h=0;a.index("timestamp").openCursor(null,"prev").onsuccess=(({target:s})=>{const a=s.result;if(a){const s=a.value;s.cacheName===this.t&&(t&&s.timestamp=e?n.push(a.value):h++),a.continue()}else i(n)})}),i=[];for(const t of s)await this.s.delete(c,t.id),i.push(t.url);return i}h(t){return this.t+"|"+r(t)}}class u{constructor(t,e={}){this.o=!1,this.u=!1,this.l=e.maxEntries,this.p=e.maxAgeSeconds,this.t=t,this.m=new o(t)}async expireEntries(){if(this.o)return void(this.u=!0);this.o=!0;const t=this.p?Date.now()-1e3*this.p:void 0,e=await this.m.expireEntries(t,this.l),s=await caches.open(this.t);for(const t of e)await s.delete(t);this.o=!1,this.u&&(this.u=!1,this.expireEntries())}async updateTimestamp(t){await this.m.setTimestamp(t,Date.now())}async isURLExpired(t){return await this.m.getTimestamp(t)this.deleteCacheAndMetadata())}k(t){if(t===a.cacheNames.getRuntimeName())throw new i.WorkboxError("expire-custom-caches-only");let e=this.g.get(t);return e||(e=new u(t,this.D),this.g.set(t,e)),e}cachedResponseWillBeUsed({event:t,request:e,cacheName:s,cachedResponse:i}){if(!i)return null;let a=this.N(i);const n=this.k(s);n.expireEntries();const h=n.updateTimestamp(e.url);if(t)try{t.waitUntil(h)}catch(t){}return a?i:null}N(t){if(!this.p)return!0;const e=this._(t);return null===e||e>=Date.now()-1e3*this.p}_(t){if(!t.headers.has("date"))return null;const e=t.headers.get("date"),s=new Date(e).getTime();return isNaN(s)?null:s}async cacheDidUpdate({cacheName:t,request:e}){const s=this.k(t);await s.updateTimestamp(e.url),await s.expireEntries()}async deleteCacheAndMetadata(){for(const[t,e]of this.g)await caches.delete(t),await e.delete();this.g=new Map}},t}({},workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private,workbox.core); +//# sourceMappingURL=workbox-expiration.prod.js.map diff --git a/public/javascripts/workbox/workbox-expiration.prod.js.map b/public/javascripts/workbox/workbox-expiration.prod.js.map new file mode 100644 index 0000000000..6cb8ede7ba --- /dev/null +++ b/public/javascripts/workbox/workbox-expiration.prod.js.map @@ -0,0 +1 @@ +{"version":3,"file":"workbox-expiration.prod.js","sources":["../_version.mjs","../models/CacheTimestampsModel.mjs","../CacheExpiration.mjs","../Plugin.mjs"],"sourcesContent":["try{self['workbox:expiration:4.3.1']&&_()}catch(e){}// eslint-disable-line","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {DBWrapper} from 'workbox-core/_private/DBWrapper.mjs';\nimport {deleteDatabase} from 'workbox-core/_private/deleteDatabase.mjs';\nimport '../_version.mjs';\n\n\nconst DB_NAME = 'workbox-expiration';\nconst OBJECT_STORE_NAME = 'cache-entries';\n\nconst normalizeURL = (unNormalizedUrl) => {\n const url = new URL(unNormalizedUrl, location);\n url.hash = '';\n\n return url.href;\n};\n\n\n/**\n * Returns the timestamp model.\n *\n * @private\n */\nclass CacheTimestampsModel {\n /**\n *\n * @param {string} cacheName\n *\n * @private\n */\n constructor(cacheName) {\n this._cacheName = cacheName;\n\n this._db = new DBWrapper(DB_NAME, 1, {\n onupgradeneeded: (event) => this._handleUpgrade(event),\n });\n }\n\n /**\n * Should perform an upgrade of indexedDB.\n *\n * @param {Event} event\n *\n * @private\n */\n _handleUpgrade(event) {\n const db = event.target.result;\n\n // TODO(philipwalton): EdgeHTML doesn't support arrays as a keyPath, so we\n // have to use the `id` keyPath here and create our own values (a\n // concatenation of `url + cacheName`) instead of simply using\n // `keyPath: ['url', 'cacheName']`, which is supported in other browsers.\n const objStore = db.createObjectStore(OBJECT_STORE_NAME, {keyPath: 'id'});\n\n // TODO(philipwalton): once we don't have to support EdgeHTML, we can\n // create a single index with the keyPath `['cacheName', 'timestamp']`\n // instead of doing both these indexes.\n objStore.createIndex('cacheName', 'cacheName', {unique: false});\n objStore.createIndex('timestamp', 'timestamp', {unique: false});\n\n // Previous versions of `workbox-expiration` used `this._cacheName`\n // as the IDBDatabase name.\n deleteDatabase(this._cacheName);\n }\n\n /**\n * @param {string} url\n * @param {number} timestamp\n *\n * @private\n */\n async setTimestamp(url, timestamp) {\n url = normalizeURL(url);\n\n await this._db.put(OBJECT_STORE_NAME, {\n url,\n timestamp,\n cacheName: this._cacheName,\n // Creating an ID from the URL and cache name won't be necessary once\n // Edge switches to Chromium and all browsers we support work with\n // array keyPaths.\n id: this._getId(url),\n });\n }\n\n /**\n * Returns the timestamp stored for a given URL.\n *\n * @param {string} url\n * @return {number}\n *\n * @private\n */\n async getTimestamp(url) {\n const entry = await this._db.get(OBJECT_STORE_NAME, this._getId(url));\n return entry.timestamp;\n }\n\n /**\n * Iterates through all the entries in the object store (from newest to\n * oldest) and removes entries once either `maxCount` is reached or the\n * entry's timestamp is less than `minTimestamp`.\n *\n * @param {number} minTimestamp\n * @param {number} maxCount\n *\n * @private\n */\n async expireEntries(minTimestamp, maxCount) {\n const entriesToDelete = await this._db.transaction(\n OBJECT_STORE_NAME, 'readwrite', (txn, done) => {\n const store = txn.objectStore(OBJECT_STORE_NAME);\n const entriesToDelete = [];\n let entriesNotDeletedCount = 0;\n\n store.index('timestamp')\n .openCursor(null, 'prev')\n .onsuccess = ({target}) => {\n const cursor = target.result;\n if (cursor) {\n const result = cursor.value;\n // TODO(philipwalton): once we can use a multi-key index, we\n // won't have to check `cacheName` here.\n if (result.cacheName === this._cacheName) {\n // Delete an entry if it's older than the max age or\n // if we already have the max number allowed.\n if ((minTimestamp && result.timestamp < minTimestamp) ||\n (maxCount && entriesNotDeletedCount >= maxCount)) {\n // TODO(philipwalton): we should be able to delete the\n // entry right here, but doing so causes an iteration\n // bug in Safari stable (fixed in TP). Instead we can\n // store the keys of the entries to delete, and then\n // delete the separate transactions.\n // https://github.com/GoogleChrome/workbox/issues/1978\n // cursor.delete();\n\n // We only need to return the URL, not the whole entry.\n entriesToDelete.push(cursor.value);\n } else {\n entriesNotDeletedCount++;\n }\n }\n cursor.continue();\n } else {\n done(entriesToDelete);\n }\n };\n });\n\n // TODO(philipwalton): once the Safari bug in the following issue is fixed,\n // we should be able to remove this loop and do the entry deletion in the\n // cursor loop above:\n // https://github.com/GoogleChrome/workbox/issues/1978\n const urlsDeleted = [];\n for (const entry of entriesToDelete) {\n await this._db.delete(OBJECT_STORE_NAME, entry.id);\n urlsDeleted.push(entry.url);\n }\n\n return urlsDeleted;\n }\n\n /**\n * Takes a URL and returns an ID that will be unique in the object store.\n *\n * @param {string} url\n * @return {string}\n *\n * @private\n */\n _getId(url) {\n // Creating an ID from the URL and cache name won't be necessary once\n // Edge switches to Chromium and all browsers we support work with\n // array keyPaths.\n return this._cacheName + '|' + normalizeURL(url);\n }\n}\n\nexport {CacheTimestampsModel};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {CacheTimestampsModel} from './models/CacheTimestampsModel.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\n\nimport './_version.mjs';\n\n/**\n * The `CacheExpiration` class allows you define an expiration and / or\n * limit on the number of responses stored in a\n * [`Cache`](https://developer.mozilla.org/en-US/docs/Web/API/Cache).\n *\n * @memberof workbox.expiration\n */\nclass CacheExpiration {\n /**\n * To construct a new CacheExpiration instance you must provide at least\n * one of the `config` properties.\n *\n * @param {string} cacheName Name of the cache to apply restrictions to.\n * @param {Object} config\n * @param {number} [config.maxEntries] The maximum number of entries to cache.\n * Entries used the least will be removed as the maximum is reached.\n * @param {number} [config.maxAgeSeconds] The maximum age of an entry before\n * it's treated as stale and removed.\n */\n constructor(cacheName, config = {}) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(cacheName, 'string', {\n moduleName: 'workbox-expiration',\n className: 'CacheExpiration',\n funcName: 'constructor',\n paramName: 'cacheName',\n });\n\n if (!(config.maxEntries || config.maxAgeSeconds)) {\n throw new WorkboxError('max-entries-or-age-required', {\n moduleName: 'workbox-expiration',\n className: 'CacheExpiration',\n funcName: 'constructor',\n });\n }\n\n if (config.maxEntries) {\n assert.isType(config.maxEntries, 'number', {\n moduleName: 'workbox-expiration',\n className: 'CacheExpiration',\n funcName: 'constructor',\n paramName: 'config.maxEntries',\n });\n\n // TODO: Assert is positive\n }\n\n if (config.maxAgeSeconds) {\n assert.isType(config.maxAgeSeconds, 'number', {\n moduleName: 'workbox-expiration',\n className: 'CacheExpiration',\n funcName: 'constructor',\n paramName: 'config.maxAgeSeconds',\n });\n\n // TODO: Assert is positive\n }\n }\n\n this._isRunning = false;\n this._rerunRequested = false;\n this._maxEntries = config.maxEntries;\n this._maxAgeSeconds = config.maxAgeSeconds;\n this._cacheName = cacheName;\n this._timestampModel = new CacheTimestampsModel(cacheName);\n }\n\n /**\n * Expires entries for the given cache and given criteria.\n */\n async expireEntries() {\n if (this._isRunning) {\n this._rerunRequested = true;\n return;\n }\n this._isRunning = true;\n\n const minTimestamp = this._maxAgeSeconds ?\n Date.now() - (this._maxAgeSeconds * 1000) : undefined;\n\n const urlsExpired = await this._timestampModel.expireEntries(\n minTimestamp, this._maxEntries);\n\n // Delete URLs from the cache\n const cache = await caches.open(this._cacheName);\n for (const url of urlsExpired) {\n await cache.delete(url);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n if (urlsExpired.length > 0) {\n logger.groupCollapsed(\n `Expired ${urlsExpired.length} ` +\n `${urlsExpired.length === 1 ? 'entry' : 'entries'} and removed ` +\n `${urlsExpired.length === 1 ? 'it' : 'them'} from the ` +\n `'${this._cacheName}' cache.`);\n logger.log(`Expired the following ${urlsExpired.length === 1 ?\n 'URL' : 'URLs'}:`);\n urlsExpired.forEach((url) => logger.log(` ${url}`));\n logger.groupEnd();\n } else {\n logger.debug(`Cache expiration ran and found no entries to remove.`);\n }\n }\n\n this._isRunning = false;\n if (this._rerunRequested) {\n this._rerunRequested = false;\n this.expireEntries();\n }\n }\n\n /**\n * Update the timestamp for the given URL. This ensures the when\n * removing entries based on maximum entries, most recently used\n * is accurate or when expiring, the timestamp is up-to-date.\n *\n * @param {string} url\n */\n async updateTimestamp(url) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(url, 'string', {\n moduleName: 'workbox-expiration',\n className: 'CacheExpiration',\n funcName: 'updateTimestamp',\n paramName: 'url',\n });\n }\n\n await this._timestampModel.setTimestamp(url, Date.now());\n }\n\n /**\n * Can be used to check if a URL has expired or not before it's used.\n *\n * This requires a look up from IndexedDB, so can be slow.\n *\n * Note: This method will not remove the cached entry, call\n * `expireEntries()` to remove indexedDB and Cache entries.\n *\n * @param {string} url\n * @return {boolean}\n */\n async isURLExpired(url) {\n if (process.env.NODE_ENV !== 'production') {\n if (!this._maxAgeSeconds) {\n throw new WorkboxError(`expired-test-without-max-age`, {\n methodName: 'isURLExpired',\n paramName: 'maxAgeSeconds',\n });\n }\n }\n\n const timestamp = await this._timestampModel.getTimestamp(url);\n const expireOlderThan = Date.now() - (this._maxAgeSeconds * 1000);\n return (timestamp < expireOlderThan);\n }\n\n /**\n * Removes the IndexedDB object store used to keep track of cache expiration\n * metadata.\n */\n async delete() {\n // Make sure we don't attempt another rerun if we're called in the middle of\n // a cache expiration.\n this._rerunRequested = false;\n await this._timestampModel.expireEntries(Infinity); // Expires all.\n }\n}\n\nexport {CacheExpiration};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {cacheNames} from 'workbox-core/_private/cacheNames.mjs';\nimport {getFriendlyURL} from 'workbox-core/_private/getFriendlyURL.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\nimport {registerQuotaErrorCallback}\n from 'workbox-core/registerQuotaErrorCallback.mjs';\n\nimport {CacheExpiration} from './CacheExpiration.mjs';\nimport './_version.mjs';\n\n/**\n * This plugin can be used in the Workbox APIs to regularly enforce a\n * limit on the age and / or the number of cached requests.\n *\n * Whenever a cached request is used or updated, this plugin will look\n * at the used Cache and remove any old or extra requests.\n *\n * When using `maxAgeSeconds`, requests may be used *once* after expiring\n * because the expiration clean up will not have occurred until *after* the\n * cached request has been used. If the request has a \"Date\" header, then\n * a light weight expiration check is performed and the request will not be\n * used immediately.\n *\n * When using `maxEntries`, the entry least-recently requested will be removed from the cache first.\n *\n * @memberof workbox.expiration\n */\nclass Plugin {\n /**\n * @param {Object} config\n * @param {number} [config.maxEntries] The maximum number of entries to cache.\n * Entries used the least will be removed as the maximum is reached.\n * @param {number} [config.maxAgeSeconds] The maximum age of an entry before\n * it's treated as stale and removed.\n * @param {boolean} [config.purgeOnQuotaError] Whether to opt this cache in to\n * automatic deletion if the available storage quota has been exceeded.\n */\n constructor(config = {}) {\n if (process.env.NODE_ENV !== 'production') {\n if (!(config.maxEntries || config.maxAgeSeconds)) {\n throw new WorkboxError('max-entries-or-age-required', {\n moduleName: 'workbox-expiration',\n className: 'Plugin',\n funcName: 'constructor',\n });\n }\n\n if (config.maxEntries) {\n assert.isType(config.maxEntries, 'number', {\n moduleName: 'workbox-expiration',\n className: 'Plugin',\n funcName: 'constructor',\n paramName: 'config.maxEntries',\n });\n }\n\n if (config.maxAgeSeconds) {\n assert.isType(config.maxAgeSeconds, 'number', {\n moduleName: 'workbox-expiration',\n className: 'Plugin',\n funcName: 'constructor',\n paramName: 'config.maxAgeSeconds',\n });\n }\n }\n\n this._config = config;\n this._maxAgeSeconds = config.maxAgeSeconds;\n this._cacheExpirations = new Map();\n\n if (config.purgeOnQuotaError) {\n registerQuotaErrorCallback(() => this.deleteCacheAndMetadata());\n }\n }\n\n /**\n * A simple helper method to return a CacheExpiration instance for a given\n * cache name.\n *\n * @param {string} cacheName\n * @return {CacheExpiration}\n *\n * @private\n */\n _getCacheExpiration(cacheName) {\n if (cacheName === cacheNames.getRuntimeName()) {\n throw new WorkboxError('expire-custom-caches-only');\n }\n\n let cacheExpiration = this._cacheExpirations.get(cacheName);\n if (!cacheExpiration) {\n cacheExpiration = new CacheExpiration(cacheName, this._config);\n this._cacheExpirations.set(cacheName, cacheExpiration);\n }\n return cacheExpiration;\n }\n\n /**\n * A \"lifecycle\" callback that will be triggered automatically by the\n * `workbox.strategies` handlers when a `Response` is about to be returned\n * from a [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache) to\n * the handler. It allows the `Response` to be inspected for freshness and\n * prevents it from being used if the `Response`'s `Date` header value is\n * older than the configured `maxAgeSeconds`.\n *\n * @param {Object} options\n * @param {string} options.cacheName Name of the cache the response is in.\n * @param {Response} options.cachedResponse The `Response` object that's been\n * read from a cache and whose freshness should be checked.\n * @return {Response} Either the `cachedResponse`, if it's\n * fresh, or `null` if the `Response` is older than `maxAgeSeconds`.\n *\n * @private\n */\n cachedResponseWillBeUsed({event, request, cacheName, cachedResponse}) {\n if (!cachedResponse) {\n return null;\n }\n\n let isFresh = this._isResponseDateFresh(cachedResponse);\n\n // Expire entries to ensure that even if the expiration date has\n // expired, it'll only be used once.\n const cacheExpiration = this._getCacheExpiration(cacheName);\n cacheExpiration.expireEntries();\n\n // Update the metadata for the request URL to the current timestamp,\n // but don't `await` it as we don't want to block the response.\n const updateTimestampDone = cacheExpiration.updateTimestamp(request.url);\n if (event) {\n try {\n event.waitUntil(updateTimestampDone);\n } catch (error) {\n if (process.env.NODE_ENV !== 'production') {\n logger.warn(`Unable to ensure service worker stays alive when ` +\n `updating cache entry for '${getFriendlyURL(event.request.url)}'.`);\n }\n }\n }\n\n return isFresh ? cachedResponse : null;\n }\n\n /**\n * @param {Response} cachedResponse\n * @return {boolean}\n *\n * @private\n */\n _isResponseDateFresh(cachedResponse) {\n if (!this._maxAgeSeconds) {\n // We aren't expiring by age, so return true, it's fresh\n return true;\n }\n\n // Check if the 'date' header will suffice a quick expiration check.\n // See https://github.com/GoogleChromeLabs/sw-toolbox/issues/164 for\n // discussion.\n const dateHeaderTimestamp = this._getDateHeaderTimestamp(cachedResponse);\n if (dateHeaderTimestamp === null) {\n // Unable to parse date, so assume it's fresh.\n return true;\n }\n\n // If we have a valid headerTime, then our response is fresh iff the\n // headerTime plus maxAgeSeconds is greater than the current time.\n const now = Date.now();\n return dateHeaderTimestamp >= now - (this._maxAgeSeconds * 1000);\n }\n\n /**\n * This method will extract the data header and parse it into a useful\n * value.\n *\n * @param {Response} cachedResponse\n * @return {number}\n *\n * @private\n */\n _getDateHeaderTimestamp(cachedResponse) {\n if (!cachedResponse.headers.has('date')) {\n return null;\n }\n\n const dateHeader = cachedResponse.headers.get('date');\n const parsedDate = new Date(dateHeader);\n const headerTime = parsedDate.getTime();\n\n // If the Date header was invalid for some reason, parsedDate.getTime()\n // will return NaN.\n if (isNaN(headerTime)) {\n return null;\n }\n\n return headerTime;\n }\n\n /**\n * A \"lifecycle\" callback that will be triggered automatically by the\n * `workbox.strategies` handlers when an entry is added to a cache.\n *\n * @param {Object} options\n * @param {string} options.cacheName Name of the cache that was updated.\n * @param {string} options.request The Request for the cached entry.\n *\n * @private\n */\n async cacheDidUpdate({cacheName, request}) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(cacheName, 'string', {\n moduleName: 'workbox-expiration',\n className: 'Plugin',\n funcName: 'cacheDidUpdate',\n paramName: 'cacheName',\n });\n assert.isInstance(request, Request, {\n moduleName: 'workbox-expiration',\n className: 'Plugin',\n funcName: 'cacheDidUpdate',\n paramName: 'request',\n });\n }\n\n const cacheExpiration = this._getCacheExpiration(cacheName);\n await cacheExpiration.updateTimestamp(request.url);\n await cacheExpiration.expireEntries();\n }\n\n\n /**\n * This is a helper method that performs two operations:\n *\n * - Deletes *all* the underlying Cache instances associated with this plugin\n * instance, by calling caches.delete() on your behalf.\n * - Deletes the metadata from IndexedDB used to keep track of expiration\n * details for each Cache instance.\n *\n * When using cache expiration, calling this method is preferable to calling\n * `caches.delete()` directly, since this will ensure that the IndexedDB\n * metadata is also cleanly removed and open IndexedDB instances are deleted.\n *\n * Note that if you're *not* using cache expiration for a given cache, calling\n * `caches.delete()` and passing in the cache's name should be sufficient.\n * There is no Workbox-specific method needed for cleanup in that case.\n */\n async deleteCacheAndMetadata() {\n // Do this one at a time instead of all at once via `Promise.all()` to\n // reduce the chance of inconsistency if a promise rejects.\n for (const [cacheName, cacheExpiration] of this._cacheExpirations) {\n await caches.delete(cacheName);\n await cacheExpiration.delete();\n }\n\n // Reset this._cacheExpirations to its initial state.\n this._cacheExpirations = new Map();\n }\n}\n\nexport {Plugin};\n"],"names":["self","_","e","DB_NAME","OBJECT_STORE_NAME","normalizeURL","unNormalizedUrl","url","URL","location","hash","href","CacheTimestampsModel","constructor","cacheName","_cacheName","_db","DBWrapper","onupgradeneeded","event","this","_handleUpgrade","objStore","target","result","createObjectStore","keyPath","createIndex","unique","deleteDatabase","timestamp","put","id","_getId","get","minTimestamp","maxCount","entriesToDelete","transaction","txn","done","store","objectStore","entriesNotDeletedCount","index","openCursor","onsuccess","cursor","value","push","continue","urlsDeleted","entry","delete","CacheExpiration","config","_isRunning","_rerunRequested","_maxEntries","maxEntries","_maxAgeSeconds","maxAgeSeconds","_timestampModel","Date","now","undefined","urlsExpired","expireEntries","cache","caches","open","setTimestamp","getTimestamp","Infinity","_config","_cacheExpirations","Map","purgeOnQuotaError","registerQuotaErrorCallback","deleteCacheAndMetadata","_getCacheExpiration","cacheNames","getRuntimeName","WorkboxError","cacheExpiration","set","cachedResponseWillBeUsed","request","cachedResponse","isFresh","_isResponseDateFresh","updateTimestampDone","updateTimestamp","waitUntil","error","dateHeaderTimestamp","_getDateHeaderTimestamp","headers","has","dateHeader","headerTime","getTime","isNaN"],"mappings":"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"} \ No newline at end of file diff --git a/public/javascripts/workbox/workbox-routing.dev.js b/public/javascripts/workbox/workbox-routing.dev.js new file mode 100644 index 0000000000..b3acf069a4 --- /dev/null +++ b/public/javascripts/workbox/workbox-routing.dev.js @@ -0,0 +1,1020 @@ +this.workbox = this.workbox || {}; +this.workbox.routing = (function (exports, assert_mjs, logger_mjs, cacheNames_mjs, WorkboxError_mjs, getFriendlyURL_mjs) { + 'use strict'; + + try { + self['workbox:routing:4.3.1'] && _(); + } catch (e) {} // eslint-disable-line + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The default HTTP method, 'GET', used when there's no specific method + * configured for a route. + * + * @type {string} + * + * @private + */ + + const defaultMethod = 'GET'; + /** + * The list of valid HTTP methods associated with requests that could be routed. + * + * @type {Array} + * + * @private + */ + + const validMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT']; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * @param {function()|Object} handler Either a function, or an object with a + * 'handle' method. + * @return {Object} An object with a handle method. + * + * @private + */ + + const normalizeHandler = handler => { + if (handler && typeof handler === 'object') { + { + assert_mjs.assert.hasMethod(handler, 'handle', { + moduleName: 'workbox-routing', + className: 'Route', + funcName: 'constructor', + paramName: 'handler' + }); + } + + return handler; + } else { + { + assert_mjs.assert.isType(handler, 'function', { + moduleName: 'workbox-routing', + className: 'Route', + funcName: 'constructor', + paramName: 'handler' + }); + } + + return { + handle: handler + }; + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * A `Route` consists of a pair of callback functions, "match" and "handler". + * The "match" callback determine if a route should be used to "handle" a + * request by returning a non-falsy value if it can. The "handler" callback + * is called when there is a match and should return a Promise that resolves + * to a `Response`. + * + * @memberof workbox.routing + */ + + class Route { + /** + * Constructor for Route class. + * + * @param {workbox.routing.Route~matchCallback} match + * A callback function that determines whether the route matches a given + * `fetch` event by returning a non-falsy value. + * @param {workbox.routing.Route~handlerCallback} handler A callback + * function that returns a Promise resolving to a Response. + * @param {string} [method='GET'] The HTTP method to match the Route + * against. + */ + constructor(match, handler, method) { + { + assert_mjs.assert.isType(match, 'function', { + moduleName: 'workbox-routing', + className: 'Route', + funcName: 'constructor', + paramName: 'match' + }); + + if (method) { + assert_mjs.assert.isOneOf(method, validMethods, { + paramName: 'method' + }); + } + } // These values are referenced directly by Router so cannot be + // altered by minifification. + + + this.handler = normalizeHandler(handler); + this.match = match; + this.method = method || defaultMethod; + } + + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * NavigationRoute makes it easy to create a [Route]{@link + * workbox.routing.Route} that matches for browser + * [navigation requests]{@link https://developers.google.com/web/fundamentals/primers/service-workers/high-performance-loading#first_what_are_navigation_requests}. + * + * It will only match incoming Requests whose + * [`mode`]{@link https://fetch.spec.whatwg.org/#concept-request-mode} + * is set to `navigate`. + * + * You can optionally only apply this route to a subset of navigation requests + * by using one or both of the `blacklist` and `whitelist` parameters. + * + * @memberof workbox.routing + * @extends workbox.routing.Route + */ + + class NavigationRoute extends Route { + /** + * If both `blacklist` and `whiltelist` are provided, the `blacklist` will + * take precedence and the request will not match this route. + * + * The regular expressions in `whitelist` and `blacklist` + * are matched against the concatenated + * [`pathname`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/pathname} + * and [`search`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/search} + * portions of the requested URL. + * + * @param {workbox.routing.Route~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + * @param {Object} options + * @param {Array} [options.blacklist] If any of these patterns match, + * the route will not handle the request (even if a whitelist RegExp matches). + * @param {Array} [options.whitelist=[/./]] If any of these patterns + * match the URL's pathname and search parameter, the route will handle the + * request (assuming the blacklist doesn't match). + */ + constructor(handler, { + whitelist = [/./], + blacklist = [] + } = {}) { + { + assert_mjs.assert.isArrayOfClass(whitelist, RegExp, { + moduleName: 'workbox-routing', + className: 'NavigationRoute', + funcName: 'constructor', + paramName: 'options.whitelist' + }); + assert_mjs.assert.isArrayOfClass(blacklist, RegExp, { + moduleName: 'workbox-routing', + className: 'NavigationRoute', + funcName: 'constructor', + paramName: 'options.blacklist' + }); + } + + super(options => this._match(options), handler); + this._whitelist = whitelist; + this._blacklist = blacklist; + } + /** + * Routes match handler. + * + * @param {Object} options + * @param {URL} options.url + * @param {Request} options.request + * @return {boolean} + * + * @private + */ + + + _match({ + url, + request + }) { + if (request.mode !== 'navigate') { + return false; + } + + const pathnameAndSearch = url.pathname + url.search; + + for (const regExp of this._blacklist) { + if (regExp.test(pathnameAndSearch)) { + { + logger_mjs.logger.log(`The navigation route is not being used, since the ` + `URL matches this blacklist pattern: ${regExp}`); + } + + return false; + } + } + + if (this._whitelist.some(regExp => regExp.test(pathnameAndSearch))) { + { + logger_mjs.logger.debug(`The navigation route is being used.`); + } + + return true; + } + + { + logger_mjs.logger.log(`The navigation route is not being used, since the URL ` + `being navigated to doesn't match the whitelist.`); + } + + return false; + } + + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * RegExpRoute makes it easy to create a regular expression based + * [Route]{@link workbox.routing.Route}. + * + * For same-origin requests the RegExp only needs to match part of the URL. For + * requests against third-party servers, you must define a RegExp that matches + * the start of the URL. + * + * [See the module docs for info.]{@link https://developers.google.com/web/tools/workbox/modules/workbox-routing} + * + * @memberof workbox.routing + * @extends workbox.routing.Route + */ + + class RegExpRoute extends Route { + /** + * If the regulard expression contains + * [capture groups]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#grouping-back-references}, + * th ecaptured values will be passed to the + * [handler's]{@link workbox.routing.Route~handlerCallback} `params` + * argument. + * + * @param {RegExp} regExp The regular expression to match against URLs. + * @param {workbox.routing.Route~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + * @param {string} [method='GET'] The HTTP method to match the Route + * against. + */ + constructor(regExp, handler, method) { + { + assert_mjs.assert.isInstance(regExp, RegExp, { + moduleName: 'workbox-routing', + className: 'RegExpRoute', + funcName: 'constructor', + paramName: 'pattern' + }); + } + + const match = ({ + url + }) => { + const result = regExp.exec(url.href); // Return null immediately if there's no match. + + if (!result) { + return null; + } // Require that the match start at the first character in the URL string + // if it's a cross-origin request. + // See https://github.com/GoogleChrome/workbox/issues/281 for the context + // behind this behavior. + + + if (url.origin !== location.origin && result.index !== 0) { + { + logger_mjs.logger.debug(`The regular expression '${regExp}' only partially matched ` + `against the cross-origin URL '${url}'. RegExpRoute's will only ` + `handle cross-origin requests if they match the entire URL.`); + } + + return null; + } // If the route matches, but there aren't any capture groups defined, then + // this will return [], which is truthy and therefore sufficient to + // indicate a match. + // If there are capture groups, then it will return their values. + + + return result.slice(1); + }; + + super(match, handler, method); + } + + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * The Router can be used to process a FetchEvent through one or more + * [Routes]{@link workbox.routing.Route} responding with a Request if + * a matching route exists. + * + * If no route matches a given a request, the Router will use a "default" + * handler if one is defined. + * + * Should the matching Route throw an error, the Router will use a "catch" + * handler if one is defined to gracefully deal with issues and respond with a + * Request. + * + * If a request matches multiple routes, the **earliest** registered route will + * be used to respond to the request. + * + * @memberof workbox.routing + */ + + class Router { + /** + * Initializes a new Router. + */ + constructor() { + this._routes = new Map(); + } + /** + * @return {Map>} routes A `Map` of HTTP + * method name ('GET', etc.) to an array of all the corresponding `Route` + * instances that are registered. + */ + + + get routes() { + return this._routes; + } + /** + * Adds a fetch event listener to respond to events when a route matches + * the event's request. + */ + + + addFetchListener() { + self.addEventListener('fetch', event => { + const { + request + } = event; + const responsePromise = this.handleRequest({ + request, + event + }); + + if (responsePromise) { + event.respondWith(responsePromise); + } + }); + } + /** + * Adds a message event listener for URLs to cache from the window. + * This is useful to cache resources loaded on the page prior to when the + * service worker started controlling it. + * + * The format of the message data sent from the window should be as follows. + * Where the `urlsToCache` array may consist of URL strings or an array of + * URL string + `requestInit` object (the same as you'd pass to `fetch()`). + * + * ``` + * { + * type: 'CACHE_URLS', + * payload: { + * urlsToCache: [ + * './script1.js', + * './script2.js', + * ['./script3.js', {mode: 'no-cors'}], + * ], + * }, + * } + * ``` + */ + + + addCacheListener() { + self.addEventListener('message', async event => { + if (event.data && event.data.type === 'CACHE_URLS') { + const { + payload + } = event.data; + + { + logger_mjs.logger.debug(`Caching URLs from the window`, payload.urlsToCache); + } + + const requestPromises = Promise.all(payload.urlsToCache.map(entry => { + if (typeof entry === 'string') { + entry = [entry]; + } + + const request = new Request(...entry); + return this.handleRequest({ + request + }); + })); + event.waitUntil(requestPromises); // If a MessageChannel was used, reply to the message on success. + + if (event.ports && event.ports[0]) { + await requestPromises; + event.ports[0].postMessage(true); + } + } + }); + } + /** + * Apply the routing rules to a FetchEvent object to get a Response from an + * appropriate Route's handler. + * + * @param {Object} options + * @param {Request} options.request The request to handle (this is usually + * from a fetch event, but it does not have to be). + * @param {FetchEvent} [options.event] The event that triggered the request, + * if applicable. + * @return {Promise|undefined} A promise is returned if a + * registered route can handle the request. If there is no matching + * route and there's no `defaultHandler`, `undefined` is returned. + */ + + + handleRequest({ + request, + event + }) { + { + assert_mjs.assert.isInstance(request, Request, { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'handleRequest', + paramName: 'options.request' + }); + } + + const url = new URL(request.url, location); + + if (!url.protocol.startsWith('http')) { + { + logger_mjs.logger.debug(`Workbox Router only supports URLs that start with 'http'.`); + } + + return; + } + + let { + params, + route + } = this.findMatchingRoute({ + url, + request, + event + }); + let handler = route && route.handler; + let debugMessages = []; + + { + if (handler) { + debugMessages.push([`Found a route to handle this request:`, route]); + + if (params) { + debugMessages.push([`Passing the following params to the route's handler:`, params]); + } + } + } // If we don't have a handler because there was no matching route, then + // fall back to defaultHandler if that's defined. + + + if (!handler && this._defaultHandler) { + { + debugMessages.push(`Failed to find a matching route. Falling ` + `back to the default handler.`); // This is used for debugging in logs in the case of an error. + + route = '[Default Handler]'; + } + + handler = this._defaultHandler; + } + + if (!handler) { + { + // No handler so Workbox will do nothing. If logs is set of debug + // i.e. verbose, we should print out this information. + logger_mjs.logger.debug(`No route found for: ${getFriendlyURL_mjs.getFriendlyURL(url)}`); + } + + return; + } + + { + // We have a handler, meaning Workbox is going to handle the route. + // print the routing details to the console. + logger_mjs.logger.groupCollapsed(`Router is responding to: ${getFriendlyURL_mjs.getFriendlyURL(url)}`); + debugMessages.forEach(msg => { + if (Array.isArray(msg)) { + logger_mjs.logger.log(...msg); + } else { + logger_mjs.logger.log(msg); + } + }); // The Request and Response objects contains a great deal of information, + // hide it under a group in case developers want to see it. + + logger_mjs.logger.groupCollapsed(`View request details here.`); + logger_mjs.logger.log(request); + logger_mjs.logger.groupEnd(); + logger_mjs.logger.groupEnd(); + } // Wrap in try and catch in case the handle method throws a synchronous + // error. It should still callback to the catch handler. + + + let responsePromise; + + try { + responsePromise = handler.handle({ + url, + request, + event, + params + }); + } catch (err) { + responsePromise = Promise.reject(err); + } + + if (responsePromise && this._catchHandler) { + responsePromise = responsePromise.catch(err => { + { + // Still include URL here as it will be async from the console group + // and may not make sense without the URL + logger_mjs.logger.groupCollapsed(`Error thrown when responding to: ` + ` ${getFriendlyURL_mjs.getFriendlyURL(url)}. Falling back to Catch Handler.`); + logger_mjs.logger.error(`Error thrown by:`, route); + logger_mjs.logger.error(err); + logger_mjs.logger.groupEnd(); + } + + return this._catchHandler.handle({ + url, + event, + err + }); + }); + } + + return responsePromise; + } + /** + * Checks a request and URL (and optionally an event) against the list of + * registered routes, and if there's a match, returns the corresponding + * route along with any params generated by the match. + * + * @param {Object} options + * @param {URL} options.url + * @param {Request} options.request The request to match. + * @param {FetchEvent} [options.event] The corresponding event (unless N/A). + * @return {Object} An object with `route` and `params` properties. + * They are populated if a matching route was found or `undefined` + * otherwise. + */ + + + findMatchingRoute({ + url, + request, + event + }) { + { + assert_mjs.assert.isInstance(url, URL, { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'findMatchingRoute', + paramName: 'options.url' + }); + assert_mjs.assert.isInstance(request, Request, { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'findMatchingRoute', + paramName: 'options.request' + }); + } + + const routes = this._routes.get(request.method) || []; + + for (const route of routes) { + let params; + let matchResult = route.match({ + url, + request, + event + }); + + if (matchResult) { + if (Array.isArray(matchResult) && matchResult.length > 0) { + // Instead of passing an empty array in as params, use undefined. + params = matchResult; + } else if (matchResult.constructor === Object && Object.keys(matchResult).length > 0) { + // Instead of passing an empty object in as params, use undefined. + params = matchResult; + } // Return early if have a match. + + + return { + route, + params + }; + } + } // If no match was found above, return and empty object. + + + return {}; + } + /** + * Define a default `handler` that's called when no routes explicitly + * match the incoming request. + * + * Without a default handler, unmatched requests will go against the + * network as if there were no service worker present. + * + * @param {workbox.routing.Route~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + */ + + + setDefaultHandler(handler) { + this._defaultHandler = normalizeHandler(handler); + } + /** + * If a Route throws an error while handling a request, this `handler` + * will be called and given a chance to provide a response. + * + * @param {workbox.routing.Route~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + */ + + + setCatchHandler(handler) { + this._catchHandler = normalizeHandler(handler); + } + /** + * Registers a route with the router. + * + * @param {workbox.routing.Route} route The route to register. + */ + + + registerRoute(route) { + { + assert_mjs.assert.isType(route, 'object', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route' + }); + assert_mjs.assert.hasMethod(route, 'match', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route' + }); + assert_mjs.assert.isType(route.handler, 'object', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route' + }); + assert_mjs.assert.hasMethod(route.handler, 'handle', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route.handler' + }); + assert_mjs.assert.isType(route.method, 'string', { + moduleName: 'workbox-routing', + className: 'Router', + funcName: 'registerRoute', + paramName: 'route.method' + }); + } + + if (!this._routes.has(route.method)) { + this._routes.set(route.method, []); + } // Give precedence to all of the earlier routes by adding this additional + // route to the end of the array. + + + this._routes.get(route.method).push(route); + } + /** + * Unregisters a route with the router. + * + * @param {workbox.routing.Route} route The route to unregister. + */ + + + unregisterRoute(route) { + if (!this._routes.has(route.method)) { + throw new WorkboxError_mjs.WorkboxError('unregister-route-but-not-found-with-method', { + method: route.method + }); + } + + const routeIndex = this._routes.get(route.method).indexOf(route); + + if (routeIndex > -1) { + this._routes.get(route.method).splice(routeIndex, 1); + } else { + throw new WorkboxError_mjs.WorkboxError('unregister-route-route-not-registered'); + } + } + + } + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + let defaultRouter; + /** + * Creates a new, singleton Router instance if one does not exist. If one + * does already exist, that instance is returned. + * + * @private + * @return {Router} + */ + + const getOrCreateDefaultRouter = () => { + if (!defaultRouter) { + defaultRouter = new Router(); // The helpers that use the default Router assume these listeners exist. + + defaultRouter.addFetchListener(); + defaultRouter.addCacheListener(); + } + + return defaultRouter; + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Registers a route that will return a precached file for a navigation + * request. This is useful for the + * [application shell pattern]{@link https://developers.google.com/web/fundamentals/architecture/app-shell}. + * + * When determining the URL of the precached HTML document, you will likely need + * to call `workbox.precaching.getCacheKeyForURL(originalUrl)`, to account for + * the fact that Workbox's precaching naming conventions often results in URL + * cache keys that contain extra revisioning info. + * + * This method will generate a + * [NavigationRoute]{@link workbox.routing.NavigationRoute} + * and call + * [Router.registerRoute()]{@link workbox.routing.Router#registerRoute} on a + * singleton Router instance. + * + * @param {string} cachedAssetUrl The cache key to use for the HTML file. + * @param {Object} [options] + * @param {string} [options.cacheName] Cache name to store and retrieve + * requests. Defaults to precache cache name provided by + * [workbox-core.cacheNames]{@link workbox.core.cacheNames}. + * @param {Array} [options.blacklist=[]] If any of these patterns + * match, the route will not handle the request (even if a whitelist entry + * matches). + * @param {Array} [options.whitelist=[/./]] If any of these patterns + * match the URL's pathname and search parameter, the route will handle the + * request (assuming the blacklist doesn't match). + * @return {workbox.routing.NavigationRoute} Returns the generated + * Route. + * + * @alias workbox.routing.registerNavigationRoute + */ + + const registerNavigationRoute = (cachedAssetUrl, options = {}) => { + { + assert_mjs.assert.isType(cachedAssetUrl, 'string', { + moduleName: 'workbox-routing', + funcName: 'registerNavigationRoute', + paramName: 'cachedAssetUrl' + }); + } + + const cacheName = cacheNames_mjs.cacheNames.getPrecacheName(options.cacheName); + + const handler = async () => { + try { + const response = await caches.match(cachedAssetUrl, { + cacheName + }); + + if (response) { + return response; + } // This shouldn't normally happen, but there are edge cases: + // https://github.com/GoogleChrome/workbox/issues/1441 + + + throw new Error(`The cache ${cacheName} did not have an entry for ` + `${cachedAssetUrl}.`); + } catch (error) { + // If there's either a cache miss, or the caches.match() call threw + // an exception, then attempt to fulfill the navigation request with + // a response from the network rather than leaving the user with a + // failed navigation. + { + logger_mjs.logger.debug(`Unable to respond to navigation request with ` + `cached response. Falling back to network.`, error); + } // This might still fail if the browser is offline... + + + return fetch(cachedAssetUrl); + } + }; + + const route = new NavigationRoute(handler, { + whitelist: options.whitelist, + blacklist: options.blacklist + }); + const defaultRouter = getOrCreateDefaultRouter(); + defaultRouter.registerRoute(route); + return route; + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Easily register a RegExp, string, or function with a caching + * strategy to a singleton Router instance. + * + * This method will generate a Route for you if needed and + * call [Router.registerRoute()]{@link + * workbox.routing.Router#registerRoute}. + * + * @param { + * RegExp| + * string| + * workbox.routing.Route~matchCallback| + * workbox.routing.Route + * } capture + * If the capture param is a `Route`, all other arguments will be ignored. + * @param {workbox.routing.Route~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + * @param {string} [method='GET'] The HTTP method to match the Route + * against. + * @return {workbox.routing.Route} The generated `Route`(Useful for + * unregistering). + * + * @alias workbox.routing.registerRoute + */ + + const registerRoute = (capture, handler, method = 'GET') => { + let route; + + if (typeof capture === 'string') { + const captureUrl = new URL(capture, location); + + { + if (!(capture.startsWith('/') || capture.startsWith('http'))) { + throw new WorkboxError_mjs.WorkboxError('invalid-string', { + moduleName: 'workbox-routing', + funcName: 'registerRoute', + paramName: 'capture' + }); + } // We want to check if Express-style wildcards are in the pathname only. + // TODO: Remove this log message in v4. + + + const valueToCheck = capture.startsWith('http') ? captureUrl.pathname : capture; // See https://github.com/pillarjs/path-to-regexp#parameters + + const wildcards = '[*:?+]'; + + if (valueToCheck.match(new RegExp(`${wildcards}`))) { + logger_mjs.logger.debug(`The '$capture' parameter contains an Express-style wildcard ` + `character (${wildcards}). Strings are now always interpreted as ` + `exact matches; use a RegExp for partial or wildcard matches.`); + } + } + + const matchCallback = ({ + url + }) => { + { + if (url.pathname === captureUrl.pathname && url.origin !== captureUrl.origin) { + logger_mjs.logger.debug(`${capture} only partially matches the cross-origin URL ` + `${url}. This route will only handle cross-origin requests ` + `if they match the entire URL.`); + } + } + + return url.href === captureUrl.href; + }; + + route = new Route(matchCallback, handler, method); + } else if (capture instanceof RegExp) { + route = new RegExpRoute(capture, handler, method); + } else if (typeof capture === 'function') { + route = new Route(capture, handler, method); + } else if (capture instanceof Route) { + route = capture; + } else { + throw new WorkboxError_mjs.WorkboxError('unsupported-route-type', { + moduleName: 'workbox-routing', + funcName: 'registerRoute', + paramName: 'capture' + }); + } + + const defaultRouter = getOrCreateDefaultRouter(); + defaultRouter.registerRoute(route); + return route; + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * If a Route throws an error while handling a request, this `handler` + * will be called and given a chance to provide a response. + * + * @param {workbox.routing.Route~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + * + * @alias workbox.routing.setCatchHandler + */ + + const setCatchHandler = handler => { + const defaultRouter = getOrCreateDefaultRouter(); + defaultRouter.setCatchHandler(handler); + }; + + /* + Copyright 2019 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * Define a default `handler` that's called when no routes explicitly + * match the incoming request. + * + * Without a default handler, unmatched requests will go against the + * network as if there were no service worker present. + * + * @param {workbox.routing.Route~handlerCallback} handler A callback + * function that returns a Promise resulting in a Response. + * + * @alias workbox.routing.setDefaultHandler + */ + + const setDefaultHandler = handler => { + const defaultRouter = getOrCreateDefaultRouter(); + defaultRouter.setDefaultHandler(handler); + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + + { + assert_mjs.assert.isSWEnv('workbox-routing'); + } + + exports.NavigationRoute = NavigationRoute; + exports.RegExpRoute = RegExpRoute; + exports.registerNavigationRoute = registerNavigationRoute; + exports.registerRoute = registerRoute; + exports.Route = Route; + exports.Router = Router; + exports.setCatchHandler = setCatchHandler; + exports.setDefaultHandler = setDefaultHandler; + + return exports; + +}({}, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core._private)); +//# sourceMappingURL=workbox-routing.dev.js.map diff --git a/public/javascripts/workbox/workbox-routing.dev.js.map b/public/javascripts/workbox/workbox-routing.dev.js.map new file mode 100644 index 0000000000..7107132241 --- /dev/null +++ b/public/javascripts/workbox/workbox-routing.dev.js.map @@ -0,0 +1 @@ +{"version":3,"file":"workbox-routing.dev.js","sources":["../_version.mjs","../utils/constants.mjs","../utils/normalizeHandler.mjs","../Route.mjs","../NavigationRoute.mjs","../RegExpRoute.mjs","../Router.mjs","../utils/getOrCreateDefaultRouter.mjs","../registerNavigationRoute.mjs","../registerRoute.mjs","../setCatchHandler.mjs","../setDefaultHandler.mjs","../index.mjs"],"sourcesContent":["try{self['workbox:routing:4.3.1']&&_()}catch(e){}// eslint-disable-line","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n/**\n * The default HTTP method, 'GET', used when there's no specific method\n * configured for a route.\n *\n * @type {string}\n *\n * @private\n */\nexport const defaultMethod = 'GET';\n\n/**\n * The list of valid HTTP methods associated with requests that could be routed.\n *\n * @type {Array}\n *\n * @private\n */\nexport const validMethods = [\n 'DELETE',\n 'GET',\n 'HEAD',\n 'PATCH',\n 'POST',\n 'PUT',\n];\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport '../_version.mjs';\n\n/**\n * @param {function()|Object} handler Either a function, or an object with a\n * 'handle' method.\n * @return {Object} An object with a handle method.\n *\n * @private\n */\nexport const normalizeHandler = (handler) => {\n if (handler && typeof handler === 'object') {\n if (process.env.NODE_ENV !== 'production') {\n assert.hasMethod(handler, 'handle', {\n moduleName: 'workbox-routing',\n className: 'Route',\n funcName: 'constructor',\n paramName: 'handler',\n });\n }\n return handler;\n } else {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(handler, 'function', {\n moduleName: 'workbox-routing',\n className: 'Route',\n funcName: 'constructor',\n paramName: 'handler',\n });\n }\n return {handle: handler};\n }\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\n\nimport {defaultMethod, validMethods} from './utils/constants.mjs';\nimport {normalizeHandler} from './utils/normalizeHandler.mjs';\nimport './_version.mjs';\n\n/**\n * A `Route` consists of a pair of callback functions, \"match\" and \"handler\".\n * The \"match\" callback determine if a route should be used to \"handle\" a\n * request by returning a non-falsy value if it can. The \"handler\" callback\n * is called when there is a match and should return a Promise that resolves\n * to a `Response`.\n *\n * @memberof workbox.routing\n */\nclass Route {\n /**\n * Constructor for Route class.\n *\n * @param {workbox.routing.Route~matchCallback} match\n * A callback function that determines whether the route matches a given\n * `fetch` event by returning a non-falsy value.\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resolving to a Response.\n * @param {string} [method='GET'] The HTTP method to match the Route\n * against.\n */\n constructor(match, handler, method) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(match, 'function', {\n moduleName: 'workbox-routing',\n className: 'Route',\n funcName: 'constructor',\n paramName: 'match',\n });\n\n if (method) {\n assert.isOneOf(method, validMethods, {paramName: 'method'});\n }\n }\n\n // These values are referenced directly by Router so cannot be\n // altered by minifification.\n this.handler = normalizeHandler(handler);\n this.match = match;\n this.method = method || defaultMethod;\n }\n}\n\nexport {Route};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {Route} from './Route.mjs';\nimport './_version.mjs';\n\n/**\n * NavigationRoute makes it easy to create a [Route]{@link\n * workbox.routing.Route} that matches for browser\n * [navigation requests]{@link https://developers.google.com/web/fundamentals/primers/service-workers/high-performance-loading#first_what_are_navigation_requests}.\n *\n * It will only match incoming Requests whose\n * [`mode`]{@link https://fetch.spec.whatwg.org/#concept-request-mode}\n * is set to `navigate`.\n *\n * You can optionally only apply this route to a subset of navigation requests\n * by using one or both of the `blacklist` and `whitelist` parameters.\n *\n * @memberof workbox.routing\n * @extends workbox.routing.Route\n */\nclass NavigationRoute extends Route {\n /**\n * If both `blacklist` and `whiltelist` are provided, the `blacklist` will\n * take precedence and the request will not match this route.\n *\n * The regular expressions in `whitelist` and `blacklist`\n * are matched against the concatenated\n * [`pathname`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/pathname}\n * and [`search`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/search}\n * portions of the requested URL.\n *\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resulting in a Response.\n * @param {Object} options\n * @param {Array} [options.blacklist] If any of these patterns match,\n * the route will not handle the request (even if a whitelist RegExp matches).\n * @param {Array} [options.whitelist=[/./]] If any of these patterns\n * match the URL's pathname and search parameter, the route will handle the\n * request (assuming the blacklist doesn't match).\n */\n constructor(handler, {whitelist = [/./], blacklist = []} = {}) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isArrayOfClass(whitelist, RegExp, {\n moduleName: 'workbox-routing',\n className: 'NavigationRoute',\n funcName: 'constructor',\n paramName: 'options.whitelist',\n });\n assert.isArrayOfClass(blacklist, RegExp, {\n moduleName: 'workbox-routing',\n className: 'NavigationRoute',\n funcName: 'constructor',\n paramName: 'options.blacklist',\n });\n }\n\n super((options) => this._match(options), handler);\n\n this._whitelist = whitelist;\n this._blacklist = blacklist;\n }\n\n /**\n * Routes match handler.\n *\n * @param {Object} options\n * @param {URL} options.url\n * @param {Request} options.request\n * @return {boolean}\n *\n * @private\n */\n _match({url, request}) {\n if (request.mode !== 'navigate') {\n return false;\n }\n\n const pathnameAndSearch = url.pathname + url.search;\n\n for (const regExp of this._blacklist) {\n if (regExp.test(pathnameAndSearch)) {\n if (process.env.NODE_ENV !== 'production') {\n logger.log(`The navigation route is not being used, since the ` +\n `URL matches this blacklist pattern: ${regExp}`);\n }\n return false;\n }\n }\n\n if (this._whitelist.some((regExp) => regExp.test(pathnameAndSearch))) {\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(`The navigation route is being used.`);\n }\n return true;\n }\n\n if (process.env.NODE_ENV !== 'production') {\n logger.log(`The navigation route is not being used, since the URL ` +\n `being navigated to doesn't match the whitelist.`);\n }\n return false;\n }\n}\n\nexport {NavigationRoute};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {Route} from './Route.mjs';\nimport './_version.mjs';\n\n/**\n * RegExpRoute makes it easy to create a regular expression based\n * [Route]{@link workbox.routing.Route}.\n *\n * For same-origin requests the RegExp only needs to match part of the URL. For\n * requests against third-party servers, you must define a RegExp that matches\n * the start of the URL.\n *\n * [See the module docs for info.]{@link https://developers.google.com/web/tools/workbox/modules/workbox-routing}\n *\n * @memberof workbox.routing\n * @extends workbox.routing.Route\n */\nclass RegExpRoute extends Route {\n /**\n * If the regulard expression contains\n * [capture groups]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#grouping-back-references},\n * th ecaptured values will be passed to the\n * [handler's]{@link workbox.routing.Route~handlerCallback} `params`\n * argument.\n *\n * @param {RegExp} regExp The regular expression to match against URLs.\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resulting in a Response.\n * @param {string} [method='GET'] The HTTP method to match the Route\n * against.\n */\n constructor(regExp, handler, method) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(regExp, RegExp, {\n moduleName: 'workbox-routing',\n className: 'RegExpRoute',\n funcName: 'constructor',\n paramName: 'pattern',\n });\n }\n\n const match = ({url}) => {\n const result = regExp.exec(url.href);\n\n // Return null immediately if there's no match.\n if (!result) {\n return null;\n }\n\n // Require that the match start at the first character in the URL string\n // if it's a cross-origin request.\n // See https://github.com/GoogleChrome/workbox/issues/281 for the context\n // behind this behavior.\n if ((url.origin !== location.origin) && (result.index !== 0)) {\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(\n `The regular expression '${regExp}' only partially matched ` +\n `against the cross-origin URL '${url}'. RegExpRoute's will only ` +\n `handle cross-origin requests if they match the entire URL.`\n );\n }\n\n return null;\n }\n\n // If the route matches, but there aren't any capture groups defined, then\n // this will return [], which is truthy and therefore sufficient to\n // indicate a match.\n // If there are capture groups, then it will return their values.\n return result.slice(1);\n };\n\n super(match, handler, method);\n }\n}\n\nexport {RegExpRoute};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\nimport {getFriendlyURL} from 'workbox-core/_private/getFriendlyURL.mjs';\n\nimport {normalizeHandler} from './utils/normalizeHandler.mjs';\nimport './_version.mjs';\n\n/**\n * The Router can be used to process a FetchEvent through one or more\n * [Routes]{@link workbox.routing.Route} responding with a Request if\n * a matching route exists.\n *\n * If no route matches a given a request, the Router will use a \"default\"\n * handler if one is defined.\n *\n * Should the matching Route throw an error, the Router will use a \"catch\"\n * handler if one is defined to gracefully deal with issues and respond with a\n * Request.\n *\n * If a request matches multiple routes, the **earliest** registered route will\n * be used to respond to the request.\n *\n * @memberof workbox.routing\n */\nclass Router {\n /**\n * Initializes a new Router.\n */\n constructor() {\n this._routes = new Map();\n }\n\n /**\n * @return {Map>} routes A `Map` of HTTP\n * method name ('GET', etc.) to an array of all the corresponding `Route`\n * instances that are registered.\n */\n get routes() {\n return this._routes;\n }\n\n /**\n * Adds a fetch event listener to respond to events when a route matches\n * the event's request.\n */\n addFetchListener() {\n self.addEventListener('fetch', (event) => {\n const {request} = event;\n const responsePromise = this.handleRequest({request, event});\n if (responsePromise) {\n event.respondWith(responsePromise);\n }\n });\n }\n\n /**\n * Adds a message event listener for URLs to cache from the window.\n * This is useful to cache resources loaded on the page prior to when the\n * service worker started controlling it.\n *\n * The format of the message data sent from the window should be as follows.\n * Where the `urlsToCache` array may consist of URL strings or an array of\n * URL string + `requestInit` object (the same as you'd pass to `fetch()`).\n *\n * ```\n * {\n * type: 'CACHE_URLS',\n * payload: {\n * urlsToCache: [\n * './script1.js',\n * './script2.js',\n * ['./script3.js', {mode: 'no-cors'}],\n * ],\n * },\n * }\n * ```\n */\n addCacheListener() {\n self.addEventListener('message', async (event) => {\n if (event.data && event.data.type === 'CACHE_URLS') {\n const {payload} = event.data;\n\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(`Caching URLs from the window`, payload.urlsToCache);\n }\n\n const requestPromises = Promise.all(payload.urlsToCache.map((entry) => {\n if (typeof entry === 'string') {\n entry = [entry];\n }\n\n const request = new Request(...entry);\n return this.handleRequest({request});\n }));\n\n event.waitUntil(requestPromises);\n\n // If a MessageChannel was used, reply to the message on success.\n if (event.ports && event.ports[0]) {\n await requestPromises;\n event.ports[0].postMessage(true);\n }\n }\n });\n }\n\n /**\n * Apply the routing rules to a FetchEvent object to get a Response from an\n * appropriate Route's handler.\n *\n * @param {Object} options\n * @param {Request} options.request The request to handle (this is usually\n * from a fetch event, but it does not have to be).\n * @param {FetchEvent} [options.event] The event that triggered the request,\n * if applicable.\n * @return {Promise|undefined} A promise is returned if a\n * registered route can handle the request. If there is no matching\n * route and there's no `defaultHandler`, `undefined` is returned.\n */\n handleRequest({request, event}) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(request, Request, {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'handleRequest',\n paramName: 'options.request',\n });\n }\n\n const url = new URL(request.url, location);\n if (!url.protocol.startsWith('http')) {\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(\n `Workbox Router only supports URLs that start with 'http'.`);\n }\n return;\n }\n\n let {params, route} = this.findMatchingRoute({url, request, event});\n let handler = route && route.handler;\n\n let debugMessages = [];\n if (process.env.NODE_ENV !== 'production') {\n if (handler) {\n debugMessages.push([\n `Found a route to handle this request:`, route,\n ]);\n\n if (params) {\n debugMessages.push([\n `Passing the following params to the route's handler:`, params,\n ]);\n }\n }\n }\n\n // If we don't have a handler because there was no matching route, then\n // fall back to defaultHandler if that's defined.\n if (!handler && this._defaultHandler) {\n if (process.env.NODE_ENV !== 'production') {\n debugMessages.push(`Failed to find a matching route. Falling ` +\n `back to the default handler.`);\n\n // This is used for debugging in logs in the case of an error.\n route = '[Default Handler]';\n }\n handler = this._defaultHandler;\n }\n\n if (!handler) {\n if (process.env.NODE_ENV !== 'production') {\n // No handler so Workbox will do nothing. If logs is set of debug\n // i.e. verbose, we should print out this information.\n logger.debug(`No route found for: ${getFriendlyURL(url)}`);\n }\n return;\n }\n\n if (process.env.NODE_ENV !== 'production') {\n // We have a handler, meaning Workbox is going to handle the route.\n // print the routing details to the console.\n logger.groupCollapsed(`Router is responding to: ${getFriendlyURL(url)}`);\n debugMessages.forEach((msg) => {\n if (Array.isArray(msg)) {\n logger.log(...msg);\n } else {\n logger.log(msg);\n }\n });\n\n // The Request and Response objects contains a great deal of information,\n // hide it under a group in case developers want to see it.\n logger.groupCollapsed(`View request details here.`);\n logger.log(request);\n logger.groupEnd();\n\n logger.groupEnd();\n }\n\n // Wrap in try and catch in case the handle method throws a synchronous\n // error. It should still callback to the catch handler.\n let responsePromise;\n try {\n responsePromise = handler.handle({url, request, event, params});\n } catch (err) {\n responsePromise = Promise.reject(err);\n }\n\n if (responsePromise && this._catchHandler) {\n responsePromise = responsePromise.catch((err) => {\n if (process.env.NODE_ENV !== 'production') {\n // Still include URL here as it will be async from the console group\n // and may not make sense without the URL\n logger.groupCollapsed(`Error thrown when responding to: ` +\n ` ${getFriendlyURL(url)}. Falling back to Catch Handler.`);\n logger.error(`Error thrown by:`, route);\n logger.error(err);\n logger.groupEnd();\n }\n return this._catchHandler.handle({url, event, err});\n });\n }\n\n return responsePromise;\n }\n\n /**\n * Checks a request and URL (and optionally an event) against the list of\n * registered routes, and if there's a match, returns the corresponding\n * route along with any params generated by the match.\n *\n * @param {Object} options\n * @param {URL} options.url\n * @param {Request} options.request The request to match.\n * @param {FetchEvent} [options.event] The corresponding event (unless N/A).\n * @return {Object} An object with `route` and `params` properties.\n * They are populated if a matching route was found or `undefined`\n * otherwise.\n */\n findMatchingRoute({url, request, event}) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(url, URL, {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'findMatchingRoute',\n paramName: 'options.url',\n });\n assert.isInstance(request, Request, {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'findMatchingRoute',\n paramName: 'options.request',\n });\n }\n\n const routes = this._routes.get(request.method) || [];\n for (const route of routes) {\n let params;\n let matchResult = route.match({url, request, event});\n if (matchResult) {\n if (Array.isArray(matchResult) && matchResult.length > 0) {\n // Instead of passing an empty array in as params, use undefined.\n params = matchResult;\n } else if ((matchResult.constructor === Object &&\n Object.keys(matchResult).length > 0)) {\n // Instead of passing an empty object in as params, use undefined.\n params = matchResult;\n }\n\n // Return early if have a match.\n return {route, params};\n }\n }\n // If no match was found above, return and empty object.\n return {};\n }\n\n /**\n * Define a default `handler` that's called when no routes explicitly\n * match the incoming request.\n *\n * Without a default handler, unmatched requests will go against the\n * network as if there were no service worker present.\n *\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resulting in a Response.\n */\n setDefaultHandler(handler) {\n this._defaultHandler = normalizeHandler(handler);\n }\n\n /**\n * If a Route throws an error while handling a request, this `handler`\n * will be called and given a chance to provide a response.\n *\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resulting in a Response.\n */\n setCatchHandler(handler) {\n this._catchHandler = normalizeHandler(handler);\n }\n\n /**\n * Registers a route with the router.\n *\n * @param {workbox.routing.Route} route The route to register.\n */\n registerRoute(route) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(route, 'object', {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'registerRoute',\n paramName: 'route',\n });\n\n assert.hasMethod(route, 'match', {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'registerRoute',\n paramName: 'route',\n });\n\n assert.isType(route.handler, 'object', {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'registerRoute',\n paramName: 'route',\n });\n\n assert.hasMethod(route.handler, 'handle', {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'registerRoute',\n paramName: 'route.handler',\n });\n\n assert.isType(route.method, 'string', {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'registerRoute',\n paramName: 'route.method',\n });\n }\n\n if (!this._routes.has(route.method)) {\n this._routes.set(route.method, []);\n }\n\n // Give precedence to all of the earlier routes by adding this additional\n // route to the end of the array.\n this._routes.get(route.method).push(route);\n }\n\n /**\n * Unregisters a route with the router.\n *\n * @param {workbox.routing.Route} route The route to unregister.\n */\n unregisterRoute(route) {\n if (!this._routes.has(route.method)) {\n throw new WorkboxError(\n 'unregister-route-but-not-found-with-method', {\n method: route.method,\n }\n );\n }\n\n const routeIndex = this._routes.get(route.method).indexOf(route);\n if (routeIndex > -1) {\n this._routes.get(route.method).splice(routeIndex, 1);\n } else {\n throw new WorkboxError('unregister-route-route-not-registered');\n }\n }\n}\n\nexport {Router};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {Router} from '../Router.mjs';\nimport '../_version.mjs';\n\nlet defaultRouter;\n\n/**\n * Creates a new, singleton Router instance if one does not exist. If one\n * does already exist, that instance is returned.\n *\n * @private\n * @return {Router}\n */\nexport const getOrCreateDefaultRouter = () => {\n if (!defaultRouter) {\n defaultRouter = new Router();\n\n // The helpers that use the default Router assume these listeners exist.\n defaultRouter.addFetchListener();\n defaultRouter.addCacheListener();\n }\n return defaultRouter;\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {cacheNames} from 'workbox-core/_private/cacheNames.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {NavigationRoute} from './NavigationRoute.mjs';\nimport {getOrCreateDefaultRouter} from './utils/getOrCreateDefaultRouter.mjs';\nimport './_version.mjs';\n\n\n/**\n * Registers a route that will return a precached file for a navigation\n * request. This is useful for the\n * [application shell pattern]{@link https://developers.google.com/web/fundamentals/architecture/app-shell}.\n *\n * When determining the URL of the precached HTML document, you will likely need\n * to call `workbox.precaching.getCacheKeyForURL(originalUrl)`, to account for\n * the fact that Workbox's precaching naming conventions often results in URL\n * cache keys that contain extra revisioning info.\n *\n * This method will generate a\n * [NavigationRoute]{@link workbox.routing.NavigationRoute}\n * and call\n * [Router.registerRoute()]{@link workbox.routing.Router#registerRoute} on a\n * singleton Router instance.\n *\n * @param {string} cachedAssetUrl The cache key to use for the HTML file.\n * @param {Object} [options]\n * @param {string} [options.cacheName] Cache name to store and retrieve\n * requests. Defaults to precache cache name provided by\n * [workbox-core.cacheNames]{@link workbox.core.cacheNames}.\n * @param {Array} [options.blacklist=[]] If any of these patterns\n * match, the route will not handle the request (even if a whitelist entry\n * matches).\n * @param {Array} [options.whitelist=[/./]] If any of these patterns\n * match the URL's pathname and search parameter, the route will handle the\n * request (assuming the blacklist doesn't match).\n * @return {workbox.routing.NavigationRoute} Returns the generated\n * Route.\n *\n * @alias workbox.routing.registerNavigationRoute\n */\nexport const registerNavigationRoute = (cachedAssetUrl, options = {}) => {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(cachedAssetUrl, 'string', {\n moduleName: 'workbox-routing',\n funcName: 'registerNavigationRoute',\n paramName: 'cachedAssetUrl',\n });\n }\n\n const cacheName = cacheNames.getPrecacheName(options.cacheName);\n const handler = async () => {\n try {\n const response = await caches.match(cachedAssetUrl, {cacheName});\n\n if (response) {\n return response;\n }\n\n // This shouldn't normally happen, but there are edge cases:\n // https://github.com/GoogleChrome/workbox/issues/1441\n throw new Error(`The cache ${cacheName} did not have an entry for ` +\n `${cachedAssetUrl}.`);\n } catch (error) {\n // If there's either a cache miss, or the caches.match() call threw\n // an exception, then attempt to fulfill the navigation request with\n // a response from the network rather than leaving the user with a\n // failed navigation.\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(`Unable to respond to navigation request with ` +\n `cached response. Falling back to network.`, error);\n }\n\n // This might still fail if the browser is offline...\n return fetch(cachedAssetUrl);\n }\n };\n\n const route = new NavigationRoute(handler, {\n whitelist: options.whitelist,\n blacklist: options.blacklist,\n });\n\n const defaultRouter = getOrCreateDefaultRouter();\n defaultRouter.registerRoute(route);\n\n return route;\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\nimport {Route} from './Route.mjs';\nimport {RegExpRoute} from './RegExpRoute.mjs';\nimport {getOrCreateDefaultRouter} from './utils/getOrCreateDefaultRouter.mjs';\nimport './_version.mjs';\n\n\n/**\n * Easily register a RegExp, string, or function with a caching\n * strategy to a singleton Router instance.\n *\n * This method will generate a Route for you if needed and\n * call [Router.registerRoute()]{@link\n * workbox.routing.Router#registerRoute}.\n *\n * @param {\n * RegExp|\n * string|\n * workbox.routing.Route~matchCallback|\n * workbox.routing.Route\n * } capture\n * If the capture param is a `Route`, all other arguments will be ignored.\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resulting in a Response.\n * @param {string} [method='GET'] The HTTP method to match the Route\n * against.\n * @return {workbox.routing.Route} The generated `Route`(Useful for\n * unregistering).\n *\n * @alias workbox.routing.registerRoute\n */\nexport const registerRoute = (capture, handler, method = 'GET') => {\n let route;\n\n if (typeof capture === 'string') {\n const captureUrl = new URL(capture, location);\n\n if (process.env.NODE_ENV !== 'production') {\n if (!(capture.startsWith('/') || capture.startsWith('http'))) {\n throw new WorkboxError('invalid-string', {\n moduleName: 'workbox-routing',\n funcName: 'registerRoute',\n paramName: 'capture',\n });\n }\n\n // We want to check if Express-style wildcards are in the pathname only.\n // TODO: Remove this log message in v4.\n const valueToCheck = capture.startsWith('http') ?\n captureUrl.pathname : capture;\n\n // See https://github.com/pillarjs/path-to-regexp#parameters\n const wildcards = '[*:?+]';\n if (valueToCheck.match(new RegExp(`${wildcards}`))) {\n logger.debug(\n `The '$capture' parameter contains an Express-style wildcard ` +\n `character (${wildcards}). Strings are now always interpreted as ` +\n `exact matches; use a RegExp for partial or wildcard matches.`\n );\n }\n }\n\n const matchCallback = ({url}) => {\n if (process.env.NODE_ENV !== 'production') {\n if ((url.pathname === captureUrl.pathname) &&\n (url.origin !== captureUrl.origin)) {\n logger.debug(\n `${capture} only partially matches the cross-origin URL ` +\n `${url}. This route will only handle cross-origin requests ` +\n `if they match the entire URL.`);\n }\n }\n\n return url.href === captureUrl.href;\n };\n\n route = new Route(matchCallback, handler, method);\n } else if (capture instanceof RegExp) {\n route = new RegExpRoute(capture, handler, method);\n } else if (typeof capture === 'function') {\n route = new Route(capture, handler, method);\n } else if (capture instanceof Route) {\n route = capture;\n } else {\n throw new WorkboxError('unsupported-route-type', {\n moduleName: 'workbox-routing',\n funcName: 'registerRoute',\n paramName: 'capture',\n });\n }\n\n const defaultRouter = getOrCreateDefaultRouter();\n defaultRouter.registerRoute(route);\n\n return route;\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {getOrCreateDefaultRouter} from './utils/getOrCreateDefaultRouter.mjs';\n\nimport './_version.mjs';\n\n/**\n * If a Route throws an error while handling a request, this `handler`\n * will be called and given a chance to provide a response.\n *\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resulting in a Response.\n *\n * @alias workbox.routing.setCatchHandler\n */\nexport const setCatchHandler = (handler) => {\n const defaultRouter = getOrCreateDefaultRouter();\n defaultRouter.setCatchHandler(handler);\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {getOrCreateDefaultRouter} from './utils/getOrCreateDefaultRouter.mjs';\n\nimport './_version.mjs';\n\n/**\n * Define a default `handler` that's called when no routes explicitly\n * match the incoming request.\n *\n * Without a default handler, unmatched requests will go against the\n * network as if there were no service worker present.\n *\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resulting in a Response.\n *\n * @alias workbox.routing.setDefaultHandler\n */\nexport const setDefaultHandler = (handler) => {\n const defaultRouter = getOrCreateDefaultRouter();\n defaultRouter.setDefaultHandler(handler);\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\n\nimport {NavigationRoute} from './NavigationRoute.mjs';\nimport {RegExpRoute} from './RegExpRoute.mjs';\nimport {registerNavigationRoute} from './registerNavigationRoute.mjs';\nimport {registerRoute} from './registerRoute.mjs';\nimport {Route} from './Route.mjs';\nimport {Router} from './Router.mjs';\nimport {setCatchHandler} from './setCatchHandler.mjs';\nimport {setDefaultHandler} from './setDefaultHandler.mjs';\n\nimport './_version.mjs';\n\nif (process.env.NODE_ENV !== 'production') {\n assert.isSWEnv('workbox-routing');\n}\n\n/**\n * @namespace workbox.routing\n */\n\nexport {\n NavigationRoute,\n RegExpRoute,\n registerNavigationRoute,\n registerRoute,\n Route,\n Router,\n setCatchHandler,\n setDefaultHandler,\n};\n"],"names":["self","_","e","defaultMethod","validMethods","normalizeHandler","handler","assert","hasMethod","moduleName","className","funcName","paramName","isType","handle","Route","constructor","match","method","isOneOf","NavigationRoute","whitelist","blacklist","isArrayOfClass","RegExp","options","_match","_whitelist","_blacklist","url","request","mode","pathnameAndSearch","pathname","search","regExp","test","logger","log","some","debug","RegExpRoute","isInstance","result","exec","href","origin","location","index","slice","Router","_routes","Map","routes","addFetchListener","addEventListener","event","responsePromise","handleRequest","respondWith","addCacheListener","data","type","payload","urlsToCache","requestPromises","Promise","all","map","entry","Request","waitUntil","ports","postMessage","URL","protocol","startsWith","params","route","findMatchingRoute","debugMessages","push","_defaultHandler","getFriendlyURL","groupCollapsed","forEach","msg","Array","isArray","groupEnd","err","reject","_catchHandler","catch","error","get","matchResult","length","Object","keys","setDefaultHandler","setCatchHandler","registerRoute","has","set","unregisterRoute","WorkboxError","routeIndex","indexOf","splice","defaultRouter","getOrCreateDefaultRouter","registerNavigationRoute","cachedAssetUrl","cacheName","cacheNames","getPrecacheName","response","caches","Error","fetch","capture","captureUrl","valueToCheck","wildcards","matchCallback","isSWEnv"],"mappings":";;;;EAAA,IAAG;EAACA,EAAAA,IAAI,CAAC,uBAAD,CAAJ,IAA+BC,CAAC,EAAhC;EAAmC,CAAvC,CAAuC,OAAMC,CAAN,EAAQ;;ECA/C;;;;;;;AAQA,EAEA;;;;;;;;;AAQA,EAAO,MAAMC,aAAa,GAAG,KAAtB;EAEP;;;;;;;;AAOA,EAAO,MAAMC,YAAY,GAAG,CAC1B,QAD0B,EAE1B,KAF0B,EAG1B,MAH0B,EAI1B,OAJ0B,EAK1B,MAL0B,EAM1B,KAN0B,CAArB;;EC3BP;;;;;;;AAQA,EAGA;;;;;;;;AAOA,EAAO,MAAMC,gBAAgB,GAAIC,OAAD,IAAa;EAC3C,MAAIA,OAAO,IAAI,OAAOA,OAAP,KAAmB,QAAlC,EAA4C;EAC1C,IAA2C;EACzCC,MAAAA,iBAAM,CAACC,SAAP,CAAiBF,OAAjB,EAA0B,QAA1B,EAAoC;EAClCG,QAAAA,UAAU,EAAE,iBADsB;EAElCC,QAAAA,SAAS,EAAE,OAFuB;EAGlCC,QAAAA,QAAQ,EAAE,aAHwB;EAIlCC,QAAAA,SAAS,EAAE;EAJuB,OAApC;EAMD;;EACD,WAAON,OAAP;EACD,GAVD,MAUO;EACL,IAA2C;EACzCC,MAAAA,iBAAM,CAACM,MAAP,CAAcP,OAAd,EAAuB,UAAvB,EAAmC;EACjCG,QAAAA,UAAU,EAAE,iBADqB;EAEjCC,QAAAA,SAAS,EAAE,OAFsB;EAGjCC,QAAAA,QAAQ,EAAE,aAHuB;EAIjCC,QAAAA,SAAS,EAAE;EAJsB,OAAnC;EAMD;;EACD,WAAO;EAACE,MAAAA,MAAM,EAAER;EAAT,KAAP;EACD;EACF,CAtBM;;EClBP;;;;;;;AAQA,EAMA;;;;;;;;;;EASA,MAAMS,KAAN,CAAY;EACV;;;;;;;;;;;EAWAC,EAAAA,WAAW,CAACC,KAAD,EAAQX,OAAR,EAAiBY,MAAjB,EAAyB;EAClC,IAA2C;EACzCX,MAAAA,iBAAM,CAACM,MAAP,CAAcI,KAAd,EAAqB,UAArB,EAAiC;EAC/BR,QAAAA,UAAU,EAAE,iBADmB;EAE/BC,QAAAA,SAAS,EAAE,OAFoB;EAG/BC,QAAAA,QAAQ,EAAE,aAHqB;EAI/BC,QAAAA,SAAS,EAAE;EAJoB,OAAjC;;EAOA,UAAIM,MAAJ,EAAY;EACVX,QAAAA,iBAAM,CAACY,OAAP,CAAeD,MAAf,EAAuBd,YAAvB,EAAqC;EAACQ,UAAAA,SAAS,EAAE;EAAZ,SAArC;EACD;EACF,KAZiC;EAelC;;;EACA,SAAKN,OAAL,GAAeD,gBAAgB,CAACC,OAAD,CAA/B;EACA,SAAKW,KAAL,GAAaA,KAAb;EACA,SAAKC,MAAL,GAAcA,MAAM,IAAIf,aAAxB;EACD;;EA/BS;;ECvBZ;;;;;;;AAQA,EAKA;;;;;;;;;;;;;;;;EAeA,MAAMiB,eAAN,SAA8BL,KAA9B,CAAoC;EAClC;;;;;;;;;;;;;;;;;;;EAmBAC,EAAAA,WAAW,CAACV,OAAD,EAAU;EAACe,IAAAA,SAAS,GAAG,CAAC,GAAD,CAAb;EAAoBC,IAAAA,SAAS,GAAG;EAAhC,MAAsC,EAAhD,EAAoD;EAC7D,IAA2C;EACzCf,MAAAA,iBAAM,CAACgB,cAAP,CAAsBF,SAAtB,EAAiCG,MAAjC,EAAyC;EACvCf,QAAAA,UAAU,EAAE,iBAD2B;EAEvCC,QAAAA,SAAS,EAAE,iBAF4B;EAGvCC,QAAAA,QAAQ,EAAE,aAH6B;EAIvCC,QAAAA,SAAS,EAAE;EAJ4B,OAAzC;EAMAL,MAAAA,iBAAM,CAACgB,cAAP,CAAsBD,SAAtB,EAAiCE,MAAjC,EAAyC;EACvCf,QAAAA,UAAU,EAAE,iBAD2B;EAEvCC,QAAAA,SAAS,EAAE,iBAF4B;EAGvCC,QAAAA,QAAQ,EAAE,aAH6B;EAIvCC,QAAAA,SAAS,EAAE;EAJ4B,OAAzC;EAMD;;EAED,UAAOa,OAAD,IAAa,KAAKC,MAAL,CAAYD,OAAZ,CAAnB,EAAyCnB,OAAzC;EAEA,SAAKqB,UAAL,GAAkBN,SAAlB;EACA,SAAKO,UAAL,GAAkBN,SAAlB;EACD;EAED;;;;;;;;;;;;EAUAI,EAAAA,MAAM,CAAC;EAACG,IAAAA,GAAD;EAAMC,IAAAA;EAAN,GAAD,EAAiB;EACrB,QAAIA,OAAO,CAACC,IAAR,KAAiB,UAArB,EAAiC;EAC/B,aAAO,KAAP;EACD;;EAED,UAAMC,iBAAiB,GAAGH,GAAG,CAACI,QAAJ,GAAeJ,GAAG,CAACK,MAA7C;;EAEA,SAAK,MAAMC,MAAX,IAAqB,KAAKP,UAA1B,EAAsC;EACpC,UAAIO,MAAM,CAACC,IAAP,CAAYJ,iBAAZ,CAAJ,EAAoC;EAClC,QAA2C;EACzCK,UAAAA,iBAAM,CAACC,GAAP,CAAY,oDAAD,GACN,uCAAsCH,MAAO,EADlD;EAED;;EACD,eAAO,KAAP;EACD;EACF;;EAED,QAAI,KAAKR,UAAL,CAAgBY,IAAhB,CAAsBJ,MAAD,IAAYA,MAAM,CAACC,IAAP,CAAYJ,iBAAZ,CAAjC,CAAJ,EAAsE;EACpE,MAA2C;EACzCK,QAAAA,iBAAM,CAACG,KAAP,CAAc,qCAAd;EACD;;EACD,aAAO,IAAP;EACD;;EAED,IAA2C;EACzCH,MAAAA,iBAAM,CAACC,GAAP,CAAY,wDAAD,GACN,iDADL;EAED;;EACD,WAAO,KAAP;EACD;;EAjFiC;;EC5BpC;;;;;;;AAQA,EAKA;;;;;;;;;;;;;;EAaA,MAAMG,WAAN,SAA0B1B,KAA1B,CAAgC;EAC9B;;;;;;;;;;;;;EAaAC,EAAAA,WAAW,CAACmB,MAAD,EAAS7B,OAAT,EAAkBY,MAAlB,EAA0B;EACnC,IAA2C;EACzCX,MAAAA,iBAAM,CAACmC,UAAP,CAAkBP,MAAlB,EAA0BX,MAA1B,EAAkC;EAChCf,QAAAA,UAAU,EAAE,iBADoB;EAEhCC,QAAAA,SAAS,EAAE,aAFqB;EAGhCC,QAAAA,QAAQ,EAAE,aAHsB;EAIhCC,QAAAA,SAAS,EAAE;EAJqB,OAAlC;EAMD;;EAED,UAAMK,KAAK,GAAG,CAAC;EAACY,MAAAA;EAAD,KAAD,KAAW;EACvB,YAAMc,MAAM,GAAGR,MAAM,CAACS,IAAP,CAAYf,GAAG,CAACgB,IAAhB,CAAf,CADuB;;EAIvB,UAAI,CAACF,MAAL,EAAa;EACX,eAAO,IAAP;EACD,OANsB;EASvB;EACA;EACA;;;EACA,UAAKd,GAAG,CAACiB,MAAJ,KAAeC,QAAQ,CAACD,MAAzB,IAAqCH,MAAM,CAACK,KAAP,KAAiB,CAA1D,EAA8D;EAC5D,QAA2C;EACzCX,UAAAA,iBAAM,CAACG,KAAP,CACK,2BAA0BL,MAAO,2BAAlC,GACD,iCAAgCN,GAAI,6BADnC,GAED,4DAHH;EAKD;;EAED,eAAO,IAAP;EACD,OAtBsB;EAyBvB;EACA;EACA;;;EACA,aAAOc,MAAM,CAACM,KAAP,CAAa,CAAb,CAAP;EACD,KA7BD;;EA+BA,UAAMhC,KAAN,EAAaX,OAAb,EAAsBY,MAAtB;EACD;;EAxD6B;;EC1BhC;;;;;;;AAQA,EAQA;;;;;;;;;;;;;;;;;;EAiBA,MAAMgC,MAAN,CAAa;EACX;;;EAGAlC,EAAAA,WAAW,GAAG;EACZ,SAAKmC,OAAL,GAAe,IAAIC,GAAJ,EAAf;EACD;EAED;;;;;;;EAKA,MAAIC,MAAJ,GAAa;EACX,WAAO,KAAKF,OAAZ;EACD;EAED;;;;;;EAIAG,EAAAA,gBAAgB,GAAG;EACjBtD,IAAAA,IAAI,CAACuD,gBAAL,CAAsB,OAAtB,EAAgCC,KAAD,IAAW;EACxC,YAAM;EAAC1B,QAAAA;EAAD,UAAY0B,KAAlB;EACA,YAAMC,eAAe,GAAG,KAAKC,aAAL,CAAmB;EAAC5B,QAAAA,OAAD;EAAU0B,QAAAA;EAAV,OAAnB,CAAxB;;EACA,UAAIC,eAAJ,EAAqB;EACnBD,QAAAA,KAAK,CAACG,WAAN,CAAkBF,eAAlB;EACD;EACF,KAND;EAOD;EAED;;;;;;;;;;;;;;;;;;;;;;;;EAsBAG,EAAAA,gBAAgB,GAAG;EACjB5D,IAAAA,IAAI,CAACuD,gBAAL,CAAsB,SAAtB,EAAiC,MAAOC,KAAP,IAAiB;EAChD,UAAIA,KAAK,CAACK,IAAN,IAAcL,KAAK,CAACK,IAAN,CAAWC,IAAX,KAAoB,YAAtC,EAAoD;EAClD,cAAM;EAACC,UAAAA;EAAD,YAAYP,KAAK,CAACK,IAAxB;;EAEA,QAA2C;EACzCxB,UAAAA,iBAAM,CAACG,KAAP,CAAc,8BAAd,EAA6CuB,OAAO,CAACC,WAArD;EACD;;EAED,cAAMC,eAAe,GAAGC,OAAO,CAACC,GAAR,CAAYJ,OAAO,CAACC,WAAR,CAAoBI,GAApB,CAAyBC,KAAD,IAAW;EACrE,cAAI,OAAOA,KAAP,KAAiB,QAArB,EAA+B;EAC7BA,YAAAA,KAAK,GAAG,CAACA,KAAD,CAAR;EACD;;EAED,gBAAMvC,OAAO,GAAG,IAAIwC,OAAJ,CAAY,GAAGD,KAAf,CAAhB;EACA,iBAAO,KAAKX,aAAL,CAAmB;EAAC5B,YAAAA;EAAD,WAAnB,CAAP;EACD,SAPmC,CAAZ,CAAxB;EASA0B,QAAAA,KAAK,CAACe,SAAN,CAAgBN,eAAhB,EAhBkD;;EAmBlD,YAAIT,KAAK,CAACgB,KAAN,IAAehB,KAAK,CAACgB,KAAN,CAAY,CAAZ,CAAnB,EAAmC;EACjC,gBAAMP,eAAN;EACAT,UAAAA,KAAK,CAACgB,KAAN,CAAY,CAAZ,EAAeC,WAAf,CAA2B,IAA3B;EACD;EACF;EACF,KAzBD;EA0BD;EAED;;;;;;;;;;;;;;;EAaAf,EAAAA,aAAa,CAAC;EAAC5B,IAAAA,OAAD;EAAU0B,IAAAA;EAAV,GAAD,EAAmB;EAC9B,IAA2C;EACzCjD,MAAAA,iBAAM,CAACmC,UAAP,CAAkBZ,OAAlB,EAA2BwC,OAA3B,EAAoC;EAClC7D,QAAAA,UAAU,EAAE,iBADsB;EAElCC,QAAAA,SAAS,EAAE,QAFuB;EAGlCC,QAAAA,QAAQ,EAAE,eAHwB;EAIlCC,QAAAA,SAAS,EAAE;EAJuB,OAApC;EAMD;;EAED,UAAMiB,GAAG,GAAG,IAAI6C,GAAJ,CAAQ5C,OAAO,CAACD,GAAhB,EAAqBkB,QAArB,CAAZ;;EACA,QAAI,CAAClB,GAAG,CAAC8C,QAAJ,CAAaC,UAAb,CAAwB,MAAxB,CAAL,EAAsC;EACpC,MAA2C;EACzCvC,QAAAA,iBAAM,CAACG,KAAP,CACK,2DADL;EAED;;EACD;EACD;;EAED,QAAI;EAACqC,MAAAA,MAAD;EAASC,MAAAA;EAAT,QAAkB,KAAKC,iBAAL,CAAuB;EAAClD,MAAAA,GAAD;EAAMC,MAAAA,OAAN;EAAe0B,MAAAA;EAAf,KAAvB,CAAtB;EACA,QAAIlD,OAAO,GAAGwE,KAAK,IAAIA,KAAK,CAACxE,OAA7B;EAEA,QAAI0E,aAAa,GAAG,EAApB;;EACA,IAA2C;EACzC,UAAI1E,OAAJ,EAAa;EACX0E,QAAAA,aAAa,CAACC,IAAd,CAAmB,CAChB,uCADgB,EACwBH,KADxB,CAAnB;;EAIA,YAAID,MAAJ,EAAY;EACVG,UAAAA,aAAa,CAACC,IAAd,CAAmB,CAChB,sDADgB,EACuCJ,MADvC,CAAnB;EAGD;EACF;EACF,KAnC6B;EAsC9B;;;EACA,QAAI,CAACvE,OAAD,IAAY,KAAK4E,eAArB,EAAsC;EACpC,MAA2C;EACzCF,QAAAA,aAAa,CAACC,IAAd,CAAoB,2CAAD,GAChB,8BADH,EADyC;;EAKzCH,QAAAA,KAAK,GAAG,mBAAR;EACD;;EACDxE,MAAAA,OAAO,GAAG,KAAK4E,eAAf;EACD;;EAED,QAAI,CAAC5E,OAAL,EAAc;EACZ,MAA2C;EACzC;EACA;EACA+B,QAAAA,iBAAM,CAACG,KAAP,CAAc,uBAAsB2C,iCAAc,CAACtD,GAAD,CAAM,EAAxD;EACD;;EACD;EACD;;EAED,IAA2C;EACzC;EACA;EACAQ,MAAAA,iBAAM,CAAC+C,cAAP,CAAuB,4BAA2BD,iCAAc,CAACtD,GAAD,CAAM,EAAtE;EACAmD,MAAAA,aAAa,CAACK,OAAd,CAAuBC,GAAD,IAAS;EAC7B,YAAIC,KAAK,CAACC,OAAN,CAAcF,GAAd,CAAJ,EAAwB;EACtBjD,UAAAA,iBAAM,CAACC,GAAP,CAAW,GAAGgD,GAAd;EACD,SAFD,MAEO;EACLjD,UAAAA,iBAAM,CAACC,GAAP,CAAWgD,GAAX;EACD;EACF,OAND,EAJyC;EAazC;;EACAjD,MAAAA,iBAAM,CAAC+C,cAAP,CAAuB,4BAAvB;EACA/C,MAAAA,iBAAM,CAACC,GAAP,CAAWR,OAAX;EACAO,MAAAA,iBAAM,CAACoD,QAAP;EAEApD,MAAAA,iBAAM,CAACoD,QAAP;EACD,KA9E6B;EAiF9B;;;EACA,QAAIhC,eAAJ;;EACA,QAAI;EACFA,MAAAA,eAAe,GAAGnD,OAAO,CAACQ,MAAR,CAAe;EAACe,QAAAA,GAAD;EAAMC,QAAAA,OAAN;EAAe0B,QAAAA,KAAf;EAAsBqB,QAAAA;EAAtB,OAAf,CAAlB;EACD,KAFD,CAEE,OAAOa,GAAP,EAAY;EACZjC,MAAAA,eAAe,GAAGS,OAAO,CAACyB,MAAR,CAAeD,GAAf,CAAlB;EACD;;EAED,QAAIjC,eAAe,IAAI,KAAKmC,aAA5B,EAA2C;EACzCnC,MAAAA,eAAe,GAAGA,eAAe,CAACoC,KAAhB,CAAuBH,GAAD,IAAS;EAC/C,QAA2C;EACzC;EACA;EACArD,UAAAA,iBAAM,CAAC+C,cAAP,CAAuB,mCAAD,GACnB,IAAGD,iCAAc,CAACtD,GAAD,CAAM,kCAD1B;EAEAQ,UAAAA,iBAAM,CAACyD,KAAP,CAAc,kBAAd,EAAiChB,KAAjC;EACAzC,UAAAA,iBAAM,CAACyD,KAAP,CAAaJ,GAAb;EACArD,UAAAA,iBAAM,CAACoD,QAAP;EACD;;EACD,eAAO,KAAKG,aAAL,CAAmB9E,MAAnB,CAA0B;EAACe,UAAAA,GAAD;EAAM2B,UAAAA,KAAN;EAAakC,UAAAA;EAAb,SAA1B,CAAP;EACD,OAXiB,CAAlB;EAYD;;EAED,WAAOjC,eAAP;EACD;EAED;;;;;;;;;;;;;;;EAaAsB,EAAAA,iBAAiB,CAAC;EAAClD,IAAAA,GAAD;EAAMC,IAAAA,OAAN;EAAe0B,IAAAA;EAAf,GAAD,EAAwB;EACvC,IAA2C;EACzCjD,MAAAA,iBAAM,CAACmC,UAAP,CAAkBb,GAAlB,EAAuB6C,GAAvB,EAA4B;EAC1BjE,QAAAA,UAAU,EAAE,iBADc;EAE1BC,QAAAA,SAAS,EAAE,QAFe;EAG1BC,QAAAA,QAAQ,EAAE,mBAHgB;EAI1BC,QAAAA,SAAS,EAAE;EAJe,OAA5B;EAMAL,MAAAA,iBAAM,CAACmC,UAAP,CAAkBZ,OAAlB,EAA2BwC,OAA3B,EAAoC;EAClC7D,QAAAA,UAAU,EAAE,iBADsB;EAElCC,QAAAA,SAAS,EAAE,QAFuB;EAGlCC,QAAAA,QAAQ,EAAE,mBAHwB;EAIlCC,QAAAA,SAAS,EAAE;EAJuB,OAApC;EAMD;;EAED,UAAMyC,MAAM,GAAG,KAAKF,OAAL,CAAa4C,GAAb,CAAiBjE,OAAO,CAACZ,MAAzB,KAAoC,EAAnD;;EACA,SAAK,MAAM4D,KAAX,IAAoBzB,MAApB,EAA4B;EAC1B,UAAIwB,MAAJ;EACA,UAAImB,WAAW,GAAGlB,KAAK,CAAC7D,KAAN,CAAY;EAACY,QAAAA,GAAD;EAAMC,QAAAA,OAAN;EAAe0B,QAAAA;EAAf,OAAZ,CAAlB;;EACA,UAAIwC,WAAJ,EAAiB;EACf,YAAIT,KAAK,CAACC,OAAN,CAAcQ,WAAd,KAA8BA,WAAW,CAACC,MAAZ,GAAqB,CAAvD,EAA0D;EACxD;EACApB,UAAAA,MAAM,GAAGmB,WAAT;EACD,SAHD,MAGO,IAAKA,WAAW,CAAChF,WAAZ,KAA4BkF,MAA5B,IACRA,MAAM,CAACC,IAAP,CAAYH,WAAZ,EAAyBC,MAAzB,GAAkC,CAD/B,EACmC;EACxC;EACApB,UAAAA,MAAM,GAAGmB,WAAT;EACD,SARc;;;EAWf,eAAO;EAAClB,UAAAA,KAAD;EAAQD,UAAAA;EAAR,SAAP;EACD;EACF,KAjCsC;;;EAmCvC,WAAO,EAAP;EACD;EAED;;;;;;;;;;;;EAUAuB,EAAAA,iBAAiB,CAAC9F,OAAD,EAAU;EACzB,SAAK4E,eAAL,GAAuB7E,gBAAgB,CAACC,OAAD,CAAvC;EACD;EAED;;;;;;;;;EAOA+F,EAAAA,eAAe,CAAC/F,OAAD,EAAU;EACvB,SAAKsF,aAAL,GAAqBvF,gBAAgB,CAACC,OAAD,CAArC;EACD;EAED;;;;;;;EAKAgG,EAAAA,aAAa,CAACxB,KAAD,EAAQ;EACnB,IAA2C;EACzCvE,MAAAA,iBAAM,CAACM,MAAP,CAAciE,KAAd,EAAqB,QAArB,EAA+B;EAC7BrE,QAAAA,UAAU,EAAE,iBADiB;EAE7BC,QAAAA,SAAS,EAAE,QAFkB;EAG7BC,QAAAA,QAAQ,EAAE,eAHmB;EAI7BC,QAAAA,SAAS,EAAE;EAJkB,OAA/B;EAOAL,MAAAA,iBAAM,CAACC,SAAP,CAAiBsE,KAAjB,EAAwB,OAAxB,EAAiC;EAC/BrE,QAAAA,UAAU,EAAE,iBADmB;EAE/BC,QAAAA,SAAS,EAAE,QAFoB;EAG/BC,QAAAA,QAAQ,EAAE,eAHqB;EAI/BC,QAAAA,SAAS,EAAE;EAJoB,OAAjC;EAOAL,MAAAA,iBAAM,CAACM,MAAP,CAAciE,KAAK,CAACxE,OAApB,EAA6B,QAA7B,EAAuC;EACrCG,QAAAA,UAAU,EAAE,iBADyB;EAErCC,QAAAA,SAAS,EAAE,QAF0B;EAGrCC,QAAAA,QAAQ,EAAE,eAH2B;EAIrCC,QAAAA,SAAS,EAAE;EAJ0B,OAAvC;EAOAL,MAAAA,iBAAM,CAACC,SAAP,CAAiBsE,KAAK,CAACxE,OAAvB,EAAgC,QAAhC,EAA0C;EACxCG,QAAAA,UAAU,EAAE,iBAD4B;EAExCC,QAAAA,SAAS,EAAE,QAF6B;EAGxCC,QAAAA,QAAQ,EAAE,eAH8B;EAIxCC,QAAAA,SAAS,EAAE;EAJ6B,OAA1C;EAOAL,MAAAA,iBAAM,CAACM,MAAP,CAAciE,KAAK,CAAC5D,MAApB,EAA4B,QAA5B,EAAsC;EACpCT,QAAAA,UAAU,EAAE,iBADwB;EAEpCC,QAAAA,SAAS,EAAE,QAFyB;EAGpCC,QAAAA,QAAQ,EAAE,eAH0B;EAIpCC,QAAAA,SAAS,EAAE;EAJyB,OAAtC;EAMD;;EAED,QAAI,CAAC,KAAKuC,OAAL,CAAaoD,GAAb,CAAiBzB,KAAK,CAAC5D,MAAvB,CAAL,EAAqC;EACnC,WAAKiC,OAAL,CAAaqD,GAAb,CAAiB1B,KAAK,CAAC5D,MAAvB,EAA+B,EAA/B;EACD,KAxCkB;EA2CnB;;;EACA,SAAKiC,OAAL,CAAa4C,GAAb,CAAiBjB,KAAK,CAAC5D,MAAvB,EAA+B+D,IAA/B,CAAoCH,KAApC;EACD;EAED;;;;;;;EAKA2B,EAAAA,eAAe,CAAC3B,KAAD,EAAQ;EACrB,QAAI,CAAC,KAAK3B,OAAL,CAAaoD,GAAb,CAAiBzB,KAAK,CAAC5D,MAAvB,CAAL,EAAqC;EACnC,YAAM,IAAIwF,6BAAJ,CACF,4CADE,EAC4C;EAC5CxF,QAAAA,MAAM,EAAE4D,KAAK,CAAC5D;EAD8B,OAD5C,CAAN;EAKD;;EAED,UAAMyF,UAAU,GAAG,KAAKxD,OAAL,CAAa4C,GAAb,CAAiBjB,KAAK,CAAC5D,MAAvB,EAA+B0F,OAA/B,CAAuC9B,KAAvC,CAAnB;;EACA,QAAI6B,UAAU,GAAG,CAAC,CAAlB,EAAqB;EACnB,WAAKxD,OAAL,CAAa4C,GAAb,CAAiBjB,KAAK,CAAC5D,MAAvB,EAA+B2F,MAA/B,CAAsCF,UAAtC,EAAkD,CAAlD;EACD,KAFD,MAEO;EACL,YAAM,IAAID,6BAAJ,CAAiB,uCAAjB,CAAN;EACD;EACF;;EA9VU;;ECjCb;;;;;;;AAQA,EAGA,IAAII,aAAJ;EAEA;;;;;;;;AAOA,EAAO,MAAMC,wBAAwB,GAAG,MAAM;EAC5C,MAAI,CAACD,aAAL,EAAoB;EAClBA,IAAAA,aAAa,GAAG,IAAI5D,MAAJ,EAAhB,CADkB;;EAIlB4D,IAAAA,aAAa,CAACxD,gBAAd;EACAwD,IAAAA,aAAa,CAAClD,gBAAd;EACD;;EACD,SAAOkD,aAAP;EACD,CATM;;ECpBP;;;;;;;AAQA,EAQA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,QAAaE,uBAAuB,GAAG,CAACC,cAAD,EAAiBxF,OAAO,GAAG,EAA3B,KAAkC;EACvE,EAA2C;EACzClB,IAAAA,iBAAM,CAACM,MAAP,CAAcoG,cAAd,EAA8B,QAA9B,EAAwC;EACtCxG,MAAAA,UAAU,EAAE,iBAD0B;EAEtCE,MAAAA,QAAQ,EAAE,yBAF4B;EAGtCC,MAAAA,SAAS,EAAE;EAH2B,KAAxC;EAKD;;EAED,QAAMsG,SAAS,GAAGC,yBAAU,CAACC,eAAX,CAA2B3F,OAAO,CAACyF,SAAnC,CAAlB;;EACA,QAAM5G,OAAO,GAAG,YAAY;EAC1B,QAAI;EACF,YAAM+G,QAAQ,GAAG,MAAMC,MAAM,CAACrG,KAAP,CAAagG,cAAb,EAA6B;EAACC,QAAAA;EAAD,OAA7B,CAAvB;;EAEA,UAAIG,QAAJ,EAAc;EACZ,eAAOA,QAAP;EACD,OALC;EAQF;;;EACA,YAAM,IAAIE,KAAJ,CAAW,aAAYL,SAAU,6BAAvB,GACX,GAAED,cAAe,GADhB,CAAN;EAED,KAXD,CAWE,OAAOnB,KAAP,EAAc;EACd;EACA;EACA;EACA;EACA,MAA2C;EACzCzD,QAAAA,iBAAM,CAACG,KAAP,CAAc,+CAAD,GACR,2CADL,EACiDsD,KADjD;EAED,OARa;;;EAWd,aAAO0B,KAAK,CAACP,cAAD,CAAZ;EACD;EACF,GAzBD;;EA2BA,QAAMnC,KAAK,GAAG,IAAI1D,eAAJ,CAAoBd,OAApB,EAA6B;EACzCe,IAAAA,SAAS,EAAEI,OAAO,CAACJ,SADsB;EAEzCC,IAAAA,SAAS,EAAEG,OAAO,CAACH;EAFsB,GAA7B,CAAd;EAKA,QAAMwF,aAAa,GAAGC,wBAAwB,EAA9C;EACAD,EAAAA,aAAa,CAACR,aAAd,CAA4BxB,KAA5B;EAEA,SAAOA,KAAP;EACD,CA9CM;;EChDP;;;;;;;AAQA,EAQA;;;;;;;;;;;;;;;;;;;;;;;;;AAwBA,QAAawB,aAAa,GAAG,CAACmB,OAAD,EAAUnH,OAAV,EAAmBY,MAAM,GAAG,KAA5B,KAAsC;EACjE,MAAI4D,KAAJ;;EAEA,MAAI,OAAO2C,OAAP,KAAmB,QAAvB,EAAiC;EAC/B,UAAMC,UAAU,GAAG,IAAIhD,GAAJ,CAAQ+C,OAAR,EAAiB1E,QAAjB,CAAnB;;EAEA,IAA2C;EACzC,UAAI,EAAE0E,OAAO,CAAC7C,UAAR,CAAmB,GAAnB,KAA2B6C,OAAO,CAAC7C,UAAR,CAAmB,MAAnB,CAA7B,CAAJ,EAA8D;EAC5D,cAAM,IAAI8B,6BAAJ,CAAiB,gBAAjB,EAAmC;EACvCjG,UAAAA,UAAU,EAAE,iBAD2B;EAEvCE,UAAAA,QAAQ,EAAE,eAF6B;EAGvCC,UAAAA,SAAS,EAAE;EAH4B,SAAnC,CAAN;EAKD,OAPwC;EAUzC;;;EACA,YAAM+G,YAAY,GAAGF,OAAO,CAAC7C,UAAR,CAAmB,MAAnB,IACjB8C,UAAU,CAACzF,QADM,GACKwF,OAD1B,CAXyC;;EAezC,YAAMG,SAAS,GAAG,QAAlB;;EACA,UAAID,YAAY,CAAC1G,KAAb,CAAmB,IAAIO,MAAJ,CAAY,GAAEoG,SAAU,EAAxB,CAAnB,CAAJ,EAAoD;EAClDvF,QAAAA,iBAAM,CAACG,KAAP,CACK,8DAAD,GACD,cAAaoF,SAAU,2CADtB,GAED,8DAHH;EAKD;EACF;;EAED,UAAMC,aAAa,GAAG,CAAC;EAAChG,MAAAA;EAAD,KAAD,KAAW;EAC/B,MAA2C;EACzC,YAAKA,GAAG,CAACI,QAAJ,KAAiByF,UAAU,CAACzF,QAA7B,IACCJ,GAAG,CAACiB,MAAJ,KAAe4E,UAAU,CAAC5E,MAD/B,EACwC;EACtCT,UAAAA,iBAAM,CAACG,KAAP,CACK,GAAEiF,OAAQ,+CAAX,GACC,GAAE5F,GAAI,sDADP,GAEC,+BAHL;EAID;EACF;;EAED,aAAOA,GAAG,CAACgB,IAAJ,KAAa6E,UAAU,CAAC7E,IAA/B;EACD,KAZD;;EAcAiC,IAAAA,KAAK,GAAG,IAAI/D,KAAJ,CAAU8G,aAAV,EAAyBvH,OAAzB,EAAkCY,MAAlC,CAAR;EACD,GA3CD,MA2CO,IAAIuG,OAAO,YAAYjG,MAAvB,EAA+B;EACpCsD,IAAAA,KAAK,GAAG,IAAIrC,WAAJ,CAAgBgF,OAAhB,EAAyBnH,OAAzB,EAAkCY,MAAlC,CAAR;EACD,GAFM,MAEA,IAAI,OAAOuG,OAAP,KAAmB,UAAvB,EAAmC;EACxC3C,IAAAA,KAAK,GAAG,IAAI/D,KAAJ,CAAU0G,OAAV,EAAmBnH,OAAnB,EAA4BY,MAA5B,CAAR;EACD,GAFM,MAEA,IAAIuG,OAAO,YAAY1G,KAAvB,EAA8B;EACnC+D,IAAAA,KAAK,GAAG2C,OAAR;EACD,GAFM,MAEA;EACL,UAAM,IAAIf,6BAAJ,CAAiB,wBAAjB,EAA2C;EAC/CjG,MAAAA,UAAU,EAAE,iBADmC;EAE/CE,MAAAA,QAAQ,EAAE,eAFqC;EAG/CC,MAAAA,SAAS,EAAE;EAHoC,KAA3C,CAAN;EAKD;;EAED,QAAMkG,aAAa,GAAGC,wBAAwB,EAA9C;EACAD,EAAAA,aAAa,CAACR,aAAd,CAA4BxB,KAA5B;EAEA,SAAOA,KAAP;EACD,CAhEM;;ECxCP;;;;;;;AAQA,EAIA;;;;;;;;;;AASA,QAAauB,eAAe,GAAI/F,OAAD,IAAa;EAC1C,QAAMwG,aAAa,GAAGC,wBAAwB,EAA9C;EACAD,EAAAA,aAAa,CAACT,eAAd,CAA8B/F,OAA9B;EACD,CAHM;;ECrBP;;;;;;;AAQA,EAIA;;;;;;;;;;;;;AAYA,QAAa8F,iBAAiB,GAAI9F,OAAD,IAAa;EAC5C,QAAMwG,aAAa,GAAGC,wBAAwB,EAA9C;EACAD,EAAAA,aAAa,CAACV,iBAAd,CAAgC9F,OAAhC;EACD,CAHM;;ECxBP;;;;;;;AAQA;AAaA,EAA2C;EACzCC,EAAAA,iBAAM,CAACuH,OAAP,CAAe,iBAAf;EACD;;;;;;;;;;;;;;;;;"} \ No newline at end of file diff --git a/public/javascripts/workbox/workbox-routing.prod.js b/public/javascripts/workbox/workbox-routing.prod.js new file mode 100644 index 0000000000..ed87f9d141 --- /dev/null +++ b/public/javascripts/workbox/workbox-routing.prod.js @@ -0,0 +1,2 @@ +this.workbox=this.workbox||{},this.workbox.routing=function(t,e,r){"use strict";try{self["workbox:routing:4.3.1"]&&_()}catch(t){}const s="GET",n=t=>t&&"object"==typeof t?t:{handle:t};class o{constructor(t,e,r){this.handler=n(e),this.match=t,this.method=r||s}}class i extends o{constructor(t,{whitelist:e=[/./],blacklist:r=[]}={}){super(t=>this.t(t),t),this.s=e,this.o=r}t({url:t,request:e}){if("navigate"!==e.mode)return!1;const r=t.pathname+t.search;for(const t of this.o)if(t.test(r))return!1;return!!this.s.some(t=>t.test(r))}}class u extends o{constructor(t,e,r){super(({url:e})=>{const r=t.exec(e.href);return r?e.origin!==location.origin&&0!==r.index?null:r.slice(1):null},e,r)}}class c{constructor(){this.i=new Map}get routes(){return this.i}addFetchListener(){self.addEventListener("fetch",t=>{const{request:e}=t,r=this.handleRequest({request:e,event:t});r&&t.respondWith(r)})}addCacheListener(){self.addEventListener("message",async t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,r=Promise.all(e.urlsToCache.map(t=>{"string"==typeof t&&(t=[t]);const e=new Request(...t);return this.handleRequest({request:e})}));t.waitUntil(r),t.ports&&t.ports[0]&&(await r,t.ports[0].postMessage(!0))}})}handleRequest({request:t,event:e}){const r=new URL(t.url,location);if(!r.protocol.startsWith("http"))return;let s,{params:n,route:o}=this.findMatchingRoute({url:r,request:t,event:e}),i=o&&o.handler;if(!i&&this.u&&(i=this.u),i){try{s=i.handle({url:r,request:t,event:e,params:n})}catch(t){s=Promise.reject(t)}return s&&this.h&&(s=s.catch(t=>this.h.handle({url:r,event:e,err:t}))),s}}findMatchingRoute({url:t,request:e,event:r}){const s=this.i.get(e.method)||[];for(const n of s){let s,o=n.match({url:t,request:e,event:r});if(o)return Array.isArray(o)&&o.length>0?s=o:o.constructor===Object&&Object.keys(o).length>0&&(s=o),{route:n,params:s}}return{}}setDefaultHandler(t){this.u=n(t)}setCatchHandler(t){this.h=n(t)}registerRoute(t){this.i.has(t.method)||this.i.set(t.method,[]),this.i.get(t.method).push(t)}unregisterRoute(t){if(!this.i.has(t.method))throw new r.WorkboxError("unregister-route-but-not-found-with-method",{method:t.method});const e=this.i.get(t.method).indexOf(t);if(!(e>-1))throw new r.WorkboxError("unregister-route-route-not-registered");this.i.get(t.method).splice(e,1)}}let a;const h=()=>(a||((a=new c).addFetchListener(),a.addCacheListener()),a);return t.NavigationRoute=i,t.RegExpRoute=u,t.registerNavigationRoute=((t,r={})=>{const s=e.cacheNames.getPrecacheName(r.cacheName),n=new i(async()=>{try{const e=await caches.match(t,{cacheName:s});if(e)return e;throw new Error(`The cache ${s} did not have an entry for `+`${t}.`)}catch(e){return fetch(t)}},{whitelist:r.whitelist,blacklist:r.blacklist});return h().registerRoute(n),n}),t.registerRoute=((t,e,s="GET")=>{let n;if("string"==typeof t){const r=new URL(t,location);n=new o(({url:t})=>t.href===r.href,e,s)}else if(t instanceof RegExp)n=new u(t,e,s);else if("function"==typeof t)n=new o(t,e,s);else{if(!(t instanceof o))throw new r.WorkboxError("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});n=t}return h().registerRoute(n),n}),t.Route=o,t.Router=c,t.setCatchHandler=(t=>{h().setCatchHandler(t)}),t.setDefaultHandler=(t=>{h().setDefaultHandler(t)}),t}({},workbox.core._private,workbox.core._private); +//# sourceMappingURL=workbox-routing.prod.js.map diff --git a/public/javascripts/workbox/workbox-routing.prod.js.map b/public/javascripts/workbox/workbox-routing.prod.js.map new file mode 100644 index 0000000000..56e5c0eee4 --- /dev/null +++ b/public/javascripts/workbox/workbox-routing.prod.js.map @@ -0,0 +1 @@ +{"version":3,"file":"workbox-routing.prod.js","sources":["../_version.mjs","../utils/constants.mjs","../utils/normalizeHandler.mjs","../Route.mjs","../NavigationRoute.mjs","../RegExpRoute.mjs","../Router.mjs","../utils/getOrCreateDefaultRouter.mjs","../registerNavigationRoute.mjs","../registerRoute.mjs","../setCatchHandler.mjs","../setDefaultHandler.mjs"],"sourcesContent":["try{self['workbox:routing:4.3.1']&&_()}catch(e){}// eslint-disable-line","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\n/**\n * The default HTTP method, 'GET', used when there's no specific method\n * configured for a route.\n *\n * @type {string}\n *\n * @private\n */\nexport const defaultMethod = 'GET';\n\n/**\n * The list of valid HTTP methods associated with requests that could be routed.\n *\n * @type {Array}\n *\n * @private\n */\nexport const validMethods = [\n 'DELETE',\n 'GET',\n 'HEAD',\n 'PATCH',\n 'POST',\n 'PUT',\n];\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport '../_version.mjs';\n\n/**\n * @param {function()|Object} handler Either a function, or an object with a\n * 'handle' method.\n * @return {Object} An object with a handle method.\n *\n * @private\n */\nexport const normalizeHandler = (handler) => {\n if (handler && typeof handler === 'object') {\n if (process.env.NODE_ENV !== 'production') {\n assert.hasMethod(handler, 'handle', {\n moduleName: 'workbox-routing',\n className: 'Route',\n funcName: 'constructor',\n paramName: 'handler',\n });\n }\n return handler;\n } else {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(handler, 'function', {\n moduleName: 'workbox-routing',\n className: 'Route',\n funcName: 'constructor',\n paramName: 'handler',\n });\n }\n return {handle: handler};\n }\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\n\nimport {defaultMethod, validMethods} from './utils/constants.mjs';\nimport {normalizeHandler} from './utils/normalizeHandler.mjs';\nimport './_version.mjs';\n\n/**\n * A `Route` consists of a pair of callback functions, \"match\" and \"handler\".\n * The \"match\" callback determine if a route should be used to \"handle\" a\n * request by returning a non-falsy value if it can. The \"handler\" callback\n * is called when there is a match and should return a Promise that resolves\n * to a `Response`.\n *\n * @memberof workbox.routing\n */\nclass Route {\n /**\n * Constructor for Route class.\n *\n * @param {workbox.routing.Route~matchCallback} match\n * A callback function that determines whether the route matches a given\n * `fetch` event by returning a non-falsy value.\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resolving to a Response.\n * @param {string} [method='GET'] The HTTP method to match the Route\n * against.\n */\n constructor(match, handler, method) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(match, 'function', {\n moduleName: 'workbox-routing',\n className: 'Route',\n funcName: 'constructor',\n paramName: 'match',\n });\n\n if (method) {\n assert.isOneOf(method, validMethods, {paramName: 'method'});\n }\n }\n\n // These values are referenced directly by Router so cannot be\n // altered by minifification.\n this.handler = normalizeHandler(handler);\n this.match = match;\n this.method = method || defaultMethod;\n }\n}\n\nexport {Route};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {Route} from './Route.mjs';\nimport './_version.mjs';\n\n/**\n * NavigationRoute makes it easy to create a [Route]{@link\n * workbox.routing.Route} that matches for browser\n * [navigation requests]{@link https://developers.google.com/web/fundamentals/primers/service-workers/high-performance-loading#first_what_are_navigation_requests}.\n *\n * It will only match incoming Requests whose\n * [`mode`]{@link https://fetch.spec.whatwg.org/#concept-request-mode}\n * is set to `navigate`.\n *\n * You can optionally only apply this route to a subset of navigation requests\n * by using one or both of the `blacklist` and `whitelist` parameters.\n *\n * @memberof workbox.routing\n * @extends workbox.routing.Route\n */\nclass NavigationRoute extends Route {\n /**\n * If both `blacklist` and `whiltelist` are provided, the `blacklist` will\n * take precedence and the request will not match this route.\n *\n * The regular expressions in `whitelist` and `blacklist`\n * are matched against the concatenated\n * [`pathname`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/pathname}\n * and [`search`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/search}\n * portions of the requested URL.\n *\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resulting in a Response.\n * @param {Object} options\n * @param {Array} [options.blacklist] If any of these patterns match,\n * the route will not handle the request (even if a whitelist RegExp matches).\n * @param {Array} [options.whitelist=[/./]] If any of these patterns\n * match the URL's pathname and search parameter, the route will handle the\n * request (assuming the blacklist doesn't match).\n */\n constructor(handler, {whitelist = [/./], blacklist = []} = {}) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isArrayOfClass(whitelist, RegExp, {\n moduleName: 'workbox-routing',\n className: 'NavigationRoute',\n funcName: 'constructor',\n paramName: 'options.whitelist',\n });\n assert.isArrayOfClass(blacklist, RegExp, {\n moduleName: 'workbox-routing',\n className: 'NavigationRoute',\n funcName: 'constructor',\n paramName: 'options.blacklist',\n });\n }\n\n super((options) => this._match(options), handler);\n\n this._whitelist = whitelist;\n this._blacklist = blacklist;\n }\n\n /**\n * Routes match handler.\n *\n * @param {Object} options\n * @param {URL} options.url\n * @param {Request} options.request\n * @return {boolean}\n *\n * @private\n */\n _match({url, request}) {\n if (request.mode !== 'navigate') {\n return false;\n }\n\n const pathnameAndSearch = url.pathname + url.search;\n\n for (const regExp of this._blacklist) {\n if (regExp.test(pathnameAndSearch)) {\n if (process.env.NODE_ENV !== 'production') {\n logger.log(`The navigation route is not being used, since the ` +\n `URL matches this blacklist pattern: ${regExp}`);\n }\n return false;\n }\n }\n\n if (this._whitelist.some((regExp) => regExp.test(pathnameAndSearch))) {\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(`The navigation route is being used.`);\n }\n return true;\n }\n\n if (process.env.NODE_ENV !== 'production') {\n logger.log(`The navigation route is not being used, since the URL ` +\n `being navigated to doesn't match the whitelist.`);\n }\n return false;\n }\n}\n\nexport {NavigationRoute};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {Route} from './Route.mjs';\nimport './_version.mjs';\n\n/**\n * RegExpRoute makes it easy to create a regular expression based\n * [Route]{@link workbox.routing.Route}.\n *\n * For same-origin requests the RegExp only needs to match part of the URL. For\n * requests against third-party servers, you must define a RegExp that matches\n * the start of the URL.\n *\n * [See the module docs for info.]{@link https://developers.google.com/web/tools/workbox/modules/workbox-routing}\n *\n * @memberof workbox.routing\n * @extends workbox.routing.Route\n */\nclass RegExpRoute extends Route {\n /**\n * If the regulard expression contains\n * [capture groups]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#grouping-back-references},\n * th ecaptured values will be passed to the\n * [handler's]{@link workbox.routing.Route~handlerCallback} `params`\n * argument.\n *\n * @param {RegExp} regExp The regular expression to match against URLs.\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resulting in a Response.\n * @param {string} [method='GET'] The HTTP method to match the Route\n * against.\n */\n constructor(regExp, handler, method) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(regExp, RegExp, {\n moduleName: 'workbox-routing',\n className: 'RegExpRoute',\n funcName: 'constructor',\n paramName: 'pattern',\n });\n }\n\n const match = ({url}) => {\n const result = regExp.exec(url.href);\n\n // Return null immediately if there's no match.\n if (!result) {\n return null;\n }\n\n // Require that the match start at the first character in the URL string\n // if it's a cross-origin request.\n // See https://github.com/GoogleChrome/workbox/issues/281 for the context\n // behind this behavior.\n if ((url.origin !== location.origin) && (result.index !== 0)) {\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(\n `The regular expression '${regExp}' only partially matched ` +\n `against the cross-origin URL '${url}'. RegExpRoute's will only ` +\n `handle cross-origin requests if they match the entire URL.`\n );\n }\n\n return null;\n }\n\n // If the route matches, but there aren't any capture groups defined, then\n // this will return [], which is truthy and therefore sufficient to\n // indicate a match.\n // If there are capture groups, then it will return their values.\n return result.slice(1);\n };\n\n super(match, handler, method);\n }\n}\n\nexport {RegExpRoute};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\nimport {getFriendlyURL} from 'workbox-core/_private/getFriendlyURL.mjs';\n\nimport {normalizeHandler} from './utils/normalizeHandler.mjs';\nimport './_version.mjs';\n\n/**\n * The Router can be used to process a FetchEvent through one or more\n * [Routes]{@link workbox.routing.Route} responding with a Request if\n * a matching route exists.\n *\n * If no route matches a given a request, the Router will use a \"default\"\n * handler if one is defined.\n *\n * Should the matching Route throw an error, the Router will use a \"catch\"\n * handler if one is defined to gracefully deal with issues and respond with a\n * Request.\n *\n * If a request matches multiple routes, the **earliest** registered route will\n * be used to respond to the request.\n *\n * @memberof workbox.routing\n */\nclass Router {\n /**\n * Initializes a new Router.\n */\n constructor() {\n this._routes = new Map();\n }\n\n /**\n * @return {Map>} routes A `Map` of HTTP\n * method name ('GET', etc.) to an array of all the corresponding `Route`\n * instances that are registered.\n */\n get routes() {\n return this._routes;\n }\n\n /**\n * Adds a fetch event listener to respond to events when a route matches\n * the event's request.\n */\n addFetchListener() {\n self.addEventListener('fetch', (event) => {\n const {request} = event;\n const responsePromise = this.handleRequest({request, event});\n if (responsePromise) {\n event.respondWith(responsePromise);\n }\n });\n }\n\n /**\n * Adds a message event listener for URLs to cache from the window.\n * This is useful to cache resources loaded on the page prior to when the\n * service worker started controlling it.\n *\n * The format of the message data sent from the window should be as follows.\n * Where the `urlsToCache` array may consist of URL strings or an array of\n * URL string + `requestInit` object (the same as you'd pass to `fetch()`).\n *\n * ```\n * {\n * type: 'CACHE_URLS',\n * payload: {\n * urlsToCache: [\n * './script1.js',\n * './script2.js',\n * ['./script3.js', {mode: 'no-cors'}],\n * ],\n * },\n * }\n * ```\n */\n addCacheListener() {\n self.addEventListener('message', async (event) => {\n if (event.data && event.data.type === 'CACHE_URLS') {\n const {payload} = event.data;\n\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(`Caching URLs from the window`, payload.urlsToCache);\n }\n\n const requestPromises = Promise.all(payload.urlsToCache.map((entry) => {\n if (typeof entry === 'string') {\n entry = [entry];\n }\n\n const request = new Request(...entry);\n return this.handleRequest({request});\n }));\n\n event.waitUntil(requestPromises);\n\n // If a MessageChannel was used, reply to the message on success.\n if (event.ports && event.ports[0]) {\n await requestPromises;\n event.ports[0].postMessage(true);\n }\n }\n });\n }\n\n /**\n * Apply the routing rules to a FetchEvent object to get a Response from an\n * appropriate Route's handler.\n *\n * @param {Object} options\n * @param {Request} options.request The request to handle (this is usually\n * from a fetch event, but it does not have to be).\n * @param {FetchEvent} [options.event] The event that triggered the request,\n * if applicable.\n * @return {Promise|undefined} A promise is returned if a\n * registered route can handle the request. If there is no matching\n * route and there's no `defaultHandler`, `undefined` is returned.\n */\n handleRequest({request, event}) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(request, Request, {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'handleRequest',\n paramName: 'options.request',\n });\n }\n\n const url = new URL(request.url, location);\n if (!url.protocol.startsWith('http')) {\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(\n `Workbox Router only supports URLs that start with 'http'.`);\n }\n return;\n }\n\n let {params, route} = this.findMatchingRoute({url, request, event});\n let handler = route && route.handler;\n\n let debugMessages = [];\n if (process.env.NODE_ENV !== 'production') {\n if (handler) {\n debugMessages.push([\n `Found a route to handle this request:`, route,\n ]);\n\n if (params) {\n debugMessages.push([\n `Passing the following params to the route's handler:`, params,\n ]);\n }\n }\n }\n\n // If we don't have a handler because there was no matching route, then\n // fall back to defaultHandler if that's defined.\n if (!handler && this._defaultHandler) {\n if (process.env.NODE_ENV !== 'production') {\n debugMessages.push(`Failed to find a matching route. Falling ` +\n `back to the default handler.`);\n\n // This is used for debugging in logs in the case of an error.\n route = '[Default Handler]';\n }\n handler = this._defaultHandler;\n }\n\n if (!handler) {\n if (process.env.NODE_ENV !== 'production') {\n // No handler so Workbox will do nothing. If logs is set of debug\n // i.e. verbose, we should print out this information.\n logger.debug(`No route found for: ${getFriendlyURL(url)}`);\n }\n return;\n }\n\n if (process.env.NODE_ENV !== 'production') {\n // We have a handler, meaning Workbox is going to handle the route.\n // print the routing details to the console.\n logger.groupCollapsed(`Router is responding to: ${getFriendlyURL(url)}`);\n debugMessages.forEach((msg) => {\n if (Array.isArray(msg)) {\n logger.log(...msg);\n } else {\n logger.log(msg);\n }\n });\n\n // The Request and Response objects contains a great deal of information,\n // hide it under a group in case developers want to see it.\n logger.groupCollapsed(`View request details here.`);\n logger.log(request);\n logger.groupEnd();\n\n logger.groupEnd();\n }\n\n // Wrap in try and catch in case the handle method throws a synchronous\n // error. It should still callback to the catch handler.\n let responsePromise;\n try {\n responsePromise = handler.handle({url, request, event, params});\n } catch (err) {\n responsePromise = Promise.reject(err);\n }\n\n if (responsePromise && this._catchHandler) {\n responsePromise = responsePromise.catch((err) => {\n if (process.env.NODE_ENV !== 'production') {\n // Still include URL here as it will be async from the console group\n // and may not make sense without the URL\n logger.groupCollapsed(`Error thrown when responding to: ` +\n ` ${getFriendlyURL(url)}. Falling back to Catch Handler.`);\n logger.error(`Error thrown by:`, route);\n logger.error(err);\n logger.groupEnd();\n }\n return this._catchHandler.handle({url, event, err});\n });\n }\n\n return responsePromise;\n }\n\n /**\n * Checks a request and URL (and optionally an event) against the list of\n * registered routes, and if there's a match, returns the corresponding\n * route along with any params generated by the match.\n *\n * @param {Object} options\n * @param {URL} options.url\n * @param {Request} options.request The request to match.\n * @param {FetchEvent} [options.event] The corresponding event (unless N/A).\n * @return {Object} An object with `route` and `params` properties.\n * They are populated if a matching route was found or `undefined`\n * otherwise.\n */\n findMatchingRoute({url, request, event}) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(url, URL, {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'findMatchingRoute',\n paramName: 'options.url',\n });\n assert.isInstance(request, Request, {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'findMatchingRoute',\n paramName: 'options.request',\n });\n }\n\n const routes = this._routes.get(request.method) || [];\n for (const route of routes) {\n let params;\n let matchResult = route.match({url, request, event});\n if (matchResult) {\n if (Array.isArray(matchResult) && matchResult.length > 0) {\n // Instead of passing an empty array in as params, use undefined.\n params = matchResult;\n } else if ((matchResult.constructor === Object &&\n Object.keys(matchResult).length > 0)) {\n // Instead of passing an empty object in as params, use undefined.\n params = matchResult;\n }\n\n // Return early if have a match.\n return {route, params};\n }\n }\n // If no match was found above, return and empty object.\n return {};\n }\n\n /**\n * Define a default `handler` that's called when no routes explicitly\n * match the incoming request.\n *\n * Without a default handler, unmatched requests will go against the\n * network as if there were no service worker present.\n *\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resulting in a Response.\n */\n setDefaultHandler(handler) {\n this._defaultHandler = normalizeHandler(handler);\n }\n\n /**\n * If a Route throws an error while handling a request, this `handler`\n * will be called and given a chance to provide a response.\n *\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resulting in a Response.\n */\n setCatchHandler(handler) {\n this._catchHandler = normalizeHandler(handler);\n }\n\n /**\n * Registers a route with the router.\n *\n * @param {workbox.routing.Route} route The route to register.\n */\n registerRoute(route) {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(route, 'object', {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'registerRoute',\n paramName: 'route',\n });\n\n assert.hasMethod(route, 'match', {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'registerRoute',\n paramName: 'route',\n });\n\n assert.isType(route.handler, 'object', {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'registerRoute',\n paramName: 'route',\n });\n\n assert.hasMethod(route.handler, 'handle', {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'registerRoute',\n paramName: 'route.handler',\n });\n\n assert.isType(route.method, 'string', {\n moduleName: 'workbox-routing',\n className: 'Router',\n funcName: 'registerRoute',\n paramName: 'route.method',\n });\n }\n\n if (!this._routes.has(route.method)) {\n this._routes.set(route.method, []);\n }\n\n // Give precedence to all of the earlier routes by adding this additional\n // route to the end of the array.\n this._routes.get(route.method).push(route);\n }\n\n /**\n * Unregisters a route with the router.\n *\n * @param {workbox.routing.Route} route The route to unregister.\n */\n unregisterRoute(route) {\n if (!this._routes.has(route.method)) {\n throw new WorkboxError(\n 'unregister-route-but-not-found-with-method', {\n method: route.method,\n }\n );\n }\n\n const routeIndex = this._routes.get(route.method).indexOf(route);\n if (routeIndex > -1) {\n this._routes.get(route.method).splice(routeIndex, 1);\n } else {\n throw new WorkboxError('unregister-route-route-not-registered');\n }\n }\n}\n\nexport {Router};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {Router} from '../Router.mjs';\nimport '../_version.mjs';\n\nlet defaultRouter;\n\n/**\n * Creates a new, singleton Router instance if one does not exist. If one\n * does already exist, that instance is returned.\n *\n * @private\n * @return {Router}\n */\nexport const getOrCreateDefaultRouter = () => {\n if (!defaultRouter) {\n defaultRouter = new Router();\n\n // The helpers that use the default Router assume these listeners exist.\n defaultRouter.addFetchListener();\n defaultRouter.addCacheListener();\n }\n return defaultRouter;\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {cacheNames} from 'workbox-core/_private/cacheNames.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {NavigationRoute} from './NavigationRoute.mjs';\nimport {getOrCreateDefaultRouter} from './utils/getOrCreateDefaultRouter.mjs';\nimport './_version.mjs';\n\n\n/**\n * Registers a route that will return a precached file for a navigation\n * request. This is useful for the\n * [application shell pattern]{@link https://developers.google.com/web/fundamentals/architecture/app-shell}.\n *\n * When determining the URL of the precached HTML document, you will likely need\n * to call `workbox.precaching.getCacheKeyForURL(originalUrl)`, to account for\n * the fact that Workbox's precaching naming conventions often results in URL\n * cache keys that contain extra revisioning info.\n *\n * This method will generate a\n * [NavigationRoute]{@link workbox.routing.NavigationRoute}\n * and call\n * [Router.registerRoute()]{@link workbox.routing.Router#registerRoute} on a\n * singleton Router instance.\n *\n * @param {string} cachedAssetUrl The cache key to use for the HTML file.\n * @param {Object} [options]\n * @param {string} [options.cacheName] Cache name to store and retrieve\n * requests. Defaults to precache cache name provided by\n * [workbox-core.cacheNames]{@link workbox.core.cacheNames}.\n * @param {Array} [options.blacklist=[]] If any of these patterns\n * match, the route will not handle the request (even if a whitelist entry\n * matches).\n * @param {Array} [options.whitelist=[/./]] If any of these patterns\n * match the URL's pathname and search parameter, the route will handle the\n * request (assuming the blacklist doesn't match).\n * @return {workbox.routing.NavigationRoute} Returns the generated\n * Route.\n *\n * @alias workbox.routing.registerNavigationRoute\n */\nexport const registerNavigationRoute = (cachedAssetUrl, options = {}) => {\n if (process.env.NODE_ENV !== 'production') {\n assert.isType(cachedAssetUrl, 'string', {\n moduleName: 'workbox-routing',\n funcName: 'registerNavigationRoute',\n paramName: 'cachedAssetUrl',\n });\n }\n\n const cacheName = cacheNames.getPrecacheName(options.cacheName);\n const handler = async () => {\n try {\n const response = await caches.match(cachedAssetUrl, {cacheName});\n\n if (response) {\n return response;\n }\n\n // This shouldn't normally happen, but there are edge cases:\n // https://github.com/GoogleChrome/workbox/issues/1441\n throw new Error(`The cache ${cacheName} did not have an entry for ` +\n `${cachedAssetUrl}.`);\n } catch (error) {\n // If there's either a cache miss, or the caches.match() call threw\n // an exception, then attempt to fulfill the navigation request with\n // a response from the network rather than leaving the user with a\n // failed navigation.\n if (process.env.NODE_ENV !== 'production') {\n logger.debug(`Unable to respond to navigation request with ` +\n `cached response. Falling back to network.`, error);\n }\n\n // This might still fail if the browser is offline...\n return fetch(cachedAssetUrl);\n }\n };\n\n const route = new NavigationRoute(handler, {\n whitelist: options.whitelist,\n blacklist: options.blacklist,\n });\n\n const defaultRouter = getOrCreateDefaultRouter();\n defaultRouter.registerRoute(route);\n\n return route;\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\nimport {Route} from './Route.mjs';\nimport {RegExpRoute} from './RegExpRoute.mjs';\nimport {getOrCreateDefaultRouter} from './utils/getOrCreateDefaultRouter.mjs';\nimport './_version.mjs';\n\n\n/**\n * Easily register a RegExp, string, or function with a caching\n * strategy to a singleton Router instance.\n *\n * This method will generate a Route for you if needed and\n * call [Router.registerRoute()]{@link\n * workbox.routing.Router#registerRoute}.\n *\n * @param {\n * RegExp|\n * string|\n * workbox.routing.Route~matchCallback|\n * workbox.routing.Route\n * } capture\n * If the capture param is a `Route`, all other arguments will be ignored.\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resulting in a Response.\n * @param {string} [method='GET'] The HTTP method to match the Route\n * against.\n * @return {workbox.routing.Route} The generated `Route`(Useful for\n * unregistering).\n *\n * @alias workbox.routing.registerRoute\n */\nexport const registerRoute = (capture, handler, method = 'GET') => {\n let route;\n\n if (typeof capture === 'string') {\n const captureUrl = new URL(capture, location);\n\n if (process.env.NODE_ENV !== 'production') {\n if (!(capture.startsWith('/') || capture.startsWith('http'))) {\n throw new WorkboxError('invalid-string', {\n moduleName: 'workbox-routing',\n funcName: 'registerRoute',\n paramName: 'capture',\n });\n }\n\n // We want to check if Express-style wildcards are in the pathname only.\n // TODO: Remove this log message in v4.\n const valueToCheck = capture.startsWith('http') ?\n captureUrl.pathname : capture;\n\n // See https://github.com/pillarjs/path-to-regexp#parameters\n const wildcards = '[*:?+]';\n if (valueToCheck.match(new RegExp(`${wildcards}`))) {\n logger.debug(\n `The '$capture' parameter contains an Express-style wildcard ` +\n `character (${wildcards}). Strings are now always interpreted as ` +\n `exact matches; use a RegExp for partial or wildcard matches.`\n );\n }\n }\n\n const matchCallback = ({url}) => {\n if (process.env.NODE_ENV !== 'production') {\n if ((url.pathname === captureUrl.pathname) &&\n (url.origin !== captureUrl.origin)) {\n logger.debug(\n `${capture} only partially matches the cross-origin URL ` +\n `${url}. This route will only handle cross-origin requests ` +\n `if they match the entire URL.`);\n }\n }\n\n return url.href === captureUrl.href;\n };\n\n route = new Route(matchCallback, handler, method);\n } else if (capture instanceof RegExp) {\n route = new RegExpRoute(capture, handler, method);\n } else if (typeof capture === 'function') {\n route = new Route(capture, handler, method);\n } else if (capture instanceof Route) {\n route = capture;\n } else {\n throw new WorkboxError('unsupported-route-type', {\n moduleName: 'workbox-routing',\n funcName: 'registerRoute',\n paramName: 'capture',\n });\n }\n\n const defaultRouter = getOrCreateDefaultRouter();\n defaultRouter.registerRoute(route);\n\n return route;\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {getOrCreateDefaultRouter} from './utils/getOrCreateDefaultRouter.mjs';\n\nimport './_version.mjs';\n\n/**\n * If a Route throws an error while handling a request, this `handler`\n * will be called and given a chance to provide a response.\n *\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resulting in a Response.\n *\n * @alias workbox.routing.setCatchHandler\n */\nexport const setCatchHandler = (handler) => {\n const defaultRouter = getOrCreateDefaultRouter();\n defaultRouter.setCatchHandler(handler);\n};\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {getOrCreateDefaultRouter} from './utils/getOrCreateDefaultRouter.mjs';\n\nimport './_version.mjs';\n\n/**\n * Define a default `handler` that's called when no routes explicitly\n * match the incoming request.\n *\n * Without a default handler, unmatched requests will go against the\n * network as if there were no service worker present.\n *\n * @param {workbox.routing.Route~handlerCallback} handler A callback\n * function that returns a Promise resulting in a Response.\n *\n * @alias workbox.routing.setDefaultHandler\n */\nexport const setDefaultHandler = (handler) => {\n const defaultRouter = getOrCreateDefaultRouter();\n defaultRouter.setDefaultHandler(handler);\n};\n"],"names":["self","_","e","defaultMethod","normalizeHandler","handler","handle","Route","constructor","match","method","NavigationRoute","whitelist","blacklist","options","this","_match","_whitelist","_blacklist","url","request","mode","pathnameAndSearch","pathname","search","regExp","test","some","RegExpRoute","result","exec","href","origin","location","index","slice","Router","_routes","Map","addFetchListener","addEventListener","event","responsePromise","handleRequest","respondWith","addCacheListener","async","data","type","payload","requestPromises","Promise","all","urlsToCache","map","entry","Request","waitUntil","ports","postMessage","URL","protocol","startsWith","params","route","findMatchingRoute","_defaultHandler","err","reject","_catchHandler","catch","routes","get","matchResult","Array","isArray","length","Object","keys","setDefaultHandler","setCatchHandler","registerRoute","has","set","push","unregisterRoute","WorkboxError","routeIndex","indexOf","splice","defaultRouter","getOrCreateDefaultRouter","cachedAssetUrl","cacheName","cacheNames","getPrecacheName","response","caches","Error","error","fetch","capture","captureUrl","RegExp","moduleName","funcName","paramName"],"mappings":"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"} \ No newline at end of file diff --git a/public/javascripts/workbox/workbox-strategies.dev.js b/public/javascripts/workbox/workbox-strategies.dev.js new file mode 100644 index 0000000000..e88a65d8b7 --- /dev/null +++ b/public/javascripts/workbox/workbox-strategies.dev.js @@ -0,0 +1,1138 @@ +this.workbox = this.workbox || {}; +this.workbox.strategies = (function (exports, logger_mjs, assert_mjs, cacheNames_mjs, cacheWrapper_mjs, fetchWrapper_mjs, getFriendlyURL_mjs, WorkboxError_mjs) { + 'use strict'; + + try { + self['workbox:strategies:4.3.1'] && _(); + } catch (e) {} // eslint-disable-line + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + + const getFriendlyURL = url => { + const urlObj = new URL(url, location); + + if (urlObj.origin === location.origin) { + return urlObj.pathname; + } + + return urlObj.href; + }; + + const messages = { + strategyStart: (strategyName, request) => `Using ${strategyName} to ` + `respond to '${getFriendlyURL(request.url)}'`, + printFinalResponse: response => { + if (response) { + logger_mjs.logger.groupCollapsed(`View the final response here.`); + logger_mjs.logger.log(response); + logger_mjs.logger.groupEnd(); + } + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * An implementation of a [cache-first]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#cache-falling-back-to-network} + * request strategy. + * + * A cache first strategy is useful for assets that have been revisioned, + * such as URLs like `/styles/example.a8f5f1.css`, since they + * can be cached for long periods of time. + * + * If the network request fails, and there is no cache match, this will throw + * a `WorkboxError` exception. + * + * @memberof workbox.strategies + */ + + class CacheFirst { + /** + * @param {Object} options + * @param {string} options.cacheName Cache name to store and retrieve + * requests. Defaults to cache names provided by + * [workbox-core]{@link workbox.core.cacheNames}. + * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * to use in conjunction with this caching strategy. + * @param {Object} options.fetchOptions Values passed along to the + * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) + * of all fetch() requests made by this strategy. + * @param {Object} options.matchOptions [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions) + */ + constructor(options = {}) { + this._cacheName = cacheNames_mjs.cacheNames.getRuntimeName(options.cacheName); + this._plugins = options.plugins || []; + this._fetchOptions = options.fetchOptions || null; + this._matchOptions = options.matchOptions || null; + } + /** + * This method will perform a request strategy and follows an API that + * will work with the + * [Workbox Router]{@link workbox.routing.Router}. + * + * @param {Object} options + * @param {Request} options.request The request to run this strategy for. + * @param {Event} [options.event] The event that triggered the request. + * @return {Promise} + */ + + + async handle({ + event, + request + }) { + return this.makeRequest({ + event, + request: request || event.request + }); + } + /** + * This method can be used to perform a make a standalone request outside the + * context of the [Workbox Router]{@link workbox.routing.Router}. + * + * See "[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)" + * for more usage information. + * + * @param {Object} options + * @param {Request|string} options.request Either a + * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request} + * object, or a string URL, corresponding to the request to be made. + * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will + be called automatically to extend the service worker's lifetime. + * @return {Promise} + */ + + + async makeRequest({ + event, + request + }) { + const logs = []; + + if (typeof request === 'string') { + request = new Request(request); + } + + { + assert_mjs.assert.isInstance(request, Request, { + moduleName: 'workbox-strategies', + className: 'CacheFirst', + funcName: 'makeRequest', + paramName: 'request' + }); + } + + let response = await cacheWrapper_mjs.cacheWrapper.match({ + cacheName: this._cacheName, + request, + event, + matchOptions: this._matchOptions, + plugins: this._plugins + }); + let error; + + if (!response) { + { + logs.push(`No response found in the '${this._cacheName}' cache. ` + `Will respond with a network request.`); + } + + try { + response = await this._getFromNetwork(request, event); + } catch (err) { + error = err; + } + + { + if (response) { + logs.push(`Got response from network.`); + } else { + logs.push(`Unable to get a response from the network.`); + } + } + } else { + { + logs.push(`Found a cached response in the '${this._cacheName}' cache.`); + } + } + + { + logger_mjs.logger.groupCollapsed(messages.strategyStart('CacheFirst', request)); + + for (let log of logs) { + logger_mjs.logger.log(log); + } + + messages.printFinalResponse(response); + logger_mjs.logger.groupEnd(); + } + + if (!response) { + throw new WorkboxError_mjs.WorkboxError('no-response', { + url: request.url, + error + }); + } + + return response; + } + /** + * Handles the network and cache part of CacheFirst. + * + * @param {Request} request + * @param {FetchEvent} [event] + * @return {Promise} + * + * @private + */ + + + async _getFromNetwork(request, event) { + const response = await fetchWrapper_mjs.fetchWrapper.fetch({ + request, + event, + fetchOptions: this._fetchOptions, + plugins: this._plugins + }); // Keep the service worker while we put the request to the cache + + const responseClone = response.clone(); + const cachePutPromise = cacheWrapper_mjs.cacheWrapper.put({ + cacheName: this._cacheName, + request, + response: responseClone, + event, + plugins: this._plugins + }); + + if (event) { + try { + event.waitUntil(cachePutPromise); + } catch (error) { + { + logger_mjs.logger.warn(`Unable to ensure service worker stays alive when ` + `updating cache for '${getFriendlyURL_mjs.getFriendlyURL(request.url)}'.`); + } + } + } + + return response; + } + + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * An implementation of a + * [cache-only]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#cache-only} + * request strategy. + * + * This class is useful if you want to take advantage of any + * [Workbox plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}. + * + * If there is no cache match, this will throw a `WorkboxError` exception. + * + * @memberof workbox.strategies + */ + + class CacheOnly { + /** + * @param {Object} options + * @param {string} options.cacheName Cache name to store and retrieve + * requests. Defaults to cache names provided by + * [workbox-core]{@link workbox.core.cacheNames}. + * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * to use in conjunction with this caching strategy. + * @param {Object} options.matchOptions [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions) + */ + constructor(options = {}) { + this._cacheName = cacheNames_mjs.cacheNames.getRuntimeName(options.cacheName); + this._plugins = options.plugins || []; + this._matchOptions = options.matchOptions || null; + } + /** + * This method will perform a request strategy and follows an API that + * will work with the + * [Workbox Router]{@link workbox.routing.Router}. + * + * @param {Object} options + * @param {Request} options.request The request to run this strategy for. + * @param {Event} [options.event] The event that triggered the request. + * @return {Promise} + */ + + + async handle({ + event, + request + }) { + return this.makeRequest({ + event, + request: request || event.request + }); + } + /** + * This method can be used to perform a make a standalone request outside the + * context of the [Workbox Router]{@link workbox.routing.Router}. + * + * See "[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)" + * for more usage information. + * + * @param {Object} options + * @param {Request|string} options.request Either a + * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request} + * object, or a string URL, corresponding to the request to be made. + * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will + * be called automatically to extend the service worker's lifetime. + * @return {Promise} + */ + + + async makeRequest({ + event, + request + }) { + if (typeof request === 'string') { + request = new Request(request); + } + + { + assert_mjs.assert.isInstance(request, Request, { + moduleName: 'workbox-strategies', + className: 'CacheOnly', + funcName: 'makeRequest', + paramName: 'request' + }); + } + + const response = await cacheWrapper_mjs.cacheWrapper.match({ + cacheName: this._cacheName, + request, + event, + matchOptions: this._matchOptions, + plugins: this._plugins + }); + + { + logger_mjs.logger.groupCollapsed(messages.strategyStart('CacheOnly', request)); + + if (response) { + logger_mjs.logger.log(`Found a cached response in the '${this._cacheName}'` + ` cache.`); + messages.printFinalResponse(response); + } else { + logger_mjs.logger.log(`No response found in the '${this._cacheName}' cache.`); + } + + logger_mjs.logger.groupEnd(); + } + + if (!response) { + throw new WorkboxError_mjs.WorkboxError('no-response', { + url: request.url + }); + } + + return response; + } + + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const cacheOkAndOpaquePlugin = { + /** + * Returns a valid response (to allow caching) if the status is 200 (OK) or + * 0 (opaque). + * + * @param {Object} options + * @param {Response} options.response + * @return {Response|null} + * + * @private + */ + cacheWillUpdate: ({ + response + }) => { + if (response.status === 200 || response.status === 0) { + return response; + } + + return null; + } + }; + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * An implementation of a + * [network first]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#network-falling-back-to-cache} + * request strategy. + * + * By default, this strategy will cache responses with a 200 status code as + * well as [opaque responses]{@link https://developers.google.com/web/tools/workbox/guides/handle-third-party-requests}. + * Opaque responses are are cross-origin requests where the response doesn't + * support [CORS]{@link https://enable-cors.org/}. + * + * If the network request fails, and there is no cache match, this will throw + * a `WorkboxError` exception. + * + * @memberof workbox.strategies + */ + + class NetworkFirst { + /** + * @param {Object} options + * @param {string} options.cacheName Cache name to store and retrieve + * requests. Defaults to cache names provided by + * [workbox-core]{@link workbox.core.cacheNames}. + * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * to use in conjunction with this caching strategy. + * @param {Object} options.fetchOptions Values passed along to the + * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) + * of all fetch() requests made by this strategy. + * @param {Object} options.matchOptions [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions) + * @param {number} options.networkTimeoutSeconds If set, any network requests + * that fail to respond within the timeout will fallback to the cache. + * + * This option can be used to combat + * "[lie-fi]{@link https://developers.google.com/web/fundamentals/performance/poor-connectivity/#lie-fi}" + * scenarios. + */ + constructor(options = {}) { + this._cacheName = cacheNames_mjs.cacheNames.getRuntimeName(options.cacheName); + + if (options.plugins) { + let isUsingCacheWillUpdate = options.plugins.some(plugin => !!plugin.cacheWillUpdate); + this._plugins = isUsingCacheWillUpdate ? options.plugins : [cacheOkAndOpaquePlugin, ...options.plugins]; + } else { + // No plugins passed in, use the default plugin. + this._plugins = [cacheOkAndOpaquePlugin]; + } + + this._networkTimeoutSeconds = options.networkTimeoutSeconds; + + { + if (this._networkTimeoutSeconds) { + assert_mjs.assert.isType(this._networkTimeoutSeconds, 'number', { + moduleName: 'workbox-strategies', + className: 'NetworkFirst', + funcName: 'constructor', + paramName: 'networkTimeoutSeconds' + }); + } + } + + this._fetchOptions = options.fetchOptions || null; + this._matchOptions = options.matchOptions || null; + } + /** + * This method will perform a request strategy and follows an API that + * will work with the + * [Workbox Router]{@link workbox.routing.Router}. + * + * @param {Object} options + * @param {Request} options.request The request to run this strategy for. + * @param {Event} [options.event] The event that triggered the request. + * @return {Promise} + */ + + + async handle({ + event, + request + }) { + return this.makeRequest({ + event, + request: request || event.request + }); + } + /** + * This method can be used to perform a make a standalone request outside the + * context of the [Workbox Router]{@link workbox.routing.Router}. + * + * See "[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)" + * for more usage information. + * + * @param {Object} options + * @param {Request|string} options.request Either a + * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request} + * object, or a string URL, corresponding to the request to be made. + * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will + * be called automatically to extend the service worker's lifetime. + * @return {Promise} + */ + + + async makeRequest({ + event, + request + }) { + const logs = []; + + if (typeof request === 'string') { + request = new Request(request); + } + + { + assert_mjs.assert.isInstance(request, Request, { + moduleName: 'workbox-strategies', + className: 'NetworkFirst', + funcName: 'handle', + paramName: 'makeRequest' + }); + } + + const promises = []; + let timeoutId; + + if (this._networkTimeoutSeconds) { + const { + id, + promise + } = this._getTimeoutPromise({ + request, + event, + logs + }); + + timeoutId = id; + promises.push(promise); + } + + const networkPromise = this._getNetworkPromise({ + timeoutId, + request, + event, + logs + }); + + promises.push(networkPromise); // Promise.race() will resolve as soon as the first promise resolves. + + let response = await Promise.race(promises); // If Promise.race() resolved with null, it might be due to a network + // timeout + a cache miss. If that were to happen, we'd rather wait until + // the networkPromise resolves instead of returning null. + // Note that it's fine to await an already-resolved promise, so we don't + // have to check to see if it's still "in flight". + + if (!response) { + response = await networkPromise; + } + + { + logger_mjs.logger.groupCollapsed(messages.strategyStart('NetworkFirst', request)); + + for (let log of logs) { + logger_mjs.logger.log(log); + } + + messages.printFinalResponse(response); + logger_mjs.logger.groupEnd(); + } + + if (!response) { + throw new WorkboxError_mjs.WorkboxError('no-response', { + url: request.url + }); + } + + return response; + } + /** + * @param {Object} options + * @param {Request} options.request + * @param {Array} options.logs A reference to the logs array + * @param {Event} [options.event] + * @return {Promise} + * + * @private + */ + + + _getTimeoutPromise({ + request, + logs, + event + }) { + let timeoutId; + const timeoutPromise = new Promise(resolve => { + const onNetworkTimeout = async () => { + { + logs.push(`Timing out the network response at ` + `${this._networkTimeoutSeconds} seconds.`); + } + + resolve((await this._respondFromCache({ + request, + event + }))); + }; + + timeoutId = setTimeout(onNetworkTimeout, this._networkTimeoutSeconds * 1000); + }); + return { + promise: timeoutPromise, + id: timeoutId + }; + } + /** + * @param {Object} options + * @param {number|undefined} options.timeoutId + * @param {Request} options.request + * @param {Array} options.logs A reference to the logs Array. + * @param {Event} [options.event] + * @return {Promise} + * + * @private + */ + + + async _getNetworkPromise({ + timeoutId, + request, + logs, + event + }) { + let error; + let response; + + try { + response = await fetchWrapper_mjs.fetchWrapper.fetch({ + request, + event, + fetchOptions: this._fetchOptions, + plugins: this._plugins + }); + } catch (err) { + error = err; + } + + if (timeoutId) { + clearTimeout(timeoutId); + } + + { + if (response) { + logs.push(`Got response from network.`); + } else { + logs.push(`Unable to get a response from the network. Will respond ` + `with a cached response.`); + } + } + + if (error || !response) { + response = await this._respondFromCache({ + request, + event + }); + + { + if (response) { + logs.push(`Found a cached response in the '${this._cacheName}'` + ` cache.`); + } else { + logs.push(`No response found in the '${this._cacheName}' cache.`); + } + } + } else { + // Keep the service worker alive while we put the request in the cache + const responseClone = response.clone(); + const cachePut = cacheWrapper_mjs.cacheWrapper.put({ + cacheName: this._cacheName, + request, + response: responseClone, + event, + plugins: this._plugins + }); + + if (event) { + try { + // The event has been responded to so we can keep the SW alive to + // respond to the request + event.waitUntil(cachePut); + } catch (err) { + { + logger_mjs.logger.warn(`Unable to ensure service worker stays alive when ` + `updating cache for '${getFriendlyURL_mjs.getFriendlyURL(request.url)}'.`); + } + } + } + } + + return response; + } + /** + * Used if the network timeouts or fails to make the request. + * + * @param {Object} options + * @param {Request} request The request to match in the cache + * @param {Event} [options.event] + * @return {Promise} + * + * @private + */ + + + _respondFromCache({ + event, + request + }) { + return cacheWrapper_mjs.cacheWrapper.match({ + cacheName: this._cacheName, + request, + event, + matchOptions: this._matchOptions, + plugins: this._plugins + }); + } + + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * An implementation of a + * [network-only]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#network-only} + * request strategy. + * + * This class is useful if you want to take advantage of any + * [Workbox plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}. + * + * If the network request fails, this will throw a `WorkboxError` exception. + * + * @memberof workbox.strategies + */ + + class NetworkOnly { + /** + * @param {Object} options + * @param {string} options.cacheName Cache name to store and retrieve + * requests. Defaults to cache names provided by + * [workbox-core]{@link workbox.core.cacheNames}. + * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * to use in conjunction with this caching strategy. + * @param {Object} options.fetchOptions Values passed along to the + * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) + * of all fetch() requests made by this strategy. + */ + constructor(options = {}) { + this._cacheName = cacheNames_mjs.cacheNames.getRuntimeName(options.cacheName); + this._plugins = options.plugins || []; + this._fetchOptions = options.fetchOptions || null; + } + /** + * This method will perform a request strategy and follows an API that + * will work with the + * [Workbox Router]{@link workbox.routing.Router}. + * + * @param {Object} options + * @param {Request} options.request The request to run this strategy for. + * @param {Event} [options.event] The event that triggered the request. + * @return {Promise} + */ + + + async handle({ + event, + request + }) { + return this.makeRequest({ + event, + request: request || event.request + }); + } + /** + * This method can be used to perform a make a standalone request outside the + * context of the [Workbox Router]{@link workbox.routing.Router}. + * + * See "[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)" + * for more usage information. + * + * @param {Object} options + * @param {Request|string} options.request Either a + * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request} + * object, or a string URL, corresponding to the request to be made. + * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will + * be called automatically to extend the service worker's lifetime. + * @return {Promise} + */ + + + async makeRequest({ + event, + request + }) { + if (typeof request === 'string') { + request = new Request(request); + } + + { + assert_mjs.assert.isInstance(request, Request, { + moduleName: 'workbox-strategies', + className: 'NetworkOnly', + funcName: 'handle', + paramName: 'request' + }); + } + + let error; + let response; + + try { + response = await fetchWrapper_mjs.fetchWrapper.fetch({ + request, + event, + fetchOptions: this._fetchOptions, + plugins: this._plugins + }); + } catch (err) { + error = err; + } + + { + logger_mjs.logger.groupCollapsed(messages.strategyStart('NetworkOnly', request)); + + if (response) { + logger_mjs.logger.log(`Got response from network.`); + } else { + logger_mjs.logger.log(`Unable to get a response from the network.`); + } + + messages.printFinalResponse(response); + logger_mjs.logger.groupEnd(); + } + + if (!response) { + throw new WorkboxError_mjs.WorkboxError('no-response', { + url: request.url, + error + }); + } + + return response; + } + + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + /** + * An implementation of a + * [stale-while-revalidate]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#stale-while-revalidate} + * request strategy. + * + * Resources are requested from both the cache and the network in parallel. + * The strategy will respond with the cached version if available, otherwise + * wait for the network response. The cache is updated with the network response + * with each successful request. + * + * By default, this strategy will cache responses with a 200 status code as + * well as [opaque responses]{@link https://developers.google.com/web/tools/workbox/guides/handle-third-party-requests}. + * Opaque responses are are cross-origin requests where the response doesn't + * support [CORS]{@link https://enable-cors.org/}. + * + * If the network request fails, and there is no cache match, this will throw + * a `WorkboxError` exception. + * + * @memberof workbox.strategies + */ + + class StaleWhileRevalidate { + /** + * @param {Object} options + * @param {string} options.cacheName Cache name to store and retrieve + * requests. Defaults to cache names provided by + * [workbox-core]{@link workbox.core.cacheNames}. + * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} + * to use in conjunction with this caching strategy. + * @param {Object} options.fetchOptions Values passed along to the + * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) + * of all fetch() requests made by this strategy. + * @param {Object} options.matchOptions [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions) + */ + constructor(options = {}) { + this._cacheName = cacheNames_mjs.cacheNames.getRuntimeName(options.cacheName); + this._plugins = options.plugins || []; + + if (options.plugins) { + let isUsingCacheWillUpdate = options.plugins.some(plugin => !!plugin.cacheWillUpdate); + this._plugins = isUsingCacheWillUpdate ? options.plugins : [cacheOkAndOpaquePlugin, ...options.plugins]; + } else { + // No plugins passed in, use the default plugin. + this._plugins = [cacheOkAndOpaquePlugin]; + } + + this._fetchOptions = options.fetchOptions || null; + this._matchOptions = options.matchOptions || null; + } + /** + * This method will perform a request strategy and follows an API that + * will work with the + * [Workbox Router]{@link workbox.routing.Router}. + * + * @param {Object} options + * @param {Request} options.request The request to run this strategy for. + * @param {Event} [options.event] The event that triggered the request. + * @return {Promise} + */ + + + async handle({ + event, + request + }) { + return this.makeRequest({ + event, + request: request || event.request + }); + } + /** + * This method can be used to perform a make a standalone request outside the + * context of the [Workbox Router]{@link workbox.routing.Router}. + * + * See "[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)" + * for more usage information. + * + * @param {Object} options + * @param {Request|string} options.request Either a + * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request} + * object, or a string URL, corresponding to the request to be made. + * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will + * be called automatically to extend the service worker's lifetime. + * @return {Promise} + */ + + + async makeRequest({ + event, + request + }) { + const logs = []; + + if (typeof request === 'string') { + request = new Request(request); + } + + { + assert_mjs.assert.isInstance(request, Request, { + moduleName: 'workbox-strategies', + className: 'StaleWhileRevalidate', + funcName: 'handle', + paramName: 'request' + }); + } + + const fetchAndCachePromise = this._getFromNetwork({ + request, + event + }); + + let response = await cacheWrapper_mjs.cacheWrapper.match({ + cacheName: this._cacheName, + request, + event, + matchOptions: this._matchOptions, + plugins: this._plugins + }); + let error; + + if (response) { + { + logs.push(`Found a cached response in the '${this._cacheName}'` + ` cache. Will update with the network response in the background.`); + } + + if (event) { + try { + event.waitUntil(fetchAndCachePromise); + } catch (error) { + { + logger_mjs.logger.warn(`Unable to ensure service worker stays alive when ` + `updating cache for '${getFriendlyURL_mjs.getFriendlyURL(request.url)}'.`); + } + } + } + } else { + { + logs.push(`No response found in the '${this._cacheName}' cache. ` + `Will wait for the network response.`); + } + + try { + response = await fetchAndCachePromise; + } catch (err) { + error = err; + } + } + + { + logger_mjs.logger.groupCollapsed(messages.strategyStart('StaleWhileRevalidate', request)); + + for (let log of logs) { + logger_mjs.logger.log(log); + } + + messages.printFinalResponse(response); + logger_mjs.logger.groupEnd(); + } + + if (!response) { + throw new WorkboxError_mjs.WorkboxError('no-response', { + url: request.url, + error + }); + } + + return response; + } + /** + * @param {Object} options + * @param {Request} options.request + * @param {Event} [options.event] + * @return {Promise} + * + * @private + */ + + + async _getFromNetwork({ + request, + event + }) { + const response = await fetchWrapper_mjs.fetchWrapper.fetch({ + request, + event, + fetchOptions: this._fetchOptions, + plugins: this._plugins + }); + const cachePutPromise = cacheWrapper_mjs.cacheWrapper.put({ + cacheName: this._cacheName, + request, + response: response.clone(), + event, + plugins: this._plugins + }); + + if (event) { + try { + event.waitUntil(cachePutPromise); + } catch (error) { + { + logger_mjs.logger.warn(`Unable to ensure service worker stays alive when ` + `updating cache for '${getFriendlyURL_mjs.getFriendlyURL(request.url)}'.`); + } + } + } + + return response; + } + + } + + /* + Copyright 2018 Google LLC + + Use of this source code is governed by an MIT-style + license that can be found in the LICENSE file or at + https://opensource.org/licenses/MIT. + */ + const mapping = { + cacheFirst: CacheFirst, + cacheOnly: CacheOnly, + networkFirst: NetworkFirst, + networkOnly: NetworkOnly, + staleWhileRevalidate: StaleWhileRevalidate + }; + + const deprecate = strategy => { + const StrategyCtr = mapping[strategy]; + return options => { + { + const strategyCtrName = strategy[0].toUpperCase() + strategy.slice(1); + logger_mjs.logger.warn(`The 'workbox.strategies.${strategy}()' function has been ` + `deprecated and will be removed in a future version of Workbox.\n` + `Please use 'new workbox.strategies.${strategyCtrName}()' instead.`); + } + + return new StrategyCtr(options); + }; + }; + /** + * @function workbox.strategies.cacheFirst + * @param {Object} options See the {@link workbox.strategies.CacheFirst} + * constructor for more info. + * @deprecated since v4.0.0 + */ + + + const cacheFirst = deprecate('cacheFirst'); + /** + * @function workbox.strategies.cacheOnly + * @param {Object} options See the {@link workbox.strategies.CacheOnly} + * constructor for more info. + * @deprecated since v4.0.0 + */ + + const cacheOnly = deprecate('cacheOnly'); + /** + * @function workbox.strategies.networkFirst + * @param {Object} options See the {@link workbox.strategies.NetworkFirst} + * constructor for more info. + * @deprecated since v4.0.0 + */ + + const networkFirst = deprecate('networkFirst'); + /** + * @function workbox.strategies.networkOnly + * @param {Object} options See the {@link workbox.strategies.NetworkOnly} + * constructor for more info. + * @deprecated since v4.0.0 + */ + + const networkOnly = deprecate('networkOnly'); + /** + * @function workbox.strategies.staleWhileRevalidate + * @param {Object} options See the + * {@link workbox.strategies.StaleWhileRevalidate} constructor for more info. + * @deprecated since v4.0.0 + */ + + const staleWhileRevalidate = deprecate('staleWhileRevalidate'); + + exports.CacheFirst = CacheFirst; + exports.CacheOnly = CacheOnly; + exports.NetworkFirst = NetworkFirst; + exports.NetworkOnly = NetworkOnly; + exports.StaleWhileRevalidate = StaleWhileRevalidate; + exports.cacheFirst = cacheFirst; + exports.cacheOnly = cacheOnly; + exports.networkFirst = networkFirst; + exports.networkOnly = networkOnly; + exports.staleWhileRevalidate = staleWhileRevalidate; + + return exports; + +}({}, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core._private, workbox.core._private)); +//# sourceMappingURL=workbox-strategies.dev.js.map diff --git a/public/javascripts/workbox/workbox-strategies.dev.js.map b/public/javascripts/workbox/workbox-strategies.dev.js.map new file mode 100644 index 0000000000..12df5e6498 --- /dev/null +++ b/public/javascripts/workbox/workbox-strategies.dev.js.map @@ -0,0 +1 @@ +{"version":3,"file":"workbox-strategies.dev.js","sources":["../_version.mjs","../utils/messages.mjs","../CacheFirst.mjs","../CacheOnly.mjs","../plugins/cacheOkAndOpaquePlugin.mjs","../NetworkFirst.mjs","../NetworkOnly.mjs","../StaleWhileRevalidate.mjs","../index.mjs"],"sourcesContent":["try{self['workbox:strategies:4.3.1']&&_()}catch(e){}// eslint-disable-line","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport '../_version.mjs';\n\nconst getFriendlyURL = (url) => {\n const urlObj = new URL(url, location);\n if (urlObj.origin === location.origin) {\n return urlObj.pathname;\n }\n return urlObj.href;\n};\n\nexport const messages = {\n strategyStart: (strategyName, request) => `Using ${strategyName} to ` +\n `respond to '${getFriendlyURL(request.url)}'`,\n printFinalResponse: (response) => {\n if (response) {\n logger.groupCollapsed(`View the final response here.`);\n logger.log(response);\n logger.groupEnd();\n }\n },\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {cacheNames} from 'workbox-core/_private/cacheNames.mjs';\nimport {cacheWrapper} from 'workbox-core/_private/cacheWrapper.mjs';\nimport {fetchWrapper} from 'workbox-core/_private/fetchWrapper.mjs';\nimport {getFriendlyURL} from 'workbox-core/_private/getFriendlyURL.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\n\nimport {messages} from './utils/messages.mjs';\nimport './_version.mjs';\n\n/**\n * An implementation of a [cache-first]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#cache-falling-back-to-network}\n * request strategy.\n *\n * A cache first strategy is useful for assets that have been revisioned,\n * such as URLs like `/styles/example.a8f5f1.css`, since they\n * can be cached for long periods of time.\n *\n * If the network request fails, and there is no cache match, this will throw\n * a `WorkboxError` exception.\n *\n * @memberof workbox.strategies\n */\nclass CacheFirst {\n /**\n * @param {Object} options\n * @param {string} options.cacheName Cache name to store and retrieve\n * requests. Defaults to cache names provided by\n * [workbox-core]{@link workbox.core.cacheNames}.\n * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}\n * to use in conjunction with this caching strategy.\n * @param {Object} options.fetchOptions Values passed along to the\n * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters)\n * of all fetch() requests made by this strategy.\n * @param {Object} options.matchOptions [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions)\n */\n constructor(options = {}) {\n this._cacheName = cacheNames.getRuntimeName(options.cacheName);\n this._plugins = options.plugins || [];\n this._fetchOptions = options.fetchOptions || null;\n this._matchOptions = options.matchOptions || null;\n }\n\n /**\n * This method will perform a request strategy and follows an API that\n * will work with the\n * [Workbox Router]{@link workbox.routing.Router}.\n *\n * @param {Object} options\n * @param {Request} options.request The request to run this strategy for.\n * @param {Event} [options.event] The event that triggered the request.\n * @return {Promise}\n */\n async handle({event, request}) {\n return this.makeRequest({\n event,\n request: request || event.request,\n });\n }\n\n /**\n * This method can be used to perform a make a standalone request outside the\n * context of the [Workbox Router]{@link workbox.routing.Router}.\n *\n * See \"[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)\"\n * for more usage information.\n *\n * @param {Object} options\n * @param {Request|string} options.request Either a\n * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request}\n * object, or a string URL, corresponding to the request to be made.\n * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will\n be called automatically to extend the service worker's lifetime.\n * @return {Promise}\n */\n async makeRequest({event, request}) {\n const logs = [];\n\n if (typeof request === 'string') {\n request = new Request(request);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(request, Request, {\n moduleName: 'workbox-strategies',\n className: 'CacheFirst',\n funcName: 'makeRequest',\n paramName: 'request',\n });\n }\n\n let response = await cacheWrapper.match({\n cacheName: this._cacheName,\n request,\n event,\n matchOptions: this._matchOptions,\n plugins: this._plugins,\n });\n\n let error;\n if (!response) {\n if (process.env.NODE_ENV !== 'production') {\n logs.push(\n `No response found in the '${this._cacheName}' cache. ` +\n `Will respond with a network request.`);\n }\n try {\n response = await this._getFromNetwork(request, event);\n } catch (err) {\n error = err;\n }\n\n if (process.env.NODE_ENV !== 'production') {\n if (response) {\n logs.push(`Got response from network.`);\n } else {\n logs.push(`Unable to get a response from the network.`);\n }\n }\n } else {\n if (process.env.NODE_ENV !== 'production') {\n logs.push(\n `Found a cached response in the '${this._cacheName}' cache.`);\n }\n }\n\n if (process.env.NODE_ENV !== 'production') {\n logger.groupCollapsed(\n messages.strategyStart('CacheFirst', request));\n for (let log of logs) {\n logger.log(log);\n }\n messages.printFinalResponse(response);\n logger.groupEnd();\n }\n\n if (!response) {\n throw new WorkboxError('no-response', {url: request.url, error});\n }\n return response;\n }\n\n /**\n * Handles the network and cache part of CacheFirst.\n *\n * @param {Request} request\n * @param {FetchEvent} [event]\n * @return {Promise}\n *\n * @private\n */\n async _getFromNetwork(request, event) {\n const response = await fetchWrapper.fetch({\n request,\n event,\n fetchOptions: this._fetchOptions,\n plugins: this._plugins,\n });\n\n // Keep the service worker while we put the request to the cache\n const responseClone = response.clone();\n const cachePutPromise = cacheWrapper.put({\n cacheName: this._cacheName,\n request,\n response: responseClone,\n event,\n plugins: this._plugins,\n });\n\n if (event) {\n try {\n event.waitUntil(cachePutPromise);\n } catch (error) {\n if (process.env.NODE_ENV !== 'production') {\n logger.warn(`Unable to ensure service worker stays alive when ` +\n `updating cache for '${getFriendlyURL(request.url)}'.`);\n }\n }\n }\n\n return response;\n }\n}\n\nexport {CacheFirst};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {cacheNames} from 'workbox-core/_private/cacheNames.mjs';\nimport {cacheWrapper} from 'workbox-core/_private/cacheWrapper.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\n\nimport {messages} from './utils/messages.mjs';\nimport './_version.mjs';\n\n\n/**\n * An implementation of a\n * [cache-only]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#cache-only}\n * request strategy.\n *\n * This class is useful if you want to take advantage of any\n * [Workbox plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}.\n *\n * If there is no cache match, this will throw a `WorkboxError` exception.\n *\n * @memberof workbox.strategies\n */\nclass CacheOnly {\n /**\n * @param {Object} options\n * @param {string} options.cacheName Cache name to store and retrieve\n * requests. Defaults to cache names provided by\n * [workbox-core]{@link workbox.core.cacheNames}.\n * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}\n * to use in conjunction with this caching strategy.\n * @param {Object} options.matchOptions [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions)\n */\n constructor(options = {}) {\n this._cacheName = cacheNames.getRuntimeName(options.cacheName);\n this._plugins = options.plugins || [];\n this._matchOptions = options.matchOptions || null;\n }\n\n /**\n * This method will perform a request strategy and follows an API that\n * will work with the\n * [Workbox Router]{@link workbox.routing.Router}.\n *\n * @param {Object} options\n * @param {Request} options.request The request to run this strategy for.\n * @param {Event} [options.event] The event that triggered the request.\n * @return {Promise}\n */\n async handle({event, request}) {\n return this.makeRequest({\n event,\n request: request || event.request,\n });\n }\n\n /**\n * This method can be used to perform a make a standalone request outside the\n * context of the [Workbox Router]{@link workbox.routing.Router}.\n *\n * See \"[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)\"\n * for more usage information.\n *\n * @param {Object} options\n * @param {Request|string} options.request Either a\n * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request}\n * object, or a string URL, corresponding to the request to be made.\n * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will\n * be called automatically to extend the service worker's lifetime.\n * @return {Promise}\n */\n async makeRequest({event, request}) {\n if (typeof request === 'string') {\n request = new Request(request);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(request, Request, {\n moduleName: 'workbox-strategies',\n className: 'CacheOnly',\n funcName: 'makeRequest',\n paramName: 'request',\n });\n }\n\n const response = await cacheWrapper.match({\n cacheName: this._cacheName,\n request,\n event,\n matchOptions: this._matchOptions,\n plugins: this._plugins,\n });\n\n if (process.env.NODE_ENV !== 'production') {\n logger.groupCollapsed(\n messages.strategyStart('CacheOnly', request));\n if (response) {\n logger.log(`Found a cached response in the '${this._cacheName}'` +\n ` cache.`);\n messages.printFinalResponse(response);\n } else {\n logger.log(`No response found in the '${this._cacheName}' cache.`);\n }\n logger.groupEnd();\n }\n\n if (!response) {\n throw new WorkboxError('no-response', {url: request.url});\n }\n return response;\n }\n}\n\nexport {CacheOnly};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\nexport const cacheOkAndOpaquePlugin = {\n /**\n * Returns a valid response (to allow caching) if the status is 200 (OK) or\n * 0 (opaque).\n *\n * @param {Object} options\n * @param {Response} options.response\n * @return {Response|null}\n *\n * @private\n */\n cacheWillUpdate: ({response}) => {\n if (response.status === 200 || response.status === 0) {\n return response;\n }\n return null;\n },\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {cacheNames} from 'workbox-core/_private/cacheNames.mjs';\nimport {cacheWrapper} from 'workbox-core/_private/cacheWrapper.mjs';\nimport {fetchWrapper} from 'workbox-core/_private/fetchWrapper.mjs';\nimport {getFriendlyURL} from 'workbox-core/_private/getFriendlyURL.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\n\nimport {messages} from './utils/messages.mjs';\nimport {cacheOkAndOpaquePlugin} from './plugins/cacheOkAndOpaquePlugin.mjs';\nimport './_version.mjs';\n\n/**\n * An implementation of a\n * [network first]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#network-falling-back-to-cache}\n * request strategy.\n *\n * By default, this strategy will cache responses with a 200 status code as\n * well as [opaque responses]{@link https://developers.google.com/web/tools/workbox/guides/handle-third-party-requests}.\n * Opaque responses are are cross-origin requests where the response doesn't\n * support [CORS]{@link https://enable-cors.org/}.\n *\n * If the network request fails, and there is no cache match, this will throw\n * a `WorkboxError` exception.\n *\n * @memberof workbox.strategies\n */\nclass NetworkFirst {\n /**\n * @param {Object} options\n * @param {string} options.cacheName Cache name to store and retrieve\n * requests. Defaults to cache names provided by\n * [workbox-core]{@link workbox.core.cacheNames}.\n * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}\n * to use in conjunction with this caching strategy.\n * @param {Object} options.fetchOptions Values passed along to the\n * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters)\n * of all fetch() requests made by this strategy.\n * @param {Object} options.matchOptions [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions)\n * @param {number} options.networkTimeoutSeconds If set, any network requests\n * that fail to respond within the timeout will fallback to the cache.\n *\n * This option can be used to combat\n * \"[lie-fi]{@link https://developers.google.com/web/fundamentals/performance/poor-connectivity/#lie-fi}\"\n * scenarios.\n */\n constructor(options = {}) {\n this._cacheName = cacheNames.getRuntimeName(options.cacheName);\n\n if (options.plugins) {\n let isUsingCacheWillUpdate =\n options.plugins.some((plugin) => !!plugin.cacheWillUpdate);\n this._plugins = isUsingCacheWillUpdate ?\n options.plugins : [cacheOkAndOpaquePlugin, ...options.plugins];\n } else {\n // No plugins passed in, use the default plugin.\n this._plugins = [cacheOkAndOpaquePlugin];\n }\n\n this._networkTimeoutSeconds = options.networkTimeoutSeconds;\n if (process.env.NODE_ENV !== 'production') {\n if (this._networkTimeoutSeconds) {\n assert.isType(this._networkTimeoutSeconds, 'number', {\n moduleName: 'workbox-strategies',\n className: 'NetworkFirst',\n funcName: 'constructor',\n paramName: 'networkTimeoutSeconds',\n });\n }\n }\n\n this._fetchOptions = options.fetchOptions || null;\n this._matchOptions = options.matchOptions || null;\n }\n\n /**\n * This method will perform a request strategy and follows an API that\n * will work with the\n * [Workbox Router]{@link workbox.routing.Router}.\n *\n * @param {Object} options\n * @param {Request} options.request The request to run this strategy for.\n * @param {Event} [options.event] The event that triggered the request.\n * @return {Promise}\n */\n async handle({event, request}) {\n return this.makeRequest({\n event,\n request: request || event.request,\n });\n }\n\n /**\n * This method can be used to perform a make a standalone request outside the\n * context of the [Workbox Router]{@link workbox.routing.Router}.\n *\n * See \"[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)\"\n * for more usage information.\n *\n * @param {Object} options\n * @param {Request|string} options.request Either a\n * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request}\n * object, or a string URL, corresponding to the request to be made.\n * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will\n * be called automatically to extend the service worker's lifetime.\n * @return {Promise}\n */\n async makeRequest({event, request}) {\n const logs = [];\n\n if (typeof request === 'string') {\n request = new Request(request);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(request, Request, {\n moduleName: 'workbox-strategies',\n className: 'NetworkFirst',\n funcName: 'handle',\n paramName: 'makeRequest',\n });\n }\n\n const promises = [];\n let timeoutId;\n\n if (this._networkTimeoutSeconds) {\n const {id, promise} = this._getTimeoutPromise({request, event, logs});\n timeoutId = id;\n promises.push(promise);\n }\n\n const networkPromise =\n this._getNetworkPromise({timeoutId, request, event, logs});\n promises.push(networkPromise);\n\n // Promise.race() will resolve as soon as the first promise resolves.\n let response = await Promise.race(promises);\n // If Promise.race() resolved with null, it might be due to a network\n // timeout + a cache miss. If that were to happen, we'd rather wait until\n // the networkPromise resolves instead of returning null.\n // Note that it's fine to await an already-resolved promise, so we don't\n // have to check to see if it's still \"in flight\".\n if (!response) {\n response = await networkPromise;\n }\n\n if (process.env.NODE_ENV !== 'production') {\n logger.groupCollapsed(\n messages.strategyStart('NetworkFirst', request));\n for (let log of logs) {\n logger.log(log);\n }\n messages.printFinalResponse(response);\n logger.groupEnd();\n }\n\n if (!response) {\n throw new WorkboxError('no-response', {url: request.url});\n }\n return response;\n }\n\n /**\n * @param {Object} options\n * @param {Request} options.request\n * @param {Array} options.logs A reference to the logs array\n * @param {Event} [options.event]\n * @return {Promise}\n *\n * @private\n */\n _getTimeoutPromise({request, logs, event}) {\n let timeoutId;\n const timeoutPromise = new Promise((resolve) => {\n const onNetworkTimeout = async () => {\n if (process.env.NODE_ENV !== 'production') {\n logs.push(`Timing out the network response at ` +\n `${this._networkTimeoutSeconds} seconds.`);\n }\n\n resolve(await this._respondFromCache({request, event}));\n };\n\n timeoutId = setTimeout(\n onNetworkTimeout,\n this._networkTimeoutSeconds * 1000,\n );\n });\n\n return {\n promise: timeoutPromise,\n id: timeoutId,\n };\n }\n\n /**\n * @param {Object} options\n * @param {number|undefined} options.timeoutId\n * @param {Request} options.request\n * @param {Array} options.logs A reference to the logs Array.\n * @param {Event} [options.event]\n * @return {Promise}\n *\n * @private\n */\n async _getNetworkPromise({timeoutId, request, logs, event}) {\n let error;\n let response;\n try {\n response = await fetchWrapper.fetch({\n request,\n event,\n fetchOptions: this._fetchOptions,\n plugins: this._plugins,\n });\n } catch (err) {\n error = err;\n }\n\n if (timeoutId) {\n clearTimeout(timeoutId);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n if (response) {\n logs.push(`Got response from network.`);\n } else {\n logs.push(`Unable to get a response from the network. Will respond ` +\n `with a cached response.`);\n }\n }\n\n if (error || !response) {\n response = await this._respondFromCache({request, event});\n if (process.env.NODE_ENV !== 'production') {\n if (response) {\n logs.push(`Found a cached response in the '${this._cacheName}'` +\n ` cache.`);\n } else {\n logs.push(`No response found in the '${this._cacheName}' cache.`);\n }\n }\n } else {\n // Keep the service worker alive while we put the request in the cache\n const responseClone = response.clone();\n const cachePut = cacheWrapper.put({\n cacheName: this._cacheName,\n request,\n response: responseClone,\n event,\n plugins: this._plugins,\n });\n\n if (event) {\n try {\n // The event has been responded to so we can keep the SW alive to\n // respond to the request\n event.waitUntil(cachePut);\n } catch (err) {\n if (process.env.NODE_ENV !== 'production') {\n logger.warn(`Unable to ensure service worker stays alive when ` +\n `updating cache for '${getFriendlyURL(request.url)}'.`);\n }\n }\n }\n }\n\n return response;\n }\n\n /**\n * Used if the network timeouts or fails to make the request.\n *\n * @param {Object} options\n * @param {Request} request The request to match in the cache\n * @param {Event} [options.event]\n * @return {Promise}\n *\n * @private\n */\n _respondFromCache({event, request}) {\n return cacheWrapper.match({\n cacheName: this._cacheName,\n request,\n event,\n matchOptions: this._matchOptions,\n plugins: this._plugins,\n });\n }\n}\n\nexport {NetworkFirst};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {cacheNames} from 'workbox-core/_private/cacheNames.mjs';\nimport {fetchWrapper} from 'workbox-core/_private/fetchWrapper.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\n\nimport {messages} from './utils/messages.mjs';\nimport './_version.mjs';\n\n/**\n * An implementation of a\n * [network-only]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#network-only}\n * request strategy.\n *\n * This class is useful if you want to take advantage of any\n * [Workbox plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}.\n *\n * If the network request fails, this will throw a `WorkboxError` exception.\n *\n * @memberof workbox.strategies\n */\nclass NetworkOnly {\n /**\n * @param {Object} options\n * @param {string} options.cacheName Cache name to store and retrieve\n * requests. Defaults to cache names provided by\n * [workbox-core]{@link workbox.core.cacheNames}.\n * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}\n * to use in conjunction with this caching strategy.\n * @param {Object} options.fetchOptions Values passed along to the\n * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters)\n * of all fetch() requests made by this strategy.\n */\n constructor(options = {}) {\n this._cacheName = cacheNames.getRuntimeName(options.cacheName);\n this._plugins = options.plugins || [];\n this._fetchOptions = options.fetchOptions || null;\n }\n\n /**\n * This method will perform a request strategy and follows an API that\n * will work with the\n * [Workbox Router]{@link workbox.routing.Router}.\n *\n * @param {Object} options\n * @param {Request} options.request The request to run this strategy for.\n * @param {Event} [options.event] The event that triggered the request.\n * @return {Promise}\n */\n async handle({event, request}) {\n return this.makeRequest({\n event,\n request: request || event.request,\n });\n }\n\n /**\n * This method can be used to perform a make a standalone request outside the\n * context of the [Workbox Router]{@link workbox.routing.Router}.\n *\n * See \"[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)\"\n * for more usage information.\n *\n * @param {Object} options\n * @param {Request|string} options.request Either a\n * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request}\n * object, or a string URL, corresponding to the request to be made.\n * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will\n * be called automatically to extend the service worker's lifetime.\n * @return {Promise}\n */\n async makeRequest({event, request}) {\n if (typeof request === 'string') {\n request = new Request(request);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(request, Request, {\n moduleName: 'workbox-strategies',\n className: 'NetworkOnly',\n funcName: 'handle',\n paramName: 'request',\n });\n }\n\n let error;\n let response;\n try {\n response = await fetchWrapper.fetch({\n request,\n event,\n fetchOptions: this._fetchOptions,\n plugins: this._plugins,\n });\n } catch (err) {\n error = err;\n }\n\n if (process.env.NODE_ENV !== 'production') {\n logger.groupCollapsed(\n messages.strategyStart('NetworkOnly', request));\n if (response) {\n logger.log(`Got response from network.`);\n } else {\n logger.log(`Unable to get a response from the network.`);\n }\n messages.printFinalResponse(response);\n logger.groupEnd();\n }\n\n if (!response) {\n throw new WorkboxError('no-response', {url: request.url, error});\n }\n return response;\n }\n}\n\nexport {NetworkOnly};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {cacheNames} from 'workbox-core/_private/cacheNames.mjs';\nimport {cacheWrapper} from 'workbox-core/_private/cacheWrapper.mjs';\nimport {fetchWrapper} from 'workbox-core/_private/fetchWrapper.mjs';\nimport {getFriendlyURL} from 'workbox-core/_private/getFriendlyURL.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\n\nimport {messages} from './utils/messages.mjs';\nimport {cacheOkAndOpaquePlugin} from './plugins/cacheOkAndOpaquePlugin.mjs';\nimport './_version.mjs';\n\n/**\n * An implementation of a\n * [stale-while-revalidate]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#stale-while-revalidate}\n * request strategy.\n *\n * Resources are requested from both the cache and the network in parallel.\n * The strategy will respond with the cached version if available, otherwise\n * wait for the network response. The cache is updated with the network response\n * with each successful request.\n *\n * By default, this strategy will cache responses with a 200 status code as\n * well as [opaque responses]{@link https://developers.google.com/web/tools/workbox/guides/handle-third-party-requests}.\n * Opaque responses are are cross-origin requests where the response doesn't\n * support [CORS]{@link https://enable-cors.org/}.\n *\n * If the network request fails, and there is no cache match, this will throw\n * a `WorkboxError` exception.\n *\n * @memberof workbox.strategies\n */\nclass StaleWhileRevalidate {\n /**\n * @param {Object} options\n * @param {string} options.cacheName Cache name to store and retrieve\n * requests. Defaults to cache names provided by\n * [workbox-core]{@link workbox.core.cacheNames}.\n * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}\n * to use in conjunction with this caching strategy.\n * @param {Object} options.fetchOptions Values passed along to the\n * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters)\n * of all fetch() requests made by this strategy.\n * @param {Object} options.matchOptions [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions)\n */\n constructor(options = {}) {\n this._cacheName = cacheNames.getRuntimeName(options.cacheName);\n this._plugins = options.plugins || [];\n\n if (options.plugins) {\n let isUsingCacheWillUpdate =\n options.plugins.some((plugin) => !!plugin.cacheWillUpdate);\n this._plugins = isUsingCacheWillUpdate ?\n options.plugins : [cacheOkAndOpaquePlugin, ...options.plugins];\n } else {\n // No plugins passed in, use the default plugin.\n this._plugins = [cacheOkAndOpaquePlugin];\n }\n\n this._fetchOptions = options.fetchOptions || null;\n this._matchOptions = options.matchOptions || null;\n }\n\n /**\n * This method will perform a request strategy and follows an API that\n * will work with the\n * [Workbox Router]{@link workbox.routing.Router}.\n *\n * @param {Object} options\n * @param {Request} options.request The request to run this strategy for.\n * @param {Event} [options.event] The event that triggered the request.\n * @return {Promise}\n */\n async handle({event, request}) {\n return this.makeRequest({\n event,\n request: request || event.request,\n });\n }\n /**\n * This method can be used to perform a make a standalone request outside the\n * context of the [Workbox Router]{@link workbox.routing.Router}.\n *\n * See \"[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)\"\n * for more usage information.\n *\n * @param {Object} options\n * @param {Request|string} options.request Either a\n * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request}\n * object, or a string URL, corresponding to the request to be made.\n * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will\n * be called automatically to extend the service worker's lifetime.\n * @return {Promise}\n */\n async makeRequest({event, request}) {\n const logs = [];\n\n if (typeof request === 'string') {\n request = new Request(request);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(request, Request, {\n moduleName: 'workbox-strategies',\n className: 'StaleWhileRevalidate',\n funcName: 'handle',\n paramName: 'request',\n });\n }\n\n const fetchAndCachePromise = this._getFromNetwork({request, event});\n\n let response = await cacheWrapper.match({\n cacheName: this._cacheName,\n request,\n event,\n matchOptions: this._matchOptions,\n plugins: this._plugins,\n });\n let error;\n if (response) {\n if (process.env.NODE_ENV !== 'production') {\n logs.push(`Found a cached response in the '${this._cacheName}'` +\n ` cache. Will update with the network response in the background.`);\n }\n\n if (event) {\n try {\n event.waitUntil(fetchAndCachePromise);\n } catch (error) {\n if (process.env.NODE_ENV !== 'production') {\n logger.warn(`Unable to ensure service worker stays alive when ` +\n `updating cache for '${getFriendlyURL(request.url)}'.`);\n }\n }\n }\n } else {\n if (process.env.NODE_ENV !== 'production') {\n logs.push(`No response found in the '${this._cacheName}' cache. ` +\n `Will wait for the network response.`);\n }\n try {\n response = await fetchAndCachePromise;\n } catch (err) {\n error = err;\n }\n }\n\n if (process.env.NODE_ENV !== 'production') {\n logger.groupCollapsed(\n messages.strategyStart('StaleWhileRevalidate', request));\n for (let log of logs) {\n logger.log(log);\n }\n messages.printFinalResponse(response);\n logger.groupEnd();\n }\n\n if (!response) {\n throw new WorkboxError('no-response', {url: request.url, error});\n }\n return response;\n }\n\n /**\n * @param {Object} options\n * @param {Request} options.request\n * @param {Event} [options.event]\n * @return {Promise}\n *\n * @private\n */\n async _getFromNetwork({request, event}) {\n const response = await fetchWrapper.fetch({\n request,\n event,\n fetchOptions: this._fetchOptions,\n plugins: this._plugins,\n });\n\n const cachePutPromise = cacheWrapper.put({\n cacheName: this._cacheName,\n request,\n response: response.clone(),\n event,\n plugins: this._plugins,\n });\n\n if (event) {\n try {\n event.waitUntil(cachePutPromise);\n } catch (error) {\n if (process.env.NODE_ENV !== 'production') {\n logger.warn(`Unable to ensure service worker stays alive when ` +\n `updating cache for '${getFriendlyURL(request.url)}'.`);\n }\n }\n }\n\n return response;\n }\n}\n\nexport {StaleWhileRevalidate};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {CacheFirst} from './CacheFirst.mjs';\nimport {CacheOnly} from './CacheOnly.mjs';\nimport {NetworkFirst} from './NetworkFirst.mjs';\nimport {NetworkOnly} from './NetworkOnly.mjs';\nimport {StaleWhileRevalidate} from './StaleWhileRevalidate.mjs';\nimport './_version.mjs';\n\n\nconst mapping = {\n cacheFirst: CacheFirst,\n cacheOnly: CacheOnly,\n networkFirst: NetworkFirst,\n networkOnly: NetworkOnly,\n staleWhileRevalidate: StaleWhileRevalidate,\n};\n\nconst deprecate = (strategy) => {\n const StrategyCtr = mapping[strategy];\n\n return (options) => {\n if (process.env.NODE_ENV !== 'production') {\n const strategyCtrName = strategy[0].toUpperCase() + strategy.slice(1);\n logger.warn(`The 'workbox.strategies.${strategy}()' function has been ` +\n `deprecated and will be removed in a future version of Workbox.\\n` +\n `Please use 'new workbox.strategies.${strategyCtrName}()' instead.`);\n }\n return new StrategyCtr(options);\n };\n};\n\n/**\n * @function workbox.strategies.cacheFirst\n * @param {Object} options See the {@link workbox.strategies.CacheFirst}\n * constructor for more info.\n * @deprecated since v4.0.0\n */\nconst cacheFirst = deprecate('cacheFirst');\n\n/**\n * @function workbox.strategies.cacheOnly\n * @param {Object} options See the {@link workbox.strategies.CacheOnly}\n * constructor for more info.\n * @deprecated since v4.0.0\n */\nconst cacheOnly = deprecate('cacheOnly');\n\n/**\n * @function workbox.strategies.networkFirst\n * @param {Object} options See the {@link workbox.strategies.NetworkFirst}\n * constructor for more info.\n * @deprecated since v4.0.0\n */\nconst networkFirst = deprecate('networkFirst');\n\n/**\n * @function workbox.strategies.networkOnly\n * @param {Object} options See the {@link workbox.strategies.NetworkOnly}\n * constructor for more info.\n * @deprecated since v4.0.0\n */\nconst networkOnly = deprecate('networkOnly');\n\n/**\n * @function workbox.strategies.staleWhileRevalidate\n * @param {Object} options See the\n * {@link workbox.strategies.StaleWhileRevalidate} constructor for more info.\n * @deprecated since v4.0.0\n */\nconst staleWhileRevalidate = deprecate('staleWhileRevalidate');\n\n/**\n * There are common caching strategies that most service workers will need\n * and use. This module provides simple implementations of these strategies.\n *\n * @namespace workbox.strategies\n */\n\nexport {\n CacheFirst,\n CacheOnly,\n NetworkFirst,\n NetworkOnly,\n StaleWhileRevalidate,\n\n // Deprecated...\n cacheFirst,\n cacheOnly,\n networkFirst,\n networkOnly,\n staleWhileRevalidate,\n};\n\n"],"names":["self","_","e","getFriendlyURL","url","urlObj","URL","location","origin","pathname","href","messages","strategyStart","strategyName","request","printFinalResponse","response","logger","groupCollapsed","log","groupEnd","CacheFirst","constructor","options","_cacheName","cacheNames","getRuntimeName","cacheName","_plugins","plugins","_fetchOptions","fetchOptions","_matchOptions","matchOptions","handle","event","makeRequest","logs","Request","assert","isInstance","moduleName","className","funcName","paramName","cacheWrapper","match","error","push","_getFromNetwork","err","WorkboxError","fetchWrapper","fetch","responseClone","clone","cachePutPromise","put","waitUntil","warn","CacheOnly","cacheOkAndOpaquePlugin","cacheWillUpdate","status","NetworkFirst","isUsingCacheWillUpdate","some","plugin","_networkTimeoutSeconds","networkTimeoutSeconds","isType","promises","timeoutId","id","promise","_getTimeoutPromise","networkPromise","_getNetworkPromise","Promise","race","timeoutPromise","resolve","onNetworkTimeout","_respondFromCache","setTimeout","clearTimeout","cachePut","NetworkOnly","StaleWhileRevalidate","fetchAndCachePromise","mapping","cacheFirst","cacheOnly","networkFirst","networkOnly","staleWhileRevalidate","deprecate","strategy","StrategyCtr","strategyCtrName","toUpperCase","slice"],"mappings":"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"} \ No newline at end of file diff --git a/public/javascripts/workbox/workbox-strategies.prod.js b/public/javascripts/workbox/workbox-strategies.prod.js new file mode 100644 index 0000000000..29909af302 --- /dev/null +++ b/public/javascripts/workbox/workbox-strategies.prod.js @@ -0,0 +1,2 @@ +this.workbox=this.workbox||{},this.workbox.strategies=function(e,t,s,n,r){"use strict";try{self["workbox:strategies:4.3.1"]&&_()}catch(e){}class i{constructor(e={}){this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],this.i=e.fetchOptions||null,this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){"string"==typeof t&&(t=new Request(t));let n,i=await s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s});if(!i)try{i=await this.u(t,e)}catch(e){n=e}if(!i)throw new r.WorkboxError("no-response",{url:t.url,error:n});return i}async u(e,t){const r=await n.fetchWrapper.fetch({request:e,event:t,fetchOptions:this.i,plugins:this.s}),i=r.clone(),h=s.cacheWrapper.put({cacheName:this.t,request:e,response:i,event:t,plugins:this.s});if(t)try{t.waitUntil(h)}catch(e){}return r}}class h{constructor(e={}){this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){"string"==typeof t&&(t=new Request(t));const n=await s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s});if(!n)throw new r.WorkboxError("no-response",{url:t.url});return n}}const u={cacheWillUpdate:({response:e})=>200===e.status||0===e.status?e:null};class a{constructor(e={}){if(this.t=t.cacheNames.getRuntimeName(e.cacheName),e.plugins){let t=e.plugins.some(e=>!!e.cacheWillUpdate);this.s=t?e.plugins:[u,...e.plugins]}else this.s=[u];this.o=e.networkTimeoutSeconds,this.i=e.fetchOptions||null,this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){const s=[];"string"==typeof t&&(t=new Request(t));const n=[];let i;if(this.o){const{id:r,promise:h}=this.l({request:t,event:e,logs:s});i=r,n.push(h)}const h=this.q({timeoutId:i,request:t,event:e,logs:s});n.push(h);let u=await Promise.race(n);if(u||(u=await h),!u)throw new r.WorkboxError("no-response",{url:t.url});return u}l({request:e,logs:t,event:s}){let n;return{promise:new Promise(t=>{n=setTimeout(async()=>{t(await this.p({request:e,event:s}))},1e3*this.o)}),id:n}}async q({timeoutId:e,request:t,logs:r,event:i}){let h,u;try{u=await n.fetchWrapper.fetch({request:t,event:i,fetchOptions:this.i,plugins:this.s})}catch(e){h=e}if(e&&clearTimeout(e),h||!u)u=await this.p({request:t,event:i});else{const e=u.clone(),n=s.cacheWrapper.put({cacheName:this.t,request:t,response:e,event:i,plugins:this.s});if(i)try{i.waitUntil(n)}catch(e){}}return u}p({event:e,request:t}){return s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s})}}class c{constructor(e={}){this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],this.i=e.fetchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){let s,i;"string"==typeof t&&(t=new Request(t));try{i=await n.fetchWrapper.fetch({request:t,event:e,fetchOptions:this.i,plugins:this.s})}catch(e){s=e}if(!i)throw new r.WorkboxError("no-response",{url:t.url,error:s});return i}}class o{constructor(e={}){if(this.t=t.cacheNames.getRuntimeName(e.cacheName),this.s=e.plugins||[],e.plugins){let t=e.plugins.some(e=>!!e.cacheWillUpdate);this.s=t?e.plugins:[u,...e.plugins]}else this.s=[u];this.i=e.fetchOptions||null,this.h=e.matchOptions||null}async handle({event:e,request:t}){return this.makeRequest({event:e,request:t||e.request})}async makeRequest({event:e,request:t}){"string"==typeof t&&(t=new Request(t));const n=this.u({request:t,event:e});let i,h=await s.cacheWrapper.match({cacheName:this.t,request:t,event:e,matchOptions:this.h,plugins:this.s});if(h){if(e)try{e.waitUntil(n)}catch(i){}}else try{h=await n}catch(e){i=e}if(!h)throw new r.WorkboxError("no-response",{url:t.url,error:i});return h}async u({request:e,event:t}){const r=await n.fetchWrapper.fetch({request:e,event:t,fetchOptions:this.i,plugins:this.s}),i=s.cacheWrapper.put({cacheName:this.t,request:e,response:r.clone(),event:t,plugins:this.s});if(t)try{t.waitUntil(i)}catch(e){}return r}}const l={cacheFirst:i,cacheOnly:h,networkFirst:a,networkOnly:c,staleWhileRevalidate:o},q=e=>{const t=l[e];return e=>new t(e)},w=q("cacheFirst"),p=q("cacheOnly"),v=q("networkFirst"),y=q("networkOnly"),m=q("staleWhileRevalidate");return e.CacheFirst=i,e.CacheOnly=h,e.NetworkFirst=a,e.NetworkOnly=c,e.StaleWhileRevalidate=o,e.cacheFirst=w,e.cacheOnly=p,e.networkFirst=v,e.networkOnly=y,e.staleWhileRevalidate=m,e}({},workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private); +//# sourceMappingURL=workbox-strategies.prod.js.map diff --git a/public/javascripts/workbox/workbox-strategies.prod.js.map b/public/javascripts/workbox/workbox-strategies.prod.js.map new file mode 100644 index 0000000000..6ad0b3e377 --- /dev/null +++ b/public/javascripts/workbox/workbox-strategies.prod.js.map @@ -0,0 +1 @@ +{"version":3,"file":"workbox-strategies.prod.js","sources":["../_version.mjs","../CacheFirst.mjs","../CacheOnly.mjs","../plugins/cacheOkAndOpaquePlugin.mjs","../NetworkFirst.mjs","../NetworkOnly.mjs","../StaleWhileRevalidate.mjs","../index.mjs"],"sourcesContent":["try{self['workbox:strategies:4.3.1']&&_()}catch(e){}// eslint-disable-line","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {cacheNames} from 'workbox-core/_private/cacheNames.mjs';\nimport {cacheWrapper} from 'workbox-core/_private/cacheWrapper.mjs';\nimport {fetchWrapper} from 'workbox-core/_private/fetchWrapper.mjs';\nimport {getFriendlyURL} from 'workbox-core/_private/getFriendlyURL.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\n\nimport {messages} from './utils/messages.mjs';\nimport './_version.mjs';\n\n/**\n * An implementation of a [cache-first]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#cache-falling-back-to-network}\n * request strategy.\n *\n * A cache first strategy is useful for assets that have been revisioned,\n * such as URLs like `/styles/example.a8f5f1.css`, since they\n * can be cached for long periods of time.\n *\n * If the network request fails, and there is no cache match, this will throw\n * a `WorkboxError` exception.\n *\n * @memberof workbox.strategies\n */\nclass CacheFirst {\n /**\n * @param {Object} options\n * @param {string} options.cacheName Cache name to store and retrieve\n * requests. Defaults to cache names provided by\n * [workbox-core]{@link workbox.core.cacheNames}.\n * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}\n * to use in conjunction with this caching strategy.\n * @param {Object} options.fetchOptions Values passed along to the\n * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters)\n * of all fetch() requests made by this strategy.\n * @param {Object} options.matchOptions [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions)\n */\n constructor(options = {}) {\n this._cacheName = cacheNames.getRuntimeName(options.cacheName);\n this._plugins = options.plugins || [];\n this._fetchOptions = options.fetchOptions || null;\n this._matchOptions = options.matchOptions || null;\n }\n\n /**\n * This method will perform a request strategy and follows an API that\n * will work with the\n * [Workbox Router]{@link workbox.routing.Router}.\n *\n * @param {Object} options\n * @param {Request} options.request The request to run this strategy for.\n * @param {Event} [options.event] The event that triggered the request.\n * @return {Promise}\n */\n async handle({event, request}) {\n return this.makeRequest({\n event,\n request: request || event.request,\n });\n }\n\n /**\n * This method can be used to perform a make a standalone request outside the\n * context of the [Workbox Router]{@link workbox.routing.Router}.\n *\n * See \"[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)\"\n * for more usage information.\n *\n * @param {Object} options\n * @param {Request|string} options.request Either a\n * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request}\n * object, or a string URL, corresponding to the request to be made.\n * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will\n be called automatically to extend the service worker's lifetime.\n * @return {Promise}\n */\n async makeRequest({event, request}) {\n const logs = [];\n\n if (typeof request === 'string') {\n request = new Request(request);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(request, Request, {\n moduleName: 'workbox-strategies',\n className: 'CacheFirst',\n funcName: 'makeRequest',\n paramName: 'request',\n });\n }\n\n let response = await cacheWrapper.match({\n cacheName: this._cacheName,\n request,\n event,\n matchOptions: this._matchOptions,\n plugins: this._plugins,\n });\n\n let error;\n if (!response) {\n if (process.env.NODE_ENV !== 'production') {\n logs.push(\n `No response found in the '${this._cacheName}' cache. ` +\n `Will respond with a network request.`);\n }\n try {\n response = await this._getFromNetwork(request, event);\n } catch (err) {\n error = err;\n }\n\n if (process.env.NODE_ENV !== 'production') {\n if (response) {\n logs.push(`Got response from network.`);\n } else {\n logs.push(`Unable to get a response from the network.`);\n }\n }\n } else {\n if (process.env.NODE_ENV !== 'production') {\n logs.push(\n `Found a cached response in the '${this._cacheName}' cache.`);\n }\n }\n\n if (process.env.NODE_ENV !== 'production') {\n logger.groupCollapsed(\n messages.strategyStart('CacheFirst', request));\n for (let log of logs) {\n logger.log(log);\n }\n messages.printFinalResponse(response);\n logger.groupEnd();\n }\n\n if (!response) {\n throw new WorkboxError('no-response', {url: request.url, error});\n }\n return response;\n }\n\n /**\n * Handles the network and cache part of CacheFirst.\n *\n * @param {Request} request\n * @param {FetchEvent} [event]\n * @return {Promise}\n *\n * @private\n */\n async _getFromNetwork(request, event) {\n const response = await fetchWrapper.fetch({\n request,\n event,\n fetchOptions: this._fetchOptions,\n plugins: this._plugins,\n });\n\n // Keep the service worker while we put the request to the cache\n const responseClone = response.clone();\n const cachePutPromise = cacheWrapper.put({\n cacheName: this._cacheName,\n request,\n response: responseClone,\n event,\n plugins: this._plugins,\n });\n\n if (event) {\n try {\n event.waitUntil(cachePutPromise);\n } catch (error) {\n if (process.env.NODE_ENV !== 'production') {\n logger.warn(`Unable to ensure service worker stays alive when ` +\n `updating cache for '${getFriendlyURL(request.url)}'.`);\n }\n }\n }\n\n return response;\n }\n}\n\nexport {CacheFirst};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {cacheNames} from 'workbox-core/_private/cacheNames.mjs';\nimport {cacheWrapper} from 'workbox-core/_private/cacheWrapper.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\n\nimport {messages} from './utils/messages.mjs';\nimport './_version.mjs';\n\n\n/**\n * An implementation of a\n * [cache-only]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#cache-only}\n * request strategy.\n *\n * This class is useful if you want to take advantage of any\n * [Workbox plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}.\n *\n * If there is no cache match, this will throw a `WorkboxError` exception.\n *\n * @memberof workbox.strategies\n */\nclass CacheOnly {\n /**\n * @param {Object} options\n * @param {string} options.cacheName Cache name to store and retrieve\n * requests. Defaults to cache names provided by\n * [workbox-core]{@link workbox.core.cacheNames}.\n * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}\n * to use in conjunction with this caching strategy.\n * @param {Object} options.matchOptions [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions)\n */\n constructor(options = {}) {\n this._cacheName = cacheNames.getRuntimeName(options.cacheName);\n this._plugins = options.plugins || [];\n this._matchOptions = options.matchOptions || null;\n }\n\n /**\n * This method will perform a request strategy and follows an API that\n * will work with the\n * [Workbox Router]{@link workbox.routing.Router}.\n *\n * @param {Object} options\n * @param {Request} options.request The request to run this strategy for.\n * @param {Event} [options.event] The event that triggered the request.\n * @return {Promise}\n */\n async handle({event, request}) {\n return this.makeRequest({\n event,\n request: request || event.request,\n });\n }\n\n /**\n * This method can be used to perform a make a standalone request outside the\n * context of the [Workbox Router]{@link workbox.routing.Router}.\n *\n * See \"[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)\"\n * for more usage information.\n *\n * @param {Object} options\n * @param {Request|string} options.request Either a\n * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request}\n * object, or a string URL, corresponding to the request to be made.\n * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will\n * be called automatically to extend the service worker's lifetime.\n * @return {Promise}\n */\n async makeRequest({event, request}) {\n if (typeof request === 'string') {\n request = new Request(request);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(request, Request, {\n moduleName: 'workbox-strategies',\n className: 'CacheOnly',\n funcName: 'makeRequest',\n paramName: 'request',\n });\n }\n\n const response = await cacheWrapper.match({\n cacheName: this._cacheName,\n request,\n event,\n matchOptions: this._matchOptions,\n plugins: this._plugins,\n });\n\n if (process.env.NODE_ENV !== 'production') {\n logger.groupCollapsed(\n messages.strategyStart('CacheOnly', request));\n if (response) {\n logger.log(`Found a cached response in the '${this._cacheName}'` +\n ` cache.`);\n messages.printFinalResponse(response);\n } else {\n logger.log(`No response found in the '${this._cacheName}' cache.`);\n }\n logger.groupEnd();\n }\n\n if (!response) {\n throw new WorkboxError('no-response', {url: request.url});\n }\n return response;\n }\n}\n\nexport {CacheOnly};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\nexport const cacheOkAndOpaquePlugin = {\n /**\n * Returns a valid response (to allow caching) if the status is 200 (OK) or\n * 0 (opaque).\n *\n * @param {Object} options\n * @param {Response} options.response\n * @return {Response|null}\n *\n * @private\n */\n cacheWillUpdate: ({response}) => {\n if (response.status === 200 || response.status === 0) {\n return response;\n }\n return null;\n },\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {cacheNames} from 'workbox-core/_private/cacheNames.mjs';\nimport {cacheWrapper} from 'workbox-core/_private/cacheWrapper.mjs';\nimport {fetchWrapper} from 'workbox-core/_private/fetchWrapper.mjs';\nimport {getFriendlyURL} from 'workbox-core/_private/getFriendlyURL.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\n\nimport {messages} from './utils/messages.mjs';\nimport {cacheOkAndOpaquePlugin} from './plugins/cacheOkAndOpaquePlugin.mjs';\nimport './_version.mjs';\n\n/**\n * An implementation of a\n * [network first]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#network-falling-back-to-cache}\n * request strategy.\n *\n * By default, this strategy will cache responses with a 200 status code as\n * well as [opaque responses]{@link https://developers.google.com/web/tools/workbox/guides/handle-third-party-requests}.\n * Opaque responses are are cross-origin requests where the response doesn't\n * support [CORS]{@link https://enable-cors.org/}.\n *\n * If the network request fails, and there is no cache match, this will throw\n * a `WorkboxError` exception.\n *\n * @memberof workbox.strategies\n */\nclass NetworkFirst {\n /**\n * @param {Object} options\n * @param {string} options.cacheName Cache name to store and retrieve\n * requests. Defaults to cache names provided by\n * [workbox-core]{@link workbox.core.cacheNames}.\n * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}\n * to use in conjunction with this caching strategy.\n * @param {Object} options.fetchOptions Values passed along to the\n * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters)\n * of all fetch() requests made by this strategy.\n * @param {Object} options.matchOptions [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions)\n * @param {number} options.networkTimeoutSeconds If set, any network requests\n * that fail to respond within the timeout will fallback to the cache.\n *\n * This option can be used to combat\n * \"[lie-fi]{@link https://developers.google.com/web/fundamentals/performance/poor-connectivity/#lie-fi}\"\n * scenarios.\n */\n constructor(options = {}) {\n this._cacheName = cacheNames.getRuntimeName(options.cacheName);\n\n if (options.plugins) {\n let isUsingCacheWillUpdate =\n options.plugins.some((plugin) => !!plugin.cacheWillUpdate);\n this._plugins = isUsingCacheWillUpdate ?\n options.plugins : [cacheOkAndOpaquePlugin, ...options.plugins];\n } else {\n // No plugins passed in, use the default plugin.\n this._plugins = [cacheOkAndOpaquePlugin];\n }\n\n this._networkTimeoutSeconds = options.networkTimeoutSeconds;\n if (process.env.NODE_ENV !== 'production') {\n if (this._networkTimeoutSeconds) {\n assert.isType(this._networkTimeoutSeconds, 'number', {\n moduleName: 'workbox-strategies',\n className: 'NetworkFirst',\n funcName: 'constructor',\n paramName: 'networkTimeoutSeconds',\n });\n }\n }\n\n this._fetchOptions = options.fetchOptions || null;\n this._matchOptions = options.matchOptions || null;\n }\n\n /**\n * This method will perform a request strategy and follows an API that\n * will work with the\n * [Workbox Router]{@link workbox.routing.Router}.\n *\n * @param {Object} options\n * @param {Request} options.request The request to run this strategy for.\n * @param {Event} [options.event] The event that triggered the request.\n * @return {Promise}\n */\n async handle({event, request}) {\n return this.makeRequest({\n event,\n request: request || event.request,\n });\n }\n\n /**\n * This method can be used to perform a make a standalone request outside the\n * context of the [Workbox Router]{@link workbox.routing.Router}.\n *\n * See \"[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)\"\n * for more usage information.\n *\n * @param {Object} options\n * @param {Request|string} options.request Either a\n * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request}\n * object, or a string URL, corresponding to the request to be made.\n * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will\n * be called automatically to extend the service worker's lifetime.\n * @return {Promise}\n */\n async makeRequest({event, request}) {\n const logs = [];\n\n if (typeof request === 'string') {\n request = new Request(request);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(request, Request, {\n moduleName: 'workbox-strategies',\n className: 'NetworkFirst',\n funcName: 'handle',\n paramName: 'makeRequest',\n });\n }\n\n const promises = [];\n let timeoutId;\n\n if (this._networkTimeoutSeconds) {\n const {id, promise} = this._getTimeoutPromise({request, event, logs});\n timeoutId = id;\n promises.push(promise);\n }\n\n const networkPromise =\n this._getNetworkPromise({timeoutId, request, event, logs});\n promises.push(networkPromise);\n\n // Promise.race() will resolve as soon as the first promise resolves.\n let response = await Promise.race(promises);\n // If Promise.race() resolved with null, it might be due to a network\n // timeout + a cache miss. If that were to happen, we'd rather wait until\n // the networkPromise resolves instead of returning null.\n // Note that it's fine to await an already-resolved promise, so we don't\n // have to check to see if it's still \"in flight\".\n if (!response) {\n response = await networkPromise;\n }\n\n if (process.env.NODE_ENV !== 'production') {\n logger.groupCollapsed(\n messages.strategyStart('NetworkFirst', request));\n for (let log of logs) {\n logger.log(log);\n }\n messages.printFinalResponse(response);\n logger.groupEnd();\n }\n\n if (!response) {\n throw new WorkboxError('no-response', {url: request.url});\n }\n return response;\n }\n\n /**\n * @param {Object} options\n * @param {Request} options.request\n * @param {Array} options.logs A reference to the logs array\n * @param {Event} [options.event]\n * @return {Promise}\n *\n * @private\n */\n _getTimeoutPromise({request, logs, event}) {\n let timeoutId;\n const timeoutPromise = new Promise((resolve) => {\n const onNetworkTimeout = async () => {\n if (process.env.NODE_ENV !== 'production') {\n logs.push(`Timing out the network response at ` +\n `${this._networkTimeoutSeconds} seconds.`);\n }\n\n resolve(await this._respondFromCache({request, event}));\n };\n\n timeoutId = setTimeout(\n onNetworkTimeout,\n this._networkTimeoutSeconds * 1000,\n );\n });\n\n return {\n promise: timeoutPromise,\n id: timeoutId,\n };\n }\n\n /**\n * @param {Object} options\n * @param {number|undefined} options.timeoutId\n * @param {Request} options.request\n * @param {Array} options.logs A reference to the logs Array.\n * @param {Event} [options.event]\n * @return {Promise}\n *\n * @private\n */\n async _getNetworkPromise({timeoutId, request, logs, event}) {\n let error;\n let response;\n try {\n response = await fetchWrapper.fetch({\n request,\n event,\n fetchOptions: this._fetchOptions,\n plugins: this._plugins,\n });\n } catch (err) {\n error = err;\n }\n\n if (timeoutId) {\n clearTimeout(timeoutId);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n if (response) {\n logs.push(`Got response from network.`);\n } else {\n logs.push(`Unable to get a response from the network. Will respond ` +\n `with a cached response.`);\n }\n }\n\n if (error || !response) {\n response = await this._respondFromCache({request, event});\n if (process.env.NODE_ENV !== 'production') {\n if (response) {\n logs.push(`Found a cached response in the '${this._cacheName}'` +\n ` cache.`);\n } else {\n logs.push(`No response found in the '${this._cacheName}' cache.`);\n }\n }\n } else {\n // Keep the service worker alive while we put the request in the cache\n const responseClone = response.clone();\n const cachePut = cacheWrapper.put({\n cacheName: this._cacheName,\n request,\n response: responseClone,\n event,\n plugins: this._plugins,\n });\n\n if (event) {\n try {\n // The event has been responded to so we can keep the SW alive to\n // respond to the request\n event.waitUntil(cachePut);\n } catch (err) {\n if (process.env.NODE_ENV !== 'production') {\n logger.warn(`Unable to ensure service worker stays alive when ` +\n `updating cache for '${getFriendlyURL(request.url)}'.`);\n }\n }\n }\n }\n\n return response;\n }\n\n /**\n * Used if the network timeouts or fails to make the request.\n *\n * @param {Object} options\n * @param {Request} request The request to match in the cache\n * @param {Event} [options.event]\n * @return {Promise}\n *\n * @private\n */\n _respondFromCache({event, request}) {\n return cacheWrapper.match({\n cacheName: this._cacheName,\n request,\n event,\n matchOptions: this._matchOptions,\n plugins: this._plugins,\n });\n }\n}\n\nexport {NetworkFirst};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {cacheNames} from 'workbox-core/_private/cacheNames.mjs';\nimport {fetchWrapper} from 'workbox-core/_private/fetchWrapper.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\n\nimport {messages} from './utils/messages.mjs';\nimport './_version.mjs';\n\n/**\n * An implementation of a\n * [network-only]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#network-only}\n * request strategy.\n *\n * This class is useful if you want to take advantage of any\n * [Workbox plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}.\n *\n * If the network request fails, this will throw a `WorkboxError` exception.\n *\n * @memberof workbox.strategies\n */\nclass NetworkOnly {\n /**\n * @param {Object} options\n * @param {string} options.cacheName Cache name to store and retrieve\n * requests. Defaults to cache names provided by\n * [workbox-core]{@link workbox.core.cacheNames}.\n * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}\n * to use in conjunction with this caching strategy.\n * @param {Object} options.fetchOptions Values passed along to the\n * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters)\n * of all fetch() requests made by this strategy.\n */\n constructor(options = {}) {\n this._cacheName = cacheNames.getRuntimeName(options.cacheName);\n this._plugins = options.plugins || [];\n this._fetchOptions = options.fetchOptions || null;\n }\n\n /**\n * This method will perform a request strategy and follows an API that\n * will work with the\n * [Workbox Router]{@link workbox.routing.Router}.\n *\n * @param {Object} options\n * @param {Request} options.request The request to run this strategy for.\n * @param {Event} [options.event] The event that triggered the request.\n * @return {Promise}\n */\n async handle({event, request}) {\n return this.makeRequest({\n event,\n request: request || event.request,\n });\n }\n\n /**\n * This method can be used to perform a make a standalone request outside the\n * context of the [Workbox Router]{@link workbox.routing.Router}.\n *\n * See \"[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)\"\n * for more usage information.\n *\n * @param {Object} options\n * @param {Request|string} options.request Either a\n * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request}\n * object, or a string URL, corresponding to the request to be made.\n * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will\n * be called automatically to extend the service worker's lifetime.\n * @return {Promise}\n */\n async makeRequest({event, request}) {\n if (typeof request === 'string') {\n request = new Request(request);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(request, Request, {\n moduleName: 'workbox-strategies',\n className: 'NetworkOnly',\n funcName: 'handle',\n paramName: 'request',\n });\n }\n\n let error;\n let response;\n try {\n response = await fetchWrapper.fetch({\n request,\n event,\n fetchOptions: this._fetchOptions,\n plugins: this._plugins,\n });\n } catch (err) {\n error = err;\n }\n\n if (process.env.NODE_ENV !== 'production') {\n logger.groupCollapsed(\n messages.strategyStart('NetworkOnly', request));\n if (response) {\n logger.log(`Got response from network.`);\n } else {\n logger.log(`Unable to get a response from the network.`);\n }\n messages.printFinalResponse(response);\n logger.groupEnd();\n }\n\n if (!response) {\n throw new WorkboxError('no-response', {url: request.url, error});\n }\n return response;\n }\n}\n\nexport {NetworkOnly};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {assert} from 'workbox-core/_private/assert.mjs';\nimport {cacheNames} from 'workbox-core/_private/cacheNames.mjs';\nimport {cacheWrapper} from 'workbox-core/_private/cacheWrapper.mjs';\nimport {fetchWrapper} from 'workbox-core/_private/fetchWrapper.mjs';\nimport {getFriendlyURL} from 'workbox-core/_private/getFriendlyURL.mjs';\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {WorkboxError} from 'workbox-core/_private/WorkboxError.mjs';\n\nimport {messages} from './utils/messages.mjs';\nimport {cacheOkAndOpaquePlugin} from './plugins/cacheOkAndOpaquePlugin.mjs';\nimport './_version.mjs';\n\n/**\n * An implementation of a\n * [stale-while-revalidate]{@link https://developers.google.com/web/fundamentals/instant-and-offline/offline-cookbook/#stale-while-revalidate}\n * request strategy.\n *\n * Resources are requested from both the cache and the network in parallel.\n * The strategy will respond with the cached version if available, otherwise\n * wait for the network response. The cache is updated with the network response\n * with each successful request.\n *\n * By default, this strategy will cache responses with a 200 status code as\n * well as [opaque responses]{@link https://developers.google.com/web/tools/workbox/guides/handle-third-party-requests}.\n * Opaque responses are are cross-origin requests where the response doesn't\n * support [CORS]{@link https://enable-cors.org/}.\n *\n * If the network request fails, and there is no cache match, this will throw\n * a `WorkboxError` exception.\n *\n * @memberof workbox.strategies\n */\nclass StaleWhileRevalidate {\n /**\n * @param {Object} options\n * @param {string} options.cacheName Cache name to store and retrieve\n * requests. Defaults to cache names provided by\n * [workbox-core]{@link workbox.core.cacheNames}.\n * @param {Array} options.plugins [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins}\n * to use in conjunction with this caching strategy.\n * @param {Object} options.fetchOptions Values passed along to the\n * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters)\n * of all fetch() requests made by this strategy.\n * @param {Object} options.matchOptions [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions)\n */\n constructor(options = {}) {\n this._cacheName = cacheNames.getRuntimeName(options.cacheName);\n this._plugins = options.plugins || [];\n\n if (options.plugins) {\n let isUsingCacheWillUpdate =\n options.plugins.some((plugin) => !!plugin.cacheWillUpdate);\n this._plugins = isUsingCacheWillUpdate ?\n options.plugins : [cacheOkAndOpaquePlugin, ...options.plugins];\n } else {\n // No plugins passed in, use the default plugin.\n this._plugins = [cacheOkAndOpaquePlugin];\n }\n\n this._fetchOptions = options.fetchOptions || null;\n this._matchOptions = options.matchOptions || null;\n }\n\n /**\n * This method will perform a request strategy and follows an API that\n * will work with the\n * [Workbox Router]{@link workbox.routing.Router}.\n *\n * @param {Object} options\n * @param {Request} options.request The request to run this strategy for.\n * @param {Event} [options.event] The event that triggered the request.\n * @return {Promise}\n */\n async handle({event, request}) {\n return this.makeRequest({\n event,\n request: request || event.request,\n });\n }\n /**\n * This method can be used to perform a make a standalone request outside the\n * context of the [Workbox Router]{@link workbox.routing.Router}.\n *\n * See \"[Advanced Recipes](https://developers.google.com/web/tools/workbox/guides/advanced-recipes#make-requests)\"\n * for more usage information.\n *\n * @param {Object} options\n * @param {Request|string} options.request Either a\n * [`Request`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Request}\n * object, or a string URL, corresponding to the request to be made.\n * @param {FetchEvent} [options.event] If provided, `event.waitUntil()` will\n * be called automatically to extend the service worker's lifetime.\n * @return {Promise}\n */\n async makeRequest({event, request}) {\n const logs = [];\n\n if (typeof request === 'string') {\n request = new Request(request);\n }\n\n if (process.env.NODE_ENV !== 'production') {\n assert.isInstance(request, Request, {\n moduleName: 'workbox-strategies',\n className: 'StaleWhileRevalidate',\n funcName: 'handle',\n paramName: 'request',\n });\n }\n\n const fetchAndCachePromise = this._getFromNetwork({request, event});\n\n let response = await cacheWrapper.match({\n cacheName: this._cacheName,\n request,\n event,\n matchOptions: this._matchOptions,\n plugins: this._plugins,\n });\n let error;\n if (response) {\n if (process.env.NODE_ENV !== 'production') {\n logs.push(`Found a cached response in the '${this._cacheName}'` +\n ` cache. Will update with the network response in the background.`);\n }\n\n if (event) {\n try {\n event.waitUntil(fetchAndCachePromise);\n } catch (error) {\n if (process.env.NODE_ENV !== 'production') {\n logger.warn(`Unable to ensure service worker stays alive when ` +\n `updating cache for '${getFriendlyURL(request.url)}'.`);\n }\n }\n }\n } else {\n if (process.env.NODE_ENV !== 'production') {\n logs.push(`No response found in the '${this._cacheName}' cache. ` +\n `Will wait for the network response.`);\n }\n try {\n response = await fetchAndCachePromise;\n } catch (err) {\n error = err;\n }\n }\n\n if (process.env.NODE_ENV !== 'production') {\n logger.groupCollapsed(\n messages.strategyStart('StaleWhileRevalidate', request));\n for (let log of logs) {\n logger.log(log);\n }\n messages.printFinalResponse(response);\n logger.groupEnd();\n }\n\n if (!response) {\n throw new WorkboxError('no-response', {url: request.url, error});\n }\n return response;\n }\n\n /**\n * @param {Object} options\n * @param {Request} options.request\n * @param {Event} [options.event]\n * @return {Promise}\n *\n * @private\n */\n async _getFromNetwork({request, event}) {\n const response = await fetchWrapper.fetch({\n request,\n event,\n fetchOptions: this._fetchOptions,\n plugins: this._plugins,\n });\n\n const cachePutPromise = cacheWrapper.put({\n cacheName: this._cacheName,\n request,\n response: response.clone(),\n event,\n plugins: this._plugins,\n });\n\n if (event) {\n try {\n event.waitUntil(cachePutPromise);\n } catch (error) {\n if (process.env.NODE_ENV !== 'production') {\n logger.warn(`Unable to ensure service worker stays alive when ` +\n `updating cache for '${getFriendlyURL(request.url)}'.`);\n }\n }\n }\n\n return response;\n }\n}\n\nexport {StaleWhileRevalidate};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {logger} from 'workbox-core/_private/logger.mjs';\nimport {CacheFirst} from './CacheFirst.mjs';\nimport {CacheOnly} from './CacheOnly.mjs';\nimport {NetworkFirst} from './NetworkFirst.mjs';\nimport {NetworkOnly} from './NetworkOnly.mjs';\nimport {StaleWhileRevalidate} from './StaleWhileRevalidate.mjs';\nimport './_version.mjs';\n\n\nconst mapping = {\n cacheFirst: CacheFirst,\n cacheOnly: CacheOnly,\n networkFirst: NetworkFirst,\n networkOnly: NetworkOnly,\n staleWhileRevalidate: StaleWhileRevalidate,\n};\n\nconst deprecate = (strategy) => {\n const StrategyCtr = mapping[strategy];\n\n return (options) => {\n if (process.env.NODE_ENV !== 'production') {\n const strategyCtrName = strategy[0].toUpperCase() + strategy.slice(1);\n logger.warn(`The 'workbox.strategies.${strategy}()' function has been ` +\n `deprecated and will be removed in a future version of Workbox.\\n` +\n `Please use 'new workbox.strategies.${strategyCtrName}()' instead.`);\n }\n return new StrategyCtr(options);\n };\n};\n\n/**\n * @function workbox.strategies.cacheFirst\n * @param {Object} options See the {@link workbox.strategies.CacheFirst}\n * constructor for more info.\n * @deprecated since v4.0.0\n */\nconst cacheFirst = deprecate('cacheFirst');\n\n/**\n * @function workbox.strategies.cacheOnly\n * @param {Object} options See the {@link workbox.strategies.CacheOnly}\n * constructor for more info.\n * @deprecated since v4.0.0\n */\nconst cacheOnly = deprecate('cacheOnly');\n\n/**\n * @function workbox.strategies.networkFirst\n * @param {Object} options See the {@link workbox.strategies.NetworkFirst}\n * constructor for more info.\n * @deprecated since v4.0.0\n */\nconst networkFirst = deprecate('networkFirst');\n\n/**\n * @function workbox.strategies.networkOnly\n * @param {Object} options See the {@link workbox.strategies.NetworkOnly}\n * constructor for more info.\n * @deprecated since v4.0.0\n */\nconst networkOnly = deprecate('networkOnly');\n\n/**\n * @function workbox.strategies.staleWhileRevalidate\n * @param {Object} options See the\n * {@link workbox.strategies.StaleWhileRevalidate} constructor for more info.\n * @deprecated since v4.0.0\n */\nconst staleWhileRevalidate = deprecate('staleWhileRevalidate');\n\n/**\n * There are common caching strategies that most service workers will need\n * and use. This module provides simple implementations of these strategies.\n *\n * @namespace workbox.strategies\n */\n\nexport {\n CacheFirst,\n CacheOnly,\n NetworkFirst,\n NetworkOnly,\n StaleWhileRevalidate,\n\n // Deprecated...\n cacheFirst,\n cacheOnly,\n networkFirst,\n networkOnly,\n staleWhileRevalidate,\n};\n\n"],"names":["self","_","e","CacheFirst","constructor","options","_cacheName","cacheNames","getRuntimeName","cacheName","_plugins","plugins","_fetchOptions","fetchOptions","_matchOptions","matchOptions","event","request","this","makeRequest","Request","error","response","cacheWrapper","match","_getFromNetwork","err","WorkboxError","url","fetchWrapper","fetch","responseClone","clone","cachePutPromise","put","waitUntil","CacheOnly","cacheOkAndOpaquePlugin","cacheWillUpdate","status","NetworkFirst","isUsingCacheWillUpdate","some","plugin","_networkTimeoutSeconds","networkTimeoutSeconds","logs","promises","timeoutId","id","promise","_getTimeoutPromise","push","networkPromise","_getNetworkPromise","Promise","race","resolve","setTimeout","async","_respondFromCache","clearTimeout","cachePut","NetworkOnly","StaleWhileRevalidate","fetchAndCachePromise","mapping","cacheFirst","cacheOnly","networkFirst","networkOnly","staleWhileRevalidate","deprecate","strategy","StrategyCtr"],"mappings":"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"} \ No newline at end of file diff --git a/public/javascripts/workbox/workbox-sw.js b/public/javascripts/workbox/workbox-sw.js new file mode 100644 index 0000000000..61b3289a81 --- /dev/null +++ b/public/javascripts/workbox/workbox-sw.js @@ -0,0 +1,2 @@ +!function(){"use strict";try{self["workbox:sw:4.3.1"]&&_()}catch(t){}const t="https://storage.googleapis.com/workbox-cdn/releases/4.3.1",e={backgroundSync:"background-sync",broadcastUpdate:"broadcast-update",cacheableResponse:"cacheable-response",core:"core",expiration:"expiration",googleAnalytics:"offline-ga",navigationPreload:"navigation-preload",precaching:"precaching",rangeRequests:"range-requests",routing:"routing",strategies:"strategies",streams:"streams"};self.workbox=new class{constructor(){return this.v={},this.t={debug:"localhost"===self.location.hostname,modulePathPrefix:null,modulePathCb:null},this.s=this.t.debug?"dev":"prod",this.o=!1,new Proxy(this,{get(t,s){if(t[s])return t[s];const o=e[s];return o&&t.loadModule(`workbox-${o}`),t[s]}})}setConfig(t={}){if(this.o)throw new Error("Config must be set before accessing workbox.* modules");Object.assign(this.t,t),this.s=this.t.debug?"dev":"prod"}loadModule(t){const e=this.i(t);try{importScripts(e),this.o=!0}catch(s){throw console.error(`Unable to import module '${t}' from '${e}'.`),s}}i(e){if(this.t.modulePathCb)return this.t.modulePathCb(e,this.t.debug);let s=[t];const o=`${e}.${this.s}.js`,r=this.t.modulePathPrefix;return r&&""===(s=r.split("/"))[s.length-1]&&s.splice(s.length-1,1),s.push(o),s.join("/")}}}(); +//# sourceMappingURL=workbox-sw.js.map diff --git a/public/javascripts/workbox/workbox-sw.js.map b/public/javascripts/workbox/workbox-sw.js.map new file mode 100644 index 0000000000..efb3c3655e --- /dev/null +++ b/public/javascripts/workbox/workbox-sw.js.map @@ -0,0 +1 @@ +{"version":3,"file":"workbox-sw.js","sources":["../_version.mjs","../controllers/WorkboxSW.mjs","../index.mjs"],"sourcesContent":["try{self['workbox:sw:4.3.1']&&_()}catch(e){}// eslint-disable-line","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport '../_version.mjs';\n\nconst CDN_PATH = `WORKBOX_CDN_ROOT_URL`;\n\nconst MODULE_KEY_TO_NAME_MAPPING = {\n // TODO(philipwalton): add jsdoc tags to associate these with their module.\n // @name backgroundSync\n // @memberof workbox\n // @see module:workbox-background-sync\n backgroundSync: 'background-sync',\n broadcastUpdate: 'broadcast-update',\n cacheableResponse: 'cacheable-response',\n core: 'core',\n expiration: 'expiration',\n googleAnalytics: 'offline-ga',\n navigationPreload: 'navigation-preload',\n precaching: 'precaching',\n rangeRequests: 'range-requests',\n routing: 'routing',\n strategies: 'strategies',\n streams: 'streams',\n};\n\n/**\n * This class can be used to make it easy to use the various parts of\n * Workbox.\n *\n * @private\n */\nexport class WorkboxSW {\n /**\n * Creates a proxy that automatically loads workbox namespaces on demand.\n *\n * @private\n */\n constructor() {\n this.v = {};\n this._options = {\n debug: self.location.hostname === 'localhost',\n modulePathPrefix: null,\n modulePathCb: null,\n };\n\n this._env = this._options.debug ? 'dev' : 'prod';\n this._modulesLoaded = false;\n\n return new Proxy(this, {\n get(target, key) {\n if (target[key]) {\n return target[key];\n }\n\n const moduleName = MODULE_KEY_TO_NAME_MAPPING[key];\n if (moduleName) {\n target.loadModule(`workbox-${moduleName}`);\n }\n\n return target[key];\n },\n });\n }\n\n /**\n * Updates the configuration options. You can specify whether to treat as a\n * debug build and whether to use a CDN or a specific path when importing\n * other workbox-modules\n *\n * @param {Object} [options]\n * @param {boolean} [options.debug] If true, `dev` builds are using, otherwise\n * `prod` builds are used. By default, `prod` is used unless on localhost.\n * @param {Function} [options.modulePathPrefix] To avoid using the CDN with\n * `workbox-sw` set the path prefix of where modules should be loaded from.\n * For example `modulePathPrefix: '/third_party/workbox/v3.0.0/'`.\n * @param {workbox~ModulePathCallback} [options.modulePathCb] If defined,\n * this callback will be responsible for determining the path of each\n * workbox module.\n *\n * @alias workbox.setConfig\n */\n setConfig(options = {}) {\n if (!this._modulesLoaded) {\n Object.assign(this._options, options);\n this._env = this._options.debug ? 'dev' : 'prod';\n } else {\n throw new Error('Config must be set before accessing workbox.* modules');\n }\n }\n\n /**\n * Load a Workbox module by passing in the appropriate module name.\n *\n * This is not generally needed unless you know there are modules that are\n * dynamically used and you want to safe guard use of the module while the\n * user may be offline.\n *\n * @param {string} moduleName\n *\n * @alias workbox.loadModule\n */\n loadModule(moduleName) {\n const modulePath = this._getImportPath(moduleName);\n try {\n importScripts(modulePath);\n this._modulesLoaded = true;\n } catch (err) {\n // TODO Add context of this error if using the CDN vs the local file.\n\n // We can't rely on workbox-core being loaded so using console\n // eslint-disable-next-line\n console.error(\n `Unable to import module '${moduleName}' from '${modulePath}'.`);\n throw err;\n }\n }\n\n /**\n * This method will get the path / CDN URL to be used for importScript calls.\n *\n * @param {string} moduleName\n * @return {string} URL to the desired module.\n *\n * @private\n */\n _getImportPath(moduleName) {\n if (this._options.modulePathCb) {\n return this._options.modulePathCb(moduleName, this._options.debug);\n }\n\n // TODO: This needs to be dynamic some how.\n let pathParts = [CDN_PATH];\n\n const fileName = `${moduleName}.${this._env}.js`;\n\n const pathPrefix = this._options.modulePathPrefix;\n if (pathPrefix) {\n // Split to avoid issues with developers ending / not ending with slash\n pathParts = pathPrefix.split('/');\n\n // We don't need a slash at the end as we will be adding\n // a filename regardless\n if (pathParts[pathParts.length - 1] === '') {\n pathParts.splice(pathParts.length - 1, 1);\n }\n }\n\n pathParts.push(fileName);\n\n return pathParts.join('/');\n }\n}\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport {WorkboxSW} from './controllers/WorkboxSW.mjs';\nimport './_version.mjs';\n\n/**\n * @namespace workbox\n */\n\n// Don't export anything, just expose a global.\nself.workbox = new WorkboxSW();\n"],"names":["self","_","e","CDN_PATH","MODULE_KEY_TO_NAME_MAPPING","backgroundSync","broadcastUpdate","cacheableResponse","core","expiration","googleAnalytics","navigationPreload","precaching","rangeRequests","routing","strategies","streams","workbox","constructor","v","_options","debug","location","hostname","modulePathPrefix","modulePathCb","_env","this","_modulesLoaded","Proxy","get","target","key","moduleName","loadModule","setConfig","options","Error","Object","assign","modulePath","_getImportPath","importScripts","err","console","error","pathParts","fileName","pathPrefix","split","length","splice","push","join"],"mappings":"yBAAA,IAAIA,KAAK,qBAAqBC,IAAI,MAAMC,ICUxC,MAAMC,EAAY,4DAEZC,EAA6B,CAKjCC,eAAgB,kBAChBC,gBAAiB,mBACjBC,kBAAmB,qBACnBC,KAAM,OACNC,WAAY,aACZC,gBAAiB,aACjBC,kBAAmB,qBACnBC,WAAY,aACZC,cAAe,iBACfC,QAAS,UACTC,WAAY,aACZC,QAAS,WCZXhB,KAAKiB,QAAU,IDqBR,MAMLC,0BACOC,EAAI,QACJC,EAAW,CACdC,MAAkC,cAA3BrB,KAAKsB,SAASC,SACrBC,iBAAkB,KAClBC,aAAc,WAGXC,EAAOC,KAAKP,EAASC,MAAQ,MAAQ,YACrCO,GAAiB,EAEf,IAAIC,MAAMF,KAAM,CACrBG,IAAIC,EAAQC,MACND,EAAOC,UACFD,EAAOC,SAGVC,EAAa7B,EAA2B4B,UAC1CC,GACFF,EAAOG,sBAAsBD,KAGxBF,EAAOC,MAsBpBG,UAAUC,EAAU,OACbT,KAAKC,QAIF,IAAIS,MAAM,yDAHhBC,OAAOC,OAAOZ,KAAKP,EAAUgB,QACxBV,EAAOC,KAAKP,EAASC,MAAQ,MAAQ,OAiB9Ca,WAAWD,SACHO,EAAab,KAAKc,EAAeR,OAErCS,cAAcF,QACTZ,GAAiB,EACtB,MAAOe,SAKPC,QAAQC,kCACwBZ,YAAqBO,OAC/CG,GAYVF,EAAeR,MACTN,KAAKP,EAASK,oBACTE,KAAKP,EAASK,aAAaQ,EAAYN,KAAKP,EAASC,WAI1DyB,EAAY,CAAC3C,SAEX4C,KAAcd,KAAcN,KAAKD,OAEjCsB,EAAarB,KAAKP,EAASI,wBAC7BwB,GAMsC,MAJxCF,EAAYE,EAAWC,MAAM,MAIfH,EAAUI,OAAS,IAC/BJ,EAAUK,OAAOL,EAAUI,OAAS,EAAG,GAI3CJ,EAAUM,KAAKL,GAERD,EAAUO,KAAK"} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 8c05847689..a0b4ec517b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2584,6 +2584,37 @@ wordwrap@~1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= +workbox-core@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-4.3.1.tgz#005d2c6a06a171437afd6ca2904a5727ecd73be6" + integrity sha512-I3C9jlLmMKPxAC1t0ExCq+QoAMd0vAAHULEgRZ7kieCdUd919n53WC0AfvokHNwqRhGn+tIIj7vcb5duCjs2Kg== + +workbox-expiration@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-4.3.1.tgz#d790433562029e56837f341d7f553c4a78ebe921" + integrity sha512-vsJLhgQsQouv9m0rpbXubT5jw0jMQdjpkum0uT+d9tTwhXcEZks7qLfQ9dGSaufTD2eimxbUOJfWLbNQpIDMPw== + dependencies: + workbox-core "^4.3.1" + +workbox-routing@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-4.3.1.tgz#a675841af623e0bb0c67ce4ed8e724ac0bed0cda" + integrity sha512-FkbtrODA4Imsi0p7TW9u9MXuQ5P4pVs1sWHK4dJMMChVROsbEltuE79fBoIk/BCztvOJ7yUpErMKa4z3uQLX+g== + dependencies: + workbox-core "^4.3.1" + +workbox-strategies@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-4.3.1.tgz#d2be03c4ef214c115e1ab29c9c759c9fe3e9e646" + integrity sha512-F/+E57BmVG8dX6dCCopBlkDvvhg/zj6VDs0PigYwSN23L8hseSRwljrceU2WzTvk/+BSYICsWmRq5qHS2UYzhw== + dependencies: + workbox-core "^4.3.1" + +workbox-sw@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/workbox-sw/-/workbox-sw-4.3.1.tgz#df69e395c479ef4d14499372bcd84c0f5e246164" + integrity sha512-0jXdusCL2uC5gM3yYFT6QMBzKfBr2XTk0g5TPAV4y8IZDyVNDyj1a8uSXy3/XrvkVTmQvLN4O5k3JawGReXr9w== + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" From 90e0f1b37829403531f674221e22498f7d6f3e23 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Mon, 15 Jul 2019 13:44:44 -0400 Subject: [PATCH 004/441] UX: rearrange controls in edit modals Allows users to see the controls even after scrolling contents of edit modal. --- .../discourse/templates/modal/history.hbs | 94 +++++++++---------- .../stylesheets/common/base/history.scss | 7 +- app/assets/stylesheets/desktop/history.scss | 13 ++- app/assets/stylesheets/mobile/history.scss | 14 +++ 4 files changed, 69 insertions(+), 59 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/modal/history.hbs b/app/assets/javascripts/discourse/templates/modal/history.hbs index fc6f987451..2ef4015365 100644 --- a/app/assets/javascripts/discourse/templates/modal/history.hbs +++ b/app/assets/javascripts/discourse/templates/modal/history.hbs @@ -1,24 +1,31 @@ {{#d-modal-body title="history" maxHeight="70%"}}
-
- {{d-button class="btn-default" action=(action "loadFirstVersion") icon="fast-backward" title="post.revisions.controls.first" disabled=loadFirstDisabled}} - {{d-button class="btn-default" action=(action "loadPreviousVersion") icon="backward" title="post.revisions.controls.previous" disabled=loadPreviousDisabled}} -
- {{#conditional-loading-spinner condition=loading size="small"}} - {{{revisionsText}}} - {{/conditional-loading-spinner}} -
- {{d-button class="btn-default" action=(action "loadNextVersion") icon="forward" title="post.revisions.controls.next" disabled=loadNextDisabled}} - {{d-button class="btn-default" action=(action "loadLastVersion") icon="fast-forward" title="post.revisions.controls.last" disabled=loadLastDisabled}} +
+ {{d-icon "pencil-alt"}} + {{#link-to 'user' model.username}} + {{bound-avatar-template model.avatar_template "small"}} {{model.username}} + {{/link-to}} + {{bound-date model.created_at}} + {{#if model.edit_reason}} + — {{model.edit_reason}} + {{/if}} + {{#unless site.mobileView}} + {{#if model.user_changes}} + — {{bound-avatar-template model.user_changes.previous.avatar_template "small"}} {{model.user_changes.previous.username}} + → {{bound-avatar-template model.user_changes.current.avatar_template "small"}} {{model.user_changes.current.username}} + {{/if}} + {{#if model.wiki_changes}} + — {{disabled-icon icon="pencil-square-o" disabled=wikiDisabled}} + {{/if}} + {{#if model.post_type_changes}} + — {{disabled-icon icon="shield-alt" disabled=postTypeDisabled}} + {{/if}} + {{#if model.category_id_changes}} + — {{{previousCategory}}} → {{{currentCategory}}} + {{/if}} + {{/unless}}
- {{#if displayEdit}} - {{d-button action=(action "editPost") - class="btn-default" - icon="pencil-alt" - label=editButtonLabel}} - {{/if}} -
{{d-button action=(action "displayInline") icon="square-o" @@ -40,31 +47,6 @@ {{/unless}}
-
- {{d-icon "pencil-alt"}} - {{#link-to 'user' model.username}} - {{bound-avatar-template model.avatar_template "small"}} {{model.username}} - {{/link-to}} - {{bound-date model.created_at}} - {{#if model.edit_reason}} - — {{model.edit_reason}} - {{/if}} - {{#unless site.mobileView}} - {{#if model.user_changes}} - — {{bound-avatar-template model.user_changes.previous.avatar_template "small"}} {{model.user_changes.previous.username}} - → {{bound-avatar-template model.user_changes.current.avatar_template "small"}} {{model.user_changes.current.username}} - {{/if}} - {{#if model.wiki_changes}} - — {{disabled-icon icon="pencil-square-o" disabled=wikiDisabled}} - {{/if}} - {{#if model.post_type_changes}} - — {{disabled-icon icon="shield-alt" disabled=postTypeDisabled}} - {{/if}} - {{#if model.category_id_changes}} - — {{{previousCategory}}} → {{{currentCategory}}} - {{/if}} - {{/unless}} -
{{#if model.title_changes}}
@@ -124,6 +106,26 @@ {{/d-modal-body}} {{#if topicController}} {{/if}} diff --git a/app/assets/stylesheets/common/base/history.scss b/app/assets/stylesheets/common/base/history.scss index a17ad25490..c4724f06b5 100644 --- a/app/assets/stylesheets/common/base/history.scss +++ b/app/assets/stylesheets/common/base/history.scss @@ -9,12 +9,9 @@ #revision { overflow: auto; + border-bottom: 3px solid $primary-low; } - #revision-controls { - display: inline-block; - margin-bottom: 5px; - } table.markdown > tbody > tr > td, .revision-content { width: 47.5%; @@ -27,8 +24,6 @@ #revision-details { padding: 5px; - margin-top: 10px; - border-bottom: 3px solid $primary-low; } #revisions .row:first-of-type { diff --git a/app/assets/stylesheets/desktop/history.scss b/app/assets/stylesheets/desktop/history.scss index 196c1b307c..9f11b58e81 100644 --- a/app/assets/stylesheets/desktop/history.scss +++ b/app/assets/stylesheets/desktop/history.scss @@ -16,10 +16,10 @@ background-color: $danger-medium; } } - #display-modes { - text-align: right; - display: inline-block; - float: right; + + #revision { + display: flex; + justify-content: space-between; } #revisions { @@ -59,4 +59,9 @@ .modal-header { height: 42px; } + + .modal-footer { + display: flex; + justify-content: space-between; + } } diff --git a/app/assets/stylesheets/mobile/history.scss b/app/assets/stylesheets/mobile/history.scss index cdcbd44d2e..662baef279 100644 --- a/app/assets/stylesheets/mobile/history.scss +++ b/app/assets/stylesheets/mobile/history.scss @@ -28,4 +28,18 @@ word-wrap: break-word; } } + + .modal-footer { + text-align: center; + } + + #revision-controls { + margin-bottom: 5px; + } + + #revision-footer-buttons { + button { + @extend .btn-small; + } + } } From 6515ff19e5c8e62ba3aaecb5947eaccdcbbaf0dd Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Mon, 15 Jul 2019 20:47:44 +0300 Subject: [PATCH 005/441] FEATURE: Allow customization of robots.txt (#7884) * FEATURE: Allow customization of robots.txt This allows admins to customize/override the content of the robots.txt file at /admin/customize/robots. That page is not linked to anywhere in the UI -- admins have to manually type the URL to access that page. * use Ember.computed.not * Jeff feedback * Feedback * Remove unused import --- .../admin-customize-robots-txt.js.es6 | 45 +++++++++ .../routes/admin-customize-robots-txt.js.es6 | 7 ++ .../admin/routes/admin-route-map.js.es6 | 4 + .../admin/templates/customize-robots-txt.hbs | 20 ++++ .../stylesheets/common/admin/customize.scss | 13 +++ .../admin/robots_txt_controller.rb | 38 ++++++++ app/controllers/robots_txt_controller.rb | 19 ++-- config/locales/client.en.yml | 5 +- config/routes.rb | 4 + config/site_settings.yml | 4 + .../admin/robots_txt_controller_spec.rb | 91 +++++++++++++++++++ spec/requests/robots_txt_controller_spec.rb | 39 ++++++++ 12 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 app/assets/javascripts/admin/controllers/admin-customize-robots-txt.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-customize-robots-txt.js.es6 create mode 100644 app/assets/javascripts/admin/templates/customize-robots-txt.hbs create mode 100644 app/controllers/admin/robots_txt_controller.rb create mode 100644 spec/requests/admin/robots_txt_controller_spec.rb diff --git a/app/assets/javascripts/admin/controllers/admin-customize-robots-txt.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-robots-txt.js.es6 new file mode 100644 index 0000000000..9cb38c75ea --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-customize-robots-txt.js.es6 @@ -0,0 +1,45 @@ +import { ajax } from "discourse/lib/ajax"; +import { bufferedProperty } from "discourse/mixins/buffered-content"; +import { propertyEqual } from "discourse/lib/computed"; + +export default Ember.Controller.extend(bufferedProperty("model"), { + saved: false, + isSaving: false, + saveDisabled: propertyEqual("model.robots_txt", "buffered.robots_txt"), + resetDisbaled: Ember.computed.not("model.overridden"), + + actions: { + save() { + this.setProperties({ + isSaving: true, + saved: false + }); + + ajax("robots.json", { + method: "PUT", + data: { robots_txt: this.buffered.get("robots_txt") } + }) + .then(data => { + this.commitBuffer(); + this.set("saved", true); + this.set("model.overridden", data.overridden); + }) + .finally(() => this.set("isSaving", false)); + }, + + reset() { + this.setProperties({ + isSaving: true, + saved: false + }); + ajax("robots.json", { method: "DELETE" }) + .then(data => { + this.buffered.set("robots_txt", data.robots_txt); + this.commitBuffer(); + this.set("saved", true); + this.set("model.overridden", false); + }) + .finally(() => this.set("isSaving", false)); + } + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-customize-robots-txt.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-robots-txt.js.es6 new file mode 100644 index 0000000000..50acd6cac1 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-customize-robots-txt.js.es6 @@ -0,0 +1,7 @@ +import { ajax } from "discourse/lib/ajax"; + +export default Ember.Route.extend({ + model() { + return ajax("/admin/customize/robots"); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index 9ae0063ffe..a20165db02 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -86,6 +86,10 @@ export default function() { this.route("edit", { path: "/:id" }); } ); + this.route("adminCustomizeRobotsTxt", { + path: "/robots", + resetNamespace: true + }); } ); diff --git a/app/assets/javascripts/admin/templates/customize-robots-txt.hbs b/app/assets/javascripts/admin/templates/customize-robots-txt.hbs new file mode 100644 index 0000000000..b556f0c537 --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-robots-txt.hbs @@ -0,0 +1,20 @@ +
+

{{i18n "admin.customize.robots.title"}}

+

{{i18n "admin.customize.robots.warning"}}

+ {{#if model.overridden}} +
+ {{i18n "admin.customize.robots.overridden"}} +
+ {{/if}} + {{textarea + value=buffered.robots_txt + class="robots-txt-input"}} + {{#save-controls model=this action=(action "save") saved=saved saveDisabled=saveDisabled}} + {{d-button + class="btn-default" + disabled=resetDisbaled + icon="undo" + action=(action "reset") + label="admin.settings.reset"}} + {{/save-controls}} +
diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss index 86290bcf03..c68f895b6a 100644 --- a/app/assets/stylesheets/common/admin/customize.scss +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -777,3 +777,16 @@ margin-left: 1em; } } + +.robots-txt-edit { + div.overridden { + background: $highlight-medium; + padding: 7px; + margin-bottom: 7px; + } + .robots-txt-input { + width: 100%; + box-sizing: border-box; + height: 600px; + } +} diff --git a/app/controllers/admin/robots_txt_controller.rb b/app/controllers/admin/robots_txt_controller.rb new file mode 100644 index 0000000000..b269a6c9ec --- /dev/null +++ b/app/controllers/admin/robots_txt_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Admin::RobotsTxtController < Admin::AdminController + + def show + render json: { robots_txt: current_robots_txt, overridden: @overridden } + end + + def update + params.require(:robots_txt) + SiteSetting.overridden_robots_txt = params[:robots_txt] + + render json: { robots_txt: current_robots_txt, overridden: @overridden } + end + + def reset + SiteSetting.overridden_robots_txt = "" + render json: { robots_txt: original_robots_txt, overridden: false } + end + + private + + def current_robots_txt + robots_txt = SiteSetting.overridden_robots_txt.presence + @overridden = robots_txt.present? + robots_txt ||= original_robots_txt + robots_txt + end + + def original_robots_txt + if SiteSetting.allow_index_in_robots_txt? + @robots_info = ::RobotsTxtController.fetch_default_robots_info + render_to_string "robots_txt/index" + else + render_to_string "robots_txt/no_index" + end + end +end diff --git a/app/controllers/robots_txt_controller.rb b/app/controllers/robots_txt_controller.rb index 6d66579fa1..9c99cb5c1d 100644 --- a/app/controllers/robots_txt_controller.rb +++ b/app/controllers/robots_txt_controller.rb @@ -4,6 +4,8 @@ class RobotsTxtController < ApplicationController layout false skip_before_action :preload_json, :check_xhr, :redirect_to_login_if_required + OVERRIDDEN_HEADER = "# This robots.txt file has been customized at /admin/customize/robots\n" + # NOTE: order is important! DISALLOWED_PATHS ||= %w{ /auth/ @@ -33,8 +35,13 @@ class RobotsTxtController < ApplicationController } def index + if (overridden = SiteSetting.overridden_robots_txt.dup).present? + overridden.prepend(OVERRIDDEN_HEADER) if guardian.is_admin? && !is_api? + render plain: overridden + return + end if SiteSetting.allow_index_in_robots_txt? - @robots_info = fetch_robots_info + @robots_info = self.class.fetch_default_robots_info render :index, content_type: 'text/plain' else render :no_index, content_type: 'text/plain' @@ -46,12 +53,13 @@ class RobotsTxtController < ApplicationController # JSON that can be used by a script to create a robots.txt that works well with your # existing site. def builder - render json: fetch_robots_info + result = self.class.fetch_default_robots_info + overridden = SiteSetting.overridden_robots_txt + result[:overridden] = overridden if overridden.present? + render json: result end -protected - - def fetch_robots_info + def self.fetch_default_robots_info deny_paths = DISALLOWED_PATHS.map { |p| Discourse.base_uri + p } deny_all = [ "#{Discourse.base_uri}/" ] @@ -87,5 +95,4 @@ protected result end - end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index fd5a24d256..5414bf769a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3625,7 +3625,10 @@ en: love: name: "love" description: "The like button's color." - + robots: + title: "Override your site's robots.txt file:" + warning: "Warning: overriding the robots.txt file will prevent all future changes to the site settings that modify robots.txt from being applied." + overridden: Your site's default robots.txt file is overridden. email: title: "Emails" settings: "Settings" diff --git a/config/routes.rb b/config/routes.rb index 44a26637ff..48f93e5d9a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -235,6 +235,10 @@ Discourse::Application.routes.draw do get 'email_templates/(:id)' => 'email_templates#show', constraints: { id: /[0-9a-z_.]+/ } put 'email_templates/(:id)' => 'email_templates#update', constraints: { id: /[0-9a-z_.]+/ } delete 'email_templates/(:id)' => 'email_templates#revert', constraints: { id: /[0-9a-z_.]+/ } + + get 'robots' => 'robots_txt#show' + put 'robots.json' => 'robots_txt#update' + delete 'robots.json' => 'robots_txt#reset' end resources :embeddable_hosts, constraints: AdminConstraint.new diff --git a/config/site_settings.yml b/config/site_settings.yml index 6e17b3ca33..ec69826d76 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1929,6 +1929,10 @@ uncategorized: default: 50000 hidden: true + overridden_robots_txt: + default: "" + hidden: true + user_preferences: default_email_digest_frequency: enum: "DigestEmailSiteSetting" diff --git a/spec/requests/admin/robots_txt_controller_spec.rb b/spec/requests/admin/robots_txt_controller_spec.rb new file mode 100644 index 0000000000..046914017a --- /dev/null +++ b/spec/requests/admin/robots_txt_controller_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::RobotsTxtController do + it "is a subclass of AdminController" do + expect(described_class < Admin::AdminController).to eq(true) + end + + fab!(:admin) { Fabricate(:admin) } + fab!(:user) { Fabricate(:user) } + + describe "non-admin users" do + before { sign_in(user) } + + it "can't see #show" do + get "/admin/customize/robots.json" + expect(response.status).to eq(404) + end + + it "can't perform #update" do + put "/admin/customize/robots.json", params: { robots_txt: "adasdasd" } + expect(response.status).to eq(404) + expect(SiteSetting.overridden_robots_txt).to eq("") + end + + it "can't perform #reset" do + SiteSetting.overridden_robots_txt = "overridden_content" + delete "/admin/customize/robots.json" + expect(response.status).to eq(404) + expect(SiteSetting.overridden_robots_txt).to eq("overridden_content") + end + end + + describe "#show" do + before { sign_in(admin) } + + it "returns default content if there are no overrides" do + get "/admin/customize/robots.json" + expect(response.status).to eq(200) + json = JSON.parse(response.body) + expect(json["robots_txt"]).to be_present + expect(json["overridden"]).to eq(false) + end + + it "returns overridden content if there are overrides" do + SiteSetting.overridden_robots_txt = "something" + get "/admin/customize/robots.json" + expect(response.status).to eq(200) + json = JSON.parse(response.body) + expect(json["robots_txt"]).to eq("something") + expect(json["overridden"]).to eq(true) + end + end + + describe "#update" do + before { sign_in(admin) } + + it "overrides the site's default robots.txt" do + put "/admin/customize/robots.json", params: { robots_txt: "new_content" } + expect(response.status).to eq(200) + json = JSON.parse(response.body) + expect(json["robots_txt"]).to eq("new_content") + expect(json["overridden"]).to eq(true) + expect(SiteSetting.overridden_robots_txt).to eq("new_content") + + get "/robots.txt" + expect(response.body).to include("new_content") + end + + it "requires `robots_txt` param to be present" do + SiteSetting.overridden_robots_txt = "overridden_content" + put "/admin/customize/robots.json", params: { robots_txt: "" } + expect(response.status).to eq(400) + end + end + + describe "#reset" do + before { sign_in(admin) } + + it "resets robots.txt file to the default version" do + SiteSetting.overridden_robots_txt = "overridden_content" + delete "/admin/customize/robots.json" + expect(response.status).to eq(200) + json = JSON.parse(response.body) + expect(json["robots_txt"]).not_to include("overridden_content") + expect(json["overridden"]).to eq(false) + expect(SiteSetting.overridden_robots_txt).to eq("") + end + end +end diff --git a/spec/requests/robots_txt_controller_spec.rb b/spec/requests/robots_txt_controller_spec.rb index d18d3d4967..413e806a3d 100644 --- a/spec/requests/robots_txt_controller_spec.rb +++ b/spec/requests/robots_txt_controller_spec.rb @@ -11,10 +11,42 @@ RSpec.describe RobotsTxtController do expect(json['header']).to be_present expect(json['agents']).to be_present end + + it "includes overridden content if robots.txt is is overridden" do + SiteSetting.overridden_robots_txt = "something" + + get "/robots-builder.json" + expect(response.status).to eq(200) + json = ::JSON.parse(response.body) + expect(json['header']).to be_present + expect(json['agents']).to be_present + expect(json['overridden']).to eq("something") + end end describe '#index' do + context "header for when the content is overridden" do + it "is not prepended if there are no overrides" do + sign_in(Fabricate(:admin)) + get '/robots.txt' + expect(response.body).not_to start_with(RobotsTxtController::OVERRIDDEN_HEADER) + end + + it "is prepended if there are overrides and the user is admin" do + SiteSetting.overridden_robots_txt = "overridden_content" + sign_in(Fabricate(:admin)) + get '/robots.txt' + expect(response.body).to start_with(RobotsTxtController::OVERRIDDEN_HEADER) + end + + it "is not prepended if the user is not admin" do + SiteSetting.overridden_robots_txt = "overridden_content" + get '/robots.txt' + expect(response.body).not_to start_with(RobotsTxtController::OVERRIDDEN_HEADER) + end + end + context 'subfolder' do it 'prefixes the rules with the directory' do Discourse.stubs(:base_uri).returns('/forum') @@ -101,5 +133,12 @@ RSpec.describe RobotsTxtController do expect(response.body).to_not include("Disallow: /u/") end + + it "returns overridden robots.txt if the file is overridden" do + SiteSetting.overridden_robots_txt = "blah whatever" + get '/robots.txt' + expect(response.status).to eq(200) + expect(response.body).to eq(SiteSetting.overridden_robots_txt) + end end end From 7d01c5de1a92b020e19f387123a3cbbbe969c727 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Mon, 15 Jul 2019 21:55:11 +0300 Subject: [PATCH 006/441] FIX: apply defaults constraints to routes format (#7890) This fixes the problem where if a route ends with a dynamic segment and the segment contains a period e.g. `my.name`, `name` is interpreted as the format. This applies a default format constraints `/(json|html)/` on all routes. If you'd like a route to have a different format constraints, you can do something like this: ```ruby get "your-route" => "your_controlller#method", constraints: { format: /(rss|xml)/ } #or get "your-route" => "your_controlller#method", constraints: { format: :xml } ``` --- app/controllers/users_controller.rb | 5 +- config/routes.rb | 83 +++++++++++++------------- spec/requests/users_controller_spec.rb | 12 ++-- 3 files changed, 53 insertions(+), 47 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index bd0bf7ae62..1fc1879588 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1065,7 +1065,10 @@ class UsersController < ApplicationController @confirmed = true end - render layout: 'no_ember' + respond_to do |format| + format.json { render json: success_json } + format.html { render layout: 'no_ember' } + end end def list_second_factors diff --git a/config/routes.rb b/config/routes.rb index 48f93e5d9a..62e3ae4f39 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,7 +12,8 @@ USERNAME_ROUTE_FORMAT = /[%\w.\-]+?/ unless defined? USERNAME_ROUTE_FORMAT BACKUP_ROUTE_FORMAT = /.+\.(sql\.gz|tar\.gz|tgz)/i unless defined? BACKUP_ROUTE_FORMAT Discourse::Application.routes.draw do - relative_url_root = (defined?(Rails.configuration.relative_url_root) && Rails.configuration.relative_url_root) ? Rails.configuration.relative_url_root + '/' : '/' + scope path: nil, constraints: { format: /(json|html)/ } do + relative_url_root = (defined?(Rails.configuration.relative_url_root) && Rails.configuration.relative_url_root) ? Rails.configuration.relative_url_root + '/' : '/' match "/404", to: "exceptions#not_found", via: [:get, :post] get "/404-body" => "exceptions#not_found_body" @@ -24,13 +25,15 @@ Discourse::Application.routes.draw do post "webhooks/sendgrid" => "webhooks#sendgrid" post "webhooks/sparkpost" => "webhooks#sparkpost" - if Rails.env.development? - mount Sidekiq::Web => "/sidekiq" - mount Logster::Web => "/logs" - else - # only allow sidekiq in master site - mount Sidekiq::Web => "/sidekiq", constraints: AdminConstraint.new(require_master: true) - mount Logster::Web => "/logs", constraints: AdminConstraint.new + scope path: nil, constraints: { format: /.*/ } do + if Rails.env.development? + mount Sidekiq::Web => "/sidekiq" + mount Logster::Web => "/logs" + else + # only allow sidekiq in master site + mount Sidekiq::Web => "/sidekiq", constraints: AdminConstraint.new(require_master: true) + mount Logster::Web => "/logs", constraints: AdminConstraint.new + end end resources :about do @@ -350,17 +353,17 @@ Discourse::Application.routes.draw do post "composer/parse_html" => "composer#parse_html" resources :static - post "login" => "static#enter", constraints: { format: /(json|html)/ } - get "login" => "static#show", id: "login", constraints: { format: /(json|html)/ } - get "password-reset" => "static#show", id: "password_reset", constraints: { format: /(json|html)/ } - get "faq" => "static#show", id: "faq", constraints: { format: /(json|html)/ } - get "tos" => "static#show", id: "tos", as: 'tos', constraints: { format: /(json|html)/ } - get "privacy" => "static#show", id: "privacy", as: 'privacy', constraints: { format: /(json|html)/ } - get "signup" => "static#show", id: "signup", constraints: { format: /(json|html)/ } - get "login-preferences" => "static#show", id: "login", constraints: { format: /(json|html)/ } + post "login" => "static#enter" + get "login" => "static#show", id: "login" + get "password-reset" => "static#show", id: "password_reset" + get "faq" => "static#show", id: "faq" + get "tos" => "static#show", id: "tos", as: 'tos' + get "privacy" => "static#show", id: "privacy", as: 'privacy' + get "signup" => "static#show", id: "signup" + get "login-preferences" => "static#show", id: "login" %w{guidelines rules conduct}.each do |faq_alias| - get faq_alias => "static#show", id: "guidelines", as: faq_alias, constraints: { format: /(json|html)/ } + get faq_alias => "static#show", id: "guidelines", as: faq_alias end get "my/*path", to: 'users#my_redirect' @@ -420,8 +423,8 @@ Discourse::Application.routes.draw do get "#{root_path}/:username/messages/group/:group_name/archive" => "user_actions#private_messages", constraints: { username: RouteFormat.username, group_name: RouteFormat.username } get "#{root_path}/:username/messages/tags/:tag_id" => "user_actions#private_messages", constraints: StaffConstraint.new get "#{root_path}/:username.json" => "users#show", constraints: { username: RouteFormat.username }, defaults: { format: :json } - get({ "#{root_path}/:username" => "users#show", constraints: { username: RouteFormat.username, format: /(json|html)/ } }.merge(index == 1 ? { as: 'user' } : {})) - put "#{root_path}/:username" => "users#update", constraints: { username: RouteFormat.username, format: /(json|html)/ }, defaults: { format: :json } + get({ "#{root_path}/:username" => "users#show", constraints: { username: RouteFormat.username } }.merge(index == 1 ? { as: 'user' } : {})) + put "#{root_path}/:username" => "users#update", constraints: { username: RouteFormat.username }, defaults: { format: :json } get "#{root_path}/:username/emails" => "users#check_emails", constraints: { username: RouteFormat.username } get({ "#{root_path}/:username/preferences" => "users#preferences", constraints: { username: RouteFormat.username } }.merge(index == 1 ? { as: :email_preferences } : {})) get "#{root_path}/:username/preferences/email" => "users_email#index", constraints: { username: RouteFormat.username } @@ -463,7 +466,7 @@ Discourse::Application.routes.draw do get "#{root_path}/:username/badges" => "users#badges", constraints: { username: RouteFormat.username } get "#{root_path}/:username/notifications" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/notifications/:filter" => "users#show", constraints: { username: RouteFormat.username } - delete "#{root_path}/:username" => "users#destroy", constraints: { username: RouteFormat.username, format: /(json|html)/ } + delete "#{root_path}/:username" => "users#destroy", constraints: { username: RouteFormat.username } get "#{root_path}/by-external/:external_id" => "users#show", constraints: { external_id: /[^\/]+/ } get "#{root_path}/:username/flagged-posts" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/deleted-posts" => "users#show", constraints: { username: RouteFormat.username } @@ -472,18 +475,18 @@ Discourse::Application.routes.draw do end get "user-badges/:username.json" => "user_badges#username", constraints: { username: RouteFormat.username }, defaults: { format: :json } - get "user-badges/:username" => "user_badges#username", constraints: { username: RouteFormat.username, format: /(json|html)/ } + get "user-badges/:username" => "user_badges#username", constraints: { username: RouteFormat.username } post "user_avatar/:username/refresh_gravatar" => "user_avatars#refresh_gravatar", constraints: { username: RouteFormat.username } - get "letter_avatar/:username/:size/:version.png" => "user_avatars#show_letter", format: false, constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: RouteFormat.username } - get "user_avatar/:hostname/:username/:size/:version.png" => "user_avatars#show", format: false, constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: RouteFormat.username } + get "letter_avatar/:username/:size/:version.png" => "user_avatars#show_letter", constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: RouteFormat.username, format: :png } + get "user_avatar/:hostname/:username/:size/:version.png" => "user_avatars#show", constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: RouteFormat.username, format: :png } - get "letter_avatar_proxy/:version/letter/:letter/:color/:size.png" => "user_avatars#show_proxy_letter" + get "letter_avatar_proxy/:version/letter/:letter/:color/:size.png" => "user_avatars#show_proxy_letter", constraints: { format: :png } - get "svg-sprite/:hostname/svg-:theme_ids-:version.js" => "svg_sprite#show", format: false, constraints: { hostname: /[\w\.-]+/, version: /\h{40}/, theme_ids: /([0-9]+(,[0-9]+)*)?/ } + get "svg-sprite/:hostname/svg-:theme_ids-:version.js" => "svg_sprite#show", constraints: { hostname: /[\w\.-]+/, version: /\h{40}/, theme_ids: /([0-9]+(,[0-9]+)*)?/, format: :js } get "svg-sprite/search/:keyword" => "svg_sprite#search", format: false, constraints: { keyword: /[-a-z0-9\s\%]+/ } - get "highlight-js/:hostname/:version.js" => "highlight_js#show", format: false, constraints: { hostname: /[\w\.-]+/ } + get "highlight-js/:hostname/:version.js" => "highlight_js#show", constraints: { hostname: /[\w\.-]+/, format: :js } get "stylesheets/:name.css.map" => "stylesheets#show_source_map", constraints: { name: /[-a-z0-9_]+/ } get "stylesheets/:name.css" => "stylesheets#show", constraints: { name: /[-a-z0-9_]+/ } @@ -499,10 +502,10 @@ Discourse::Application.routes.draw do # used to download attachments get "uploads/:site/original/:tree:sha(.:extension)" => "uploads#show", constraints: { site: /\w+/, tree: /([a-z0-9]+\/)+/i, sha: /\h{40}/, extension: /[a-z0-9\.]+/i } # used to download attachments (old route) - get "uploads/:site/:id/:sha" => "uploads#show", constraints: { site: /\w+/, id: /\d+/, sha: /\h{16}/ } + get "uploads/:site/:id/:sha" => "uploads#show", constraints: { site: /\w+/, id: /\d+/, sha: /\h{16}/, format: /.*/ } - get "posts" => "posts#latest", id: "latest_posts" - get "private-posts" => "posts#latest", id: "private_posts" + get "posts" => "posts#latest", id: "latest_posts", constraints: { format: /(json|rss)/ } + get "private-posts" => "posts#latest", id: "private_posts", constraints: { format: /(json|rss)/ } get "posts/by_number/:topic_id/:post_number" => "posts#by_number" get "posts/by-date/:topic_id/:date" => "posts#by_date" get "posts/:id/reply-history" => "posts#reply_history" @@ -612,7 +615,7 @@ Discourse::Application.routes.draw do resources :user_actions resources :badges, only: [:index] - get "/badges/:id(/:slug)" => "badges#show" + get "/badges/:id(/:slug)" => "badges#show", constraints: { format: /(json|html|rss)/ } resources :user_badges, only: [:index, :create, :destroy] get '/c', to: redirect(relative_url_root + 'categories') @@ -653,7 +656,7 @@ Discourse::Application.routes.draw do end Discourse.filters.each do |filter| - get "#{filter}" => "list##{filter}", constraints: { format: /(json|html)/ } + get "#{filter}" => "list##{filter}" get "c/:category/l/#{filter}" => "list#category_#{filter}", as: "category_#{filter}" get "c/:category/none/l/#{filter}" => "list#category_none_#{filter}", as: "category_none_#{filter}" get "c/:parent_category/:category/l/#{filter}" => "list#parent_category_category_#{filter}", as: "parent_category_category_#{filter}" @@ -686,11 +689,11 @@ Discourse::Application.routes.draw do get "topics/feature_stats" scope "/topics", username: RouteFormat.username do - get "created-by/:username" => "list#topics_by", as: "topics_by", constraints: { format: /(json|html)/ }, defaults: { format: :json } - get "private-messages/:username" => "list#private_messages", as: "topics_private_messages", constraints: { format: /(json|html)/ }, defaults: { format: :json } - get "private-messages-sent/:username" => "list#private_messages_sent", as: "topics_private_messages_sent", constraints: { format: /(json|html)/ }, defaults: { format: :json } - get "private-messages-archive/:username" => "list#private_messages_archive", as: "topics_private_messages_archive", constraints: { format: /(json|html)/ }, defaults: { format: :json } - get "private-messages-unread/:username" => "list#private_messages_unread", as: "topics_private_messages_unread", constraints: { format: /(json|html)/ }, defaults: { format: :json } + get "created-by/:username" => "list#topics_by", as: "topics_by", defaults: { format: :json } + get "private-messages/:username" => "list#private_messages", as: "topics_private_messages", defaults: { format: :json } + get "private-messages-sent/:username" => "list#private_messages_sent", as: "topics_private_messages_sent", defaults: { format: :json } + get "private-messages-archive/:username" => "list#private_messages_archive", as: "topics_private_messages_archive", defaults: { format: :json } + get "private-messages-unread/:username" => "list#private_messages_unread", as: "topics_private_messages_unread", defaults: { format: :json } get "private-messages-tags/:username/:tag_id.json" => "list#private_messages_tag", as: "topics_private_messages_tag", constraints: StaffConstraint.new get "groups/:group_name" => "list#group_topics", as: "group_topics", group_name: RouteFormat.username @@ -801,8 +804,8 @@ Discourse::Application.routes.draw do get "/service-worker.js" => "static#service_worker_asset", format: :js end - get "cdn_asset/:site/*path" => "static#cdn_asset", format: false - get "brotli_asset/*path" => "static#brotli_asset", format: false + get "cdn_asset/:site/*path" => "static#cdn_asset", format: false, constraints: { format: /.*/ } + get "brotli_asset/*path" => "static#brotli_asset", format: false, constraints: { format: /.*/ } get "favicon/proxied" => "static#favicon", format: false @@ -811,7 +814,7 @@ Discourse::Application.routes.draw do get "offline.html" => "offline#index" get "manifest.webmanifest" => "metadata#manifest", as: :manifest get "manifest.json" => "metadata#manifest" - get "opensearch" => "metadata#opensearch", format: :xml + get "opensearch" => "metadata#opensearch", constraints: { format: :xml } scope "/tags" do get '/' => 'tags#index' @@ -880,5 +883,5 @@ Discourse::Application.routes.draw do resources :csp_reports, only: [:create] get "*url", to: 'permalinks#show', constraints: PermalinkConstraint.new - + end end diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index ed177c2fff..0d39c338b6 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -2372,12 +2372,12 @@ describe UsersController do describe '#confirm_admin' do it "fails without a valid token" do - get "/u/confirm-admin/invalid-token.josn" + get "/u/confirm-admin/invalid-token.json" expect(response).not_to be_successful end it "fails with a missing token" do - get "/u/confirm-admin/a0a0a0a0a0.josn" + get "/u/confirm-admin/a0a0a0a0a0.json" expect(response).to_not be_successful end @@ -2385,7 +2385,7 @@ describe UsersController do user = Fabricate(:user) ac = AdminConfirmation.new(user, Fabricate(:admin)) ac.create_confirmation - get "/u/confirm-admin/#{ac.token}.josn" + get "/u/confirm-admin/#{ac.token}.json" expect(response.status).to eq(200) user.reload @@ -2398,7 +2398,7 @@ describe UsersController do ac = AdminConfirmation.new(user, admin) ac.create_confirmation - get "/u/confirm-admin/#{ac.token}.josn", params: { token: ac.token } + get "/u/confirm-admin/#{ac.token}.json", params: { token: ac.token } expect(response.status).to eq(200) user.reload @@ -2411,7 +2411,7 @@ describe UsersController do ac = AdminConfirmation.new(user, Fabricate(:admin)) ac.create_confirmation - get "/u/confirm-admin/#{ac.token}.josn" + get "/u/confirm-admin/#{ac.token}.json" expect(response).to_not be_successful user.reload @@ -2423,7 +2423,7 @@ describe UsersController do user = Fabricate(:user) ac = AdminConfirmation.new(user, Fabricate(:admin)) ac.create_confirmation - post "/u/confirm-admin/#{ac.token}.josn" + post "/u/confirm-admin/#{ac.token}.json" expect(response.status).to eq(200) user.reload From b505d1d700ae129f966cadb792d85bfec7d11d6e Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Mon, 15 Jul 2019 16:07:49 -0300 Subject: [PATCH 007/441] DEV: Force workboxjs debug to false on dev env too --- app/assets/javascripts/service-worker.js.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/service-worker.js.erb b/app/assets/javascripts/service-worker.js.erb index 56d5eb7bc1..cc911ea251 100644 --- a/app/assets/javascripts/service-worker.js.erb +++ b/app/assets/javascripts/service-worker.js.erb @@ -3,7 +3,8 @@ importScripts("<%= ::UrlHelper.absolute("/javascripts/workbox/workbox-sw.js") %>"); workbox.setConfig({ - modulePathPrefix: "<%= ::UrlHelper.absolute("/javascripts/workbox") %>" + modulePathPrefix: "<%= ::UrlHelper.absolute("/javascripts/workbox") %>", + debug: false }); const cacheVersion = "1"; From 0277f8c674bfd455ffae215a2b1b08294138498a Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Mon, 15 Jul 2019 15:14:40 -0400 Subject: [PATCH 008/441] Update robots.txt editor 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 5414bf769a..b59fb1f83a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3627,7 +3627,7 @@ en: description: "The like button's color." robots: title: "Override your site's robots.txt file:" - warning: "Warning: overriding the robots.txt file will prevent all future changes to the site settings that modify robots.txt from being applied." + warning: "This will permanently override any related site settings." overridden: Your site's default robots.txt file is overridden. email: title: "Emails" From 8a525cafec5c7b5400d3af329739deee75ef6809 Mon Sep 17 00:00:00 2001 From: OsamaSayegh Date: Mon, 15 Jul 2019 19:32:55 +0000 Subject: [PATCH 009/441] UX: Use height relative to the viewport for robots.txt textarea --- app/assets/stylesheets/common/admin/customize.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss index c68f895b6a..e9f8fc23e2 100644 --- a/app/assets/stylesheets/common/admin/customize.scss +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -787,6 +787,6 @@ .robots-txt-input { width: 100%; box-sizing: border-box; - height: 600px; + height: 55vh; } } From 12e71f1fb2d155a6b0fad93db6341cc841427156 Mon Sep 17 00:00:00 2001 From: romanrizzi Date: Mon, 15 Jul 2019 17:30:01 -0300 Subject: [PATCH 010/441] UX: Swap ignore and mute sections to move the 'Save changes' button to the bottom --- .../discourse/templates/preferences/users.hbs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/preferences/users.hbs b/app/assets/javascripts/discourse/templates/preferences/users.hbs index 23885b38f4..c513c26176 100644 --- a/app/assets/javascripts/discourse/templates/preferences/users.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/users.hbs @@ -1,4 +1,13 @@ +{{#if ignoredEnabled}} +
+
+ + {{ignored-user-list model=model items=model.ignored_usernames saving=saved}} +
+
+{{/if}} +
- -{{#if ignoredEnabled}} -
-
- - {{ignored-user-list model=model items=model.ignored_usernames saving=saved}} -
-
-{{/if}} From e8ee39218695abf79e53e0fb66c0e485ea2b0426 Mon Sep 17 00:00:00 2001 From: Michael Brown Date: Mon, 15 Jul 2019 17:31:24 -0400 Subject: [PATCH 011/441] Revert "FIX: apply defaults constraints to routes format (#7890)" This reverts commit 7d01c5de1a92b020e19f387123a3cbbbe969c727. Trivial get on / was failing with a 404 with this change. --- app/controllers/users_controller.rb | 5 +- config/routes.rb | 83 +++++++++++++------------- spec/requests/users_controller_spec.rb | 12 ++-- 3 files changed, 47 insertions(+), 53 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 1fc1879588..bd0bf7ae62 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1065,10 +1065,7 @@ class UsersController < ApplicationController @confirmed = true end - respond_to do |format| - format.json { render json: success_json } - format.html { render layout: 'no_ember' } - end + render layout: 'no_ember' end def list_second_factors diff --git a/config/routes.rb b/config/routes.rb index 62e3ae4f39..48f93e5d9a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,8 +12,7 @@ USERNAME_ROUTE_FORMAT = /[%\w.\-]+?/ unless defined? USERNAME_ROUTE_FORMAT BACKUP_ROUTE_FORMAT = /.+\.(sql\.gz|tar\.gz|tgz)/i unless defined? BACKUP_ROUTE_FORMAT Discourse::Application.routes.draw do - scope path: nil, constraints: { format: /(json|html)/ } do - relative_url_root = (defined?(Rails.configuration.relative_url_root) && Rails.configuration.relative_url_root) ? Rails.configuration.relative_url_root + '/' : '/' + relative_url_root = (defined?(Rails.configuration.relative_url_root) && Rails.configuration.relative_url_root) ? Rails.configuration.relative_url_root + '/' : '/' match "/404", to: "exceptions#not_found", via: [:get, :post] get "/404-body" => "exceptions#not_found_body" @@ -25,15 +24,13 @@ Discourse::Application.routes.draw do post "webhooks/sendgrid" => "webhooks#sendgrid" post "webhooks/sparkpost" => "webhooks#sparkpost" - scope path: nil, constraints: { format: /.*/ } do - if Rails.env.development? - mount Sidekiq::Web => "/sidekiq" - mount Logster::Web => "/logs" - else - # only allow sidekiq in master site - mount Sidekiq::Web => "/sidekiq", constraints: AdminConstraint.new(require_master: true) - mount Logster::Web => "/logs", constraints: AdminConstraint.new - end + if Rails.env.development? + mount Sidekiq::Web => "/sidekiq" + mount Logster::Web => "/logs" + else + # only allow sidekiq in master site + mount Sidekiq::Web => "/sidekiq", constraints: AdminConstraint.new(require_master: true) + mount Logster::Web => "/logs", constraints: AdminConstraint.new end resources :about do @@ -353,17 +350,17 @@ Discourse::Application.routes.draw do post "composer/parse_html" => "composer#parse_html" resources :static - post "login" => "static#enter" - get "login" => "static#show", id: "login" - get "password-reset" => "static#show", id: "password_reset" - get "faq" => "static#show", id: "faq" - get "tos" => "static#show", id: "tos", as: 'tos' - get "privacy" => "static#show", id: "privacy", as: 'privacy' - get "signup" => "static#show", id: "signup" - get "login-preferences" => "static#show", id: "login" + post "login" => "static#enter", constraints: { format: /(json|html)/ } + get "login" => "static#show", id: "login", constraints: { format: /(json|html)/ } + get "password-reset" => "static#show", id: "password_reset", constraints: { format: /(json|html)/ } + get "faq" => "static#show", id: "faq", constraints: { format: /(json|html)/ } + get "tos" => "static#show", id: "tos", as: 'tos', constraints: { format: /(json|html)/ } + get "privacy" => "static#show", id: "privacy", as: 'privacy', constraints: { format: /(json|html)/ } + get "signup" => "static#show", id: "signup", constraints: { format: /(json|html)/ } + get "login-preferences" => "static#show", id: "login", constraints: { format: /(json|html)/ } %w{guidelines rules conduct}.each do |faq_alias| - get faq_alias => "static#show", id: "guidelines", as: faq_alias + get faq_alias => "static#show", id: "guidelines", as: faq_alias, constraints: { format: /(json|html)/ } end get "my/*path", to: 'users#my_redirect' @@ -423,8 +420,8 @@ Discourse::Application.routes.draw do get "#{root_path}/:username/messages/group/:group_name/archive" => "user_actions#private_messages", constraints: { username: RouteFormat.username, group_name: RouteFormat.username } get "#{root_path}/:username/messages/tags/:tag_id" => "user_actions#private_messages", constraints: StaffConstraint.new get "#{root_path}/:username.json" => "users#show", constraints: { username: RouteFormat.username }, defaults: { format: :json } - get({ "#{root_path}/:username" => "users#show", constraints: { username: RouteFormat.username } }.merge(index == 1 ? { as: 'user' } : {})) - put "#{root_path}/:username" => "users#update", constraints: { username: RouteFormat.username }, defaults: { format: :json } + get({ "#{root_path}/:username" => "users#show", constraints: { username: RouteFormat.username, format: /(json|html)/ } }.merge(index == 1 ? { as: 'user' } : {})) + put "#{root_path}/:username" => "users#update", constraints: { username: RouteFormat.username, format: /(json|html)/ }, defaults: { format: :json } get "#{root_path}/:username/emails" => "users#check_emails", constraints: { username: RouteFormat.username } get({ "#{root_path}/:username/preferences" => "users#preferences", constraints: { username: RouteFormat.username } }.merge(index == 1 ? { as: :email_preferences } : {})) get "#{root_path}/:username/preferences/email" => "users_email#index", constraints: { username: RouteFormat.username } @@ -466,7 +463,7 @@ Discourse::Application.routes.draw do get "#{root_path}/:username/badges" => "users#badges", constraints: { username: RouteFormat.username } get "#{root_path}/:username/notifications" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/notifications/:filter" => "users#show", constraints: { username: RouteFormat.username } - delete "#{root_path}/:username" => "users#destroy", constraints: { username: RouteFormat.username } + delete "#{root_path}/:username" => "users#destroy", constraints: { username: RouteFormat.username, format: /(json|html)/ } get "#{root_path}/by-external/:external_id" => "users#show", constraints: { external_id: /[^\/]+/ } get "#{root_path}/:username/flagged-posts" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/deleted-posts" => "users#show", constraints: { username: RouteFormat.username } @@ -475,18 +472,18 @@ Discourse::Application.routes.draw do end get "user-badges/:username.json" => "user_badges#username", constraints: { username: RouteFormat.username }, defaults: { format: :json } - get "user-badges/:username" => "user_badges#username", constraints: { username: RouteFormat.username } + get "user-badges/:username" => "user_badges#username", constraints: { username: RouteFormat.username, format: /(json|html)/ } post "user_avatar/:username/refresh_gravatar" => "user_avatars#refresh_gravatar", constraints: { username: RouteFormat.username } - get "letter_avatar/:username/:size/:version.png" => "user_avatars#show_letter", constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: RouteFormat.username, format: :png } - get "user_avatar/:hostname/:username/:size/:version.png" => "user_avatars#show", constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: RouteFormat.username, format: :png } + get "letter_avatar/:username/:size/:version.png" => "user_avatars#show_letter", format: false, constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: RouteFormat.username } + get "user_avatar/:hostname/:username/:size/:version.png" => "user_avatars#show", format: false, constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: RouteFormat.username } - get "letter_avatar_proxy/:version/letter/:letter/:color/:size.png" => "user_avatars#show_proxy_letter", constraints: { format: :png } + get "letter_avatar_proxy/:version/letter/:letter/:color/:size.png" => "user_avatars#show_proxy_letter" - get "svg-sprite/:hostname/svg-:theme_ids-:version.js" => "svg_sprite#show", constraints: { hostname: /[\w\.-]+/, version: /\h{40}/, theme_ids: /([0-9]+(,[0-9]+)*)?/, format: :js } + get "svg-sprite/:hostname/svg-:theme_ids-:version.js" => "svg_sprite#show", format: false, constraints: { hostname: /[\w\.-]+/, version: /\h{40}/, theme_ids: /([0-9]+(,[0-9]+)*)?/ } get "svg-sprite/search/:keyword" => "svg_sprite#search", format: false, constraints: { keyword: /[-a-z0-9\s\%]+/ } - get "highlight-js/:hostname/:version.js" => "highlight_js#show", constraints: { hostname: /[\w\.-]+/, format: :js } + get "highlight-js/:hostname/:version.js" => "highlight_js#show", format: false, constraints: { hostname: /[\w\.-]+/ } get "stylesheets/:name.css.map" => "stylesheets#show_source_map", constraints: { name: /[-a-z0-9_]+/ } get "stylesheets/:name.css" => "stylesheets#show", constraints: { name: /[-a-z0-9_]+/ } @@ -502,10 +499,10 @@ Discourse::Application.routes.draw do # used to download attachments get "uploads/:site/original/:tree:sha(.:extension)" => "uploads#show", constraints: { site: /\w+/, tree: /([a-z0-9]+\/)+/i, sha: /\h{40}/, extension: /[a-z0-9\.]+/i } # used to download attachments (old route) - get "uploads/:site/:id/:sha" => "uploads#show", constraints: { site: /\w+/, id: /\d+/, sha: /\h{16}/, format: /.*/ } + get "uploads/:site/:id/:sha" => "uploads#show", constraints: { site: /\w+/, id: /\d+/, sha: /\h{16}/ } - get "posts" => "posts#latest", id: "latest_posts", constraints: { format: /(json|rss)/ } - get "private-posts" => "posts#latest", id: "private_posts", constraints: { format: /(json|rss)/ } + get "posts" => "posts#latest", id: "latest_posts" + get "private-posts" => "posts#latest", id: "private_posts" get "posts/by_number/:topic_id/:post_number" => "posts#by_number" get "posts/by-date/:topic_id/:date" => "posts#by_date" get "posts/:id/reply-history" => "posts#reply_history" @@ -615,7 +612,7 @@ Discourse::Application.routes.draw do resources :user_actions resources :badges, only: [:index] - get "/badges/:id(/:slug)" => "badges#show", constraints: { format: /(json|html|rss)/ } + get "/badges/:id(/:slug)" => "badges#show" resources :user_badges, only: [:index, :create, :destroy] get '/c', to: redirect(relative_url_root + 'categories') @@ -656,7 +653,7 @@ Discourse::Application.routes.draw do end Discourse.filters.each do |filter| - get "#{filter}" => "list##{filter}" + get "#{filter}" => "list##{filter}", constraints: { format: /(json|html)/ } get "c/:category/l/#{filter}" => "list#category_#{filter}", as: "category_#{filter}" get "c/:category/none/l/#{filter}" => "list#category_none_#{filter}", as: "category_none_#{filter}" get "c/:parent_category/:category/l/#{filter}" => "list#parent_category_category_#{filter}", as: "parent_category_category_#{filter}" @@ -689,11 +686,11 @@ Discourse::Application.routes.draw do get "topics/feature_stats" scope "/topics", username: RouteFormat.username do - get "created-by/:username" => "list#topics_by", as: "topics_by", defaults: { format: :json } - get "private-messages/:username" => "list#private_messages", as: "topics_private_messages", defaults: { format: :json } - get "private-messages-sent/:username" => "list#private_messages_sent", as: "topics_private_messages_sent", defaults: { format: :json } - get "private-messages-archive/:username" => "list#private_messages_archive", as: "topics_private_messages_archive", defaults: { format: :json } - get "private-messages-unread/:username" => "list#private_messages_unread", as: "topics_private_messages_unread", defaults: { format: :json } + get "created-by/:username" => "list#topics_by", as: "topics_by", constraints: { format: /(json|html)/ }, defaults: { format: :json } + get "private-messages/:username" => "list#private_messages", as: "topics_private_messages", constraints: { format: /(json|html)/ }, defaults: { format: :json } + get "private-messages-sent/:username" => "list#private_messages_sent", as: "topics_private_messages_sent", constraints: { format: /(json|html)/ }, defaults: { format: :json } + get "private-messages-archive/:username" => "list#private_messages_archive", as: "topics_private_messages_archive", constraints: { format: /(json|html)/ }, defaults: { format: :json } + get "private-messages-unread/:username" => "list#private_messages_unread", as: "topics_private_messages_unread", constraints: { format: /(json|html)/ }, defaults: { format: :json } get "private-messages-tags/:username/:tag_id.json" => "list#private_messages_tag", as: "topics_private_messages_tag", constraints: StaffConstraint.new get "groups/:group_name" => "list#group_topics", as: "group_topics", group_name: RouteFormat.username @@ -804,8 +801,8 @@ Discourse::Application.routes.draw do get "/service-worker.js" => "static#service_worker_asset", format: :js end - get "cdn_asset/:site/*path" => "static#cdn_asset", format: false, constraints: { format: /.*/ } - get "brotli_asset/*path" => "static#brotli_asset", format: false, constraints: { format: /.*/ } + get "cdn_asset/:site/*path" => "static#cdn_asset", format: false + get "brotli_asset/*path" => "static#brotli_asset", format: false get "favicon/proxied" => "static#favicon", format: false @@ -814,7 +811,7 @@ Discourse::Application.routes.draw do get "offline.html" => "offline#index" get "manifest.webmanifest" => "metadata#manifest", as: :manifest get "manifest.json" => "metadata#manifest" - get "opensearch" => "metadata#opensearch", constraints: { format: :xml } + get "opensearch" => "metadata#opensearch", format: :xml scope "/tags" do get '/' => 'tags#index' @@ -883,5 +880,5 @@ Discourse::Application.routes.draw do resources :csp_reports, only: [:create] get "*url", to: 'permalinks#show', constraints: PermalinkConstraint.new - end + end diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index 0d39c338b6..ed177c2fff 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -2372,12 +2372,12 @@ describe UsersController do describe '#confirm_admin' do it "fails without a valid token" do - get "/u/confirm-admin/invalid-token.json" + get "/u/confirm-admin/invalid-token.josn" expect(response).not_to be_successful end it "fails with a missing token" do - get "/u/confirm-admin/a0a0a0a0a0.json" + get "/u/confirm-admin/a0a0a0a0a0.josn" expect(response).to_not be_successful end @@ -2385,7 +2385,7 @@ describe UsersController do user = Fabricate(:user) ac = AdminConfirmation.new(user, Fabricate(:admin)) ac.create_confirmation - get "/u/confirm-admin/#{ac.token}.json" + get "/u/confirm-admin/#{ac.token}.josn" expect(response.status).to eq(200) user.reload @@ -2398,7 +2398,7 @@ describe UsersController do ac = AdminConfirmation.new(user, admin) ac.create_confirmation - get "/u/confirm-admin/#{ac.token}.json", params: { token: ac.token } + get "/u/confirm-admin/#{ac.token}.josn", params: { token: ac.token } expect(response.status).to eq(200) user.reload @@ -2411,7 +2411,7 @@ describe UsersController do ac = AdminConfirmation.new(user, Fabricate(:admin)) ac.create_confirmation - get "/u/confirm-admin/#{ac.token}.json" + get "/u/confirm-admin/#{ac.token}.josn" expect(response).to_not be_successful user.reload @@ -2423,7 +2423,7 @@ describe UsersController do user = Fabricate(:user) ac = AdminConfirmation.new(user, Fabricate(:admin)) ac.create_confirmation - post "/u/confirm-admin/#{ac.token}.json" + post "/u/confirm-admin/#{ac.token}.josn" expect(response.status).to eq(200) user.reload From 08b286808ac13dbb6501ae306ebd971269cd24c9 Mon Sep 17 00:00:00 2001 From: Michael Brown Date: Mon, 15 Jul 2019 18:07:44 -0400 Subject: [PATCH 012/441] FIX: backups taken by pg_dump >= 11 are nonportable (#7893) --- lib/backup_restore/restorer.rb | 6 ++++-- spec/lib/backup_restore/restorer_spec.rb | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/backup_restore/restorer.rb b/lib/backup_restore/restorer.rb index c7ff9266d5..1e95502242 100644 --- a/lib/backup_restore/restorer.rb +++ b/lib/backup_restore/restorer.rb @@ -11,7 +11,7 @@ module BackupRestore attr_reader :success def self.pg_produces_portable_dump?(version) - version = Gem::Version.new(version) + gem_version = Gem::Version.new(version) %w{ 10.3 @@ -20,7 +20,9 @@ module BackupRestore 9.4.17 9.3.22 }.each do |unportable_version| - return false if Gem::Dependency.new("", "~> #{unportable_version}").match?("", version) + # anything pg 11 or above will produce a non-portable dump + return false if version.to_i >= 11 + return false if Gem::Dependency.new("", "~> #{unportable_version}").match?("", gem_version) end true diff --git a/spec/lib/backup_restore/restorer_spec.rb b/spec/lib/backup_restore/restorer_spec.rb index 1cd1dd698e..49f829b27e 100644 --- a/spec/lib/backup_restore/restorer_spec.rb +++ b/spec/lib/backup_restore/restorer_spec.rb @@ -12,6 +12,9 @@ describe BackupRestore::Restorer do "10.3" => false, "10.3.1" => false, "10.4" => false, + "11" => false, + "11.4" => false, + "21" => false, }.each do |key, value| expect(described_class.pg_produces_portable_dump?(key)).to eq(value) end From 2a10ddd4250f540911b980b79a1b1902e2f35b4c Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 16 Jul 2019 10:28:35 +0100 Subject: [PATCH 013/441] UX: Disable system edit notifications by default (#7896) Enabling this setting prevents notifications when the system downloads hotlinked images. This stops an onslaught of notifications when old posts are rebaked. It does not affect regular edit notifications --- config/site_settings.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/site_settings.yml b/config/site_settings.yml index ec69826d76..ebe51f6082 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1835,7 +1835,7 @@ uncategorized: hidden: true default: true - disable_edit_notifications: false + disable_edit_notifications: true likes_notification_consolidation_threshold: default: 3 From 7ecbf3865b6d58948da92bd567db79e47f96663f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Tue, 16 Jul 2019 11:53:34 +0200 Subject: [PATCH 014/441] UX: use SCSS color variables --- .../stylesheets/common/components/ignored-user-list.scss | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/common/components/ignored-user-list.scss b/app/assets/stylesheets/common/components/ignored-user-list.scss index a0723fe0d7..6b13f98994 100644 --- a/app/assets/stylesheets/common/components/ignored-user-list.scss +++ b/app/assets/stylesheets/common/components/ignored-user-list.scss @@ -4,13 +4,15 @@ display: flex; flex-wrap: wrap; align-items: center; - background-color: #fff; + background-color: $secondary; + border: 1px solid $primary-medium; min-height: 30px; box-sizing: border-box; + padding: 5px; } .ignored-user-list-item { - border: 1px solid #e9e9e9; + border: 1px solid $primary-medium; border-radius: 0.25em; padding: 0; display: flex; From e2fa5704e9e57dfb683684410332fcc8b02311ee Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 16 Jul 2019 10:57:11 +0100 Subject: [PATCH 015/441] UX: Remove duplicate copy in two-factor preferences --- .../javascripts/discourse/templates/preferences/account.hbs | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/javascripts/discourse/templates/preferences/account.hbs b/app/assets/javascripts/discourse/templates/preferences/account.hbs index 3f3024d0e3..0c904e4ced 100644 --- a/app/assets/javascripts/discourse/templates/preferences/account.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/account.hbs @@ -90,7 +90,6 @@ {{/unless}}
- {{i18n 'user.second_factor.enable'}} {{#if isCurrentUser}} {{#link-to "preferences.second-factor" class="btn btn-default"}} {{d-icon "lock"}} {{i18n 'user.second_factor.enable'}} From b3eb67976dd0c0129736fe0b86167d7e512b51f4 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Tue, 16 Jul 2019 12:45:15 +0200 Subject: [PATCH 016/441] DEV: Upgrades to Ember 3.10 (#7871) Co-Authored-By: majakomel --- Gemfile | 2 +- Gemfile.lock | 4 +- .../admin/components/ace-editor.js.es6 | 4 +- .../components/admin-backups-logs.js.es6 | 4 +- .../admin/components/admin-graph.js.es6 | 2 +- .../components/admin-report-chart.js.es6 | 11 ++- .../admin-report-stacked-chart.js.es6 | 11 ++- .../admin/components/color-input.js.es6 | 12 +-- .../admin/components/embeddable-host.js.es6 | 2 +- .../admin/components/highlighted-code.js.es6 | 2 +- .../components/penalty-post-action.js.es6 | 6 +- .../admin/components/permalink-form.js.es6 | 6 +- .../screened-ip-address-form.js.es6 | 8 +- .../admin/components/site-text-summary.js.es6 | 6 +- .../admin/components/staff-actions.js.es6 | 20 +++-- .../admin/components/themes-list-item.js.es6 | 4 +- .../admin/components/watched-word-form.js.es6 | 8 +- .../admin/mixins/setting-component.js.es6 | 4 +- .../discourse/components/backup-codes.js.es6 | 12 +-- .../components/backup-uploader.js.es6 | 2 +- .../discourse/components/composer-body.js.es6 | 12 +-- .../components/composer-editor.js.es6 | 29 +++---- .../components/composer-message.js.es6 | 2 +- .../components/composer-title.js.es6 | 8 +- .../components/composer-user-selector.js.es6 | 8 +- .../components/create-account.js.es6 | 8 +- .../discourse/components/csv-uploader.js.es6 | 2 +- .../components/d-editor-modal.js.es6 | 8 +- .../discourse/components/d-editor.js.es6 | 76 ++++++++++--------- .../discourse/components/d-modal-body.js.es6 | 12 ++- .../discourse/components/d-modal.js.es6 | 2 +- .../components/discourse-topic.js.es6 | 7 +- .../components/edit-category-tab.js.es6 | 2 +- .../edit-category-topic-template.js.es6 | 2 +- .../discourse/components/emoji-picker.js.es6 | 14 ++-- .../components/expanding-text-area.js.es6 | 4 +- .../components/flag-selection.js.es6 | 4 +- .../discourse/components/footer-nav.js.es6 | 2 +- .../components/generated-invite-link.js.es6 | 2 +- .../components/group-selector.js.es6 | 2 +- .../components/highlight-text.js.es6 | 2 +- .../components/image-uploader.js.es6 | 6 +- .../discourse/components/link-to-input.js.es6 | 2 +- .../discourse/components/mobile-nav.js.es6 | 8 +- .../components/navigation-bar.js.es6 | 4 +- .../components/popup-input-tip.js.es6 | 2 +- .../discourse/components/quote-button.js.es6 | 2 +- .../discourse/components/radio-button.js.es6 | 2 +- .../components/scrolling-post-stream.js.es6 | 12 +-- .../components/search-text-field.js.es6 | 2 +- .../discourse/components/share-panel.js.es6 | 6 +- .../discourse/components/share-popup.js.es6 | 2 +- .../discourse/components/site-header.js.es6 | 6 +- .../discourse/components/text-overflow.js.es6 | 2 +- .../components/topic-list-item.js.es6 | 2 +- .../components/topic-progress.js.es6 | 4 +- .../components/topic-timeline.js.es6 | 4 +- .../components/user-card-contents.js.es6 | 6 +- .../discourse/components/user-stream.js.es6 | 12 ++- .../discourse/lib/plugin-api.js.es6 | 2 +- .../javascripts/discourse/lib/url.js.es6 | 6 +- .../mixins/card-contents-base.js.es6 | 17 +++-- .../discourse/mixins/pan-events.js.es6 | 4 +- .../discourse/mixins/upload.js.es6 | 4 +- .../category-drop/category-drop-header.js.es6 | 6 +- .../components/mini-tag-chooser.js.es6 | 6 +- .../select-kit/components/multi-select.js.es6 | 2 +- .../multi-select/multi-select-header.js.es6 | 6 +- .../select-kit/mixins/dom-helpers.js.es6 | 64 ++++++++-------- .../select-kit/mixins/events.js.es6 | 9 ++- .../wizard/components/invite-list.js.es6 | 2 +- .../wizard/components/radio-button.js.es6 | 4 +- .../wizard/components/wizard-canvas.js.es6 | 4 +- .../components/wizard-field-image.js.es6 | 2 +- .../javascripts/wizard/lib/preview.js.es6 | 2 +- app/models/theme_field.rb | 2 +- lib/theme_javascript_compiler.rb | 17 +++-- spec/lib/theme_javascript_compiler_spec.rb | 14 ++-- 78 files changed, 332 insertions(+), 263 deletions(-) diff --git a/Gemfile b/Gemfile index f1420c3458..6a621c456d 100644 --- a/Gemfile +++ b/Gemfile @@ -51,7 +51,7 @@ gem 'onebox', '1.9.2' gem 'http_accept_language', '~>2.0.5', require: false gem 'ember-rails', '0.18.5' -gem 'discourse-ember-source', '~> 3.8.0' +gem 'discourse-ember-source', '~> 3.10.0' gem 'ember-handlebars-template', '0.8.0' gem 'barber' diff --git a/Gemfile.lock b/Gemfile.lock index 61885cc8bd..898e8b7658 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -91,7 +91,7 @@ GEM debug_inspector (0.0.3) diff-lcs (1.3) diffy (3.3.0) - discourse-ember-source (3.8.0.1) + discourse-ember-source (3.10.0.1) discourse_image_optim (0.26.2) exifr (~> 1.2, >= 1.2.2) fspath (~> 3.0) @@ -438,7 +438,7 @@ DEPENDENCIES colored2 cppjieba_rb diffy - discourse-ember-source (~> 3.8.0) + discourse-ember-source (~> 3.10.0) discourse_image_optim email_reply_trimmer (~> 0.1) ember-handlebars-template (= 0.8.0) diff --git a/app/assets/javascripts/admin/components/ace-editor.js.es6 b/app/assets/javascripts/admin/components/ace-editor.js.es6 index ff3203f9d3..8fd9089b2e 100644 --- a/app/assets/javascripts/admin/components/ace-editor.js.es6 +++ b/app/assets/javascripts/admin/components/ace-editor.js.es6 @@ -75,7 +75,7 @@ export default Ember.Component.extend({ if (!this.element || this.isDestroying || this.isDestroyed) { return; } - const editor = loadedAce.edit(this.$(".ace")[0]); + const editor = loadedAce.edit(this.element.querySelector(".ace")); editor.setTheme("ace/theme/chrome"); editor.setShowPrintMargin(false); @@ -89,7 +89,7 @@ export default Ember.Component.extend({ editor.$blockScrolling = Infinity; editor.renderer.setScrollMargin(10, 10); - this.$().data("editor", editor); + this.element.setAttribute("data-editor", editor); this._editor = editor; this.changeDisabledState(); diff --git a/app/assets/javascripts/admin/components/admin-backups-logs.js.es6 b/app/assets/javascripts/admin/components/admin-backups-logs.js.es6 index 56f90325f3..5112564eec 100644 --- a/app/assets/javascripts/admin/components/admin-backups-logs.js.es6 +++ b/app/assets/javascripts/admin/components/admin-backups-logs.js.es6 @@ -18,8 +18,8 @@ export default Ember.Component.extend( }, _scrollDown() { - const $div = this.$()[0]; - $div.scrollTop = $div.scrollHeight; + const div = this.element; + div.scrollTop = div.scrollHeight; }, @on("init") diff --git a/app/assets/javascripts/admin/components/admin-graph.js.es6 b/app/assets/javascripts/admin/components/admin-graph.js.es6 index 5ef384022e..5949d51e24 100644 --- a/app/assets/javascripts/admin/components/admin-graph.js.es6 +++ b/app/assets/javascripts/admin/components/admin-graph.js.es6 @@ -5,7 +5,7 @@ export default Ember.Component.extend({ type: "line", refreshChart() { - const ctx = this.$()[0].getContext("2d"); + const ctx = this.element.getContext("2d"); const model = this.model; const rawData = this.get("model.data"); diff --git a/app/assets/javascripts/admin/components/admin-report-chart.js.es6 b/app/assets/javascripts/admin/components/admin-report-chart.js.es6 index b4ced4e1cb..80d35c4693 100644 --- a/app/assets/javascripts/admin/components/admin-report-chart.js.es6 +++ b/app/assets/javascripts/admin/components/admin-report-chart.js.es6 @@ -35,14 +35,17 @@ export default Ember.Component.extend({ _scheduleChartRendering() { Ember.run.schedule("afterRender", () => { - this._renderChart(this.model, this.$(".chart-canvas")); + this._renderChart( + this.model, + this.element && this.element.querySelector(".chart-canvas") + ); }); }, - _renderChart(model, $chartCanvas) { - if (!$chartCanvas || !$chartCanvas.length) return; + _renderChart(model, chartCanvas) { + if (!chartCanvas) return; - const context = $chartCanvas[0].getContext("2d"); + const context = chartCanvas.getContext("2d"); const chartData = Ember.makeArray( model.get("chartData") || model.get("data") ); diff --git a/app/assets/javascripts/admin/components/admin-report-stacked-chart.js.es6 b/app/assets/javascripts/admin/components/admin-report-stacked-chart.js.es6 index a70f698010..ce0dfd9021 100644 --- a/app/assets/javascripts/admin/components/admin-report-stacked-chart.js.es6 +++ b/app/assets/javascripts/admin/components/admin-report-stacked-chart.js.es6 @@ -33,14 +33,17 @@ export default Ember.Component.extend({ _scheduleChartRendering() { Ember.run.schedule("afterRender", () => { - this._renderChart(this.model, this.$(".chart-canvas")); + this._renderChart( + this.model, + this.element.querySelector(".chart-canvas") + ); }); }, - _renderChart(model, $chartCanvas) { - if (!$chartCanvas || !$chartCanvas.length) return; + _renderChart(model, chartCanvas) { + if (!chartCanvas) return; - const context = $chartCanvas[0].getContext("2d"); + const context = chartCanvas.getContext("2d"); const chartData = Ember.makeArray( model.get("chartData") || model.get("data") diff --git a/app/assets/javascripts/admin/components/color-input.js.es6 b/app/assets/javascripts/admin/components/color-input.js.es6 index e57a8efde8..17783cd36a 100644 --- a/app/assets/javascripts/admin/components/color-input.js.es6 +++ b/app/assets/javascripts/admin/components/color-input.js.es6 @@ -11,10 +11,10 @@ export default Ember.Component.extend({ classNames: ["color-picker"], hexValueChanged: function() { var hex = this.hexValue; - let $text = this.$("input.hex-input"); + let text = this.element.querySelector("input.hex-input"); if (this.valid) { - $text.attr( + text.setAttribute( "style", "color: " + (this.brightnessValue > 125 ? "black" : "white") + @@ -24,10 +24,12 @@ export default Ember.Component.extend({ ); if (this.pickerLoaded) { - this.$(".picker").spectrum({ color: "#" + this.hexValue }); + $(this.element.querySelector(".picker")).spectrum({ + color: "#" + this.hexValue + }); } } else { - $text.attr("style", ""); + text.setAttribute("style", ""); } }.observes("hexValue", "brightnessValue", "valid"), @@ -35,7 +37,7 @@ export default Ember.Component.extend({ loadScript("/javascripts/spectrum.js").then(() => { loadCSS("/javascripts/spectrum.css").then(() => { Ember.run.schedule("afterRender", () => { - this.$(".picker") + $(this.element.querySelector(".picker")) .spectrum({ color: "#" + this.hexValue }) .on("change.spectrum", (me, color) => { this.set("hexValue", color.toHexString().replace("#", "")); diff --git a/app/assets/javascripts/admin/components/embeddable-host.js.es6 b/app/assets/javascripts/admin/components/embeddable-host.js.es6 index 34d46ac181..7639312ceb 100644 --- a/app/assets/javascripts/admin/components/embeddable-host.js.es6 +++ b/app/assets/javascripts/admin/components/embeddable-host.js.es6 @@ -14,7 +14,7 @@ export default Ember.Component.extend(bufferedProperty("host"), { @observes("editing") _focusOnInput() { Ember.run.schedule("afterRender", () => { - this.$(".host-name").focus(); + this.element.querySelector(".host-name").focus(); }); }, diff --git a/app/assets/javascripts/admin/components/highlighted-code.js.es6 b/app/assets/javascripts/admin/components/highlighted-code.js.es6 index 62cf58f21b..9f99c0929b 100644 --- a/app/assets/javascripts/admin/components/highlighted-code.js.es6 +++ b/app/assets/javascripts/admin/components/highlighted-code.js.es6 @@ -5,6 +5,6 @@ export default Ember.Component.extend({ @on("didInsertElement") @observes("code") _refresh: function() { - highlightSyntax(this.$()); + highlightSyntax($(this.element)); } }); diff --git a/app/assets/javascripts/admin/components/penalty-post-action.js.es6 b/app/assets/javascripts/admin/components/penalty-post-action.js.es6 index 543a8baf3c..e60393b3ba 100644 --- a/app/assets/javascripts/admin/components/penalty-post-action.js.es6 +++ b/app/assets/javascripts/admin/components/penalty-post-action.js.es6 @@ -23,10 +23,10 @@ export default Ember.Component.extend({ // If we switch to edit mode, jump to the edit textarea if (postAction === "edit") { Ember.run.scheduleOnce("afterRender", () => { - let $elem = this.$(); - let body = $elem.closest(".modal-body"); + let elem = this.element; + let body = elem.closest(".modal-body"); body.scrollTop(body.height()); - $elem.find(".post-editor").focus(); + elem.querySelector(".post-editor").focus(); }); } } diff --git a/app/assets/javascripts/admin/components/permalink-form.js.es6 b/app/assets/javascripts/admin/components/permalink-form.js.es6 index bce150f8c9..40ae69d090 100644 --- a/app/assets/javascripts/admin/components/permalink-form.js.es6 +++ b/app/assets/javascripts/admin/components/permalink-form.js.es6 @@ -19,7 +19,9 @@ export default Ember.Component.extend({ }, focusPermalink() { - Ember.run.schedule("afterRender", () => this.$(".permalink-url").focus()); + Ember.run.schedule("afterRender", () => + this.element.querySelector(".permalink-url").focus() + ); }, actions: { @@ -67,7 +69,7 @@ export default Ember.Component.extend({ this._super(...arguments); Ember.run.schedule("afterRender", () => { - this.$(".external-url").keydown(e => { + $(this.element.querySelector(".external-url")).keydown(e => { // enter key if (e.keyCode === 13) { this.send("submit"); diff --git a/app/assets/javascripts/admin/components/screened-ip-address-form.js.es6 b/app/assets/javascripts/admin/components/screened-ip-address-form.js.es6 index 5573374a24..7fb246bf7a 100644 --- a/app/assets/javascripts/admin/components/screened-ip-address-form.js.es6 +++ b/app/assets/javascripts/admin/components/screened-ip-address-form.js.es6 @@ -62,7 +62,7 @@ export default Ember.Component.extend({ this.setProperties({ ip_address: "", formSubmitted: false }); this.action(ScreenedIpAddress.create(result.screened_ip_address)); Ember.run.schedule("afterRender", () => - this.$(".ip-address-input").focus() + this.element.querySelector(".ip-address-input").focus() ); }) .catch(e => { @@ -73,7 +73,9 @@ export default Ember.Component.extend({ error: e.jqXHR.responseJSON.errors.join(". ") }) : I18n.t("generic_error"); - bootbox.alert(msg, () => this.$(".ip-address-input").focus()); + bootbox.alert(msg, () => + this.element.querySelector(".ip-address-input").focus() + ); }); } } @@ -82,7 +84,7 @@ export default Ember.Component.extend({ @on("didInsertElement") _init() { Ember.run.schedule("afterRender", () => { - this.$(".ip-address-input").keydown(e => { + $(this.element.querySelector(".ip-address-input")).keydown(e => { if (e.keyCode === 13) { this.send("submit"); } diff --git a/app/assets/javascripts/admin/components/site-text-summary.js.es6 b/app/assets/javascripts/admin/components/site-text-summary.js.es6 index 003ff2ffef..da6dda43f8 100644 --- a/app/assets/javascripts/admin/components/site-text-summary.js.es6 +++ b/app/assets/javascripts/admin/components/site-text-summary.js.es6 @@ -9,11 +9,13 @@ export default Ember.Component.extend({ const term = this._searchTerm(); if (term) { - this.$(".site-text-id, .site-text-value").highlight(term, { + $( + this.element.querySelector(".site-text-id, .site-text-value") + ).highlight(term, { className: "text-highlight" }); } - this.$(".site-text-value").ellipsis(); + $(this.element.querySelector(".site-text-value")).ellipsis(); }, click() { diff --git a/app/assets/javascripts/admin/components/staff-actions.js.es6 b/app/assets/javascripts/admin/components/staff-actions.js.es6 index 3a979b0290..5c7da1dc9d 100644 --- a/app/assets/javascripts/admin/components/staff-actions.js.es6 +++ b/app/assets/javascripts/admin/components/staff-actions.js.es6 @@ -4,19 +4,23 @@ export default Ember.Component.extend({ classNames: ["table", "staff-actions"], willDestroyElement() { - this.$().off("click.discourse-staff-logs"); + $(this.element).off("click.discourse-staff-logs"); }, didInsertElement() { this._super(...arguments); - this.$().on("click.discourse-staff-logs", "[data-link-post-id]", e => { - let postId = $(e.target).attr("data-link-post-id"); + $(this.element).on( + "click.discourse-staff-logs", + "[data-link-post-id]", + e => { + let postId = $(e.target).attr("data-link-post-id"); - this.store.find("post", postId).then(p => { - DiscourseURL.routeTo(p.get("url")); - }); - return false; - }); + this.store.find("post", postId).then(p => { + DiscourseURL.routeTo(p.get("url")); + }); + return false; + } + ); } }); diff --git a/app/assets/javascripts/admin/components/themes-list-item.js.es6 b/app/assets/javascripts/admin/components/themes-list-item.js.es6 index a3a84ea3d8..7bafa95c21 100644 --- a/app/assets/javascripts/admin/components/themes-list-item.js.es6 +++ b/app/assets/javascripts/admin/components/themes-list-item.js.es6 @@ -38,8 +38,8 @@ export default Ember.Component.extend({ }, animate(isInitial) { - const $container = this.$(); - const $list = this.$(".components-list"); + const $container = $(this.element); + const $list = $(this.element.querySelector(".components-list")); if ($list.length === 0 || Ember.testing) { return; } 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 837ce517b0..b56f2c423c 100644 --- a/app/assets/javascripts/admin/components/watched-word-form.js.es6 +++ b/app/assets/javascripts/admin/components/watched-word-form.js.es6 @@ -64,7 +64,7 @@ export default Ember.Component.extend({ }); this.action(WatchedWord.create(result)); Ember.run.schedule("afterRender", () => - this.$(".watched-word-input").focus() + this.element.querySelector(".watched-word-input").focus() ); }) .catch(e => { @@ -75,7 +75,9 @@ export default Ember.Component.extend({ error: e.jqXHR.responseJSON.errors.join(". ") }) : I18n.t("generic_error"); - bootbox.alert(msg, () => this.$(".watched-word-input").focus()); + bootbox.alert(msg, () => + this.element.querySelector(".watched-word-input").focus() + ); }); } } @@ -84,7 +86,7 @@ export default Ember.Component.extend({ @on("didInsertElement") _init() { Ember.run.schedule("afterRender", () => { - this.$(".watched-word-input").keydown(e => { + $(this.element.querySelector(".watched-word-input")).keydown(e => { if (e.keyCode === 13) { this.send("submit"); } diff --git a/app/assets/javascripts/admin/mixins/setting-component.js.es6 b/app/assets/javascripts/admin/mixins/setting-component.js.es6 index dbed60a799..18be48a2d1 100644 --- a/app/assets/javascripts/admin/mixins/setting-component.js.es6 +++ b/app/assets/javascripts/admin/mixins/setting-component.js.es6 @@ -90,7 +90,7 @@ export default Ember.Mixin.create({ }, _watchEnterKey: function() { - this.$().on("keydown.setting-enter", ".input-setting-string", e => { + $(this.element).on("keydown.setting-enter", ".input-setting-string", e => { if (e.keyCode === 13) { // enter key this.send("save"); @@ -99,7 +99,7 @@ export default Ember.Mixin.create({ }.on("didInsertElement"), _removeBindings: function() { - this.$().off("keydown.setting-enter"); + $(this.element).off("keydown.setting-enter"); }.on("willDestroyElement"), _save() { diff --git a/app/assets/javascripts/discourse/components/backup-codes.js.es6 b/app/assets/javascripts/discourse/components/backup-codes.js.es6 index 08d33416f4..51be5edccd 100644 --- a/app/assets/javascripts/discourse/components/backup-codes.js.es6 +++ b/app/assets/javascripts/discourse/components/backup-codes.js.es6 @@ -25,9 +25,9 @@ export default Ember.Component.extend({ didRender() { this._super(...arguments); - const $backupCodes = this.$("#backupCodes"); - if ($backupCodes.length) { - $backupCodes.height($backupCodes[0].scrollHeight); + const backupCodes = this.element.querySelector("#backupCodes"); + if (backupCodes) { + backupCodes.style.height = backupCodes.scrollHeight; } }, @@ -49,8 +49,8 @@ export default Ember.Component.extend({ }, _selectAllBackupCodes() { - const $textArea = this.$("#backupCodes"); - $textArea[0].focus(); - $textArea[0].setSelectionRange(0, this.formattedBackupCodes.length); + const textArea = this.element.querySelector("#backupCodes"); + textArea.focus(); + textArea.setSelectionRange(0, this.formattedBackupCodes.length); } }); diff --git a/app/assets/javascripts/discourse/components/backup-uploader.js.es6 b/app/assets/javascripts/discourse/components/backup-uploader.js.es6 index 939777f0a7..66b2665095 100644 --- a/app/assets/javascripts/discourse/components/backup-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/backup-uploader.js.es6 @@ -35,7 +35,7 @@ export default Ember.Component.extend(UploadMixin, { }, _init: function() { - const $upload = this.$(); + const $upload = $(this.element); $upload.on("fileuploadadd", (e, data) => { ajax("/admin/backups/upload_url", { diff --git a/app/assets/javascripts/discourse/components/composer-body.js.es6 b/app/assets/javascripts/discourse/components/composer-body.js.es6 index bd7a4488eb..1d89f93f1a 100644 --- a/app/assets/javascripts/discourse/components/composer-body.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-body.js.es6 @@ -93,9 +93,9 @@ export default Ember.Component.extend(KeyEnterEscape, { }, setupComposerResizeEvents() { - const $composer = this.$(); - const $grippie = this.$(".grippie"); - const $document = Ember.$(document); + const $composer = $(this.element); + const $grippie = $(this.element.querySelector(".grippie")); + const $document = $(document); let origComposerSize = 0; let lastMousePos = 0; @@ -105,7 +105,7 @@ export default Ember.Component.extend(KeyEnterEscape, { const currentMousePos = mouseYPos(event); let size = origComposerSize + (lastMousePos - currentMousePos); - const winHeight = Ember.$(window).height(); + const winHeight = $(window).height(); size = Math.min(size, winHeight - headerHeight()); size = Math.max(size, MIN_COMPOSER_SIZE); this.movePanels(size); @@ -145,11 +145,11 @@ export default Ember.Component.extend(KeyEnterEscape, { }; triggerOpen(); - afterTransition(this.$(), () => { + afterTransition($(this.element), () => { resize(); triggerOpen(); }); - positioningWorkaround(this.$()); + positioningWorkaround($(this.element)); }, willDestroyElement() { diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index 616a0228c8..92fd6190e1 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -112,7 +112,7 @@ export default Ember.Component.extend({ @observes("focusTarget") setFocus() { if (this.focusTarget === "editor") { - this.$("textarea").putCursorAtEnd(); + $(this.element.querySelector("textarea")).putCursorAtEnd(); } }, @@ -158,8 +158,8 @@ export default Ember.Component.extend({ @on("didInsertElement") _composerEditorInit() { const topicId = this.get("topic.id"); - const $input = this.$(".d-editor-input"); - const $preview = this.$(".d-editor-preview-wrapper"); + const $input = $(this.element.querySelector(".d-editor-input")); + const $preview = $(this.element.querySelector(".d-editor-preview-wrapper")); if (this.siteSettings.enable_mentions) { $input.autocomplete({ @@ -206,7 +206,7 @@ export default Ember.Component.extend({ !this.get("composer.canEditTitle") && (!this.capabilities.isIOS || safariHacksDisabled()) ) { - this.$(".d-editor-input").putCursorAtEnd(); + $(this.element.querySelector(".d-editor-input")).putCursorAtEnd(); } this._bindUploadTarget(); @@ -345,12 +345,13 @@ export default Ember.Component.extend({ }, _teardownInputPreviewSync() { - [this.$(".d-editor-input"), this.$(".d-editor-preview-wrapper")].forEach( - $element => { - $element.off("mouseenter touchstart"); - $element.off("scroll"); - } - ); + [ + $(this.element.querySelector(".d-editor-input")), + $(this.element.querySelector(".d-editor-preview-wrapper")) + ].forEach($element => { + $element.off("mouseenter touchstart"); + $element.off("scroll"); + }); REBUILD_SCROLL_MAP_EVENTS.forEach(event => { this.appEvents.off(event, this, this._resetShouldBuildScrollMap); @@ -647,7 +648,7 @@ export default Ember.Component.extend({ this._unbindUploadTarget(); // in case it's still bound, let's clean it up first this._pasted = false; - const $element = this.$(); + const $element = $(this.element); const csrf = this.session.get("csrfToken"); $element.fileupload({ @@ -890,7 +891,7 @@ export default Ember.Component.extend({ this._validUploads = 0; $("#reply-control .mobile-file-upload").off("click.uploader"); this.messageBus.unsubscribe("/uploads/composer"); - const $uploadTarget = this.$(); + const $uploadTarget = $(this.element); try { $uploadTarget.fileupload("destroy"); } catch (e) { @@ -925,7 +926,7 @@ export default Ember.Component.extend({ }, showPreview() { - const $preview = this.$(".d-editor-preview-wrapper"); + const $preview = $(this.element.querySelector(".d-editor-preview-wrapper")); this._placeImageScaleButtons($preview); this.send("togglePreview"); }, @@ -1071,7 +1072,7 @@ export default Ember.Component.extend({ if (this._enableAdvancedEditorPreviewSync()) { this._syncScroll( this._syncEditorAndPreviewScroll, - this.$(".d-editor-input"), + $(this.element.querySelector(".d-editor-input")), $preview ); } diff --git a/app/assets/javascripts/discourse/components/composer-message.js.es6 b/app/assets/javascripts/discourse/components/composer-message.js.es6 index dc8d387a1e..4cdda3fc49 100644 --- a/app/assets/javascripts/discourse/components/composer-message.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-message.js.es6 @@ -11,7 +11,7 @@ export default Ember.Component.extend({ didInsertElement() { this._super(...arguments); - this.$().show(); + this.element.style.display = "block"; }, actions: { diff --git a/app/assets/javascripts/discourse/components/composer-title.js.es6 b/app/assets/javascripts/discourse/components/composer-title.js.es6 index 345e2ca9b4..5302ab85f4 100644 --- a/app/assets/javascripts/discourse/components/composer-title.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-title.js.es6 @@ -14,9 +14,9 @@ export default Ember.Component.extend({ didInsertElement() { this._super(...arguments); if (this.focusTarget === "title") { - const $input = this.$("input"); + const $input = $(this.element.querySelector("input")); - afterTransition(this.$().closest("#reply-control"), () => { + afterTransition($(this.element).closest("#reply-control"), () => { $input.putCursorAtEnd(); }); } @@ -133,14 +133,14 @@ export default Ember.Component.extend({ .finally(() => { this.set("composer.loading", false); Ember.run.schedule("afterRender", () => { - this.$("input").putCursorAtEnd(); + $(this.element.querySelector("input")).putCursorAtEnd(); }); }); } else { this._updatePost(loadOnebox); this.set("composer.loading", false); Ember.run.schedule("afterRender", () => { - this.$("input").putCursorAtEnd(); + $(this.element.querySelector("input")).putCursorAtEnd(); }); } } diff --git a/app/assets/javascripts/discourse/components/composer-user-selector.js.es6 b/app/assets/javascripts/discourse/components/composer-user-selector.js.es6 index 77c7537ece..04a1ba7dc5 100644 --- a/app/assets/javascripts/discourse/components/composer-user-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-user-selector.js.es6 @@ -12,14 +12,14 @@ export default Ember.Component.extend({ this._super(...arguments); if (this.focusTarget === "usernames") { - this.$("input").putCursorAtEnd(); + $(this.element.querySelector("input")).putCursorAtEnd(); } }, @observes("usernames") _checkWidth() { let width = 0; - const $acWrap = this.$().find(".ac-wrap"); + const $acWrap = $(this.element).find(".ac-wrap"); const limit = $acWrap.width(); this.set("defaultUsernameCount", 0); @@ -76,7 +76,7 @@ export default Ember.Component.extend({ this.set("showSelector", true); Ember.run.schedule("afterRender", () => { - this.$() + $(this.element) .find("input") .focus(); }); @@ -84,7 +84,7 @@ export default Ember.Component.extend({ triggerResize() { this.appEvents.trigger("composer:resize"); - const $this = this.$().find(".ac-wrap"); + const $this = $(this.element).find(".ac-wrap"); if ($this.height() >= 150) $this.scrollTop($this.height()); } } diff --git a/app/assets/javascripts/discourse/components/create-account.js.es6 b/app/assets/javascripts/discourse/components/create-account.js.es6 index 74f08fbb9b..9d91b58b35 100644 --- a/app/assets/javascripts/discourse/components/create-account.js.es6 +++ b/app/assets/javascripts/discourse/components/create-account.js.es6 @@ -8,7 +8,7 @@ export default Ember.Component.extend({ this.set("email", $.cookie("email")); } - this.$().on("keydown.discourse-create-account", e => { + $(this.element).on("keydown.discourse-create-account", e => { if (!this.disabled && e.keyCode === 13) { e.preventDefault(); e.stopPropagation(); @@ -17,7 +17,7 @@ export default Ember.Component.extend({ } }); - this.$().on("click.dropdown-user-field-label", "[for]", event => { + $(this.element).on("click.dropdown-user-field-label", "[for]", event => { const $element = $(event.target); const $target = $(`#${$element.attr("for")}`); @@ -31,7 +31,7 @@ export default Ember.Component.extend({ willDestroyElement() { this._super(...arguments); - this.$().off("keydown.discourse-create-account"); - this.$().off("click.dropdown-user-field-label"); + $(this.element).off("keydown.discourse-create-account"); + $(this.element).off("click.dropdown-user-field-label"); } }); diff --git a/app/assets/javascripts/discourse/components/csv-uploader.js.es6 b/app/assets/javascripts/discourse/components/csv-uploader.js.es6 index f080dcf755..a37e10f3cc 100644 --- a/app/assets/javascripts/discourse/components/csv-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/csv-uploader.js.es6 @@ -32,7 +32,7 @@ export default Ember.Component.extend(UploadMixin, { }, _init: function() { - const $upload = this.$(); + const $upload = $(this.element); $upload.on("fileuploadadd", (e, data) => { bootbox.confirm( diff --git a/app/assets/javascripts/discourse/components/d-editor-modal.js.es6 b/app/assets/javascripts/discourse/components/d-editor-modal.js.es6 index 6456bdc7d5..4085b8cc8f 100644 --- a/app/assets/javascripts/discourse/components/d-editor-modal.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor-modal.js.es6 @@ -7,8 +7,8 @@ export default Ember.Component.extend({ _hiddenChanged() { if (!this.hidden) { Ember.run.scheduleOnce("afterRender", () => { - const $modal = this.$(); - const $parent = this.$().closest(".d-editor"); + const $modal = $(this.element); + const $parent = $(this.element).closest(".d-editor"); const w = $parent.width(); const h = $parent.height(); const dir = $("html").css("direction") === "rtl" ? "right" : "left"; @@ -27,7 +27,7 @@ export default Ember.Component.extend({ @on("didInsertElement") _listenKeys() { - this.$().on("keydown.d-modal", key => { + $(this.element).on("keydown.d-modal", key => { if (this.hidden) { return; } @@ -45,7 +45,7 @@ export default Ember.Component.extend({ @on("willDestroyElement") _stopListening() { - this.$().off("keydown.d-modal"); + $(this.element).off("keydown.d-modal"); }, actions: { diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6 index ccafc0bd04..ad5611cd3f 100644 --- a/app/assets/javascripts/discourse/components/d-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor.js.es6 @@ -231,7 +231,7 @@ export default Ember.Component.extend({ this.set("ready", true); if (this.autofocus) { - this.$("textarea").focus(); + this.element.querySelector("textarea").focus(); } }, @@ -244,13 +244,13 @@ export default Ember.Component.extend({ didInsertElement() { this._super(...arguments); - const $editorInput = this.$(".d-editor-input"); + const $editorInput = $(this.element.querySelector(".d-editor-input")); this._applyEmojiAutocomplete($editorInput); this._applyCategoryHashtagAutocomplete($editorInput); Ember.run.scheduleOnce("afterRender", this, this._readyNow); - const mouseTrap = Mousetrap(this.$(".d-editor-input")[0]); + const mouseTrap = Mousetrap(this.element.querySelector(".d-editor-input")); const shortcuts = this.get("toolbar.shortcuts"); Object.keys(shortcuts).forEach(sc => { @@ -262,28 +262,31 @@ export default Ember.Component.extend({ }); // disable clicking on links in the preview - this.$(".d-editor-preview").on("click.preview", e => { - if (wantsNewWindow(e)) { - return; + $(this.element.querySelector(".d-editor-preview")).on( + "click.preview", + e => { + if (wantsNewWindow(e)) { + return; + } + const $target = $(e.target); + if ($target.is("a.mention")) { + this.appEvents.trigger( + "click.discourse-preview-user-card-mention", + $target + ); + } + if ($target.is("a.mention-group")) { + this.appEvents.trigger( + "click.discourse-preview-group-card-mention-group", + $target + ); + } + if ($target.is("a")) { + e.preventDefault(); + return false; + } } - const $target = $(e.target); - if ($target.is("a.mention")) { - this.appEvents.trigger( - "click.discourse-preview-user-card-mention", - $target - ); - } - if ($target.is("a.mention-group")) { - this.appEvents.trigger( - "click.discourse-preview-group-card-mention-group", - $target - ); - } - if ($target.is("a")) { - e.preventDefault(); - return false; - } - }); + ); if (this.composerEvents) { this.appEvents.on("composer:insert-block", this, "_insertBlock"); @@ -313,7 +316,7 @@ export default Ember.Component.extend({ Object.keys(this.get("toolbar.shortcuts")).forEach(sc => mouseTrap.unbind(sc) ); - this.$(".d-editor-preview").off("click.preview"); + $(this.element.querySelector(".d-editor-preview")).off("click.preview"); }, @computed @@ -348,7 +351,7 @@ export default Ember.Component.extend({ if (this._state !== "inDOM") { return; } - const $preview = this.$(".d-editor-preview"); + const $preview = $(this.element.querySelector(".d-editor-preview")); if ($preview.length === 0) return; if (this.previewUpdated) { @@ -375,7 +378,7 @@ export default Ember.Component.extend({ _applyCategoryHashtagAutocomplete() { const siteSettings = this.siteSettings; - this.$(".d-editor-input").autocomplete({ + $(this.element.querySelector(".d-editor-input")).autocomplete({ template: findRawTemplate("category-tag-autocomplete"), key: "#", afterComplete: () => this._focusTextArea(), @@ -500,7 +503,7 @@ export default Ember.Component.extend({ return; } - const textarea = this.$("textarea.d-editor-input")[0]; + const textarea = this.element.querySelector("textarea.d-editor-input"); const value = textarea.value; let start = textarea.selectionStart; let end = textarea.selectionEnd; @@ -533,8 +536,8 @@ export default Ember.Component.extend({ _selectText(from, length) { Ember.run.scheduleOnce("afterRender", () => { - const $textarea = this.$("textarea.d-editor-input"); - const textarea = $textarea[0]; + const textarea = this.element.querySelector("textarea.d-editor-input"); + const $textarea = $(textarea); const oldScrollPos = $textarea.scrollTop(); if (!this.capabilities.isIOS || safariHacksDisabled()) { $textarea.focus(); @@ -687,7 +690,7 @@ export default Ember.Component.extend({ return; } - const textarea = this.$("textarea.d-editor-input")[0]; + const textarea = this.element.querySelector("textarea.d-editor-input"); // Determine post-replace selection. const newSelection = determinePostReplaceSelection({ @@ -737,7 +740,7 @@ export default Ember.Component.extend({ } const value = pre + text + post; - const $textarea = this.$("textarea.d-editor-input"); + const $textarea = $(this.element.querySelector("textarea.d-editor-input")); this.set("value", value); @@ -749,7 +752,7 @@ export default Ember.Component.extend({ }, _addText(sel, text, options) { - const $textarea = this.$("textarea.d-editor-input"); + const $textarea = $(this.element.querySelector("textarea.d-editor-input")); if (options && options.ensureSpace) { if ((sel.pre + "").length > 0) { @@ -870,8 +873,11 @@ export default Ember.Component.extend({ // ensures textarea scroll position is correct _focusTextArea() { - const $textarea = this.$("textarea.d-editor-input"); - Ember.run.scheduleOnce("afterRender", () => $textarea.blur().focus()); + const textarea = this.element.querySelector("textarea.d-editor-input"); + Ember.run.scheduleOnce("afterRender", () => { + textarea.blur(); + textarea.focus(); + }); }, actions: { diff --git a/app/assets/javascripts/discourse/components/d-modal-body.js.es6 b/app/assets/javascripts/discourse/components/d-modal-body.js.es6 index f9f46e6167..8e8f01b0bc 100644 --- a/app/assets/javascripts/discourse/components/d-modal-body.js.es6 +++ b/app/assets/javascripts/discourse/components/d-modal-body.js.es6 @@ -7,7 +7,7 @@ export default Ember.Component.extend({ this._super(...arguments); $("#modal-alert").hide(); - let fixedParent = this.$().closest(".d-modal.fixed-modal"); + let fixedParent = $(this.element).closest(".d-modal.fixed-modal"); if (fixedParent.length) { this.set("fixed", true); fixedParent.modal("show"); @@ -26,8 +26,12 @@ export default Ember.Component.extend({ }, _afterFirstRender() { - if (!this.site.mobileView && this.autoFocus !== "false") { - this.$("input:first").focus(); + if ( + !this.site.mobileView && + this.autoFocus !== "false" && + this.element.querySelector("input") + ) { + this.element.querySelector("input").focus(); } const maxHeight = this.maxHeight; @@ -35,7 +39,7 @@ export default Ember.Component.extend({ const maxHeightFloat = parseFloat(maxHeight) / 100.0; if (maxHeightFloat > 0) { const viewPortHeight = $(window).height(); - this.$().css( + $(this.element).css( "max-height", Math.floor(maxHeightFloat * viewPortHeight) + "px" ); diff --git a/app/assets/javascripts/discourse/components/d-modal.js.es6 b/app/assets/javascripts/discourse/components/d-modal.js.es6 index cdf5fff878..f870780789 100644 --- a/app/assets/javascripts/discourse/components/d-modal.js.es6 +++ b/app/assets/javascripts/discourse/components/d-modal.js.es6 @@ -66,7 +66,7 @@ export default Ember.Component.extend({ } if (data.fixed) { - this.$().removeClass("hidden"); + this.element.classList.remove("hidden"); } if (data.title) { diff --git a/app/assets/javascripts/discourse/components/discourse-topic.js.es6 b/app/assets/javascripts/discourse/components/discourse-topic.js.es6 index dc84102a30..f94788b470 100644 --- a/app/assets/javascripts/discourse/components/discourse-topic.js.es6 +++ b/app/assets/javascripts/discourse/components/discourse-topic.js.es6 @@ -102,7 +102,7 @@ export default Ember.Component.extend( $(window).on("resize.discourse-on-scroll", () => this.scrolled()); - this.$().on( + $(this.element).on( "click.discourse-redirect", ".cooked a, a.track-link", function(e) { @@ -120,7 +120,10 @@ export default Ember.Component.extend( $(window).unbind("resize.discourse-on-scroll"); // Unbind link tracking - this.$().off("click.discourse-redirect", ".cooked a, a.track-link"); + $(this.element).off( + "click.discourse-redirect", + ".cooked a, a.track-link" + ); this.resetExamineDockCache(); diff --git a/app/assets/javascripts/discourse/components/edit-category-tab.js.es6 b/app/assets/javascripts/discourse/components/edit-category-tab.js.es6 index c36ec0175a..5f2f6b4912 100644 --- a/app/assets/javascripts/discourse/components/edit-category-tab.js.es6 +++ b/app/assets/javascripts/discourse/components/edit-category-tab.js.es6 @@ -27,7 +27,7 @@ export default Ember.Component.extend({ }, _resetModalScrollState() { - const $modalBody = this.$() + const $modalBody = $(this.element) .parents("#discourse-modal") .find(".modal-body"); if ($modalBody.length === 1) { diff --git a/app/assets/javascripts/discourse/components/edit-category-topic-template.js.es6 b/app/assets/javascripts/discourse/components/edit-category-topic-template.js.es6 index ab643885a4..b1d5c79d81 100644 --- a/app/assets/javascripts/discourse/components/edit-category-topic-template.js.es6 +++ b/app/assets/javascripts/discourse/components/edit-category-topic-template.js.es6 @@ -4,7 +4,7 @@ export default buildCategoryPanel("topic-template", { _activeTabChanged: function() { if (this.activeTab) { Ember.run.scheduleOnce("afterRender", () => - this.$(".d-editor-input").focus() + this.element.querySelector(".d-editor-input").focus() ); } }.observes("activeTab") diff --git a/app/assets/javascripts/discourse/components/emoji-picker.js.es6 b/app/assets/javascripts/discourse/components/emoji-picker.js.es6 index 0a972e7227..88185d9bd8 100644 --- a/app/assets/javascripts/discourse/components/emoji-picker.js.es6 +++ b/app/assets/javascripts/discourse/components/emoji-picker.js.es6 @@ -86,8 +86,8 @@ export default Ember.Component.extend({ @on("didInsertElement") _setup() { - this.$picker = this.$(".emoji-picker"); - this.$modal = this.$(".emoji-picker-modal"); + this.$picker = $(this.element.querySelector(".emoji-picker")); + this.$modal = $(this.element.querySelector(".emoji-picker-modal")); this.appEvents.on("emoji-picker:close", this, "_closeEmojiPicker"); @@ -228,8 +228,8 @@ export default Ember.Component.extend({ @on("willDestroyElement") _unbindEvents() { - this.$().off(); - this.$(window).off("resize"); + $(this.element).off(); + $(window).off("resize"); clearInterval(this._refreshInterval); $("#reply-control").off("div-resizing"); $("html").off("mouseup.emoji-picker"); @@ -312,7 +312,7 @@ export default Ember.Component.extend({ }, _bindResizing() { - this.$(window).on("resize", () => { + $(window).on("resize", () => { run.throttle(this, this._positionPicker, 16); }); @@ -468,7 +468,7 @@ export default Ember.Component.extend({ _isReplyControlExpanded() { const verticalSpace = - this.$(window).height() - + $(window).height() - $(".d-header").height() - $("#reply-control").height(); @@ -480,7 +480,7 @@ export default Ember.Component.extend({ return; } - let windowWidth = this.$(window).width(); + let windowWidth = $(window).width(); const desktopModalePositioning = options => { let attributes = { diff --git a/app/assets/javascripts/discourse/components/expanding-text-area.js.es6 b/app/assets/javascripts/discourse/components/expanding-text-area.js.es6 index 7af160b4fe..064764713f 100644 --- a/app/assets/javascripts/discourse/components/expanding-text-area.js.es6 +++ b/app/assets/javascripts/discourse/components/expanding-text-area.js.es6 @@ -5,7 +5,7 @@ export default Ember.TextArea.extend({ @on("didInsertElement") _startWatching() { Ember.run.scheduleOnce("afterRender", () => { - this.$().focus(); + $(this.element).focus(); autosize(this.element); }); }, @@ -19,6 +19,6 @@ export default Ember.TextArea.extend({ @on("willDestroyElement") _disableAutosize() { - autosize.destroy(this.$()); + autosize.destroy($(this.element)); } }); diff --git a/app/assets/javascripts/discourse/components/flag-selection.js.es6 b/app/assets/javascripts/discourse/components/flag-selection.js.es6 index ca02fd03bd..8499713ce2 100644 --- a/app/assets/javascripts/discourse/components/flag-selection.js.es6 +++ b/app/assets/javascripts/discourse/components/flag-selection.js.es6 @@ -3,14 +3,14 @@ import { observes } from "ember-addons/ember-computed-decorators"; // Mostly hacks because `flag.hbs` didn't use `radio-button` export default Ember.Component.extend({ _selectRadio() { - this.$("input[type='radio']").prop("checked", false); + this.element.querySelector("input[type='radio']").checked = false; const nameKey = this.nameKey; if (!nameKey) { return; } - this.$("#radio_" + nameKey).prop("checked", "true"); + this.element.querySelector("#radio_" + nameKey).checked = "true"; }, @observes("nameKey") diff --git a/app/assets/javascripts/discourse/components/footer-nav.js.es6 b/app/assets/javascripts/discourse/components/footer-nav.js.es6 index 8e1a4e8a2f..2e932686c9 100644 --- a/app/assets/javascripts/discourse/components/footer-nav.js.es6 +++ b/app/assets/javascripts/discourse/components/footer-nav.js.es6 @@ -91,7 +91,7 @@ const FooterNavComponent = MountWidget.extend( // in the header, otherwise, we hide it. @observes("mobileScrollDirection") toggleMobileFooter() { - this.$().toggleClass( + $(this.element).toggleClass( "visible", this.mobileScrollDirection === null ? true : false ); diff --git a/app/assets/javascripts/discourse/components/generated-invite-link.js.es6 b/app/assets/javascripts/discourse/components/generated-invite-link.js.es6 index 35179719ec..74426a45b6 100644 --- a/app/assets/javascripts/discourse/components/generated-invite-link.js.es6 +++ b/app/assets/javascripts/discourse/components/generated-invite-link.js.es6 @@ -1,7 +1,7 @@ export default Ember.Component.extend({ didInsertElement() { this._super(...arguments); - this.$("input") + $(this.element.querySelector("input")) .select() .focus(); } diff --git a/app/assets/javascripts/discourse/components/group-selector.js.es6 b/app/assets/javascripts/discourse/components/group-selector.js.es6 index 9447f63875..c70c51fbc7 100644 --- a/app/assets/javascripts/discourse/components/group-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/group-selector.js.es6 @@ -22,7 +22,7 @@ export default Ember.Component.extend({ let selectedGroups; let groupNames = this.groupNames; - this.$("input").autocomplete({ + $(this.element.querySelector("input")).autocomplete({ allowAny: false, items: _.isArray(groupNames) ? groupNames diff --git a/app/assets/javascripts/discourse/components/highlight-text.js.es6 b/app/assets/javascripts/discourse/components/highlight-text.js.es6 index 73c2b045bc..6e8be431dc 100644 --- a/app/assets/javascripts/discourse/components/highlight-text.js.es6 +++ b/app/assets/javascripts/discourse/components/highlight-text.js.es6 @@ -5,7 +5,7 @@ export default Ember.Component.extend({ _highlightOnInsert: function() { const term = this.highlight; - highlightText(this.$(), term); + highlightText($(this.element), term); } .observes("highlight") .on("didInsertElement") diff --git a/app/assets/javascripts/discourse/components/image-uploader.js.es6 b/app/assets/javascripts/discourse/components/image-uploader.js.es6 index 6bdb84553a..df9ce3e2b2 100644 --- a/app/assets/javascripts/discourse/components/image-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/image-uploader.js.es6 @@ -76,11 +76,13 @@ export default Ember.Component.extend(UploadMixin, { }, _openLightbox() { - Ember.run.next(() => this.$("a.lightbox").magnificPopup("open")); + Ember.run.next(() => + $(this.element.querySelector("a.lightbox")).magnificPopup("open") + ); }, _applyLightbox() { - if (this.imageUrl) Ember.run.next(() => lightbox(this.$())); + if (this.imageUrl) Ember.run.next(() => lightbox($(this.element))); }, actions: { diff --git a/app/assets/javascripts/discourse/components/link-to-input.js.es6 b/app/assets/javascripts/discourse/components/link-to-input.js.es6 index 34eaedc7d4..0afdbd304b 100644 --- a/app/assets/javascripts/discourse/components/link-to-input.js.es6 +++ b/app/assets/javascripts/discourse/components/link-to-input.js.es6 @@ -5,7 +5,7 @@ export default Ember.Component.extend({ this.onClick(); Ember.run.schedule("afterRender", () => { - this.$() + $(this.element) .find("input") .focus(); }); diff --git a/app/assets/javascripts/discourse/components/mobile-nav.js.es6 b/app/assets/javascripts/discourse/components/mobile-nav.js.es6 index f63183b217..bba0162b81 100644 --- a/app/assets/javascripts/discourse/components/mobile-nav.js.es6 +++ b/app/assets/javascripts/discourse/components/mobile-nav.js.es6 @@ -24,9 +24,9 @@ export default Ember.Component.extend({ }, _updateSelectedHtml() { - const active = this.$(".active"); - if (active && active.html) { - this.set("selectedHtml", active.html()); + const active = this.element.querySelector(".active"); + if (active && active.innerHTML) { + this.set("selectedHtml", active.innerHTML); } }, @@ -43,7 +43,7 @@ export default Ember.Component.extend({ $(window) .off("click.mobile-nav") .on("click.mobile-nav", e => { - let expander = this.$(".expander"); + let expander = $(this.element.querySelector(".expander")); expander = expander && expander[0]; if ($(e.target)[0] !== expander) { this.set("expanded", false); diff --git a/app/assets/javascripts/discourse/components/navigation-bar.js.es6 b/app/assets/javascripts/discourse/components/navigation-bar.js.es6 index de4ba53a1d..4be9c2c055 100644 --- a/app/assets/javascripts/discourse/components/navigation-bar.js.es6 +++ b/app/assets/javascripts/discourse/components/navigation-bar.js.es6 @@ -73,8 +73,8 @@ export default Ember.Component.extend({ return; } - this.$(".drop a").on("click", () => { - this.$(".drop").hide(); + $(this.element.querySelector(".drop a")).on("click", () => { + this.element.querySelector(".drop").style.display = "none"; Ember.run.next(() => { if (!this.element || this.isDestroying || this.isDestroyed) { diff --git a/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 b/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 index 3088ca25a4..5f4fda605c 100644 --- a/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 +++ b/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 @@ -29,7 +29,7 @@ export default Ember.Component.extend( @observes("lastShownAt") bounce() { if (this.lastShownAt) { - var $elem = this.$(); + var $elem = $(this.element); if (!this.animateAttribute) { this.animateAttribute = $elem.css("left") === "auto" ? "right" : "left"; diff --git a/app/assets/javascripts/discourse/components/quote-button.js.es6 b/app/assets/javascripts/discourse/components/quote-button.js.es6 index 2636fc8e41..0bde5b4ffa 100644 --- a/app/assets/javascripts/discourse/components/quote-button.js.es6 +++ b/app/assets/javascripts/discourse/components/quote-button.js.es6 @@ -83,7 +83,7 @@ export default Ember.Component.extend({ const $markerElement = $(markerElement); const markerOffset = $markerElement.offset(); const parentScrollLeft = $markerElement.parent().scrollLeft(); - const $quoteButton = this.$(); + const $quoteButton = $(this.element); // remove the marker const parent = markerElement.parentNode; diff --git a/app/assets/javascripts/discourse/components/radio-button.js.es6 b/app/assets/javascripts/discourse/components/radio-button.js.es6 index 8cdf74ecb9..4a7929d058 100644 --- a/app/assets/javascripts/discourse/components/radio-button.js.es6 +++ b/app/assets/javascripts/discourse/components/radio-button.js.es6 @@ -12,7 +12,7 @@ export default Ember.Component.extend({ ], click() { - const value = this.$().val(); + const value = $(this.element).val(); if (this.selection === value) { this.set("selection", undefined); } diff --git a/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6 b/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6 index dd26007e10..4121a8b0b3 100644 --- a/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6 +++ b/app/assets/javascripts/discourse/components/scrolling-post-stream.js.es6 @@ -89,7 +89,9 @@ export default MountWidget.extend({ const windowTop = $w.scrollTop(); const postsWrapperTop = $(".posts-wrapper").offset().top; - const $posts = this.$(".onscreen-post, .cloaked-post"); + const $posts = $( + this.element.querySelectorAll(".onscreen-post, .cloaked-post") + ); const viewportTop = windowTop - slack; const topView = findTopView( $posts, @@ -314,12 +316,12 @@ export default MountWidget.extend({ this.appEvents.on("post-stream:posted", this, "_posted"); - this.$().on("mouseenter.post-stream", "button.widget-button", e => { + $(this.element).on("mouseenter.post-stream", "button.widget-button", e => { $("button.widget-button").removeClass("d-hover"); $(e.target).addClass("d-hover"); }); - this.$().on("mouseleave.post-stream", "button.widget-button", () => { + $(this.element).on("mouseleave.post-stream", "button.widget-button", () => { $("button.widget-button").removeClass("d-hover"); }); @@ -331,8 +333,8 @@ export default MountWidget.extend({ $(document).unbind("touchmove.post-stream"); $(window).unbind("scroll.post-stream"); this.appEvents.off("post-stream:refresh", this, "_debouncedScroll"); - this.$().off("mouseenter.post-stream"); - this.$().off("mouseleave.post-stream"); + $(this.element).off("mouseenter.post-stream"); + $(this.element).off("mouseleave.post-stream"); this.appEvents.off("post-stream:refresh", this, "_refresh"); this.appEvents.off("post-stream:posted", this, "_posted"); } diff --git a/app/assets/javascripts/discourse/components/search-text-field.js.es6 b/app/assets/javascripts/discourse/components/search-text-field.js.es6 index e2629bd812..349918e22e 100644 --- a/app/assets/javascripts/discourse/components/search-text-field.js.es6 +++ b/app/assets/javascripts/discourse/components/search-text-field.js.es6 @@ -13,7 +13,7 @@ export default TextField.extend({ @on("didInsertElement") becomeFocused() { - const $searchInput = this.$(); + const $searchInput = $(this.element); applySearchAutocomplete($searchInput, this.siteSettings); if (!this.hasAutofocus) { diff --git a/app/assets/javascripts/discourse/components/share-panel.js.es6 b/app/assets/javascripts/discourse/components/share-panel.js.es6 index 872c1771bf..136e6a9ccc 100644 --- a/app/assets/javascripts/discourse/components/share-panel.js.es6 +++ b/app/assets/javascripts/discourse/components/share-panel.js.es6 @@ -41,8 +41,10 @@ export default Ember.Component.extend({ this._super(...arguments); const shareUrl = this.shareUrl; - const $linkInput = this.$(".topic-share-url"); - const $linkForTouch = this.$(".topic-share-url-for-touch a"); + const $linkInput = $(this.element.querySelector(".topic-share-url")); + const $linkForTouch = $( + this.element.querySelector(".topic-share-url-for-touch a") + ); Ember.run.schedule("afterRender", () => { if (!this.capabilities.touch) { diff --git a/app/assets/javascripts/discourse/components/share-popup.js.es6 b/app/assets/javascripts/discourse/components/share-popup.js.es6 index eb878763b7..a396b61cfb 100644 --- a/app/assets/javascripts/discourse/components/share-popup.js.es6 +++ b/app/assets/javascripts/discourse/components/share-popup.js.es6 @@ -54,7 +54,7 @@ export default Ember.Component.extend({ _showUrl($target, url) { const $currentTargetOffset = $target.offset(); - const $this = this.$(); + const $this = $(this.element); if (Ember.isEmpty(url)) { return; diff --git a/app/assets/javascripts/discourse/components/site-header.js.es6 b/app/assets/javascripts/discourse/components/site-header.js.es6 index 98e8dbc6ed..731a762988 100644 --- a/app/assets/javascripts/discourse/components/site-header.js.es6 +++ b/app/assets/javascripts/discourse/components/site-header.js.es6 @@ -124,7 +124,7 @@ const SiteHeaderComponent = MountWidget.extend(Docking, PanEvents, { this._isPanning = true; } else if ( center.x < SCREEN_EDGE_MARGIN && - !this.$(".menu-panel").length && + !this.element.querySelector(".menu-panel") && e.direction === "right" ) { this._animate = false; @@ -136,7 +136,7 @@ const SiteHeaderComponent = MountWidget.extend(Docking, PanEvents, { window.requestAnimationFrame(() => this.panMove(e)); } else if ( windowWidth - center.x < SCREEN_EDGE_MARGIN && - !this.$(".menu-panel").length && + !this.element.querySelector(".menu-panel") && e.direction === "left" ) { this._animate = false; @@ -245,7 +245,7 @@ const SiteHeaderComponent = MountWidget.extend(Docking, PanEvents, { _cleanDom() { // For performance, only trigger a re-render if any menu panels are visible - if (this.$(".menu-panel").length) { + if (this.element.querySelector(".menu-panel")) { this.eventDispatched("dom:clean", "header"); } }, diff --git a/app/assets/javascripts/discourse/components/text-overflow.js.es6 b/app/assets/javascripts/discourse/components/text-overflow.js.es6 index 402b3a9cf1..a63b6fed5d 100644 --- a/app/assets/javascripts/discourse/components/text-overflow.js.es6 +++ b/app/assets/javascripts/discourse/components/text-overflow.js.es6 @@ -2,7 +2,7 @@ export default Ember.Component.extend({ didInsertElement() { this._super(...arguments); Ember.run.next(null, () => { - const $this = this.$(); + const $this = $(this.element); if ($this) { $this.find("hr").remove(); diff --git a/app/assets/javascripts/discourse/components/topic-list-item.js.es6 b/app/assets/javascripts/discourse/components/topic-list-item.js.es6 index cc0c07d801..564938e056 100644 --- a/app/assets/javascripts/discourse/components/topic-list-item.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-list-item.js.es6 @@ -153,7 +153,7 @@ export const ListItemDefaults = { navigateToTopic, highlight(opts = { isLastViewedTopic: false }) { - const $topic = this.$(); + const $topic = $(this.element); $topic .addClass("highlighted") .attr("data-islastviewedtopic", opts.isLastViewedTopic); diff --git a/app/assets/javascripts/discourse/components/topic-progress.js.es6 b/app/assets/javascripts/discourse/components/topic-progress.js.es6 index 599de61ab5..f387de7bee 100644 --- a/app/assets/javascripts/discourse/components/topic-progress.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-progress.js.es6 @@ -126,7 +126,7 @@ export default Ember.Component.extend({ return; } - const $topicProgress = this.$("#topic-progress"); + const $topicProgress = $(this.element.querySelector("#topic-progress")); // speeds up stuff, bypass jquery slowness and extra checks if (!this._totalWidth) { this._totalWidth = $topicProgress[0].offsetWidth; @@ -151,7 +151,7 @@ export default Ember.Component.extend({ }, _dock() { - const $wrapper = this.$(); + const $wrapper = $(this.element); if (!$wrapper || $wrapper.length === 0) return; const $html = $("html"); diff --git a/app/assets/javascripts/discourse/components/topic-timeline.js.es6 b/app/assets/javascripts/discourse/components/topic-timeline.js.es6 index 1b0d92ca51..12e9aefea6 100644 --- a/app/assets/javascripts/discourse/components/topic-timeline.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-timeline.js.es6 @@ -52,8 +52,8 @@ export default MountWidget.extend(Docking, { const offsetTop = mainOffset ? mainOffset.top : 0; const topicTop = $(".container.posts").offset().top - offsetTop; const topicBottom = $("#topic-bottom").offset().top; - const $timeline = this.$(".timeline-container"); - const timelineHeight = $timeline.height() || 400; + const timeline = this.element.querySelector(".timeline-container"); + const timelineHeight = (timeline && timeline.offsetHeight) || 400; const footerHeight = $(".timeline-footer-controls").outerHeight(true) || 0; const prev = this.dockAt; diff --git a/app/assets/javascripts/discourse/components/user-card-contents.js.es6 b/app/assets/javascripts/discourse/components/user-card-contents.js.es6 index 7fdb614e2a..03aa390aa0 100644 --- a/app/assets/javascripts/discourse/components/user-card-contents.js.es6 +++ b/app/assets/javascripts/discourse/components/user-card-contents.js.es6 @@ -137,8 +137,8 @@ export default Ember.Component.extend( return; } - const $this = this.$(); - if (!$this) { + const thisElem = this.element; + if (!thisElem) { return; } @@ -146,7 +146,7 @@ export default Ember.Component.extend( const bg = Ember.isEmpty(url) ? "" : `url(${Discourse.getURLWithCDN(url)})`; - $this.css("background-image", bg); + thisElem.style.backgroundImage = bg; }, _showCallback(username, $target) { diff --git a/app/assets/javascripts/discourse/components/user-stream.js.es6 b/app/assets/javascripts/discourse/components/user-stream.js.es6 index ca24119a52..c29b80b09f 100644 --- a/app/assets/javascripts/discourse/components/user-stream.js.es6 +++ b/app/assets/javascripts/discourse/components/user-stream.js.es6 @@ -30,8 +30,12 @@ export default Ember.Component.extend(LoadMore, { $(window).on("resize.discourse-on-scroll", () => this.scrolled()); - this.$().on("click.details-disabled", "details.disabled", () => false); - this.$().on("click.discourse-redirect", ".excerpt a", function(e) { + $(this.element).on( + "click.details-disabled", + "details.disabled", + () => false + ); + $(this.element).on("click.discourse-redirect", ".excerpt a", function(e) { return ClickTrack.trackClick(e); }); }.on("didInsertElement"), @@ -40,10 +44,10 @@ export default Ember.Component.extend(LoadMore, { _destroyed: function() { this.unbindScrolling("user-stream-view"); $(window).unbind("resize.discourse-on-scroll"); - this.$().off("click.details-disabled", "details.disabled"); + $(this.element).off("click.details-disabled", "details.disabled"); // Unbind link tracking - this.$().off("click.discourse-redirect", ".excerpt a"); + $(this.element).off("click.discourse-redirect", ".excerpt a"); }.on("willDestroyElement"), actions: { diff --git a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 index 19c2c5fc0d..991bf3ea29 100644 --- a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 +++ b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 @@ -962,7 +962,7 @@ function decorate(klass, evt, cb, id) { const mixin = {}; mixin["_decorate_" + _decorateId++] = function($elem) { - $elem = $elem || this.$(); + $elem = $elem || $(this.element); if ($elem) { cb($elem); } diff --git a/app/assets/javascripts/discourse/lib/url.js.es6 b/app/assets/javascripts/discourse/lib/url.js.es6 index bba1db7ebb..18af8aee7e 100644 --- a/app/assets/javascripts/discourse/lib/url.js.es6 +++ b/app/assets/javascripts/discourse/lib/url.js.es6 @@ -405,11 +405,9 @@ const DiscourseURL = Ember.Object.extend({ @property router **/ - router: function() { + get router() { return Discourse.__container__.lookup("router:main"); - } - .property() - .volatile(), + }, // Get a controller. Note that currently it uses `__container__` which is not // advised but there is no other way to access the router. diff --git a/app/assets/javascripts/discourse/mixins/card-contents-base.js.es6 b/app/assets/javascripts/discourse/mixins/card-contents-base.js.es6 index f81a573891..212278f2e0 100644 --- a/app/assets/javascripts/discourse/mixins/card-contents-base.js.es6 +++ b/app/assets/javascripts/discourse/mixins/card-contents-base.js.es6 @@ -73,7 +73,7 @@ export default Ember.Mixin.create({ didInsertElement() { this._super(...arguments); - afterTransition(this.$(), this._hide.bind(this)); + afterTransition($(this.element), this._hide.bind(this)); const id = this.elementId; const triggeringLinkClass = this.triggeringLinkClass; const clickOutsideEventName = `mousedown.outside-${id}`; @@ -164,7 +164,7 @@ export default Ember.Mixin.create({ if (!target) { return; } - const width = this.$().width(); + const width = $(this.element).width(); const height = 175; const isFixed = this.isFixed; const isDocked = this.isDocked; @@ -227,7 +227,7 @@ export default Ember.Mixin.create({ position.top = avatarOverflowSize; } - this.$().css(position); + $(this.element).css(position); } } @@ -236,23 +236,26 @@ export default Ember.Mixin.create({ let position = target.offset(); position.top = "10%"; // match modal behaviour position.left = 0; - this.$().css(position); + $(this.element).css(position); } - this.$().toggleClass("docked-card", isDocked); + $(this.element).toggleClass("docked-card", isDocked); // After the card is shown, focus on the first link // // note: we DO NOT use afterRender here cause _positionCard may // run afterwards, if we allowed this to happen the usercard // may be offscreen and we may scroll all the way to it on focus - Ember.run.next(null, () => this.$("a:first").focus()); + Ember.run.next(null, () => { + const firstLink = this.element.querySelector("a"); + firstLink && firstLink.focus(); + }); } }); }, _hide() { if (!this.visible) { - this.$().css({ left: -9999, top: -9999 }); + $(this.element).css({ left: -9999, top: -9999 }); if (this.site.mobileView) { $(".card-cloak").addClass("hidden"); } diff --git a/app/assets/javascripts/discourse/mixins/pan-events.js.es6 b/app/assets/javascripts/discourse/mixins/pan-events.js.es6 index d64f714917..1608d72fea 100644 --- a/app/assets/javascripts/discourse/mixins/pan-events.js.es6 +++ b/app/assets/javascripts/discourse/mixins/pan-events.js.es6 @@ -13,12 +13,12 @@ export default Ember.Mixin.create({ didInsertElement() { this._super(...arguments); - this.addTouchListeners(this.$()); + this.addTouchListeners($(this.element)); }, willDestroyElement() { this._super(...arguments); - this.removeTouchListeners(this.$()); + this.removeTouchListeners($(this.element)); }, addTouchListeners($element) { diff --git a/app/assets/javascripts/discourse/mixins/upload.js.es6 b/app/assets/javascripts/discourse/mixins/upload.js.es6 index 394b6a73f3..f812187967 100644 --- a/app/assets/javascripts/discourse/mixins/upload.js.es6 +++ b/app/assets/javascripts/discourse/mixins/upload.js.es6 @@ -36,7 +36,7 @@ export default Ember.Mixin.create({ }, _initialize: function() { - const $upload = this.$(); + const $upload = $(this.element); const reset = () => this.setProperties({ uploading: false, uploadProgress: 0 }); const maxFiles = this.getWithDefault( @@ -108,7 +108,7 @@ export default Ember.Mixin.create({ _destroy: function() { this.messageBus && this.messageBus.unsubscribe("/uploads/" + this.type); - const $upload = this.$(); + const $upload = $(this.element); try { $upload.fileupload("destroy"); } catch (e) { diff --git a/app/assets/javascripts/select-kit/components/category-drop/category-drop-header.js.es6 b/app/assets/javascripts/select-kit/components/category-drop/category-drop-header.js.es6 index 37ad706fad..629accda68 100644 --- a/app/assets/javascripts/select-kit/components/category-drop/category-drop-header.js.es6 +++ b/app/assets/javascripts/select-kit/components/category-drop/category-drop-header.js.es6 @@ -57,7 +57,9 @@ export default ComboBoxSelectBoxHeaderComponent.extend({ didRender() { this._super(...arguments); - this.$().attr("style", this.categoryStyle); - this.$(".caret-icon").attr("style", this.categoryStyle); + this.element.setAttribute("style", this.categoryStyle); + this.element + .querySelector(".caret-icon") + .setAttribute("style", this.categoryStyle); } }); diff --git a/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 index 2df02b143d..5da4bce7be 100644 --- a/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 +++ b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 @@ -54,7 +54,7 @@ export default ComboBox.extend(TagsMixin, { didInsertElement() { this._super(...arguments); - this.$(".select-kit-body").on( + $(this.element.querySelector(".select-kit-body")).on( "mousedown touchstart", ".selected-tag", event => { @@ -68,7 +68,9 @@ export default ComboBox.extend(TagsMixin, { willDestroyElement() { this._super(...arguments); - this.$(".select-kit-body").off("mousedown touchstart"); + $(this.element.querySelector(".select-kit-body")).off( + "mousedown touchstart" + ); }, @computed("hasReachedMaximum") 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 4d0571429a..2bb2bbb610 100644 --- a/app/assets/javascripts/select-kit/components/multi-select.js.es6 +++ b/app/assets/javascripts/select-kit/components/multi-select.js.es6 @@ -40,7 +40,7 @@ export default SelectKitComponent.extend({ _setChoicesMaxWidth() { const width = this.$body().outerWidth(false); if (width > 0) { - this.$(".choices").css({ maxWidth: width }); + this.element.querySelector(".choices").style.maxWidth = `${width}px`; } }, diff --git a/app/assets/javascripts/select-kit/components/multi-select/multi-select-header.js.es6 b/app/assets/javascripts/select-kit/components/multi-select/multi-select-header.js.es6 index d821b70193..191e694e6f 100644 --- a/app/assets/javascripts/select-kit/components/multi-select/multi-select-header.js.es6 +++ b/app/assets/javascripts/select-kit/components/multi-select/multi-select-header.js.es6 @@ -24,13 +24,13 @@ export default SelectKitHeaderComponent.extend({ _positionFilter() { if (!this.shouldDisplayFilter) return; - const $filter = this.$(".filter"); + const $filter = $(this.element.querySelector(".filter")); $filter.width(0); - const leftHeaderOffset = this.$().offset().left; + const leftHeaderOffset = $(this.element).offset().left; const leftFilterOffset = $filter.offset().left; const offset = leftFilterOffset - leftHeaderOffset; - const width = this.$().outerWidth(false); + const width = $(this.element).outerWidth(false); const availableSpace = width - offset; const $choices = $filter.parent(".choices"); const parentRightPadding = parseInt($choices.css("padding-right"), 10); diff --git a/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 b/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 index f0e9835e43..b086aa2e43 100644 --- a/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 +++ b/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 @@ -18,23 +18,27 @@ export default Ember.Mixin.create({ }, $findRowByValue(value) { - return this.$(`${this.rowSelector}[data-value='${value}']`); + return $( + this.element.querySelector(`${this.rowSelector}[data-value='${value}']`) + ); }, $header() { - return this.$(this.headerSelector); + return $(this.element && this.element.querySelector(this.headerSelector)); }, $body() { - return this.$(this.bodySelector); + return $(this.element && this.element.querySelector(this.bodySelector)); }, $wrapper() { - return this.$(this.wrapperSelector); + return $(this.element && this.element.querySelector(this.wrapperSelector)); }, $collection() { - return this.$(this.collectionSelector); + return $( + this.element && this.element.querySelector(this.collectionSelector) + ); }, $scrollableParent() { @@ -58,7 +62,9 @@ export default Ember.Mixin.create({ }, $filterInput() { - return this.$(this.filterInputSelector); + return $( + this.element && this.element.querySelector(this.filterInputSelector) + ); }, _adjustPosition() { @@ -180,7 +186,8 @@ export default Ember.Mixin.create({ if (this.fullWidthOnMobile && (this.site && this.site.isMobileDevice)) { const margin = 10; - const relativeLeft = this.$().offset().left - $(window).scrollLeft(); + const relativeLeft = + $(this.element).offset().left - $(window).scrollLeft(); options.left = margin - relativeLeft; options.width = windowWidth - margin * 2; options.maxWidth = options.minWidth = "unset"; @@ -193,7 +200,8 @@ export default Ember.Mixin.create({ let spaceToLeftEdge; if (this.$scrollableParent().length) { spaceToLeftEdge = - this.$().offset().left - this.$scrollableParent().offset().left; + $(this.element).offset().left - + this.$scrollableParent().offset().left; } else { spaceToLeftEdge = this.element.getBoundingClientRect().left; } @@ -206,9 +214,8 @@ export default Ember.Mixin.create({ } if (isLeftAligned) { - this.$() - .addClass("is-left-aligned") - .removeClass("is-right-aligned"); + this.element.classList.add("is-left-aligned"); + this.element.classList.remove("is-right-aligned"); if (this._isRTL()) { options.right = this.horizontalOffset; @@ -216,9 +223,8 @@ export default Ember.Mixin.create({ options.left = -bodyWidth + elementWidth - this.horizontalOffset; } } else { - this.$() - .addClass("is-right-aligned") - .removeClass("is-left-aligned"); + this.element.classList.add("is-right-aligned"); + this.element.classList.remove("is-left-aligned"); if (this._isRTL()) { options.right = -bodyWidth + elementWidth - this.horizontalOffset; @@ -234,14 +240,12 @@ export default Ember.Mixin.create({ const headerHeight = this._computedStyle(this.$header()[0], "height"); if (hasBelowSpace || (!hasBelowSpace && !hasAboveSpace)) { - this.$() - .addClass("is-below") - .removeClass("is-above"); + this.element.classList.add("is-below"); + this.element.classList.remove("is-above"); options.top = headerHeight + this.verticalOffset; } else { - this.$() - .addClass("is-above") - .removeClass("is-below"); + this.element.classList.add("is-above"); + this.element.classList.remove("is-below"); options.bottom = headerHeight + this.verticalOffset; } @@ -262,13 +266,13 @@ export default Ember.Mixin.create({ this._previousCSSContext = this._previousCSSContext || { width, - minWidth: this.$().css("min-width"), - maxWidth: this.$().css("max-width"), - top: this.$().css("top"), - left: this.$().css("left"), - marginLeft: this.$().css("margin-left"), - marginRight: this.$().css("margin-right"), - position: this.$().css("position") + minWidth: this.element.style.minWidth, + maxWidth: this.element.style.maxWidth, + top: this.element.style.top, + left: this.element.style.left, + marginLeft: this.element.style.marginLeft, + marginRight: this.element.style.marginRight, + position: this.element.style.position }; const componentStyles = { @@ -289,11 +293,11 @@ export default Ember.Mixin.create({ display: "inline-block", width, height, - "margin-bottom": this.$().css("margin-bottom"), + "margin-bottom": this.element.style.marginBottom, "vertical-align": "middle" }); - this.$() + $(this.element) .before($placeholderTemplate) .css(componentStyles); @@ -306,7 +310,7 @@ export default Ember.Mixin.create({ if (!this.element || this.isDestroying || this.isDestroyed) return; if (this.$scrollableParent().length === 0) return; - this.$().css(this._previousCSSContext || {}); + $(this.element).css(this._previousCSSContext || {}); this.$scrollableParent().css( "overflow", this._previousScrollParentOverflow || {} diff --git a/app/assets/javascripts/select-kit/mixins/events.js.es6 b/app/assets/javascripts/select-kit/mixins/events.js.es6 index e41b1dfc23..0cbdf5da67 100644 --- a/app/assets/javascripts/select-kit/mixins/events.js.es6 +++ b/app/assets/javascripts/select-kit/mixins/events.js.es6 @@ -82,7 +82,7 @@ export default Ember.Mixin.create({ return true; } - if (Ember.$.contains(this.element, event.target)) { + if (this.element !== event.target && this.element.contains(event.target)) { event.stopPropagation(); if (!this.renderedBodyOnce) return; if (!this.isFocused) return; @@ -398,7 +398,12 @@ export default Ember.Mixin.create({ }, onFilterInputFocusout(event) { - if (!Ember.$.contains(this.element, event.relatedTarget)) { + if ( + !( + this.element !== event.relatedTarget && + this.element.contains(event.relatedTarget) + ) + ) { this.close(event); } }, diff --git a/app/assets/javascripts/wizard/components/invite-list.js.es6 b/app/assets/javascripts/wizard/components/invite-list.js.es6 index 585e91a892..13902ca2d6 100644 --- a/app/assets/javascripts/wizard/components/invite-list.js.es6 +++ b/app/assets/javascripts/wizard/components/invite-list.js.es6 @@ -59,7 +59,7 @@ export default Ember.Component.extend({ this.set("inviteEmail", ""); Ember.run.scheduleOnce("afterRender", () => - this.$(".invite-email").focus() + this.element.querySelector(".invite-email").focus() ); }, diff --git a/app/assets/javascripts/wizard/components/radio-button.js.es6 b/app/assets/javascripts/wizard/components/radio-button.js.es6 index ff6f00e6cc..1dc2803cdd 100644 --- a/app/assets/javascripts/wizard/components/radio-button.js.es6 +++ b/app/assets/javascripts/wizard/components/radio-button.js.es6 @@ -12,6 +12,8 @@ export default Ember.Component.extend({ @on("init") updateVal() { const checked = this.value === this.radioValue; - Ember.run.next(() => this.$("input[type=radio]").prop("checked", checked)); + Ember.run.next( + () => (this.element.querySelector("input[type=radio]").checked = checked) + ); } }); diff --git a/app/assets/javascripts/wizard/components/wizard-canvas.js.es6 b/app/assets/javascripts/wizard/components/wizard-canvas.js.es6 index ac9d3be809..bed1a2f9a9 100644 --- a/app/assets/javascripts/wizard/components/wizard-canvas.js.es6 +++ b/app/assets/javascripts/wizard/components/wizard-canvas.js.es6 @@ -62,7 +62,7 @@ export default Ember.Component.extend({ didInsertElement() { this._super(...arguments); - const canvas = this.$()[0]; + const canvas = this.element; this.ctx = canvas.getContext("2d"); this.resized(); @@ -86,7 +86,7 @@ export default Ember.Component.extend({ width = $(window).width(); height = $(window).height(); - const canvas = this.$()[0]; + const canvas = this.element; canvas.width = width; canvas.height = height; }, diff --git a/app/assets/javascripts/wizard/components/wizard-field-image.js.es6 b/app/assets/javascripts/wizard/components/wizard-field-image.js.es6 index 758c6ba0bf..86de715b70 100644 --- a/app/assets/javascripts/wizard/components/wizard-field-image.js.es6 +++ b/app/assets/javascripts/wizard/components/wizard-field-image.js.es6 @@ -17,7 +17,7 @@ export default Ember.Component.extend({ didInsertElement() { this._super(...arguments); - const $upload = this.$(); + const $upload = $(this.element); const id = this.get("field.id"); diff --git a/app/assets/javascripts/wizard/lib/preview.js.es6 b/app/assets/javascripts/wizard/lib/preview.js.es6 index 9a6dc99f37..307fc0126a 100644 --- a/app/assets/javascripts/wizard/lib/preview.js.es6 +++ b/app/assets/javascripts/wizard/lib/preview.js.es6 @@ -43,7 +43,7 @@ export function createPreviewComponent(width, height, obj) { didInsertElement() { this._super(...arguments); - const c = this.$("canvas")[0]; + const c = this.element.querySelector("canvas"); this.ctx = c.getContext("2d"); this.ctx.scale(scale, scale); this.reload(); diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb index 9653325d13..a8b4ec52bf 100644 --- a/app/models/theme_field.rb +++ b/app/models/theme_field.rb @@ -64,7 +64,7 @@ class ThemeField < ActiveRecord::Base validates :name, format: { with: /\A[a-z_][a-z0-9_-]*\z/i }, if: Proc.new { |field| ThemeField.theme_var_type_ids.include?(field.type_id) } - BASE_COMPILER_VERSION = 11 + BASE_COMPILER_VERSION = 12 DEPENDENT_CONSTANTS = [BASE_COMPILER_VERSION, GlobalSetting.cdn_url] COMPILER_VERSION = Digest::SHA1.hexdigest(DEPENDENT_CONSTANTS.join) diff --git a/lib/theme_javascript_compiler.rb b/lib/theme_javascript_compiler.rb index 22a47c351d..64437e33ee 100644 --- a/lib/theme_javascript_compiler.rb +++ b/lib/theme_javascript_compiler.rb @@ -13,6 +13,7 @@ class ThemeJavascriptCompiler // Helper to replace old themeSetting syntax function generateHelper(settingParts) { +console.log(settingParts) const settingName = settingParts.join('.'); return { "path": { @@ -64,7 +65,7 @@ class ThemeJavascriptCompiler } function manipulateNode(node) { - // Magically add theme id as the first param for each of these helpers + // Magically add theme id as the first param for each of these helpers) if (node.path.parts && ["theme-i18n", "theme-prefix", "theme-setting"].includes(node.path.parts[0])) { if(node.params.length === 1){ node.params.unshift({ @@ -134,10 +135,16 @@ class ThemeJavascriptCompiler def discourse_extension <<~JS - Ember.HTMLBars.registerPlugin('ast', function(){ - return { name: 'theme-template-manipulator', - visitor: { SubExpression: manipulateNode, MustacheStatement: manipulateNode, PathExpression: manipulatePath} - }}); + Ember.HTMLBars.registerPlugin('ast', function() { + return { + name: 'theme-template-manipulator', + visitor: { + SubExpression: manipulateNode, + MustacheStatement: manipulateNode, + PathExpression: manipulatePath + } + } + }); JS end end diff --git a/spec/lib/theme_javascript_compiler_spec.rb b/spec/lib/theme_javascript_compiler_spec.rb index a43db0ef49..e1c9695509 100644 --- a/spec/lib/theme_javascript_compiler_spec.rb +++ b/spec/lib/theme_javascript_compiler_spec.rb @@ -84,26 +84,28 @@ describe ThemeJavascriptCompiler do block["statements"] end + # might change/break when updating ember + EMBER_INTERNAL_ID = 29 it 'adds the theme id to the helpers' do expect(statement("{{theme-prefix 'translation_key'}}")). - to eq([[1, [27, "theme-prefix", [22, "translation_key"], nil], false]]) + to eq([[1, [EMBER_INTERNAL_ID, "theme-prefix", [22, "translation_key"], nil], false]]) expect(statement("{{theme-i18n 'translation_key'}}")). - to eq([[1, [27, "theme-i18n", [22, "translation_key"], nil], false]]) + to eq([[1, [EMBER_INTERNAL_ID, "theme-i18n", [22, "translation_key"], nil], false]]) expect(statement("{{theme-setting 'setting_key'}}")). - to eq([[1, [27, "theme-setting", [22, "setting_key"], nil], false]]) + to eq([[1, [EMBER_INTERNAL_ID, "theme-setting", [22, "setting_key"], nil], false]]) # Works when used inside other statements expect(statement("{{dummy-helper (theme-prefix 'translation_key')}}")). - to eq([[1, [27, "dummy-helper", [[27, "theme-prefix", [22, "translation_key"], nil]], nil], false]]) + to eq([[1, [EMBER_INTERNAL_ID, "dummy-helper", [[EMBER_INTERNAL_ID, "theme-prefix", [22, "translation_key"], nil]], nil], false]]) end it 'works with the old settings syntax' do expect(statement("{{themeSettings.setting_key}}")). - to eq([[1, [27, "theme-setting", [22, "setting_key"], [["deprecated"], [true]]], false]]) + to eq([[1, [EMBER_INTERNAL_ID, "theme-setting", [22, "setting_key"], [["deprecated"], [true]]], false]]) # Works when used inside other statements expect(statement("{{dummy-helper themeSettings.setting_key}}")). - to eq([[1, [27, "dummy-helper", [[27, "theme-setting", [22, "setting_key"], [["deprecated"], [true]]]], nil], false]]) + to eq([[1, [EMBER_INTERNAL_ID, "dummy-helper", [[EMBER_INTERNAL_ID, "theme-setting", [22, "setting_key"], [["deprecated"], [true]]]], nil], false]]) end end From 1308919a3de197eaea87b70ee963091b54fd1bb4 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Tue, 16 Jul 2019 14:41:03 +0300 Subject: [PATCH 017/441] DEV: Fix heisentest. Follow-up to 8e133de83118200f46a21b0e4c3a931e1cd14954. --- spec/models/category_featured_topic_spec.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/models/category_featured_topic_spec.rb b/spec/models/category_featured_topic_spec.rb index f994fe5371..42448484ba 100644 --- a/spec/models/category_featured_topic_spec.rb +++ b/spec/models/category_featured_topic_spec.rb @@ -12,6 +12,10 @@ describe CategoryFeaturedTopic do fab!(:category) { Fabricate(:category) } let!(:category_post) { PostCreator.create(user, raw: "I put this post in the category", title: "categorize THIS", category: category.id) } + before do + CategoryFeaturedTopic.clear_exclude_category_ids + end + it "works in batched mode" do category2 = Fabricate(:category) post2 = create_post(category: category2.id) From 3840ace97820acf98cb8f58ab1cab96f66e2def2 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Tue, 16 Jul 2019 18:05:17 +0530 Subject: [PATCH 018/441] FIX: skip markdown conversion for hotlinked non image urls --- app/services/inline_uploads.rb | 8 ++++++++ spec/fabricators/upload_fabricator.rb | 9 +++++++++ spec/services/inline_uploads_spec.rb | 13 +++++++++++++ 3 files changed, 30 insertions(+) diff --git a/app/services/inline_uploads.rb b/app/services/inline_uploads.rb index e5652dbdfc..13d5db72dd 100644 --- a/app/services/inline_uploads.rb +++ b/app/services/inline_uploads.rb @@ -73,6 +73,9 @@ class InlineUploads markdown.scan(/(\n{2,}|\A)#{regexp}$/) do |match| if match[1].present? + extension = match[1].split(".")[-1].downcase + next if FileHelper.supported_images.exclude?(extension) + index = $~.offset(2)[0] indexes << index raw_matches << [match[1], match[1], +"![](#{PLACEHOLDER})", index] @@ -120,6 +123,11 @@ class InlineUploads .sort { |a, b| a[3] <=> b[3] } .each do |match, link, replace_with, _index| + if match == link + extension = match.split(".")[-1].downcase + next if FileHelper.supported_images.exclude?(extension) + end + node_info = link_occurences.shift next unless node_info&.dig(:is_valid) diff --git a/spec/fabricators/upload_fabricator.rb b/spec/fabricators/upload_fabricator.rb index 8c54009caf..ae211c56cb 100644 --- a/spec/fabricators/upload_fabricator.rb +++ b/spec/fabricators/upload_fabricator.rb @@ -21,6 +21,15 @@ Fabricator(:upload) do extension "png" end +Fabricator(:video_upload, from: :upload) do + original_filename "video.mp4" + width nil + height nil + thumbnail_width nil + thumbnail_height nil + extension "mp4" +end + Fabricator(:upload_s3, from: :upload) do url do |attrs| sequence(:url) do |n| diff --git a/spec/services/inline_uploads_spec.rb b/spec/services/inline_uploads_spec.rb index 88e3f39681..32e1d18225 100644 --- a/spec/services/inline_uploads_spec.rb +++ b/spec/services/inline_uploads_spec.rb @@ -228,6 +228,19 @@ RSpec.describe InlineUploads do MD end + it "should not correct non image URLs to the short url and paths" do + SiteSetting.authorized_extensions = "mp4" + upload4 = Fabricate(:video_upload) + + md = <<~MD + #{GlobalSetting.cdn_url}#{upload4.url} + MD + + expect(InlineUploads.process(md)).to eq(<<~MD) + #{GlobalSetting.cdn_url}#{upload4.url} + MD + end + it "should correct img tags with uppercase upload extension" do md = <<~MD test From 7890f106933149a00d30a7f090b855dd584ad874 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Tue, 16 Jul 2019 19:21:16 +0530 Subject: [PATCH 019/441] SPEC: improve the code readability 3840ace97820acf98cb8f58ab1cab96f66e2def2 --- spec/services/inline_uploads_spec.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/spec/services/inline_uploads_spec.rb b/spec/services/inline_uploads_spec.rb index 32e1d18225..dfc2ec4b01 100644 --- a/spec/services/inline_uploads_spec.rb +++ b/spec/services/inline_uploads_spec.rb @@ -230,15 +230,13 @@ RSpec.describe InlineUploads do it "should not correct non image URLs to the short url and paths" do SiteSetting.authorized_extensions = "mp4" - upload4 = Fabricate(:video_upload) + upload = Fabricate(:video_upload) md = <<~MD - #{GlobalSetting.cdn_url}#{upload4.url} + #{GlobalSetting.cdn_url}#{upload.url} MD - expect(InlineUploads.process(md)).to eq(<<~MD) - #{GlobalSetting.cdn_url}#{upload4.url} - MD + expect(InlineUploads.process(md)).to eq(md) end it "should correct img tags with uppercase upload extension" do From eff1c19e3be473b609c7f585337fd9dd1af968d1 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Tue, 16 Jul 2019 17:05:37 +0300 Subject: [PATCH 020/441] FIX: Fallback to gzip compression if brotli isn't supported (#7895) --- app/helpers/application_helper.rb | 6 ++++++ spec/helpers/application_helper_spec.rb | 6 ++++++ spec/requests/static_controller_spec.rb | 22 ++++++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 241871cb5d..9b5337e7cb 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -58,6 +58,10 @@ module ApplicationHelper request.env["HTTP_ACCEPT_ENCODING"] =~ /br/ end + def is_gzip_req? + request.env["HTTP_ACCEPT_ENCODING"] =~ /gzip/ + end + def script_asset_path(script) path = asset_path("#{script}.js") @@ -77,6 +81,8 @@ module ApplicationHelper if is_brotli_req? path = path.gsub(/\.([^.]+)$/, '.br.\1') + elsif is_gzip_req? + path = path.gsub(/\.([^.]+)$/, '.gz.\1') end elsif GlobalSetting.cdn_url&.start_with?("https") && is_brotli_req? diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 25e75430da..20af731aaa 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -54,6 +54,12 @@ describe ApplicationHelper do expect(link).to eq("\n") end + it "can fall back to gzip compression" do + helper.request.env["HTTP_ACCEPT_ENCODING"] = 'gzip' + link = helper.preload_script('application') + expect(link).to eq("\n") + end + it "gives s3 cdn even if asset host is set" do set_cdn_url "https://awesome.com" link = helper.preload_script('application') diff --git a/spec/requests/static_controller_spec.rb b/spec/requests/static_controller_spec.rb index 8e4e10de46..6fcb784ab7 100644 --- a/spec/requests/static_controller_spec.rb +++ b/spec/requests/static_controller_spec.rb @@ -120,6 +120,28 @@ describe StaticController do end end + context '#cdn_asset' do + let (:site) { RailsMultisite::ConnectionManagement.current_db } + + it 'can serve assets' do + begin + assets_path = Rails.root.join("public/assets") + + FileUtils.mkdir_p(assets_path) + + file_path = assets_path.join("test.js.br") + File.write(file_path, 'fake brotli file') + + get "/cdn_asset/#{site}/test.js.br" + + expect(response.status).to eq(200) + expect(response.headers["Cache-Control"]).to match(/public/) + ensure + File.delete(file_path) + end + end + end + context '#show' do before do post = create_post From ed5b31f4276eabd825c64bcf7df085398375f7fd Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 16 Jul 2019 15:34:33 +0100 Subject: [PATCH 021/441] FIX: Recompile extra_js theme assets when COMPILER_VERSION changes (#7897) --- app/models/theme.rb | 20 +++++++++++++++----- spec/models/theme_spec.rb | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/app/models/theme.rb b/app/models/theme.rb index 798bb2e03f..72e9471fb4 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -63,6 +63,15 @@ class Theme < ActiveRecord::Base settings_field&.ensure_baked! # Other fields require setting to be **baked** theme_fields.each(&:ensure_baked!) + update_javascript_cache! + + remove_from_cache! + clear_cached_settings! + ColorScheme.hex_cache.clear + notify_theme_change(with_scheme: notify_with_scheme) + end + + def update_javascript_cache! all_extra_js = theme_fields.where(target_id: Theme.targets[:extra_js]).pluck(:value_baked).join("\n") if all_extra_js.present? js_compiler = ThemeJavascriptCompiler.new(id, name) @@ -73,11 +82,6 @@ class Theme < ActiveRecord::Base else javascript_cache&.destroy! end - - remove_from_cache! - clear_cached_settings! - ColorScheme.hex_cache.clear - notify_theme_change(with_scheme: notify_with_scheme) end after_destroy do @@ -288,6 +292,12 @@ class Theme < ActiveRecord::Base def self.resolve_baked_field(theme_ids, target, name) if target == :extra_js + require_rebake = ThemeField.where(theme_id: theme_ids, target_id: Theme.targets[:extra_js]). + where("compiler_version <> ?", ThemeField::COMPILER_VERSION) + require_rebake.each { |tf| tf.ensure_baked! } + require_rebake.map(&:theme_id).uniq.each do |theme_id| + Theme.find(theme_id).update_javascript_cache! + end caches = JavascriptCache.where(theme_id: theme_ids) caches = caches.sort_by { |cache| theme_ids.index(cache.theme_id) } return caches.map { |c| "" }.join("\n") diff --git a/spec/models/theme_spec.rb b/spec/models/theme_spec.rb index 0431413867..d760a5fc20 100644 --- a/spec/models/theme_spec.rb +++ b/spec/models/theme_spec.rb @@ -674,4 +674,39 @@ HTML expect(Theme.list_baked_fields([theme.id, theme2.id], :translations, 'fr').map(&:id)).to contain_exactly(fr_translation.id, en_translation2.id) end end + + describe "automatic recompile" do + it 'must recompile after bumping theme_field version' do + def stub_const(target, const, value) + old = target.const_get(const) + target.send(:remove_const, const) + target.const_set(const, value) + yield + ensure + target.send(:remove_const, const) + target.const_set(const, old) + end + + child.set_field(target: :common, name: "header", value: "World") + child.set_field(target: :extra_js, name: "test.js.es6", value: "const hello = 'world';") + child.save! + + first_common_value = Theme.lookup_field(child.id, :desktop, "header") + first_extra_js_value = Theme.lookup_field(child.id, :extra_js, nil) + + stub_const(ThemeField, :COMPILER_VERSION, "SOME_NEW_HASH") do + second_common_value = Theme.lookup_field(child.id, :desktop, "header") + second_extra_js_value = Theme.lookup_field(child.id, :extra_js, nil) + + new_common_compiler_version = ThemeField.find_by(theme_id: child.id, name: "header").compiler_version + new_extra_js_compiler_version = ThemeField.find_by(theme_id: child.id, name: "test.js.es6").compiler_version + + expect(first_common_value).to eq(second_common_value) + expect(first_extra_js_value).to eq(second_extra_js_value) + + expect(new_common_compiler_version).to eq("SOME_NEW_HASH") + expect(new_extra_js_compiler_version).to eq("SOME_NEW_HASH") + end + end + end end From a571efba35e40f69670768e8a6aff14522cb5718 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Tue, 16 Jul 2019 11:13:44 -0400 Subject: [PATCH 022/441] FIX: Rename deprecated icons, allow custom icons in badges - adds a migration renaming FA4 icon names in badges - allows all icons to be used in badges (previously was limited to icons prefixed with fa-) - renames remaining FA 4.7 icons equivalents --- .../admin/components/admin-web-hook-status.js.es6 | 2 +- .../admin/templates/customize-colors-show.hbs | 2 +- app/assets/javascripts/admin/templates/web-hooks.hbs | 2 +- .../discourse/helpers/icon-or-image.js.es6 | 2 +- .../javascripts/discourse/models/composer.js.es6 | 4 ++-- app/assets/javascripts/discourse/templates/about.hbs | 2 +- .../templates/components/shared-draft-controls.hbs | 2 +- .../discourse/widgets/post-admin-menu.js.es6 | 4 ++-- .../discourse/widgets/post-edits-indicator.js.es6 | 2 +- .../javascripts/discourse/widgets/post-menu.js.es6 | 2 +- .../select-kit/components/composer-actions.js.es6 | 4 ++-- config/locales/client.en.yml | 2 +- db/fixtures/006_badges.rb | 4 ++-- .../20190716014949_rename_deprecated_badge_icons.rb | 11 +++++++++++ test/javascripts/fixtures/user_fixtures.js.es6 | 6 +++--- test/javascripts/widgets/topic-status-test.js.es6 | 4 ++-- 16 files changed, 33 insertions(+), 22 deletions(-) create mode 100644 db/migrate/20190716014949_rename_deprecated_badge_icons.rb diff --git a/app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 b/app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 index 1dc7a27c35..6e325b2fe9 100644 --- a/app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 +++ b/app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 @@ -5,7 +5,7 @@ import { bufferedRender } from "discourse-common/lib/buffered-render"; export default Ember.Component.extend( bufferedRender({ classes: ["text-muted", "text-danger", "text-successful", "text-muted"], - icons: ["circle-o", "times-circle", "circle", "circle"], + icons: ["far-circle", "times-circle", "circle", "circle"], @computed("deliveryStatuses", "model.last_delivery_status") status(deliveryStatuses, lastDeliveryStatus) { diff --git a/app/assets/javascripts/admin/templates/customize-colors-show.hbs b/app/assets/javascripts/admin/templates/customize-colors-show.hbs index fd44f997f6..521cd8688c 100644 --- a/app/assets/javascripts/admin/templates/customize-colors-show.hbs +++ b/app/assets/javascripts/admin/templates/customize-colors-show.hbs @@ -6,7 +6,7 @@ {{/unless}} - + {{#if model.theme_id}} {{i18n "admin.customize.theme_owner"}} {{#link-to "adminCustomizeThemes.show" model.theme_id}}{{model.theme_name}}{{/link-to}} diff --git a/app/assets/javascripts/admin/templates/web-hooks.hbs b/app/assets/javascripts/admin/templates/web-hooks.hbs index b48c0e37c3..389f7d4815 100644 --- a/app/assets/javascripts/admin/templates/web-hooks.hbs +++ b/app/assets/javascripts/admin/templates/web-hooks.hbs @@ -25,7 +25,7 @@ {{webHook.description}} {{#link-to 'adminWebHooks.show' webHook tagName='button' classNames='btn btn-default no-text'}}{{d-icon 'far-edit'}}{{/link-to}} - {{d-button class="destroy btn-danger" action=(action "destroy") actionParam=webHook icon="remove"}} + {{d-button class="destroy btn-danger" action=(action "destroy") actionParam=webHook icon="times"}} {{/each}} diff --git a/app/assets/javascripts/discourse/helpers/icon-or-image.js.es6 b/app/assets/javascripts/discourse/helpers/icon-or-image.js.es6 index 8e48171eee..0751b6f0e2 100644 --- a/app/assets/javascripts/discourse/helpers/icon-or-image.js.es6 +++ b/app/assets/javascripts/discourse/helpers/icon-or-image.js.es6 @@ -6,7 +6,7 @@ export default htmlHelper(function({ icon, image }) { return ``; } - if (Ember.isEmpty(icon) || icon.indexOf("fa-") < 0) { + if (Ember.isEmpty(icon)) { return ""; } diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index 7e4f38a585..cdce634ff2 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -69,11 +69,11 @@ export const SAVE_LABELS = { export const SAVE_ICONS = { [EDIT]: "pencil-alt", - [EDIT_SHARED_DRAFT]: "clipboard", + [EDIT_SHARED_DRAFT]: "far-clipboard", [REPLY]: "reply", [CREATE_TOPIC]: "plus", [PRIVATE_MESSAGE]: "envelope", - [CREATE_SHARED_DRAFT]: "clipboard" + [CREATE_SHARED_DRAFT]: "far-clipboard" }; const Composer = RestModel.extend({ diff --git a/app/assets/javascripts/discourse/templates/about.hbs b/app/assets/javascripts/discourse/templates/about.hbs index e0b3ca0c17..495a7a3db9 100644 --- a/app/assets/javascripts/discourse/templates/about.hbs +++ b/app/assets/javascripts/discourse/templates/about.hbs @@ -58,7 +58,7 @@ args=(hash model=model)}}
-

{{d-icon "bar-chart"}} {{i18n 'about.stats'}}

+

{{d-icon "far-chart-bar"}} {{i18n 'about.stats'}}

diff --git a/app/assets/javascripts/discourse/templates/components/shared-draft-controls.hbs b/app/assets/javascripts/discourse/templates/components/shared-draft-controls.hbs index feeed4be43..0f45814483 100644 --- a/app/assets/javascripts/discourse/templates/components/shared-draft-controls.hbs +++ b/app/assets/javascripts/discourse/templates/components/shared-draft-controls.hbs @@ -17,7 +17,7 @@ action=(action "publish") label="shared_drafts.publish" class="btn-primary publish-shared-draft" - icon="clipboard"}} + icon="far-clipboard"}} {{/if}} {{/if}} diff --git a/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6 b/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6 index 2ae0a487be..83e7734757 100644 --- a/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-admin-menu.js.es6 @@ -107,14 +107,14 @@ export function buildManageButtons(attrs, currentUser, siteSettings) { contents.push({ action: "toggleWiki", label: "post.controls.unwiki", - icon: "pencil-square-o", + icon: "far-edit", className: "btn-default wiki wikied" }); } else { contents.push({ action: "toggleWiki", label: "post.controls.wiki", - icon: "pencil-square-o", + icon: "far-edit", className: "btn-default wiki" }); } diff --git a/app/assets/javascripts/discourse/widgets/post-edits-indicator.js.es6 b/app/assets/javascripts/discourse/widgets/post-edits-indicator.js.es6 index 81f339d455..46f7ed3f4d 100644 --- a/app/assets/javascripts/discourse/widgets/post-edits-indicator.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-edits-indicator.js.es6 @@ -40,7 +40,7 @@ export default createWidget("post-edits-indicator", { let title; if (attrs.wiki) { - icon = "pencil-square-o"; + icon = "far-edit"; className = `${className || ""} wiki`.trim(); if (attrs.version > 1) { diff --git a/app/assets/javascripts/discourse/widgets/post-menu.js.es6 b/app/assets/javascripts/discourse/widgets/post-menu.js.es6 index e3d8c6ba3c..4eadb59c0d 100644 --- a/app/assets/javascripts/discourse/widgets/post-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-menu.js.es6 @@ -178,7 +178,7 @@ registerButton("wiki-edit", attrs => { action: "editPost", className: "edit create", title: "post.controls.edit", - icon: "pencil-square-o", + icon: "far-edit", alwaysShowYours: true }; if (!attrs.mobileView) { 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 2f95497511..80f7f4c9ef 100644 --- a/app/assets/javascripts/select-kit/components/composer-actions.js.es6 +++ b/app/assets/javascripts/select-kit/components/composer-actions.js.es6 @@ -67,7 +67,7 @@ export default DropdownSelectBoxComponent.extend({ content.title = I18n.t("composer.composer_actions.edit"); break; case CREATE_SHARED_DRAFT: - content.icon = "clipboard"; + content.icon = "far-clipboard"; content.title = I18n.t("composer.composer_actions.draft"); break; } @@ -167,7 +167,7 @@ export default DropdownSelectBoxComponent.extend({ items.push({ name: I18n.t("composer.composer_actions.shared_draft.label"), description: I18n.t("composer.composer_actions.shared_draft.desc"), - icon: "clipboard", + icon: "far-clipboard", id: "shared_draft" }); } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index b59fb1f83a..4e01d15691 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -4240,7 +4240,7 @@ en: enabled: Enable badge icon: Icon image: Image - icon_help: "Use a Font Awesome class" + icon_help: "Enter a Font Awesome icon name (use prefix 'far-' for regular icons and 'fab-' for brand icons)" image_help: "Enter the URL of the image (overrides icon field if both are set)" query: Badge Query (SQL) target_posts: Query targets posts diff --git a/db/fixtures/006_badges.rb b/db/fixtures/006_badges.rb index 211a972f17..c0b3b76dfc 100644 --- a/db/fixtures/006_badges.rb +++ b/db/fixtures/006_badges.rb @@ -270,7 +270,7 @@ end Badge.seed do |b| b.id = Badge::Anniversary b.name = "Anniversary" - b.default_icon = "fa-clock-o" + b.default_icon = "far-clock" b.badge_type_id = BadgeType::Silver b.default_badge_grouping_id = BadgeGrouping::Community b.query = nil @@ -427,7 +427,7 @@ end Badge.seed do |b| b.id = id b.name = name - b.default_icon = "fa-eye" + b.default_icon = "far-eye" b.badge_type_id = level b.query = BadgeQueries.consecutive_visits(days) b.default_badge_grouping_id = BadgeGrouping::Community diff --git a/db/migrate/20190716014949_rename_deprecated_badge_icons.rb b/db/migrate/20190716014949_rename_deprecated_badge_icons.rb new file mode 100644 index 0000000000..e7eed38200 --- /dev/null +++ b/db/migrate/20190716014949_rename_deprecated_badge_icons.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class RenameDeprecatedBadgeIcons < ActiveRecord::Migration[5.2] + def up + execute "UPDATE badges SET icon = 'far-clock' WHERE icon = 'fa-clock-o'" + execute "UPDATE badges SET icon = 'far-eye' WHERE icon = 'fa-eye'" + end + + def down + end +end diff --git a/test/javascripts/fixtures/user_fixtures.js.es6 b/test/javascripts/fixtures/user_fixtures.js.es6 index 4505893c6d..e8a42c3d56 100644 --- a/test/javascripts/fixtures/user_fixtures.js.es6 +++ b/test/javascripts/fixtures/user_fixtures.js.es6 @@ -246,7 +246,7 @@ export default { browser: "Google Chrome", device: "Linux Computer", os: "Linux", - icon: "linux", + icon: "fab-linux", created_at: "2018-09-08T21:22:56.225Z", seen_at: "2018-09-08T21:22:56.512Z", is_active: false @@ -258,7 +258,7 @@ export default { browser: "Google Chrome", device: "Linux Computer", os: "Linux", - icon: "linux", + icon: "fab-linux", created_at: "2018-09-08T21:33:41.616Z", seen_at: "2018-09-08T21:33:42.209Z", is_active: true @@ -270,7 +270,7 @@ export default { browser: "Internet Explorer", device: "Windows Computer", os: "Windows", - icon: "windows", + icon: "fab-windows", created_at: "2018-09-07T21:44:41.616Z", seen_at: "2018-09-08T21:44:42.209Z", is_active: false diff --git a/test/javascripts/widgets/topic-status-test.js.es6 b/test/javascripts/widgets/topic-status-test.js.es6 index e0ed454fc3..c5888d4aa4 100644 --- a/test/javascripts/widgets/topic-status-test.js.es6 +++ b/test/javascripts/widgets/topic-status-test.js.es6 @@ -21,7 +21,7 @@ widgetTest("extendability", { beforeEach(store) { TopicStatusIcons.addObject([ "has_accepted_answer", - "check-square-o", + "far-check-square", "solved" ]); this.set("args", { @@ -32,6 +32,6 @@ widgetTest("extendability", { }); }, test(assert) { - assert.ok(find(".topic-status .d-icon-check-square-o").length); + assert.ok(find(".topic-status .d-icon-far-check-square").length); } }); From 2a0cd066a77322d141200af32d7c63363ffb4b41 Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Tue, 16 Jul 2019 19:30:38 -0300 Subject: [PATCH 023/441] FIX: Remove all service workers from Apple devices *again* There is a bug that when Safari starts up, and reloads the tabs from the previous session **and** there is a service worker registered for the scope of the document, all cookies marked as `SameSite=Lax` won't be sent in the request. This puts Discourse in a **very** broken state, where: - You appear as a anon user - Subsequent xhr requests will come with logged in data - Refreshing doesn't log you in (cookies are still not sent) - Clicking on the address bar and hitting enter, will log you in (as it will finally send those damn `SameSite=Lax` cookies. Looks a lot like a corner case missed by the fix at https://trac.webkit.org/changeset/241918/webkit --- .../discourse/initializers/register-service-worker.js.es6 | 4 +++- 1 file changed, 3 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 92ae988625..b78044e11f 100644 --- a/app/assets/javascripts/discourse/initializers/register-service-worker.js.es6 +++ b/app/assets/javascripts/discourse/initializers/register-service-worker.js.es6 @@ -9,7 +9,9 @@ export default { const isSupported = isSecured && "serviceWorker" in navigator; if (isSupported) { - if (Discourse.ServiceWorkerURL) { + const isApple = !!navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i); + + if (Discourse.ServiceWorkerURL && !isApple) { navigator.serviceWorker.getRegistrations().then(registrations => { for (let registration of registrations) { if ( From dd0f0494c64e75046a4f26e3e46c132edb750cb0 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 17 Jul 2019 09:15:09 +0530 Subject: [PATCH 024/441] FIX: convert hotlinked non-image urls to short url. 3840ace97820acf98cb8f58ab1cab96f66e2def2 --- app/services/inline_uploads.rb | 5 ----- spec/services/inline_uploads_spec.rb | 6 ++++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/app/services/inline_uploads.rb b/app/services/inline_uploads.rb index 13d5db72dd..6f1cedeb4a 100644 --- a/app/services/inline_uploads.rb +++ b/app/services/inline_uploads.rb @@ -123,11 +123,6 @@ class InlineUploads .sort { |a, b| a[3] <=> b[3] } .each do |match, link, replace_with, _index| - if match == link - extension = match.split(".")[-1].downcase - next if FileHelper.supported_images.exclude?(extension) - end - node_info = link_occurences.shift next unless node_info&.dig(:is_valid) diff --git a/spec/services/inline_uploads_spec.rb b/spec/services/inline_uploads_spec.rb index dfc2ec4b01..dd9c109059 100644 --- a/spec/services/inline_uploads_spec.rb +++ b/spec/services/inline_uploads_spec.rb @@ -228,7 +228,7 @@ RSpec.describe InlineUploads do MD end - it "should not correct non image URLs to the short url and paths" do + it "should correct non image URLs to the short url" do SiteSetting.authorized_extensions = "mp4" upload = Fabricate(:video_upload) @@ -236,7 +236,9 @@ RSpec.describe InlineUploads do #{GlobalSetting.cdn_url}#{upload.url} MD - expect(InlineUploads.process(md)).to eq(md) + expect(InlineUploads.process(md)).to eq(<<~MD) + #{Discourse.base_url}#{upload.short_path} + MD end it "should correct img tags with uppercase upload extension" do From dc6b13e4d205fd148d10d56d1f38f0b12d55825d Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 17 Jul 2019 11:13:50 +0530 Subject: [PATCH 025/441] FIX: when 'raw' started with non-image upload url it's not converted to short-url. dd0f0494c64e75046a4f26e3e46c132edb750cb0 --- app/services/inline_uploads.rb | 8 +++++--- spec/services/inline_uploads_spec.rb | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/services/inline_uploads.rb b/app/services/inline_uploads.rb index 6f1cedeb4a..0473b1c81c 100644 --- a/app/services/inline_uploads.rb +++ b/app/services/inline_uploads.rb @@ -74,11 +74,13 @@ class InlineUploads markdown.scan(/(\n{2,}|\A)#{regexp}$/) do |match| if match[1].present? extension = match[1].split(".")[-1].downcase - next if FileHelper.supported_images.exclude?(extension) - index = $~.offset(2)[0] indexes << index - raw_matches << [match[1], match[1], +"![](#{PLACEHOLDER})", index] + if FileHelper.supported_images.include?(extension) + raw_matches << [match[1], match[1], +"![](#{PLACEHOLDER})", index] + else + raw_matches << [match[1], match[1], ++"#{Discourse.base_url}#{PATH_PLACEHOLDER}", index] + end end end diff --git a/spec/services/inline_uploads_spec.rb b/spec/services/inline_uploads_spec.rb index dd9c109059..79f6e78d79 100644 --- a/spec/services/inline_uploads_spec.rb +++ b/spec/services/inline_uploads_spec.rb @@ -231,13 +231,18 @@ RSpec.describe InlineUploads do it "should correct non image URLs to the short url" do SiteSetting.authorized_extensions = "mp4" upload = Fabricate(:video_upload) + upload2 = Fabricate(:video_upload) md = <<~MD - #{GlobalSetting.cdn_url}#{upload.url} + #{Discourse.base_url}#{upload.url} + + #{Discourse.base_url}#{upload.url} #{Discourse.base_url}#{upload2.url} MD expect(InlineUploads.process(md)).to eq(<<~MD) #{Discourse.base_url}#{upload.short_path} + + #{Discourse.base_url}#{upload.short_path} #{Discourse.base_url}#{upload2.short_path} MD end From 4bbf341ab11e2b90ed03c779089344ef819be817 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 17 Jul 2019 11:16:35 +0530 Subject: [PATCH 026/441] SPEC: add additional test with 'cdn_url'. dc6b13e4d205fd148d10d56d1f38f0b12d55825d --- spec/services/inline_uploads_spec.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/services/inline_uploads_spec.rb b/spec/services/inline_uploads_spec.rb index 79f6e78d79..93943c0e34 100644 --- a/spec/services/inline_uploads_spec.rb +++ b/spec/services/inline_uploads_spec.rb @@ -237,12 +237,16 @@ RSpec.describe InlineUploads do #{Discourse.base_url}#{upload.url} #{Discourse.base_url}#{upload.url} #{Discourse.base_url}#{upload2.url} + + #{GlobalSetting.cdn_url}#{upload2.url} MD expect(InlineUploads.process(md)).to eq(<<~MD) #{Discourse.base_url}#{upload.short_path} #{Discourse.base_url}#{upload.short_path} #{Discourse.base_url}#{upload2.short_path} + + #{Discourse.base_url}#{upload2.short_path} MD end From e4d743910d63be6fdc6eb21a88881de2c6eb71e1 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Wed, 17 Jul 2019 11:57:12 +0530 Subject: [PATCH 027/441] FIX: respect `logout_redirect` setting on 'Log out all' --- .../discourse/controllers/preferences/account.js.es6 | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 index ac56e0ec37..64b8262f21 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 @@ -228,7 +228,17 @@ export default Ember.Controller.extend( type: "POST", data: token ? { token_id: token.id } : {} } - ); + ).then(() => { + if (!token) { + const redirect = this.siteSettings.logout_redirect; + if (Ember.isEmpty(redirect)) { + window.location.pathname = Discourse.getURL("/"); + } else { + window.location.href = redirect; + } + } + }) + .catch(popupAjaxError); }, showToken(token) { From d4d81515d297b157545bf92cd310c8e9d4a6f053 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Wed, 17 Jul 2019 12:03:45 +0530 Subject: [PATCH 028/441] Fix the build. --- .../controllers/preferences/account.js.es6 | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 index 64b8262f21..2c4a2d1b1e 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 @@ -228,17 +228,18 @@ export default Ember.Controller.extend( type: "POST", data: token ? { token_id: token.id } : {} } - ).then(() => { - if (!token) { - const redirect = this.siteSettings.logout_redirect; - if (Ember.isEmpty(redirect)) { - window.location.pathname = Discourse.getURL("/"); - } else { - window.location.href = redirect; + ) + .then(() => { + if (!token) { + const redirect = this.siteSettings.logout_redirect; + if (Ember.isEmpty(redirect)) { + window.location.pathname = Discourse.getURL("/"); + } else { + window.location.href = redirect; + } } - } - }) - .catch(popupAjaxError); + }) + .catch(popupAjaxError); }, showToken(token) { From 4f0004a0c277b6a32f144c99003726437c8c3b21 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 17 Jul 2019 15:54:42 +0530 Subject: [PATCH 029/441] Fix typo in dc6b13e4d205fd148d10d56d1f38f0b12d55825d. --- app/services/inline_uploads.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/inline_uploads.rb b/app/services/inline_uploads.rb index 0473b1c81c..989301ebc1 100644 --- a/app/services/inline_uploads.rb +++ b/app/services/inline_uploads.rb @@ -79,7 +79,7 @@ class InlineUploads if FileHelper.supported_images.include?(extension) raw_matches << [match[1], match[1], +"![](#{PLACEHOLDER})", index] else - raw_matches << [match[1], match[1], ++"#{Discourse.base_url}#{PATH_PLACEHOLDER}", index] + raw_matches << [match[1], match[1], +"#{Discourse.base_url}#{PATH_PLACEHOLDER}", index] end end end From 98866ca0434a60da77224733f7e5c33cece5d077 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 17 Jul 2019 09:15:13 -0400 Subject: [PATCH 030/441] Adds down SQL statements to badge icons migration follows up on a571efba --- db/migrate/20190716014949_rename_deprecated_badge_icons.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/db/migrate/20190716014949_rename_deprecated_badge_icons.rb b/db/migrate/20190716014949_rename_deprecated_badge_icons.rb index e7eed38200..73540dd957 100644 --- a/db/migrate/20190716014949_rename_deprecated_badge_icons.rb +++ b/db/migrate/20190716014949_rename_deprecated_badge_icons.rb @@ -7,5 +7,7 @@ class RenameDeprecatedBadgeIcons < ActiveRecord::Migration[5.2] end def down + execute "UPDATE badges SET icon = 'fa-clock-o' WHERE icon = 'far-clock'" + execute "UPDATE badges SET icon = 'fa-eye' WHERE icon = 'far-eye'" end end From 092eeb5ca351a34d8a2892048a3a40099026497b Mon Sep 17 00:00:00 2001 From: Blake Erickson Date: Wed, 17 Jul 2019 12:41:13 -0600 Subject: [PATCH 031/441] FEATURE: Create a rake task for destroying categories Created a rake task for destroying multiple categories along with any subcategories and topics the belong to those categories. Also created a rake task for listing all of your categories. Refactored existing destroy rake tasks to use new logging method, that allows for puts output in the console but prevents it from showing in the specs. --- app/services/destroy_task.rb | 104 +++++++++++++++++++---------- lib/tasks/categories.rake | 8 +++ lib/tasks/destroy.rake | 33 ++++++--- spec/services/destroy_task_spec.rb | 55 ++++++++++++--- 4 files changed, 148 insertions(+), 52 deletions(-) diff --git a/app/services/destroy_task.rb b/app/services/destroy_task.rb index f7c8a64123..e5ff8e5789 100644 --- a/app/services/destroy_task.rb +++ b/app/services/destroy_task.rb @@ -1,83 +1,110 @@ # frozen_string_literal: true -## Because these methods are meant to be called from a rake task -# we are capturing all log output into a log array to return -# to the rake task rather than using `puts` statements. class DestroyTask - def self.destroy_topics(category, parent_category = nil) + + def initialize(io = $stdout) + @io = io + end + + def destroy_topics(category, parent_category = nil, delete_system_topics = false) c = Category.find_by_slug(category, parent_category) - log = [] descriptive_slug = parent_category ? "#{parent_category}/#{category}" : category - return "A category with the slug: #{descriptive_slug} could not be found" if c.nil? - topics = Topic.where(category_id: c.id, pinned_at: nil).where.not(user_id: -1) - log << "There are #{topics.count} topics to delete in #{descriptive_slug} category" + return @io.puts "A category with the slug: #{descriptive_slug} could not be found" if c.nil? + if delete_system_topics + topics = Topic.where(category_id: c.id, pinned_at: nil) + else + topics = Topic.where(category_id: c.id, pinned_at: nil).where.not(user_id: -1) + end + @io.puts "There are #{topics.count} topics to delete in #{descriptive_slug} category" topics.each do |topic| - log << "Deleting #{topic.slug}..." + @io.puts "Deleting #{topic.slug}..." first_post = topic.ordered_posts.first if first_post.nil? - return log << "Topic.ordered_posts.first was nil" + return @io.puts "Topic.ordered_posts.first was nil" end system_user = User.find(-1) - log << PostDestroyer.new(system_user, first_post).destroy + @io.puts PostDestroyer.new(system_user, first_post).destroy end - log end - def self.destroy_topics_all_categories + def destroy_topics_in_category(category_id, delete_system_topics = false) + c = Category.find(category_id) + return @io.puts "A category with the id: #{category_id} could not be found" if c.nil? + if delete_system_topics + topics = Topic.where(category_id: c.id, pinned_at: nil) + else + topics = Topic.where(category_id: c.id, pinned_at: nil).where.not(user_id: -1) + end + @io.puts "There are #{topics.count} topics to delete in #{c.slug} category" + topics.each do |topic| + first_post = topic.ordered_posts.first + return @io.puts "Topic.ordered_posts.first was nil for topic: #{topic.id}" if first_post.nil? + system_user = User.find(-1) + PostDestroyer.new(system_user, first_post).destroy + end + topics = Topic.where(category_id: c.id, pinned_at: nil) + @io.puts "There are #{topics.count} topics that could not be deleted in #{c.slug} category" + end + + def destroy_topics_all_categories categories = Category.all - log = [] categories.each do |c| - log << destroy_topics(c.slug, c.parent_category&.slug) + @io.puts destroy_topics(c.slug, c.parent_category&.slug) end - log end - def self.destroy_private_messages + def destroy_private_messages pms = Topic.where(archetype: "private_message") current_user = User.find(-1) #system - log = [] pms.each do |pm| - log << "Destroying #{pm.slug} pm" + @io.puts "Destroying #{pm.slug} pm" first_post = pm.ordered_posts.first - log << PostDestroyer.new(current_user, first_post).destroy + @io.puts PostDestroyer.new(current_user, first_post).destroy end - log end - def self.destroy_groups + def destroy_category(category_id, destroy_system_topics = false) + c = Category.find_by_id(category_id) + return @io.puts "A category with the id: #{category_id} could not be found" if c.nil? + subcategories = Category.where(parent_category_id: c.id).pluck(:id) + @io.puts "There are #{subcategories.count} subcategories to delete" if subcategories + subcategories.each do |subcategory_id| + s = Category.find_by_id(subcategory_id) + category_topic_destroyer(s, destroy_system_topics) + end + category_topic_destroyer(c, destroy_system_topics) + end + + def destroy_groups groups = Group.where(automatic: false) - log = [] groups.each do |group| - log << "destroying group: #{group.id}" - log << group.destroy + @io.puts "destroying group: #{group.id}" + @io.puts group.destroy end - log end - def self.destroy_users - log = [] + def destroy_users users = User.where(admin: false, id: 1..Float::INFINITY) - log << "There are #{users.count} users to delete" + @io.puts "There are #{users.count} users to delete" options = {} options[:delete_posts] = true current_user = User.find(-1) #system users.each do |user| begin if UserDestroyer.new(current_user).destroy(user, options) - log << "#{user.username} deleted" + @io.puts "#{user.username} deleted" else - log << "#{user.username} not deleted" + @io.puts "#{user.username} not deleted" end rescue UserDestroyer::PostsExistError raise Discourse::InvalidAccess.new("User #{user.username} has #{user.post_count} posts, so can't be deleted.") rescue NoMethodError - log << "#{user.username} could not be deleted" + @io.puts "#{user.username} could not be deleted" end end - log end - def self.destroy_stats + def destroy_stats ApplicationRequest.destroy_all IncomingLink.destroy_all UserVisit.destroy_all @@ -90,4 +117,13 @@ class DestroyTask PostAction.unscoped.destroy_all EmailLog.destroy_all end + + private + + def category_topic_destroyer(category, destroy_system_topics = false) + destroy_topics_log = destroy_topics_in_category(category.id, destroy_system_topics) + @io.puts "Destroying #{category.slug} category" + category.destroy + end + end diff --git a/lib/tasks/categories.rake b/lib/tasks/categories.rake index 97341f59b8..b2c9ab2c9f 100644 --- a/lib/tasks/categories.rake +++ b/lib/tasks/categories.rake @@ -36,3 +36,11 @@ end def print_status(current, max) print "\r%9d / %d (%5.1f%%)" % [current, max, ((current.to_f / max.to_f) * 100).round(1)] end + +desc "Output a list of categories" +task "categories:list" => :environment do + categories = Category.pluck(:id, :slug, :parent_category_id) + categories.each do |c| + puts "id: #{c[0]}, slug: #{c[1]}, parent: #{c[2]}" + end +end diff --git a/lib/tasks/destroy.rake b/lib/tasks/destroy.rake index 767f14370c..dd99bf52de 100644 --- a/lib/tasks/destroy.rake +++ b/lib/tasks/destroy.rake @@ -1,43 +1,60 @@ # frozen_string_literal: true ## These tasks are destructive and are for clearing out all the -# content and users from your site, but keeping your site settings, -# theme, and category structure. +# content and users from your site. desc "Remove all topics in a category" task "destroy:topics", [:category, :parent_category] => :environment do |t, args| + destroy_task = DestroyTask.new category = args[:category] parent_category = args[:parent_category] descriptive_slug = parent_category ? "#{parent_category}/#{category}" : category puts "Going to delete all topics in the #{descriptive_slug} category" - puts log = DestroyTask.destroy_topics(category, parent_category) + destroy_task.destroy_topics(category, parent_category) end desc "Remove all topics in all categories" task "destroy:topics_all_categories" => :environment do + destroy_task = DestroyTask.new puts "Going to delete all topics in all categories..." - puts log = DestroyTask.destroy_topics_all_categories + puts log = destroy_task.destroy_topics_all_categories end desc "Remove all private messages" task "destroy:private_messages" => :environment do + destroy_task = DestroyTask.new puts "Going to delete all private messages..." - puts log = DestroyTask.destroy_private_messages + puts log = destroy_task.destroy_private_messages end desc "Destroy all groups" task "destroy:groups" => :environment do + destroy_task = DestroyTask.new puts "Going to delete all non-default groups..." - puts log = DestroyTask.destroy_groups + puts log = destroy_task.destroy_groups end desc "Destroy all non-admin users" task "destroy:users" => :environment do + destroy_task = DestroyTask.new puts "Going to delete all non-admin users..." - puts log = DestroyTask.destroy_users + puts log = destroy_task.destroy_users end desc "Destroy site stats" task "destroy:stats" => :environment do + destroy_task = DestroyTask.new puts "Going to delete all site stats..." - DestroyTask.destroy_stats + destroy_task.destroy_stats +end + +# Example: rake destroy:categories[28,29,44,85] +# Run rake categories:list for a list of category ids +desc "Destroy a comma separated list of category ids." +task "destroy:categories" => :environment do |t, args| + destroy_task = DestroyTask.new + categories = args.extras + puts "Going to delete these categories: #{categories}" + categories.each do |id| + destroy_task.destroy_category(id, true) + end end diff --git a/spec/services/destroy_task_spec.rb b/spec/services/destroy_task_spec.rb index e33276710b..9d81aa2c46 100644 --- a/spec/services/destroy_task_spec.rb +++ b/spec/services/destroy_task_spec.rb @@ -11,37 +11,68 @@ describe DestroyTask do fab!(:c2) { Fabricate(:category) } fab!(:t2) { Fabricate(:topic, category: c2) } let!(:p2) { Fabricate(:post, topic: t2) } - fab!(:sc) { Fabricate(:category, parent_category: c) } + fab!(:sc) { Fabricate(:category, parent_category: c2) } fab!(:t3) { Fabricate(:topic, category: sc) } let!(:p3) { Fabricate(:post, topic: t3) } it 'destroys all topics in a category' do - expect { DestroyTask.destroy_topics(c.slug) } + destroy_task = DestroyTask.new(StringIO.new) + expect { destroy_task.destroy_topics(c.slug) } .to change { Topic.where(category_id: c.id).count }.by (-1) end it 'destroys all topics in a sub category' do - expect { DestroyTask.destroy_topics(sc.slug, c.slug) } + destroy_task = DestroyTask.new(StringIO.new) + expect { destroy_task.destroy_topics(sc.slug, c2.slug) } .to change { Topic.where(category_id: sc.id).count }.by(-1) end it "doesn't destroy system topics" do - DestroyTask.destroy_topics(c2.slug) + destroy_task = DestroyTask.new(StringIO.new) + destroy_task.destroy_topics(c2.slug) expect(Topic.where(category_id: c2.id).count).to eq 1 end it 'destroys topics in all categories' do - DestroyTask.destroy_topics_all_categories + destroy_task = DestroyTask.new(StringIO.new) + destroy_task.destroy_topics_all_categories expect(Post.where(topic_id: [t.id, t2.id, t3.id]).count).to eq 0 end end + describe 'destroy categories' do + fab!(:c) { Fabricate(:category) } + fab!(:t) { Fabricate(:topic, category: c) } + let!(:p) { Fabricate(:post, topic: t) } + fab!(:c2) { Fabricate(:category) } + fab!(:t2) { Fabricate(:topic, category: c) } + let!(:p2) { Fabricate(:post, topic: t2) } + fab!(:sc) { Fabricate(:category, parent_category: c2) } + fab!(:t3) { Fabricate(:topic, category: sc) } + let!(:p3) { Fabricate(:post, topic: t3) } + + it 'destroys specified category' do + destroy_task = DestroyTask.new(StringIO.new) + + expect { destroy_task.destroy_category(c.id) } + .to change { Category.where(id: c.id).count }.by (-1) + end + + it 'destroys sub-categories when destroying parent category' do + destroy_task = DestroyTask.new(StringIO.new) + + expect { destroy_task.destroy_category(c2.id) } + .to change { Category.where(id: sc.id).count }.by (-1) + end + end + describe 'private messages' do let!(:pm) { Fabricate(:private_message_post) } let!(:pm2) { Fabricate(:private_message_post) } it 'destroys all private messages' do - DestroyTask.destroy_private_messages + destroy_task = DestroyTask.new(StringIO.new) + destroy_task.destroy_private_messages expect(Topic.where(archetype: "private_message").count).to eq 0 end end @@ -51,13 +82,15 @@ describe DestroyTask do let!(:g2) { Fabricate(:group) } it 'destroys all groups' do - DestroyTask.destroy_groups + destroy_task = DestroyTask.new(StringIO.new) + destroy_task.destroy_groups expect(Group.where(automatic: false).count).to eq 0 end it "doesn't destroy default groups" do + destroy_task = DestroyTask.new(StringIO.new) before_count = Group.count - DestroyTask.destroy_groups + destroy_task.destroy_groups expect(Group.count).to eq before_count - 2 end end @@ -70,7 +103,8 @@ describe DestroyTask do Fabricate(:user) Fabricate(:admin) - DestroyTask.destroy_users + destroy_task = DestroyTask.new(StringIO.new) + destroy_task.destroy_users expect(User.where(admin: false).count).to eq 0 # admin does not get detroyed expect(User.count).to eq before_count + 1 @@ -79,7 +113,8 @@ describe DestroyTask do describe 'stats' do it 'destroys all site stats' do - DestroyTask.destroy_stats + destroy_task = DestroyTask.new(StringIO.new) + destroy_task.destroy_stats end end end From 95182be9703a962431f93c2ff7af4502e2ca65a6 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 17 Jul 2019 15:05:56 -0400 Subject: [PATCH 032/441] DEV: Use updated lodash-cli commit hash in yarn.lock Previous commit hash in yarn.lock looks to have been deleted, this should fix our builds. --- yarn.lock | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/yarn.lock b/yarn.lock index a0b4ec517b..6478b2da96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1535,21 +1535,16 @@ linkify-it@^2.0.0: uc.micro "^1.0.1" "lodash-cli@https://github.com/lodash-archive/lodash-cli.git": - version "4.17.14" - resolved "https://github.com/lodash-archive/lodash-cli.git#169d0651a93bb46edcd23b03f48026f3326dc0bc" + version "4.17.15" + resolved "https://github.com/lodash-archive/lodash-cli.git#91176e422d7c10634a59c7e367fe224795ef5a24" dependencies: closure-compiler "0.2.12" glob "7.1.1" - lodash "4.17.13" + lodash "4.17.15" semver "5.3.0" uglify-js "2.7.5" -lodash@4.17.13: - version "4.17.13" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.13.tgz#0bdc3a6adc873d2f4e0c4bac285df91b64fc7b93" - integrity sha512-vm3/XWXfWtRua0FkUyEHBZy8kCPjErNBT9fJx8Zvs+U6zjqPbTUOpkaoum3O5uiA8sm+yNMHXfYkTUHFoMxFNA== - -lodash@^4.17.11, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.3.0: +lodash@4.17.15, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.3.0: version "4.17.14" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba" integrity sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw== From 514aaacdf486613bda49f8c729867926fbceb174 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 17 Jul 2019 15:19:24 -0400 Subject: [PATCH 033/441] DEV: Set version to 4.17.14 for lodash-cli Lodash-cli uses lodash 4.17.15, which is not yet published on yarn/npm. --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6478b2da96..a799bd1141 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1535,16 +1535,16 @@ linkify-it@^2.0.0: uc.micro "^1.0.1" "lodash-cli@https://github.com/lodash-archive/lodash-cli.git": - version "4.17.15" + version "4.17.14" resolved "https://github.com/lodash-archive/lodash-cli.git#91176e422d7c10634a59c7e367fe224795ef5a24" dependencies: closure-compiler "0.2.12" glob "7.1.1" - lodash "4.17.15" + lodash "4.17.14" semver "5.3.0" uglify-js "2.7.5" -lodash@4.17.15, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.3.0: +lodash@4.17.14, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.3.0: version "4.17.14" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba" integrity sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw== From c8661674d4f471143326333caf2ee6c149234ee6 Mon Sep 17 00:00:00 2001 From: Blake Erickson Date: Wed, 17 Jul 2019 17:07:10 -0600 Subject: [PATCH 034/441] FIX: Empty backup names with unicode site titles If a site title contains unicode it may end up with an empty backup filename because of the rails `parameterize` method we are calling. This fix ensures that the backup filenames default to "discourse" if the parameterized site title is empty. Bug reported [here][1]. [1]: https://meta.discourse.org/t/backup-checksum-and-backup-name-missing-when-unicode-site-name/123192?u=blake --- lib/backup_restore/backuper.rb | 6 +++++- spec/lib/backup_restore/backuper_spec.rb | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 spec/lib/backup_restore/backuper_spec.rb diff --git a/lib/backup_restore/backuper.rb b/lib/backup_restore/backuper.rb index 5ee1f01639..4eb0c9838c 100644 --- a/lib/backup_restore/backuper.rb +++ b/lib/backup_restore/backuper.rb @@ -83,6 +83,10 @@ module BackupRestore raise Discourse::InvalidParameters.new(:user_id) unless @user end + def get_parameterized_title + SiteSetting.title.parameterize.empty? ? "discourse" : SiteSetting.title.parameterize + end + def initialize_state @success = false @store = BackupRestore::BackupStore.create @@ -91,7 +95,7 @@ module BackupRestore @tmp_directory = File.join(Rails.root, "tmp", "backups", @current_db, @timestamp) @dump_filename = File.join(@tmp_directory, BackupRestore::DUMP_FILE) @archive_directory = BackupRestore::LocalBackupStore.base_directory(db: @current_db) - filename = @filename_override || "#{SiteSetting.title.parameterize}-#{@timestamp}" + filename = @filename_override || "#{get_parameterized_title}-#{@timestamp}" @archive_basename = File.join(@archive_directory, "#{filename}-#{BackupRestore::VERSION_PREFIX}#{BackupRestore.current_version}") @backup_filename = diff --git a/spec/lib/backup_restore/backuper_spec.rb b/spec/lib/backup_restore/backuper_spec.rb new file mode 100644 index 0000000000..1c96892caa --- /dev/null +++ b/spec/lib/backup_restore/backuper_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe BackupRestore::Backuper do + it 'returns a non-empty parameterized title when site title contains unicode' do + SiteSetting.title = 'Ɣ' + backuper = BackupRestore::Backuper.new(-1) + + expect(backuper.send(:get_parameterized_title)).to eq("discourse") + end + + it 'returns a valid parameterized site title' do + SiteSetting.title = "Coding Horror" + backuper = BackupRestore::Backuper.new(-1) + + expect(backuper.send(:get_parameterized_title)).to eq("coding-horror") + end +end From 7e69c5cc369ac7c13aa6dd97e680830dcd462390 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Thu, 18 Jul 2019 11:55:49 +0200 Subject: [PATCH 035/441] Revert "FEATURE: Use configured quotation marks in fancy topic title" This reverts most of commit ce8e09963903fcad725002b2d42b54b4af5d0930. The rake task to update fancy topic titles is still there, because that's useful even without this feature. --- app/models/site_setting.rb | 18 ------------------ app/models/topic.rb | 2 +- lib/html_prettify.rb | 4 ++-- spec/models/topic_spec.rb | 10 ---------- 4 files changed, 3 insertions(+), 31 deletions(-) diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb index 160d19e84e..f0be63c668 100644 --- a/app/models/site_setting.rb +++ b/app/models/site_setting.rb @@ -3,7 +3,6 @@ require 'site_setting_extension' require_dependency 'global_path' require_dependency 'site_settings/yaml_loader' -require 'htmlentities' class SiteSetting < ActiveRecord::Base extend GlobalPath @@ -123,7 +122,6 @@ class SiteSetting < ActiveRecord::Base @attachment_content_type_blacklist_regex = nil @attachment_filename_blacklist_regex = nil @unicode_username_whitelist_regex = nil - @pretty_quote_entities = nil end def self.attachment_content_type_blacklist_regex @@ -139,22 +137,6 @@ class SiteSetting < ActiveRecord::Base ? Regexp.new(SiteSetting.unicode_username_character_whitelist) : nil end - def self.pretty_quote_entities - @pretty_quote_entities ||= begin - htmlentities = HTMLEntities.new - quotation_marks = SiteSetting.markdown_typographer_quotation_marks - .split("|") - .map { |quote| htmlentities.encode(quote, :basic, :named, :decimal) } - - { - double_left_quote: quotation_marks[0], - double_right_quote: quotation_marks[1], - single_left_quote: quotation_marks[2], - single_right_quote: quotation_marks[3] - } - end - end - # helpers for getting s3 settings that fallback to global class Upload def self.s3_cdn_url diff --git a/app/models/topic.rb b/app/models/topic.rb index 4fd4c67e6f..648da51a74 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -336,7 +336,7 @@ class Topic < ActiveRecord::Base def self.fancy_title(title) return unless escaped = ERB::Util.html_escape(title) - fancy_title = Emoji.unicode_unescape(HtmlPrettify.render(escaped, SiteSetting.pretty_quote_entities)) + fancy_title = Emoji.unicode_unescape(HtmlPrettify.render(escaped)) fancy_title.length > Topic.max_fancy_title_length ? escaped : fancy_title end diff --git a/lib/html_prettify.rb b/lib/html_prettify.rb index c339091d58..db3bd14e08 100644 --- a/lib/html_prettify.rb +++ b/lib/html_prettify.rb @@ -10,8 +10,8 @@ # class HtmlPrettify < String - def self.render(html, entities = {}) - new(html, [2], entities).to_html + def self.render(html) + new(html).to_html end # Create a new RubyPants instance with the text in +string+. diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 510146a0bb..26dda3fb46 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -390,16 +390,6 @@ describe Topic do expect(topic.fancy_title).to eq(long_title) end - it "uses the configured quote entities" do - SiteSetting.markdown_typographer_quotation_marks = "„|“|‚|‘" - topic.title = %q|"Weißt du", sagte er, "was 'Discourse' ist?"| - expect(topic.fancy_title).to eq('„Weißt du“, sagte er, „was ‚Discourse‘ ist?“') - - SiteSetting.markdown_typographer_quotation_marks = "«\u00A0|\u00A0»|‹\u00A0|\u00A0›" - topic.title = '"Qui vivra verra"' - expect(topic.fancy_title).to eq('« Qui vivra verra »') - end - context 'readonly mode' do before do Discourse.enable_readonly_mode From c1b58613a2453cfa2196edfe6a9321037d34e50f Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 18 Jul 2019 19:25:39 +0800 Subject: [PATCH 036/441] UX: adds tag with href category box titles (#7901) This Ensures that category titles in category-boxes can be opened in a new tab. --- .../templates/components/categories-boxes.hbs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/components/categories-boxes.hbs b/app/assets/javascripts/discourse/templates/components/categories-boxes.hbs index 8c51088578..8ae095c267 100644 --- a/app/assets/javascripts/discourse/templates/components/categories-boxes.hbs +++ b/app/assets/javascripts/discourse/templates/components/categories-boxes.hbs @@ -1,5 +1,6 @@ {{#each categories as |c|}} -
+
-{{/each}} +{{/each}} \ No newline at end of file From f5c707c97ae626b5ae1632a1e83347f5e093ac0a Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Thu, 18 Jul 2019 09:34:48 -0300 Subject: [PATCH 037/441] FEATURE: Gz to zip for exports (#7889) * Revert "Revert "FEATURE: admin/user exports are compressed using the zip format (#7784)"" This reverts commit f89bd555763bd61a130145d2eff6c2ee75fe6b06. * Replace .tar.zip with .zip --- Gemfile | 2 + Gemfile.lock | 2 + .../templates/modal/admin-install-theme.hbs | 2 +- app/controllers/admin/themes_controller.rb | 3 +- app/jobs/regular/export_csv_file.rb | 12 ++-- config/locales/client.en.yml | 2 +- config/locales/server.en.yml | 2 +- config/site_settings.yml | 2 +- lib/import_export/zip_utils.rb | 58 +++++++++++++++++++ lib/theme_store/tgz_exporter.rb | 10 +--- lib/theme_store/tgz_importer.rb | 14 ++++- .../theme_store/tgz_exporter_spec.rb | 15 +++-- .../theme_store/tgz_importer_spec.rb | 31 +++++++--- .../validators/upload_validator_spec.rb | 6 +- spec/requests/admin/themes_controller_spec.rb | 4 +- 15 files changed, 127 insertions(+), 38 deletions(-) create mode 100644 lib/import_export/zip_utils.rb diff --git a/Gemfile b/Gemfile index 6a621c456d..ffa12dfe51 100644 --- a/Gemfile +++ b/Gemfile @@ -203,6 +203,8 @@ gem "sassc-rails" gem 'rotp' gem 'rqrcode' +gem 'rubyzip', require: false + gem 'sshkey', require: false gem 'rchardet', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 898e8b7658..194ec34d1e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -351,6 +351,7 @@ GEM guess_html_encoding (>= 0.0.4) nokogiri (>= 1.6.0) ruby_dep (1.5.0) + rubyzip (1.2.3) safe_yaml (1.0.5) sanitize (5.0.0) crass (~> 1.0.2) @@ -516,6 +517,7 @@ DEPENDENCIES rubocop ruby-prof ruby-readability + rubyzip sanitize sassc sassc-rails diff --git a/app/assets/javascripts/admin/templates/modal/admin-install-theme.hbs b/app/assets/javascripts/admin/templates/modal/admin-install-theme.hbs index 153dbc9e8f..a41bfa93a9 100644 --- a/app/assets/javascripts/admin/templates/modal/admin-install-theme.hbs +++ b/app/assets/javascripts/admin/templates/modal/admin-install-theme.hbs @@ -44,7 +44,7 @@ {{#if local}}
-
+
{{i18n 'admin.customize.theme.import_file_tip'}}
{{/if}} diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb index 5389269af8..4def5bd214 100644 --- a/app/controllers/admin/themes_controller.rb +++ b/app/controllers/admin/themes_controller.rb @@ -88,7 +88,7 @@ class Admin::ThemesController < Admin::AdminController rescue RemoteTheme::ImportError => e render_json_error e.message end - elsif params[:bundle] || (params[:theme] && ["application/x-gzip", "application/gzip"].include?(params[:theme].content_type)) + elsif params[:bundle] || (params[:theme] && ["application/x-gzip", "application/gzip", "application/zip"].include?(params[:theme].content_type)) # params[:bundle] used by theme CLI. params[:theme] used by admin UI bundle = params[:bundle] || params[:theme] theme_id = params[:theme_id] @@ -252,6 +252,7 @@ class Admin::ThemesController < Admin::AdminController exporter = ThemeStore::TgzExporter.new(@theme) file_path = exporter.package_filename + headers['Content-Length'] = File.size(file_path).to_s send_data File.read(file_path), filename: File.basename(file_path), diff --git a/app/jobs/regular/export_csv_file.rb b/app/jobs/regular/export_csv_file.rb index e1b57398c1..d8d3a7204e 100644 --- a/app/jobs/regular/export_csv_file.rb +++ b/app/jobs/regular/export_csv_file.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'csv' +require 'zip' require_dependency 'system_message' require_dependency 'upload_creator' @@ -53,18 +54,19 @@ module Jobs # ensure directory exists FileUtils.mkdir_p(UserExport.base_directory) unless Dir.exists?(UserExport.base_directory) - # write to CSV file - CSV.open(absolute_path, "w") do |csv| + # Generate a compressed CSV file + csv_to_export = CSV.generate do |csv| csv << get_header if @entity != "report" public_send(export_method).each { |d| csv << d } end - # compress CSV file - system('gzip', '-5', absolute_path) + compressed_file_path = "#{absolute_path}.zip" + Zip::File.open(compressed_file_path, Zip::File::CREATE) do |zipfile| + zipfile.get_output_stream(file_name) { |f| f.puts csv_to_export } + end # create upload upload = nil - compressed_file_path = "#{absolute_path}.gz" if File.exist?(compressed_file_path) File.open(compressed_file_path) do |file| diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 4e01d15691..47f8dacca2 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3517,7 +3517,7 @@ en: delete_upload_confirm: "Delete this upload? (Theme CSS may stop working!)" import_web_tip: "Repository containing theme" import_web_advanced: "Advanced..." - import_file_tip: ".tar.gz or .dcstyle.json file containing theme" + import_file_tip: ".tar.gz, .zip, or .dcstyle.json file containing theme" is_private: "Theme is in a private git repository" remote_branch: "Branch name (optional)" public_key: "Grant the following public key access to the repo:" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 0a7a0af609..6fa93ed7b2 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -2671,7 +2671,7 @@ en: The above download link will be valid for 48 hours. - The data is compressed as a gzip archive. If the archive does not extract itself when you open it, use the tools recommended here: https://www.gzip.org/#faq4 + The data is compressed as a zip archive. If the archive does not extract itself when you open it, use the tool recommended here: https://www.7-zip.org/ csv_export_failed: title: "CSV Export Failed" diff --git a/config/site_settings.yml b/config/site_settings.yml index ebe51f6082..3b5736ccd2 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1057,7 +1057,7 @@ files: list_type: compact export_authorized_extensions: hidden: true - default: "gz" + default: "zip" type: list list_type: compact responsive_post_image_sizes: diff --git a/lib/import_export/zip_utils.rb b/lib/import_export/zip_utils.rb new file mode 100644 index 0000000000..dc06e0efb0 --- /dev/null +++ b/lib/import_export/zip_utils.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'zip' + +class ZipUtils + def zip_directory(path, export_name) + zip_filename = "#{export_name}.zip" + absolute_path = "#{path}/#{export_name}" + entries = Dir.entries(absolute_path) - %w[. ..] + + Zip::File.open(zip_filename, Zip::File::CREATE) do |zipfile| + write_entries(entries, absolute_path, '', zipfile) + end + + "#{absolute_path}.zip" + end + + def unzip_directory(path, zip_filename, allow_non_root_folder: false) + Zip::File.open(zip_filename) do |zip_file| + root = root_folder_present?(zip_file, allow_non_root_folder) ? '' : 'unzipped/' + zip_file.each do |entry| + entry_path = File.join(path, "#{root}#{entry.name}") + FileUtils.mkdir_p(File.dirname(entry_path)) + entry.extract(entry_path) + end + end + end + + private + + def root_folder_present?(filenames, allow_non_root_folder) + filenames.map { |p| p.name.split('/').first }.uniq!.size == 1 || allow_non_root_folder + end + + # A helper method to make the recursion work. + def write_entries(entries, base_path, path, zipfile) + entries.each do |e| + zipfile_path = path == '' ? e : File.join(path, e) + disk_file_path = File.join(base_path, zipfile_path) + + if File.directory? disk_file_path + recursively_deflate_directory(disk_file_path, zipfile, base_path, zipfile_path) + else + put_into_archive(disk_file_path, zipfile, zipfile_path) + end + end + end + + def recursively_deflate_directory(disk_file_path, zipfile, base_path, zipfile_path) + zipfile.mkdir zipfile_path + subdir = Dir.entries(disk_file_path) - %w[. ..] + write_entries subdir, base_path, zipfile_path, zipfile + end + + def put_into_archive(disk_file_path, zipfile, zipfile_path) + zipfile.add(zipfile_path, disk_file_path) + end +end diff --git a/lib/theme_store/tgz_exporter.rb b/lib/theme_store/tgz_exporter.rb index 824a874ab9..75f5bff896 100644 --- a/lib/theme_store/tgz_exporter.rb +++ b/lib/theme_store/tgz_exporter.rb @@ -56,14 +56,10 @@ class ThemeStore::TgzExporter end private + def export_package export_to_folder - Dir.chdir(@temp_folder) do - tar_filename = "#{@export_name}.tar" - Discourse::Utils.execute_command('tar', '--create', '--file', tar_filename, @export_name, failure_message: "Failed to tar theme.") - Discourse::Utils.execute_command('gzip', '-5', tar_filename, failure_message: "Failed to gzip archive.") - "#{@temp_folder}/#{tar_filename}.gz" - end - end + Dir.chdir(@temp_folder) { ZipUtils.new.zip_directory(@temp_folder, @export_name) } + end end diff --git a/lib/theme_store/tgz_importer.rb b/lib/theme_store/tgz_importer.rb index 5dfb0716e6..f0c88a4d1f 100644 --- a/lib/theme_store/tgz_importer.rb +++ b/lib/theme_store/tgz_importer.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'import_export/zip_utils' + module ThemeStore; end class ThemeStore::TgzImporter @@ -13,8 +15,16 @@ class ThemeStore::TgzImporter def import! FileUtils.mkdir(@temp_folder) + Dir.chdir(@temp_folder) do - Discourse::Utils.execute_command("tar", "-xzvf", @filename, "--strip", "1") + if @filename.include?('.zip') + ZipUtils.new.unzip_directory(@temp_folder, @filename) + + # --strip 1 equivalent + FileUtils.mv(Dir.glob("#{@temp_folder}/*/*"), @temp_folder) + else + Discourse::Utils.execute_command("tar", "-xzvf", @filename, "--strip", "1") + end end rescue RuntimeError raise RemoteTheme::ImportError, I18n.t("themes.import_error.unpack_failed") @@ -44,7 +54,7 @@ class ThemeStore::TgzImporter def all_files Dir.chdir(@temp_folder) do - Dir.glob("**/*").reject { |f| File.directory?(f) } + Dir.glob("**/**").reject { |f| File.directory?(f) } end end diff --git a/spec/components/theme_store/tgz_exporter_spec.rb b/spec/components/theme_store/tgz_exporter_spec.rb index b66bf6e1b0..18266eeb26 100644 --- a/spec/components/theme_store/tgz_exporter_spec.rb +++ b/spec/components/theme_store/tgz_exporter_spec.rb @@ -2,6 +2,7 @@ require 'rails_helper' require 'theme_store/tgz_exporter' +require 'import_export/zip_utils' describe ThemeStore::TgzExporter do let!(:theme) do @@ -55,15 +56,17 @@ describe ThemeStore::TgzExporter do filename = exporter.package_filename FileUtils.cp(filename, dir) exporter.cleanup! - "#{dir}/discourse-header-icons.tar.gz" + "#{dir}/discourse-header-icons.zip" end it "exports the theme correctly" do package - Dir.chdir("#{dir}") do - `tar -xzf discourse-header-icons.tar.gz` - end - Dir.chdir("#{dir}/discourse-header-icons") do + file = 'discourse-header-icons.zip' + dest = 'discourse-header-icons' + Dir.chdir(dir) do + ZipUtils.new.unzip_directory(dir, file, allow_non_root_folder: true) + `rm #{file}` + folders = Dir.glob("**/*").reject { |f| File.file?(f) } expect(folders).to contain_exactly("assets", "common", "locales", "mobile") @@ -121,7 +124,7 @@ describe ThemeStore::TgzExporter do exporter = ThemeStore::TgzExporter.new(theme) filename = exporter.package_filename exporter.cleanup! - expect(filename).to end_with "/discourse-header-icons.tar.gz" + expect(filename).to end_with "/discourse-header-icons.zip" end end diff --git a/spec/components/theme_store/tgz_importer_spec.rb b/spec/components/theme_store/tgz_importer_spec.rb index 2986b1d2c9..0aad92584e 100644 --- a/spec/components/theme_store/tgz_importer_spec.rb +++ b/spec/components/theme_store/tgz_importer_spec.rb @@ -4,26 +4,41 @@ require 'rails_helper' require 'theme_store/tgz_importer' +require 'import_export/zip_utils' describe ThemeStore::TgzImporter do before do @temp_folder = "#{Pathname.new(Dir.tmpdir).realpath}/discourse_theme_#{SecureRandom.hex}" + + FileUtils.mkdir(@temp_folder) + Dir.chdir(@temp_folder) do + FileUtils.mkdir_p('test/a') + File.write("test/hello.txt", "hello world") + File.write("test/a/inner", "hello world inner") + end end after do FileUtils.rm_rf @temp_folder end - it "can import a simple theme" do - - FileUtils.mkdir(@temp_folder) - + it "can import a simple zipped theme" do Dir.chdir(@temp_folder) do - FileUtils.mkdir('test/') - File.write("test/hello.txt", "hello world") - FileUtils.mkdir('test/a') - File.write("test/a/inner", "hello world inner") + ZipUtils.new.zip_directory(@temp_folder, 'test') + FileUtils.rm_rf('test/') + end + importer = ThemeStore::TgzImporter.new("#{@temp_folder}/test.zip") + importer.import! + + expect(importer["hello.txt"]).to eq("hello world") + expect(importer["a/inner"]).to eq("hello world inner") + + importer.cleanup! + end + + it "can import a simple gzipped theme" do + Dir.chdir(@temp_folder) do `tar -cvzf test.tar.gz test/* 2> /dev/null` end diff --git a/spec/components/validators/upload_validator_spec.rb b/spec/components/validators/upload_validator_spec.rb index e9a3dfe601..9348c85e79 100644 --- a/spec/components/validators/upload_validator_spec.rb +++ b/spec/components/validators/upload_validator_spec.rb @@ -22,14 +22,14 @@ describe Validators::UploadValidator do it "allows 'gz' as extension when uploading export file" do SiteSetting.authorized_extensions = "" - expect(UploadCreator.new(csv_file, "#{filename}.gz", for_export: true).create_for(user.id)).to be_valid + expect(UploadCreator.new(csv_file, "#{filename}.zip", for_export: true).create_for(user.id)).to be_valid end it "allows uses max_export_file_size_kb when uploading export file" do SiteSetting.max_attachment_size_kb = "0" - SiteSetting.authorized_extensions = "gz" + SiteSetting.authorized_extensions = "zip" - expect(UploadCreator.new(csv_file, "#{filename}.gz", for_export: true).create_for(user.id)).to be_valid + expect(UploadCreator.new(csv_file, "#{filename}.zip", for_export: true).create_for(user.id)).to be_valid end describe 'when allow_staff_to_upload_any_file_in_pm is true' do diff --git a/spec/requests/admin/themes_controller_spec.rb b/spec/requests/admin/themes_controller_spec.rb index 4f32a63e01..43cc16cd8b 100644 --- a/spec/requests/admin/themes_controller_spec.rb +++ b/spec/requests/admin/themes_controller_spec.rb @@ -51,10 +51,10 @@ describe Admin::ThemesController do expect(response.status).to eq(200) # Save the output in a temp file (automatically cleaned up) - file = Tempfile.new('archive.tar.gz') + file = Tempfile.new('archive.tar.zip') file.write(response.body) file.rewind - uploaded_file = Rack::Test::UploadedFile.new(file.path, "application/x-gzip") + uploaded_file = Rack::Test::UploadedFile.new(file.path, "application/zip") # Now import it again expect do From d841bff9f8630ecb7ed711907022ff15f8d45d38 Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 18 Jul 2019 09:45:09 -0400 Subject: [PATCH 038/441] remove whitespace from twitter onebox --- app/assets/stylesheets/common/base/onebox.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index 64e891c941..0696e4bf51 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -454,7 +454,6 @@ aside.onebox.twitterstatus .onebox-body { } .date { clear: left; - padding-top: 10px; } } From 194a2b612f69dc16f1094e40fe7128f8a0898df4 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Thu, 18 Jul 2019 11:07:02 -0400 Subject: [PATCH 039/441] FIX: string that can't be translated in watched words UI --- .../admin/templates/components/watched-word-uploader.hbs | 2 +- config/locales/client.en.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/admin/templates/components/watched-word-uploader.hbs b/app/assets/javascripts/admin/templates/components/watched-word-uploader.hbs index d7c7f2a572..4042bc5c82 100644 --- a/app/assets/javascripts/admin/templates/components/watched-word-uploader.hbs +++ b/app/assets/javascripts/admin/templates/components/watched-word-uploader.hbs @@ -4,4 +4,4 @@
-One word per line +{{i18n 'admin.watched_words.one_word_per_line'}} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 47f8dacca2..032cbe7f36 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3865,6 +3865,7 @@ en: search: "search" clear_filter: "Clear" show_words: "show words" + one_word_per_line: "One word per line" word_count: one: "%{count} word" other: "%{count} words" From 95ad4f9077610739de1fd58a834f37a8fe6f3db7 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 18 Jul 2019 17:29:41 +0200 Subject: [PATCH 040/441] FEATURE: new date/time components (#7898) --- .../discourse/components/date-input.js.es6 | 101 +++++++++++++++++ .../components/date-time-input-range.js.es6 | 51 +++++++++ .../components/date-time-input.js.es6 | 35 ++++++ .../discourse/components/time-input.js.es6 | 73 +++++++++++++ .../templates/components/date-input.hbs | 5 + .../components/date-time-input-range.hbs | 34 ++++++ .../templates/components/date-time-input.hbs | 9 ++ .../templates/components/time-input.hbs | 40 +++++++ app/assets/javascripts/polyfills.js | 36 +++++++ .../common/components/date-input.scss | 13 +++ .../components/date-time-input-range.scss | 42 ++++++++ .../common/components/date-time-input.scss | 15 +++ .../common/components/time-input.scss | 39 +++++++ config/locales/client.en.yml | 6 ++ .../components/date-input-test.js.es6 | 65 +++++++++++ .../date-time-input-range-test.js.es6 | 102 ++++++++++++++++++ .../components/date-time-input-test.js.es6 | 84 +++++++++++++++ .../components/time-input-test.js.es6 | 97 +++++++++++++++++ 18 files changed, 847 insertions(+) create mode 100644 app/assets/javascripts/discourse/components/date-input.js.es6 create mode 100644 app/assets/javascripts/discourse/components/date-time-input-range.js.es6 create mode 100644 app/assets/javascripts/discourse/components/date-time-input.js.es6 create mode 100644 app/assets/javascripts/discourse/components/time-input.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/components/date-input.hbs create mode 100644 app/assets/javascripts/discourse/templates/components/date-time-input-range.hbs create mode 100644 app/assets/javascripts/discourse/templates/components/date-time-input.hbs create mode 100644 app/assets/javascripts/discourse/templates/components/time-input.hbs create mode 100644 app/assets/stylesheets/common/components/date-input.scss create mode 100644 app/assets/stylesheets/common/components/date-time-input-range.scss create mode 100644 app/assets/stylesheets/common/components/date-time-input.scss create mode 100644 app/assets/stylesheets/common/components/time-input.scss create mode 100644 test/javascripts/components/date-input-test.js.es6 create mode 100644 test/javascripts/components/date-time-input-range-test.js.es6 create mode 100644 test/javascripts/components/date-time-input-test.js.es6 create mode 100644 test/javascripts/components/time-input-test.js.es6 diff --git a/app/assets/javascripts/discourse/components/date-input.js.es6 b/app/assets/javascripts/discourse/components/date-input.js.es6 new file mode 100644 index 0000000000..d29962c02c --- /dev/null +++ b/app/assets/javascripts/discourse/components/date-input.js.es6 @@ -0,0 +1,101 @@ +/* global Pikaday:true */ +import loadScript from "discourse/lib/load-script"; +import { + default as computed, + on +} from "ember-addons/ember-computed-decorators"; + +export default Ember.Component.extend({ + classNames: ["d-date-input"], + date: null, + _picker: null, + + @computed("site.mobileView") + inputType(mobileView) { + return mobileView ? "date" : "text"; + }, + + @on("didInsertElement") + _loadDatePicker() { + const container = this.element.querySelector(`#${this.containerId}`); + + if (this.site.mobileView) { + this._loadNativePicker(container); + } else { + this._loadPikadayPicker(container); + } + }, + + didUpdateAttrs() { + this._super(...arguments); + + if (this._picker) { + this._picker.setDate(this.date, true); + } + }, + + _loadPikadayPicker(container) { + loadScript("/javascripts/pikaday.js").then(() => { + Ember.run.next(() => { + const default_opts = { + field: this.element.querySelector(".date-picker"), + container: container || this.element, + bound: container === null, + format: "LL", + firstDay: 1, + i18n: { + previousMonth: I18n.t("dates.previous_month"), + nextMonth: I18n.t("dates.next_month"), + months: moment.months(), + weekdays: moment.weekdays(), + weekdaysShort: moment.weekdaysShort() + }, + onSelect: date => this._handleSelection(date) + }; + + this._picker = new Pikaday(Object.assign(default_opts, this._opts())); + this._picker.setDate(this.date, true); + }); + }); + }, + + _loadNativePicker(container) { + const wrapper = container || this.element; + const picker = wrapper.querySelector("input.date-picker"); + picker.onchange = () => this._handleSelection(picker.value); + picker.hide = () => { + /* do nothing for native */ + }; + picker.destroy = () => { + /* do nothing for native */ + }; + this._picker = picker; + }, + + _handleSelection(value) { + if (!this.element || this.isDestroying || this.isDestroyed) return; + + this._picker && this._picker.hide(); + + if (this.onChange) { + this.onChange(moment(value).toDate()); + } + }, + + @on("willDestroyElement") + _destroy() { + if (this._picker) { + this._picker.destroy(); + } + this._picker = null; + }, + + @computed() + placeholder() { + return I18n.t("dates.placeholder"); + }, + + _opts() { + return null; + } +}); diff --git a/app/assets/javascripts/discourse/components/date-time-input-range.js.es6 b/app/assets/javascripts/discourse/components/date-time-input-range.js.es6 new file mode 100644 index 0000000000..3754be93cd --- /dev/null +++ b/app/assets/javascripts/discourse/components/date-time-input-range.js.es6 @@ -0,0 +1,51 @@ +export default Ember.Component.extend({ + classNames: ["d-date-time-input-range"], + + from: null, + to: null, + onChangeTo: null, + onChangeFrom: null, + currentPanel: "from", + showFromTime: true, + showToTime: true, + error: null, + + fromPanelActive: Ember.computed.equal("currentPanel", "from"), + toPanelActive: Ember.computed.equal("currentPanel", "to"), + + _valid(state) { + if (state.to < state.from) { + return I18n.t("date_time_picker.errors.to_before_from"); + } + + return true; + }, + + actions: { + _onChange(options, value) { + if (this.onChange) { + const state = { + from: this.from, + to: this.to + }; + + const diff = {}; + diff[options.prop] = value; + + const newState = Object.assign(state, diff); + + const validation = this._valid(newState); + if (validation === true) { + this.set("error", null); + this.onChange(newState); + } else { + this.set("error", validation); + } + } + }, + + onChangePanel(panel) { + this.set("currentPanel", panel); + } + } +}); diff --git a/app/assets/javascripts/discourse/components/date-time-input.js.es6 b/app/assets/javascripts/discourse/components/date-time-input.js.es6 new file mode 100644 index 0000000000..ce173e3d42 --- /dev/null +++ b/app/assets/javascripts/discourse/components/date-time-input.js.es6 @@ -0,0 +1,35 @@ +export default Ember.Component.extend({ + classNames: ["d-date-time-input"], + date: null, + showTime: true, + + _hours: Ember.computed("date", function() { + return this.date ? this.date.getHours() : null; + }), + + _minutes: Ember.computed("date", function() { + return this.date ? this.date.getMinutes() : null; + }), + + actions: { + onChangeTime(time) { + if (this.onChange) { + const year = this.date.getFullYear(); + const month = this.date.getMonth(); + const day = this.date.getDate(); + this.onChange(new Date(year, month, day, time.hours, time.minutes)); + } + }, + + onChangeDate(date) { + if (this.onChange) { + const year = date.getFullYear(); + const month = date.getMonth(); + const day = date.getDate(); + this.onChange( + new Date(year, month, day, this._hours || 0, this._minutes || 0) + ); + } + } + } +}); diff --git a/app/assets/javascripts/discourse/components/time-input.js.es6 b/app/assets/javascripts/discourse/components/time-input.js.es6 new file mode 100644 index 0000000000..bdeb1b3a72 --- /dev/null +++ b/app/assets/javascripts/discourse/components/time-input.js.es6 @@ -0,0 +1,73 @@ +import { isNumeric } from "discourse/lib/utilities"; + +export default Ember.Component.extend({ + classNames: ["d-time-input"], + hours: null, + minutes: null, + _hours: Ember.computed.oneWay("hours"), + _minutes: Ember.computed.oneWay("minutes"), + isSafari: Ember.computed.oneWay("capabilities.isSafari"), + isMobile: Ember.computed.oneWay("site.mobileView"), + nativePicker: Ember.computed.or("isSafari", "isMobile"), + + actions: { + onInput(options, event) { + event.preventDefault(); + + if (this.onChange) { + let value = event.target.value; + + if (!isNumeric(value)) { + value = 0; + } else { + value = parseInt(value, 10); + } + + if (options.prop === "hours") { + value = Math.max(0, Math.min(value, 23)) + .toString() + .padStart(2, "0"); + this._processHoursChange(value); + } else { + value = Math.max(0, Math.min(value, 59)) + .toString() + .padStart(2, "0"); + this._processMinutesChange(value); + } + + Ember.run.schedule("afterRender", () => (event.target.value = value)); + } + }, + + onFocusIn(value, event) { + if (value && event.target) { + event.target.select(); + } + }, + + onChangeTime(event) { + const time = event.target.value; + + if (time && this.onChange) { + this.onChange({ + hours: time.split(":")[0], + minutes: time.split(":")[1] + }); + } + } + }, + + _processHoursChange(hours) { + this.onChange({ + hours, + minutes: this._minutes || "00" + }); + }, + + _processMinutesChange(minutes) { + this.onChange({ + hours: this._hours || "00", + minutes + }); + } +}); diff --git a/app/assets/javascripts/discourse/templates/components/date-input.hbs b/app/assets/javascripts/discourse/templates/components/date-input.hbs new file mode 100644 index 0000000000..e29eb0e73a --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/date-input.hbs @@ -0,0 +1,5 @@ +{{input + type=inputType + class="date-picker" + placeholder=placeholder + value=value}} diff --git a/app/assets/javascripts/discourse/templates/components/date-time-input-range.hbs b/app/assets/javascripts/discourse/templates/components/date-time-input-range.hbs new file mode 100644 index 0000000000..e4476ae948 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/date-time-input-range.hbs @@ -0,0 +1,34 @@ +
    +
  • + {{d-button + label="date_time_picker.from" + class="from-panel" + action=(action "onChangePanel" "from")}} +
  • +
  • + {{d-button + label="date_time_picker.to" + class="to-panel" + action=(action "onChangePanel" "to")}} +
  • +
+ +{{#if error}} +
{{error}}
+{{/if}} + +
+ {{date-time-input + date=from + onChange=(action "_onChange" (hash prop="from")) + showTime=showFromTime + }} +
+ +
+ {{date-time-input + date=to + onChange=(action "_onChange" (hash prop="to")) + showTime=showToTime + }} +
diff --git a/app/assets/javascripts/discourse/templates/components/date-time-input.hbs b/app/assets/javascripts/discourse/templates/components/date-time-input.hbs new file mode 100644 index 0000000000..a2da31c28c --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/date-time-input.hbs @@ -0,0 +1,9 @@ +{{date-input date=date onChange=(action "onChangeDate")}} + +{{#if showTime}} + {{time-input + hours=_hours + minutes=_minutes + onChange=(action "onChangeTime") + }} +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/time-input.hbs b/app/assets/javascripts/discourse/templates/components/time-input.hbs new file mode 100644 index 0000000000..51ada91bf6 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/time-input.hbs @@ -0,0 +1,40 @@ +
+ {{#if nativePicker}} + {{input + class="field time" + type="time" + value=(concat _hours ":" _minutes) + change=(action "onChangeTime") + }} + {{else}} + {{input + class="field hours" + type="number" + title="Hours" + minlength=2 + maxlength=2 + max="23" + min="0" + placeholder="00" + value=_hours + input=(action "onInput" (hash prop="hours")) + focus-in=(action "onFocusIn") + }} + +
:
+ + {{input + class="field minutes" + title="Minutes" + type="number" + minlength=2 + maxlength=2 + max="59" + min="0" + placeholder="00" + value=_minutes + input=(action "onInput" (hash prop="minutes")) + focus-in=(action "onFocusIn") + }} + {{/if}} +
diff --git a/app/assets/javascripts/polyfills.js b/app/assets/javascripts/polyfills.js index ab97aebb35..5c115208f7 100644 --- a/app/assets/javascripts/polyfills.js +++ b/app/assets/javascripts/polyfills.js @@ -189,4 +189,40 @@ if (RegExp.prototype.flags === undefined) { }); } +// https://github.com/uxitten/polyfill/blob/master/string.polyfill.js +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padStart +if (!String.prototype.padStart) { + String.prototype.padStart = function padStart(targetLength, padString) { + targetLength = targetLength >> 0; //truncate if number, or convert non-number to 0; + padString = String(typeof padString !== "undefined" ? padString : " "); + if (this.length >= targetLength) { + return String(this); + } else { + targetLength = targetLength - this.length; + if (targetLength > padString.length) { + padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed + } + return padString.slice(0, targetLength) + String(this); + } + }; +} + +// https://github.com/uxitten/polyfill/blob/master/string.polyfill.js +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/padEnd +if (!String.prototype.padEnd) { + String.prototype.padEnd = function padEnd(targetLength, padString) { + targetLength = targetLength >> 0; //floor if number or convert non-number to 0; + padString = String(typeof padString !== "undefined" ? padString : " "); + if (this.length > targetLength) { + return String(this); + } else { + targetLength = targetLength - this.length; + if (targetLength > padString.length) { + padString += padString.repeat(targetLength / padString.length); //append to original to ensure we are longer than needed + } + return String(this) + padString.slice(0, targetLength); + } + }; +} + /* eslint-enable */ diff --git a/app/assets/stylesheets/common/components/date-input.scss b/app/assets/stylesheets/common/components/date-input.scss new file mode 100644 index 0000000000..583039a001 --- /dev/null +++ b/app/assets/stylesheets/common/components/date-input.scss @@ -0,0 +1,13 @@ +.d-date-input { + .date-picker { + margin: 0; + text-align: left; + width: 100%; + outline: none; + box-shadow: none !important; + } + + .pika-single { + margin-left: -1px; + } +} diff --git a/app/assets/stylesheets/common/components/date-time-input-range.scss b/app/assets/stylesheets/common/components/date-time-input-range.scss new file mode 100644 index 0000000000..438de7742e --- /dev/null +++ b/app/assets/stylesheets/common/components/date-time-input-range.scss @@ -0,0 +1,42 @@ +.d-date-time-input-range { + padding: 0.5em; + background: whitesmole; + border: 1px solid $primary-low; + width: 300px; + display: flex; + flex-direction: column; + + .panels { + display: inline-flex; + list-style: none; + margin: 0 0 0.5em 0; + flex: 1; + + &.from { + .from-panel { + background: $danger; + color: $secondary; + } + } + + &.to { + .to-panel { + background: $danger; + color: $secondary; + } + } + + .btn { + margin-right: 0.5em; + } + } + + .panel { + display: none; + flex: 1; + + &.visible { + display: flex; + } + } +} diff --git a/app/assets/stylesheets/common/components/date-time-input.scss b/app/assets/stylesheets/common/components/date-time-input.scss new file mode 100644 index 0000000000..634bc21961 --- /dev/null +++ b/app/assets/stylesheets/common/components/date-time-input.scss @@ -0,0 +1,15 @@ +.d-date-time-input { + display: flex; + align-items: center; + border: 1px solid $primary-low; + width: 258px; + box-sizing: border-box; + position: relative; + flex: 1; + justify-content: space-between; + + .date-picker, + .fields { + border: 0; + } +} diff --git a/app/assets/stylesheets/common/components/time-input.scss b/app/assets/stylesheets/common/components/time-input.scss new file mode 100644 index 0000000000..d11e2b7c7d --- /dev/null +++ b/app/assets/stylesheets/common/components/time-input.scss @@ -0,0 +1,39 @@ +.d-time-input { + box-sizing: border-box; + + .fields { + display: flex; + align-items: center; + border: 1px solid $primary-low; + + .field { + text-align: center; + width: auto; + margin: 0; + border: none; + outline: none; + box-shadow: none; + width: 32px; + + &.time { + width: 100%; + text-align: left; + } + + &.hours, + &.minutes { + text-align: center; + width: 45px; + } + + &.hours { + padding-right: 0; + } + + &.minutes { + padding-left: 10px; + width: 55px; + } + } + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 032cbe7f36..a728f185d2 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1518,6 +1518,12 @@ en: one: "Select at least {{count}} item." other: "Select at least {{count}} items." + date_time_picker: + from: From + to: To + errors: + to_before_from: "To date must be later than from date." + emoji_picker: filter_placeholder: Search for emoji smileys_&_emotion: Smileys and Emotion diff --git a/test/javascripts/components/date-input-test.js.es6 b/test/javascripts/components/date-input-test.js.es6 new file mode 100644 index 0000000000..64686aba06 --- /dev/null +++ b/test/javascripts/components/date-input-test.js.es6 @@ -0,0 +1,65 @@ +import componentTest from "helpers/component-test"; + +moduleForComponent("date-input", { integration: true }); + +function dateInput() { + return find(".date-picker"); +} + +function setDate(date) { + this.set("date", date); +} + +async function pika(year, month, day) { + await click( + `.pika-button.pika-day[data-pika-year="${year}"][data-pika-month="${month}"][data-pika-day="${day}"]` + ); +} + +function noop() {} + +const DEFAULT_DATE = new Date(2019, 0, 29); + +componentTest("default", { + template: `{{date-input date=date}}`, + + beforeEach() { + this.setProperties({ date: DEFAULT_DATE }); + }, + + test(assert) { + assert.equal(dateInput().val(), "January 29, 2019"); + } +}); + +componentTest("prevents mutations", { + template: `{{date-input date=date onChange=onChange}}`, + + beforeEach() { + this.setProperties({ date: DEFAULT_DATE }); + this.set("onChange", noop); + }, + + async test(assert) { + await click(dateInput()); + await pika(2019, 0, 2); + + assert.ok(this.date.getTime() === DEFAULT_DATE.getTime()); + } +}); + +componentTest("allows mutations through actions", { + template: `{{date-input date=date onChange=onChange}}`, + + beforeEach() { + this.setProperties({ date: DEFAULT_DATE }); + this.set("onChange", setDate); + }, + + async test(assert) { + await click(dateInput()); + await pika(2019, 0, 2); + + assert.ok(this.date.getTime() === new Date(2019, 0, 2).getTime()); + } +}); diff --git a/test/javascripts/components/date-time-input-range-test.js.es6 b/test/javascripts/components/date-time-input-range-test.js.es6 new file mode 100644 index 0000000000..8176943b99 --- /dev/null +++ b/test/javascripts/components/date-time-input-range-test.js.es6 @@ -0,0 +1,102 @@ +import componentTest from "helpers/component-test"; + +moduleForComponent("date-time-input-range", { integration: true }); + +function fromDateInput() { + return find(".from .date-picker"); +} + +function fromHoursInput() { + return find(".from .field.hours"); +} + +function fromMinutesInput() { + return find(".from .field.minutes"); +} + +function toDateInput() { + return find(".to .date-picker"); +} + +function toHoursInput() { + return find(".to .field.hours"); +} + +function toMinutesInput() { + return find(".to .field.minutes"); +} + +function setDates(dates) { + this.setProperties(dates); +} + +async function pika(year, month, day) { + await click( + `.pika-button.pika-day[data-pika-year="${year}"][data-pika-month="${month}"][data-pika-day="${day}"]` + ); +} + +const DEFAULT_DATE_TIME = new Date(2019, 0, 29, 14, 45); + +componentTest("default", { + template: `{{date-time-input-range from=date to=to}}`, + + beforeEach() { + this.setProperties({ date: DEFAULT_DATE_TIME, to: null }); + }, + + test(assert) { + assert.equal(fromDateInput().val(), "January 29, 2019"); + assert.equal(fromHoursInput().val(), "14"); + assert.equal(fromMinutesInput().val(), "45"); + + assert.equal(toDateInput().val(), ""); + assert.equal(toHoursInput().val(), ""); + assert.equal(toMinutesInput().val(), ""); + } +}); + +componentTest("can switch panels", { + template: `{{date-time-input-range}}`, + + async test(assert) { + assert.ok(exists(".panel.from.visible")); + assert.notOk(exists(".panel.to.visible")); + + await click(".panels .to-panel"); + + assert.ok(exists(".panel.to.visible")); + assert.notOk(exists(".panel.from.visible")); + } +}); + +componentTest("prevents toDate to be before fromDate", { + template: `{{date-time-input-range from=from to=to onChange=onChange}}`, + + beforeEach() { + this.setProperties({ + from: DEFAULT_DATE_TIME, + to: DEFAULT_DATE_TIME, + onChange: setDates + }); + }, + + async test(assert) { + assert.notOk(exists(".error")); + + await click(toDateInput()); + await pika(2019, 0, 1); + + assert.ok(exists(".error")); + assert.ok( + this.to.getTime() === DEFAULT_DATE_TIME.getTime(), + "it didnt trigger a mutation" + ); + + await click(toDateInput()); + await pika(2019, 0, 30); + + assert.notOk(exists(".error")); + assert.ok(this.to.getTime() === new Date(2019, 0, 30, 14, 45).getTime()); + } +}); diff --git a/test/javascripts/components/date-time-input-test.js.es6 b/test/javascripts/components/date-time-input-test.js.es6 new file mode 100644 index 0000000000..2dd4ac8fcf --- /dev/null +++ b/test/javascripts/components/date-time-input-test.js.es6 @@ -0,0 +1,84 @@ +import componentTest from "helpers/component-test"; + +moduleForComponent("date-time-input", { integration: true }); + +function dateInput() { + return find(".date-picker"); +} + +function hoursInput() { + return find(".field.hours"); +} + +function minutesInput() { + return find(".field.minutes"); +} + +function setDate(date) { + this.set("date", date); +} + +async function pika(year, month, day) { + await click( + `.pika-button.pika-day[data-pika-year="${year}"][data-pika-month="${month}"][data-pika-day="${day}"]` + ); +} + +const DEFAULT_DATE_TIME = new Date(2019, 0, 29, 14, 45); + +componentTest("default", { + template: `{{date-time-input date=date}}`, + + beforeEach() { + this.setProperties({ date: DEFAULT_DATE_TIME }); + }, + + test(assert) { + assert.equal(dateInput().val(), "January 29, 2019"); + assert.equal(hoursInput().val(), "14"); + assert.equal(minutesInput().val(), "45"); + } +}); + +componentTest("prevents mutations", { + template: `{{date-time-input date=date}}`, + + beforeEach() { + this.setProperties({ date: DEFAULT_DATE_TIME }); + }, + + async test(assert) { + await click(dateInput()); + await pika(2019, 0, 2); + + assert.ok(this.date.getTime() === DEFAULT_DATE_TIME.getTime()); + } +}); + +componentTest("allows mutations through actions", { + template: `{{date-time-input date=date onChange=onChange}}`, + + beforeEach() { + this.setProperties({ date: DEFAULT_DATE_TIME }); + this.set("onChange", setDate); + }, + + async test(assert) { + await click(dateInput()); + await pika(2019, 0, 2); + + assert.ok(this.date.getTime() === new Date(2019, 0, 2, 14, 45).getTime()); + } +}); + +componentTest("can hide time", { + template: `{{date-time-input date=date showTime=false}}`, + + beforeEach() { + this.setProperties({ date: DEFAULT_DATE_TIME }); + }, + + async test(assert) { + assert.notOk(exists(hoursInput())); + } +}); diff --git a/test/javascripts/components/time-input-test.js.es6 b/test/javascripts/components/time-input-test.js.es6 new file mode 100644 index 0000000000..edbe7574c8 --- /dev/null +++ b/test/javascripts/components/time-input-test.js.es6 @@ -0,0 +1,97 @@ +import componentTest from "helpers/component-test"; + +moduleForComponent("time-input", { integration: true }); + +function hoursInput() { + return find(".field.hours"); +} + +function minutesInput() { + return find(".field.minutes"); +} + +function setTime(time) { + this.setProperties(time); +} + +function noop() {} + +componentTest("default", { + template: `{{time-input hours=hours minutes=minutes}}`, + + beforeEach() { + this.setProperties({ hours: "14", minutes: "58" }); + }, + + test(assert) { + assert.equal(hoursInput().val(), "14"); + assert.equal(minutesInput().val(), "58"); + } +}); + +componentTest("prevents mutations", { + template: `{{time-input hours=hours minutes=minutes}}`, + + beforeEach() { + this.setProperties({ hours: "14", minutes: "58" }); + }, + + async test(assert) { + await fillIn(hoursInput(), "12"); + assert.ok(this.hours === "14"); + + await fillIn(minutesInput(), "36"); + assert.ok(this.minutes === "58"); + } +}); + +componentTest("allows mutations through actions", { + template: `{{time-input hours=hours minutes=minutes onChange=onChange}}`, + + beforeEach() { + this.setProperties({ hours: "14", minutes: "58" }); + this.set("onChange", setTime); + }, + + async test(assert) { + await fillIn(hoursInput(), "12"); + assert.ok(this.hours === "12"); + + await fillIn(minutesInput(), "36"); + assert.ok(this.minutes === "36"); + } +}); + +componentTest("hours and minutes have boundaries", { + template: `{{time-input hours=14 minutes=58 onChange=onChange}}`, + + beforeEach() { + this.set("onChange", noop); + }, + + async test(assert) { + await fillIn(hoursInput(), "2"); + assert.equal(hoursInput().val(), "02"); + + await fillIn(hoursInput(), "@"); + assert.equal(hoursInput().val(), "00"); + + await fillIn(hoursInput(), "24"); + assert.equal(hoursInput().val(), "23"); + + await fillIn(hoursInput(), "-1"); + assert.equal(hoursInput().val(), "00"); + + await fillIn(minutesInput(), "@"); + assert.equal(minutesInput().val(), "00"); + + await fillIn(minutesInput(), "2"); + assert.equal(minutesInput().val(), "02"); + + await fillIn(minutesInput(), "60"); + assert.equal(minutesInput().val(), "59"); + + await fillIn(minutesInput(), "-1"); + assert.equal(minutesInput().val(), "00"); + } +}); From 0f1a0b45de29188443a85346ad4fd8eb39ea9129 Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 18 Jul 2019 11:36:24 -0400 Subject: [PATCH 041/441] UX: Fix profile image upload control spacing on mobile --- app/assets/stylesheets/common/base/upload.scss | 2 ++ app/assets/stylesheets/desktop/upload.scss | 7 ------- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/app/assets/stylesheets/common/base/upload.scss b/app/assets/stylesheets/common/base/upload.scss index 8eebba747a..c7b010f302 100644 --- a/app/assets/stylesheets/common/base/upload.scss +++ b/app/assets/stylesheets/common/base/upload.scss @@ -18,9 +18,11 @@ .image-upload-controls { position: relative; display: flex; + padding: 10px; .btn { margin-right: 5px; + padding: 7px 10px; } .image-uploader-lightbox-btn { diff --git a/app/assets/stylesheets/desktop/upload.scss b/app/assets/stylesheets/desktop/upload.scss index e48bc79641..35799258f3 100644 --- a/app/assets/stylesheets/desktop/upload.scss +++ b/app/assets/stylesheets/desktop/upload.scss @@ -48,10 +48,3 @@ max-height: 150px; margin-bottom: 10px; } - -.image-upload-controls { - padding: 10px; - label.btn { - padding: 7px 10px; - } -} From cb84133855ee7ad5069b7731e25da5efffa776a2 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 18 Jul 2019 18:16:29 +0200 Subject: [PATCH 042/441] FIX: bugs preventing to close delete account modal with button (#7904) --- .../discourse/controllers/preferences/account.js.es6 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 index 2c4a2d1b1e..543c3a9ee0 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 @@ -173,7 +173,9 @@ export default Ember.Controller.extend( label: I18n.t("cancel"), class: "d-modal-cancel", link: true, - callback: () => this.set("deleting", false) + callback: () => { + this.set("deleting", false); + } }, { label: From 5e50a24d3a13fc415e9992edcdc05057351b957f Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 18 Jul 2019 12:44:35 -0400 Subject: [PATCH 043/441] Fix typo It's jump-to not jumpt-to --- .../javascripts/discourse/templates/modal/jump-to-post.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/templates/modal/jump-to-post.hbs b/app/assets/javascripts/discourse/templates/modal/jump-to-post.hbs index f8f8ea1cc2..05827c9bbb 100644 --- a/app/assets/javascripts/discourse/templates/modal/jump-to-post.hbs +++ b/app/assets/javascripts/discourse/templates/modal/jump-to-post.hbs @@ -1,6 +1,6 @@ {{#d-modal-body title="topic.progress.jump_prompt_long"}} -
+
# {{input id="post-jump" type="number" value=postNumber insert-newline=(action "jump")}} From 617c74bc79d4a3277af1401c19a8200c295f1e5a Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 18 Jul 2019 19:28:23 +0200 Subject: [PATCH 044/441] DEV: remove .property() deprecations (#7906) More context at https://deprecations.emberjs.com/v3.x#toc_function-prototype-extensions-property --- .../admin/controllers/admin-dashboard-general.js.es6 | 4 ++-- .../javascripts/discourse/controllers/history.js.es6 | 4 ++-- app/assets/javascripts/discourse/lib/computed.js.es6 | 12 ++++-------- .../discourse/mixins/buffered-content.js.es6 | 4 ++-- .../discourse/widgets/post-small-action.js.es6 | 4 ++-- 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard-general.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard-general.js.es6 index 205c5fe853..eb85ef4a41 100644 --- a/app/assets/javascripts/admin/controllers/admin-dashboard-general.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-dashboard-general.js.es6 @@ -5,11 +5,11 @@ import Report from "admin/models/report"; import PeriodComputationMixin from "admin/mixins/period-computation"; function staticReport(reportType) { - return function() { + return Ember.computed("reports.[]", function() { return Ember.makeArray(this.reports).find( report => report.type === reportType ); - }.property("reports.[]"); + }); } export default Ember.Controller.extend(PeriodComputationMixin, { diff --git a/app/assets/javascripts/discourse/controllers/history.js.es6 b/app/assets/javascripts/discourse/controllers/history.js.es6 index df2833e187..53627c157f 100644 --- a/app/assets/javascripts/discourse/controllers/history.js.es6 +++ b/app/assets/javascripts/discourse/controllers/history.js.es6 @@ -7,7 +7,7 @@ import { sanitizeAsync } from "discourse/lib/text"; import { iconHTML } from "discourse-common/lib/icon-library"; function customTagArray(fieldName) { - return function() { + return Ember.computed(fieldName, function() { var val = this.get(fieldName); if (!val) { return val; @@ -16,7 +16,7 @@ function customTagArray(fieldName) { val = [val]; } return val; - }.property(fieldName); + }); } // This controller handles displaying of history diff --git a/app/assets/javascripts/discourse/lib/computed.js.es6 b/app/assets/javascripts/discourse/lib/computed.js.es6 index 2683d944fe..e6a440eb83 100644 --- a/app/assets/javascripts/discourse/lib/computed.js.es6 +++ b/app/assets/javascripts/discourse/lib/computed.js.es6 @@ -51,10 +51,9 @@ export function propertyLessThan(p1, p2) { **/ export function i18n(...args) { const format = args.pop(); - const computed = Ember.computed(function() { + return Ember.computed(...args, function() { return I18n.t(addonFmt(format, ...args.map(a => this.get(a)))); }); - return computed.property.apply(computed, args); } /** @@ -68,10 +67,9 @@ export function i18n(...args) { **/ export function fmt(...args) { const format = args.pop(); - const computed = Ember.computed(function() { + return Ember.computed(...args, function() { return addonFmt(format, ...args.map(a => this.get(a))); }); - return computed.property.apply(computed, args); } /** @@ -85,10 +83,9 @@ export function fmt(...args) { **/ export function url(...args) { const format = args.pop(); - const computed = Ember.computed(function() { + return Ember.computed(...args, function() { return Discourse.getURL(addonFmt(format, ...args.map(a => this.get(a)))); }); - return computed.property.apply(computed, args); } /** @@ -102,7 +99,7 @@ export function url(...args) { export function endWith() { const args = Array.prototype.slice.call(arguments, 0); const substring = args.pop(); - const computed = Ember.computed(function() { + return Ember.computed(...args, function() { return args .map(a => this.get(a)) .every(s => { @@ -111,7 +108,6 @@ export function endWith() { return lastIndex !== -1 && lastIndex === position; }); }); - return computed.property.apply(computed, args); } /** diff --git a/app/assets/javascripts/discourse/mixins/buffered-content.js.es6 b/app/assets/javascripts/discourse/mixins/buffered-content.js.es6 index aefee9990e..36f6de6071 100644 --- a/app/assets/javascripts/discourse/mixins/buffered-content.js.es6 +++ b/app/assets/javascripts/discourse/mixins/buffered-content.js.es6 @@ -1,11 +1,11 @@ /* global BufferedProxy: true */ export function bufferedProperty(property) { const mixin = { - buffered: function() { + buffered: Ember.computed(property, function() { return Ember.ObjectProxy.extend(BufferedProxy).create({ content: this.get(property) }); - }.property(property), + }), rollbackBuffer: function() { this.buffered.discardBufferedChanges(); diff --git a/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 b/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 index 6e96e56628..ea45f0ed7c 100644 --- a/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 @@ -22,12 +22,12 @@ export function actionDescriptionHtml(actionCode, createdAt, username) { } export function actionDescription(actionCode, createdAt, username) { - return function() { + return Ember.computed(actionCode, createdAt, function() { const ac = this.get(actionCode); if (ac) { return actionDescriptionHtml(ac, this.get(createdAt), this.get(username)); } - }.property(actionCode, createdAt); + }); } const icons = { From f9c7d5a4bdf4876c466def9a48ceac714d34ea3d Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 18 Jul 2019 19:29:37 +0200 Subject: [PATCH 045/441] DEV: removes application.currentPath deprecation (#7905) See https://deprecations.emberjs.com/v3.x#toc_application-controller-router-properties for more context --- app/assets/javascripts/admin/controllers/admin.js.es6 | 4 ++-- .../javascripts/discourse/controllers/group-activity.js.es6 | 2 +- .../javascripts/discourse/controllers/group-manage.js.es6 | 2 +- .../javascripts/discourse/controllers/group-messages.js.es6 | 2 +- .../javascripts/discourse/controllers/preferences.js.es6 | 2 +- .../javascripts/discourse/controllers/user-activity.js.es6 | 1 + app/assets/javascripts/discourse/controllers/user-card.js.es6 | 2 +- .../discourse/controllers/user-private-messages.js.es6 | 4 ++-- app/assets/javascripts/discourse/controllers/user.js.es6 | 4 ++-- app/assets/javascripts/discourse/templates/group.hbs | 4 ++-- app/assets/javascripts/discourse/templates/group/activity.hbs | 2 +- app/assets/javascripts/discourse/templates/group/manage.hbs | 2 +- app/assets/javascripts/discourse/templates/group/messages.hbs | 2 +- app/assets/javascripts/discourse/templates/preferences.hbs | 2 +- app/assets/javascripts/discourse/templates/user-card.hbs | 4 ++-- app/assets/javascripts/discourse/templates/user/activity.hbs | 4 ++-- .../javascripts/discourse/templates/user/notifications.hbs | 2 +- 17 files changed, 23 insertions(+), 22 deletions(-) diff --git a/app/assets/javascripts/admin/controllers/admin.js.es6 b/app/assets/javascripts/admin/controllers/admin.js.es6 index 9823f0fa79..ace435d655 100644 --- a/app/assets/javascripts/admin/controllers/admin.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin.js.es6 @@ -1,7 +1,7 @@ import computed from "ember-addons/ember-computed-decorators"; export default Ember.Controller.extend({ - application: Ember.inject.controller(), + router: Ember.inject.service(), @computed("siteSettings.enable_group_directory") showGroups(enableGroupDirectory) { @@ -13,7 +13,7 @@ export default Ember.Controller.extend({ return this.currentUser.get("admin") && enableBadges; }, - @computed("application.currentPath") + @computed("router.currentRouteName") adminContentsClassName(currentPath) { let cssClasses = currentPath .split(".") diff --git a/app/assets/javascripts/discourse/controllers/group-activity.js.es6 b/app/assets/javascripts/discourse/controllers/group-activity.js.es6 index 86356eeaac..26fa94835a 100644 --- a/app/assets/javascripts/discourse/controllers/group-activity.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group-activity.js.es6 @@ -1,4 +1,4 @@ export default Ember.Controller.extend({ - application: Ember.inject.controller(), + router: Ember.inject.service(), queryParams: ["category_id"] }); diff --git a/app/assets/javascripts/discourse/controllers/group-manage.js.es6 b/app/assets/javascripts/discourse/controllers/group-manage.js.es6 index 93e17ca0fe..795627c3cb 100644 --- a/app/assets/javascripts/discourse/controllers/group-manage.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group-manage.js.es6 @@ -1,7 +1,7 @@ import { default as computed } from "ember-addons/ember-computed-decorators"; export default Ember.Controller.extend({ - application: Ember.inject.controller(), + router: Ember.inject.service(), @computed("model.automatic") tabs(automatic) { diff --git a/app/assets/javascripts/discourse/controllers/group-messages.js.es6 b/app/assets/javascripts/discourse/controllers/group-messages.js.es6 index d4180e8b6b..cda126a2e2 100644 --- a/app/assets/javascripts/discourse/controllers/group-messages.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group-messages.js.es6 @@ -1,3 +1,3 @@ export default Ember.Controller.extend({ - application: Ember.inject.controller() + router: Ember.inject.service() }); diff --git a/app/assets/javascripts/discourse/controllers/preferences.js.es6 b/app/assets/javascripts/discourse/controllers/preferences.js.es6 index d4180e8b6b..cda126a2e2 100644 --- a/app/assets/javascripts/discourse/controllers/preferences.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences.js.es6 @@ -1,3 +1,3 @@ export default Ember.Controller.extend({ - application: Ember.inject.controller() + router: Ember.inject.service() }); diff --git a/app/assets/javascripts/discourse/controllers/user-activity.js.es6 b/app/assets/javascripts/discourse/controllers/user-activity.js.es6 index e214e58826..d8e6f29dca 100644 --- a/app/assets/javascripts/discourse/controllers/user-activity.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-activity.js.es6 @@ -2,6 +2,7 @@ import { exportUserArchive } from "discourse/lib/export-csv"; export default Ember.Controller.extend({ application: Ember.inject.controller(), + router: Ember.inject.service(), user: Ember.inject.controller(), userActionType: null, diff --git a/app/assets/javascripts/discourse/controllers/user-card.js.es6 b/app/assets/javascripts/discourse/controllers/user-card.js.es6 index cf84b13232..0f26ea5e59 100644 --- a/app/assets/javascripts/discourse/controllers/user-card.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-card.js.es6 @@ -6,7 +6,7 @@ import { export default Ember.Controller.extend({ topic: Ember.inject.controller(), - application: Ember.inject.controller(), + router: Ember.inject.service(), actions: { togglePosts(user) { diff --git a/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 b/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 index 62fc7a2a5c..9a9cba115b 100644 --- a/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 @@ -2,14 +2,14 @@ import computed from "ember-addons/ember-computed-decorators"; import Topic from "discourse/models/topic"; export default Ember.Controller.extend({ - application: Ember.inject.controller(), + router: Ember.inject.service(), userTopicsList: Ember.inject.controller("user-topics-list"), user: Ember.inject.controller(), pmView: false, viewingSelf: Ember.computed.alias("user.viewingSelf"), isGroup: Ember.computed.equal("pmView", "groups"), - currentPath: Ember.computed.alias("application.currentPath"), + currentPath: Ember.computed.alias("router.currentRouteName"), selected: Ember.computed.alias("userTopicsList.selected"), bulkSelectEnabled: Ember.computed.alias("userTopicsList.bulkSelectEnabled"), showToggleBulkSelect: true, diff --git a/app/assets/javascripts/discourse/controllers/user.js.es6 b/app/assets/javascripts/discourse/controllers/user.js.es6 index 0051d0377e..661be19aaf 100644 --- a/app/assets/javascripts/discourse/controllers/user.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user.js.es6 @@ -5,9 +5,9 @@ import optionalService from "discourse/lib/optional-service"; export default Ember.Controller.extend(CanCheckEmails, { indexStream: false, - application: Ember.inject.controller(), + router: Ember.inject.service(), userNotifications: Ember.inject.controller("user-notifications"), - currentPath: Ember.computed.alias("application.currentPath"), + currentPath: Ember.computed.alias("router.currentRouteName"), adminTools: optionalService(), @computed("model.username") diff --git a/app/assets/javascripts/discourse/templates/group.hbs b/app/assets/javascripts/discourse/templates/group.hbs index 5ac0a81101..7e88c925e4 100644 --- a/app/assets/javascripts/discourse/templates/group.hbs +++ b/app/assets/javascripts/discourse/templates/group.hbs @@ -56,10 +56,10 @@
- {{group-navigation group=model currentPath=application.currentPath tabs=tabs}} + {{group-navigation group=model currentPath=router.currentRouteName tabs=tabs}}
{{outlet}}
-
\ No newline at end of file +
diff --git a/app/assets/javascripts/discourse/templates/group/activity.hbs b/app/assets/javascripts/discourse/templates/group/activity.hbs index ba0eaadfed..b6ca30345e 100644 --- a/app/assets/javascripts/discourse/templates/group/activity.hbs +++ b/app/assets/javascripts/discourse/templates/group/activity.hbs @@ -1,5 +1,5 @@
- {{#mobile-nav class='activity-nav' desktopClass='action-list activity-list nav-stacked' currentPath=application.currentPath}} + {{#mobile-nav class='activity-nav' desktopClass='action-list activity-list nav-stacked' currentPath=router.currentRouteName}} {{group-activity-filter filter="posts" categoryId=category_id}} {{group-activity-filter filter="topics" categoryId=category_id}} {{#if siteSettings.enable_mentions}} diff --git a/app/assets/javascripts/discourse/templates/group/manage.hbs b/app/assets/javascripts/discourse/templates/group/manage.hbs index 583edecf3a..ff43273d40 100644 --- a/app/assets/javascripts/discourse/templates/group/manage.hbs +++ b/app/assets/javascripts/discourse/templates/group/manage.hbs @@ -1,5 +1,5 @@
- {{#mobile-nav class='activity-nav' desktopClass='action-list activity-list nav-stacked' currentPath=application.currentPath}} + {{#mobile-nav class='activity-nav' desktopClass='action-list activity-list nav-stacked' currentPath=router.currentRouteName}} {{#each tabs as |tab|}}
  • {{#link-to tab.route model.name}} diff --git a/app/assets/javascripts/discourse/templates/group/messages.hbs b/app/assets/javascripts/discourse/templates/group/messages.hbs index fa98699147..b567f2d031 100644 --- a/app/assets/javascripts/discourse/templates/group/messages.hbs +++ b/app/assets/javascripts/discourse/templates/group/messages.hbs @@ -1,5 +1,5 @@
    - {{#mobile-nav class='messages-nav' desktopClass='nav-stacked action-list' currentPath=application.currentPath}} + {{#mobile-nav class='messages-nav' desktopClass='nav-stacked action-list' currentPath=router.currentRouteName}}
  • {{#link-to 'group.messages.inbox' model.name}} diff --git a/app/assets/javascripts/discourse/templates/preferences.hbs b/app/assets/javascripts/discourse/templates/preferences.hbs index a7c88a5e02..4a78a98f90 100644 --- a/app/assets/javascripts/discourse/templates/preferences.hbs +++ b/app/assets/javascripts/discourse/templates/preferences.hbs @@ -1,5 +1,5 @@ {{#d-section pageClass="user-preferences" class="user-secondary-navigation"}} - {{#mobile-nav class='preferences-nav' desktopClass='preferences-list action-list nav-stacked' currentPath=application.currentPath}} + {{#mobile-nav class='preferences-nav' desktopClass='preferences-list action-list nav-stacked' currentPath=router.currentRouteName}}
  • {{#link-to 'userActivity.index'}}{{i18n 'user.filters.all'}}{{/link-to}}
  • @@ -26,7 +26,7 @@ connectorTagName='li' args=(hash model=model)}} {{/mobile-nav}} - + {{/d-section}} {{#if canDownloadPosts}}
    diff --git a/app/assets/javascripts/discourse/templates/user/notifications.hbs b/app/assets/javascripts/discourse/templates/user/notifications.hbs index e6b2137205..c968b42f9d 100644 --- a/app/assets/javascripts/discourse/templates/user/notifications.hbs +++ b/app/assets/javascripts/discourse/templates/user/notifications.hbs @@ -1,5 +1,5 @@ {{#d-section pageClass="user-notifications" class="user-secondary-navigation"}} - {{#mobile-nav class='notifications-nav' desktopClass='notification-list action-list nav-stacked' currentPath=application.currentPath}} + {{#mobile-nav class='notifications-nav' desktopClass='notification-list action-list nav-stacked' currentPath=router.currentRouteName}}
  • {{#link-to 'userNotifications.index'}} {{i18n 'user.filters.all'}} From 9cdc059a990c113608d5eaf22e8bfa07a5e31a2a Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 18 Jul 2019 19:48:12 +0200 Subject: [PATCH 046/441] fix tests (#7908) --- app/assets/javascripts/admin/controllers/admin.js.es6 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/admin/controllers/admin.js.es6 b/app/assets/javascripts/admin/controllers/admin.js.es6 index ace435d655..2bca382439 100644 --- a/app/assets/javascripts/admin/controllers/admin.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin.js.es6 @@ -22,15 +22,16 @@ export default Ember.Controller.extend({ segment !== "index" && segment !== "loading" && segment !== "show" && - segment !== "admin" + segment !== "admin" && + segment !== "dashboard" ); }) .map(Ember.String.dasherize) .join(" "); // this is done to avoid breaking css customizations - if (cssClasses.includes("dashboard")) { - cssClasses = `${cssClasses} dashboard-next`; + if (currentPath.indexOf("admin.dashboard") > -1) { + cssClasses = `${cssClasses} dashboard dashboard-next`; } return cssClasses; From ad04ce9f43848112373e2d98ae0589a0faa62c00 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Fri, 19 Jul 2019 01:44:08 +0530 Subject: [PATCH 047/441] FIX: remove post upload record creation inside 'find_missing_uploads' method. --- app/models/post.rb | 5 +---- lib/s3_inventory.rb | 3 ++- spec/components/s3_inventory_spec.rb | 29 ++++++++++++++++++++++++++-- spec/fixtures/csv/s3_inventory.csv | 6 +++--- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/app/models/post.rb b/app/models/post.rb index 0fcf0f802f..c579d012ea 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -955,10 +955,7 @@ class Post < ActiveRecord::Base upload_id = Upload.where(sha1: sha1).pluck(:id).first if sha1.present? upload_id ||= yield(post, src, path, sha1) - if upload_id.present? - attributes = { post_id: post.id, upload_id: upload_id } - PostUpload.create!(attributes) unless PostUpload.exists?(attributes) - else + if upload_id.blank? missing_uploads << src missing_post_uploads[post.id] << src end diff --git a/lib/s3_inventory.rb b/lib/s3_inventory.rb index 4afc545167..309b2ef61c 100644 --- a/lib/s3_inventory.rb +++ b/lib/s3_inventory.rb @@ -82,7 +82,7 @@ class S3Inventory def list_missing_post_uploads log "Listing missing post uploads..." - missing = Post.find_missing_uploads(include_local_upload: false) do |_, _, _, sha1| + missing = Post.find_missing_uploads(include_local_upload: false) do |post, _, _, sha1| next if sha1.blank? upload_id = nil @@ -105,6 +105,7 @@ class S3Inventory ) upload.save!(validate: false) upload_id = upload.id + post.link_post_uploads rescue Aws::S3::Errors::NotFound next end diff --git a/spec/components/s3_inventory_spec.rb b/spec/components/s3_inventory_spec.rb index 0b8c66d45e..a63913a7b7 100644 --- a/spec/components/s3_inventory_spec.rb +++ b/spec/components/s3_inventory_spec.rb @@ -82,8 +82,8 @@ describe "S3Inventory" do it "should backfill etags to uploads table correctly" do files = [ - ["#{Discourse.store.absolute_base_url}/original/0184537a4f419224404d013414e913a4f56018f2.jpg", "defcaac0b4aca535c284e95f30d608d0"], - ["#{Discourse.store.absolute_base_url}/original/0789fbf5490babc68326b9cec90eeb0d6590db05.png", "25c02eaceef4cb779fc17030d33f7f06"] + ["#{Discourse.store.absolute_base_url}/original/1X/0184537a4f419224404d013414e913a4f56018f2.jpg", "defcaac0b4aca535c284e95f30d608d0"], + ["#{Discourse.store.absolute_base_url}/original/1X/0789fbf5490babc68326b9cec90eeb0d6590db05.png", "25c02eaceef4cb779fc17030d33f7f06"] ] files.each { |file| Fabricate(:upload, url: file[0]) } @@ -97,4 +97,29 @@ describe "S3Inventory" do expect(Upload.by_users.order(:url).pluck(:url, :etag)).to eq(files) end + + it "should recover missing uploads correctly" do + freeze_time + + CSV.foreach(csv_filename, headers: false) do |row| + Fabricate(:upload, url: File.join(Discourse.store.absolute_base_url, row[S3Inventory::CSV_KEY_INDEX]), etag: row[S3Inventory::CSV_ETAG_INDEX], created_at: 2.days.ago) + end + + upload = Upload.last + etag = upload.etag + post = Fabricate(:post, raw: "![](#{upload.url})") + post.link_post_uploads + upload.delete + + inventory.expects(:download_inventory_files_to_tmp_directory) + inventory.expects(:decompress_inventory_files) + inventory.expects(:files).returns([{ key: "Key", filename: "#{csv_filename}.gz" }]).times(2) + + output = capture_stdout do + inventory.backfill_etags_and_list_missing + end + + expect(output).to eq("Listing missing post uploads...\n0 post uploads are missing.\n") + expect(post.uploads.first.etag).to eq(etag) + end end diff --git a/spec/fixtures/csv/s3_inventory.csv b/spec/fixtures/csv/s3_inventory.csv index 04325fb7c3..a45103c2a0 100644 --- a/spec/fixtures/csv/s3_inventory.csv +++ b/spec/fixtures/csv/s3_inventory.csv @@ -1,3 +1,3 @@ -"abc","original/0184537a4f419224404d013414e913a4f56018f2.jpg","defcaac0b4aca535c284e95f30d608d0" -"abc","original/050afc0ab01debe8cf48fd2ce50fbbf5eb072815.jpg","0cdc623af39cde0adb382670a6dc702a" -"abc","original/0789fbf5490babc68326b9cec90eeb0d6590db05.png","25c02eaceef4cb779fc17030d33f7f06" +"abc","original/1X/0184537a4f419224404d013414e913a4f56018f2.jpg","defcaac0b4aca535c284e95f30d608d0" +"abc","original/1X/050afc0ab01debe8cf48fd2ce50fbbf5eb072815.jpg","0cdc623af39cde0adb382670a6dc702a" +"abc","original/1X/0789fbf5490babc68326b9cec90eeb0d6590db05.png","25c02eaceef4cb779fc17030d33f7f06" From e8a14a3a65f5c23a8d1749d5c5e587e7396e41b2 Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 18 Jul 2019 16:33:12 -0400 Subject: [PATCH 048/441] Updating breakpoint mixin value name --- app/assets/stylesheets/common/base/groups.scss | 4 ++-- app/assets/stylesheets/common/base/reviewables.scss | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/common/base/groups.scss b/app/assets/stylesheets/common/base/groups.scss index 242d3077dd..3689277fe6 100644 --- a/app/assets/stylesheets/common/base/groups.scss +++ b/app/assets/stylesheets/common/base/groups.scss @@ -40,7 +40,7 @@ grid-template-columns: repeat(3, 32%); grid-column-gap: 2%; } - @include breakpoint("mobile") { + @include breakpoint("mobile-large") { grid-template-columns: 100%; } } @@ -49,7 +49,7 @@ // Flex and margin are for IE11 flex: 1 1 24%; margin: 1%; - @include breakpoint("mobile") { + @include breakpoint("mobile-large") { margin: 0; } display: flex; diff --git a/app/assets/stylesheets/common/base/reviewables.scss b/app/assets/stylesheets/common/base/reviewables.scss index ba89457c85..018ea923b8 100644 --- a/app/assets/stylesheets/common/base/reviewables.scss +++ b/app/assets/stylesheets/common/base/reviewables.scss @@ -327,7 +327,7 @@ &:not(:empty) { padding: 0.5em 1em 0.5em 0; } - @include breakpoint("mobile") { + @include breakpoint("mobile-large") { overflow: hidden; text-overflow: ellipsis; padding-right: 0.5em; @@ -370,7 +370,7 @@ .created-by { margin-right: 1em; padding-top: 0.35em; - @include breakpoint("mobile") { + @include breakpoint("mobile-large") { float: left; margin-bottom: 1em; } @@ -385,7 +385,7 @@ margin-top: 1em; min-width: 275px; word-break: break-word; - @include breakpoint("mobile", min-width) { + @include breakpoint("mobile-large", min-width) { display: flex; } } @@ -495,7 +495,7 @@ } } -@include breakpoint("mobile") { +@include breakpoint("mobile-large") { tr.reviewable-score { grid-template-columns: auto auto auto; } From 8dfd0e0374c7e97f14fcb644f2b760bdf65d57be Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 18 Jul 2019 23:00:39 +0200 Subject: [PATCH 049/441] DEV: uses private API for currentPath (#7911) * DEV: uses with private API for currentPath router.currentRouteName as a slightly different API and application.currentPath is deprecated * another fix --- app/assets/javascripts/admin/controllers/admin.js.es6 | 9 ++++----- .../discourse/controllers/user-private-messages.js.es6 | 2 +- app/assets/javascripts/discourse/controllers/user.js.es6 | 2 +- app/assets/javascripts/discourse/templates/group.hbs | 2 +- .../javascripts/discourse/templates/group/activity.hbs | 2 +- .../javascripts/discourse/templates/group/manage.hbs | 2 +- .../javascripts/discourse/templates/group/messages.hbs | 2 +- .../javascripts/discourse/templates/preferences.hbs | 2 +- app/assets/javascripts/discourse/templates/user-card.hbs | 4 ++-- .../javascripts/discourse/templates/user/activity.hbs | 2 +- .../discourse/templates/user/notifications.hbs | 2 +- app/assets/javascripts/discourse/widgets/header.js.es6 | 8 ++++---- 12 files changed, 19 insertions(+), 20 deletions(-) diff --git a/app/assets/javascripts/admin/controllers/admin.js.es6 b/app/assets/javascripts/admin/controllers/admin.js.es6 index 2bca382439..a79cf81944 100644 --- a/app/assets/javascripts/admin/controllers/admin.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin.js.es6 @@ -13,7 +13,7 @@ export default Ember.Controller.extend({ return this.currentUser.get("admin") && enableBadges; }, - @computed("router.currentRouteName") + @computed("router._router.currentPath") adminContentsClassName(currentPath) { let cssClasses = currentPath .split(".") @@ -22,16 +22,15 @@ export default Ember.Controller.extend({ segment !== "index" && segment !== "loading" && segment !== "show" && - segment !== "admin" && - segment !== "dashboard" + segment !== "admin" ); }) .map(Ember.String.dasherize) .join(" "); // this is done to avoid breaking css customizations - if (currentPath.indexOf("admin.dashboard") > -1) { - cssClasses = `${cssClasses} dashboard dashboard-next`; + if (cssClasses.includes("dashboard")) { + cssClasses = `${cssClasses} dashboard-next`; } return cssClasses; diff --git a/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 b/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 index 9a9cba115b..0d087261a1 100644 --- a/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 @@ -9,7 +9,7 @@ export default Ember.Controller.extend({ pmView: false, viewingSelf: Ember.computed.alias("user.viewingSelf"), isGroup: Ember.computed.equal("pmView", "groups"), - currentPath: Ember.computed.alias("router.currentRouteName"), + currentPath: Ember.computed.alias("router._router.currentPath"), selected: Ember.computed.alias("userTopicsList.selected"), bulkSelectEnabled: Ember.computed.alias("userTopicsList.bulkSelectEnabled"), showToggleBulkSelect: true, diff --git a/app/assets/javascripts/discourse/controllers/user.js.es6 b/app/assets/javascripts/discourse/controllers/user.js.es6 index 661be19aaf..5045cf55c4 100644 --- a/app/assets/javascripts/discourse/controllers/user.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user.js.es6 @@ -7,7 +7,7 @@ export default Ember.Controller.extend(CanCheckEmails, { indexStream: false, router: Ember.inject.service(), userNotifications: Ember.inject.controller("user-notifications"), - currentPath: Ember.computed.alias("router.currentRouteName"), + currentPath: Ember.computed.alias("router._router.currentPath"), adminTools: optionalService(), @computed("model.username") diff --git a/app/assets/javascripts/discourse/templates/group.hbs b/app/assets/javascripts/discourse/templates/group.hbs index 7e88c925e4..6a7d2e0b14 100644 --- a/app/assets/javascripts/discourse/templates/group.hbs +++ b/app/assets/javascripts/discourse/templates/group.hbs @@ -56,7 +56,7 @@
    - {{group-navigation group=model currentPath=router.currentRouteName tabs=tabs}} + {{group-navigation group=model currentPath=router._router.currentPath tabs=tabs}}
    diff --git a/app/assets/javascripts/discourse/templates/group/activity.hbs b/app/assets/javascripts/discourse/templates/group/activity.hbs index b6ca30345e..24c06e20c9 100644 --- a/app/assets/javascripts/discourse/templates/group/activity.hbs +++ b/app/assets/javascripts/discourse/templates/group/activity.hbs @@ -1,5 +1,5 @@
    - {{#mobile-nav class='activity-nav' desktopClass='action-list activity-list nav-stacked' currentPath=router.currentRouteName}} + {{#mobile-nav class='activity-nav' desktopClass='action-list activity-list nav-stacked' currentPath=router._router.currentPath}} {{group-activity-filter filter="posts" categoryId=category_id}} {{group-activity-filter filter="topics" categoryId=category_id}} {{#if siteSettings.enable_mentions}} diff --git a/app/assets/javascripts/discourse/templates/group/manage.hbs b/app/assets/javascripts/discourse/templates/group/manage.hbs index ff43273d40..5ee0f8591f 100644 --- a/app/assets/javascripts/discourse/templates/group/manage.hbs +++ b/app/assets/javascripts/discourse/templates/group/manage.hbs @@ -1,5 +1,5 @@
    - {{#mobile-nav class='activity-nav' desktopClass='action-list activity-list nav-stacked' currentPath=router.currentRouteName}} + {{#mobile-nav class='activity-nav' desktopClass='action-list activity-list nav-stacked' currentPath=router._router.currentPath}} {{#each tabs as |tab|}}
  • {{#link-to tab.route model.name}} diff --git a/app/assets/javascripts/discourse/templates/group/messages.hbs b/app/assets/javascripts/discourse/templates/group/messages.hbs index b567f2d031..1d3edaa440 100644 --- a/app/assets/javascripts/discourse/templates/group/messages.hbs +++ b/app/assets/javascripts/discourse/templates/group/messages.hbs @@ -1,5 +1,5 @@
    - {{#mobile-nav class='messages-nav' desktopClass='nav-stacked action-list' currentPath=router.currentRouteName}} + {{#mobile-nav class='messages-nav' desktopClass='nav-stacked action-list' currentPath=router._router.currentPath}}
  • {{#link-to 'group.messages.inbox' model.name}} diff --git a/app/assets/javascripts/discourse/templates/preferences.hbs b/app/assets/javascripts/discourse/templates/preferences.hbs index 4a78a98f90..3abd833348 100644 --- a/app/assets/javascripts/discourse/templates/preferences.hbs +++ b/app/assets/javascripts/discourse/templates/preferences.hbs @@ -1,5 +1,5 @@ {{#d-section pageClass="user-preferences" class="user-secondary-navigation"}} - {{#mobile-nav class='preferences-nav' desktopClass='preferences-list action-list nav-stacked' currentPath=router.currentRouteName}} + {{#mobile-nav class='preferences-nav' desktopClass='preferences-list action-list nav-stacked' currentPath=router._router.currentPath}}
  • {{#link-to 'userActivity.index'}}{{i18n 'user.filters.all'}}{{/link-to}}
  • diff --git a/app/assets/javascripts/discourse/templates/user/notifications.hbs b/app/assets/javascripts/discourse/templates/user/notifications.hbs index c968b42f9d..fa74e925d3 100644 --- a/app/assets/javascripts/discourse/templates/user/notifications.hbs +++ b/app/assets/javascripts/discourse/templates/user/notifications.hbs @@ -1,5 +1,5 @@ {{#d-section pageClass="user-notifications" class="user-secondary-navigation"}} - {{#mobile-nav class='notifications-nav' desktopClass='notification-list action-list nav-stacked' currentPath=router.currentRouteName}} + {{#mobile-nav class='notifications-nav' desktopClass='notification-list action-list nav-stacked' currentPath=router._router.currentPath}}
  • {{#link-to 'userNotifications.index'}} {{i18n 'user.filters.all'}} diff --git a/app/assets/javascripts/discourse/widgets/header.js.es6 b/app/assets/javascripts/discourse/widgets/header.js.es6 index b48b302bbc..3db96d0803 100644 --- a/app/assets/javascripts/discourse/widgets/header.js.es6 +++ b/app/assets/javascripts/discourse/widgets/header.js.es6 @@ -403,8 +403,8 @@ export default createWidget("header", { } const currentPath = this.register - .lookup("controller:application") - .get("currentPath"); + .lookup("service:router") + .get("router._router.currentPath"); if (currentPath === "full-page-search") { scrollTop(); @@ -479,8 +479,8 @@ export default createWidget("header", { state.contextEnabled = false; const currentPath = this.register - .lookup("controller:application") - .get("currentPath"); + .lookup("service:router") + .get("router._router.currentPath"); const blacklist = [/^discovery\.categories/]; const whitelist = [/^topic\./]; const check = function(regex) { From 533f5172d7fb0d05f2f6a8bc0f77dba097e7852d Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 18 Jul 2019 23:15:36 +0200 Subject: [PATCH 050/441] fix tests (#7912) --- app/assets/javascripts/discourse/widgets/header.js.es6 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/widgets/header.js.es6 b/app/assets/javascripts/discourse/widgets/header.js.es6 index 3db96d0803..0d1d30cc9a 100644 --- a/app/assets/javascripts/discourse/widgets/header.js.es6 +++ b/app/assets/javascripts/discourse/widgets/header.js.es6 @@ -404,7 +404,7 @@ export default createWidget("header", { const currentPath = this.register .lookup("service:router") - .get("router._router.currentPath"); + .get("_router.currentPath"); if (currentPath === "full-page-search") { scrollTop(); @@ -480,7 +480,7 @@ export default createWidget("header", { const currentPath = this.register .lookup("service:router") - .get("router._router.currentPath"); + .get("_router.currentPath"); const blacklist = [/^discovery\.categories/]; const whitelist = [/^topic\./]; const check = function(regex) { From 2ecc613c5d16c406486427468040399caedffdd2 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Thu, 18 Jul 2019 23:17:29 +0200 Subject: [PATCH 051/441] FIX: URL encode usernames in user profile links in RSS feeds user_url() failed for usernames containing Unicode characters because it expects URL encoded usernames. RSS feeds do not support IRIs, so lets convert them to URIs by encoding the usernames. --- app/views/list/list.rss.erb | 2 +- app/views/topics/show.rss.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/list/list.rss.erb b/app/views/list/list.rss.erb index 8824eeb86b..0895d208da 100644 --- a/app/views/list/list.rss.erb +++ b/app/views/list/list.rss.erb @@ -20,7 +20,7 @@ <%= topic.category.name %> -

    <%= t('author_wrote', author: link_to("@#{username}", "#{Discourse.base_url}/u/#{topic.user.username_lower}")).html_safe %>

    +

    <%= t('author_wrote', author: link_to("@#{username}", "#{Discourse.base_url}/u/#{url_encode(topic.user.username_lower)}")).html_safe %>

    <% end %>
    <%- if first_post = topic.ordered_posts.first %> diff --git a/app/views/topics/show.rss.erb b/app/views/topics/show.rss.erb index 729fa86071..2a71f1f163 100644 --- a/app/views/topics/show.rss.erb +++ b/app/views/topics/show.rss.erb @@ -18,7 +18,7 @@ ]]> -

    <%= t('author_wrote', author: link_to("@#{post.user.username}", user_url(post.user.username_lower))).html_safe %>

    +

    <%= t('author_wrote', author: link_to("@#{post.user.username}", "#{Discourse.base_url}/u/#{url_encode(post.user.username_lower)}")).html_safe %>

    <% if post.hidden %> <%= t('flagging.user_must_edit').html_safe %> From b0c92bb0b925a2406e309de97a320ccbcff9bedd Mon Sep 17 00:00:00 2001 From: Blake Erickson Date: Thu, 18 Jul 2019 15:49:16 -0600 Subject: [PATCH 052/441] REFACTOR: Clean up parameterized title Follow up to [FIX: Empty backup names with unicode site titles][1] - Use .presence - "It's cleaner" - Update spec to use System.system_user so it is more readable [1]: https://github.com/discourse/discourse/commit/c8661674d4f471143326333caf2ee6c149234ee6 --- lib/backup_restore/backuper.rb | 2 +- q | 158 +++++++++++++++++++++++ spec/lib/backup_restore/backuper_spec.rb | 4 +- 3 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 q diff --git a/lib/backup_restore/backuper.rb b/lib/backup_restore/backuper.rb index 4eb0c9838c..d93ac58644 100644 --- a/lib/backup_restore/backuper.rb +++ b/lib/backup_restore/backuper.rb @@ -84,7 +84,7 @@ module BackupRestore end def get_parameterized_title - SiteSetting.title.parameterize.empty? ? "discourse" : SiteSetting.title.parameterize + SiteSetting.title.parameterize.presence || "discourse" end def initialize_state diff --git a/q b/q new file mode 100644 index 0000000000..45102073da --- /dev/null +++ b/q @@ -0,0 +1,158 @@ + List of relations + Schema | Name | Type | Owner +--------+-----------------------------+-------+------- + public | anonymous_users | table | blake + public | api_keys | table | blake + public | application_requests | table | blake + public | ar_internal_metadata | table | blake + public | backup_metadata | table | blake + public | badge_groupings | table | blake + public | badge_types | table | blake + public | badges | table | blake + public | categories | table | blake + public | categories_web_hooks | table | blake + public | category_custom_fields | table | blake + public | category_featured_topics | table | blake + public | category_groups | table | blake + public | category_search_data | table | blake + public | category_tag_groups | table | blake + public | category_tag_stats | table | blake + public | category_tags | table | blake + public | category_users | table | blake + public | child_themes | table | blake + public | color_scheme_colors | table | blake + public | color_schemes | table | blake + public | custom_emojis | table | blake + public | developers | table | blake + public | directory_items | table | blake + public | draft_sequences | table | blake + public | drafts | table | blake + public | email_change_requests | table | blake + public | email_logs | table | blake + public | email_tokens | table | blake + public | embeddable_hosts | table | blake + public | github_commits | table | blake + public | github_repos | table | blake + public | github_user_infos | table | blake + public | given_daily_likes | table | blake + public | google_user_infos | table | blake + public | group_archived_messages | table | blake + public | group_custom_fields | table | blake + public | group_histories | table | blake + public | group_mentions | table | blake + public | group_requests | table | blake + public | group_users | table | blake + public | groups | table | blake + public | groups_web_hooks | table | blake + public | ignored_users | table | blake + public | incoming_domains | table | blake + public | incoming_emails | table | blake + public | incoming_links | table | blake + public | incoming_referers | table | blake + public | instagram_user_infos | table | blake + public | invited_groups | table | blake + public | invites | table | blake + public | javascript_caches | table | blake + public | message_bus | table | blake + public | muted_users | table | blake + public | notifications | table | blake + public | oauth2_user_infos | table | blake + public | onceoff_logs | table | blake + public | optimized_images | table | blake + public | permalinks | table | blake + public | plugin_store_rows | table | blake + public | poll_options | table | blake + public | poll_votes | table | blake + public | polls | table | blake + public | post_action_types | table | blake + public | post_actions | table | blake + public | post_custom_fields | table | blake + public | post_details | table | blake + public | post_replies | table | blake + public | post_reply_keys | table | blake + public | post_revisions | table | blake + public | post_search_data | table | blake + public | post_stats | table | blake + public | post_timings | table | blake + public | post_uploads | table | blake + public | posts | table | blake + public | push_subscriptions | table | blake + public | quoted_posts | table | blake + public | remote_themes | table | blake + public | reviewable_claimed_topics | table | blake + public | reviewable_histories | table | blake + public | reviewable_scores | table | blake + public | reviewables | table | blake + public | scheduler_stats | table | blake + public | schema_migration_details | table | blake + public | schema_migrations | table | blake + public | screened_emails | table | blake + public | screened_ip_addresses | table | blake + public | screened_urls | table | blake + public | search_logs | table | blake + public | shared_drafts | table | blake + public | single_sign_on_records | table | blake + public | site_settings | table | blake + public | skipped_email_logs | table | blake + public | stylesheet_cache | table | blake + public | tag_group_memberships | table | blake + public | tag_group_permissions | table | blake + public | tag_groups | table | blake + public | tag_search_data | table | blake + public | tag_users | table | blake + public | tags | table | blake + public | tags_web_hooks | table | blake + public | theme_fields | table | blake + public | theme_settings | table | blake + public | theme_translation_overrides | table | blake + public | themes | table | blake + public | top_topics | table | blake + public | topic_allowed_groups | table | blake + public | topic_allowed_users | table | blake + public | topic_custom_fields | table | blake + public | topic_embeds | table | blake + public | topic_invites | table | blake + public | topic_link_clicks | table | blake + public | topic_links | table | blake + public | topic_search_data | table | blake + public | topic_tags | table | blake + public | topic_timers | table | blake + public | topic_users | table | blake + public | topic_views | table | blake + public | topics | table | blake + public | translation_overrides | table | blake + public | unsubscribe_keys | table | blake + public | uploads | table | blake + public | user_actions | table | blake + public | user_api_keys | table | blake + public | user_archived_messages | table | blake + public | user_associated_accounts | table | blake + public | user_auth_token_logs | table | blake + public | user_auth_tokens | table | blake + public | user_avatars | table | blake + public | user_badges | table | blake + public | user_custom_fields | table | blake + public | user_emails | table | blake + public | user_exports | table | blake + public | user_field_options | table | blake + public | user_fields | table | blake + public | user_histories | table | blake + public | user_open_ids | table | blake + public | user_options | table | blake + public | user_profile_views | table | blake + public | user_profiles | table | blake + public | user_search_data | table | blake + public | user_second_factors | table | blake + public | user_stats | table | blake + public | user_uploads | table | blake + public | user_visits | table | blake + public | user_warnings | table | blake + public | users | table | blake + public | watched_words | table | blake + public | web_crawler_requests | table | blake + public | web_hook_event_types | table | blake + public | web_hook_event_types_hooks | table | blake + public | web_hook_events | table | blake + public | web_hooks | table | blake +(153 rows) + diff --git a/spec/lib/backup_restore/backuper_spec.rb b/spec/lib/backup_restore/backuper_spec.rb index 1c96892caa..389b8d7bca 100644 --- a/spec/lib/backup_restore/backuper_spec.rb +++ b/spec/lib/backup_restore/backuper_spec.rb @@ -5,14 +5,14 @@ require 'rails_helper' describe BackupRestore::Backuper do it 'returns a non-empty parameterized title when site title contains unicode' do SiteSetting.title = 'Ɣ' - backuper = BackupRestore::Backuper.new(-1) + backuper = BackupRestore::Backuper.new(Discourse.system_user.id) expect(backuper.send(:get_parameterized_title)).to eq("discourse") end it 'returns a valid parameterized site title' do SiteSetting.title = "Coding Horror" - backuper = BackupRestore::Backuper.new(-1) + backuper = BackupRestore::Backuper.new(Discourse.system_user.id) expect(backuper.send(:get_parameterized_title)).to eq("coding-horror") end From a6be0ca5c7a37e1c81fddfd44aa240a5712ddfc0 Mon Sep 17 00:00:00 2001 From: Blake Erickson Date: Thu, 18 Jul 2019 16:02:26 -0600 Subject: [PATCH 053/441] Remove file. I blame vim. Follow up to: https://github.com/discourse/discourse/commit/b0c92bb0b925a2406e309de97a320ccbcff9bedd --- q | 158 -------------------------------------------------------------- 1 file changed, 158 deletions(-) delete mode 100644 q diff --git a/q b/q deleted file mode 100644 index 45102073da..0000000000 --- a/q +++ /dev/null @@ -1,158 +0,0 @@ - List of relations - Schema | Name | Type | Owner ---------+-----------------------------+-------+------- - public | anonymous_users | table | blake - public | api_keys | table | blake - public | application_requests | table | blake - public | ar_internal_metadata | table | blake - public | backup_metadata | table | blake - public | badge_groupings | table | blake - public | badge_types | table | blake - public | badges | table | blake - public | categories | table | blake - public | categories_web_hooks | table | blake - public | category_custom_fields | table | blake - public | category_featured_topics | table | blake - public | category_groups | table | blake - public | category_search_data | table | blake - public | category_tag_groups | table | blake - public | category_tag_stats | table | blake - public | category_tags | table | blake - public | category_users | table | blake - public | child_themes | table | blake - public | color_scheme_colors | table | blake - public | color_schemes | table | blake - public | custom_emojis | table | blake - public | developers | table | blake - public | directory_items | table | blake - public | draft_sequences | table | blake - public | drafts | table | blake - public | email_change_requests | table | blake - public | email_logs | table | blake - public | email_tokens | table | blake - public | embeddable_hosts | table | blake - public | github_commits | table | blake - public | github_repos | table | blake - public | github_user_infos | table | blake - public | given_daily_likes | table | blake - public | google_user_infos | table | blake - public | group_archived_messages | table | blake - public | group_custom_fields | table | blake - public | group_histories | table | blake - public | group_mentions | table | blake - public | group_requests | table | blake - public | group_users | table | blake - public | groups | table | blake - public | groups_web_hooks | table | blake - public | ignored_users | table | blake - public | incoming_domains | table | blake - public | incoming_emails | table | blake - public | incoming_links | table | blake - public | incoming_referers | table | blake - public | instagram_user_infos | table | blake - public | invited_groups | table | blake - public | invites | table | blake - public | javascript_caches | table | blake - public | message_bus | table | blake - public | muted_users | table | blake - public | notifications | table | blake - public | oauth2_user_infos | table | blake - public | onceoff_logs | table | blake - public | optimized_images | table | blake - public | permalinks | table | blake - public | plugin_store_rows | table | blake - public | poll_options | table | blake - public | poll_votes | table | blake - public | polls | table | blake - public | post_action_types | table | blake - public | post_actions | table | blake - public | post_custom_fields | table | blake - public | post_details | table | blake - public | post_replies | table | blake - public | post_reply_keys | table | blake - public | post_revisions | table | blake - public | post_search_data | table | blake - public | post_stats | table | blake - public | post_timings | table | blake - public | post_uploads | table | blake - public | posts | table | blake - public | push_subscriptions | table | blake - public | quoted_posts | table | blake - public | remote_themes | table | blake - public | reviewable_claimed_topics | table | blake - public | reviewable_histories | table | blake - public | reviewable_scores | table | blake - public | reviewables | table | blake - public | scheduler_stats | table | blake - public | schema_migration_details | table | blake - public | schema_migrations | table | blake - public | screened_emails | table | blake - public | screened_ip_addresses | table | blake - public | screened_urls | table | blake - public | search_logs | table | blake - public | shared_drafts | table | blake - public | single_sign_on_records | table | blake - public | site_settings | table | blake - public | skipped_email_logs | table | blake - public | stylesheet_cache | table | blake - public | tag_group_memberships | table | blake - public | tag_group_permissions | table | blake - public | tag_groups | table | blake - public | tag_search_data | table | blake - public | tag_users | table | blake - public | tags | table | blake - public | tags_web_hooks | table | blake - public | theme_fields | table | blake - public | theme_settings | table | blake - public | theme_translation_overrides | table | blake - public | themes | table | blake - public | top_topics | table | blake - public | topic_allowed_groups | table | blake - public | topic_allowed_users | table | blake - public | topic_custom_fields | table | blake - public | topic_embeds | table | blake - public | topic_invites | table | blake - public | topic_link_clicks | table | blake - public | topic_links | table | blake - public | topic_search_data | table | blake - public | topic_tags | table | blake - public | topic_timers | table | blake - public | topic_users | table | blake - public | topic_views | table | blake - public | topics | table | blake - public | translation_overrides | table | blake - public | unsubscribe_keys | table | blake - public | uploads | table | blake - public | user_actions | table | blake - public | user_api_keys | table | blake - public | user_archived_messages | table | blake - public | user_associated_accounts | table | blake - public | user_auth_token_logs | table | blake - public | user_auth_tokens | table | blake - public | user_avatars | table | blake - public | user_badges | table | blake - public | user_custom_fields | table | blake - public | user_emails | table | blake - public | user_exports | table | blake - public | user_field_options | table | blake - public | user_fields | table | blake - public | user_histories | table | blake - public | user_open_ids | table | blake - public | user_options | table | blake - public | user_profile_views | table | blake - public | user_profiles | table | blake - public | user_search_data | table | blake - public | user_second_factors | table | blake - public | user_stats | table | blake - public | user_uploads | table | blake - public | user_visits | table | blake - public | user_warnings | table | blake - public | users | table | blake - public | watched_words | table | blake - public | web_crawler_requests | table | blake - public | web_hook_event_types | table | blake - public | web_hook_event_types_hooks | table | blake - public | web_hook_events | table | blake - public | web_hooks | table | blake -(153 rows) - From d26aa6e71e13147d02f105b4976b8b2ebec0fa87 Mon Sep 17 00:00:00 2001 From: Blake Erickson Date: Thu, 18 Jul 2019 19:15:01 -0600 Subject: [PATCH 054/441] REFACTOR: Cleanup rake tasks based on feedback Follow up to: [FEATURE: Create a rake task for destroying categories][1] - `Discourse.system_user` is my friend - Remove puts statements from rake tasks that don't return anything - `for_each` is also my friend - Use `human_users` to also exclude discobot - Sort/format categories:list [1]: https://github.com/discourse/discourse/commit/092eeb5ca351a34d8a2892048a3a40099026497b --- app/services/destroy_task.rb | 32 +++++++++++--------------------- lib/tasks/categories.rake | 9 +++++++-- lib/tasks/destroy.rake | 18 ++++++------------ 3 files changed, 24 insertions(+), 35 deletions(-) diff --git a/app/services/destroy_task.rb b/app/services/destroy_task.rb index e5ff8e5789..b76d8f7f57 100644 --- a/app/services/destroy_task.rb +++ b/app/services/destroy_task.rb @@ -16,14 +16,13 @@ class DestroyTask topics = Topic.where(category_id: c.id, pinned_at: nil).where.not(user_id: -1) end @io.puts "There are #{topics.count} topics to delete in #{descriptive_slug} category" - topics.each do |topic| + topics.find_each do |topic| @io.puts "Deleting #{topic.slug}..." first_post = topic.ordered_posts.first if first_post.nil? return @io.puts "Topic.ordered_posts.first was nil" end - system_user = User.find(-1) - @io.puts PostDestroyer.new(system_user, first_post).destroy + @io.puts PostDestroyer.new(Discourse.system_user, first_post).destroy end end @@ -36,11 +35,10 @@ class DestroyTask topics = Topic.where(category_id: c.id, pinned_at: nil).where.not(user_id: -1) end @io.puts "There are #{topics.count} topics to delete in #{c.slug} category" - topics.each do |topic| + topics.find_each do |topic| first_post = topic.ordered_posts.first return @io.puts "Topic.ordered_posts.first was nil for topic: #{topic.id}" if first_post.nil? - system_user = User.find(-1) - PostDestroyer.new(system_user, first_post).destroy + PostDestroyer.new(Discourse.system_user, first_post).destroy end topics = Topic.where(category_id: c.id, pinned_at: nil) @io.puts "There are #{topics.count} topics that could not be deleted in #{c.slug} category" @@ -54,22 +52,19 @@ class DestroyTask end def destroy_private_messages - pms = Topic.where(archetype: "private_message") - current_user = User.find(-1) #system - pms.each do |pm| + Topic.where(archetype: "private_message").find_each do |pm| @io.puts "Destroying #{pm.slug} pm" first_post = pm.ordered_posts.first - @io.puts PostDestroyer.new(current_user, first_post).destroy + @io.puts PostDestroyer.new(Discourse.system_user, first_post).destroy end end def destroy_category(category_id, destroy_system_topics = false) c = Category.find_by_id(category_id) return @io.puts "A category with the id: #{category_id} could not be found" if c.nil? - subcategories = Category.where(parent_category_id: c.id).pluck(:id) + subcategories = Category.where(parent_category_id: c.id) @io.puts "There are #{subcategories.count} subcategories to delete" if subcategories - subcategories.each do |subcategory_id| - s = Category.find_by_id(subcategory_id) + subcategories.each do |s| category_topic_destroyer(s, destroy_system_topics) end category_topic_destroyer(c, destroy_system_topics) @@ -84,14 +79,9 @@ class DestroyTask end def destroy_users - users = User.where(admin: false, id: 1..Float::INFINITY) - @io.puts "There are #{users.count} users to delete" - options = {} - options[:delete_posts] = true - current_user = User.find(-1) #system - users.each do |user| + User.human_users.where(admin: false).find_each do |user| begin - if UserDestroyer.new(current_user).destroy(user, options) + if UserDestroyer.new(Discourse.system_user).destroy(user, delete_posts: true) @io.puts "#{user.username} deleted" else @io.puts "#{user.username} not deleted" @@ -121,7 +111,7 @@ class DestroyTask private def category_topic_destroyer(category, destroy_system_topics = false) - destroy_topics_log = destroy_topics_in_category(category.id, destroy_system_topics) + destroy_topics_in_category(category.id, destroy_system_topics) @io.puts "Destroying #{category.slug} category" category.destroy end diff --git a/lib/tasks/categories.rake b/lib/tasks/categories.rake index b2c9ab2c9f..a3d5895892 100644 --- a/lib/tasks/categories.rake +++ b/lib/tasks/categories.rake @@ -39,8 +39,13 @@ end desc "Output a list of categories" task "categories:list" => :environment do - categories = Category.pluck(:id, :slug, :parent_category_id) + categories = Category.where(parent_category_id: nil).order(:slug).pluck(:id, :slug) + puts "id category-slug" + puts "-- -----------------" categories.each do |c| - puts "id: #{c[0]}, slug: #{c[1]}, parent: #{c[2]}" + puts "#{c[0]} #{c[1]}" + Category.where(parent_category_id: c[0]).order(:slug).pluck(:id, :slug).each do |s| + puts " #{s[0]} #{s[1]}" + end end end diff --git a/lib/tasks/destroy.rake b/lib/tasks/destroy.rake index dd99bf52de..d65a2039c3 100644 --- a/lib/tasks/destroy.rake +++ b/lib/tasks/destroy.rake @@ -4,47 +4,41 @@ # content and users from your site. desc "Remove all topics in a category" task "destroy:topics", [:category, :parent_category] => :environment do |t, args| - destroy_task = DestroyTask.new category = args[:category] parent_category = args[:parent_category] descriptive_slug = parent_category ? "#{parent_category}/#{category}" : category puts "Going to delete all topics in the #{descriptive_slug} category" - destroy_task.destroy_topics(category, parent_category) + DestroyTask.new.destroy_topics(category, parent_category) end desc "Remove all topics in all categories" task "destroy:topics_all_categories" => :environment do - destroy_task = DestroyTask.new puts "Going to delete all topics in all categories..." - puts log = destroy_task.destroy_topics_all_categories + DestroyTask.new.destroy_topics_all_categories end desc "Remove all private messages" task "destroy:private_messages" => :environment do - destroy_task = DestroyTask.new puts "Going to delete all private messages..." - puts log = destroy_task.destroy_private_messages + DestroyTask.new.destroy_private_messages end desc "Destroy all groups" task "destroy:groups" => :environment do - destroy_task = DestroyTask.new puts "Going to delete all non-default groups..." - puts log = destroy_task.destroy_groups + DestroyTask.new.destroy_groups end desc "Destroy all non-admin users" task "destroy:users" => :environment do - destroy_task = DestroyTask.new puts "Going to delete all non-admin users..." - puts log = destroy_task.destroy_users + DestroyTask.new.destroy_users end desc "Destroy site stats" task "destroy:stats" => :environment do - destroy_task = DestroyTask.new puts "Going to delete all site stats..." - destroy_task.destroy_stats + DestroyTask.new.destroy_stats end # Example: rake destroy:categories[28,29,44,85] From eb9155f3fef34ae7b474bdf3d2655bd1a043cbd3 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Fri, 19 Jul 2019 11:29:12 +0530 Subject: [PATCH 055/441] FEATURE: send max 200 emails every minute for bulk invites (#7875) DEV: deprecate `invite.via_email` in favor of `invite.emailed_status` This commit adds a new column `emailed_status` in `invites` table for tracking email sending status. 0 - not required 1 - pending 2 - bulk pending 3 - sending 4 - sent For normal email invites, invite record is created with emailed_status set to 'pending'. When bulk invites are sent invite record is created with emailed_status set to 'bulk pending'. For invites that generates link, invite record is created with emailed_status set to 'not required'. When invite email is in queue emailed_status is updated to 'sending' Once the email is sent via `InviteEmail` job the invite emailed_status is updated to 'sent'. --- app/jobs/regular/bulk_invite.rb | 15 ++++++- app/jobs/regular/invite_email.rb | 6 ++- .../regular/process_bulk_invite_emails.rb | 21 ++++++++++ app/models/invite.rb | 32 +++++++++++---- app/models/invite_redeemer.rb | 2 +- app/models/user_second_factor.rb | 1 + ...0711154946_add_emailed_status_to_invite.rb | 14 +++++++ ...0716124050_remove_via_email_from_invite.rb | 13 ++++++ spec/fabricators/invite_fabricator.rb | 1 - spec/jobs/bulk_invite_spec.rb | 23 ++++++++++- spec/jobs/invite_email_spec.rb | 11 ++++- spec/jobs/process_bulk_invite_emails_spec.rb | 18 ++++++++ spec/models/invite_redeemer_spec.rb | 9 ++++ spec/models/invite_spec.rb | 41 +++++++++++++++---- spec/requests/invites_controller_spec.rb | 4 +- 15 files changed, 185 insertions(+), 26 deletions(-) create mode 100644 app/jobs/regular/process_bulk_invite_emails.rb create mode 100644 db/migrate/20190711154946_add_emailed_status_to_invite.rb create mode 100644 db/post_migrate/20190716124050_remove_via_email_from_invite.rb create mode 100644 spec/jobs/process_bulk_invite_emails_spec.rb diff --git a/app/jobs/regular/bulk_invite.rb b/app/jobs/regular/bulk_invite.rb index 7c3d1a67cd..f7ccd44fd6 100644 --- a/app/jobs/regular/bulk_invite.rb +++ b/app/jobs/regular/bulk_invite.rb @@ -23,8 +23,13 @@ module Jobs @current_user = User.find_by(id: args[:current_user_id]) raise Discourse::InvalidParameters.new(:current_user_id) unless @current_user @guardian = Guardian.new(@current_user) + @total_invites = invites.length process_invites(invites) + + if @total_invites > Invite::BULK_INVITE_EMAIL_LIMIT + Jobs.enqueue(:process_bulk_invite_emails) + end ensure notify_user end @@ -104,7 +109,15 @@ module Jobs end end else - Invite.invite_by_email(email, @current_user, topic, groups.map(&:id)) + if @total_invites > Invite::BULK_INVITE_EMAIL_LIMIT + invite = Invite.create_invite_by_email(email, @current_user, + topic: topic, + group_ids: groups.map(&:id), + emailed_status: Invite.emailed_status_types[:bulk_pending] + ) + else + Invite.invite_by_email(email, @current_user, topic, groups.map(&:id)) + end end rescue => e save_log "Error inviting '#{email}' -- #{Rails::Html::FullSanitizer.new.sanitize(e.message)}" diff --git a/app/jobs/regular/invite_email.rb b/app/jobs/regular/invite_email.rb index 0db080ce80..c91e67ae35 100644 --- a/app/jobs/regular/invite_email.rb +++ b/app/jobs/regular/invite_email.rb @@ -15,8 +15,10 @@ module Jobs message = InviteMailer.send_invite(invite) Email::Sender.new(message, :invite).send + + if invite.emailed_status != Invite.emailed_status_types[:not_required] + invite.update_column(:emailed_status, Invite.emailed_status_types[:sent]) + end end - end - end diff --git a/app/jobs/regular/process_bulk_invite_emails.rb b/app/jobs/regular/process_bulk_invite_emails.rb new file mode 100644 index 0000000000..f4d057e7c2 --- /dev/null +++ b/app/jobs/regular/process_bulk_invite_emails.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_dependency 'email/sender' + +module Jobs + + class ProcessBulkInviteEmails < Jobs::Base + + def execute(args) + pending_invite_ids = Invite.where(emailed_status: Invite.emailed_status_types[:bulk_pending]).limit(Invite::BULK_INVITE_EMAIL_LIMIT).pluck(:id) + + if pending_invite_ids.length > 0 + Invite.where(id: pending_invite_ids).update_all(emailed_status: Invite.emailed_status_types[:sending]) + pending_invite_ids.each do |invite_id| + Jobs.enqueue(:invite_email, invite_id: invite_id) + end + Jobs.enqueue_in(1.minute, :process_bulk_invite_emails) + end + end + end +end diff --git a/app/models/invite.rb b/app/models/invite.rb index 1a909a248d..e92d5cd408 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -3,10 +3,16 @@ require_dependency 'rate_limiter' class Invite < ActiveRecord::Base + self.ignored_columns = %w{ + via_email + } + class UserExists < StandardError; end include RateLimiter::OnCreateRecord include Trashable + BULK_INVITE_EMAIL_LIMIT = 200 + rate_limit :limit_invites_per_day belongs_to :user @@ -31,6 +37,10 @@ class Invite < ActiveRecord::Base validate :user_doesnt_already_exist attr_accessor :email_already_exists + def self.emailed_status_types + @emailed_status_types ||= Enum.new(not_required: 0, pending: 1, bulk_pending: 2, sending: 3, sent: 4) + end + def user_doesnt_already_exist @email_already_exists = false return if email.blank? @@ -66,7 +76,7 @@ class Invite < ActiveRecord::Base topic: topic, group_ids: group_ids, custom_message: custom_message, - send_email: true + emailed_status: emailed_status_types[:pending] ) end @@ -75,7 +85,7 @@ class Invite < ActiveRecord::Base invite = create_invite_by_email(email, invited_by, topic: topic, group_ids: group_ids, - send_email: false + emailed_status: emailed_status_types[:not_required] ) "#{Discourse.base_url}/invites/#{invite.invite_key}" if invite @@ -89,8 +99,8 @@ class Invite < ActiveRecord::Base topic = opts[:topic] group_ids = opts[:group_ids] - send_email = opts[:send_email].nil? ? true : opts[:send_email] custom_message = opts[:custom_message] + emailed_status = opts[:emailed_status] || emailed_status_types[:pending] lower_email = Email.downcase(email) if user = find_user_by_email(lower_email) @@ -112,16 +122,20 @@ class Invite < ActiveRecord::Base end if invite + if invite.emailed_status == Invite.emailed_status_types[:not_required] + emailed_status = invite.emailed_status + end + invite.update_columns( created_at: Time.zone.now, updated_at: Time.zone.now, - via_email: invite.via_email && send_email + emailed_status: emailed_status ) else create_args = { invited_by: invited_by, email: lower_email, - via_email: send_email + emailed_status: emailed_status } create_args[:moderator] = true if opts[:moderator] @@ -143,7 +157,10 @@ class Invite < ActiveRecord::Base end end - Jobs.enqueue(:invite_email, invite_id: invite.id) if send_email + if emailed_status == emailed_status_types[:pending] + invite.update_column(:emailed_status, Invite.emailed_status_types[:sending]) + Jobs.enqueue(:invite_email, invite_id: invite.id) + end invite.reload invite @@ -261,10 +278,11 @@ end # invalidated_at :datetime # moderator :boolean default(FALSE), not null # custom_message :text -# via_email :boolean default(FALSE), not null +# emailed_status :integer # # Indexes # # index_invites_on_email_and_invited_by_id (email,invited_by_id) +# index_invites_on_emailed_status (emailed_status) # index_invites_on_invite_key (invite_key) UNIQUE # diff --git a/app/models/invite_redeemer.rb b/app/models/invite_redeemer.rb index d41662bb27..755241e00d 100644 --- a/app/models/invite_redeemer.rb +++ b/app/models/invite_redeemer.rb @@ -60,7 +60,7 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_f user.save! - if invite.via_email + if invite.emailed_status != Invite.emailed_status_types[:not_required] user.email_tokens.create!(email: user.email) user.activate end diff --git a/app/models/user_second_factor.rb b/app/models/user_second_factor.rb index 0d3d8ee351..88dcfefd4e 100644 --- a/app/models/user_second_factor.rb +++ b/app/models/user_second_factor.rb @@ -44,6 +44,7 @@ end # last_used :datetime # created_at :datetime not null # updated_at :datetime not null +# name :string # # Indexes # diff --git a/db/migrate/20190711154946_add_emailed_status_to_invite.rb b/db/migrate/20190711154946_add_emailed_status_to_invite.rb new file mode 100644 index 0000000000..9123f508f0 --- /dev/null +++ b/db/migrate/20190711154946_add_emailed_status_to_invite.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddEmailedStatusToInvite < ActiveRecord::Migration[5.2] + def change + add_column :invites, :emailed_status, :integer + add_index :invites, :emailed_status + + DB.exec <<~SQL + UPDATE invites + SET emailed_status = 0 + WHERE via_email = false + SQL + end +end diff --git a/db/post_migrate/20190716124050_remove_via_email_from_invite.rb b/db/post_migrate/20190716124050_remove_via_email_from_invite.rb new file mode 100644 index 0000000000..676ea11bca --- /dev/null +++ b/db/post_migrate/20190716124050_remove_via_email_from_invite.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'migration/column_dropper' + +class RemoveViaEmailFromInvite < ActiveRecord::Migration[5.2] + def up + Migration::ColumnDropper.execute_drop(:invites, %i{via_email}) + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/spec/fabricators/invite_fabricator.rb b/spec/fabricators/invite_fabricator.rb index 7b390ae90c..aedc23c177 100644 --- a/spec/fabricators/invite_fabricator.rb +++ b/spec/fabricators/invite_fabricator.rb @@ -3,5 +3,4 @@ Fabricator(:invite) do invited_by(fabricator: :user) email 'iceking@ADVENTURETIME.ooo' - via_email true end diff --git a/spec/jobs/bulk_invite_spec.rb b/spec/jobs/bulk_invite_spec.rb index d271df7ba8..283fbee2e2 100644 --- a/spec/jobs/bulk_invite_spec.rb +++ b/spec/jobs/bulk_invite_spec.rb @@ -89,6 +89,27 @@ describe Jobs::BulkInvite do expect(Invite.exists?(email: "test2@discourse.org")).to eq(true) expect(existing_user.reload.groups).to eq([group1]) end - end + context 'invites are more than 200' do + let(:bulk_invites) { [] } + + before do + 202.times do |i| + bulk_invites << { "email": "test_#{i}@discourse.org" } + end + end + + it 'rate limits email sending' do + described_class.new.execute( + current_user_id: admin.id, + invites: bulk_invites + ) + + invite = Invite.last + expect(invite.email).to eq("test_201@discourse.org") + expect(invite.emailed_status).to eq(Invite.emailed_status_types[:bulk_pending]) + expect(Jobs::ProcessBulkInviteEmails.jobs.size).to eq(1) + end + end + end end diff --git a/spec/jobs/invite_email_spec.rb b/spec/jobs/invite_email_spec.rb index 726a31600a..f67fd135e7 100644 --- a/spec/jobs/invite_email_spec.rb +++ b/spec/jobs/invite_email_spec.rb @@ -27,8 +27,15 @@ describe Jobs::InviteEmail do InviteMailer.expects(:send_invite).never Jobs::InviteEmail.new.execute(invite_id: invite.id) end + + it "updates invite emailed_status" do + invite.emailed_status = Invite.emailed_status_types[:pending] + invite.save! + Jobs::InviteEmail.new.execute(invite_id: invite.id) + + invite.reload + expect(invite.emailed_status).to eq(Invite.emailed_status_types[:sent]) + end end - end - end diff --git a/spec/jobs/process_bulk_invite_emails_spec.rb b/spec/jobs/process_bulk_invite_emails_spec.rb new file mode 100644 index 0000000000..c0687e9b6d --- /dev/null +++ b/spec/jobs/process_bulk_invite_emails_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Jobs::ProcessBulkInviteEmails do + describe '#execute' do + it 'processes pending invites' do + invite = Fabricate(:invite, emailed_status: Invite.emailed_status_types[:bulk_pending]) + + described_class.new.execute({}) + + invite.reload + expect(invite.emailed_status).to eq(Invite.emailed_status_types[:sending]) + expect(Jobs::InviteEmail.jobs.size).to eq(1) + expect(Jobs::ProcessBulkInviteEmails.jobs.size).to eq(1) + end + end +end diff --git a/spec/models/invite_redeemer_spec.rb b/spec/models/invite_redeemer_spec.rb index b370d92deb..f0f2798ba7 100644 --- a/spec/models/invite_redeemer_spec.rb +++ b/spec/models/invite_redeemer_spec.rb @@ -47,6 +47,15 @@ describe InviteRedeemer do expect(user.email).to eq('staged@account.com') expect(user.approved).to eq(true) end + + it "should not activate user invited via links" do + user = InviteRedeemer.create_user_from_invite(Fabricate(:invite, email: 'walter.white@email.com', emailed_status: Invite.emailed_status_types[:not_required]), 'walter', 'Walter White') + expect(user.username).to eq('walter') + expect(user.name).to eq('Walter White') + expect(user.email).to eq('walter.white@email.com') + expect(user.approved).to eq(true) + expect(user.active).to eq(false) + end end describe "#redeem" do diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb index 8f1136efe8..573f73c9d7 100644 --- a/spec/models/invite_spec.rb +++ b/spec/models/invite_spec.rb @@ -73,6 +73,14 @@ describe Invite do end end + context 'links' do + it 'does not enqueue a job to email the invite' do + expect do + Invite.generate_invite_link(iceking, inviter, topic) + end.not_to change { Jobs::InviteEmail.jobs.size } + end + end + context 'destroyed' do it "can invite the same user after their invite was destroyed" do Invite.invite_by_email(iceking, inviter, topic).destroy! @@ -151,26 +159,25 @@ describe Invite do end end - it 'correctly marks invite as sent via email' do - expect(invite.via_email).to eq(true) + it 'correctly marks invite emailed_status for email invites' do + expect(invite.emailed_status).to eq(Invite.emailed_status_types[:sending]) Invite.invite_by_email(iceking, inviter, topic) - expect(invite.reload.via_email).to eq(true) + expect(invite.reload.emailed_status).to eq(Invite.emailed_status_types[:sending]) end - it 'does not mark invite as sent via email after generating invite link' do - expect(invite.via_email).to eq(true) + it 'does not mark emailed_status as sending after generating invite link' do + expect(invite.emailed_status).to eq(Invite.emailed_status_types[:sending]) Invite.generate_invite_link(iceking, inviter, topic) - expect(invite.reload.via_email).to eq(false) + expect(invite.reload.emailed_status).to eq(Invite.emailed_status_types[:not_required]) Invite.invite_by_email(iceking, inviter, topic) - expect(invite.reload.via_email).to eq(false) + expect(invite.reload.emailed_status).to eq(Invite.emailed_status_types[:not_required]) Invite.generate_invite_link(iceking, inviter, topic) - expect(invite.reload.via_email).to eq(false) + expect(invite.reload.emailed_status).to eq(Invite.emailed_status_types[:not_required]) end - end end end @@ -496,4 +503,20 @@ describe Invite do expect(expired_invite.deleted_at).to be_present end end + + describe '#emailed_status_types' do + context "verify enum sequence" do + before do + @emailed_status_types = Invite.emailed_status_types + end + + it "'not_required' should be at 0 position" do + expect(@emailed_status_types[:not_required]).to eq(0) + end + + it "'sent' should be at 4th position" do + expect(@emailed_status_types[:sent]).to eq(4) + end + end + end end diff --git a/spec/requests/invites_controller_spec.rb b/spec/requests/invites_controller_spec.rb index 8b833682f6..8fe51106fa 100644 --- a/spec/requests/invites_controller_spec.rb +++ b/spec/requests/invites_controller_spec.rb @@ -353,7 +353,7 @@ describe InvitesController do context "with password" do context "user was invited via email" do - before { invite.update_column(:via_email, true) } + before { invite.update_column(:emailed_status, Invite.emailed_status_types[:pending]) } it "doesn't send an activation email and activates the user" do expect do @@ -373,7 +373,7 @@ describe InvitesController do end context "user was invited via link" do - before { invite.update_column(:via_email, false) } + before { invite.update_column(:emailed_status, Invite.emailed_status_types[:not_required]) } it "sends an activation email and doesn't activate the user" do expect do From 9f500a4ff42b898e76c8f46bf6702f1b76f8f378 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Fri, 19 Jul 2019 11:05:48 +0300 Subject: [PATCH 056/441] FIX: Show same username or name for post notices. (#7862) --- .../discourse/components/user-card-contents.js.es6 | 7 ++----- app/assets/javascripts/discourse/controllers/user.js.es6 | 7 ++----- app/assets/javascripts/discourse/lib/settings.js.es6 | 7 +++++++ app/assets/javascripts/discourse/widgets/post.js.es6 | 7 ++++--- .../javascripts/discourse/widgets/poster-name.js.es6 | 5 ++--- test/javascripts/widgets/post-test.js.es6 | 2 ++ 6 files changed, 19 insertions(+), 16 deletions(-) create mode 100644 app/assets/javascripts/discourse/lib/settings.js.es6 diff --git a/app/assets/javascripts/discourse/components/user-card-contents.js.es6 b/app/assets/javascripts/discourse/components/user-card-contents.js.es6 index 03aa390aa0..84e63e912b 100644 --- a/app/assets/javascripts/discourse/components/user-card-contents.js.es6 +++ b/app/assets/javascripts/discourse/components/user-card-contents.js.es6 @@ -8,6 +8,7 @@ import { durationTiny } from "discourse/lib/formatter"; import CanCheckEmails from "discourse/mixins/can-check-emails"; import CardContentsBase from "discourse/mixins/card-contents-base"; import CleansUp from "discourse/mixins/cleans-up"; +import { prioritizeNameInUx } from "discourse/lib/settings"; export default Ember.Component.extend( CardContentsBase, @@ -65,11 +66,7 @@ export default Ember.Component.extend( @computed("user.name") nameFirst(name) { - return ( - !this.siteSettings.prioritize_username_in_ux && - name && - name.trim().length > 0 - ); + return prioritizeNameInUx(name, this.siteSettings); }, @computed("username") diff --git a/app/assets/javascripts/discourse/controllers/user.js.es6 b/app/assets/javascripts/discourse/controllers/user.js.es6 index 5045cf55c4..e59b0edfdc 100644 --- a/app/assets/javascripts/discourse/controllers/user.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user.js.es6 @@ -2,6 +2,7 @@ import CanCheckEmails from "discourse/mixins/can-check-emails"; import computed from "ember-addons/ember-computed-decorators"; import User from "discourse/models/user"; import optionalService from "discourse/lib/optional-service"; +import { prioritizeNameInUx } from "discourse/lib/settings"; export default Ember.Controller.extend(CanCheckEmails, { indexStream: false, @@ -87,11 +88,7 @@ export default Ember.Controller.extend(CanCheckEmails, { @computed("model.name") nameFirst(name) { - return ( - !this.get("siteSettings.prioritize_username_in_ux") && - name && - name.trim().length > 0 - ); + return prioritizeNameInUx(name, this.siteSettings); }, @computed("model.badge_count") diff --git a/app/assets/javascripts/discourse/lib/settings.js.es6 b/app/assets/javascripts/discourse/lib/settings.js.es6 new file mode 100644 index 0000000000..f4f6ae6fa6 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/settings.js.es6 @@ -0,0 +1,7 @@ +export function prioritizeNameInUx(name, siteSettings) { + siteSettings = siteSettings || Discourse.SiteSettings; + + return ( + !siteSettings.prioritize_username_in_ux && name && name.trim().length > 0 + ); +} diff --git a/app/assets/javascripts/discourse/widgets/post.js.es6 b/app/assets/javascripts/discourse/widgets/post.js.es6 index 60511b30f2..4138fdbaa0 100644 --- a/app/assets/javascripts/discourse/widgets/post.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post.js.es6 @@ -15,6 +15,7 @@ import { } from "discourse/lib/utilities"; import hbs from "discourse/widgets/hbs-compiler"; import { durationTiny } from "discourse/lib/formatter"; +import { prioritizeNameInUx } from "discourse/lib/settings"; function transformWithCallbacks(post) { let transformed = transformBasicPost(post); @@ -454,9 +455,9 @@ createWidget("post-notice", { html(attrs) { const user = - this.siteSettings.prioritize_username_in_ux || !attrs.name - ? attrs.username - : attrs.name; + this.siteSettings.display_name_on_posts && prioritizeNameInUx(attrs.name) + ? attrs.name + : attrs.username; let text, icon; if (attrs.noticeType === "custom") { icon = "user-shield"; diff --git a/app/assets/javascripts/discourse/widgets/poster-name.js.es6 b/app/assets/javascripts/discourse/widgets/poster-name.js.es6 index 85c98e19cd..7babc63ee1 100644 --- a/app/assets/javascripts/discourse/widgets/poster-name.js.es6 +++ b/app/assets/javascripts/discourse/widgets/poster-name.js.es6 @@ -2,6 +2,7 @@ import { iconNode } from "discourse-common/lib/icon-library"; import { createWidget, applyDecorators } from "discourse/widgets/widget"; import { h } from "virtual-dom"; import { formatUsername } from "discourse/lib/utilities"; +import { prioritizeNameInUx } from "discourse/lib/settings"; let sanitizeName = function(name) { return name.toLowerCase().replace(/[\s\._-]/g, ""); @@ -66,9 +67,7 @@ export default createWidget("poster-name", { const name = attrs.name; const nameFirst = this.siteSettings.display_name_on_posts && - !this.siteSettings.prioritize_username_in_ux && - name && - name.trim().length > 0; + prioritizeNameInUx(name, this.siteSettings); const classNames = nameFirst ? ["first", "full-name"] : ["first", "username"]; diff --git a/test/javascripts/widgets/post-test.js.es6 b/test/javascripts/widgets/post-test.js.es6 index aa5d829194..9489c9cc66 100644 --- a/test/javascripts/widgets/post-test.js.es6 +++ b/test/javascripts/widgets/post-test.js.es6 @@ -875,6 +875,7 @@ widgetTest("post notice - with username", { beforeEach() { const twoDaysAgo = new Date(); twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); + this.siteSettings.display_name_on_posts = false; this.siteSettings.prioritize_username_in_ux = true; this.siteSettings.old_post_notice_days = 14; this.set("args", { @@ -901,6 +902,7 @@ widgetTest("post notice - with username", { widgetTest("post notice - with name", { template: '{{mount-widget widget="post" args=args}}', beforeEach() { + this.siteSettings.display_name_on_posts = true; this.siteSettings.prioritize_username_in_ux = false; this.siteSettings.old_post_notice_days = 14; this.set("args", { From 30c491500a162f51a0698f14c3f399f3deb55009 Mon Sep 17 00:00:00 2001 From: Dan Ungureanu Date: Fri, 19 Jul 2019 12:46:10 +0300 Subject: [PATCH 057/441] FEATURE: Permit users who had no penalties in last 6 months to be TL3. (#7892) Previously, users who had any penalties (were silenced or suspended) were not allowed to promote to Trust Level 3. There is also a more subtle change here: if users were silenced or suspended and then the operation was reverted (user was un-silenced or un-suspended), then it would have been like the user was never penalized in the first place. This is no longer the case. To forgive a user earlier, administrators can use "Clear Penalty History" feature. Lastly, Jobs::UnsilenceUsers will automatically unsilence any users who should no longer be silenced (silenced_till < now()). This made it so silence_count - unsilence_count == 0 for any user who is not silenced, which defeated the purpose of this TL3 requirement. --- app/models/trust_level3_requirements.rb | 24 +++++---------- config/locales/client.en.yml | 4 +-- spec/models/trust_level3_requirements_spec.rb | 29 ++++++++++++++++--- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/app/models/trust_level3_requirements.rb b/app/models/trust_level3_requirements.rb index 0690a582ac..bfd70fea0f 100644 --- a/app/models/trust_level3_requirements.rb +++ b/app/models/trust_level3_requirements.rb @@ -20,6 +20,7 @@ class TrustLevel3Requirements include ActiveModel::Serialization LOW_WATER_MARK = 0.9 + FORGIVENESS_PERIOD = 6.months attr_accessor :days_visited, :min_days_visited, :num_topics_replied_to, :min_topics_replied_to, @@ -99,29 +100,18 @@ class TrustLevel3Requirements args = { user_id: @user.id, silence_user: UserHistory.actions[:silence_user], - unsilence_user: UserHistory.actions[:unsilence_user], suspend_user: UserHistory.actions[:suspend_user], - unsuspend_user: UserHistory.actions[:unsuspend_user] + since: FORGIVENESS_PERIOD.ago } sql = <<~SQL - SELECT SUM( - CASE - WHEN action = :silence_user THEN 1 - WHEN action = :unsilence_user THEN -1 - ELSE 0 - END - ) AS silence_count, - SUM( - CASE - WHEN action = :suspend_user THEN 1 - WHEN action = :unsuspend_user THEN -1 - ELSE 0 - END - ) AS suspend_count + SELECT + SUM(CASE WHEN action = :silence_user THEN 1 ELSE 0 END) AS silence_count, + SUM(CASE WHEN action = :suspend_user THEN 1 ELSE 0 END) AS suspend_count FROM user_histories AS uh WHERE uh.target_user_id = :user_id - AND uh.action IN (:silence_user, :unsilence_user, :suspend_user, :unsuspend_user) + AND uh.action IN (:silence_user, :suspend_user) + AND uh.created_at > :since SQL PenaltyCounts.new(DB.query_hash(sql, args).first) diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index a728f185d2..1ebecc9fb4 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -4094,8 +4094,8 @@ en: likes_received: "Likes Received" likes_received_days: "Likes Received: unique days" likes_received_users: "Likes Received: unique users" - suspended: "Suspended (all time)" - silenced: "Silenced (all time)" + suspended: "Suspended (last 6 months)" + silenced: "Silenced (last 6 months)" qualifies: "Qualifies for trust level 3." does_not_qualify: "Doesn't qualify for trust level 3." will_be_promoted: "Will be promoted soon." diff --git a/spec/models/trust_level3_requirements_spec.rb b/spec/models/trust_level3_requirements_spec.rb index eea2e073ac..3f124019a3 100644 --- a/spec/models/trust_level3_requirements_spec.rb +++ b/spec/models/trust_level3_requirements_spec.rb @@ -24,18 +24,18 @@ describe TrustLevel3Requirements do describe "penalty_counts" do - it "returns if the user has ever been silenced" do + it "returns if the user has been silenced in last 6 months" do expect(tl3_requirements.penalty_counts.silenced).to eq(0) expect(tl3_requirements.penalty_counts.total).to eq(0) UserSilencer.new(user, moderator).silence expect(tl3_requirements.penalty_counts.silenced).to eq(1) expect(tl3_requirements.penalty_counts.total).to eq(1) UserSilencer.new(user, moderator).unsilence - expect(tl3_requirements.penalty_counts.silenced).to eq(0) - expect(tl3_requirements.penalty_counts.total).to eq(0) + expect(tl3_requirements.penalty_counts.silenced).to eq(1) + expect(tl3_requirements.penalty_counts.total).to eq(1) end - it "returns if the user has ever been suspended" do + it "returns if the user has been suspended in last 6 months" do user.save! expect(tl3_requirements.penalty_counts.suspended).to eq(0) @@ -54,8 +54,29 @@ describe TrustLevel3Requirements do action: UserHistory.actions[:unsuspend_user] ) + expect(tl3_requirements.penalty_counts.suspended).to eq(1) + expect(tl3_requirements.penalty_counts.total).to eq(1) + end + + it "does not return if the user been silenced or suspended over 6 months ago" do + freeze_time 1.year.ago do + UserSilencer.new(user, moderator, silenced_till: 1.months.from_now).silence + UserHistory.create!(target_user_id: user.id, action: UserHistory.actions[:suspend_user]) + end + + expect(tl3_requirements.penalty_counts.silenced).to eq(0) expect(tl3_requirements.penalty_counts.suspended).to eq(0) expect(tl3_requirements.penalty_counts.total).to eq(0) + + freeze_time 3.months.ago do + UserSilencer.new(user).unsilence + UserSilencer.new(user, moderator, silenced_till: 1.months.from_now).silence + UserHistory.create!(target_user_id: user.id, action: UserHistory.actions[:suspend_user]) + end + + expect(tl3_requirements.penalty_counts.silenced).to eq(1) + expect(tl3_requirements.penalty_counts.suspended).to eq(1) + expect(tl3_requirements.penalty_counts.total).to eq(2) end end From 2f6ce29736e08da82d405c359c569de44d7898d6 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Fri, 19 Jul 2019 16:24:58 +0530 Subject: [PATCH 058/441] FIX: do not request refresh on 'log out all' request --- app/controllers/users_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index bd0bf7ae62..9604762214 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1224,12 +1224,12 @@ class UsersController < ApplicationController # The user should not be able to revoke the auth token of current session. raise Discourse::InvalidParameters.new(:token_id) if guardian.auth_token == token.auth_token UserAuthToken.where(id: params[:token_id], user_id: user.id).each(&:destroy!) + + MessageBus.publish "/file-change", ["refresh"], user_ids: [user.id] else UserAuthToken.where(user_id: user.id).each(&:destroy!) end - MessageBus.publish "/file-change", ["refresh"], user_ids: [user.id] - render json: success_json end From 1f1b3e99d19fa1e15b739a33f4274d435537c9b3 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Fri, 19 Jul 2019 16:39:44 +0530 Subject: [PATCH 059/441] UX: update invite 'not found' message --- app/controllers/invites_controller.rb | 4 ++-- config/locales/server.en.yml | 3 ++- spec/requests/invites_controller_spec.rb | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index d569359b54..cef597971f 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -35,7 +35,7 @@ class InvitesController < ApplicationController render layout: 'no_ember' end else - flash.now[:error] = I18n.t('invite.not_found') + flash.now[:error] = I18n.t('invite.not_found', base_url: Discourse.base_url) render layout: 'no_ember' end end @@ -70,7 +70,7 @@ class InvitesController < ApplicationController } end else - render json: { success: false, message: I18n.t('invite.not_found') } + render json: { success: false, message: I18n.t('invite.not_found_json') } end end diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 6fa93ed7b2..4999a5f823 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -207,7 +207,8 @@ en: <<: *errors invite: - not_found: "Your invite token is invalid. Please contact the site's administrator." + not_found: "Your invite token is invalid. Please contact staff." + not_found_json: "Your invite token is invalid. Please contact staff." not_found_template: |

    Your invite to %{site_name} has already been redeemed.

    diff --git a/spec/requests/invites_controller_spec.rb b/spec/requests/invites_controller_spec.rb index 8fe51106fa..23c7f173b0 100644 --- a/spec/requests/invites_controller_spec.rb +++ b/spec/requests/invites_controller_spec.rb @@ -14,7 +14,7 @@ describe InvitesController do body = response.body expect(body).to_not have_tag(:script, with: { src: '/assets/application.js' }) - expect(CGI.unescapeHTML(body)).to include(I18n.t('invite.not_found', site_name: SiteSetting.title, base_url: Discourse.base_url)) + expect(CGI.unescapeHTML(body)).to include(I18n.t('invite.not_found', base_url: Discourse.base_url)) end it "renders the accept invite page if invite exists" do @@ -210,7 +210,7 @@ describe InvitesController do expect(response.status).to eq(200) json = JSON.parse(response.body) expect(json["success"]).to eq(false) - expect(json["message"]).to eq(I18n.t('invite.not_found')) + expect(json["message"]).to eq(I18n.t('invite.not_found_json')) expect(session[:current_user_id]).to be_blank end end @@ -245,7 +245,7 @@ describe InvitesController do expect(response.status).to eq(200) json = JSON.parse(response.body) expect(json["success"]).to eq(false) - expect(json["message"]).to eq(I18n.t('invite.not_found')) + expect(json["message"]).to eq(I18n.t('invite.not_found_json')) expect(session[:current_user_id]).to be_blank end end From b73bd7fc1bf96f54a0b64c2fe5ff1318a8755fb5 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Fri, 19 Jul 2019 15:13:05 +0200 Subject: [PATCH 060/441] FIX: Always backup local uploads in addition to files stored on S3 --- lib/backup_restore/backuper.rb | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/backup_restore/backuper.rb b/lib/backup_restore/backuper.rb index d93ac58644..1306d365e7 100644 --- a/lib/backup_restore/backuper.rb +++ b/lib/backup_restore/backuper.rb @@ -252,11 +252,8 @@ module BackupRestore ) end - if SiteSetting.Upload.enable_s3_uploads - add_remote_uploads_to_archive(tar_filename) - else - add_local_uploads_to_archive(tar_filename) - end + add_local_uploads_to_archive(tar_filename) + add_remote_uploads_to_archive(tar_filename) if SiteSetting.Upload.enable_s3_uploads remove_tmp_directory @@ -280,7 +277,7 @@ module BackupRestore failure_message: "Failed to archive uploads.", success_status_codes: [0, 1] ) else - log "No uploads found, skipping archiving uploads..." + log "No local uploads found. Skipping archiving of local uploads..." end end end @@ -323,7 +320,7 @@ module BackupRestore end end - log "No uploads found, skipping archiving uploads..." if count == 0 + log "No uploads found on S3. Skipping archiving of uploads stored on S3..." if count == 0 end def upload_archive From 5a3a6824c40ac0e8d1fcf289611de52bb7b72bd9 Mon Sep 17 00:00:00 2001 From: Kris Date: Fri, 19 Jul 2019 10:39:38 -0400 Subject: [PATCH 061/441] UX: Refactor avatar upload modal for better mobile spacing --- .../components/avatar-uploader.js.es6 | 7 -- .../components/images-uploader.js.es6 | 4 +- .../templates/components/avatar-uploader.hbs | 14 ++-- .../templates/modal/avatar-selector.hbs | 2 +- app/assets/stylesheets/common/base/user.scss | 71 ++++++++++++++----- config/locales/client.en.yml | 1 - 6 files changed, 66 insertions(+), 33 deletions(-) diff --git a/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 b/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 index 5fe9aecec6..7349ce5a6b 100644 --- a/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 @@ -6,13 +6,6 @@ export default Ember.Component.extend(UploadMixin, { tagName: "span", imageIsNotASquare: false, - @computed("uploading") - uploadButtonText(uploading) { - return uploading - ? I18n.t("uploading") - : I18n.t("user.change_avatar.upload_picture"); - }, - validateUploadedFilesOptions() { return { imagesOnly: true }; }, diff --git a/app/assets/javascripts/discourse/components/images-uploader.js.es6 b/app/assets/javascripts/discourse/components/images-uploader.js.es6 index 5fd63d0d01..23b8fbe688 100644 --- a/app/assets/javascripts/discourse/components/images-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/images-uploader.js.es6 @@ -7,9 +7,7 @@ export default Ember.Component.extend(UploadMixin, { @computed("uploading") uploadButtonText(uploading) { - return uploading - ? I18n.t("uploading") - : I18n.t("user.change_avatar.upload_picture"); + return uploading ? I18n.t("uploading") : I18n.t("upload"); }, validateUploadedFilesOptions() { diff --git a/app/assets/javascripts/discourse/templates/components/avatar-uploader.hbs b/app/assets/javascripts/discourse/templates/components/avatar-uploader.hbs index e980fc5062..69c627e7ba 100644 --- a/app/assets/javascripts/discourse/templates/components/avatar-uploader.hbs +++ b/app/assets/javascripts/discourse/templates/components/avatar-uploader.hbs @@ -1,10 +1,14 @@ -
  • {{radio-button id="gravatar" name="avatar" value="gravatar" selection=selected}} - + {{d-button action=(action "refreshGravatar") title="user.change_avatar.refresh_gravatar_title" diff --git a/app/assets/stylesheets/common/base/user.scss b/app/assets/stylesheets/common/base/user.scss index cfac1c76ea..617d3c9c1d 100644 --- a/app/assets/stylesheets/common/base/user.scss +++ b/app/assets/stylesheets/common/base/user.scss @@ -392,26 +392,65 @@ } .avatar-selector { - label.radio { - padding-left: 10px; - } .avatar-choice { - min-height: 40px; + display: grid; + grid-template-columns: 2em 1fr auto; + grid-template-rows: auto auto; + align-items: center; + &:not(:last-of-type) { + margin-bottom: 0.75em; + } + span { + word-break: break-word; // Prevents long emails from breaking the modal width + } + input[type="radio"] { + margin-top: 0; + } + button { + margin-left: auto; + } } - label { - display: inline-block; - margin-right: 10px; + $label-max-width: 300px; + label.radio { + display: flex; + align-items: center; + max-width: $label-max-width; + margin: 0 0.5em 0 0; + padding: 0; + .avatar { + flex: 0 0 auto; + margin: 0 0.75em 0 0; + } } - #avatar-input { - width: 0; - height: 0; - overflow: hidden; - } - .avatar { - margin: 5px 10px 5px 0; - } - p.error { + .error { color: $danger; + margin: 0; + max-width: calc(#{$label-max-width} - 20px); + grid-column-start: 2; + grid-column-end: 3; + } + + // IE11 Support + .avatar-choice { + display: -ms-grid; + -ms-grid-columns: 2em 1fr auto; + -ms-grid-rows: auto auto; + input[type="radio"] { + -ms-grid-row: 1; + -ms-grid-column: 1; + } + label.radio { + -ms-grid-row: 1; + -ms-grid-column: 2; + } + button { + -ms-grid-row: 1; + -ms-grid-column: 3; + } + .error { + -ms-grid-row: 2; + -ms-grid-column-span: 3; + } } } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 1ebecc9fb4..ce1738906e 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -991,7 +991,6 @@ en: uploaded_avatar: "Custom picture" uploaded_avatar_empty: "Add a custom picture" upload_title: "Upload your picture" - upload_picture: "Upload Picture" image_is_not_a_square: "Warning: we've cropped your image; width and height were not equal." change_profile_background: From 9ba2c7cd8b0f61e7afc6c16b341a8deb617a5966 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Fri, 19 Jul 2019 18:15:38 +0300 Subject: [PATCH 062/441] FIX: Set a minimum reading time per post. (#7842) Topics containing only images could generate a reading time of zero minutes. --- .../widgets/toggle-topic-summary.js.es6 | 11 ++++++++--- lib/topic_view.rb | 9 ++++++++- spec/components/topic_view_spec.rb | 17 +++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/discourse/widgets/toggle-topic-summary.js.es6 b/app/assets/javascripts/discourse/widgets/toggle-topic-summary.js.es6 index 30adab9773..c712a81a31 100644 --- a/app/assets/javascripts/discourse/widgets/toggle-topic-summary.js.es6 +++ b/app/assets/javascripts/discourse/widgets/toggle-topic-summary.js.es6 @@ -1,15 +1,20 @@ import RawHtml from "discourse/widgets/raw-html"; import { createWidget } from "discourse/widgets/widget"; +const MIN_POST_READ_TIME = 4; + createWidget("toggle-summary-description", { description(attrs) { if (attrs.topicSummaryEnabled) { return I18n.t("summary.enabled_description"); } - if (attrs.topicWordCount) { - const readingTime = Math.floor( - attrs.topicWordCount / this.siteSettings.read_time_word_count + if (attrs.topicWordCount && this.siteSettings.read_time_word_count > 0) { + const readingTime = Math.ceil( + Math.max( + attrs.topicWordCount / this.siteSettings.read_time_word_count, + (attrs.topicPostsCount * MIN_POST_READ_TIME) / 60 + ) ); return I18n.t("summary.description_time", { replyCount: attrs.topicReplyCount, diff --git a/lib/topic_view.rb b/lib/topic_view.rb index 6f12cda012..060eaf79ba 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -7,6 +7,7 @@ require_dependency 'gaps' class TopicView MEGA_TOPIC_POSTS_COUNT = 10000 + MIN_POST_READ_TIME = 4.0 attr_reader( :topic, @@ -208,7 +209,13 @@ class TopicView def read_time return nil if @post_number > 1 # only show for topic URLs - (@topic.word_count / SiteSetting.read_time_word_count).floor if @topic.word_count + + if @topic.word_count && SiteSetting.read_time_word_count > 0 + [ + @topic.word_count / SiteSetting.read_time_word_count, + @topic.posts_count * MIN_POST_READ_TIME / 60 + ].max.ceil + end end def like_count diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb index 736bf1ef58..aecb9a9466 100644 --- a/spec/components/topic_view_spec.rb +++ b/spec/components/topic_view_spec.rb @@ -694,4 +694,21 @@ describe TopicView do expect(topic_view.last_post_id).to eq(p3.id) end end + + describe '#read_time' do + let!(:post) { Fabricate(:post, topic: topic) } + + before do + PostCreator.create!(Discourse.system_user, topic_id: topic.id, raw: "![image|100x100](upload://upload.png)") + topic_view.topic.reload + end + + it 'should return the right read time' do + SiteSetting.read_time_word_count = 500 + expect(topic_view.read_time).to eq(1) + + SiteSetting.read_time_word_count = 0 + expect(topic_view.read_time).to eq(nil) + end + end end From 907578978361625f4e1575ec8f99eb0f9dc20492 Mon Sep 17 00:00:00 2001 From: Kris Date: Fri, 19 Jul 2019 11:46:20 -0400 Subject: [PATCH 063/441] IE11 fix for b73bd7f --- .../javascripts/discourse/templates/modal/avatar-selector.hbs | 1 + app/assets/stylesheets/common/base/user.scss | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/templates/modal/avatar-selector.hbs b/app/assets/javascripts/discourse/templates/modal/avatar-selector.hbs index f7b61066c4..59bb27619e 100644 --- a/app/assets/javascripts/discourse/templates/modal/avatar-selector.hbs +++ b/app/assets/javascripts/discourse/templates/modal/avatar-selector.hbs @@ -41,6 +41,7 @@ uploadedAvatarTemplate=user.custom_avatar_template uploadedAvatarId=user.custom_avatar_upload_id uploading=uploading + class="avatar-uploader" done=(action "uploadComplete")}}
    {{/if}} diff --git a/app/assets/stylesheets/common/base/user.scss b/app/assets/stylesheets/common/base/user.scss index 617d3c9c1d..da5bbb3ef3 100644 --- a/app/assets/stylesheets/common/base/user.scss +++ b/app/assets/stylesheets/common/base/user.scss @@ -443,7 +443,7 @@ -ms-grid-row: 1; -ms-grid-column: 2; } - button { + span.avatar-uploader { -ms-grid-row: 1; -ms-grid-column: 3; } From 8dd3cbfcb954e853fde2fe04e0bb220b5a5eb140 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Fri, 19 Jul 2019 11:52:50 -0400 Subject: [PATCH 064/441] FEATURE: Allow choice of category when making a PM public (#7907) * FEATURE: Allow choice of category when making a PM public Previously it would default to uncategorized, which was not ideal on some forums. This gives the staff member more choice about what they'd like to do. * Make the optional category more explicit * Joffrey's feedback --- .../convert-to-public-topic.js.es6 | 26 +++++++++++++++++++ .../discourse/controllers/topic.js.es6 | 10 +++++-- .../javascripts/discourse/models/topic.js.es6 | 10 ++++--- .../modal/convert-to-public-topic.hbs | 13 ++++++++++ .../convert-to-public-topic-modal.scss | 5 ++++ app/controllers/topics_controller.rb | 2 +- app/models/topic.rb | 4 +-- config/locales/client.en.yml | 3 +++ spec/requests/topics_controller_spec.rb | 5 +++- test/javascripts/acceptance/topic-test.js.es6 | 14 ++++++++++ .../helpers/create-pretender.js.es6 | 4 +++ 11 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 app/assets/javascripts/discourse/controllers/convert-to-public-topic.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/modal/convert-to-public-topic.hbs create mode 100644 app/assets/stylesheets/common/components/convert-to-public-topic-modal.scss diff --git a/app/assets/javascripts/discourse/controllers/convert-to-public-topic.js.es6 b/app/assets/javascripts/discourse/controllers/convert-to-public-topic.js.es6 new file mode 100644 index 0000000000..58ccfbcf88 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/convert-to-public-topic.js.es6 @@ -0,0 +1,26 @@ +import { popupAjaxError } from "discourse/lib/ajax-error"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; + +export default Ember.Controller.extend(ModalFunctionality, { + publicCategoryId: null, + saving: true, + + onShow() { + this.setProperties({ publicCategoryId: null, saving: false }); + }, + + actions: { + makePublic() { + let topic = this.model; + topic + .convertTopic("public", { categoryId: this.publicCategoryId }) + .then(() => { + topic.set("archetype", "regular"); + topic.set("category_id", this.publicCategoryId); + this.appEvents.trigger("header:show-topic", topic); + this.send("closeModal"); + }) + .catch(popupAjaxError); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 1e86063b0d..d4ef29adc9 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -1073,11 +1073,17 @@ export default Ember.Controller.extend(bufferedProperty("model"), { }, convertToPublicTopic() { - this.model.convertTopic("public"); + showModal("convert-to-public-topic", { + model: this.model, + modalClass: "convert-to-public-topic" + }); }, convertToPrivateMessage() { - this.model.convertTopic("private"); + this.model + .convertTopic("private") + .then(() => window.location.reload()) + .catch(popupAjaxError); }, removeFeaturedLink() { diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index 4d3056b3bf..ca2c4350b7 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -599,10 +599,12 @@ const Topic = RestModel.extend({ }); }, - convertTopic(type) { - return ajax(`/t/${this.id}/convert-topic/${type}`, { type: "PUT" }) - .then(() => window.location.reload()) - .catch(popupAjaxError); + convertTopic(type, opts) { + let args = { type: "PUT" }; + if (opts && opts.categoryId) { + args.data = { category_id: opts.categoryId }; + } + return ajax(`/t/${this.id}/convert-topic/${type}`, args); }, resetBumpDate() { diff --git a/app/assets/javascripts/discourse/templates/modal/convert-to-public-topic.hbs b/app/assets/javascripts/discourse/templates/modal/convert-to-public-topic.hbs new file mode 100644 index 0000000000..b13804bb0b --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/convert-to-public-topic.hbs @@ -0,0 +1,13 @@ +{{#d-modal-body title="topic.make_public.title"}} + +
    + {{i18n "topic.make_public.choose_category"}} +
    + {{category-chooser value=publicCategoryId}} + +{{/d-modal-body}} + + diff --git a/app/assets/stylesheets/common/components/convert-to-public-topic-modal.scss b/app/assets/stylesheets/common/components/convert-to-public-topic-modal.scss new file mode 100644 index 0000000000..50a32a8131 --- /dev/null +++ b/app/assets/stylesheets/common/components/convert-to-public-topic-modal.scss @@ -0,0 +1,5 @@ +.convert-to-public-topic .modal-body { + .instructions { + margin-bottom: 1em; + } +} diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 74789f51d8..8116dfb19e 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -821,7 +821,7 @@ class TopicsController < ApplicationController guardian.ensure_can_convert_topic!(topic) if params[:type] == "public" - converted_topic = topic.convert_to_public_topic(current_user) + converted_topic = topic.convert_to_public_topic(current_user, category_id: params[:category_id]) else converted_topic = topic.convert_to_private_message(current_user) end diff --git a/app/models/topic.rb b/app/models/topic.rb index 648da51a74..cb125080a5 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -1307,8 +1307,8 @@ class Topic < ActiveRecord::Base builder.query_single.first.to_i end - def convert_to_public_topic(user) - public_topic = TopicConverter.new(self, user).convert_to_public_topic + def convert_to_public_topic(user, category_id: nil) + public_topic = TopicConverter.new(self, user).convert_to_public_topic(category_id) add_small_action(user, "public_topic") if public_topic public_topic end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index ce1738906e..c092fd6768 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2171,6 +2171,9 @@ en: title: "Flag" help: "privately flag this topic for attention or send a private notification about it" success_message: "You successfully flagged this topic." + make_public: + title: "Convert to Public Topic" + choose_category: "Please choose a category for the public topic:" feature_topic: title: "Feature this topic" diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index ad718e80f3..a5440b1e78 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -2230,12 +2230,15 @@ RSpec.describe TopicsController do end context "success" do + fab!(:category) { Fabricate(:category) } + it "returns success" do sign_in(admin) - put "/t/#{topic.id}/convert-topic/public.json" + put "/t/#{topic.id}/convert-topic/public.json?category_id=#{category.id}" topic.reload expect(topic.archetype).to eq(Archetype.default) + expect(topic.category_id).to eq(category.id) expect(response.status).to eq(200) result = ::JSON.parse(response.body) diff --git a/test/javascripts/acceptance/topic-test.js.es6 b/test/javascripts/acceptance/topic-test.js.es6 index 0e9c235000..3a3b6de1fa 100644 --- a/test/javascripts/acceptance/topic-test.js.es6 +++ b/test/javascripts/acceptance/topic-test.js.es6 @@ -213,6 +213,20 @@ QUnit.test("remove featured link", async assert => { // assert.ok(!exists('.title-wrapper .topic-featured-link'), 'link is gone'); }); +QUnit.test("Converting to a public topic", async assert => { + await visit("/t/test-pm/34"); + assert.ok(exists(".private_message")); + await click(".toggle-admin-menu"); + await click(".topic-admin-convert button"); + + let categoryChooser = selectKit(".convert-to-public-topic .category-chooser"); + await categoryChooser.expand(); + await categoryChooser.selectRowByValue(21); + + await click(".convert-to-public-topic .btn-primary"); + assert.ok(!exists(".private_message")); +}); + QUnit.test("Unpinning unlisted topic", async assert => { await visit("/t/internationalization-localization/280"); diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6 index 64a70ccdb9..781cdb919c 100644 --- a/test/javascripts/helpers/create-pretender.js.es6 +++ b/test/javascripts/helpers/create-pretender.js.es6 @@ -150,6 +150,10 @@ export default function() { }); }); + this.put("/t/34/convert-topic/public", () => { + return response({}); + }); + this.put("/t/280/make-banner", () => { return response({}); }); From e47e0af123b43dfba622c3f984a3f7f68578c8ed Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Fri, 19 Jul 2019 11:56:14 -0400 Subject: [PATCH 065/441] FEATURE: Allow viewing of raw emails for reviewable queued posts (#7910) If a post arrives via email but must be reviewed, we now show an icon that can be clicked to view the raw contents of the email. This is useful if Discourse's email parser is acting odd and the user reviewing the post wants to know what the original contents were before approving/rejecting the post. --- .../components/reviewable-queued-post.js.es6 | 9 ++++++ .../components/reviewable-queued-post.hbs | 5 ++++ .../stylesheets/common/base/reviewables.scss | 4 +++ .../reviewable_queued_post_serializer.rb | 4 ++- lib/new_post_manager.rb | 2 ++ spec/components/new_post_manager_spec.rb | 30 +++++++++++++++++++ .../reviewable_queued_post_serializer_spec.rb | 2 ++ 7 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/discourse/components/reviewable-queued-post.js.es6 diff --git a/app/assets/javascripts/discourse/components/reviewable-queued-post.js.es6 b/app/assets/javascripts/discourse/components/reviewable-queued-post.js.es6 new file mode 100644 index 0000000000..1255c5bddc --- /dev/null +++ b/app/assets/javascripts/discourse/components/reviewable-queued-post.js.es6 @@ -0,0 +1,9 @@ +import showModal from "discourse/lib/show-modal"; + +export default Ember.Component.extend({ + actions: { + showRawEmail() { + showModal("raw-email").set("rawEmail", this.reviewable.payload.raw_email); + } + } +}); diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-queued-post.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-queued-post.hbs index fb54fe9763..30aff53c6a 100644 --- a/app/assets/javascripts/discourse/templates/components/reviewable-queued-post.hbs +++ b/app/assets/javascripts/discourse/templates/components/reviewable-queued-post.hbs @@ -5,6 +5,11 @@
  • {{category-badge reviewable.category}} {{reviewable-tags tags=reviewable.payload.tags tagName=''}} + {{#if reviewable.payload.via_email}} + + {{d-icon "far-envelope" title="post.via_email"}} + + {{/if}} {{/reviewable-topic-link}}
    diff --git a/app/assets/stylesheets/common/base/reviewables.scss b/app/assets/stylesheets/common/base/reviewables.scss index 018ea923b8..31071ac39d 100644 --- a/app/assets/stylesheets/common/base/reviewables.scss +++ b/app/assets/stylesheets/common/base/reviewables.scss @@ -364,6 +364,10 @@ } .reviewable-item { + .show-raw-email { + color: $primary-medium; + font-size: $font-down-2; + } .post-title { background-color: yellow; } diff --git a/app/serializers/reviewable_queued_post_serializer.rb b/app/serializers/reviewable_queued_post_serializer.rb index ebdd4d0c68..c2ce6fecf2 100644 --- a/app/serializers/reviewable_queued_post_serializer.rb +++ b/app/serializers/reviewable_queued_post_serializer.rb @@ -16,7 +16,9 @@ class ReviewableQueuedPostSerializer < ReviewableSerializer :is_poll, :typing_duration_msecs, :composer_open_duration_msecs, - :tags + :tags, + :via_email, + :raw_email ) def reply_to_post_number diff --git a/lib/new_post_manager.rb b/lib/new_post_manager.rb index ba5e468401..26712309cd 100644 --- a/lib/new_post_manager.rb +++ b/lib/new_post_manager.rb @@ -201,6 +201,8 @@ class NewPostManager %w(typing_duration_msecs composer_open_duration_msecs reply_to_post_number).each do |a| payload[a] = @args[a].to_i if @args[a] end + payload[:via_email] = true if !!@args[:via_email] + payload[:raw_email] = @args[:raw_email] if @args[:raw_email].present? reviewable = ReviewableQueuedPost.new( created_by: @user, diff --git a/spec/components/new_post_manager_spec.rb b/spec/components/new_post_manager_spec.rb index 269ebcbb50..547f30cf62 100644 --- a/spec/components/new_post_manager_spec.rb +++ b/spec/components/new_post_manager_spec.rb @@ -407,4 +407,34 @@ describe NewPostManager do end end end + + context "via email" do + let(:manager) do + NewPostManager.new( + topic.user, + raw: 'this is emailed content', + topic_id: topic.id, + via_email: true, + raw_email: 'raw email contents' + ) + end + + before do + SiteSetting.approve_post_count = 100 + topic.user.trust_level = 0 + end + + it "will store via_email and raw_email in the enqueued post" do + result = manager.perform + expect(result.action).to eq(:enqueued) + expect(result.reviewable).to be_present + expect(result.reviewable.payload['via_email']).to eq(true) + expect(result.reviewable.payload['raw_email']).to eq('raw email contents') + + post = result.reviewable.perform(Discourse.system_user, :approve_post).created_post + expect(post.via_email).to eq(true) + expect(post.raw_email).to eq("raw email contents") + end + end + end diff --git a/spec/serializers/reviewable_queued_post_serializer_spec.rb b/spec/serializers/reviewable_queued_post_serializer_spec.rb index 83bcaac4df..8fa0a5acd2 100644 --- a/spec/serializers/reviewable_queued_post_serializer_spec.rb +++ b/spec/serializers/reviewable_queued_post_serializer_spec.rb @@ -49,6 +49,8 @@ describe ReviewableQueuedPostSerializer do expect(payload['raw']).to eq('hello world post contents.') expect(payload['title']).to be_blank + expect(payload['via_email']).to eq(true) + expect(payload['raw_email']).to eq('store_me') expect(json[:topic_id]).to eq(reviewable.topic_id) expect(json[:topic_url]).to eq(reviewable.topic.url) expect(json[:can_edit]).to eq(true) From eb26bee046c264602933f43d29c734a7e266928e Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Fri, 19 Jul 2019 15:17:58 -0300 Subject: [PATCH 066/441] DEV: group_list site settings should store IDs instead of group names (#7860) * DEV: group_list site settings should store IDs instead of group names * Ship site setting to know when we should migrate group_list settings * Migrate existing group_list site settings * Bump migration timestamp and don't set null when migrating is not possible. --- .../site-settings/group-list.js.es6 | 4 ++- .../components/site-settings/group-list.hbs | 2 +- ...133743_migrate_group_list_site_settings.rb | 27 +++++++++++++++++++ lib/discourse.rb | 1 - .../components/group-list-setting-test.js.es6 | 8 +++--- 5 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 db/migrate/20190717133743_migrate_group_list_site_settings.rb diff --git a/app/assets/javascripts/admin/components/site-settings/group-list.js.es6 b/app/assets/javascripts/admin/components/site-settings/group-list.js.es6 index 4e60f1da08..0ab60a3436 100644 --- a/app/assets/javascripts/admin/components/site-settings/group-list.js.es6 +++ b/app/assets/javascripts/admin/components/site-settings/group-list.js.es6 @@ -3,6 +3,8 @@ import computed from "ember-addons/ember-computed-decorators"; export default Ember.Component.extend({ @computed() groupChoices() { - return this.site.get("groups").map(g => g.name); + return this.site.get("groups").map(g => { + return { name: g.name, id: g.id.toString() }; + }); } }); diff --git a/app/assets/javascripts/admin/templates/components/site-settings/group-list.hbs b/app/assets/javascripts/admin/templates/components/site-settings/group-list.hbs index adb6ec5098..005f14398e 100644 --- a/app/assets/javascripts/admin/templates/components/site-settings/group-list.hbs +++ b/app/assets/javascripts/admin/templates/components/site-settings/group-list.hbs @@ -1,3 +1,3 @@ -{{list-setting settingValue=value choices=groupChoices settingName=setting.setting}} +{{list-setting settingValue=value choices=groupChoices settingName='name'}} {{setting-validation-message message=validationMessage}}
    {{{unbound setting.description}}}
    diff --git a/db/migrate/20190717133743_migrate_group_list_site_settings.rb b/db/migrate/20190717133743_migrate_group_list_site_settings.rb new file mode 100644 index 0000000000..6a81fe17b0 --- /dev/null +++ b/db/migrate/20190717133743_migrate_group_list_site_settings.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class MigrateGroupListSiteSettings < ActiveRecord::Migration[5.2] + def up + migrate_value(:name, :id) + end + + def down + migrate_value(:id, :name) + end + + def migrate_value(from, to) + cast_type = from == :id ? '::int[]' : '' + DB.exec <<~SQL + UPDATE site_settings + SET value = COALESCE(array_to_string( + ( + SELECT array_agg(groups.#{to}) + FROM groups + WHERE groups.#{from} = ANY (string_to_array(site_settings.value, '|', '')#{cast_type}) + ), + '|', '' + ), site_settings.value) + WHERE data_type = #{SiteSettings::TypeSupervisor.types[:group_list]} + SQL + end +end diff --git a/lib/discourse.rb b/lib/discourse.rb index d413c1a6af..b7cf9a91c0 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -743,5 +743,4 @@ module Discourse def self.skip_post_deployment_migrations? ['1', 'true'].include?(ENV["SKIP_POST_DEPLOYMENT_MIGRATIONS"]&.to_s) end - end diff --git a/test/javascripts/admin/components/group-list-setting-test.js.es6 b/test/javascripts/admin/components/group-list-setting-test.js.es6 index e6be2313d3..f998ccbd0b 100644 --- a/test/javascripts/admin/components/group-list-setting-test.js.es6 +++ b/test/javascripts/admin/components/group-list-setting-test.js.es6 @@ -32,7 +32,7 @@ componentTest("default", { setting: "foo_bar", type: "group_list", validValues: undefined, - value: "Donuts" + value: "1" }) ); }, @@ -42,16 +42,16 @@ componentTest("default", { assert.equal( subject.header().value(), - "Donuts", + "1", "it selects the setting's value" ); await subject.expand(); - await subject.selectRowByValue("Cheese cake"); + await subject.selectRowByValue("2"); assert.equal( subject.header().value(), - "Donuts,Cheese cake", + "1,2", "it allows to select a setting from the list of choices" ); } From 651a5b6e4071d8a7741f7b6f6cb633b91ce67333 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Fri, 19 Jul 2019 16:33:08 +0200 Subject: [PATCH 067/441] SECURITY: Validate backup chunk identifier --- app/controllers/admin/backups_controller.rb | 5 +- .../requests/admin/backups_controller_spec.rb | 68 ++++++++++++++----- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/app/controllers/admin/backups_controller.rb b/app/controllers/admin/backups_controller.rb index 76e9e9cb7b..2fae2e40da 100644 --- a/app/controllers/admin/backups_controller.rb +++ b/app/controllers/admin/backups_controller.rb @@ -152,6 +152,8 @@ class Admin::BackupsController < Admin::AdminController chunk_number = params.fetch(:resumableChunkNumber) current_chunk_size = params.fetch(:resumableCurrentChunkSize).to_i + raise Discourse::InvalidParameters.new(:resumableIdentifier) unless valid_filename?(identifier) + # path to chunk file chunk = BackupRestore::LocalBackupStore.chunk_path(identifier, filename, chunk_number) # check chunk upload status @@ -163,13 +165,14 @@ class Admin::BackupsController < Admin::AdminController def upload_backup_chunk filename = params.fetch(:resumableFilename) total_size = params.fetch(:resumableTotalSize).to_i + identifier = params.fetch(:resumableIdentifier) + raise Discourse::InvalidParameters.new(:resumableIdentifier) unless valid_filename?(identifier) return render status: 415, plain: I18n.t("backup.backup_file_should_be_tar_gz") unless valid_extension?(filename) return render status: 415, plain: I18n.t("backup.not_enough_space_on_disk") unless has_enough_space_on_disk?(total_size) return render status: 415, plain: I18n.t("backup.invalid_filename") unless valid_filename?(filename) file = params.fetch(:file) - identifier = params.fetch(:resumableIdentifier) chunk_number = params.fetch(:resumableChunkNumber).to_i chunk_size = params.fetch(:resumableChunkSize).to_i current_chunk_size = params.fetch(:resumableCurrentChunkSize).to_i diff --git a/spec/requests/admin/backups_controller_spec.rb b/spec/requests/admin/backups_controller_spec.rb index 82d11c5a88..400dbd307e 100644 --- a/spec/requests/admin/backups_controller_spec.rb +++ b/spec/requests/admin/backups_controller_spec.rb @@ -201,7 +201,9 @@ RSpec.describe Admin::BackupsController do described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) post "/admin/backups/upload", params: { - resumableFilename: invalid_filename, resumableTotalSize: 1 + resumableFilename: invalid_filename, + resumableTotalSize: 1, + resumableIdentifier: 'test' } expect(response.status).to eq(415) @@ -210,27 +212,59 @@ RSpec.describe Admin::BackupsController do end end + describe "when resumableIdentifier is invalid" do + it "should raise an error" do + filename = 'test_site-0123456789.tar.gz' + @paths = [backup_path(File.join('tmp', 'test', "#{filename}.part1"))] + + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: 1, + resumableIdentifier: '../test', + resumableChunkNumber: '1', + resumableChunkSize: '1', + resumableCurrentChunkSize: '1', + file: fixture_file_upload(Tempfile.new) + } + + expect(response.status).to eq(400) + end + end + describe "when filename is valid" do it "should upload the file successfully" do - begin - described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) + described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) - filename = 'test_Site-0123456789.tar.gz' - @paths = [backup_path(File.join('tmp', 'test', "#{filename}.part1"))] + filename = 'test_Site-0123456789.tar.gz' + @paths = [backup_path(File.join('tmp', 'test', "#{filename}.part1"))] - post "/admin/backups/upload.json", params: { - resumableFilename: filename, - resumableTotalSize: 1, - resumableIdentifier: 'test', - resumableChunkNumber: '1', - resumableChunkSize: '1', - resumableCurrentChunkSize: '1', - file: fixture_file_upload(Tempfile.new) - } + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: 1, + resumableIdentifier: 'test', + resumableChunkNumber: '1', + resumableChunkSize: '1', + resumableCurrentChunkSize: '1', + file: fixture_file_upload(Tempfile.new) + } - expect(response.status).to eq(200) - expect(response.body).to eq("") - end + expect(response.status).to eq(200) + expect(response.body).to eq("") + end + end + end + + describe "#check_backup_chunk" do + describe "when resumableIdentifier is invalid" do + it "should raise an error" do + get "/admin/backups/upload", params: { + resumableIdentifier: "../some_file", + resumableFilename: "test_site-0123456789.tar.gz", + resumableChunkNumber: '1', + resumableCurrentChunkSize: '1' + } + + expect(response.status).to eq(400) end end end From 67650328b4ab645eac1f8152f1b04456b387c202 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Mon, 22 Jul 2019 09:24:27 +0200 Subject: [PATCH 068/441] FIX: allows to specify camelCased attributes in wrap component (#7919) --- .../pretty-text/engines/discourse-markdown/d-wrap.js.es6 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/d-wrap.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/d-wrap.js.es6 index 4b4b4ce4d2..73dd3ca26c 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/d-wrap.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/d-wrap.js.es6 @@ -11,10 +11,16 @@ function parseAttributes(tagInfo) { ); } +function camelCaseToDash(str) { + return str.replace(/([a-zA-Z])(?=[A-Z])/g, "$1-").toLowerCase(); +} + function applyDataAttributes(token, state, attributes) { Object.keys(attributes).forEach(tag => { const value = state.md.utils.escapeHtml(attributes[tag]); - tag = state.md.utils.escapeHtml(tag.replace(/[^a-z0-9\-]/g, "")); + tag = camelCaseToDash( + state.md.utils.escapeHtml(tag.replace(/[^A-Za-z\-0-9]/g, "")) + ); if (value && tag && tag.length > 1) { token.attrs.push([`data-${tag}`, value]); From f14c6d81f498e94654388df2ff132b18c5156c3d Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Mon, 22 Jul 2019 14:59:56 +0300 Subject: [PATCH 069/441] FEATURE: Watched words improvements (#7899) This commit contains 3 features: - FEATURE: Allow downloading watched words This introduces a button that allows admins to download watched words per action in a `.txt` file. - FEATURE: Allow clearing watched words in bulk This adds a "Clear All" button that clears all deleted words per action (e.g. block, flag etc.) - FEATURE: List all blocked words contained in the post when it's blocked When a post is rejected because it contains one or more blocked words, the error message now lists all the blocked words contained in the post. ------- This also changes the format of the file for importing watched words from `.csv` to `.txt` so it becomes inconsistent with the extension of the file when watched words are exported. --- .../components/watched-word-uploader.js.es6 | 4 +- .../admin-watched-words-action.js.es6 | 56 ++++++++++++---- .../components/watched-word-uploader.hbs | 3 +- .../admin/templates/watched-words-action.hbs | 30 +++++++-- .../javascripts/discourse/lib/url.js.es6 | 3 +- .../stylesheets/common/admin/staff_logs.scss | 24 ++++++- .../admin/watched_words_controller.rb | 27 +++++++- app/controllers/export_csv_controller.rb | 1 + app/services/word_watcher.rb | 65 +++++++++++++----- config/locales/client.en.yml | 8 ++- config/locales/server.en.yml | 3 +- config/routes.rb | 2 + lib/new_post_manager.rb | 11 +++- lib/validators/post_validator.rb | 11 +++- spec/integration/watched_words_spec.rb | 12 +++- .../admin/watched_words_controller_spec.rb | 66 +++++++++++++++++++ spec/services/word_watcher_spec.rb | 62 +++++++++++++++-- 17 files changed, 331 insertions(+), 57 deletions(-) diff --git a/app/assets/javascripts/admin/components/watched-word-uploader.js.es6 b/app/assets/javascripts/admin/components/watched-word-uploader.js.es6 index 5f047a2bc6..b1706337f2 100644 --- a/app/assets/javascripts/admin/components/watched-word-uploader.js.es6 +++ b/app/assets/javascripts/admin/components/watched-word-uploader.js.es6 @@ -2,13 +2,13 @@ import computed from "ember-addons/ember-computed-decorators"; import UploadMixin from "discourse/mixins/upload"; export default Ember.Component.extend(UploadMixin, { - type: "csv", + type: "txt", classNames: "watched-words-uploader", uploadUrl: "/admin/logs/watched_words/upload", addDisabled: Ember.computed.alias("uploading"), validateUploadedFilesOptions() { - return { csvOnly: true }; + return { skipValidation: true }; }, @computed("actionKey") diff --git a/app/assets/javascripts/admin/controllers/admin-watched-words-action.js.es6 b/app/assets/javascripts/admin/controllers/admin-watched-words-action.js.es6 index 2e38279b54..2e1260533a 100644 --- a/app/assets/javascripts/admin/controllers/admin-watched-words-action.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-watched-words-action.js.es6 @@ -1,5 +1,7 @@ import computed from "ember-addons/ember-computed-decorators"; import WatchedWord from "admin/models/watched-word"; +import { ajax } from "discourse/lib/ajax"; +import { fmt } from "discourse/lib/computed"; export default Ember.Controller.extend({ actionNameKey: null, @@ -8,6 +10,10 @@ export default Ember.Controller.extend({ "adminWatchedWords.filtered", "adminWatchedWords.showWords" ), + downloadLink: fmt( + "actionNameKey", + "/admin/logs/watched_words/action/%@/download" + ), findAction(actionName) { return (this.get("adminWatchedWords.model") || []).findBy( @@ -17,13 +23,13 @@ export default Ember.Controller.extend({ }, @computed("actionNameKey", "adminWatchedWords.model") - filteredContent(actionNameKey) { - if (!actionNameKey) { - return []; - } + currentAction(actionName) { + return this.findAction(actionName); + }, - const a = this.findAction(actionNameKey); - return a ? a.words : []; + @computed("currentAction.words.[]", "adminWatchedWords.model") + filteredContent(words) { + return words || []; }, @computed("actionNameKey") @@ -31,10 +37,9 @@ export default Ember.Controller.extend({ return I18n.t("admin.watched_words.action_descriptions." + actionNameKey); }, - @computed("actionNameKey", "adminWatchedWords.model") - wordCount(actionNameKey) { - const a = this.findAction(actionNameKey); - return a ? a.words.length : 0; + @computed("currentAction.count") + wordCount(count) { + return count || 0; }, actions: { @@ -62,10 +67,9 @@ export default Ember.Controller.extend({ }, recordRemoved(arg) { - const a = this.findAction(this.actionNameKey); - if (a) { - a.words.removeObject(arg); - a.decrementProperty("count"); + if (this.currentAction) { + this.currentAction.words.removeObject(arg); + this.currentAction.decrementProperty("count"); } }, @@ -73,6 +77,30 @@ export default Ember.Controller.extend({ WatchedWord.findAll().then(data => { this.set("adminWatchedWords.model", data); }); + }, + + clearAll() { + const actionKey = this.actionNameKey; + bootbox.confirm( + I18n.t(`admin.watched_words.clear_all_confirm_${actionKey}`), + I18n.t("no_value"), + I18n.t("yes_value"), + result => { + if (result) { + ajax(`/admin/logs/watched_words/action/${actionKey}.json`, { + method: "DELETE" + }).then(() => { + const action = this.findAction(actionKey); + if (action) { + action.setProperties({ + words: [], + count: 0 + }); + } + }); + } + } + ); } } }); diff --git a/app/assets/javascripts/admin/templates/components/watched-word-uploader.hbs b/app/assets/javascripts/admin/templates/components/watched-word-uploader.hbs index 4042bc5c82..d2422da00b 100644 --- a/app/assets/javascripts/admin/templates/components/watched-word-uploader.hbs +++ b/app/assets/javascripts/admin/templates/components/watched-word-uploader.hbs @@ -1,7 +1,6 @@ -
    {{i18n 'admin.watched_words.one_word_per_line'}} diff --git a/app/assets/javascripts/admin/templates/watched-words-action.hbs b/app/assets/javascripts/admin/templates/watched-words-action.hbs index ad04b20c5b..9e811570c7 100644 --- a/app/assets/javascripts/admin/templates/watched-words-action.hbs +++ b/app/assets/javascripts/admin/templates/watched-words-action.hbs @@ -3,14 +3,24 @@

    {{actionDescription}}

    -{{watched-word-form - actionKey=actionNameKey - action=(action "recordAdded") - filteredContent=filteredContent - regularExpressions=adminWatchedWords.regularExpressions}} + {{watched-word-form + actionKey=actionNameKey + action=(action "recordAdded") + filteredContent=filteredContent + regularExpressions=adminWatchedWords.regularExpressions}} -{{watched-word-uploader uploading=uploading actionKey=actionNameKey done=(action "uploadComplete")}} +
    +
    + {{d-button + class="btn-default download-link" + href=downloadLink + icon="download" + label="admin.watched_words.download"}} +
    + {{watched-word-uploader uploading=uploading actionKey=actionNameKey done=(action "uploadComplete")}} +
    +
    + +
    + {{d-button + class="btn-danger clear-all" + label="admin.watched_words.clear_all" + icon="trash-alt" + action=(action "clearAll")}} +
    diff --git a/app/assets/javascripts/discourse/lib/url.js.es6 b/app/assets/javascripts/discourse/lib/url.js.es6 index 18af8aee7e..66b82829ec 100644 --- a/app/assets/javascripts/discourse/lib/url.js.es6 +++ b/app/assets/javascripts/discourse/lib/url.js.es6 @@ -23,7 +23,8 @@ const SERVER_SIDE_ONLY = [ /\.rss$/, /\.json$/, /^\/admin\/upgrade$/, - /^\/logs($|\/)/ + /^\/logs($|\/)/, + /^\/admin\/logs\/watched_words\/action\/[^\/]+\/download$/ ]; export function rewritePath(path) { diff --git a/app/assets/stylesheets/common/admin/staff_logs.scss b/app/assets/stylesheets/common/admin/staff_logs.scss index bbfd6ae888..349480b8fa 100644 --- a/app/assets/stylesheets/common/admin/staff_logs.scss +++ b/app/assets/stylesheets/common/admin/staff_logs.scss @@ -362,17 +362,33 @@ table.screened-ip-addresses { display: inline-block; width: 250px; margin-bottom: 1em; - float: left; + vertical-align: top; +} + +.admin-watched-words { + .clear-all-row { + display: flex; + margin-top: 10px; + justify-content: flex-end; + } } .watched-word-controls { display: flex; flex-wrap: wrap; margin-bottom: 1em; + justify-content: space-between; + .download-upload-controls { + display: flex; + } + .download { + justify-content: flex-end; + } } .watched-words-list { margin-top: 20px; + display: inline-block; } .watched-word { @@ -395,13 +411,17 @@ table.screened-ip-addresses { } .watched-words-uploader { - margin-left: auto; + margin-left: 5px; + display: flex; + flex-direction: column; + align-items: flex-end; @media screen and (max-width: 500px) { flex: 1 1 100%; margin-top: 0.5em; } .instructions { font-size: $font-down-1; + margin-top: 5px; } } diff --git a/app/controllers/admin/watched_words_controller.rb b/app/controllers/admin/watched_words_controller.rb index a9b3a490c2..b6e013041b 100644 --- a/app/controllers/admin/watched_words_controller.rb +++ b/app/controllers/admin/watched_words_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Admin::WatchedWordsController < Admin::AdminController + skip_before_action :check_xhr, only: [:download] def index render_json_dump WatchedWordListSerializer.new(WatchedWord.by_action, scope: guardian, root: false) @@ -35,12 +36,36 @@ class Admin::WatchedWordsController < Admin::AdminController rescue => e data = failed_json.merge(errors: [e.message]) end - MessageBus.publish("/uploads/csv", data.as_json, client_ids: [params[:client_id]]) + MessageBus.publish("/uploads/txt", data.as_json, client_ids: [params[:client_id]]) end render json: success_json end + def download + params.require(:id) + name = watched_words_params[:id].to_sym + action = WatchedWord.actions[name] + raise Discourse::NotFound if !action + + content = WatchedWord.where(action: action).pluck(:word).join("\n") + headers['Content-Length'] = content.bytesize.to_s + send_data content, + filename: "#{Discourse.current_hostname}-watched-words-#{name}.txt", + content_type: "text/plain" + end + + def clear_all + params.require(:id) + name = watched_words_params[:id].to_sym + action = WatchedWord.actions[name] + raise Discourse::NotFound if !action + + WatchedWord.where(action: action).delete_all + WordWatcher.clear_cache! + render json: success_json + end + private def watched_words_params diff --git a/app/controllers/export_csv_controller.rb b/app/controllers/export_csv_controller.rb index 1f0e59ef06..8c72494725 100644 --- a/app/controllers/export_csv_controller.rb +++ b/app/controllers/export_csv_controller.rb @@ -12,6 +12,7 @@ class ExportCsvController < ApplicationController end private + def export_params @_export_params ||= begin params.require(:entity) diff --git a/app/services/word_watcher.rb b/app/services/word_watcher.rb index 0cddc96fc2..1e12f86002 100644 --- a/app/services/word_watcher.rb +++ b/app/services/word_watcher.rb @@ -14,17 +14,27 @@ class WordWatcher WatchedWord.where(action: WatchedWord.actions[action.to_sym]).exists? end - def self.word_matcher_regexp(action) - s = Discourse.cache.fetch(word_matcher_regexp_key(action), expires_in: 1.day) do - words = words_for_action(action) - if words.empty? - nil - else - regexp = '(' + words.map { |w| word_to_regexp(w) }.join('|'.freeze) + ')' - SiteSetting.watched_words_regular_expressions? ? regexp : "(? "watched_words#index" + get "action/:id/download" => "watched_words#download" + delete "action/:id" => "watched_words#clear_all" end end post "watched_words/upload" => "watched_words#upload" diff --git a/lib/new_post_manager.rb b/lib/new_post_manager.rb index 26712309cd..a9a1278694 100644 --- a/lib/new_post_manager.rb +++ b/lib/new_post_manager.rb @@ -172,9 +172,16 @@ class NewPostManager end def perform - if !self.class.exempt_user?(@user) && matches = WordWatcher.new("#{@args[:title]} #{@args[:raw]}").should_block? + if !self.class.exempt_user?(@user) && matches = WordWatcher.new("#{@args[:title]} #{@args[:raw]}").should_block?.presence result = NewPostResult.new(:created_post, false) - result.errors.add(:base, I18n.t('contains_blocked_words', word: matches[0])) + if matches.size == 1 + key = 'contains_blocked_word' + translation_args = { word: matches[0] } + else + key = 'contains_blocked_words' + translation_args = { words: matches.join(', ') } + end + result.errors.add(:base, I18n.t(key, translation_args)) return result end diff --git a/lib/validators/post_validator.rb b/lib/validators/post_validator.rb index d5a33a79bb..09c564b3b1 100644 --- a/lib/validators/post_validator.rb +++ b/lib/validators/post_validator.rb @@ -60,8 +60,15 @@ class Validators::PostValidator < ActiveModel::Validator end def watched_words(post) - if !post.acting_user&.staged && matches = WordWatcher.new(post.raw).should_block? - post.errors.add(:base, I18n.t('contains_blocked_words', word: matches[0])) + if !post.acting_user&.staged && matches = WordWatcher.new(post.raw).should_block?.presence + if matches.size == 1 + key = 'contains_blocked_word' + translation_args = { word: matches[0] } + else + key = 'contains_blocked_words' + translation_args = { words: matches.join(', ') } + end + post.errors.add(:base, I18n.t(key, translation_args)) end end diff --git a/spec/integration/watched_words_spec.rb b/spec/integration/watched_words_spec.rb index 68a7c2c05c..b78990090e 100644 --- a/spec/integration/watched_words_spec.rb +++ b/spec/integration/watched_words_spec.rb @@ -13,6 +13,7 @@ describe WatchedWord do let(:require_approval_word) { Fabricate(:watched_word, action: WatchedWord.actions[:require_approval]) } let(:flag_word) { Fabricate(:watched_word, action: WatchedWord.actions[:flag]) } let(:block_word) { Fabricate(:watched_word, action: WatchedWord.actions[:block]) } + let(:another_block_word) { Fabricate(:watched_word, action: WatchedWord.actions[:block]) } before_all do WordWatcher.clear_cache! @@ -27,7 +28,7 @@ describe WatchedWord do expect { result = manager.perform expect(result).to_not be_success - expect(result.errors[:base]&.first).to eq(I18n.t('contains_blocked_words', word: block_word.word)) + expect(result.errors[:base]&.first).to eq(I18n.t('contains_blocked_word', word: block_word.word)) }.to_not change { Post.count } end @@ -51,6 +52,15 @@ describe WatchedWord do should_block_post(manager) end + it "should block the post if it contains multiple blocked words" do + manager = NewPostManager.new(moderator, raw: "Want some #{block_word.word} #{another_block_word.word} for cheap?", topic_id: topic.id) + expect { + result = manager.perform + expect(result).to_not be_success + expect(result.errors[:base]&.first).to eq(I18n.t('contains_blocked_words', words: [block_word.word, another_block_word.word].join(', '))) + }.to_not change { Post.count } + end + it "should block in a private message too" do manager = NewPostManager.new( tl2_user, diff --git a/spec/requests/admin/watched_words_controller_spec.rb b/spec/requests/admin/watched_words_controller_spec.rb index 1b8127c373..97cac195f9 100644 --- a/spec/requests/admin/watched_words_controller_spec.rb +++ b/spec/requests/admin/watched_words_controller_spec.rb @@ -49,4 +49,70 @@ RSpec.describe Admin::WatchedWordsController do end end end + + describe '#download' do + context 'not logged in as admin' do + it "doesn't allow performing #download" do + get "/admin/logs/watched_words/action/block/download" + expect(response.status).to eq(404) + end + end + + context 'logged in as admin' do + before do + sign_in(admin) + end + + it "words of different actions are downloaded separately" do + block_word_1 = Fabricate(:watched_word, action: WatchedWord.actions[:block]) + block_word_2 = Fabricate(:watched_word, action: WatchedWord.actions[:block]) + censor_word_1 = Fabricate(:watched_word, action: WatchedWord.actions[:censor]) + + get "/admin/logs/watched_words/action/block/download" + expect(response.status).to eq(200) + block_words = response.body.split("\n") + expect(block_words).to contain_exactly(block_word_1.word, block_word_2.word) + + get "/admin/logs/watched_words/action/censor/download" + expect(response.status).to eq(200) + censor_words = response.body.split("\n") + expect(censor_words).to eq([censor_word_1.word]) + end + end + end + + context '#clear_all' do + context 'non admins' do + it "doesn't allow them to perform #clear_all" do + word = Fabricate(:watched_word, action: WatchedWord.actions[:block]) + delete "/admin/logs/watched_words/action/block" + expect(response.status).to eq(404) + expect(WatchedWord.pluck(:word)).to include(word.word) + end + end + + context 'admins' do + before do + sign_in(admin) + end + + it "allows them to perform #clear_all" do + word = Fabricate(:watched_word, action: WatchedWord.actions[:block]) + delete "/admin/logs/watched_words/action/block.json" + expect(response.status).to eq(200) + expect(WatchedWord.pluck(:word)).not_to include(word.word) + end + + it "doesn't delete words of multiple actions in one call" do + block_word = Fabricate(:watched_word, action: WatchedWord.actions[:block]) + flag_word = Fabricate(:watched_word, action: WatchedWord.actions[:flag]) + + delete "/admin/logs/watched_words/action/flag.json" + expect(response.status).to eq(200) + all_words = WatchedWord.pluck(:word) + expect(all_words).to include(block_word.word) + expect(all_words).not_to include(flag_word.word) + end + end + end end diff --git a/spec/services/word_watcher_spec.rb b/spec/services/word_watcher_spec.rb index d7d83caa48..89d854ae1a 100644 --- a/spec/services/word_watcher_spec.rb +++ b/spec/services/word_watcher_spec.rb @@ -10,6 +10,25 @@ describe WordWatcher do $redis.flushall end + describe '.word_matcher_regexp' do + let!(:word1) { Fabricate(:watched_word, action: WatchedWord.actions[:block]).word } + let!(:word2) { Fabricate(:watched_word, action: WatchedWord.actions[:block]).word } + + context 'format of the result regexp' do + it "is correct when watched_words_regular_expressions = true" do + SiteSetting.watched_words_regular_expressions = true + regexp = WordWatcher.word_matcher_regexp(:block) + expect(regexp.inspect).to eq("/(#{word1})|(#{word2})/i") + end + + it "is correct when watched_words_regular_expressions = false" do + SiteSetting.watched_words_regular_expressions = false + regexp = WordWatcher.word_matcher_regexp(:block) + expect(regexp.inspect).to eq("/(? Date: Mon, 22 Jul 2019 10:08:03 -0400 Subject: [PATCH 070/441] UX: Improve layout of long tag headings on mobile --- app/assets/stylesheets/common/base/tagging.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/assets/stylesheets/common/base/tagging.scss b/app/assets/stylesheets/common/base/tagging.scss index 02c9242c00..3dc1f6abc4 100644 --- a/app/assets/stylesheets/common/base/tagging.scss +++ b/app/assets/stylesheets/common/base/tagging.scss @@ -31,7 +31,14 @@ .tag-show-heading { display: inline-flex; + flex-wrap: wrap; align-items: center; + white-space: nowrap; + overflow: hidden; + text-overflow: hidden; + @include breakpoint(mobile-large) { + width: 100%; + } .d-icon { margin: 0 0.25em; } From f1dd7d05e421111e377fed734b97cce115569910 Mon Sep 17 00:00:00 2001 From: Kris Date: Mon, 22 Jul 2019 10:31:21 -0400 Subject: [PATCH 071/441] Update fa to d-icon for buttons, add icon space --- app/assets/stylesheets/common/components/buttons.scss | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/common/components/buttons.scss b/app/assets/stylesheets/common/components/buttons.scss index 942839f64b..8bbe8b764b 100644 --- a/app/assets/stylesheets/common/components/buttons.scss +++ b/app/assets/stylesheets/common/components/buttons.scss @@ -29,11 +29,11 @@ cursor: default; opacity: 0.4; } - .fa { + .d-icon { margin-right: 7px; } &.no-text { - .fa { + .d-icon { margin-right: 0; } } @@ -260,6 +260,11 @@ .d-icon { color: $primary-low-mid; } + &.btn-icon-text { + .d-icon { + margin-right: 7px; + } + } .discourse-no-touch & { &:hover { .d-icon { From 08b48b2ba6364df913b451192dce2649ce6e444a Mon Sep 17 00:00:00 2001 From: Saurabh Patel Date: Mon, 22 Jul 2019 20:22:35 +0530 Subject: [PATCH 072/441] add user avatar to user crawler layout (#7917) --- app/assets/stylesheets/desktop/user.scss | 7 +++++++ app/views/users/show.html.erb | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index cdb584347a..b951457984 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -252,3 +252,10 @@ margin-top: s(1); } } + +.user-crawler { + .username { + margin-left: 5px; + display: inline-block; + } +} diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index c0479dfb04..55f9b38a17 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -1,4 +1,7 @@ -

    <%= @user.username %>

    +
    + <%= @user.username %> +

    <%= @user.username %>

    +
    <% unless @restrict_fields %>

    <%= raw @user.user_profile.bio_processed %>

    From 5fc5a7f5ae8c3cf1a89c1a685258e9e942de1508 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Mon, 22 Jul 2019 17:55:49 +0300 Subject: [PATCH 073/441] FEATURE: Add search operator to see all direct messages from a user (#7913) * FEATURE: Add search operator to see all direct messages from a user * Only show message if related messages >= 5 * Make "all messages" the hyperlink * Review --- .../components/related-messages.js.es6 | 24 ++++++++ .../components/search-advanced-options.js.es6 | 14 ++--- .../controllers/full-page-search.js.es6 | 4 +- .../templates/components/related-messages.hbs | 4 ++ .../components/search-advanced-options.hbs | 2 +- .../discourse/widgets/search-menu.js.es6 | 2 +- config/locales/client.en.yml | 1 + lib/search.rb | 31 +++++++++- spec/components/search_spec.rb | 59 +++++++++++++++++++ spec/fabricators/post_fabricator.rb | 15 +++++ .../acceptance/search-full-test.js.es6 | 6 +- 11 files changed, 145 insertions(+), 17 deletions(-) diff --git a/app/assets/javascripts/discourse/components/related-messages.js.es6 b/app/assets/javascripts/discourse/components/related-messages.js.es6 index 807359d924..e7d65a4591 100644 --- a/app/assets/javascripts/discourse/components/related-messages.js.es6 +++ b/app/assets/javascripts/discourse/components/related-messages.js.es6 @@ -5,6 +5,30 @@ export default Ember.Component.extend({ elementId: "related-messages", classNames: ["suggested-topics"], + @computed("topic") + targetUser(topic) { + if (!topic || !topic.isPrivateMessage) { + return; + } + const allowedUsers = topic.details.allowed_users; + if ( + topic.relatedMessages && + topic.relatedMessages.length >= 5 && + allowedUsers.length === 2 && + topic.details.allowed_groups.length === 0 && + allowedUsers.find(u => u.username === this.currentUser.username) + ) { + return allowedUsers.find(u => u.username !== this.currentUser.username); + } + }, + + @computed + searchLink() { + return Discourse.getURL( + `/search?expanded=true&q=%40${this.targetUser.username}%20in%3Apersonal-direct` + ); + }, + @computed("topic") relatedTitle(topic) { const href = this.currentUser && this.currentUser.pmPath(topic); 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 75b2e5460a..50da93e6c2 100644 --- a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 +++ b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 @@ -19,7 +19,7 @@ const REGEXP_TAGS_REPLACE = /(^(tags?:|#(?=[a-z0-9\-]+::tag))|::tag\s?$)/gi; const REGEXP_IN_MATCH = /^(in|with):(posted|watching|tracking|bookmarks|first|pinned|unpinned|wiki|unseen|image)/gi; const REGEXP_SPECIAL_IN_LIKES_MATCH = /^in:likes/gi; const REGEXP_SPECIAL_IN_TITLE_MATCH = /^in:title/gi; -const REGEXP_SPECIAL_IN_PRIVATE_MATCH = /^in:private/gi; +const REGEXP_SPECIAL_IN_PERSONAL_MATCH = /^in:personal/gi; const REGEXP_SPECIAL_IN_SEEN_MATCH = /^in:seen/gi; const REGEXP_CATEGORY_SLUG = /^(\#[a-zA-Z0-9\-:]+)/gi; @@ -93,7 +93,7 @@ export default Ember.Component.extend({ in: { title: false, likes: false, - private: false, + personal: false, seen: false }, all_tags: false @@ -140,8 +140,8 @@ export default Ember.Component.extend({ ); this.setSearchedTermSpecialInValue( - "searchedTerms.special.in.private", - REGEXP_SPECIAL_IN_PRIVATE_MATCH + "searchedTerms.special.in.personal", + REGEXP_SPECIAL_IN_PERSONAL_MATCH ); this.setSearchedTermSpecialInValue( @@ -512,9 +512,9 @@ export default Ember.Component.extend({ this.updateInRegex(REGEXP_SPECIAL_IN_LIKES_MATCH, "likes"); }, - @observes("searchedTerms.special.in.private") - updateSearchTermForSpecialInPrivate() { - this.updateInRegex(REGEXP_SPECIAL_IN_PRIVATE_MATCH, "private"); + @observes("searchedTerms.special.in.personal") + updateSearchTermForSpecialInPersonal() { + this.updateInRegex(REGEXP_SPECIAL_IN_PERSONAL_MATCH, "personal"); }, @observes("searchedTerms.special.in.seen") diff --git a/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 b/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 index af832d4fe2..c7175a47fd 100644 --- a/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 +++ b/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 @@ -161,9 +161,9 @@ export default Ember.Controller.extend({ return ( q && this.currentUser && - (q.indexOf("in:private") > -1 || + (q.indexOf("in:personal") > -1 || q.indexOf( - `private_messages:${this.currentUser.get("username_lower")}` + `personal_messages:${this.currentUser.get("username_lower")}` ) > -1) ); }, diff --git a/app/assets/javascripts/discourse/templates/components/related-messages.hbs b/app/assets/javascripts/discourse/templates/components/related-messages.hbs index 41c98c7d7c..dde8deaea1 100644 --- a/app/assets/javascripts/discourse/templates/components/related-messages.hbs +++ b/app/assets/javascripts/discourse/templates/components/related-messages.hbs @@ -5,3 +5,7 @@ showPosters="true" topics=topic.relatedMessages}}
    + +{{#if targetUser}} +

    {{{i18n "related_messages.see_all" path=searchLink username=targetUser.username}}}

    +{{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs index 371a1c3849..b1d80198cb 100644 --- a/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs +++ b/app/assets/javascripts/discourse/templates/components/search-advanced-options.hbs @@ -62,7 +62,7 @@
    - +
    {{/if}} diff --git a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 index 4792f02b6f..808f3282c3 100644 --- a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 @@ -114,7 +114,7 @@ export default createWidget("search-menu", { this.currentUser.get("username_lower") && type === "private_messages" ) { - query += " in:private"; + query += " in:personal"; } else { query += encodeURIComponent(" " + type + ":" + ctx.id); } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 3b3370d161..23c624d85e 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -262,6 +262,7 @@ en: related_messages: title: "Related Messages" + see_all: "See all messages from @%{username}..." suggested_topics: title: "Suggested Topics" diff --git a/lib/search.rb b/lib/search.rb index 08512e3c0d..a109f48a02 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -265,6 +265,27 @@ class Search @advanced_filters end + advanced_filter(/^in:personal-direct$/) do |posts| + if @guardian.user + posts + .joins("LEFT JOIN topic_allowed_groups tg ON posts.topic_id = tg.topic_id") + .where(<<~SQL, user_id: @guardian.user.id) + tg.id IS NULL + AND posts.topic_id IN ( + SELECT tau.topic_id + FROM topic_allowed_users tau + JOIN topic_allowed_users tau2 + ON tau2.topic_id = tau.topic_id + AND tau2.id != tau.id + WHERE tau.user_id = :user_id + AND tau.topic_id = posts.topic_id + GROUP BY tau.topic_id + HAVING COUNT(*) = 1 + ) + SQL + end + end + advanced_filter(/^in:tagged$/) do |posts| posts .where('EXISTS (SELECT 1 FROM topic_tags WHERE topic_tags.topic_id = posts.topic_id)') @@ -631,10 +652,14 @@ class Search elsif word == 'order:likes' @order = :likes nil - elsif word == 'in:private' + elsif %w{in:private in:personal}.include?(word) # remove private after 2.4 release @search_pms = true nil - elsif word =~ /^private_messages:(.+)$/ + elsif word == "in:personal-direct" + @search_pms = true + @direct_pms_only = true + nil + elsif word =~ /^personal_messages:(.+)$/ @search_pms = true nil else @@ -826,7 +851,7 @@ class Search if @search_context.present? if @search_context.is_a?(User) if opts[:private_messages] - posts.private_posts_for_user(@search_context) + @direct_pms_only ? posts : posts.private_posts_for_user(@search_context) else posts.where("posts.user_id = #{@search_context.id}") end diff --git a/spec/components/search_spec.rb b/spec/components/search_spec.rb index d1d7bde413..748f08888c 100644 --- a/spec/components/search_spec.rb +++ b/spec/components/search_spec.rb @@ -236,6 +236,65 @@ describe Search do end + context 'personal-direct flag' do + let(:current) { Fabricate(:user, admin: true, username: "current_user") } + let(:participant) { Fabricate(:user, username: "participant_1") } + let(:participant_2) { Fabricate(:user, username: "participant_2") } + + let(:group) do + group = Fabricate(:group, has_messages: true) + group.add(current) + group.add(participant) + group + end + + def create_pm(users:, group: nil) + pm = Fabricate(:private_message_post_one_user, user: users.first).topic + users[1..-1].each do |u| + pm.invite(users.first, u.username) + Fabricate(:post, user: u, topic: pm) + end + if group + pm.invite_group(users.first, group) + group.users.each do |u| + Fabricate(:post, user: u, topic: pm) + end + end + pm.reload + end + + it 'can find all direct PMs of the current user' do + pm = create_pm(users: [current, participant]) + pm_2 = create_pm(users: [participant_2, participant]) + pm_3 = create_pm(users: [participant, current]) + pm_4 = create_pm(users: [participant_2, current]) + results = Search.execute("in:personal-direct", guardian: Guardian.new(current)) + expect(results.posts.size).to eq(3) + expect(results.posts.map(&:topic_id)).to contain_exactly(pm.id, pm_3.id, pm_4.id) + end + + it 'can filter direct PMs by @username' do + pm = create_pm(users: [current, participant]) + pm_2 = create_pm(users: [participant, current]) + pm_3 = create_pm(users: [participant_2, current]) + results = Search.execute("@#{participant.username} in:personal-direct", guardian: Guardian.new(current)) + expect(results.posts.size).to eq(2) + expect(results.posts.map(&:topic_id)).to contain_exactly(pm.id, pm_2.id) + expect(results.posts.map(&:user_id).uniq).to contain_exactly(participant.id) + end + + it "doesn't include PMs that have more than 2 participants" do + pm = create_pm(users: [current, participant, participant_2]) + results = Search.execute("@#{participant.username} in:personal-direct", guardian: Guardian.new(current)) + expect(results.posts.size).to eq(0) + end + + it "doesn't include PMs that have groups" do + pm = create_pm(users: [current, participant], group: group) + results = Search.execute("@#{participant.username} in:personal-direct", guardian: Guardian.new(current)) + expect(results.posts.size).to eq(0) + end + end end context 'topics' do diff --git a/spec/fabricators/post_fabricator.rb b/spec/fabricators/post_fabricator.rb index 219a165528..8f0fef9259 100644 --- a/spec/fabricators/post_fabricator.rb +++ b/spec/fabricators/post_fabricator.rb @@ -137,6 +137,21 @@ Fabricator(:private_message_post, from: :post) do raw "Ssshh! This is our secret conversation!" end +Fabricator(:private_message_post_one_user, from: :post) do + user + topic do |attrs| + Fabricate(:private_message_topic, + user: attrs[:user], + created_at: attrs[:created_at], + subtype: TopicSubtype.user_to_user, + topic_allowed_users: [ + Fabricate.build(:topic_allowed_user, user: attrs[:user]), + ] + ) + end + raw "Ssshh! This is our secret conversation!" +end + Fabricator(:post_via_email, from: :post) do incoming_email via_email true diff --git a/test/javascripts/acceptance/search-full-test.js.es6 b/test/javascripts/acceptance/search-full-test.js.es6 index ebebff6d23..d2ec23231c 100644 --- a/test/javascripts/acceptance/search-full-test.js.es6 +++ b/test/javascripts/acceptance/search-full-test.js.es6 @@ -278,7 +278,7 @@ QUnit.test( ); QUnit.test( - "update in:private filter through advanced search ui", + "update in:personal filter through advanced search ui", async assert => { await visit("/search"); await fillIn(".search-query", "none"); @@ -290,8 +290,8 @@ QUnit.test( ); assert.equal( find(".search-query").val(), - "none in:private", - 'has updated search term to "none in:private"' + "none in:personal", + 'has updated search term to "none in:personal"' ); } ); From 1235105c030c5ca8b167124848e2d584e7d7baca Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Sat, 20 Jul 2019 21:36:18 +0200 Subject: [PATCH 074/441] FIX: Old notifications didn't link to correct post after moving post --- app/models/post_mover.rb | 38 ++++++++++++++++++++- spec/fabricators/notification_fabricator.rb | 15 ++++++++ spec/models/post_mover_spec.rb | 32 +++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/app/models/post_mover.rb b/app/models/post_mover.rb index df801a06ee..bac69927b0 100644 --- a/app/models/post_mover.rb +++ b/app/models/post_mover.rb @@ -108,6 +108,8 @@ class PostMover end def create_first_post(post) + old_post_attributes = post_attributes(post) + new_post = PostCreator.create( post.user, raw: post.raw, @@ -123,6 +125,7 @@ class PostMover move_incoming_emails(post, new_post) move_email_logs(post, new_post) + move_notifications(old_post_attributes, new_post) PostAction.copy(post, new_post) new_post.update_column(:reply_count, @reply_count[1] || 0) @@ -149,11 +152,12 @@ class PostMover update[:reply_to_user_id] = nil end + old_post_attributes = post_attributes(post) post.attributes = update post.save(validate: false) move_incoming_emails(post, post) - move_email_logs(post, post) + move_notifications(old_post_attributes, post) DiscourseEvent.trigger(:post_moved, post, original_topic.id) @@ -175,6 +179,31 @@ class PostMover .update_all(post_id: new_post.id) end + def move_notifications(old_post_attributes, new_post) + params = { + old_topic_id: old_post_attributes[:topic_id], + old_post_number: old_post_attributes[:post_number], + new_topic_id: new_post.topic_id, + new_post_number: new_post.post_number, + new_topic_title: new_post.topic.title + } + + DB.exec(<<~SQL, params) + UPDATE notifications + SET topic_id = :new_topic_id, + post_number = :new_post_number, + data = (data :: JSONB || + jsonb_strip_nulls( + jsonb_build_object( + 'topic_title', CASE WHEN data :: JSONB ->> 'topic_title' IS NULL + THEN NULL + ELSE :new_topic_title END + ) + )) :: JSON + WHERE topic_id = :old_topic_id AND post_number = :old_post_number + SQL + end + def update_statistics destination_topic.update_statistics original_topic.update_statistics @@ -276,4 +305,11 @@ class PostMover end destination_topic.save! end + + def post_attributes(post) + { + topic_id: post.topic_id, + post_number: post.post_number + } + end end diff --git a/spec/fabricators/notification_fabricator.rb b/spec/fabricators/notification_fabricator.rb index da533b9c7a..2d22ed320c 100644 --- a/spec/fabricators/notification_fabricator.rb +++ b/spec/fabricators/notification_fabricator.rb @@ -5,6 +5,7 @@ Fabricator(:notification) do notification_type Notification.types[:mentioned] user topic { |attrs| attrs[:post]&.topic || Fabricate(:topic, user: attrs[:user]) } + post_number { |attrs| attrs[:post]&.post_number } data '{"poison":"ivy","killer":"croc"}' end @@ -57,3 +58,17 @@ Fabricator(:posted_notification, from: :notification) do }.to_json end end + +Fabricator(:mentioned_notification, from: :notification) do + notification_type Notification.types[:mentioned] + data do |attrs| + { + topic_title: attrs[:topic].title, + original_post_id: attrs[:post].id, + original_post_type: attrs[:post].post_type, + original_username: attrs[:post].user.username, + revision_number: nil, + display_username: attrs[:post].user.username + }.to_json + end +end diff --git a/spec/models/post_mover_spec.rb b/spec/models/post_mover_spec.rb index d1afb378cb..833705e9a5 100644 --- a/spec/models/post_mover_spec.rb +++ b/spec/models/post_mover_spec.rb @@ -292,6 +292,22 @@ describe PostMover do notifications_reason_id: TopicUser.notification_reasons[:created_topic] )).to eq(true) end + + it "updates existing notifications" do + n3 = Fabricate(:mentioned_notification, post: p3, user: another_user) + n4 = Fabricate(:mentioned_notification, post: p4, user: another_user) + + new_topic = topic.move_posts(user, [p3.id], title: "new testing topic name") + + n3.reload + expect(n3.topic_id).to eq(new_topic.id) + expect(n3.post_number).to eq(1) + expect(n3.data_hash[:topic_title]).to eq(new_topic.title) + + n4.reload + expect(n4.topic_id).to eq(topic.id) + expect(n4.post_number).to eq(4) + end end context "to an existing topic" do @@ -369,6 +385,22 @@ describe PostMover do moderator_post = topic.posts.find_by(post_number: 2) expect(moderator_post.raw).to include("4 posts were merged") end + + it "updates existing notifications" do + n3 = Fabricate(:mentioned_notification, post: p3, user: another_user) + n4 = Fabricate(:mentioned_notification, post: p4, user: another_user) + + moved_to = topic.move_posts(user, [p3.id], destination_topic_id: destination_topic.id) + + n3.reload + expect(n3.topic_id).to eq(moved_to.id) + expect(n3.post_number).to eq(2) + expect(n3.data_hash[:topic_title]).to eq(moved_to.title) + + n4.reload + expect(n4.topic_id).to eq(topic.id) + expect(n4.post_number).to eq(4) + end end context "to a message" do From 271ddac467b25d8c51510ede3bf45d96d9a0474b Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Mon, 22 Jul 2019 19:02:21 +0200 Subject: [PATCH 075/441] FIX: Delete notifications users can't see after moving posts No need to let notifications stay around when users can't access a topic after it was converted into a PM or posts were moved into a restricted topic. Also makes sure that moving to a new topic correctly uses the guardian for the first post by enqueuing jobs outside of a transaction. --- .../delete_inaccessible_notifications.rb | 17 +++++++++++++ app/models/post_mover.rb | 21 +++++++++++++--- app/models/topic_converter.rb | 3 +++ spec/models/post_mover_spec.rb | 25 +++++++++++++++++++ spec/models/topic_converter_spec.rb | 23 +++++++++++++++++ 5 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 app/jobs/regular/delete_inaccessible_notifications.rb diff --git a/app/jobs/regular/delete_inaccessible_notifications.rb b/app/jobs/regular/delete_inaccessible_notifications.rb new file mode 100644 index 0000000000..ca38dc4ec8 --- /dev/null +++ b/app/jobs/regular/delete_inaccessible_notifications.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Jobs + class DeleteInaccessibleNotifications < Jobs::Base + def execute(args) + raise Discourse::InvalidParameters.new(:topic_id) if args[:topic_id].blank? + + Notification.where(topic_id: args[:topic_id]).find_each do |notification| + next unless notification.user && notification.topic + + if !Guardian.new(notification.user).can_see?(notification.topic) + notification.destroy + end + end + end + end +end diff --git a/app/models/post_mover.rb b/app/models/post_mover.rb index bac69927b0..81afb53566 100644 --- a/app/models/post_mover.rb +++ b/app/models/post_mover.rb @@ -27,6 +27,7 @@ class PostMover move_posts_to topic end add_allowed_users(participants) if participants.present? && @move_to_pm + enqueue_jobs(topic) topic end @@ -37,7 +38,7 @@ class PostMover raise Discourse::InvalidParameters unless post archetype = @move_to_pm ? Archetype.private_message : Archetype.default - Topic.transaction do + topic = Topic.transaction do new_topic = Topic.create!( user: post.user, title: title, @@ -50,6 +51,8 @@ class PostMover watch_new_topic new_topic end + enqueue_jobs(topic) + topic end private @@ -77,6 +80,7 @@ class PostMover def move_each_post max_post_number = destination_topic.max_post_number + 1 + @post_creator = nil @move_map = {} @reply_count = {} posts.each_with_index do |post, offset| @@ -110,7 +114,7 @@ class PostMover def create_first_post(post) old_post_attributes = post_attributes(post) - new_post = PostCreator.create( + @post_creator = PostCreator.new( post.user, raw: post.raw, topic_id: destination_topic.id, @@ -120,8 +124,10 @@ class PostMover raw_email: post.raw_email, skip_validations: true, created_at: post.created_at, - guardian: Guardian.new(user) + guardian: Guardian.new(user), + skip_jobs: true ) + new_post = @post_creator.create move_incoming_emails(post, new_post) move_email_logs(post, new_post) @@ -312,4 +318,13 @@ class PostMover post_number: post.post_number } end + + def enqueue_jobs(topic) + @post_creator.enqueue_jobs if @post_creator + + Jobs.enqueue( + :delete_inaccessible_notifications, + topic_id: topic.id + ) + end end diff --git a/app/models/topic_converter.rb b/app/models/topic_converter.rb index d0ce8dacf0..f59e96c4d0 100644 --- a/app/models/topic_converter.rb +++ b/app/models/topic_converter.rb @@ -31,6 +31,7 @@ class TopicConverter update_user_stats Jobs.enqueue(:topic_action_converter, topic_id: @topic.id) + Jobs.enqueue(:delete_inaccessible_notifications, topic_id: @topic.id) watch_topic(topic) end @@ -50,6 +51,8 @@ class TopicConverter add_allowed_users Jobs.enqueue(:topic_action_converter, topic_id: @topic.id) + Jobs.enqueue(:delete_inaccessible_notifications, topic_id: @topic.id) + watch_topic(topic) end @topic diff --git a/spec/models/post_mover_spec.rb b/spec/models/post_mover_spec.rb index 833705e9a5..ce6495356d 100644 --- a/spec/models/post_mover_spec.rb +++ b/spec/models/post_mover_spec.rb @@ -308,6 +308,18 @@ describe PostMover do expect(n4.topic_id).to eq(topic.id) expect(n4.post_number).to eq(4) end + + it "deletes notifications for users not allowed to see the topic" do + another_admin = Fabricate(:admin) + staff_category = Fabricate(:private_category, group: Group[:staff]) + user_notification = Fabricate(:mentioned_notification, post: p3, user: another_user) + admin_notification = Fabricate(:mentioned_notification, post: p3, user: another_admin) + + topic.move_posts(user, [p3.id], title: "new testing topic name", category_id: staff_category.id) + + expect(Notification.exists?(user_notification.id)).to eq(false) + expect(Notification.exists?(admin_notification.id)).to eq(true) + end end context "to an existing topic" do @@ -401,6 +413,19 @@ describe PostMover do expect(n4.topic_id).to eq(topic.id) expect(n4.post_number).to eq(4) end + + it "deletes notifications for users not allowed to see the topic" do + another_admin = Fabricate(:admin) + staff_category = Fabricate(:private_category, group: Group[:staff]) + user_notification = Fabricate(:mentioned_notification, post: p3, user: another_user) + admin_notification = Fabricate(:mentioned_notification, post: p3, user: another_admin) + + destination_topic.update!(category_id: staff_category.id) + topic.move_posts(user, [p3.id], destination_topic_id: destination_topic.id) + + expect(Notification.exists?(user_notification.id)).to eq(false) + expect(Notification.exists?(admin_notification.id)).to eq(true) + end end context "to a message" do diff --git a/spec/models/topic_converter_spec.rb b/spec/models/topic_converter_spec.rb index 15fadd18be..fb4b351d21 100644 --- a/spec/models/topic_converter_spec.rb +++ b/spec/models/topic_converter_spec.rb @@ -90,6 +90,18 @@ describe TopicConverter do expect(other_user.user_actions.where(action_type: UserAction::REPLY).count).to eq(1) end end + + it "deletes notifications for users not allowed to see the topic" do + staff_category = Fabricate(:private_category, group: Group[:staff]) + user_notification = Fabricate(:mentioned_notification, post: first_post, user: Fabricate(:user)) + admin_notification = Fabricate(:mentioned_notification, post: first_post, user: Fabricate(:admin)) + + Jobs.run_immediately! + TopicConverter.new(first_post.topic, admin).convert_to_public_topic(staff_category.id) + + expect(Notification.exists?(user_notification.id)).to eq(false) + expect(Notification.exists?(admin_notification.id)).to eq(true) + end end end @@ -129,6 +141,17 @@ describe TopicConverter do expect(author.user_actions.where(action_type: UserAction::NEW_TOPIC).count).to eq(0) expect(author.user_actions.where(action_type: UserAction::NEW_PRIVATE_MESSAGE).count).to eq(1) end + + it "deletes notifications for users not allowed to see the message" do + user_notification = Fabricate(:mentioned_notification, post: post, user: Fabricate(:user)) + admin_notification = Fabricate(:mentioned_notification, post: post, user: Fabricate(:admin)) + + Jobs.run_immediately! + topic.convert_to_private_message(admin) + + expect(Notification.exists?(user_notification.id)).to eq(false) + expect(Notification.exists?(admin_notification.id)).to eq(true) + end end context 'topic has replies' do From 845fd421532c78766c6f6619826bf2298ea9d062 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Mon, 22 Jul 2019 21:42:24 +0200 Subject: [PATCH 076/441] FIX: Update reply count when moving posts --- app/models/post_mover.rb | 4 ++++ spec/models/post_mover_spec.rb | 28 ++++++++++++++++++++++------ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/app/models/post_mover.rb b/app/models/post_mover.rb index 81afb53566..de94423bd4 100644 --- a/app/models/post_mover.rb +++ b/app/models/post_mover.rb @@ -104,6 +104,10 @@ class PostMover PostReply.where("reply_id IN (:post_ids) OR post_id IN (:post_ids)", post_ids: post_ids).each do |post_reply| if post_reply.post && post_reply.reply && post_reply.reply.topic_id != post_reply.post.topic_id + Post + .where("id = ? AND reply_count > 0", post_reply.post.id) + .update_all("reply_count = reply_count - 1") + PostReply .where(reply_id: post_reply.reply.id, post_id: post_reply.post.id) .delete_all diff --git a/spec/models/post_mover_spec.rb b/spec/models/post_mover_spec.rb index ce6495356d..3cebd1e36e 100644 --- a/spec/models/post_mover_spec.rb +++ b/spec/models/post_mover_spec.rb @@ -28,14 +28,17 @@ describe PostMover do fab!(:another_user) { evil_trout } fab!(:category) { Fabricate(:category, user: user) } fab!(:topic) { Fabricate(:topic, user: user) } - fab!(:p1) { Fabricate(:post, topic: topic, user: user, created_at: 3.hours.ago) } + fab!(:p1) { Fabricate(:post, topic: topic, user: user, created_at: 3.hours.ago, reply_count: 2) } fab!(:p2) do - Fabricate(:post, + Fabricate( + :post, topic: topic, user: another_user, raw: "Has a link to [evil trout](http://eviltrout.com) which is a cool site.", - reply_to_post_number: p1.post_number) + reply_to_post_number: p1.post_number, + reply_count: 1 + ) end fab!(:p3) { Fabricate(:post, topic: topic, reply_to_post_number: p1.post_number, user: user) } @@ -46,8 +49,8 @@ describe PostMover do before do SiteSetting.tagging_enabled = true Jobs.run_immediately! - p1.replies << p3 - p2.replies << p4 + p1.replies.push(p2, p3) + p2.replies.push(p4) UserActionManager.enable @like = PostActionCreator.like(another_user, p4) end @@ -563,7 +566,7 @@ describe PostMover do expect(p1.sort_order).to eq(1) expect(p1.post_number).to eq(1) expect(p1.topic_id).to eq(topic.id) - expect(p1.reply_count).to eq(0) + expect(p1.reply_count).to eq(1) # New first post new_first = new_topic.posts.where(post_number: 1).first @@ -676,6 +679,19 @@ describe PostMover do expect(new_topic.posts.by_post_number.last.raw).to eq(p2.raw) expect(new_topic.posts_count).to eq(2) end + + it "corrects reply_counts within original topic" do + expect do + topic.move_posts(user, [p4.id], title: "new testing topic name 1") + end.to change { PostReply.count }.by(-1) + expect(p1.reload.reply_count).to eq(2) + expect(p2.reload.reply_count).to eq(0) + + expect do + topic.move_posts(user, [p2.id, p3.id], title: "new testing topic name 2") + end.to change { PostReply.count }.by(-2) + expect(p1.reload.reply_count).to eq(0) + end end end From a8cdd68518eafff4a9d955ed6f085994d50b0231 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Mon, 22 Jul 2019 22:16:43 +0200 Subject: [PATCH 077/441] FIX: Migrations tried to change frozen string Also, makes the migration work with locales which use more than just the "one" and "other" keys. --- .../20131022045114_add_uncategorized_category.rb | 2 +- .../20150729150523_migrate_auto_close_posts.rb | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/db/migrate/20131022045114_add_uncategorized_category.rb b/db/migrate/20131022045114_add_uncategorized_category.rb index 14b17d10f0..cb362fb0c3 100644 --- a/db/migrate/20131022045114_add_uncategorized_category.rb +++ b/db/migrate/20131022045114_add_uncategorized_category.rb @@ -4,7 +4,7 @@ class AddUncategorizedCategory < ActiveRecord::Migration[4.2] def up result = execute "SELECT 1 FROM categories WHERE lower(name) = 'uncategorized'" - name = 'Uncategorized' + name = +'Uncategorized' if result.count > 0 name << SecureRandom.hex end diff --git a/db/migrate/20150729150523_migrate_auto_close_posts.rb b/db/migrate/20150729150523_migrate_auto_close_posts.rb index 5b48992214..78508d7038 100644 --- a/db/migrate/20150729150523_migrate_auto_close_posts.rb +++ b/db/migrate/20150729150523_migrate_auto_close_posts.rb @@ -5,14 +5,17 @@ class MigrateAutoClosePosts < ActiveRecord::Migration[4.2] I18n.overrides_disabled do strings = [] %w(days hours lastpost_days lastpost_hours lastpost_minutes).map do |k| - strings << I18n.t("topic_statuses.autoclosed_enabled_#{k}.one") - strings << I18n.t("topic_statuses.autoclosed_enabled_#{k}.other").sub("%{count}", "\\d+") + strings << I18n.t("topic_statuses.autoclosed_enabled_#{k}").values.map { |s| s.sub("%{count}", "\\d+") } end - sql = "UPDATE posts SET action_code = 'autoclosed.enabled', post_type = 3 " - sql + "WHERE post_type = 2 AND (" - sql + strings.map { |s| "raw ~* #{ActiveRecord::Base.connection.quote(s)}" }.join(' OR ') - sql + ")" + sql = <<~SQL + UPDATE posts + SET action_code = 'autoclosed.enabled', + post_type = 3 + WHERE post_type = 2 AND ( + #{strings.map { |s| "raw ~* #{ActiveRecord::Base.connection.quote(s)}" }.join(' OR ')} + ) + SQL execute sql end From 89efcfcf28a2d66f88e30a350448d5d38db4f962 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Mon, 22 Jul 2019 22:30:57 +0200 Subject: [PATCH 078/441] Follow-up for a8cdd685 to fix nested array --- db/migrate/20150729150523_migrate_auto_close_posts.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrate/20150729150523_migrate_auto_close_posts.rb b/db/migrate/20150729150523_migrate_auto_close_posts.rb index 78508d7038..7c6664aa31 100644 --- a/db/migrate/20150729150523_migrate_auto_close_posts.rb +++ b/db/migrate/20150729150523_migrate_auto_close_posts.rb @@ -5,7 +5,7 @@ class MigrateAutoClosePosts < ActiveRecord::Migration[4.2] I18n.overrides_disabled do strings = [] %w(days hours lastpost_days lastpost_hours lastpost_minutes).map do |k| - strings << I18n.t("topic_statuses.autoclosed_enabled_#{k}").values.map { |s| s.sub("%{count}", "\\d+") } + strings += I18n.t("topic_statuses.autoclosed_enabled_#{k}").values.map { |s| s.sub("%{count}", "\\d+") } end sql = <<~SQL From f364317625ee273d1e8983faa271831db912953b Mon Sep 17 00:00:00 2001 From: OsamaSayegh Date: Tue, 23 Jul 2019 03:52:52 +0000 Subject: [PATCH 079/441] PERF: Improve query speed when looking up direct PMs Follow up to https://github.com/discourse/discourse/commit/5fc5a7f5ae8c3cf1a89c1a685258e9e942de1508 --- lib/search.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/search.rb b/lib/search.rb index a109f48a02..9fcd1b76e7 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -278,7 +278,6 @@ class Search ON tau2.topic_id = tau.topic_id AND tau2.id != tau.id WHERE tau.user_id = :user_id - AND tau.topic_id = posts.topic_id GROUP BY tau.topic_id HAVING COUNT(*) = 1 ) From afe2be4f622285d43ed10c92be0b4b708f689adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Tue, 23 Jul 2019 10:29:18 +0200 Subject: [PATCH 080/441] Follow-up for 89efcfcf to properly fix nested arrays --- ...20150729150523_migrate_auto_close_posts.rb | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/db/migrate/20150729150523_migrate_auto_close_posts.rb b/db/migrate/20150729150523_migrate_auto_close_posts.rb index 7c6664aa31..c487191d18 100644 --- a/db/migrate/20150729150523_migrate_auto_close_posts.rb +++ b/db/migrate/20150729150523_migrate_auto_close_posts.rb @@ -4,20 +4,20 @@ class MigrateAutoClosePosts < ActiveRecord::Migration[4.2] def up I18n.overrides_disabled do strings = [] - %w(days hours lastpost_days lastpost_hours lastpost_minutes).map do |k| - strings += I18n.t("topic_statuses.autoclosed_enabled_#{k}").values.map { |s| s.sub("%{count}", "\\d+") } + + %w(days hours lastpost_days lastpost_hours lastpost_minutes).each do |k| + I18n.t("topic_statuses.autoclosed_enabled_#{k}").values.each do |s| + strings << s.sub("%{count}", "\\d+") + end end - sql = <<~SQL + execute <<~SQL UPDATE posts - SET action_code = 'autoclosed.enabled', - post_type = 3 - WHERE post_type = 2 AND ( - #{strings.map { |s| "raw ~* #{ActiveRecord::Base.connection.quote(s)}" }.join(' OR ')} - ) + SET action_code = 'autoclosed.enabled' + , post_type = 3 + WHERE post_type = 2 + AND (#{strings.map { |s| "raw ~* #{ActiveRecord::Base.connection.quote(s)}" }.join(' OR ')}) SQL - - execute sql end end end From c4be8541f6917a4ea12293deb822cdc2e54e8630 Mon Sep 17 00:00:00 2001 From: romanrizzi Date: Tue, 23 Jul 2019 07:57:06 -0300 Subject: [PATCH 081/441] FIX: use uniq instead of uniq! when checking for uncompressed root path. Use rails naming convention for ZipUtils --- lib/import_export/zip_utils.rb | 80 ++++++++++--------- lib/theme_store/tgz_exporter.rb | 4 +- lib/theme_store/tgz_importer.rb | 2 +- .../theme_store/tgz_exporter_spec.rb | 3 +- .../theme_store/tgz_importer_spec.rb | 2 +- 5 files changed, 47 insertions(+), 44 deletions(-) diff --git a/lib/import_export/zip_utils.rb b/lib/import_export/zip_utils.rb index dc06e0efb0..0acd13625d 100644 --- a/lib/import_export/zip_utils.rb +++ b/lib/import_export/zip_utils.rb @@ -2,57 +2,59 @@ require 'zip' -class ZipUtils - def zip_directory(path, export_name) - zip_filename = "#{export_name}.zip" - absolute_path = "#{path}/#{export_name}" - entries = Dir.entries(absolute_path) - %w[. ..] +module ImportExport + class ZipUtils + def zip_directory(path, export_name) + zip_filename = "#{export_name}.zip" + absolute_path = "#{path}/#{export_name}" + entries = Dir.entries(absolute_path) - %w[. ..] - Zip::File.open(zip_filename, Zip::File::CREATE) do |zipfile| - write_entries(entries, absolute_path, '', zipfile) + Zip::File.open(zip_filename, Zip::File::CREATE) do |zipfile| + write_entries(entries, absolute_path, '', zipfile) + end + + "#{absolute_path}.zip" end - "#{absolute_path}.zip" - end - - def unzip_directory(path, zip_filename, allow_non_root_folder: false) - Zip::File.open(zip_filename) do |zip_file| - root = root_folder_present?(zip_file, allow_non_root_folder) ? '' : 'unzipped/' - zip_file.each do |entry| - entry_path = File.join(path, "#{root}#{entry.name}") - FileUtils.mkdir_p(File.dirname(entry_path)) - entry.extract(entry_path) + def unzip_directory(path, zip_filename, allow_non_root_folder: false) + Zip::File.open(zip_filename) do |zip_file| + root = root_folder_present?(zip_file, allow_non_root_folder) ? '' : 'unzipped/' + zip_file.each do |entry| + entry_path = File.join(path, "#{root}#{entry.name}") + FileUtils.mkdir_p(File.dirname(entry_path)) + entry.extract(entry_path) + end end end - end - private + private - def root_folder_present?(filenames, allow_non_root_folder) - filenames.map { |p| p.name.split('/').first }.uniq!.size == 1 || allow_non_root_folder - end + def root_folder_present?(filenames, allow_non_root_folder) + filenames.map { |p| p.name.split('/').first }.uniq.size == 1 || allow_non_root_folder + end - # A helper method to make the recursion work. - def write_entries(entries, base_path, path, zipfile) - entries.each do |e| - zipfile_path = path == '' ? e : File.join(path, e) - disk_file_path = File.join(base_path, zipfile_path) + # A helper method to make the recursion work. + def write_entries(entries, base_path, path, zipfile) + entries.each do |e| + zipfile_path = path == '' ? e : File.join(path, e) + disk_file_path = File.join(base_path, zipfile_path) - if File.directory? disk_file_path - recursively_deflate_directory(disk_file_path, zipfile, base_path, zipfile_path) - else - put_into_archive(disk_file_path, zipfile, zipfile_path) + if File.directory? disk_file_path + recursively_deflate_directory(disk_file_path, zipfile, base_path, zipfile_path) + else + put_into_archive(disk_file_path, zipfile, zipfile_path) + end end end - end - def recursively_deflate_directory(disk_file_path, zipfile, base_path, zipfile_path) - zipfile.mkdir zipfile_path - subdir = Dir.entries(disk_file_path) - %w[. ..] - write_entries subdir, base_path, zipfile_path, zipfile - end + def recursively_deflate_directory(disk_file_path, zipfile, base_path, zipfile_path) + zipfile.mkdir zipfile_path + subdir = Dir.entries(disk_file_path) - %w[. ..] + write_entries subdir, base_path, zipfile_path, zipfile + end - def put_into_archive(disk_file_path, zipfile, zipfile_path) - zipfile.add(zipfile_path, disk_file_path) + def put_into_archive(disk_file_path, zipfile, zipfile_path) + zipfile.add(zipfile_path, disk_file_path) + end end end diff --git a/lib/theme_store/tgz_exporter.rb b/lib/theme_store/tgz_exporter.rb index 75f5bff896..5673558249 100644 --- a/lib/theme_store/tgz_exporter.rb +++ b/lib/theme_store/tgz_exporter.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'import_export/zip_utils' + module ThemeStore; end class ThemeStore::TgzExporter @@ -60,6 +62,6 @@ class ThemeStore::TgzExporter def export_package export_to_folder - Dir.chdir(@temp_folder) { ZipUtils.new.zip_directory(@temp_folder, @export_name) } + Dir.chdir(@temp_folder) { ImportExport::ZipUtils.new.zip_directory(@temp_folder, @export_name) } end end diff --git a/lib/theme_store/tgz_importer.rb b/lib/theme_store/tgz_importer.rb index f0c88a4d1f..1efc8dee49 100644 --- a/lib/theme_store/tgz_importer.rb +++ b/lib/theme_store/tgz_importer.rb @@ -18,7 +18,7 @@ class ThemeStore::TgzImporter Dir.chdir(@temp_folder) do if @filename.include?('.zip') - ZipUtils.new.unzip_directory(@temp_folder, @filename) + ImportExport::ZipUtils.new.unzip_directory(@temp_folder, @filename) # --strip 1 equivalent FileUtils.mv(Dir.glob("#{@temp_folder}/*/*"), @temp_folder) diff --git a/spec/components/theme_store/tgz_exporter_spec.rb b/spec/components/theme_store/tgz_exporter_spec.rb index 18266eeb26..f0bd17a6b1 100644 --- a/spec/components/theme_store/tgz_exporter_spec.rb +++ b/spec/components/theme_store/tgz_exporter_spec.rb @@ -2,7 +2,6 @@ require 'rails_helper' require 'theme_store/tgz_exporter' -require 'import_export/zip_utils' describe ThemeStore::TgzExporter do let!(:theme) do @@ -64,7 +63,7 @@ describe ThemeStore::TgzExporter do file = 'discourse-header-icons.zip' dest = 'discourse-header-icons' Dir.chdir(dir) do - ZipUtils.new.unzip_directory(dir, file, allow_non_root_folder: true) + ImportExport::ZipUtils.new.unzip_directory(dir, file, allow_non_root_folder: true) `rm #{file}` folders = Dir.glob("**/*").reject { |f| File.file?(f) } diff --git a/spec/components/theme_store/tgz_importer_spec.rb b/spec/components/theme_store/tgz_importer_spec.rb index 0aad92584e..85347c7706 100644 --- a/spec/components/theme_store/tgz_importer_spec.rb +++ b/spec/components/theme_store/tgz_importer_spec.rb @@ -24,7 +24,7 @@ describe ThemeStore::TgzImporter do it "can import a simple zipped theme" do Dir.chdir(@temp_folder) do - ZipUtils.new.zip_directory(@temp_folder, 'test') + ImportExport::ZipUtils.new.zip_directory(@temp_folder, 'test') FileUtils.rm_rf('test/') end From 4bc5ccf7e474e651dd8194dd7d97477b72f918b8 Mon Sep 17 00:00:00 2001 From: Saurabh Patel Date: Tue, 23 Jul 2019 19:19:04 +0530 Subject: [PATCH 082/441] =?UTF-8?q?BUG:=20send=20featuredLink=20as=20featu?= =?UTF-8?q?red=5Flink=20to=20backend=20to=20update=20correct=20=E2=80=A6?= =?UTF-8?q?=20(#7915)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * BUG: send featuredLink as featured_link to backend to update correct value https://meta.discourse.org/t/editing-a-topic-link-does-not-change-its-featured-link/123007 * review fix --- app/assets/javascripts/discourse/models/composer.js.es6 | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index cdce634ff2..956ea9e4b5 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -709,6 +709,11 @@ const Composer = RestModel.extend({ const topicProps = this.getProperties( Object.keys(_edit_topic_serializer) ); + // frontend should have featuredLink but backend needs featured_link + if (topicProps.featuredLink) { + topicProps.featured_link = topicProps.featuredLink; + delete topicProps.featuredLink; + } const topic = this.topic; From 8a9ce7336d0ee556ba5420dd463810c8c0bee49c Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Tue, 23 Jul 2019 17:05:49 +0200 Subject: [PATCH 083/441] FIX: removes uncategorized context if not allowed in composer (#7922) --- .../javascripts/discourse/mixins/open-composer.js.es6 | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/mixins/open-composer.js.es6 b/app/assets/javascripts/discourse/mixins/open-composer.js.es6 index e3576cda08..7d925ab652 100644 --- a/app/assets/javascripts/discourse/mixins/open-composer.js.es6 +++ b/app/assets/javascripts/discourse/mixins/open-composer.js.es6 @@ -3,8 +3,17 @@ import Composer from "discourse/models/composer"; export default Ember.Mixin.create({ openComposer(controller) { + let categoryId = controller.get("category.id"); + if ( + categoryId && + controller.category.isUncategorizedCategory && + !this.siteSettings.allow_uncategorized_topics + ) { + categoryId = null; + } + this.controllerFor("composer").open({ - categoryId: controller.get("category.id"), + categoryId, action: Composer.CREATE_TOPIC, draftKey: controller.get("model.draft_key") || Composer.CREATE_TOPIC, draftSequence: controller.get("model.draft_sequence") || 0 From e117b10ea8c3d4f7c688828402a3c3996a0f3c0a Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Tue, 23 Jul 2019 17:06:25 +0200 Subject: [PATCH 084/441] FIX: improves tags checking when updating category of topic (#7921) - will ensure this tag is not restricted to another category, and not only ensure this category can use it - will clean tags param, in case client is sending an empty array, eg: [""], this could be solved client-side, but we ensure it won't happen ever this way --- app/controllers/topics_controller.rb | 13 +++++++------ spec/requests/topics_controller_spec.rb | 26 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 8116dfb19e..1c6a905d39 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -311,14 +311,15 @@ class TopicsController < ApplicationController return render_json_error(I18n.t('category.errors.not_found')) end - if category && topic_tags = (params[:tags] || topic.tags.pluck(:name)) - category_tags = category.tags.pluck(:name) - category_tag_groups = category.tag_groups.joins(:tags).pluck("tags.name") - allowed_tags = (category_tags + category_tag_groups).uniq + if category && topic_tags = (params[:tags] || topic.tags.pluck(:name)).reject { |c| c.empty? } + if topic_tags.present? + allowed_tags = DiscourseTagging.filter_allowed_tags( + Tag.all, + guardian, + category: category + ).pluck("tags.name") - if topic_tags.present? && allowed_tags.present? invalid_tags = topic_tags - allowed_tags - if !invalid_tags.empty? return render_json_error(I18n.t('category.errors.disallowed_topic_tags', tags: invalid_tags.join(", "))) end diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index a5440b1e78..d46e89ac96 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -1133,6 +1133,32 @@ RSpec.describe TopicsController do expect(response.status).to eq(200) end + + it 'can’t add a category-only tags from another category to a category' do + restricted_category.allowed_tags = [tag2.name] + + put "/t/#{topic.slug}/#{topic.id}.json", params: { + tags: [tag2], + category_id: category.id + } + + result = ::JSON.parse(response.body) + expect(response.status).to eq(422) + expect(result['errors']).to be_present + expect(topic.reload.category_id).not_to eq(restricted_category.id) + end + + it 'will clean tag params' do + restricted_category.allowed_tags = [tag2.name] + + put "/t/#{topic.slug}/#{topic.id}.json", params: { + tags: [""], + category_id: restricted_category.id + } + + result = ::JSON.parse(response.body) + expect(response.status).to eq(200) + end end context "allow_uncategorized_topics is false" do From 7d27b8bb8c27a2b4e2842775e82491153155cd61 Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 23 Jul 2019 11:31:37 -0400 Subject: [PATCH 085/441] add class for extra post buttons --- app/assets/javascripts/discourse/widgets/post-menu.js.es6 | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/widgets/post-menu.js.es6 b/app/assets/javascripts/discourse/widgets/post-menu.js.es6 index 4eadb59c0d..44aae9b713 100644 --- a/app/assets/javascripts/discourse/widgets/post-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-menu.js.es6 @@ -447,7 +447,7 @@ export default createWidget("post-menu", { if (afterButton) { content.push(afterButton(h)); } - button = h("span", content); + button = h("span.extra-buttons", content); if (button) { switch (position) { @@ -484,7 +484,6 @@ export default createWidget("post-menu", { postControls.push(this.attach("post-admin-menu", attrs)); } - const contents = [h("nav.post-controls.clearfix", postControls)]; if (state.likedUsers.length) { const remaining = state.total - state.likedUsers.length; contents.push( From 3fdc10337d6659fc9321360d7310f4d139f9c04e Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 23 Jul 2019 11:37:34 -0400 Subject: [PATCH 086/441] follow up fix to 7d27b8b --- app/assets/javascripts/discourse/widgets/post-menu.js.es6 | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/discourse/widgets/post-menu.js.es6 b/app/assets/javascripts/discourse/widgets/post-menu.js.es6 index 44aae9b713..102225e2d0 100644 --- a/app/assets/javascripts/discourse/widgets/post-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-menu.js.es6 @@ -484,6 +484,7 @@ export default createWidget("post-menu", { postControls.push(this.attach("post-admin-menu", attrs)); } + const contents = [h("nav.post-controls.clearfix", postControls)]; if (state.likedUsers.length) { const remaining = state.total - state.likedUsers.length; contents.push( From 68b082e1a4275b20fcb7021f8d6c55915d2cceda Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Tue, 23 Jul 2019 17:42:12 +0200 Subject: [PATCH 087/441] FIX: Ensure that jobs don't run immediately after migrate_to_s3 --- lib/backup_restore/restorer.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/backup_restore/restorer.rb b/lib/backup_restore/restorer.rb index 1e95502242..f183c2ce8c 100644 --- a/lib/backup_restore/restorer.rb +++ b/lib/backup_restore/restorer.rb @@ -504,6 +504,7 @@ module BackupRestore ENV["SKIP_FAILED"] = "1" ENV["MIGRATE_TO_MULTISITE"] = "1" if Rails.configuration.multisite Rake::Task["uploads:migrate_to_s3"].invoke + Jobs.run_later! end def remove_local_uploads(directory) From abe6202af9c6cf4cccc0edc9ff546fd246ffa197 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Tue, 23 Jul 2019 18:04:53 +0200 Subject: [PATCH 088/441] DEV: Fix heisentest --- spec/integration/watched_words_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/integration/watched_words_spec.rb b/spec/integration/watched_words_spec.rb index b78990090e..4657c7e0b1 100644 --- a/spec/integration/watched_words_spec.rb +++ b/spec/integration/watched_words_spec.rb @@ -57,7 +57,7 @@ describe WatchedWord do expect { result = manager.perform expect(result).to_not be_success - expect(result.errors[:base]&.first).to eq(I18n.t('contains_blocked_words', words: [block_word.word, another_block_word.word].join(', '))) + expect(result.errors[:base]&.first).to eq(I18n.t('contains_blocked_words', words: [block_word.word, another_block_word.word].sort.join(', '))) }.to_not change { Post.count } end From 9e0a3b82296747e00a5d0ac2df855e7f12aa9c2a Mon Sep 17 00:00:00 2001 From: Saurabh Patel Date: Tue, 23 Jul 2019 21:46:03 +0530 Subject: [PATCH 089/441] bug: keep query params present in auth_redirect (#7923) https://meta.discourse.org/t/user-api-keys-payload-and-existing-query-string-leads-to-a-double-question-mark/123617 --- app/controllers/user_api_keys_controller.rb | 9 ++++++--- spec/requests/user_api_keys_controller_spec.rb | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/controllers/user_api_keys_controller.rb b/app/controllers/user_api_keys_controller.rb index a30addfd32..d5d9e09cb6 100644 --- a/app/controllers/user_api_keys_controller.rb +++ b/app/controllers/user_api_keys_controller.rb @@ -93,9 +93,12 @@ class UserApiKeysController < ApplicationController end if params[:auth_redirect] - redirect_path = +"#{params[:auth_redirect]}?payload=#{CGI.escape(@payload)}" - redirect_path << "&oneTimePassword=#{CGI.escape(otp_payload)}" if scopes.include?("one_time_password") - redirect_to(redirect_path) + uri = URI.parse(params[:auth_redirect]) + query_attributes = [uri.query, "payload=#{CGI.escape(@payload)}"] + query_attributes << "oneTimePassword=#{CGI.escape(otp_payload)}" if scopes.include?("one_time_password") + uri.query = query_attributes.compact.join('&') + + redirect_to(uri.to_s) else respond_to do |format| format.html { render :show } diff --git a/spec/requests/user_api_keys_controller_spec.rb b/spec/requests/user_api_keys_controller_spec.rb index 06557d6a35..43aac9e83f 100644 --- a/spec/requests/user_api_keys_controller_spec.rb +++ b/spec/requests/user_api_keys_controller_spec.rb @@ -260,6 +260,23 @@ describe UserApiKeysController do post "/user-api-key.json", params: args expect(response.status).to eq(302) end + + it 'will keep query_params added in auth_redirect' do + SiteSetting.min_trust_level_for_user_api_key = 0 + SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect] + "/*" + + user = Fabricate(:user, trust_level: 0) + sign_in(user) + + query_str = "/?param1=val1" + args[:auth_redirect] = args[:auth_redirect] + query_str + + post "/user-api-key.json", params: args + expect(response.status).to eq(302) + + uri = URI.parse(response.redirect_url) + expect(uri.to_s).to include(query_str) + end end context '#create-one-time-password' do From 8b5f44a9a7c18f3a588dad244435510d32d7f90f Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Tue, 23 Jul 2019 20:17:44 +0300 Subject: [PATCH 090/441] FIX: apply defaults constraints to routes format (take 2) (#7920) Reapplies https://github.com/discourse/discourse/commit/7d01c5de1a92b020e19f387123a3cbbbe969c727 --- app/controllers/users_controller.rb | 5 +- config/routes.rb | 83 ++++++++++---------- spec/requests/application_controller_spec.rb | 6 ++ spec/requests/users_controller_spec.rb | 12 +-- 4 files changed, 59 insertions(+), 47 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 9604762214..786f1d3dd1 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1065,7 +1065,10 @@ class UsersController < ApplicationController @confirmed = true end - render layout: 'no_ember' + respond_to do |format| + format.json { render json: success_json } + format.html { render layout: 'no_ember' } + end end def list_second_factors diff --git a/config/routes.rb b/config/routes.rb index a4501f14bc..b27bc31c5d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,7 +12,8 @@ USERNAME_ROUTE_FORMAT = /[%\w.\-]+?/ unless defined? USERNAME_ROUTE_FORMAT BACKUP_ROUTE_FORMAT = /.+\.(sql\.gz|tar\.gz|tgz)/i unless defined? BACKUP_ROUTE_FORMAT Discourse::Application.routes.draw do - relative_url_root = (defined?(Rails.configuration.relative_url_root) && Rails.configuration.relative_url_root) ? Rails.configuration.relative_url_root + '/' : '/' + scope path: nil, constraints: { format: /(json|html|\*\/\*)/ } do + relative_url_root = (defined?(Rails.configuration.relative_url_root) && Rails.configuration.relative_url_root) ? Rails.configuration.relative_url_root + '/' : '/' match "/404", to: "exceptions#not_found", via: [:get, :post] get "/404-body" => "exceptions#not_found_body" @@ -24,13 +25,15 @@ Discourse::Application.routes.draw do post "webhooks/sendgrid" => "webhooks#sendgrid" post "webhooks/sparkpost" => "webhooks#sparkpost" - if Rails.env.development? - mount Sidekiq::Web => "/sidekiq" - mount Logster::Web => "/logs" - else - # only allow sidekiq in master site - mount Sidekiq::Web => "/sidekiq", constraints: AdminConstraint.new(require_master: true) - mount Logster::Web => "/logs", constraints: AdminConstraint.new + scope path: nil, constraints: { format: /.*/ } do + if Rails.env.development? + mount Sidekiq::Web => "/sidekiq" + mount Logster::Web => "/logs" + else + # only allow sidekiq in master site + mount Sidekiq::Web => "/sidekiq", constraints: AdminConstraint.new(require_master: true) + mount Logster::Web => "/logs", constraints: AdminConstraint.new + end end resources :about do @@ -352,17 +355,17 @@ Discourse::Application.routes.draw do post "composer/parse_html" => "composer#parse_html" resources :static - post "login" => "static#enter", constraints: { format: /(json|html)/ } - get "login" => "static#show", id: "login", constraints: { format: /(json|html)/ } - get "password-reset" => "static#show", id: "password_reset", constraints: { format: /(json|html)/ } - get "faq" => "static#show", id: "faq", constraints: { format: /(json|html)/ } - get "tos" => "static#show", id: "tos", as: 'tos', constraints: { format: /(json|html)/ } - get "privacy" => "static#show", id: "privacy", as: 'privacy', constraints: { format: /(json|html)/ } - get "signup" => "static#show", id: "signup", constraints: { format: /(json|html)/ } - get "login-preferences" => "static#show", id: "login", constraints: { format: /(json|html)/ } + post "login" => "static#enter" + get "login" => "static#show", id: "login" + get "password-reset" => "static#show", id: "password_reset" + get "faq" => "static#show", id: "faq" + get "tos" => "static#show", id: "tos", as: 'tos' + get "privacy" => "static#show", id: "privacy", as: 'privacy' + get "signup" => "static#show", id: "signup" + get "login-preferences" => "static#show", id: "login" %w{guidelines rules conduct}.each do |faq_alias| - get faq_alias => "static#show", id: "guidelines", as: faq_alias, constraints: { format: /(json|html)/ } + get faq_alias => "static#show", id: "guidelines", as: faq_alias end get "my/*path", to: 'users#my_redirect' @@ -422,8 +425,8 @@ Discourse::Application.routes.draw do get "#{root_path}/:username/messages/group/:group_name/archive" => "user_actions#private_messages", constraints: { username: RouteFormat.username, group_name: RouteFormat.username } get "#{root_path}/:username/messages/tags/:tag_id" => "user_actions#private_messages", constraints: StaffConstraint.new get "#{root_path}/:username.json" => "users#show", constraints: { username: RouteFormat.username }, defaults: { format: :json } - get({ "#{root_path}/:username" => "users#show", constraints: { username: RouteFormat.username, format: /(json|html)/ } }.merge(index == 1 ? { as: 'user' } : {})) - put "#{root_path}/:username" => "users#update", constraints: { username: RouteFormat.username, format: /(json|html)/ }, defaults: { format: :json } + get({ "#{root_path}/:username" => "users#show", constraints: { username: RouteFormat.username } }.merge(index == 1 ? { as: 'user' } : {})) + put "#{root_path}/:username" => "users#update", constraints: { username: RouteFormat.username }, defaults: { format: :json } get "#{root_path}/:username/emails" => "users#check_emails", constraints: { username: RouteFormat.username } get({ "#{root_path}/:username/preferences" => "users#preferences", constraints: { username: RouteFormat.username } }.merge(index == 1 ? { as: :email_preferences } : {})) get "#{root_path}/:username/preferences/email" => "users_email#index", constraints: { username: RouteFormat.username } @@ -465,7 +468,7 @@ Discourse::Application.routes.draw do get "#{root_path}/:username/badges" => "users#badges", constraints: { username: RouteFormat.username } get "#{root_path}/:username/notifications" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/notifications/:filter" => "users#show", constraints: { username: RouteFormat.username } - delete "#{root_path}/:username" => "users#destroy", constraints: { username: RouteFormat.username, format: /(json|html)/ } + delete "#{root_path}/:username" => "users#destroy", constraints: { username: RouteFormat.username } get "#{root_path}/by-external/:external_id" => "users#show", constraints: { external_id: /[^\/]+/ } get "#{root_path}/:username/flagged-posts" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/deleted-posts" => "users#show", constraints: { username: RouteFormat.username } @@ -474,18 +477,18 @@ Discourse::Application.routes.draw do end get "user-badges/:username.json" => "user_badges#username", constraints: { username: RouteFormat.username }, defaults: { format: :json } - get "user-badges/:username" => "user_badges#username", constraints: { username: RouteFormat.username, format: /(json|html)/ } + get "user-badges/:username" => "user_badges#username", constraints: { username: RouteFormat.username } post "user_avatar/:username/refresh_gravatar" => "user_avatars#refresh_gravatar", constraints: { username: RouteFormat.username } - get "letter_avatar/:username/:size/:version.png" => "user_avatars#show_letter", format: false, constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: RouteFormat.username } - get "user_avatar/:hostname/:username/:size/:version.png" => "user_avatars#show", format: false, constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: RouteFormat.username } + get "letter_avatar/:username/:size/:version.png" => "user_avatars#show_letter", constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: RouteFormat.username, format: :png } + get "user_avatar/:hostname/:username/:size/:version.png" => "user_avatars#show", constraints: { hostname: /[\w\.-]+/, size: /\d+/, username: RouteFormat.username, format: :png } - get "letter_avatar_proxy/:version/letter/:letter/:color/:size.png" => "user_avatars#show_proxy_letter" + get "letter_avatar_proxy/:version/letter/:letter/:color/:size.png" => "user_avatars#show_proxy_letter", constraints: { format: :png } - get "svg-sprite/:hostname/svg-:theme_ids-:version.js" => "svg_sprite#show", format: false, constraints: { hostname: /[\w\.-]+/, version: /\h{40}/, theme_ids: /([0-9]+(,[0-9]+)*)?/ } + get "svg-sprite/:hostname/svg-:theme_ids-:version.js" => "svg_sprite#show", constraints: { hostname: /[\w\.-]+/, version: /\h{40}/, theme_ids: /([0-9]+(,[0-9]+)*)?/, format: :js } get "svg-sprite/search/:keyword" => "svg_sprite#search", format: false, constraints: { keyword: /[-a-z0-9\s\%]+/ } - get "highlight-js/:hostname/:version.js" => "highlight_js#show", format: false, constraints: { hostname: /[\w\.-]+/ } + get "highlight-js/:hostname/:version.js" => "highlight_js#show", constraints: { hostname: /[\w\.-]+/, format: :js } get "stylesheets/:name.css.map" => "stylesheets#show_source_map", constraints: { name: /[-a-z0-9_]+/ } get "stylesheets/:name.css" => "stylesheets#show", constraints: { name: /[-a-z0-9_]+/ } @@ -501,10 +504,10 @@ Discourse::Application.routes.draw do # used to download attachments get "uploads/:site/original/:tree:sha(.:extension)" => "uploads#show", constraints: { site: /\w+/, tree: /([a-z0-9]+\/)+/i, sha: /\h{40}/, extension: /[a-z0-9\.]+/i } # used to download attachments (old route) - get "uploads/:site/:id/:sha" => "uploads#show", constraints: { site: /\w+/, id: /\d+/, sha: /\h{16}/ } + get "uploads/:site/:id/:sha" => "uploads#show", constraints: { site: /\w+/, id: /\d+/, sha: /\h{16}/, format: /.*/ } - get "posts" => "posts#latest", id: "latest_posts" - get "private-posts" => "posts#latest", id: "private_posts" + get "posts" => "posts#latest", id: "latest_posts", constraints: { format: /(json|rss)/ } + get "private-posts" => "posts#latest", id: "private_posts", constraints: { format: /(json|rss)/ } get "posts/by_number/:topic_id/:post_number" => "posts#by_number" get "posts/by-date/:topic_id/:date" => "posts#by_date" get "posts/:id/reply-history" => "posts#reply_history" @@ -614,7 +617,7 @@ Discourse::Application.routes.draw do resources :user_actions resources :badges, only: [:index] - get "/badges/:id(/:slug)" => "badges#show" + get "/badges/:id(/:slug)" => "badges#show", constraints: { format: /(json|html|rss)/ } resources :user_badges, only: [:index, :create, :destroy] get '/c', to: redirect(relative_url_root + 'categories') @@ -655,7 +658,7 @@ Discourse::Application.routes.draw do end Discourse.filters.each do |filter| - get "#{filter}" => "list##{filter}", constraints: { format: /(json|html)/ } + get "#{filter}" => "list##{filter}" get "c/:category/l/#{filter}" => "list#category_#{filter}", as: "category_#{filter}" get "c/:category/none/l/#{filter}" => "list#category_none_#{filter}", as: "category_none_#{filter}" get "c/:parent_category/:category/l/#{filter}" => "list#parent_category_category_#{filter}", as: "parent_category_category_#{filter}" @@ -688,11 +691,11 @@ Discourse::Application.routes.draw do get "topics/feature_stats" scope "/topics", username: RouteFormat.username do - get "created-by/:username" => "list#topics_by", as: "topics_by", constraints: { format: /(json|html)/ }, defaults: { format: :json } - get "private-messages/:username" => "list#private_messages", as: "topics_private_messages", constraints: { format: /(json|html)/ }, defaults: { format: :json } - get "private-messages-sent/:username" => "list#private_messages_sent", as: "topics_private_messages_sent", constraints: { format: /(json|html)/ }, defaults: { format: :json } - get "private-messages-archive/:username" => "list#private_messages_archive", as: "topics_private_messages_archive", constraints: { format: /(json|html)/ }, defaults: { format: :json } - get "private-messages-unread/:username" => "list#private_messages_unread", as: "topics_private_messages_unread", constraints: { format: /(json|html)/ }, defaults: { format: :json } + get "created-by/:username" => "list#topics_by", as: "topics_by", defaults: { format: :json } + get "private-messages/:username" => "list#private_messages", as: "topics_private_messages", defaults: { format: :json } + get "private-messages-sent/:username" => "list#private_messages_sent", as: "topics_private_messages_sent", defaults: { format: :json } + get "private-messages-archive/:username" => "list#private_messages_archive", as: "topics_private_messages_archive", defaults: { format: :json } + get "private-messages-unread/:username" => "list#private_messages_unread", as: "topics_private_messages_unread", defaults: { format: :json } get "private-messages-tags/:username/:tag_id.json" => "list#private_messages_tag", as: "topics_private_messages_tag", constraints: StaffConstraint.new get "groups/:group_name" => "list#group_topics", as: "group_topics", group_name: RouteFormat.username @@ -803,8 +806,8 @@ Discourse::Application.routes.draw do get "/service-worker.js" => "static#service_worker_asset", format: :js end - get "cdn_asset/:site/*path" => "static#cdn_asset", format: false - get "brotli_asset/*path" => "static#brotli_asset", format: false + get "cdn_asset/:site/*path" => "static#cdn_asset", format: false, constraints: { format: /.*/ } + get "brotli_asset/*path" => "static#brotli_asset", format: false, constraints: { format: /.*/ } get "favicon/proxied" => "static#favicon", format: false @@ -813,7 +816,7 @@ Discourse::Application.routes.draw do get "offline.html" => "offline#index" get "manifest.webmanifest" => "metadata#manifest", as: :manifest get "manifest.json" => "metadata#manifest" - get "opensearch" => "metadata#opensearch", format: :xml + get "opensearch" => "metadata#opensearch", constraints: { format: :xml } scope "/tags" do get '/' => 'tags#index' @@ -882,5 +885,5 @@ Discourse::Application.routes.draw do resources :csp_reports, only: [:create] get "*url", to: 'permalinks#show', constraints: PermalinkConstraint.new - + end end diff --git a/spec/requests/application_controller_spec.rb b/spec/requests/application_controller_spec.rb index 6941a15232..b44df1ba08 100644 --- a/spec/requests/application_controller_spec.rb +++ b/spec/requests/application_controller_spec.rb @@ -473,4 +473,10 @@ RSpec.describe ApplicationController do end.to_h end end + + it 'can respond to a request with */* accept header' do + get '/', headers: { HTTP_ACCEPT: '*/*' } + expect(response.status).to eq(200) + expect(response.body).to include('Discourse') + end end diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index ed177c2fff..0d39c338b6 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -2372,12 +2372,12 @@ describe UsersController do describe '#confirm_admin' do it "fails without a valid token" do - get "/u/confirm-admin/invalid-token.josn" + get "/u/confirm-admin/invalid-token.json" expect(response).not_to be_successful end it "fails with a missing token" do - get "/u/confirm-admin/a0a0a0a0a0.josn" + get "/u/confirm-admin/a0a0a0a0a0.json" expect(response).to_not be_successful end @@ -2385,7 +2385,7 @@ describe UsersController do user = Fabricate(:user) ac = AdminConfirmation.new(user, Fabricate(:admin)) ac.create_confirmation - get "/u/confirm-admin/#{ac.token}.josn" + get "/u/confirm-admin/#{ac.token}.json" expect(response.status).to eq(200) user.reload @@ -2398,7 +2398,7 @@ describe UsersController do ac = AdminConfirmation.new(user, admin) ac.create_confirmation - get "/u/confirm-admin/#{ac.token}.josn", params: { token: ac.token } + get "/u/confirm-admin/#{ac.token}.json", params: { token: ac.token } expect(response.status).to eq(200) user.reload @@ -2411,7 +2411,7 @@ describe UsersController do ac = AdminConfirmation.new(user, Fabricate(:admin)) ac.create_confirmation - get "/u/confirm-admin/#{ac.token}.josn" + get "/u/confirm-admin/#{ac.token}.json" expect(response).to_not be_successful user.reload @@ -2423,7 +2423,7 @@ describe UsersController do user = Fabricate(:user) ac = AdminConfirmation.new(user, Fabricate(:admin)) ac.create_confirmation - post "/u/confirm-admin/#{ac.token}.josn" + post "/u/confirm-admin/#{ac.token}.json" expect(response.status).to eq(200) user.reload From 31f583855a5607a79258d0822db40c11b0564adb Mon Sep 17 00:00:00 2001 From: Michael Brown Date: Tue, 23 Jul 2019 12:41:57 -0400 Subject: [PATCH 091/441] DEV: pull static check out of loop * followup to 08b28680 * as per https://review.discourse.org/t/4713/2 --- lib/backup_restore/restorer.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/backup_restore/restorer.rb b/lib/backup_restore/restorer.rb index f183c2ce8c..701c72e7d4 100644 --- a/lib/backup_restore/restorer.rb +++ b/lib/backup_restore/restorer.rb @@ -11,6 +11,11 @@ module BackupRestore attr_reader :success def self.pg_produces_portable_dump?(version) + # anything pg 11 or above will produce a non-portable dump + return false if version.to_i >= 11 + + # below 11, the behaviour was changed in multiple different minor + # versions depending on major release line - we list those versions below gem_version = Gem::Version.new(version) %w{ @@ -20,8 +25,6 @@ module BackupRestore 9.4.17 9.3.22 }.each do |unportable_version| - # anything pg 11 or above will produce a non-portable dump - return false if version.to_i >= 11 return false if Gem::Dependency.new("", "~> #{unportable_version}").match?("", gem_version) end From 02e27b5cff5a96d85961cd86e964d40e82338541 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 24 Jul 2019 09:40:32 +0200 Subject: [PATCH 092/441] UX: fixes onebox favicon vertical alignment (#7926) --- 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 0696e4bf51..eecdbb6c93 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -87,7 +87,7 @@ a.loading-onebox { @mixin onebox-favicon($class, $image) { &.#{$class} .source { - background: image-url("favicons/#{$image}.png") no-repeat; + background: image-url("favicons/#{$image}.png") no-repeat 0% 50%; background-size: 16px 16px; padding-left: 20px; } From 47ad2a4d7a5b207d1ad26adfad5bbc460f14eba2 Mon Sep 17 00:00:00 2001 From: Angus McLeod Date: Wed, 24 Jul 2019 19:22:26 +1000 Subject: [PATCH 093/441] DEV: Handle both name formats in managed authenticator (#7925) --- lib/auth/managed_authenticator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/auth/managed_authenticator.rb b/lib/auth/managed_authenticator.rb index bdc7f0951c..e8c74b4463 100644 --- a/lib/auth/managed_authenticator.rb +++ b/lib/auth/managed_authenticator.rb @@ -85,7 +85,7 @@ class Auth::ManagedAuthenticator < Auth::Authenticator result = Auth::Result.new info = auth_token[:info] result.email = info[:email] - result.name = "#{info[:first_name]} #{info[:last_name]}" + result.name = info[:first_name] && info[:last_name] ? "#{info[:first_name]} #{info[:last_name]}" : info[:name] result.username = info[:nickname] result.email_valid = primary_email_verified?(auth_token) if result.email result.extra_data = { From da4c1c5afc5a0ff4923ab588db413a88876e943c Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 24 Jul 2019 10:27:44 +0100 Subject: [PATCH 094/441] DEV: Remove trailing whitespace from 47ad2a4d --- lib/auth/managed_authenticator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/auth/managed_authenticator.rb b/lib/auth/managed_authenticator.rb index e8c74b4463..89107ee93e 100644 --- a/lib/auth/managed_authenticator.rb +++ b/lib/auth/managed_authenticator.rb @@ -85,7 +85,7 @@ class Auth::ManagedAuthenticator < Auth::Authenticator result = Auth::Result.new info = auth_token[:info] result.email = info[:email] - result.name = info[:first_name] && info[:last_name] ? "#{info[:first_name]} #{info[:last_name]}" : info[:name] + result.name = info[:first_name] && info[:last_name] ? "#{info[:first_name]} #{info[:last_name]}" : info[:name] result.username = info[:nickname] result.email_valid = primary_email_verified?(auth_token) if result.email result.extra_data = { From 0a6cae654bda7d6655fba9af1b67b60ca5c782a4 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 17 Jul 2019 12:34:02 +0100 Subject: [PATCH 095/441] SECURITY: Add confirmation screen when connecting associated accounts --- .../associate-account-confirm.js.es6 | 26 ++++++ .../controllers/preferences/account.js.es6 | 7 +- .../discourse/models/login-method.js.es6 | 2 +- .../discourse/routes/app-route-map.js.es6 | 1 + .../discourse/routes/associate-account.js.es6 | 16 ++++ .../modal/associate-account-confirm.hbs | 21 +++++ .../templates/preferences/account.hbs | 4 +- .../users/associate_accounts_controller.rb | 45 ++++++++++ .../users/omniauth_callbacks_controller.rb | 18 +--- config/locales/client.en.yml | 3 + config/routes.rb | 2 + lib/auth/authenticator.rb | 7 ++ lib/auth/managed_authenticator.rb | 12 ++- spec/requests/associate_accounts_spec.rb | 89 +++++++++++++++++++ .../omniauth_callbacks_controller_spec.rb | 9 +- 15 files changed, 235 insertions(+), 27 deletions(-) create mode 100644 app/assets/javascripts/discourse/controllers/associate-account-confirm.js.es6 create mode 100644 app/assets/javascripts/discourse/routes/associate-account.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/modal/associate-account-confirm.hbs create mode 100644 app/controllers/users/associate_accounts_controller.rb create mode 100644 spec/requests/associate_accounts_spec.rb diff --git a/app/assets/javascripts/discourse/controllers/associate-account-confirm.js.es6 b/app/assets/javascripts/discourse/controllers/associate-account-confirm.js.es6 new file mode 100644 index 0000000000..e9685e0cf9 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/associate-account-confirm.js.es6 @@ -0,0 +1,26 @@ +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; + +export default Ember.Controller.extend(ModalFunctionality, { + actions: { + finishConnect() { + ajax({ + url: `/associate/${encodeURIComponent(this.model.token)}`, + type: "POST" + }) + .then(result => { + if (result.success) { + this.transitionToRoute( + "preferences.account", + this.currentUser.findDetails() + ); + this.send("closeModal"); + } else { + this.set("model.error", result.error); + } + }) + .catch(popupAjaxError); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 index 543c3a9ee0..f5c1f6f5ad 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 @@ -20,6 +20,7 @@ export default Ember.Controller.extend( this._super(...arguments); this.saveAttrNames = ["name", "title"]; + this.set("revoking", {}); }, canEditName: setting("enable_names"), @@ -32,6 +33,8 @@ export default Ember.Controller.extend( showAllAuthTokens: false, + revoking: null, + cannotDeleteAccount: Ember.computed.not("currentUser.can_delete_account"), deleteDisabled: Ember.computed.or( "model.isSaving", @@ -202,7 +205,7 @@ export default Ember.Controller.extend( }, revokeAccount(account) { - this.set("revoking", true); + this.set(`revoking.${account.name}`, true); this.model .revokeAssociatedAccount(account.name) @@ -214,7 +217,7 @@ export default Ember.Controller.extend( } }) .catch(popupAjaxError) - .finally(() => this.set("revoking", false)); + .finally(() => this.set(`revoking.${account.name}`, false)); }, toggleShowAllAuthTokens() { diff --git a/app/assets/javascripts/discourse/models/login-method.js.es6 b/app/assets/javascripts/discourse/models/login-method.js.es6 index 083559ade0..a9166633f4 100644 --- a/app/assets/javascripts/discourse/models/login-method.js.es6 +++ b/app/assets/javascripts/discourse/models/login-method.js.es6 @@ -29,7 +29,7 @@ const LoginMethod = Ember.Object.extend({ authUrl += "?reconnect=true"; } - if (fullScreenLogin || this.full_screen_login) { + if (reconnect || fullScreenLogin || this.full_screen_login) { document.cookie = "fsl=true"; window.location = authUrl; } else { diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index bfec70df96..654c683a45 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -178,6 +178,7 @@ export default function() { this.route("signup", { path: "/signup" }); this.route("login", { path: "/login" }); this.route("email-login", { path: "/session/email-login/:token" }); + this.route("associate-account", { path: "/associate/:token" }); this.route("login-preferences"); this.route("forgot-password", { path: "/password-reset" }); this.route("faq", { path: "/faq" }); diff --git a/app/assets/javascripts/discourse/routes/associate-account.js.es6 b/app/assets/javascripts/discourse/routes/associate-account.js.es6 new file mode 100644 index 0000000000..dfbe2e52f7 --- /dev/null +++ b/app/assets/javascripts/discourse/routes/associate-account.js.es6 @@ -0,0 +1,16 @@ +import { ajax } from "discourse/lib/ajax"; +import showModal from "discourse/lib/show-modal"; +import { popupAjaxError } from "discourse/lib/ajax-error"; + +export default Discourse.Route.extend({ + beforeModel() { + const params = this.paramsFor("associate-account"); + this.replaceWith(`preferences.account`, this.currentUser).then(() => + Ember.run.next(() => + ajax(`/associate/${encodeURIComponent(params.token)}`) + .then(model => showModal("associate-account-confirm", { model })) + .catch(popupAjaxError) + ) + ); + } +}); diff --git a/app/assets/javascripts/discourse/templates/modal/associate-account-confirm.hbs b/app/assets/javascripts/discourse/templates/modal/associate-account-confirm.hbs new file mode 100644 index 0000000000..3fec07f02e --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/associate-account-confirm.hbs @@ -0,0 +1,21 @@ +{{#d-modal-body + rawTitle=( + i18n "user.associated_accounts.confirm_modal_title" + provider=(i18n (concat "login." model.provider_name ".name")) + ) +}} + {{#if model.error}} +
    + {{model.error}} +
    + {{/if}} + + {{i18n "user.associated_accounts.confirm_description" + provider=(i18n (concat "login." model.provider_name ".name")) + account_description=model.account_description}} +{{/d-modal-body}} + + diff --git a/app/assets/javascripts/discourse/templates/preferences/account.hbs b/app/assets/javascripts/discourse/templates/preferences/account.hbs index 0c904e4ced..9f71dccd95 100644 --- a/app/assets/javascripts/discourse/templates/preferences/account.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/account.hbs @@ -111,9 +111,7 @@
    diff --git a/app/controllers/users/associate_accounts_controller.rb b/app/controllers/users/associate_accounts_controller.rb new file mode 100644 index 0000000000..6505afa6a7 --- /dev/null +++ b/app/controllers/users/associate_accounts_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Users::AssociateAccountsController < ApplicationController + REDIS_PREFIX ||= "omniauth_reconnect" + + ## + # Presents a confirmation screen to the user. Accessed via GET, with no CSRF checks + def connect_info + auth = get_auth_hash + + provider_name = auth.provider + authenticator = Discourse.enabled_authenticators.find { |a| a.name == provider_name } + raise Discourse::InvalidAccess.new(I18n.t('authenticator_not_found')) if authenticator.nil? + + account_description = authenticator.description_for_auth_hash(auth) + + render json: { token: params[:token], provider_name: provider_name, account_description: account_description } + end + + ## + # Presents a confirmation screen to the user. Accessed via GET, with no CSRF checks + def connect + auth = get_auth_hash + $redis.del "#{REDIS_PREFIX}_#{current_user&.id}_#{params[:token]}" + + provider_name = auth.provider + authenticator = Discourse.enabled_authenticators.find { |a| a.name == provider_name } + raise Discourse::InvalidAccess.new(I18n.t('authenticator_not_found')) if authenticator.nil? + + auth_result = authenticator.after_authenticate(auth, existing_account: current_user) + DiscourseEvent.trigger(:after_auth, authenticator, auth_result) + + render json: success_json + end + + private + + def get_auth_hash + token = params[:token] + json = $redis.get "#{REDIS_PREFIX}_#{current_user&.id}_#{token}" + raise Discourse::NotFound if json.nil? + + OmniAuth::AuthHash.new(JSON.parse(json)) + end +end diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 4a9b0916e2..b23cc593df 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -28,20 +28,10 @@ class Users::OmniauthCallbacksController < ApplicationController provider = DiscoursePluginRegistry.auth_providers.find { |p| p.name == params[:provider] } if session.delete(:auth_reconnect) && authenticator.can_connect_existing_user? && current_user - # If we're reconnecting, don't actually try and log the user in - @auth_result = authenticator.after_authenticate(auth, existing_account: current_user) - if provider&.full_screen_login || cookies['fsl'] - cookies.delete('fsl') - DiscourseEvent.trigger(:after_auth, authenticator, @auth_result) - return redirect_to Discourse.base_uri("/my/preferences/account") - else - @auth_result.authenticated = true - DiscourseEvent.trigger(:after_auth, authenticator, @auth_result) - return respond_to do |format| - format.html - format.json { render json: @auth_result.to_client_hash } - end - end + # Save to redis, with a secret token, then redirect to confirmation screen + token = SecureRandom.hex + $redis.setex "#{Users::AssociateAccountsController::REDIS_PREFIX}_#{current_user.id}_#{token}", 10.minutes, auth.to_json + return redirect_to Discourse.base_uri("/associate/#{token}") else @auth_result = authenticator.after_authenticate(auth) DiscourseEvent.trigger(:after_auth, authenticator, @auth_result) diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 23c624d85e..4fae27d51e 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1021,7 +1021,10 @@ en: title: "Associated Accounts" connect: "Connect" revoke: "Revoke" + cancel: "Cancel" not_connected: "(not connected)" + confirm_modal_title: "Connect %{provider} Account" + confirm_description: "Your %{provider} account '%{account_description}' will be used for authentication." name: title: "Name" diff --git a/config/routes.rb b/config/routes.rb index b27bc31c5d..d1e4bf0bb2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -598,6 +598,8 @@ Discourse::Application.routes.draw do match "/auth/:provider/callback", to: "users/omniauth_callbacks#complete", via: [:get, :post] match "/auth/failure", to: "users/omniauth_callbacks#failure", via: [:get, :post] + get "/associate/:token", to: "users/associate_accounts#connect_info", constraints: { token: /\h{32}/ } + post "/associate/:token", to: "users/associate_accounts#connect", constraints: { token: /\h{32}/ } resources :clicks do collection do diff --git a/lib/auth/authenticator.rb b/lib/auth/authenticator.rb index b306b2c206..cd6fd4fd5e 100644 --- a/lib/auth/authenticator.rb +++ b/lib/auth/authenticator.rb @@ -40,6 +40,13 @@ class Auth::Authenticator "" end + # return a string describing the connected account + # for a given OmniAuth::AuthHash. Used in the confirmation screen + # when connecting accounts + def description_for_auth_hash(user) + "" + end + # can authorisation for this provider be revoked? def can_revoke? false diff --git a/lib/auth/managed_authenticator.rb b/lib/auth/managed_authenticator.rb index 89107ee93e..1278914f91 100644 --- a/lib/auth/managed_authenticator.rb +++ b/lib/auth/managed_authenticator.rb @@ -2,9 +2,15 @@ class Auth::ManagedAuthenticator < Auth::Authenticator def description_for_user(user) - info = UserAssociatedAccount.find_by(provider_name: name, user_id: user.id)&.info - return "" if info.nil? - info["email"] || info["nickname"] || info["name"] || I18n.t("associated_accounts.connected") + associated_account = UserAssociatedAccount.find_by(provider_name: name, user_id: user.id) + return "" if associated_account.nil? + description_for_auth_hash(associated_account) || I18n.t("associated_accounts.connected") + end + + def description_for_auth_hash(auth_token) + return if auth_token&.info.nil? + info = auth_token.info + info["email"] || info["nickname"] || info["name"] end # These three methods are designed to be overriden by child classes diff --git a/spec/requests/associate_accounts_spec.rb b/spec/requests/associate_accounts_spec.rb new file mode 100644 index 0000000000..41b199c838 --- /dev/null +++ b/spec/requests/associate_accounts_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::AssociateAccountsController do + fab!(:user) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + + before do + OmniAuth.config.test_mode = true + end + + after do + Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2] = nil + OmniAuth.config.test_mode = false + end + + context 'when attempting reconnect' do + before do + SiteSetting.enable_google_oauth2_logins = true + OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new( + provider: 'google_oauth2', + uid: '12345', + info: { + email: 'someemail@test.com', + }, + extra: { + raw_info: { + email_verified: true, + } + }, + ) + + Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2] + end + + it 'should work correctly' do + sign_in(user) + + # Reconnect flow: + get "/auth/google_oauth2?reconnect=true" + expect(response.status).to eq(302) + expect(session[:auth_reconnect]).to eq(true) + + OmniAuth.config.mock_auth[:google_oauth2].uid = "123456" + get "/auth/google_oauth2/callback.json" + expect(response.status).to eq(302) + + expect(session[:current_user_id]).to eq(user.id) # Still logged in + expect(UserAssociatedAccount.count).to eq(0) # Reconnect has not yet happened + + # Request associate info + uri = URI.parse(response.redirect_url) + get "#{uri.path}.json" + data = JSON.parse(response.body) + expect(data["provider_name"]).to eq("google_oauth2") + expect(data["account_description"]).to eq("someemail@test.com") + + # Request as different user, should not work + sign_in(user2) + get "#{uri.path}.json" + expect(response.status).to eq(404) + + # Back to first user + sign_in(user) + get "#{uri.path}.json" + data = JSON.parse(response.body) + expect(data["provider_name"]).to eq("google_oauth2") + + # Make the connection + post "#{uri.path}.json" + expect(response.status).to eq(200) + expect(UserAssociatedAccount.count).to eq(1) + + # Token cannot be reused + get "#{uri.path}.json" + expect(response.status).to eq(404) + end + + it "returns the correct response for non-existant tokens" do + get "/associate/12345678901234567890123456789012.json" + expect(response.status).to eq(404) + + get "/associate/shorttoken.json" + expect(response.status).to eq(404) + end + + end +end diff --git a/spec/requests/omniauth_callbacks_controller_spec.rb b/spec/requests/omniauth_callbacks_controller_spec.rb index 0cb2d54a40..8f3ad02c1a 100644 --- a/spec/requests/omniauth_callbacks_controller_spec.rb +++ b/spec/requests/omniauth_callbacks_controller_spec.rb @@ -460,7 +460,7 @@ RSpec.describe Users::OmniauthCallbacksController do expect(UserAssociatedAccount.count).to eq(2) end - it 'should reconnect if parameter supplied' do + it 'should redirect to associate URL if parameter supplied' do # Log in normally get "/auth/google_oauth2?reconnect=true" expect(response.status).to eq(302) @@ -483,10 +483,11 @@ RSpec.describe Users::OmniauthCallbacksController do OmniAuth.config.mock_auth[:google_oauth2].uid = "123456" get "/auth/google_oauth2/callback.json" - expect(response.status).to eq(200) - expect(JSON.parse(response.body)["authenticated"]).to eq(true) + expect(response.status).to eq(302) + expect(response.redirect_url).to start_with("http://test.localhost/associate/") + expect(session[:current_user_id]).to eq(user.id) - expect(UserAssociatedAccount.count).to eq(1) + expect(UserAssociatedAccount.count).to eq(0) # Reconnect has not yet happened end end From b084d6c8dfdbf6f6f93935babc98bd53fbc2210c Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 24 Jul 2019 11:29:18 +0100 Subject: [PATCH 096/441] DEV: Add missing parenthesis for 47ad2a4d --- lib/auth/managed_authenticator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/auth/managed_authenticator.rb b/lib/auth/managed_authenticator.rb index 1278914f91..7b2ebc58e6 100644 --- a/lib/auth/managed_authenticator.rb +++ b/lib/auth/managed_authenticator.rb @@ -91,7 +91,7 @@ class Auth::ManagedAuthenticator < Auth::Authenticator result = Auth::Result.new info = auth_token[:info] result.email = info[:email] - result.name = info[:first_name] && info[:last_name] ? "#{info[:first_name]} #{info[:last_name]}" : info[:name] + result.name = (info[:first_name] && info[:last_name]) ? "#{info[:first_name]} #{info[:last_name]}" : info[:name] result.username = info[:nickname] result.email_valid = primary_email_verified?(auth_token) if result.email result.extra_data = { From 864f68725a3fac730b036a4683faa82329e5f2c1 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 24 Jul 2019 11:45:36 +0100 Subject: [PATCH 097/441] DEV: Correct test from 9c1c8b45 --- spec/requests/omniauth_callbacks_controller_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/requests/omniauth_callbacks_controller_spec.rb b/spec/requests/omniauth_callbacks_controller_spec.rb index 8f3ad02c1a..c5a093f82c 100644 --- a/spec/requests/omniauth_callbacks_controller_spec.rb +++ b/spec/requests/omniauth_callbacks_controller_spec.rb @@ -487,7 +487,7 @@ RSpec.describe Users::OmniauthCallbacksController do expect(response.redirect_url).to start_with("http://test.localhost/associate/") expect(session[:current_user_id]).to eq(user.id) - expect(UserAssociatedAccount.count).to eq(0) # Reconnect has not yet happened + expect(UserAssociatedAccount.count).to eq(1) # Reconnect has not yet happened end end From e444ce7ccd6e0c00b25fc58f3c95bda1c58ee84c Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 24 Jul 2019 13:17:36 +0200 Subject: [PATCH 098/441] REFACTOR: this.$() deprecation (#7928) --- .../javascripts/discourse/components/composer-editor.js.es6 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index 92fd6190e1..56a82f7c07 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -868,7 +868,8 @@ export default Ember.Component.extend({ // wraps previewed upload markdown in a codeblock in its own class to keep a track // of indexes later on to replace the correct upload placeholder in the composer if ($preview.find(".codeblock-image").length === 0) { - this.$(".d-editor-preview *") + $(this.element) + .find(".d-editor-preview *") .contents() .each(function() { if (this.nodeType !== 3) return; // TEXT_NODE From e83dcfdb7bc27cb7f5b89b3796b7131519170391 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 24 Jul 2019 13:33:59 +0200 Subject: [PATCH 099/441] DEV: ensures application.hbs is using router currentPath (#7929) --- .../javascripts/discourse/controllers/application.js.es6 | 1 + app/assets/javascripts/discourse/templates/application.hbs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/application.js.es6 b/app/assets/javascripts/discourse/controllers/application.js.es6 index ce23c502fa..0d02180d76 100644 --- a/app/assets/javascripts/discourse/controllers/application.js.es6 +++ b/app/assets/javascripts/discourse/controllers/application.js.es6 @@ -4,6 +4,7 @@ import { isAppWebview, isiOSPWA } from "discourse/lib/utilities"; export default Ember.Controller.extend({ showTop: true, showFooter: false, + router: Ember.inject.service(), @computed canSignUp() { diff --git a/app/assets/javascripts/discourse/templates/application.hbs b/app/assets/javascripts/discourse/templates/application.hbs index ad010cbbf6..f0dbf0d29f 100644 --- a/app/assets/javascripts/discourse/templates/application.hbs +++ b/app/assets/javascripts/discourse/templates/application.hbs @@ -7,7 +7,7 @@ toggleAnonymous=(route-action "toggleAnonymous") logout=(route-action "logout")}} -{{plugin-outlet name="below-site-header" args=(hash currentPath=currentPath)}} +{{plugin-outlet name="below-site-header" args=(hash currentPath=router._router.currentPath)}}
    {{plugin-outlet name="above-main-container"}} @@ -18,7 +18,7 @@ {{notification-consent-banner}} {{global-notice}} {{create-topics-notice}} - {{plugin-outlet name="top-notices" args=(hash currentPath=currentPath)}} + {{plugin-outlet name="top-notices" args=(hash currentPath=router._router.currentPath)}}
    {{outlet}} {{outlet "user-card"}} From 1d38bf7e2c6d696c3f9d2bd2ead1b13512cef126 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 24 Jul 2019 13:55:18 +0200 Subject: [PATCH 100/441] DEV: removes deprecated property() usage from topic-footer-button api (#7930) --- .../discourse/lib/register-topic-footer-button.js.es6 | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/lib/register-topic-footer-button.js.es6 b/app/assets/javascripts/discourse/lib/register-topic-footer-button.js.es6 index 8c132c830b..b6e5c5c5e9 100644 --- a/app/assets/javascripts/discourse/lib/register-topic-footer-button.js.es6 +++ b/app/assets/javascripts/discourse/lib/register-topic-footer-button.js.es6 @@ -68,7 +68,7 @@ export function getTopicFooterButtons() { .filter(x => x) ); - const computedFunc = Ember.computed({ + return Ember.computed(...dependentKeys, { get() { const _isFunction = descriptor => descriptor && typeof descriptor === "function"; @@ -122,8 +122,6 @@ export function getTopicFooterButtons() { .reverse(); } }); - - return computedFunc.property.apply(computedFunc, dependentKeys); } export function clearTopicFooterButtons() { From b3e5f7a8c699a7ad57b259658524534fef265f42 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 24 Jul 2019 13:45:02 +0100 Subject: [PATCH 101/441] SECURITY: Sanitize email id for use as mutex key --- lib/email/receiver.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb index 42615bebf3..18452eb067 100644 --- a/lib/email/receiver.rb +++ b/lib/email/receiver.rb @@ -67,7 +67,8 @@ module Email def process! return if is_blacklisted? - DistributedMutex.synchronize(@message_id) do + id_hash = Digest::SHA1.hexdigest(@message_id) + DistributedMutex.synchronize("process_email_#{id_hash}") do begin return if IncomingEmail.exists?(message_id: @message_id) ensure_valid_address_lists From 2ab022494ab68ee41eafa7824130522d5f05717a Mon Sep 17 00:00:00 2001 From: Kris Date: Wed, 24 Jul 2019 10:08:59 -0400 Subject: [PATCH 102/441] UX: Add expanded/collapsed class to post-controls (#7932) --- .../javascripts/discourse/widgets/post-menu.js.es6 | 9 ++++++++- 1 file changed, 8 insertions(+), 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 102225e2d0..8739675e2f 100644 --- a/app/assets/javascripts/discourse/widgets/post-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-menu.js.es6 @@ -484,7 +484,14 @@ export default createWidget("post-menu", { postControls.push(this.attach("post-admin-menu", attrs)); } - const contents = [h("nav.post-controls.clearfix", postControls)]; + const contents = [ + h( + "nav.post-controls.clearfix" + + (this.state.collapsed ? ".collapsed" : ".expanded"), + postControls + ) + ]; + if (state.likedUsers.length) { const remaining = state.total - state.likedUsers.length; contents.push( From ae05245b007774fc85c220a54e7478b3b54ac60f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Wed, 24 Jul 2019 18:38:44 +0200 Subject: [PATCH 103/441] DEV: plugin API to register User custom field types --- lib/plugin/instance.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index 9849d0847c..67695e15a0 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -374,6 +374,13 @@ class Plugin::Instance end end + # Applies to all sites in a multisite environment. Ignores plugin.enabled? + def register_user_custom_field_type(name, type) + reloadable_patch do |plugin| + ::User.register_custom_field_type(name, type) + end + end + def register_seedfu_fixtures(paths) paths = [paths] if !paths.kind_of?(Array) SeedFu.fixture_paths.concat(paths) From cc46de8f4623e610ad194029229508ac333d69a2 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 24 Jul 2019 20:04:27 +0200 Subject: [PATCH 104/441] s/discourse-staff-notes/discourse-user-notes (#7936) --- .travis.yml | 2 +- lib/plugin/metadata.rb | 2 +- script/plugin-translations.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 16534a38cf..c134e72cea 100644 --- a/.travis.yml +++ b/.travis.yml @@ -60,7 +60,7 @@ before_install: - git clone --depth=1 https://github.com/discourse/discourse-chat-integration.git plugins/discourse-chat-integration - git clone --depth=1 https://github.com/discourse/discourse-assign.git plugins/discourse-assign - git clone --depth=1 https://github.com/discourse/discourse-patreon.git plugins/discourse-patreon - - git clone --depth=1 https://github.com/discourse/discourse-staff-notes.git plugins/discourse-staff-notes + - git clone --depth=1 https://github.com/discourse/discourse-user-notes.git plugins/discourse-user-notes - git clone --depth=1 https://github.com/discourse/discourse-group-tracker - export PATH=$HOME/.yarn/bin:$PATH diff --git a/lib/plugin/metadata.rb b/lib/plugin/metadata.rb index 2dc8951602..6de1a101ef 100644 --- a/lib/plugin/metadata.rb +++ b/lib/plugin/metadata.rb @@ -60,7 +60,7 @@ class Plugin::Metadata "discourse-sitemap", "discourse-solved", "discourse-spoiler-alert", - "discourse-staff-notes", + "discourse-user-notes", "discourse-styleguide", "discourse-tooltips", "discourse-translator", diff --git a/script/plugin-translations.rb b/script/plugin-translations.rb index ce3c93819a..3bcc5f936b 100644 --- a/script/plugin-translations.rb +++ b/script/plugin-translations.rb @@ -28,7 +28,7 @@ class PluginTxUpdater 'discourse-patreon', 'discourse-saved-searches', 'discourse-solved', - 'discourse-staff-notes', + 'discourse-user-notes', 'discourse-voting' ] From c1d2fb115c061a4d6f0b223b4c942b56f4a45d73 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 24 Jul 2019 22:01:08 +0200 Subject: [PATCH 105/441] DEV: prevents staff computed property to be overridden (#7931) --- .../discourse/controllers/composer.js.es6 | 6 +----- .../javascripts/discourse/models/user.js.es6 | 11 ++++++++++- .../poll-builder-disabled-test.js.es6 | 6 +++--- .../poll-builder-enabled-test.js.es6 | 6 +++--- .../acceptance/composer-actions-test.js.es6 | 6 +++--- .../composer-topic-links-test.js.es6 | 2 +- .../composer-uncategorized-test.js.es6 | 2 +- .../acceptance/email-notice-test.js.es6 | 2 +- .../enforce-second-factor-test.js.es6 | 4 ++-- .../acceptance/group-index-test.js.es6 | 2 +- .../group-manage-interaction-test.js.es6 | 2 +- .../group-manage-membership-test.js.es6 | 2 +- .../group-manage-profile-test.js.es6 | 2 +- test/javascripts/acceptance/tags-test.js.es6 | 4 ++-- .../acceptance/topic-edit-timer-test.js.es6 | 18 +++++++++--------- test/javascripts/controllers/topic-test.js.es6 | 2 +- test/javascripts/models/post-test.js.es6 | 2 +- .../widgets/hamburger-menu-test.js.es6 | 6 +++--- test/javascripts/widgets/post-test.js.es6 | 4 ++-- 19 files changed, 47 insertions(+), 42 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index d907a0f947..5728401875 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -199,11 +199,7 @@ export default Ember.Controller.extend({ ); }, - @computed - isStaffUser() { - const currentUser = this.currentUser; - return currentUser && currentUser.get("staff"); - }, + isStaffUser: Ember.computed.reads("currentUser.staff"), canUnlistTopic: Ember.computed.and("model.creatingTopic", "isStaffUser"), diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index 9beef8e18c..8d3ad4159b 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -54,7 +54,16 @@ const User = RestModel.extend({ return UserDraftsStream.create({ user: this }); }, - staff: Ember.computed.or("admin", "moderator"), + staff: Ember.computed("admin", "moderator", { + get() { + return this.admin || this.moderator; + }, + + // prevents staff property to be overridden + set() { + return this.admin || this.moderator; + } + }), destroySession() { return ajax(`/session/${this.username}`, { type: "DELETE" }); diff --git a/plugins/poll/test/javascripts/acceptance/poll-builder-disabled-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-builder-disabled-test.js.es6 index 6a126d68eb..bb04f7c742 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-builder-disabled-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-builder-disabled-test.js.es6 @@ -14,7 +14,7 @@ acceptance("Poll Builder - polls are disabled", { }); test("regular user - sufficient trust level", assert => { - updateCurrentUser({ staff: false, trust_level: 3 }); + updateCurrentUser({ moderator: false, admin: false, trust_level: 3 }); displayPollBuilderButton(); @@ -27,7 +27,7 @@ test("regular user - sufficient trust level", assert => { }); test("regular user - insufficient trust level", assert => { - updateCurrentUser({ staff: false, trust_level: 1 }); + updateCurrentUser({ moderator: false, admin: false, trust_level: 1 }); displayPollBuilderButton(); @@ -40,7 +40,7 @@ test("regular user - insufficient trust level", assert => { }); test("staff", assert => { - updateCurrentUser({ staff: true }); + updateCurrentUser({ moderator: true }); displayPollBuilderButton(); diff --git a/plugins/poll/test/javascripts/acceptance/poll-builder-enabled-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-builder-enabled-test.js.es6 index 8880c28c40..853fd0d9a2 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-builder-enabled-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-builder-enabled-test.js.es6 @@ -14,7 +14,7 @@ acceptance("Poll Builder - polls are enabled", { }); test("regular user - sufficient trust level", assert => { - updateCurrentUser({ staff: false, trust_level: 1 }); + updateCurrentUser({ moderator: false, admin: false, trust_level: 1 }); displayPollBuilderButton(); @@ -27,7 +27,7 @@ test("regular user - sufficient trust level", assert => { }); test("regular user - insufficient trust level", assert => { - updateCurrentUser({ staff: false, trust_level: 0 }); + updateCurrentUser({ moderator: false, admin: false, trust_level: 0 }); displayPollBuilderButton(); @@ -40,7 +40,7 @@ test("regular user - insufficient trust level", assert => { }); test("staff - with insufficient trust level", assert => { - updateCurrentUser({ staff: true, trust_level: 0 }); + updateCurrentUser({ moderator: true, trust_level: 0 }); displayPollBuilderButton(); diff --git a/test/javascripts/acceptance/composer-actions-test.js.es6 b/test/javascripts/acceptance/composer-actions-test.js.es6 index 7f690dc9c5..fe4dceb934 100644 --- a/test/javascripts/acceptance/composer-actions-test.js.es6 +++ b/test/javascripts/acceptance/composer-actions-test.js.es6 @@ -305,7 +305,7 @@ QUnit.test("replying to post - toggle_topic_bump", async assert => { QUnit.test("replying to post as staff", async assert => { const composerActions = selectKit(".composer-actions"); - updateCurrentUser({ staff: true, admin: false }); + updateCurrentUser({ admin: true }); await visit("/t/internationalization-localization/280"); await click("article#post_3 button.reply"); await composerActions.expand(); @@ -317,7 +317,7 @@ QUnit.test("replying to post as staff", async assert => { QUnit.test("replying to post as TL3 user", async assert => { const composerActions = selectKit(".composer-actions"); - updateCurrentUser({ staff: false, admin: false, trust_level: 3 }); + updateCurrentUser({ moderator: false, admin: false, trust_level: 3 }); await visit("/t/internationalization-localization/280"); await click("article#post_3 button.reply"); await composerActions.expand(); @@ -335,7 +335,7 @@ QUnit.test("replying to post as TL3 user", async assert => { QUnit.test("replying to post as TL4 user", async assert => { const composerActions = selectKit(".composer-actions"); - updateCurrentUser({ staff: false, admin: false, trust_level: 4 }); + updateCurrentUser({ moderator: false, admin: false, trust_level: 4 }); await visit("/t/internationalization-localization/280"); await click("article#post_3 button.reply"); await composerActions.expand(); diff --git a/test/javascripts/acceptance/composer-topic-links-test.js.es6 b/test/javascripts/acceptance/composer-topic-links-test.js.es6 index 8e72180568..4de900d639 100644 --- a/test/javascripts/acceptance/composer-topic-links-test.js.es6 +++ b/test/javascripts/acceptance/composer-topic-links-test.js.es6 @@ -156,7 +156,7 @@ acceptance("Composer topic featured links when uncategorized is not allowed", { }); QUnit.test("Pasting a link enables the text input area", async assert => { - updateCurrentUser({ admin: false, staff: false, trust_level: 1 }); + updateCurrentUser({ moderator: false, admin: false, trust_level: 1 }); await visit("/"); await click("#create-topic"); diff --git a/test/javascripts/acceptance/composer-uncategorized-test.js.es6 b/test/javascripts/acceptance/composer-uncategorized-test.js.es6 index 93fc5eb9e3..c048509139 100644 --- a/test/javascripts/acceptance/composer-uncategorized-test.js.es6 +++ b/test/javascripts/acceptance/composer-uncategorized-test.js.es6 @@ -18,7 +18,7 @@ acceptance("Composer and uncategorized is not allowed", { }); QUnit.test("Disable body until category is selected", async assert => { - updateCurrentUser({ admin: false, staff: false, trust_level: 1 }); + updateCurrentUser({ moderator: false, admin: false, trust_level: 1 }); await visit("/"); await click("#create-topic"); diff --git a/test/javascripts/acceptance/email-notice-test.js.es6 b/test/javascripts/acceptance/email-notice-test.js.es6 index 0c31703099..824b93c836 100644 --- a/test/javascripts/acceptance/email-notice-test.js.es6 +++ b/test/javascripts/acceptance/email-notice-test.js.es6 @@ -26,7 +26,7 @@ QUnit.test("shows banner when required", async assert => { "alert is displayed when email disabled for non-staff" ); - updateCurrentUser({ staff: true, moderator: true }); + updateCurrentUser({ moderator: true }); await visit("/"); assert.ok( exists(".alert-emails-disabled"), diff --git a/test/javascripts/acceptance/enforce-second-factor-test.js.es6 b/test/javascripts/acceptance/enforce-second-factor-test.js.es6 index 87b6ca3538..cd4570fcae 100644 --- a/test/javascripts/acceptance/enforce-second-factor-test.js.es6 +++ b/test/javascripts/acceptance/enforce-second-factor-test.js.es6 @@ -35,7 +35,7 @@ QUnit.test("as an admin", async assert => { }); QUnit.test("as a user", async assert => { - updateCurrentUser({ staff: false, admin: false }); + updateCurrentUser({ moderator: false, admin: false }); await visit("/u/eviltrout/preferences/second-factor"); Discourse.SiteSettings.enforce_second_factor = "all"; @@ -59,7 +59,7 @@ QUnit.test("as a user", async assert => { }); QUnit.test("as an anonymous user", async assert => { - updateCurrentUser({ staff: false, admin: false, is_anonymous: true }); + updateCurrentUser({ moderator: false, admin: false, is_anonymous: true }); await visit("/u/eviltrout/preferences/second-factor"); Discourse.SiteSettings.enforce_second_factor = "all"; diff --git a/test/javascripts/acceptance/group-index-test.js.es6 b/test/javascripts/acceptance/group-index-test.js.es6 index b0123a2bb5..d3ea58aac6 100644 --- a/test/javascripts/acceptance/group-index-test.js.es6 +++ b/test/javascripts/acceptance/group-index-test.js.es6 @@ -26,7 +26,7 @@ QUnit.test("Viewing Members as anon user", async assert => { acceptance("Group Members", { loggedIn: true }); QUnit.test("Viewing Members as a group owner", async assert => { - updateCurrentUser({ admin: false, staff: false }); + updateCurrentUser({ moderator: false, admin: false }); await visit("/g/discourse"); await click(".group-members-add"); diff --git a/test/javascripts/acceptance/group-manage-interaction-test.js.es6 b/test/javascripts/acceptance/group-manage-interaction-test.js.es6 index 170a3eaeb1..2cc5e4c624 100644 --- a/test/javascripts/acceptance/group-manage-interaction-test.js.es6 +++ b/test/javascripts/acceptance/group-manage-interaction-test.js.es6 @@ -42,7 +42,7 @@ QUnit.test("As an admin", async assert => { }); QUnit.test("As a group owner", async assert => { - updateCurrentUser({ admin: false, staff: false }); + updateCurrentUser({ moderator: false, admin: false }); await visit("/g/discourse/manage/interaction"); assert.equal( diff --git a/test/javascripts/acceptance/group-manage-membership-test.js.es6 b/test/javascripts/acceptance/group-manage-membership-test.js.es6 index 36dc488876..61d4138d79 100644 --- a/test/javascripts/acceptance/group-manage-membership-test.js.es6 +++ b/test/javascripts/acceptance/group-manage-membership-test.js.es6 @@ -68,7 +68,7 @@ QUnit.test("As an admin", async assert => { }); QUnit.test("As a group owner", async assert => { - updateCurrentUser({ staff: false, admin: false }); + updateCurrentUser({ moderator: false, admin: false }); await visit("/g/discourse/manage/membership"); diff --git a/test/javascripts/acceptance/group-manage-profile-test.js.es6 b/test/javascripts/acceptance/group-manage-profile-test.js.es6 index b9249adecd..d26045f897 100644 --- a/test/javascripts/acceptance/group-manage-profile-test.js.es6 +++ b/test/javascripts/acceptance/group-manage-profile-test.js.es6 @@ -34,7 +34,7 @@ QUnit.test("As an admin", async assert => { }); QUnit.test("As a group owner", async assert => { - updateCurrentUser({ staff: false, admin: false }); + updateCurrentUser({ moderator: false, admin: false }); await visit("/g/discourse/manage/profile"); diff --git a/test/javascripts/acceptance/tags-test.js.es6 b/test/javascripts/acceptance/tags-test.js.es6 index fe43783102..deab78fe80 100644 --- a/test/javascripts/acceptance/tags-test.js.es6 +++ b/test/javascripts/acceptance/tags-test.js.es6 @@ -156,7 +156,7 @@ test("new topic button is not available for staff-only tags", async assert => { } ]); - updateCurrentUser({ staff: false }); + updateCurrentUser({ moderator: false, admin: false }); await visit("/tags/regular-tag"); assert.ok(find("#create-topic:disabled").length === 0); @@ -164,7 +164,7 @@ test("new topic button is not available for staff-only tags", async assert => { await visit("/tags/staff-only-tag"); assert.ok(find("#create-topic:disabled").length === 1); - updateCurrentUser({ staff: true }); + updateCurrentUser({ moderator: true }); await visit("/tags/regular-tag"); assert.ok(find("#create-topic:disabled").length === 0); diff --git a/test/javascripts/acceptance/topic-edit-timer-test.js.es6 b/test/javascripts/acceptance/topic-edit-timer-test.js.es6 index 2fc145ebfc..005e261509 100644 --- a/test/javascripts/acceptance/topic-edit-timer-test.js.es6 +++ b/test/javascripts/acceptance/topic-edit-timer-test.js.es6 @@ -20,7 +20,7 @@ acceptance("Topic - Edit timer", { }); QUnit.test("default", async assert => { - updateCurrentUser({ admin: true, staff: true, canManageTopic: true }); + updateCurrentUser({ moderator: true, canManageTopic: true }); const timerType = selectKit(".select-kit.timer-type"); const futureDateInputSelector = selectKit(".future-date-input-selector"); @@ -41,7 +41,7 @@ QUnit.test("default", async assert => { }); QUnit.test("autoclose - specific time", async assert => { - updateCurrentUser({ admin: true, staff: true, canManageTopic: true }); + updateCurrentUser({ moderator: true, canManageTopic: true }); const futureDateInputSelector = selectKit(".future-date-input-selector"); await visit("/t/internationalization-localization"); @@ -62,7 +62,7 @@ QUnit.test("autoclose - specific time", async assert => { }); QUnit.test("autoclose", async assert => { - updateCurrentUser({ admin: true, staff: true, canManageTopic: true }); + updateCurrentUser({ moderator: true, canManageTopic: true }); const futureDateInputSelector = selectKit(".future-date-input-selector"); await visit("/t/internationalization-localization"); @@ -117,7 +117,7 @@ QUnit.test("autoclose", async assert => { }); QUnit.test("close temporarily", async assert => { - updateCurrentUser({ admin: true, staff: true, canManageTopic: true }); + updateCurrentUser({ moderator: true, canManageTopic: true }); const timerType = selectKit(".select-kit.timer-type"); const futureDateInputSelector = selectKit(".future-date-input-selector"); @@ -159,7 +159,7 @@ QUnit.test("close temporarily", async assert => { }); QUnit.test("schedule", async assert => { - updateCurrentUser({ admin: true, staff: true, canManageTopic: true }); + updateCurrentUser({ moderator: true, canManageTopic: true }); const timerType = selectKit(".select-kit.timer-type"); const categoryChooser = selectKit(".modal-body .category-chooser"); const futureDateInputSelector = selectKit(".future-date-input-selector"); @@ -194,7 +194,7 @@ QUnit.test("schedule", async assert => { }); QUnit.test("TL4 can't auto-delete", async assert => { - updateCurrentUser({ staff: false, trust_level: 4 }); + updateCurrentUser({ moderator: false, admin: false, trust_level: 4 }); await visit("/t/internationalization-localization"); await click(".toggle-admin-menu"); @@ -208,7 +208,7 @@ QUnit.test("TL4 can't auto-delete", async assert => { }); QUnit.test("auto delete", async assert => { - updateCurrentUser({ admin: true, staff: true, canManageTopic: true }); + updateCurrentUser({ moderator: true, canManageTopic: true }); const timerType = selectKit(".select-kit.timer-type"); const futureDateInputSelector = selectKit(".future-date-input-selector"); @@ -238,7 +238,7 @@ QUnit.test("auto delete", async assert => { QUnit.test( "Manually closing before the timer will clear the status text", async assert => { - updateCurrentUser({ admin: true, staff: true, canManageTopic: true }); + updateCurrentUser({ moderator: true, canManageTopic: true }); const futureDateInputSelector = selectKit(".future-date-input-selector"); await visit("/t/internationalization-localization"); @@ -265,7 +265,7 @@ QUnit.test( ); QUnit.test("Inline delete timer", async assert => { - updateCurrentUser({ admin: true, staff: true, canManageTopic: true }); + updateCurrentUser({ moderator: true, canManageTopic: true }); const futureDateInputSelector = selectKit(".future-date-input-selector"); await visit("/t/internationalization-localization"); diff --git a/test/javascripts/controllers/topic-test.js.es6 b/test/javascripts/controllers/topic-test.js.es6 index bed3cc19cd..c142ef35a7 100644 --- a/test/javascripts/controllers/topic-test.js.es6 +++ b/test/javascripts/controllers/topic-test.js.es6 @@ -537,7 +537,7 @@ QUnit.test( posts: [post, { id: 3 }, { id: 4 }] }); - const currentUser = Ember.Object.create({ staff: true }); + const currentUser = Ember.Object.create({ moderator: true }); const model = Topic.create({ postStream }); const controller = this.subject({ model, currentUser }); diff --git a/test/javascripts/models/post-test.js.es6 b/test/javascripts/models/post-test.js.es6 index 2ded01501f..afe69d03ad 100644 --- a/test/javascripts/models/post-test.js.es6 +++ b/test/javascripts/models/post-test.js.es6 @@ -54,7 +54,7 @@ QUnit.test("updateFromPost", assert => { }); QUnit.test("destroy by staff", assert => { - var user = Discourse.User.create({ username: "staff", staff: true }), + var user = Discourse.User.create({ username: "staff", moderator: true }), post = buildPost({ user: user }); post.destroy(user); diff --git a/test/javascripts/widgets/hamburger-menu-test.js.es6 b/test/javascripts/widgets/hamburger-menu-test.js.es6 index f1385baf09..dda1ee974a 100644 --- a/test/javascripts/widgets/hamburger-menu-test.js.es6 +++ b/test/javascripts/widgets/hamburger-menu-test.js.es6 @@ -53,7 +53,7 @@ widgetTest("staff menu", { beforeEach() { this.currentUser.setProperties({ - staff: true, + moderator: true, reviewable_count: 3 }); }, @@ -70,7 +70,7 @@ widgetTest("staff menu - admin", { template: '{{mount-widget widget="hamburger-menu"}}', beforeEach() { - this.currentUser.setProperties({ staff: true, admin: true }); + this.currentUser.setProperties({ admin: true }); }, test(assert) { @@ -83,7 +83,7 @@ widgetTest("reviewable content", { beforeEach() { this.currentUser.setProperties({ - staff: true, + moderator: true, reviewable_count: 5 }); }, diff --git a/test/javascripts/widgets/post-test.js.es6 b/test/javascripts/widgets/post-test.js.es6 index 9489c9cc66..3735d1036f 100644 --- a/test/javascripts/widgets/post-test.js.es6 +++ b/test/javascripts/widgets/post-test.js.es6 @@ -579,7 +579,7 @@ widgetTest("toggle moderator post", { template: '{{mount-widget widget="post" args=args togglePostType=(action "togglePostType")}}', beforeEach() { - this.currentUser.set("staff", true); + this.currentUser.set("moderator", true); this.set("args", { canManage: true }); this.on("togglePostType", () => (this.toggled = true)); }, @@ -595,7 +595,7 @@ widgetTest("toggle moderator post", { template: '{{mount-widget widget="post" args=args togglePostType=(action "togglePostType")}}', beforeEach() { - this.currentUser.set("staff", true); + this.currentUser.set("moderator", true); this.set("args", { canManage: true }); this.on("togglePostType", () => (this.toggled = true)); }, From 997add3af966a2a69dfd26d1125d7c016922c8c6 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Thu, 25 Jul 2019 00:18:27 +0300 Subject: [PATCH 106/441] DEV: Add extension point to allow modifying SSO URL (#7937) This allows plugins to, for example, add extra query params to the SSO URL when discourse redirects to to the SSO website. --- app/controllers/session_controller.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index b48ee8a8f1..1ca3f8a476 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -39,7 +39,7 @@ class SessionController < ApplicationController if SiteSetting.verbose_sso_logging Rails.logger.warn("Verbose SSO log: Started SSO process\n\n#{sso.diagnostics}") end - redirect_to sso.to_url + redirect_to sso_url(sso) else render body: nil, status: 404 end @@ -525,4 +525,9 @@ class SessionController < ApplicationController @sso_error = text render status: status, layout: 'no_ember' end + + # extension to allow plugins to customize the SSO URL + def sso_url(sso) + sso.to_url + end end From db39eae68323f33c3a397e06fb40f98f1d4f5187 Mon Sep 17 00:00:00 2001 From: Jeff Atwood Date: Thu, 25 Jul 2019 00:08:03 -0700 Subject: [PATCH 107/441] make security commits search order by date --- docs/SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index d11334bc70..7e1606d2b0 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -6,7 +6,7 @@ We take security very seriously at Discourse. We welcome any peer review of our In order to give the community time to respond and upgrade we strongly urge you report all security issues privately. Please use our [vulnerability disclosure program at Hacker One](https://hackerone.com/discourse) to provide details and repro steps and we will respond ASAP. If you prefer not to use Hacker One, email us directly at `team@discourse.org` with details and repro steps. Security issues *always* take precedence over bug fixes and feature work. We can and do mark releases as "urgent" if they contain serious security fixes. -For a list of recent security commits, check [our GitHub commits prefixed with SECURITY](https://github.com/discourse/discourse/search?utf8=%E2%9C%93&q=SECURITY&type=Commits). +For a list of recent security commits, check [our GitHub commits prefixed with SECURITY](https://github.com/discourse/discourse/search?o=desc&q=SECURITY&s=committer-date&type=Commits). ### Password Storage From 47deb8b3da8f54cc09223a7fc6cbf804876ad571 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Thu, 25 Jul 2019 14:16:47 +0530 Subject: [PATCH 108/441] FIX: use same id for both original & optimized inventories in multisite setup. --- lib/s3_inventory.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/s3_inventory.rb b/lib/s3_inventory.rb index 309b2ef61c..501cb91b06 100644 --- a/lib/s3_inventory.rb +++ b/lib/s3_inventory.rb @@ -120,6 +120,8 @@ class S3Inventory def download_inventory_files_to_tmp_directory files.each do |file| + next if File.exists?(file[:filename]) + log "Downloading inventory file '#{file[:key]}' to tmp directory..." failure_message = "Failed to inventory file '#{file[:key]}' to tmp directory." @@ -257,11 +259,8 @@ class S3Inventory def inventory_id @inventory_id ||= begin - if bucket_folder_path.present? - "#{bucket_folder_path}-#{type}" - else - type - end + id = Rails.configuration.multisite ? "original" : type # TODO: rename multisite path to "uploads" + bucket_folder_path.present? ? "#{bucket_folder_path}-#{id}" : id end end From c7b146cbdfda41494c350a58982d2fe54893d2c1 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 25 Jul 2019 11:14:23 +0200 Subject: [PATCH 109/441] FIX: reverts #18e2816 (#7940) --- app/assets/javascripts/discourse/controllers/topic.js.es6 | 5 ----- .../templates/components/topic-footer-buttons.hbs | 1 - app/assets/javascripts/discourse/templates/topic.hbs | 4 ---- .../javascripts/discourse/widgets/topic-admin-menu.js.es6 | 1 - test/javascripts/acceptance/topic-test.js.es6 | 7 ------- 5 files changed, 18 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index d4ef29adc9..c1983bc4c1 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -722,11 +722,6 @@ export default Ember.Controller.extend(bufferedProperty("model"), { this._jumpToPostId(postId); }, - hideMultiSelect() { - this.set("multiSelect", false); - this._forceRefreshPostStream(); - }, - toggleMultiSelect() { this.toggleProperty("multiSelect"); this._forceRefreshPostStream(); diff --git a/app/assets/javascripts/discourse/templates/components/topic-footer-buttons.hbs b/app/assets/javascripts/discourse/templates/components/topic-footer-buttons.hbs index 15fbecda6d..6347270fd2 100644 --- a/app/assets/javascripts/discourse/templates/components/topic-footer-buttons.hbs +++ b/app/assets/javascripts/discourse/templates/components/topic-footer-buttons.hbs @@ -4,7 +4,6 @@ topic=topic openUpwards="true" toggleMultiSelect=toggleMultiSelect - hideMultiSelect=hideMultiSelect deleteTopic=deleteTopic recoverTopic=recoverTopic toggleClosed=toggleClosed diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index afbfb72cbf..f8e5ee3903 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -95,7 +95,6 @@ topic=model fixed="true" toggleMultiSelect=(action "toggleMultiSelect") - hideMultiSelect=(action "hideMultiSelect") deleteTopic=(action "deleteTopic") recoverTopic=(action "recoverTopic") toggleClosed=(action "toggleClosed") @@ -123,7 +122,6 @@ jumpToIndex=(action "jumpToIndex") replyToPost=(action "replyToPost") toggleMultiSelect=(action "toggleMultiSelect") - hideMultiSelect=(action "hideMultiSelect") deleteTopic=(action "deleteTopic") recoverTopic=(action "recoverTopic") toggleClosed=(action "toggleClosed") @@ -147,7 +145,6 @@ openUpwards="true" rightSide="true" toggleMultiSelect=(action "toggleMultiSelect") - hideMultiSelect=(action "hideMultiSelect") deleteTopic=(action "deleteTopic") recoverTopic=(action "recoverTopic") toggleClosed=(action "toggleClosed") @@ -294,7 +291,6 @@ {{topic-footer-buttons topic=model toggleMultiSelect=(action "toggleMultiSelect") - hideMultiSelect=(action "hideMultiSelect") deleteTopic=(action "deleteTopic") recoverTopic=(action "recoverTopic") toggleClosed=(action "toggleClosed") diff --git a/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 b/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 index 466cd7a575..ccc5a82e95 100644 --- a/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 @@ -90,7 +90,6 @@ createWidget("topic-admin-menu-button", { position.left += $button.width() - 203; } this.state.position = position; - this.sendWidgetAction("hideMultiSelect"); } }); diff --git a/test/javascripts/acceptance/topic-test.js.es6 b/test/javascripts/acceptance/topic-test.js.es6 index 3a3b6de1fa..d856cc9eb1 100644 --- a/test/javascripts/acceptance/topic-test.js.es6 +++ b/test/javascripts/acceptance/topic-test.js.es6 @@ -255,13 +255,6 @@ QUnit.test("selecting posts", async assert => { exists(".select-all"), "it should allow users to select all the posts" ); - - await click(".toggle-admin-menu"); - - assert.ok( - exists(".selected-posts.hidden"), - "it should hide the multi select menu" - ); }); QUnit.test("select below", async assert => { From 460d4316211fd21cf5455927c52852fb843eaf41 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 25 Jul 2019 11:38:05 +0200 Subject: [PATCH 110/441] DEV: uses ember-cli recommended chrome flags (#7939) --- test/run-qunit.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/run-qunit.js b/test/run-qunit.js index 0dc4df7884..092a94a7f5 100644 --- a/test/run-qunit.js +++ b/test/run-qunit.js @@ -38,7 +38,15 @@ if (QUNIT_RESULT) { async function runAllTests() { function launchChrome() { const options = { - chromeFlags: ["--disable-gpu", "--headless", "--no-sandbox"] + chromeFlags: [ + "--disable-gpu", + "--headless", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-software-rasterizer", + "--mute-audio", + "--window-size=1440,900" + ] }; if (process.env.REMOTE_DEBUG) { From 1dde6a535565d029aa897e75197f75f9ae0f000b Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 25 Jul 2019 11:54:23 +0200 Subject: [PATCH 111/441] DEV: prevents post.siteSettings computed property to be overridden (#7941) This happens when loading a post from a json object and is a behavior which will be impossble in future Ember updates. --- .../javascripts/discourse/models/post.js.es6 | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/models/post.js.es6 b/app/assets/javascripts/discourse/models/post.js.es6 index 2e3681c5c6..9a9fbe8e63 100644 --- a/app/assets/javascripts/discourse/models/post.js.es6 +++ b/app/assets/javascripts/discourse/models/post.js.es6 @@ -11,11 +11,17 @@ import { userPath } from "discourse/lib/url"; import Composer from "discourse/models/composer"; const Post = RestModel.extend({ - @computed() - siteSettings() { - // TODO: Remove this once one instantiate all `Discourse.Post` models via the store. - return Discourse.SiteSettings; - }, + // TODO: Remove this once one instantiate all `Discourse.Post` models via the store. + siteSettings: Ember.computed({ + get() { + return Discourse.SiteSettings; + }, + + // prevents model created from json to overridde this property + set() { + return Discourse.SiteSettings; + } + }), @computed("url") shareUrl(url) { From 0c7df556868a25c286fd7ec4d0b4dc33385834f8 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 25 Jul 2019 12:50:30 +0200 Subject: [PATCH 112/441] DEV: uses router.currentRouteName instead of application (#7942) https://deprecations.emberjs.com/v3.x/#toc_application-controller-router-properties --- app/assets/javascripts/discourse/controllers/composer.js.es6 | 4 ++-- app/assets/javascripts/discourse/lib/page-tracker.js.es6 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index 5728401875..d77a4a4637 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -81,7 +81,7 @@ export function addPopupMenuOptionsCallback(callback) { export default Ember.Controller.extend({ topicController: Ember.inject.controller("topic"), - application: Ember.inject.controller(), + router: Ember.inject.service(), replyAsNewTopicDraft: Ember.computed.equal( "model.draftKey", @@ -730,7 +730,7 @@ export default Ember.Controller.extend({ }); if ( - this.get("application.currentRouteName").split(".")[0] === "topic" && + this.router.currentRouteName.split(".")[0] === "topic" && composer.get("topic.id") === this.get("topicModel.id") ) { staged = composer.get("stagedPost"); diff --git a/app/assets/javascripts/discourse/lib/page-tracker.js.es6 b/app/assets/javascripts/discourse/lib/page-tracker.js.es6 index e6390e08ea..d7577ed5ae 100644 --- a/app/assets/javascripts/discourse/lib/page-tracker.js.es6 +++ b/app/assets/javascripts/discourse/lib/page-tracker.js.es6 @@ -37,7 +37,7 @@ export function startPageTracking(router, appEvents) { appEvents.trigger("page:changed", { url, title, - currentRouteName: router.get("currentRouteName"), + currentRouteName: router.currentRouteName, replacedOnlyQueryParams }); }); From 4f1382a54a290f288df024ee2f593008f600c14d Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 25 Jul 2019 11:29:10 +0100 Subject: [PATCH 113/441] FIX: Hide live-loaded posts from ignored users --- .../discourse/models/post-stream.js.es6 | 9 ++++- .../models/post-stream-test.js.es6 | 38 ++++++++++++++----- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/discourse/models/post-stream.js.es6 b/app/assets/javascripts/discourse/models/post-stream.js.es6 index a426fc2c3b..51e536965e 100644 --- a/app/assets/javascripts/discourse/models/post-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/post-stream.js.es6 @@ -609,9 +609,14 @@ export default RestModel.extend({ this.set("loadingLastPost", true); return this.findPostsByIds([postId]) .then(posts => { - const ignoredUsers = this.get("currentUser.ignored_users"); + const ignoredUsers = + Discourse.User.current() && + Discourse.User.current().get("ignored_users"); posts.forEach(p => { - if (ignoredUsers && ignoredUsers.includes(p.username)) return; + if (ignoredUsers && ignoredUsers.includes(p.username)) { + this.stream.removeObject(postId); + return; + } this.appendPost(p); }); }) diff --git a/test/javascripts/models/post-stream-test.js.es6 b/test/javascripts/models/post-stream-test.js.es6 index c5153f1dac..eb99d811af 100644 --- a/test/javascripts/models/post-stream-test.js.es6 +++ b/test/javascripts/models/post-stream-test.js.es6 @@ -803,12 +803,14 @@ QUnit.test("comitting and triggerNewPostInStream race condition", assert => { QUnit.test("triggerNewPostInStream for ignored posts", async assert => { const postStream = buildStream(280, [1]); const store = postStream.store; - postStream.currentUser = Discourse.User.create({ - username: "eviltrout", - name: "eviltrout", - id: 321, - ignored_users: ["ignoreduser"] - }); + Discourse.User.resetCurrent( + Discourse.User.create({ + username: "eviltrout", + name: "eviltrout", + id: 321, + ignored_users: ["ignoreduser"] + }) + ); postStream.appendPost(store.createRecord("post", { id: 1, post_number: 1 })); @@ -829,13 +831,31 @@ QUnit.test("triggerNewPostInStream for ignored posts", async assert => { .returns(Promise.resolve([post2])); await postStream.triggerNewPostInStream(101); - assert.equal(postStream.get("posts.length"), 2, "it added the regular post"); + assert.equal( + postStream.posts.length, + 2, + "it added the regular post to the posts" + ); + assert.equal( + postStream.get("stream.length"), + 2, + "it added the regular post to the stream" + ); stub.restore(); sandbox.stub(postStream, "findPostsByIds").returns(Promise.resolve([post3])); - postStream.triggerNewPostInStream(102); - assert.equal(postStream.posts.length, 2, "it does not add the ignored post"); + await postStream.triggerNewPostInStream(102); + assert.equal( + postStream.posts.length, + 2, + "it does not add the ignored post to the posts" + ); + assert.equal( + postStream.stream.length, + 2, + "it does not add the ignored post to the stream" + ); }); QUnit.test("postsWithPlaceholders", assert => { From 6a0787445c4205a3e8862e1f34013d8df2d50aaa Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Thu, 25 Jul 2019 17:13:23 +0530 Subject: [PATCH 114/441] Bump onebox version. - Deprioritize Twitter card in generic onebox --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index ffa12dfe51..2db7fffbce 100644 --- a/Gemfile +++ b/Gemfile @@ -46,7 +46,7 @@ gem 'redis-namespace' gem 'active_model_serializers', '~> 0.8.3' -gem 'onebox', '1.9.2' +gem 'onebox', '1.9.6' gem 'http_accept_language', '~>2.0.5', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 194ec34d1e..3e50a65a27 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -238,7 +238,7 @@ GEM omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack - onebox (1.9.2) + onebox (1.9.6) htmlentities (~> 4.3) moneta (~> 1.0) multi_json (~> 1.11) @@ -488,7 +488,7 @@ DEPENDENCIES omniauth-oauth2 omniauth-openid omniauth-twitter - onebox (= 1.9.2) + onebox (= 1.9.6) openid-redis-store parallel_tests pg From a61ff16740146a0f089fb50ca757c47d8134266f Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Wed, 24 Jul 2019 16:07:09 +0200 Subject: [PATCH 115/441] DEV: Make attachment markdown reusable --- app/jobs/regular/export_csv_file.rb | 5 ++--- lib/discourse_markdown.rb | 24 ++++++++++++++++++++++++ lib/email/receiver.rb | 22 +++++----------------- script/import_scripts/base.rb | 2 -- script/import_scripts/base/uploader.rb | 17 ++++------------- 5 files changed, 35 insertions(+), 35 deletions(-) create mode 100644 lib/discourse_markdown.rb diff --git a/app/jobs/regular/export_csv_file.rb b/app/jobs/regular/export_csv_file.rb index d8d3a7204e..4db9fd2f30 100644 --- a/app/jobs/regular/export_csv_file.rb +++ b/app/jobs/regular/export_csv_file.rb @@ -4,12 +4,11 @@ require 'csv' require 'zip' require_dependency 'system_message' require_dependency 'upload_creator' +require_dependency 'discourse_markdown' module Jobs class ExportCsvFile < Jobs::Base - include ActionView::Helpers::NumberHelper - sidekiq_options retry: false HEADER_ATTRS_FOR ||= HashWithIndifferentAccess.new( @@ -406,7 +405,7 @@ module Jobs SystemMessage.create_from_system_user( @current_user, :csv_export_succeeded, - download_link: "[#{upload.original_filename}|attachment](#{upload.short_url}) (#{number_to_human_size(upload.filesize)})", + download_link: DiscourseMarkdown.attachment_markdown(upload), export_title: export_title ) else diff --git a/lib/discourse_markdown.rb b/lib/discourse_markdown.rb new file mode 100644 index 0000000000..820037276e --- /dev/null +++ b/lib/discourse_markdown.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require_dependency "file_helper" + +class DiscourseMarkdown + def self.upload_markdown(upload, display_name: nil) + if FileHelper.is_supported_image?(upload.original_filename) + image_markdown(upload) + else + attachment_markdown(upload, display_name: display_name) + end + end + + def self.image_markdown(upload) + "![#{upload.original_filename}|#{upload.width}x#{upload.height}](#{upload.short_url})" + end + + def self.attachment_markdown(upload, display_name: nil, with_filesize: true) + human_filesize = with_filesize ? " (#{upload.human_filesize})" : "" + display_name ||= upload.original_filename + + "[#{display_name}|attachment](#{upload.short_url})#{human_filesize}" + end +end diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb index 18452eb067..04882bbc11 100644 --- a/lib/email/receiver.rb +++ b/lib/email/receiver.rb @@ -5,12 +5,11 @@ require_dependency "new_post_manager" require_dependency "html_to_markdown" require_dependency "plain_text_to_markdown" require_dependency "upload_creator" +require_dependency "discourse_markdown" module Email class Receiver - include ActionView::Helpers::NumberHelper - # If you add a new error, you need to # * add it to Email::Processor#handle_failure() # * add text to server.en.yml (parent key: "emails.incoming.errors") @@ -1035,19 +1034,16 @@ module Email InlineUploads.match_img(raw) do |match, src, replacement, _| if src == upload.url - raw = raw.sub( - match, - "![#{upload.original_filename}|#{upload.width}x#{upload.height}](#{upload.short_url})" - ) + raw = raw.sub(match, DiscourseMarkdown.image_markdown(upload)) end end elsif raw[/\[image:.*?\d+[^\]]*\]/i] - raw.sub!(/\[image:.*?\d+[^\]]*\]/i, attachment_markdown(upload)) + raw.sub!(/\[image:.*?\d+[^\]]*\]/i, DiscourseMarkdown.upload_markdown(upload)) else - raw << "\n\n#{attachment_markdown(upload)}\n\n" + raw << "\n\n#{DiscourseMarkdown.upload_markdown(upload)}\n\n" end else - raw << "\n\n#{attachment_markdown(upload)}\n\n" + raw << "\n\n#{DiscourseMarkdown.upload_markdown(upload)}\n\n" end else rejected_attachments << upload @@ -1082,14 +1078,6 @@ module Email Email::Sender.new(client_message, :email_reject_attachment).send end - def attachment_markdown(upload) - if FileHelper.is_supported_image?(upload.original_filename) - "![#{upload.original_filename}|#{upload.width}x#{upload.height}](#{upload.short_url})" - else - "[#{upload.original_filename}|attachment](#{upload.short_url}) (#{number_to_human_size(upload.filesize)})" - end - end - def create_post(options = {}) options[:via_email] = true options[:raw_email] = @raw_email diff --git a/script/import_scripts/base.rb b/script/import_scripts/base.rb index 944f26c0b0..b149ac780a 100644 --- a/script/import_scripts/base.rb +++ b/script/import_scripts/base.rb @@ -21,8 +21,6 @@ module ImportScripts; end class ImportScripts::Base - include ActionView::Helpers::NumberHelper - def initialize preload_i18n diff --git a/script/import_scripts/base/uploader.rb b/script/import_scripts/base/uploader.rb index 0d8acac6a4..fc5df45039 100644 --- a/script/import_scripts/base/uploader.rb +++ b/script/import_scripts/base/uploader.rb @@ -1,12 +1,10 @@ # frozen_string_literal: true require_dependency 'url_helper' -require_dependency 'file_helper' +require_dependency 'discourse_markdown' module ImportScripts class Uploader - include ActionView::Helpers::NumberHelper - # Creates an upload. # Expects path to be the full path and filename of the source file. # @return [Upload] @@ -42,22 +40,15 @@ module ImportScripts end def html_for_upload(upload, display_filename) - if FileHelper.is_supported_image?(upload.url) - embedded_image_html(upload) - else - attachment_html(upload, display_filename) - end + DiscourseMarkdown.upload_markdown(upload, display_name: display_filename) end def embedded_image_html(upload) - image_width = [upload.width, SiteSetting.max_image_width].compact.min - image_height = [upload.height, SiteSetting.max_image_height].compact.min - upload_name = upload.short_url || upload.url - %Q~![#{upload.original_filename}|#{image_width}x#{image_height}](#{upload_name})~ + DiscourseMarkdown.image_markdown(upload) end def attachment_html(upload, display_filename) - "[#{display_filename}|attachment](#{upload.short}) (#{number_to_human_size(upload.filesize)})" + DiscourseMarkdown.attachment_markdown(upload, display_name: display_filename) end private From 0e1d6151b9612de23d750dd7dddc11d9a9c5351b Mon Sep 17 00:00:00 2001 From: Kyle Zhao Date: Thu, 25 Jul 2019 09:21:01 -0400 Subject: [PATCH 116/441] FIX: Frozen string error in `TopicEmbed.import` (#7938) When `SiteSetting.embed_truncate` is enabled (by default), the truncated string is mutatable and does not raise an error. However, when the setting is disabled, the `contents` string is frozen and immutable, and will raise a `FrozenError`. --- app/models/topic_embed.rb | 4 ++-- spec/models/topic_embed_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/models/topic_embed.rb b/app/models/topic_embed.rb index f7b46b711f..c1948d89af 100644 --- a/app/models/topic_embed.rb +++ b/app/models/topic_embed.rb @@ -36,8 +36,8 @@ class TopicEmbed < ActiveRecord::Base if SiteSetting.embed_truncate contents = first_paragraph_from(contents) end - contents ||= +'' - contents << imported_from_html(url) + contents ||= '' + contents = +contents << imported_from_html(url) url = normalize_url(url) diff --git a/spec/models/topic_embed_spec.rb b/spec/models/topic_embed_spec.rb index 71c60afb3a..18d4dd9764 100644 --- a/spec/models/topic_embed_spec.rb +++ b/spec/models/topic_embed_spec.rb @@ -86,6 +86,26 @@ describe TopicEmbed do expect(post.cook_method).to eq(Post.cook_methods[:regular]) end end + + describe 'embedded content truncation' do + MAX_LENGTH_BEFORE_TRUNCATION = 100 + + let(:long_content) { "

    #{'a' * MAX_LENGTH_BEFORE_TRUNCATION}

    \n

    more

    " } + + it 'truncates the imported post when truncation is enabled' do + SiteSetting.embed_truncate = true + post = TopicEmbed.import(user, url, title, long_content) + + expect(post.raw).not_to include(long_content) + end + + it 'keeps everything in the imported post when truncation is disabled' do + SiteSetting.embed_truncate = false + post = TopicEmbed.import(user, url, title, long_content) + + expect(post.raw).to include(long_content) + end + end end context '.topic_id_for_embed' do From 7e0eeed29219a2eacb769e955f7c1c94091e9ad6 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Thu, 25 Jul 2019 14:04:00 +0200 Subject: [PATCH 117/441] FEATURE: Add attachments to outgoing emails This feature is off by default and can can be configured with the `email_total_attachment_size_limit_kb` site setting. Co-authored-by: Maja Komel --- config/locales/server.en.yml | 1 + config/site_settings.yml | 3 ++ lib/email/sender.rb | 34 +++++++++++++++ spec/components/email/sender_spec.rb | 63 +++++++++++++++++++++++++++ spec/fixtures/pdf/large.pdf | Bin 0 -> 44134 bytes 5 files changed, 101 insertions(+) create mode 100644 spec/fixtures/pdf/large.pdf diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index afce1c4062..f7013fd1a2 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1812,6 +1812,7 @@ en: enable_forwarded_emails: "[BETA] Allow users to create a topic by forwarding an email in." always_show_trimmed_content: "Always show trimmed part of incoming emails. WARNING: might reveal email addresses." private_email: "Don't include content from posts or topics in email title or email body. NOTE: also disables digest emails." + email_total_attachment_size_limit_kb: "Max total size of files attached to outgoing emails. Set to 0 to disable sending of attachments." manual_polling_enabled: "Push emails using the API for email replies." pop3_polling_enabled: "Poll via POP3 for email replies." diff --git a/config/site_settings.yml b/config/site_settings.yml index 3b5736ccd2..4c68601cdb 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1021,6 +1021,9 @@ email: enable_forwarded_emails: false always_show_trimmed_content: false private_email: false + email_total_attachment_size_limit_kb: + default: 0 + max: 51200 files: max_image_size_kb: diff --git a/lib/email/sender.rb b/lib/email/sender.rb index 464431a3ec..60c3e9a223 100644 --- a/lib/email/sender.rb +++ b/lib/email/sender.rb @@ -100,6 +100,8 @@ module Email # guards against deleted posts return skip(SkippedEmailLog.reason_types[:sender_post_deleted]) unless post + add_attachments(post) + topic = post.topic first_post = topic.ordered_posts.first @@ -239,6 +241,38 @@ module Email private + def add_attachments(post) + max_email_size = SiteSetting.email_total_attachment_size_limit_kb.kilobytes + return if max_email_size == 0 + + email_size = 0 + post.uploads.each do |upload| + next if FileHelper.is_supported_image?(upload.original_filename) + next if email_size + upload.filesize > max_email_size + + begin + path = if upload.local? + Discourse.store.path_for(upload) + else + Discourse.store.download(upload).path + end + + @message.attachments[upload.original_filename] = File.read(path) + email_size += File.size(path) + rescue => e + Discourse.warn_exception( + e, + message: "Failed to attach file to email", + env: { + post_id: post.id, + upload_id: upload.id, + filename: upload.original_filename + } + ) + end + end + end + def header_value(name) header = @message.header[name] return nil unless header diff --git a/spec/components/email/sender_spec.rb b/spec/components/email/sender_spec.rb index 995014f5c1..b2a7a5861d 100644 --- a/spec/components/email/sender_spec.rb +++ b/spec/components/email/sender_spec.rb @@ -351,6 +351,69 @@ describe Email::Sender do end end + context "with attachments" do + fab!(:small_pdf) do + SiteSetting.authorized_extensions = 'pdf' + UploadCreator.new(file_from_fixtures("small.pdf", "pdf"), "small.pdf") + .create_for(Discourse.system_user.id) + end + fab!(:large_pdf) do + SiteSetting.authorized_extensions = 'pdf' + UploadCreator.new(file_from_fixtures("large.pdf", "pdf"), "large.pdf") + .create_for(Discourse.system_user.id) + end + fab!(:csv_file) do + SiteSetting.authorized_extensions = 'csv' + UploadCreator.new(file_from_fixtures("words.csv", "csv"), "words.csv") + .create_for(Discourse.system_user.id) + end + fab!(:image) do + SiteSetting.authorized_extensions = 'png' + UploadCreator.new(file_from_fixtures("logo.png", "images"), "logo.png") + .create_for(Discourse.system_user.id) + end + fab!(:post) { Fabricate(:post) } + fab!(:reply) do + raw = <<~RAW + Hello world! + #{DiscourseMarkdown.attachment_markdown(small_pdf)} + #{DiscourseMarkdown.attachment_markdown(large_pdf)} + #{DiscourseMarkdown.image_markdown(image)} + #{DiscourseMarkdown.attachment_markdown(csv_file)} + RAW + reply = Fabricate(:post, raw: raw, topic: post.topic, user: Fabricate(:user)) + reply.link_post_uploads + reply + end + fab!(:notification) { Fabricate(:posted_notification, user: post.user, post: reply) } + let(:message) do + UserNotifications.user_posted( + post.user, + post: reply, + notification_type: notification.notification_type, + notification_data_hash: notification.data_hash + ) + end + + it "adds only non-image uploads as attachments to the email" do + SiteSetting.email_total_attachment_size_limit_kb = 10_000 + Email::Sender.new(message, :valid_type).send + + expect(message.attachments.length).to eq(3) + expect(message.attachments.map(&:filename)) + .to contain_exactly(*[small_pdf, large_pdf, csv_file].map(&:original_filename)) + end + + it "respects the size limit and attaches only files that fit into the max email size" do + SiteSetting.email_total_attachment_size_limit_kb = 40 + Email::Sender.new(message, :valid_type).send + + expect(message.attachments.length).to eq(2) + expect(message.attachments.map(&:filename)) + .to contain_exactly(*[small_pdf, csv_file].map(&:original_filename)) + end + end + context 'with a deleted post' do it 'should skip sending the email' do diff --git a/spec/fixtures/pdf/large.pdf b/spec/fixtures/pdf/large.pdf new file mode 100644 index 0000000000000000000000000000000000000000..59e719e7e462e9dfc333379cb72c94ec8a868861 GIT binary patch literal 44134 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

    6mq4T~rmLCpJ^yI+QWJ(xizFm2~ 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 Date: Thu, 25 Jul 2019 19:41:25 +0530 Subject: [PATCH 118/441] REVERT: DEV: should ignore missing post uploads when a user export destroyed Reverts 793915fe6aa1024b6a08cb8b042b7fb1e0bbece8. We no longer need this since we're destroying each posts in commit 028121b95b982500e0c63b11d216a6162d47a7bb. --- app/models/user_export.rb | 8 -------- spec/models/user_export_spec.rb | 18 ------------------ 2 files changed, 26 deletions(-) diff --git a/app/models/user_export.rb b/app/models/user_export.rb index e6687b1ac0..a7b5b6784c 100644 --- a/app/models/user_export.rb +++ b/app/models/user_export.rb @@ -5,16 +5,8 @@ class UserExport < ActiveRecord::Base belongs_to :upload, dependent: :destroy belongs_to :topic, dependent: :destroy - around_destroy :ignore_missing_post_uploads - DESTROY_CREATED_BEFORE = 2.days.ago - def ignore_missing_post_uploads - post_ids = upload.post_uploads.pluck(:post_id) - yield - post_ids.each { |post_id| PostCustomField.create!(post_id: post_id, name: Post::MISSING_UPLOADS_IGNORED, value: "t") } - end - def self.remove_old_exports UserExport.where('created_at < ?', DESTROY_CREATED_BEFORE).find_each do |user_export| UserExport.transaction do diff --git a/spec/models/user_export_spec.rb b/spec/models/user_export_spec.rb index 4dfca38250..507e485bb1 100644 --- a/spec/models/user_export_spec.rb +++ b/spec/models/user_export_spec.rb @@ -41,22 +41,4 @@ RSpec.describe UserExport do expect(Topic.exists?(id: topic_2.id)).to eq(true) end end - - describe '#destroy!' do - it 'should create post custom field for ignored missing uploads' do - upload = Fabricate(:upload, created_at: 3.days.ago) - export = UserExport.create!( - file_name: "test", - user: user, - upload_id: upload.id, - created_at: 3.days.ago - ) - post = Fabricate(:post, raw: "![#{upload.original_filename}](#{upload.short_url})") - post.link_post_uploads - - export.destroy! - - expect(PostCustomField.exists?(post_id: post.id, name: Post::MISSING_UPLOADS_IGNORED)).to eq(true) - end - end end From fd12c414e7c9484b38e4f2aac1f0911847e564f1 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Thu, 25 Jul 2019 16:34:46 +0200 Subject: [PATCH 119/441] DEV: Refactor helper methods for upload markdown Follow-up to a61ff167 --- app/jobs/regular/export_csv_file.rb | 4 ++-- lib/discourse_markdown.rb | 24 ---------------------- lib/email/receiver.rb | 10 ++++----- lib/upload_markdown.rb | 28 ++++++++++++++++++++++++++ script/import_scripts/base/uploader.rb | 8 ++++---- spec/components/email/sender_spec.rb | 8 ++++---- 6 files changed, 43 insertions(+), 39 deletions(-) delete mode 100644 lib/discourse_markdown.rb create mode 100644 lib/upload_markdown.rb diff --git a/app/jobs/regular/export_csv_file.rb b/app/jobs/regular/export_csv_file.rb index 4db9fd2f30..cea8c1ec81 100644 --- a/app/jobs/regular/export_csv_file.rb +++ b/app/jobs/regular/export_csv_file.rb @@ -4,7 +4,7 @@ require 'csv' require 'zip' require_dependency 'system_message' require_dependency 'upload_creator' -require_dependency 'discourse_markdown' +require_dependency 'upload_markdown' module Jobs @@ -405,7 +405,7 @@ module Jobs SystemMessage.create_from_system_user( @current_user, :csv_export_succeeded, - download_link: DiscourseMarkdown.attachment_markdown(upload), + download_link: UploadMarkdown.new(upload).attachment_markdown, export_title: export_title ) else diff --git a/lib/discourse_markdown.rb b/lib/discourse_markdown.rb deleted file mode 100644 index 820037276e..0000000000 --- a/lib/discourse_markdown.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require_dependency "file_helper" - -class DiscourseMarkdown - def self.upload_markdown(upload, display_name: nil) - if FileHelper.is_supported_image?(upload.original_filename) - image_markdown(upload) - else - attachment_markdown(upload, display_name: display_name) - end - end - - def self.image_markdown(upload) - "![#{upload.original_filename}|#{upload.width}x#{upload.height}](#{upload.short_url})" - end - - def self.attachment_markdown(upload, display_name: nil, with_filesize: true) - human_filesize = with_filesize ? " (#{upload.human_filesize})" : "" - display_name ||= upload.original_filename - - "[#{display_name}|attachment](#{upload.short_url})#{human_filesize}" - end -end diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb index 04882bbc11..e02ba46146 100644 --- a/lib/email/receiver.rb +++ b/lib/email/receiver.rb @@ -5,7 +5,7 @@ require_dependency "new_post_manager" require_dependency "html_to_markdown" require_dependency "plain_text_to_markdown" require_dependency "upload_creator" -require_dependency "discourse_markdown" +require_dependency "upload_markdown" module Email @@ -1034,16 +1034,16 @@ module Email InlineUploads.match_img(raw) do |match, src, replacement, _| if src == upload.url - raw = raw.sub(match, DiscourseMarkdown.image_markdown(upload)) + raw = raw.sub(match, UploadMarkdown.new(upload).image_markdown) end end elsif raw[/\[image:.*?\d+[^\]]*\]/i] - raw.sub!(/\[image:.*?\d+[^\]]*\]/i, DiscourseMarkdown.upload_markdown(upload)) + raw.sub!(/\[image:.*?\d+[^\]]*\]/i, UploadMarkdown.new(upload).to_markdown) else - raw << "\n\n#{DiscourseMarkdown.upload_markdown(upload)}\n\n" + raw << "\n\n#{UploadMarkdown.new(upload).to_markdown}\n\n" end else - raw << "\n\n#{DiscourseMarkdown.upload_markdown(upload)}\n\n" + raw << "\n\n#{UploadMarkdown.new(upload).to_markdown}\n\n" end else rejected_attachments << upload diff --git a/lib/upload_markdown.rb b/lib/upload_markdown.rb new file mode 100644 index 0000000000..d6476417a0 --- /dev/null +++ b/lib/upload_markdown.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require_dependency "file_helper" + +class UploadMarkdown + def initialize(upload) + @upload = upload + end + + def to_markdown(display_name: nil) + if FileHelper.is_supported_image?(@upload.original_filename) + image_markdown + else + attachment_markdown(display_name: display_name) + end + end + + def image_markdown + "![#{@upload.original_filename}|#{@upload.width}x#{@upload.height}](#{@upload.short_url})" + end + + def attachment_markdown(display_name: nil, with_filesize: true) + human_filesize = with_filesize ? " (#{@upload.human_filesize})" : "" + display_name ||= @upload.original_filename + + "[#{display_name}|attachment](#{@upload.short_url})#{human_filesize}" + end +end diff --git a/script/import_scripts/base/uploader.rb b/script/import_scripts/base/uploader.rb index fc5df45039..8e38850a6b 100644 --- a/script/import_scripts/base/uploader.rb +++ b/script/import_scripts/base/uploader.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require_dependency 'url_helper' -require_dependency 'discourse_markdown' +require_dependency 'upload_markdown' module ImportScripts class Uploader @@ -40,15 +40,15 @@ module ImportScripts end def html_for_upload(upload, display_filename) - DiscourseMarkdown.upload_markdown(upload, display_name: display_filename) + UploadMarkdown.new(upload).to_markdown(display_name: display_filename) end def embedded_image_html(upload) - DiscourseMarkdown.image_markdown(upload) + UploadMarkdown.new(upload).image_markdown end def attachment_html(upload, display_filename) - DiscourseMarkdown.attachment_markdown(upload, display_name: display_filename) + UploadMarkdown.new(upload).attachment_markdown(display_name: display_filename) end private diff --git a/spec/components/email/sender_spec.rb b/spec/components/email/sender_spec.rb index b2a7a5861d..59730e9b48 100644 --- a/spec/components/email/sender_spec.rb +++ b/spec/components/email/sender_spec.rb @@ -376,10 +376,10 @@ describe Email::Sender do fab!(:reply) do raw = <<~RAW Hello world! - #{DiscourseMarkdown.attachment_markdown(small_pdf)} - #{DiscourseMarkdown.attachment_markdown(large_pdf)} - #{DiscourseMarkdown.image_markdown(image)} - #{DiscourseMarkdown.attachment_markdown(csv_file)} + #{UploadMarkdown.new(small_pdf).attachment_markdown} + #{UploadMarkdown.new(large_pdf).attachment_markdown} + #{UploadMarkdown.new(image).image_markdown} + #{UploadMarkdown.new(csv_file).attachment_markdown} RAW reply = Fabricate(:post, raw: raw, topic: post.topic, user: Fabricate(:user)) reply.link_post_uploads From a7279681122e1ace90b72891de3a748e15f1610d Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 25 Jul 2019 12:46:16 -0400 Subject: [PATCH 120/441] FIX: Provide an error message if no valid tags were selected --- config/locales/server.en.yml | 3 +++ lib/discourse_tagging.rb | 6 +++++- spec/requests/posts_controller_spec.rb | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index f7013fd1a2..fe6978038e 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -4231,6 +4231,9 @@ en: other: "You must select at least %{count} tags." upload_row_too_long: "The CSV file should have one tag per line. Optionally the tag can be followed by a comma, then the tag group name." forbidden: + invalid: + one: "The tag you selected cannot be used" + other: "None of the tags you selected can be used" in_this_category: '"%{tag_name}" cannot be used in this category' restricted_to: one: '"%{tag_name}" is restricted to the "%{category_names}" category' diff --git a/lib/discourse_tagging.rb b/lib/discourse_tagging.rb index 39e7cb17f9..0213eb73fc 100644 --- a/lib/discourse_tagging.rb +++ b/lib/discourse_tagging.rb @@ -83,7 +83,11 @@ module DiscourseTagging return false end - return false if tags.size == 0 + if tags.size == 0 + topic.errors.add(:base, I18n.t("tags.forbidden.invalid", count: new_tag_names.size)) + return false + end + topic.tags = tags else # validate minimum required tags for a category diff --git a/spec/requests/posts_controller_spec.rb b/spec/requests/posts_controller_spec.rb index a84048d1b5..7a8c83f97c 100644 --- a/spec/requests/posts_controller_spec.rb +++ b/spec/requests/posts_controller_spec.rb @@ -966,6 +966,23 @@ describe PostsController do expect(response.status).to eq(403) end + it 'can not create a post with a tag that is restricted' do + SiteSetting.tagging_enabled = true + tag = Fabricate(:tag) + category.allowed_tags = [tag.name] + category.save! + + post "/posts.json", params: { + raw: 'this is the test content', + title: 'this is the test title for the topic', + tags: [tag.name], + } + + expect(response.status).to eq(422) + json = JSON.parse(response.body) + expect(json['errors']).to be_present + end + it 'creates the post' do post "/posts.json", params: { raw: 'this is the test content', From 0f4fa98a826fa91e6913f7556b9a6de1601fd565 Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 25 Jul 2019 16:00:13 -0400 Subject: [PATCH 121/441] Margin applied too broadly, caused extra space on like-count --- app/assets/stylesheets/common/components/buttons.scss | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/assets/stylesheets/common/components/buttons.scss b/app/assets/stylesheets/common/components/buttons.scss index 8bbe8b764b..f72a14d339 100644 --- a/app/assets/stylesheets/common/components/buttons.scss +++ b/app/assets/stylesheets/common/components/buttons.scss @@ -260,11 +260,6 @@ .d-icon { color: $primary-low-mid; } - &.btn-icon-text { - .d-icon { - margin-right: 7px; - } - } .discourse-no-touch & { &:hover { .d-icon { From 0603636cea0a2c6e533f50c58651a6867d279e13 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Fri, 26 Jul 2019 12:57:13 +0530 Subject: [PATCH 122/441] FIX: include default label when exporting reports --- app/models/report.rb | 1 + spec/jobs/export_csv_file_spec.rb | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/app/models/report.rb b/app/models/report.rb index b16be1632e..783247fb0f 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -152,6 +152,7 @@ class Report report.average = opts[:average] if opts[:average] report.percent = opts[:percent] if opts[:percent] report.filters = opts[:filters] if opts[:filters] + report.labels = Report.default_labels report end diff --git a/spec/jobs/export_csv_file_spec.rb b/spec/jobs/export_csv_file_spec.rb index 88629062bf..79bdff7336 100644 --- a/spec/jobs/export_csv_file_spec.rb +++ b/spec/jobs/export_csv_file_spec.rb @@ -66,6 +66,18 @@ describe Jobs::ExportCsvFile do expect(report.third).to contain_exactly("2010-01-03", "50.0") end + it 'works with single-column reports with default label' do + user.user_visits.create!(visited_at: '2010-01-01') + Fabricate(:user).user_visits.create!(visited_at: '2010-01-03') + + exporter.instance_variable_get(:@extra)['name'] = 'visits' + report = exporter.report_export.to_a + + expect(report.first).to contain_exactly("Day", "Count") + expect(report.second).to contain_exactly("2010-01-01", "1") + expect(report.third).to contain_exactly("2010-01-03", "1") + end + it 'works with multi-columns reports' do DiscourseIpInfo.stubs(:get).with("1.1.1.1").returns(location: "Earth") user.user_auth_token_logs.create!(action: "login", client_ip: "1.1.1.1", created_at: '2010-01-01') From fe7f0982af9577314d87001f35b7ea15e044f8b7 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 26 Jul 2019 11:20:11 +0200 Subject: [PATCH 123/441] DEV: attemps to limit Discourse.User.current() usage (#7943) --- .../admin/controllers/admin-backups-index.js.es6 | 2 +- .../admin/controllers/admin-user-index.js.es6 | 2 +- app/assets/javascripts/admin/models/admin-user.js.es6 | 4 ++-- .../javascripts/admin/routes/admin-users-list.js.es6 | 2 +- .../discourse/components/composer-editor.js.es6 | 2 +- .../discourse/components/topic-status.js.es6 | 2 +- .../discourse/controllers/application.js.es6 | 2 +- .../javascripts/discourse/controllers/composer.js.es6 | 2 +- .../discourse/controllers/discovery/categories.js.es6 | 5 +---- .../discourse/controllers/user-invited-show.js.es6 | 10 ++-------- .../discourse/controllers/user-private-messages.js.es6 | 10 ++++------ .../initializers/subscribe-user-notifications.js.es6 | 7 +------ .../javascripts/discourse/models/composer.js.es6 | 6 ------ .../select-kit/components/composer-actions.js.es6 | 5 ++--- 14 files changed, 19 insertions(+), 42 deletions(-) diff --git a/app/assets/javascripts/admin/controllers/admin-backups-index.js.es6 b/app/assets/javascripts/admin/controllers/admin-backups-index.js.es6 index 07cd082fcc..684f844bc5 100644 --- a/app/assets/javascripts/admin/controllers/admin-backups-index.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-backups-index.js.es6 @@ -29,7 +29,7 @@ export default Ember.Controller.extend({ I18n.t("yes_value"), confirmed => { if (confirmed) { - Discourse.User.currentProp("hideReadOnlyAlert", true); + this.set("currentUser.hideReadOnlyAlert", true); this._toggleReadOnlyMode(true); } } diff --git a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 index 79eceed9f1..4a24bbd4b5 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 @@ -134,7 +134,7 @@ export default Ember.Controller.extend(CanCheckEmails, { return this.model.resetBounceScore(); }, approve() { - return this.model.approve(); + return this.model.approve(this.currentUser); }, deactivate() { return this.model.deactivate(); diff --git a/app/assets/javascripts/admin/models/admin-user.js.es6 b/app/assets/javascripts/admin/models/admin-user.js.es6 index f5ff4c2761..bd830be687 100644 --- a/app/assets/javascripts/admin/models/admin-user.js.es6 +++ b/app/assets/javascripts/admin/models/admin-user.js.es6 @@ -227,14 +227,14 @@ const AdminUser = Discourse.User.extend({ .catch(popupAjaxError); }, - approve() { + approve(approvedBy) { return ajax(`/admin/users/${this.id}/approve`, { type: "PUT" }).then(() => { this.setProperties({ can_approve: false, approved: true, - approved_by: Discourse.User.current() + approved_by: approvedBy }); }); }, diff --git a/app/assets/javascripts/admin/routes/admin-users-list.js.es6 b/app/assets/javascripts/admin/routes/admin-users-list.js.es6 index f2edad8aa5..d6b76356f1 100644 --- a/app/assets/javascripts/admin/routes/admin-users-list.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-users-list.js.es6 @@ -11,7 +11,7 @@ export default Discourse.Route.extend({ }, sendInvites() { - this.transitionTo("userInvited", Discourse.User.current()); + this.transitionTo("userInvited", this.currentUser); }, deleteUser(user) { diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index 56a82f7c07..4ef31938b5 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -237,7 +237,7 @@ export default Ember.Component.extend({ reason = I18n.t("composer.error.post_missing"); } else if (missingReplyCharacters > 0) { reason = I18n.t("composer.error.post_length", { min: minimumPostLength }); - const tl = Discourse.User.currentProp("trust_level"); + const tl = this.get("currentUser.trust_level"); if (tl === 0 || tl === 1) { reason += "
    " + diff --git a/app/assets/javascripts/discourse/components/topic-status.js.es6 b/app/assets/javascripts/discourse/components/topic-status.js.es6 index a8dbe464a0..d063847e83 100644 --- a/app/assets/javascripts/discourse/components/topic-status.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-status.js.es6 @@ -29,7 +29,7 @@ export default Ember.Component.extend( @computed("disableActions") canAct(disableActions) { - return Discourse.User.current() && !disableActions; + return this.currentUser && !disableActions; }, buildBuffer(buffer) { diff --git a/app/assets/javascripts/discourse/controllers/application.js.es6 b/app/assets/javascripts/discourse/controllers/application.js.es6 index 0d02180d76..7cd2898bf1 100644 --- a/app/assets/javascripts/discourse/controllers/application.js.es6 +++ b/app/assets/javascripts/discourse/controllers/application.js.es6 @@ -17,7 +17,7 @@ export default Ember.Controller.extend({ @computed loginRequired() { - return Discourse.SiteSettings.login_required && !Discourse.User.current(); + return Discourse.SiteSettings.login_required && !this.currentUser; }, @computed diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index d77a4a4637..b96051e906 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -292,7 +292,7 @@ export default Ember.Controller.extend({ @computed("model.creatingPrivateMessage", "model.targetUsernames") showWarning(creatingPrivateMessage, usernames) { - if (!Discourse.User.currentProp("staff")) { + if (!this.get("currentUser.staff")) { return false; } diff --git a/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 b/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 index dedcef9811..2d8cc9f9ed 100644 --- a/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 @@ -14,10 +14,7 @@ export default DiscoveryController.extend({ // this makes sure the composer isn't scoping to a specific category category: null, - @computed - canEdit() { - return Discourse.User.currentProp("staff"); - }, + canEdit: Ember.computed.reads("currentUser.staff"), @computed("model.categories.[].featuredTopics.length") latestTopicOnly() { diff --git a/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 b/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 index d7450ae3bc..23bbd45295 100644 --- a/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 @@ -42,15 +42,9 @@ export default Ember.Controller.extend({ ); }, - @computed - canInviteToForum() { - return Discourse.User.currentProp("can_invite_to_forum"); - }, + canInviteToForum: Ember.computed.reads("currentUser.can_invite_to_forum"), - @computed - canBulkInvite() { - return Discourse.User.currentProp("admin"); - }, + canBulkInvite: Ember.computed.reads("currentUser.admin"), showSearch: Ember.computed.gte("totalInvites", 10), diff --git a/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 b/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 index 0d087261a1..c5e1bd9452 100644 --- a/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 @@ -16,12 +16,10 @@ export default Ember.Controller.extend({ pmTaggingEnabled: Ember.computed.alias("site.can_tag_pms"), tagId: null, - @computed("user.viewingSelf") - showNewPM(viewingSelf) { - return ( - viewingSelf && Discourse.User.currentProp("can_send_private_messages") - ); - }, + showNewPM: Ember.computed.and( + "user.viewingSelf", + "currentUser.can_send_private_messages" + ), @computed("selected.[]", "bulkSelectEnabled") hasSelection(selected, bulkSelectEnabled) { diff --git a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 index 44f4025408..b71f37aaca 100644 --- a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 +++ b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 @@ -131,12 +131,7 @@ export default { if (isPushNotificationsEnabled(user, site.mobileView)) { disableDesktopNotifications(); - registerPushNotifications( - Discourse.User.current(), - site.mobileView, - router, - appEvents - ); + registerPushNotifications(user, site.mobileView, router, appEvents); } else { unsubscribePushNotifications(user); } diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index 956ea9e4b5..049bba2eb4 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -308,12 +308,6 @@ const Composer = RestModel.extend({ return options; }, - @computed - isStaffUser() { - const currentUser = Discourse.User.current(); - return currentUser && currentUser.staff; - }, - @computed( "loading", "canEditTitle", 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 80f7f4c9ef..6eb09fb77b 100644 --- a/app/assets/javascripts/select-kit/components/composer-actions.js.es6 +++ b/app/assets/javascripts/select-kit/components/composer-actions.js.es6 @@ -191,10 +191,9 @@ export default DropdownSelectBoxComponent.extend({ }); } - const currentUser = Discourse.User.current(); const showToggleTopicBump = - currentUser && - (currentUser.get("staff") || currentUser.trust_level === 4); + this.get("currentUser.staff") || + this.get("currentUser.trust_level") === 4; if (action === REPLY && showToggleTopicBump) { items.push({ From 525920a9794a4582d6588250daa302c36029fce8 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Fri, 26 Jul 2019 17:37:23 +0300 Subject: [PATCH 124/441] FIX: Better error when SSO fails due to blank secret (#7946) * FIX: Better error when SSO fails due to blank secret * Update spec/requests/session_controller_spec.rb Co-Authored-By: Robin Ward --- app/controllers/session_controller.rb | 7 ++++++- config/locales/server.en.yml | 1 + lib/single_sign_on_provider.rb | 6 ++++++ spec/requests/session_controller_spec.rb | 10 ++++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 1ca3f8a476..4acc0b2a2a 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -49,7 +49,12 @@ class SessionController < ApplicationController payload ||= request.query_string if SiteSetting.enable_sso_provider - sso = SingleSignOnProvider.parse(payload) + begin + sso = SingleSignOnProvider.parse(payload) + rescue SingleSignOnProvider::BlankSecret + render plain: I18n.t("sso.missing_secret"), status: 400 + return + end if sso.return_sso_url.blank? render plain: "return_sso_url is blank, it must be provided", status: 400 diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index fe6978038e..6dc6bd93c9 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -2120,6 +2120,7 @@ en: timeout_expired: "Account login timed out, please try logging in again." no_email: "No email address was provided. Please contact the site's administrator." email_error: "An account could not be registered with the email address %{email}. Please contact the site's administrator." + missing_secret: "SSO authentication failed due to missing secret. Contact the site administrators to fix this problem." original_poster: "Original Poster" most_posts: "Most Posts" diff --git a/lib/single_sign_on_provider.rb b/lib/single_sign_on_provider.rb index c55118719b..774c5eeb08 100644 --- a/lib/single_sign_on_provider.rb +++ b/lib/single_sign_on_provider.rb @@ -3,9 +3,15 @@ require_dependency 'single_sign_on' class SingleSignOnProvider < SingleSignOn + class BlankSecret < RuntimeError; end def self.parse(payload, sso_secret = nil) set_return_sso_url(payload) + if sso_secret.blank? && self.sso_secret.blank? + host = URI.parse(@return_sso_url).host + Rails.logger.warn("SSO failed; website #{host} is not in the `sso_provider_secrets` site settings") + raise BlankSecret + end super end diff --git a/spec/requests/session_controller_spec.rb b/spec/requests/session_controller_spec.rb index f3491ef5bd..8e5ec95c17 100644 --- a/spec/requests/session_controller_spec.rb +++ b/spec/requests/session_controller_spec.rb @@ -816,6 +816,16 @@ RSpec.describe SessionController do expect(response.status).to eq(500) end + it "fails with a nice error message if secret is blank" do + SiteSetting.sso_provider_secrets = "" + sso = SingleSignOnProvider.new + sso.nonce = "mynonce" + sso.return_sso_url = "http://website.without.secret.com/sso" + get "/session/sso_provider", params: Rack::Utils.parse_query(sso.payload("aasdasdasd")) + expect(response.status).to eq(400) + expect(response.body).to eq(I18n.t("sso.missing_secret")) + end + it "successfully redirects user to return_sso_url when the user is logged in" do sign_in(@user) From f408c583e8b5f2767f4ec33ce9134966db5b5963 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Fri, 26 Jul 2019 10:57:22 -0400 Subject: [PATCH 125/441] DEV: Remove lightbox CSS rules --- .../stylesheets/common/base/lightbox.scss | 30 ------------------- app/assets/stylesheets/mobile/lightbox.scss | 15 ---------- 2 files changed, 45 deletions(-) diff --git a/app/assets/stylesheets/common/base/lightbox.scss b/app/assets/stylesheets/common/base/lightbox.scss index bc089ac101..20ed8ebea6 100644 --- a/app/assets/stylesheets/common/base/lightbox.scss +++ b/app/assets/stylesheets/common/base/lightbox.scss @@ -50,17 +50,6 @@ $meta-element-margin: 6px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - // TODO: delete this by May 2019 - &:before { - // ideally, the SVG used here should be in HTML and reference the SVG sprite - content: svg-uri( - '' - ); - margin-right: $meta-element-margin; - display: inline-block; - vertical-align: middle; - opacity: 0.8; - } } .d-icon { @@ -70,10 +59,6 @@ $meta-element-margin: 6px; + .filename { margin-left: 0px; } - // TODO: delete this by May 2019 - + .filename:before { - display: none; - } } .informations { @@ -84,21 +69,6 @@ $meta-element-margin: 6px; flex-shrink: 0; flex-grow: 3; } - - // TODO: delete this by May 2019 - .expand { - position: absolute; - bottom: 2px; - right: 7px; - &:before { - // ideally, the SVG used here should be in HTML and reference the SVG sprite - // the SVG used here is the "expand" icon from FontAwesome 4.7.0 - content: svg-uri( - '' - ); - opacity: 0.8; - } - } } .mfp-preloader .spinner { diff --git a/app/assets/stylesheets/mobile/lightbox.scss b/app/assets/stylesheets/mobile/lightbox.scss index 46a63a9a3a..8c864502ba 100644 --- a/app/assets/stylesheets/mobile/lightbox.scss +++ b/app/assets/stylesheets/mobile/lightbox.scss @@ -22,19 +22,4 @@ .d-icon-discourse-expand { color: $primary-high; } - - // TODO: Delete by May 2019 - .expand { - position: initial; - float: none; - height: 16px; - &:before { - // ideally, the SVG used here should be in HTML and reference the SVG sprite - // the SVG used here is the "expand" icon from FontAwesome 4.7.0 - content: svg-uri( - '' - ); - opacity: inherit; - } - } } From 042f7184f125bb18f763eb19497f6bf7c30175b5 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Fri, 26 Jul 2019 15:25:20 -0400 Subject: [PATCH 126/441] DEV: Display FA 4.7 deprecation notice in all environments FA 4.7 icon mapping will be removed soon. --- .../discourse-common/lib/icon-library.js.es6 | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 b/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 index d5aecd76d4..9821368c86 100644 --- a/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 +++ b/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 @@ -579,13 +579,9 @@ function warnIfMissing(id) { } function warnIfDeprecated(oldId, newId) { - if ( - typeof Discourse !== "undefined" && - Discourse.Environment === "development" && - !Ember.testing - ) { - deprecated(`Icon "${oldId}" is now "${newId}".`); - } + deprecated( + `Please replace all occurrences of "${oldId}"" with "${newId}". FontAwesome 4.7 icon names are now deprecated and will be removed in the next release.` + ); } function handleIconId(icon) { From dcb0e5f1e5a3a3082c40fe96cbc6d46785d0aa11 Mon Sep 17 00:00:00 2001 From: Julien Ma Date: Fri, 26 Jul 2019 22:29:48 +0200 Subject: [PATCH 127/441] Fix "Host is invalid" error when TLD >10 chars (#7948) Related to https://meta.discourse.org/t/host-is-invalid-error-when-tld-is-longer-than-7-characters/46081. Using Discourse `v2.4.0.beta2 +119`, I can't add an host (when embedding, cf. `/admin/customize/embedding`) ending with `.engineering`. Turns out current regex limits to 10 characters. Fix is dumb: it only allows for up to 24 chars, which is the **current** max TLD length, see https://stackoverflow.com/a/22038535/1907212. --- Maybe a better (and longer-term) fix would be to allow for up to 64 chars, which I understand comes from the RFC. I'm not at ease with regexes, so can't be sure about it, but [this suggestion](https://meta.discourse.org/t/host-is-invalid-error-when-tld-is-longer-than-7-characters/46081/8?u=julienma) seems pretty good: > rules of DNS labels are: > > - All labels are 1 to 63 characters, case insensitive A to Z, 0 to 9 and - (hyphen), all from ASCII. > - No labels may start with a hyphen. > - No top level domain label may start with a number. > >That means a regexp for a valid domain name would look like: > >`/^([a-z0-9][a-z0-9-]{0,62}\.)+[a-z][a-z0-9-]{0,62}\.?$/` > >Domains that are just a TLD are sufficiently bizarre as to be worth ignoring. --- app/models/embeddable_host.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/embeddable_host.rb b/app/models/embeddable_host.rb index 545eeba5f2..71b44785a8 100644 --- a/app/models/embeddable_host.rb +++ b/app/models/embeddable_host.rb @@ -63,7 +63,7 @@ class EmbeddableHost < ActiveRecord::Base end def host_must_be_valid - if host !~ /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,10}(:[0-9]{1,5})?(\/.*)?\Z/i && + if host !~ /\A[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,24}(:[0-9]{1,5})?(\/.*)?\Z/i && host !~ /\A(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(:[0-9]{1,5})?(\/.*)?\Z/ && host !~ /\A([a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.)?localhost(\:[0-9]{1,5})?(\/.*)?\Z/i errors.add(:host, I18n.t('errors.messages.invalid')) From 1922d4bf78db24c6a26095736234f4a3e0d344cf Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Sat, 27 Jul 2019 11:52:21 -0300 Subject: [PATCH 128/441] PERF: Add more constraint on the Cache Storage usage Only restricting cache per age wasn't enough for instances with lots of multimedia usage and high number of posts. MaxEntries is also more effective on cleanup, and purgeOnQuotaError advertise that Discourse cache can be purged if necessary. https://developers.google.com/web/tools/workbox/guides/storage-quota --- app/assets/javascripts/service-worker.js.erb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/javascripts/service-worker.js.erb b/app/assets/javascripts/service-worker.js.erb index cc911ea251..ecc892334b 100644 --- a/app/assets/javascripts/service-worker.js.erb +++ b/app/assets/javascripts/service-worker.js.erb @@ -17,6 +17,8 @@ workbox.routing.registerRoute( plugins: [ new workbox.expiration.Plugin({ maxAgeSeconds: 7* 24 * 60 * 60, // 7 days + maxEntries: 500, + purgeOnQuotaError: true, // safe to automatically delete if exceeding the available storage }), ], }) From 3324747afe4ac6656803657892b5cc5a242f5e0a Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sat, 27 Jul 2019 16:37:21 +0100 Subject: [PATCH 129/441] UX: Improve account association when account description is missing --- .../templates/modal/associate-account-confirm.hbs | 11 ++++++++--- config/locales/client.en.yml | 4 +++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/discourse/templates/modal/associate-account-confirm.hbs b/app/assets/javascripts/discourse/templates/modal/associate-account-confirm.hbs index 3fec07f02e..d24c1d7392 100644 --- a/app/assets/javascripts/discourse/templates/modal/associate-account-confirm.hbs +++ b/app/assets/javascripts/discourse/templates/modal/associate-account-confirm.hbs @@ -10,9 +10,14 @@ {{/if}} - {{i18n "user.associated_accounts.confirm_description" - provider=(i18n (concat "login." model.provider_name ".name")) - account_description=model.account_description}} + {{#if model.account_description}} + {{i18n "user.associated_accounts.confirm_description.account_specific" + provider=(i18n (concat "login." model.provider_name ".name")) + account_description=model.account_description}} + {{else}} + {{i18n "user.associated_accounts.confirm_description.generic" + provider=(i18n (concat "login." model.provider_name ".name"))}} + {{/if}} {{/d-modal-body}}

    <%= theme_lookup("footer") %> + <%= theme_lookup("body_tag") %> <%= build_plugin_html 'no-client:footer' %> <%= build_plugin_html 'server:before-body-close' %> From 9656a21fdb0da85a16bff74120a521046867fe42 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Tue, 30 Jul 2019 15:05:08 -0400 Subject: [PATCH 154/441] FEATURE: customization of html emails (#7934) This feature adds the ability to customize the HTML part of all emails using a custom HTML template and optionally some CSS to style it. The CSS will be parsed and converted into inline styles because CSS is poorly supported by email clients. When writing the custom HTML and CSS, be aware of what email clients support. Keep customizations very simple. Customizations can be added and edited in Admin > Customize > Email Style. Since the summary email is already heavily styled, there is a setting to disable custom styles for summary emails called "apply custom styles to digest" found in Admin > Settings > Email. As part of this work, RTL locales are now rendered correctly for all emails. --- Gemfile | 1 + Gemfile.lock | 3 + .../admin/adapters/email-style.js.es6 | 7 + .../components/email-styles-editor.js.es6 | 45 +++++++ .../admin-customize-email-style-edit.js.es6 | 33 +++++ .../admin/models/email-style.js.es6 | 10 ++ .../admin-customize-email-style-edit.js.es6 | 39 ++++++ .../routes/admin-customize-email-style.js.es6 | 9 ++ .../admin/routes/admin-route-map.js.es6 | 7 + .../components/email-styles-editor.hbs | 20 +++ .../templates/customize-email-style-edit.hbs | 9 ++ .../admin/templates/customize-email-style.hbs | 7 + .../javascripts/admin/templates/customize.hbs | 1 + .../stylesheets/common/admin/customize.scss | 15 +++ .../admin/email_styles_controller.rb | 16 +++ app/helpers/email_helper.rb | 10 ++ app/mailers/invite_mailer.rb | 5 +- app/mailers/user_notifications.rb | 27 +--- app/models/email_style.rb | 37 ++++++ app/serializers/email_style_serializer.rb | 5 + app/services/email_style_updater.rb | 38 ++++++ app/services/user_notification_renderer.rb | 7 + app/views/email/default_template.html | 24 ++++ app/views/email/invite.html.erb | 11 -- app/views/email/template.html.erb | 13 -- app/views/email/unsubscribe.html.erb | 14 +- app/views/layouts/email_template.html.erb | 6 + app/views/user_notifications/digest.html.erb | 25 +--- config/locales/client.en.yml | 10 ++ config/locales/server.en.yml | 4 + config/routes.rb | 3 + config/site_settings.yml | 7 + lib/email/message_builder.rb | 15 ++- lib/email/renderer.rb | 18 ++- lib/email/styles.rb | 84 ++++++++---- spec/integration/email_style_spec.rb | 122 ++++++++++++++++++ spec/mailers/user_notifications_spec.rb | 30 ++--- .../admin/email_styles_controller_spec.rb | 71 ++++++++++ spec/services/email_style_updater_spec.rb | 46 +++++++ 39 files changed, 720 insertions(+), 134 deletions(-) create mode 100644 app/assets/javascripts/admin/adapters/email-style.js.es6 create mode 100644 app/assets/javascripts/admin/components/email-styles-editor.js.es6 create mode 100644 app/assets/javascripts/admin/controllers/admin-customize-email-style-edit.js.es6 create mode 100644 app/assets/javascripts/admin/models/email-style.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-customize-email-style-edit.js.es6 create mode 100644 app/assets/javascripts/admin/routes/admin-customize-email-style.js.es6 create mode 100644 app/assets/javascripts/admin/templates/components/email-styles-editor.hbs create mode 100644 app/assets/javascripts/admin/templates/customize-email-style-edit.hbs create mode 100644 app/assets/javascripts/admin/templates/customize-email-style.hbs create mode 100644 app/controllers/admin/email_styles_controller.rb create mode 100644 app/models/email_style.rb create mode 100644 app/serializers/email_style_serializer.rb create mode 100644 app/services/email_style_updater.rb create mode 100644 app/services/user_notification_renderer.rb create mode 100644 app/views/email/default_template.html delete mode 100644 app/views/email/invite.html.erb delete mode 100644 app/views/email/template.html.erb create mode 100644 app/views/layouts/email_template.html.erb create mode 100644 spec/integration/email_style_spec.rb create mode 100644 spec/requests/admin/email_styles_controller_spec.rb create mode 100644 spec/services/email_style_updater_spec.rb diff --git a/Gemfile b/Gemfile index 2db7fffbce..dde65dcd2a 100644 --- a/Gemfile +++ b/Gemfile @@ -78,6 +78,7 @@ gem 'discourse_image_optim', require: 'image_optim' gem 'multi_json' gem 'mustache' gem 'nokogiri' +gem 'css_parser', require: false gem 'omniauth' gem 'omniauth-openid' diff --git a/Gemfile.lock b/Gemfile.lock index 3e50a65a27..c0438f2fdf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -88,6 +88,8 @@ GEM crack (0.4.3) safe_yaml (~> 1.0.0) crass (1.0.4) + css_parser (1.7.0) + addressable debug_inspector (0.0.3) diff-lcs (1.3) diffy (3.3.0) @@ -438,6 +440,7 @@ DEPENDENCIES certified colored2 cppjieba_rb + css_parser diffy discourse-ember-source (~> 3.10.0) discourse_image_optim diff --git a/app/assets/javascripts/admin/adapters/email-style.js.es6 b/app/assets/javascripts/admin/adapters/email-style.js.es6 new file mode 100644 index 0000000000..c9f3865d4c --- /dev/null +++ b/app/assets/javascripts/admin/adapters/email-style.js.es6 @@ -0,0 +1,7 @@ +import RestAdapter from "discourse/adapters/rest"; + +export default RestAdapter.extend({ + pathFor() { + return "/admin/customize/email_style"; + } +}); diff --git a/app/assets/javascripts/admin/components/email-styles-editor.js.es6 b/app/assets/javascripts/admin/components/email-styles-editor.js.es6 new file mode 100644 index 0000000000..d0e569421f --- /dev/null +++ b/app/assets/javascripts/admin/components/email-styles-editor.js.es6 @@ -0,0 +1,45 @@ +import computed from "ember-addons/ember-computed-decorators"; + +export default Ember.Component.extend({ + editorId: Ember.computed.reads("fieldName"), + + @computed("fieldName", "styles.html", "styles.css") + resetDisabled(fieldName) { + return ( + this.get(`styles.${fieldName}`) === + this.get(`styles.default_${fieldName}`) + ); + }, + + @computed("styles", "fieldName") + editorContents: { + get(styles, fieldName) { + return styles[fieldName]; + }, + set(value, styles, fieldName) { + styles.setField(fieldName, value); + return value; + } + }, + + actions: { + reset() { + bootbox.confirm( + I18n.t("admin.customize.email_style.reset_confirm", { + fieldName: I18n.t(`admin.customize.email_style.${this.fieldName}`) + }), + I18n.t("no_value"), + I18n.t("yes_value"), + result => { + if (result) { + this.styles.setField( + this.fieldName, + this.styles.get(`default_${this.fieldName}`) + ); + this.notifyPropertyChange("editorContents"); + } + } + ); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-email-style-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-email-style-edit.js.es6 new file mode 100644 index 0000000000..b8054c8bc3 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-customize-email-style-edit.js.es6 @@ -0,0 +1,33 @@ +import computed from "ember-addons/ember-computed-decorators"; + +export default Ember.Controller.extend({ + @computed("model.isSaving") + saveButtonText(isSaving) { + return isSaving ? I18n.t("saving") : I18n.t("admin.customize.save"); + }, + + @computed("model.changed", "model.isSaving") + saveDisabled(changed, isSaving) { + return !changed || isSaving; + }, + + actions: { + save() { + if (!this.model.saving) { + this.set("saving", true); + this.model + .update(this.model.getProperties("html", "css")) + .catch(e => { + const msg = + e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors + ? I18n.t("admin.customize.email_style.save_error_with_reason", { + error: e.jqXHR.responseJSON.errors.join(". ") + }) + : I18n.t("generic_error"); + bootbox.alert(msg); + }) + .finally(() => this.set("model.changed", false)); + } + } + } +}); diff --git a/app/assets/javascripts/admin/models/email-style.js.es6 b/app/assets/javascripts/admin/models/email-style.js.es6 new file mode 100644 index 0000000000..29d7568aba --- /dev/null +++ b/app/assets/javascripts/admin/models/email-style.js.es6 @@ -0,0 +1,10 @@ +import RestModel from "discourse/models/rest"; + +export default RestModel.extend({ + changed: false, + + setField(fieldName, value) { + this.set(`${fieldName}`, value); + this.set("changed", true); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-customize-email-style-edit.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-email-style-edit.js.es6 new file mode 100644 index 0000000000..74649c1a00 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-customize-email-style-edit.js.es6 @@ -0,0 +1,39 @@ +export default Ember.Route.extend({ + model(params) { + return { + model: this.modelFor("adminCustomizeEmailStyle"), + fieldName: params.field_name + }; + }, + + setupController(controller, model) { + controller.setProperties({ + fieldName: model.fieldName, + model: model.model + }); + this._shouldAlertUnsavedChanges = true; + }, + + actions: { + willTransition(transition) { + if ( + this.get("controller.model.changed") && + this._shouldAlertUnsavedChanges && + transition.intent.name !== this.routeName + ) { + transition.abort(); + bootbox.confirm( + I18n.t("admin.customize.theme.unsaved_changes_alert"), + I18n.t("admin.customize.theme.discard"), + I18n.t("admin.customize.theme.stay"), + result => { + if (!result) { + this._shouldAlertUnsavedChanges = false; + transition.retry(); + } + } + ); + } + } + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-customize-email-style.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-email-style.js.es6 new file mode 100644 index 0000000000..8e202e62bd --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-customize-email-style.js.es6 @@ -0,0 +1,9 @@ +export default Ember.Route.extend({ + model() { + return this.store.find("email-style"); + }, + + redirect() { + this.transitionTo("adminCustomizeEmailStyle.edit", "html"); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index a20165db02..478a851d86 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -90,6 +90,13 @@ export default function() { path: "/robots", resetNamespace: true }); + this.route( + "adminCustomizeEmailStyle", + { path: "/email_style", resetNamespace: true }, + function() { + this.route("edit", { path: "/:field_name" }); + } + ); } ); diff --git a/app/assets/javascripts/admin/templates/components/email-styles-editor.hbs b/app/assets/javascripts/admin/templates/components/email-styles-editor.hbs new file mode 100644 index 0000000000..883149644d --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/email-styles-editor.hbs @@ -0,0 +1,20 @@ +
    +
    + +
    +
    + +{{ace-editor content=editorContents mode=fieldName editorId=editorId}} + + diff --git a/app/assets/javascripts/admin/templates/customize-email-style-edit.hbs b/app/assets/javascripts/admin/templates/customize-email-style-edit.hbs new file mode 100644 index 0000000000..1e68fd0c1a --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-email-style-edit.hbs @@ -0,0 +1,9 @@ +{{email-styles-editor styles=model fieldName=fieldName}} + + diff --git a/app/assets/javascripts/admin/templates/customize-email-style.hbs b/app/assets/javascripts/admin/templates/customize-email-style.hbs new file mode 100644 index 0000000000..d46e732603 --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-email-style.hbs @@ -0,0 +1,7 @@ +
    +

    {{i18n 'admin.customize.email_style.heading'}}

    + +

    {{i18n 'admin.customize.email_style.instructions'}}

    +
    + +{{outlet}} diff --git a/app/assets/javascripts/admin/templates/customize.hbs b/app/assets/javascripts/admin/templates/customize.hbs index d5d3816310..768eaf29a2 100644 --- a/app/assets/javascripts/admin/templates/customize.hbs +++ b/app/assets/javascripts/admin/templates/customize.hbs @@ -3,6 +3,7 @@ {{nav-item route='adminCustomize.colors' label='admin.customize.colors.title'}} {{nav-item route='adminSiteText' label='admin.site_text.title'}} {{nav-item route='adminCustomizeEmailTemplates' label='admin.customize.email_templates.title'}} + {{nav-item route='adminCustomizeEmailStyle' label='admin.customize.email_style.title'}} {{nav-item route='adminUserFields' label='admin.user_fields.title'}} {{nav-item route='adminEmojis' label='admin.emoji.title'}} {{nav-item route='adminPermalinks' label='admin.permalink.title'}} diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss index e9f8fc23e2..ce30f49312 100644 --- a/app/assets/stylesheets/common/admin/customize.scss +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -790,3 +790,18 @@ height: 55vh; } } + +.admin-customize-email-style { + .ace-wrapper { + position: relative; + width: 100%; + height: 400px; + .ace_editor { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + } + } +} diff --git a/app/controllers/admin/email_styles_controller.rb b/app/controllers/admin/email_styles_controller.rb new file mode 100644 index 0000000000..5649708bfe --- /dev/null +++ b/app/controllers/admin/email_styles_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Admin::EmailStylesController < Admin::AdminController + def show + render_serialized(EmailStyle.new, EmailStyleSerializer) + end + + def update + updater = EmailStyleUpdater.new(current_user) + if updater.update(params.require(:email_style).permit(:html, :css)) + render_serialized(EmailStyle.new, EmailStyleSerializer) + else + render_json_error(updater.errors, status: 422) + end + end +end diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb index 9687138eca..c579473a4d 100644 --- a/app/helpers/email_helper.rb +++ b/app/helpers/email_helper.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'erb' + module EmailHelper def mailing_list_topic(topic, post_count) @@ -23,6 +25,14 @@ module EmailHelper raw "#{title}" end + def email_html_template(binding_arg) + template = EmailStyle.new.html.sub( + '%{email_content}', + '<%= yield %><% if defined?(html_body) %><%= html_body %><% end %>' + ) + ERB.new(template).result(binding_arg) + end + protected def extract_details(topic) diff --git a/app/mailers/invite_mailer.rb b/app/mailers/invite_mailer.rb index 4c12e697a9..469a380415 100644 --- a/app/mailers/invite_mailer.rb +++ b/app/mailers/invite_mailer.rb @@ -5,10 +5,7 @@ require_dependency 'email/message_builder' class InviteMailer < ActionMailer::Base include Email::BuildEmailHelper - class UserNotificationRenderer < ActionView::Base - include UserNotificationsHelper - include EmailHelper - end + layout 'email_template' def send_invite(invite) # Find the first topic they were invited to diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index cf73aa5abe..0ffc6f77ba 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -12,6 +12,7 @@ class UserNotifications < ActionMailer::Base include ApplicationHelper helper :application, :email default charset: 'UTF-8' + layout 'email_template' include Email::BuildEmailHelper @@ -362,11 +363,6 @@ class UserNotifications < ActionMailer::Base result end - class UserNotificationRenderer < ActionView::Base - include UserNotificationsHelper - include EmailHelper - end - def self.get_context_posts(post, topic_user, user) if (user.user_option.email_previous_replies == UserOption.previous_replies_type[:never]) || SiteSetting.private_email? @@ -580,15 +576,7 @@ class UserNotifications < ActionMailer::Base site_description: SiteSetting.site_description ) - unless translation_override_exists - html = UserNotificationRenderer.with_view_paths(Rails.configuration.paths["app/views"]).render( - template: 'email/invite', - format: :html, - locals: { message: PrettyText.cook(message, sanitize: false).html_safe, - classes: Rtl.new(user).css_class - } - ) - end + html = PrettyText.cook(message, sanitize: false).html_safe else reached_limit = SiteSetting.max_emails_per_day_per_user > 0 reached_limit &&= (EmailLog.where(user_id: user.id) @@ -608,7 +596,6 @@ class UserNotifications < ActionMailer::Base end unless translation_override_exists - html = UserNotificationRenderer.with_view_paths(Rails.configuration.paths["app/views"]).render( template: 'email/notification', format: :html, @@ -651,7 +638,6 @@ class UserNotifications < ActionMailer::Base site_description: SiteSetting.site_description, site_title: SiteSetting.title, site_title_url_encoded: URI.encode(SiteSetting.title), - style: :notification, locale: locale } @@ -689,13 +675,6 @@ class UserNotifications < ActionMailer::Base @anchor_color = ColorScheme.hex_for_name('tertiary') @markdown_linker = MarkdownLinker.new(@base_url) @unsubscribe_key = UnsubscribeKey.create_key_for(@user, "digest") - end - - def apply_notification_styles(email) - email.html_part.body = Email::Styles.new(email.html_part.body.to_s).tap do |styles| - styles.format_basic - styles.format_notification - end.to_html - email + @disable_email_custom_styles = !SiteSetting.apply_custom_styles_to_digest end end diff --git a/app/models/email_style.rb b/app/models/email_style.rb new file mode 100644 index 0000000000..6d460ccbd1 --- /dev/null +++ b/app/models/email_style.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class EmailStyle + include ActiveModel::Serialization + + attr_accessor :html, :css, :default_html, :default_css + + def id + 'email-style' + end + + def html + SiteSetting.email_custom_template.presence || default_html + end + + def css + SiteSetting.email_custom_css || default_css + end + + def default_html + self.class.default_template + end + + def default_css + self.class.default_css + end + + def self.default_template + @_default_template ||= File.read( + File.join(Rails.root, 'app', 'views', 'email', 'default_template.html') + ) + end + + def self.default_css + '' + end +end diff --git a/app/serializers/email_style_serializer.rb b/app/serializers/email_style_serializer.rb new file mode 100644 index 0000000000..16225f7f53 --- /dev/null +++ b/app/serializers/email_style_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class EmailStyleSerializer < ApplicationSerializer + attributes :id, :html, :css, :default_html, :default_css +end diff --git a/app/services/email_style_updater.rb b/app/services/email_style_updater.rb new file mode 100644 index 0000000000..d0fb918636 --- /dev/null +++ b/app/services/email_style_updater.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class EmailStyleUpdater + + attr_reader :errors + + def initialize(user) + @user = user + @errors = [] + end + + def update(attrs) + if attrs.has_key?(:html) + if attrs[:html] == EmailStyle.default_template + SiteSetting.remove_override!(:email_custom_template) + else + if !attrs[:html].include?('%{email_content}') + @errors << I18n.t( + 'email_style.html_missing_placeholder', + placeholder: '%{email_content}' + ) + else + SiteSetting.email_custom_template = attrs[:html] + end + end + end + + if attrs.has_key?(:css) + if attrs[:css] == EmailStyle.default_css + SiteSetting.remove_override!(:email_custom_css) + else + SiteSetting.email_custom_css = attrs[:css] + end + end + + @errors.empty? + end +end diff --git a/app/services/user_notification_renderer.rb b/app/services/user_notification_renderer.rb new file mode 100644 index 0000000000..8ad7d20914 --- /dev/null +++ b/app/services/user_notification_renderer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UserNotificationRenderer < ActionView::Base + include ApplicationHelper + include UserNotificationsHelper + include EmailHelper +end diff --git a/app/views/email/default_template.html b/app/views/email/default_template.html new file mode 100644 index 0000000000..382f939273 --- /dev/null +++ b/app/views/email/default_template.html @@ -0,0 +1,24 @@ + + + + + + + + + + + + +%{email_content} + + +
                                        +                        
    + + + diff --git a/app/views/email/invite.html.erb b/app/views/email/invite.html.erb deleted file mode 100644 index 9903735c52..0000000000 --- a/app/views/email/invite.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -
    > - -
    %{header_instructions}
    - - <% if message.present? %> -
    <%= message %>
    - <% end %> - - - -
    diff --git a/app/views/email/template.html.erb b/app/views/email/template.html.erb deleted file mode 100644 index 052a9b561f..0000000000 --- a/app/views/email/template.html.erb +++ /dev/null @@ -1,13 +0,0 @@ -
    {{authProvider.account.description}} {{#if authProvider.method.can_revoke}} - {{#conditional-loading-spinner condition=revoking size='small'}} - {{d-button action=(action "revokeAccount") actionParam=authProvider.account title="user.associated_accounts.revoke" class="btn-danger no-text" icon="trash-alt" }} - {{/conditional-loading-spinner}} + {{d-button action=(action "revokeAccount") actionParam=authProvider.account title="user.associated_accounts.revoke" class="btn-danger no-text" icon="trash-alt" disabled=(get revoking authProvider.method.name) }} {{/if}}
    - - - - - - -
    - - -
    - <%= raw(html_body) %> -
    diff --git a/app/views/email/unsubscribe.html.erb b/app/views/email/unsubscribe.html.erb index aee145e5ba..4bc4a3d0dc 100644 --- a/app/views/email/unsubscribe.html.erb +++ b/app/views/email/unsubscribe.html.erb @@ -54,22 +54,22 @@ <% if @digest_unsubscribe %>

    - + <% if @digest_frequencies[:current] %>

    <%= t( - 'unsubscribe.digest_frequency.title', + 'unsubscribe.digest_frequency.title', frequency: t("unsubscribe.digest_frequency.#{@digest_frequencies[:current]}") ) %>


    <% end %> - + - <%= - select_tag :digest_after_minutes, - options_for_select(@digest_frequencies[:frequencies], @digest_frequencies[:selected]), - class: 'combobox' + <%= + select_tag :digest_after_minutes, + options_for_select(@digest_frequencies[:frequencies], @digest_frequencies[:selected]), + class: 'combobox' %>

    <% end %> diff --git a/app/views/layouts/email_template.html.erb b/app/views/layouts/email_template.html.erb new file mode 100644 index 0000000000..80da92efc2 --- /dev/null +++ b/app/views/layouts/email_template.html.erb @@ -0,0 +1,6 @@ +<% if @disable_email_custom_styles %> + <%= yield %> + <% if defined?(html_body) %><%= html_body %><% end %> +<% else %> + <%= email_html_template(binding).html_safe %> +<% end %> diff --git a/app/views/user_notifications/digest.html.erb b/app/views/user_notifications/digest.html.erb index ec36e142ba..dc237d15f0 100644 --- a/app/views/user_notifications/digest.html.erb +++ b/app/views/user_notifications/digest.html.erb @@ -1,19 +1,4 @@ - - - - - - - - - - - - +
    diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index d00ba1c3d7..4bafe97251 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3644,6 +3644,16 @@ en: title: "Override your site's robots.txt file:" warning: "This will permanently override any related site settings." overridden: Your site's default robots.txt file is overridden. + email_style: + title: "Email Style" + heading: "Customize Email Style" + html: "HTML Template" + css: "CSS" + reset: "Reset to default" + reset_confirm: "Are you sure you want to reset to the default %{fieldName} and lose all your changes?" + save_error_with_reason: "Your changes were not saved. %{error}" + instructions: "Customize the template in which all html emails are rendered, and style using CSS." + email: title: "Emails" settings: "Settings" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 6dc6bd93c9..6290dcaf63 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1854,6 +1854,7 @@ en: suppress_digest_email_after_days: "Suppress summary emails for users not seen on the site for more than (n) days." digest_suppress_categories: "Suppress these categories from summary emails." disable_digest_emails: "Disable summary emails for all users." + apply_custom_styles_to_digest: "Custom email template and css are applied to summary emails." email_accent_bg_color: "The accent color to be used as the background of some elements in HTML emails. Enter a color name ('red') or hex value ('#FF0000')." email_accent_fg_color: "The color of text rendered on the email bg color in HTML emails. Enter a color name ('white') or hex value ('#FFFFFF')." email_link_color: "The color of links in HTML emails. Enter a color name ('blue') or hex value ('#0000FF')." @@ -4554,3 +4555,6 @@ en: title: "Delete User" confirm: "Are you sure you want to delete that user? This will remove all of their posts and block their email and IP address." reason: "Deleted via review queue" + + email_style: + html_missing_placeholder: "The html template must include %{placeholder}" diff --git a/config/routes.rb b/config/routes.rb index d1e4bf0bb2..68b1b00fe9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -244,6 +244,9 @@ Discourse::Application.routes.draw do get 'robots' => 'robots_txt#show' put 'robots.json' => 'robots_txt#update' delete 'robots.json' => 'robots_txt#reset' + + resource :email_style, only: [:show, :update] + get 'email_style/:field' => 'email_styles#show', constraints: { field: /html|css/ } end resources :embeddable_hosts, constraints: AdminConstraint.new diff --git a/config/site_settings.yml b/config/site_settings.yml index 5535315930..29dc73a1dd 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -911,6 +911,7 @@ email: disable_digest_emails: default: false client: true + apply_custom_styles_to_digest: true email_accent_bg_color: "#2F70AC" email_accent_fg_color: "#FFFFFF" email_link_color: "#006699" @@ -1024,6 +1025,12 @@ email: enable_forwarded_emails: false always_show_trimmed_content: false private_email: false + email_custom_template: + default: "" + hidden: true + email_custom_css: + default: "" + hidden: true email_total_attachment_size_limit_kb: default: 0 max: 51200 diff --git a/lib/email/message_builder.rb b/lib/email/message_builder.rb index 92f4a2bab8..88669eb729 100644 --- a/lib/email/message_builder.rb +++ b/lib/email/message_builder.rb @@ -107,16 +107,17 @@ module Email html_override.gsub!("%{respond_instructions}", "") end - styled = Email::Styles.new(html_override, @opts) - styled.format_basic - - if style = @opts[:style] - styled.public_send("format_#{style}") - end + html = UserNotificationRenderer.with_view_paths( + Rails.configuration.paths["app/views"] + ).render( + template: 'layouts/email_template', + format: :html, + locals: { html_body: html_override.html_safe } + ) Mail::Part.new do content_type 'text/html; charset=UTF-8' - body styled.to_html + body html end end diff --git a/lib/email/renderer.rb b/lib/email/renderer.rb index f8d0328dc4..0c616be043 100644 --- a/lib/email/renderer.rb +++ b/lib/email/renderer.rb @@ -17,15 +17,21 @@ module Email end def html - if @message.html_part - style = Email::Styles.new(@message.html_part.body.to_s, @opts) - style.format_basic - style.format_html + style = if @message.html_part + Email::Styles.new(@message.html_part.body.to_s, @opts) else - style = Email::Styles.new(PrettyText.cook(text), @opts) - style.format_basic + unstyled = UserNotificationRenderer.with_view_paths( + Rails.configuration.paths["app/views"] + ).render( + template: 'layouts/email_template', + format: :html, + locals: { html_body: PrettyText.cook(text).html_safe } + ) + Email::Styles.new(unstyled, @opts) end + style.format_basic + style.format_html style.to_html end diff --git a/lib/email/styles.rb b/lib/email/styles.rb index 7dc7f14b3a..1b6373878d 100644 --- a/lib/email/styles.rb +++ b/lib/email/styles.rb @@ -16,6 +16,7 @@ module Email @html = html @opts = opts || {} @fragment = Nokogiri::HTML.fragment(@html) + @custom_styles = nil end def self.register_plugin_style(&block) @@ -32,6 +33,26 @@ module Email end end + def custom_styles + return @custom_styles unless @custom_styles.nil? + + css = EmailStyle.new.css + @custom_styles = {} + + if !css.blank? + require 'css_parser' unless defined?(CssParser) + + parser = CssParser::Parser.new(import: false) + parser.load_string!(css) + parser.each_selector do |selector, value| + @custom_styles[selector] ||= +'' + @custom_styles[selector] << value + end + end + + @custom_styles + end + def format_basic uri = URI(Discourse.base_url) @@ -83,29 +104,6 @@ module Email end end - def format_notification - style('.previous-discussion', 'font-size: 17px; color: #444; margin-bottom:10px;') - style('.notification-date', "text-align:right;color:#999999;padding-right:5px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;font-size:11px") - style('.username', "font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;color:#{SiteSetting.email_link_color};text-decoration:none;font-weight:bold") - style('.user-title', "font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;text-decoration:none;margin-left:7px;color: #999;") - style('.user-name', "font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;text-decoration:none;margin-left:7px;color: #{SiteSetting.email_link_color};font-weight:normal;") - style('.post-wrapper', "margin-bottom:25px;") - style('.user-avatar', 'vertical-align:top;width:55px;') - style('.user-avatar img', nil, width: '45', height: '45') - style('hr', 'background-color: #ddd; height: 1px; border: 1px;') - style('.rtl', 'direction: rtl;') - style('div.body', 'padding-top:5px;') - style('.whisper div.body', 'font-style: italic; color: #9c9c9c;') - style('.lightbox-wrapper .meta', 'display: none') - correct_first_body_margin - correct_footer_style - style('div.undecorated-link-footer a', "font-weight: normal;") - correct_footer_style_hilight_first - reset_tables - onebox_styles - plugin_styles - end - def onebox_styles # Links to other topics style('aside.quote', 'padding: 12px 25px 2px 12px; margin-bottom: 10px;') @@ -164,6 +162,16 @@ module Email end def format_html + html_lang = SiteSetting.default_locale.sub("_", "-") + style('html', nil, lang: html_lang, 'xml:lang' => html_lang) + style('body', "text-align:#{ Rtl.new(nil).enabled? ? 'right' : 'left' };") + style('body', nil, dir: Rtl.new(nil).enabled? ? 'rtl' : 'ltr') + + style('.with-dir', + "text-align:#{ Rtl.new(nil).enabled? ? 'right' : 'left' };", + dir: Rtl.new(nil).enabled? ? 'rtl' : 'ltr' + ) + style('.with-accent-colors', "background-color: #{SiteSetting.email_accent_bg_color}; color: #{SiteSetting.email_accent_fg_color};") style('h4', 'color: #222;') style('h3', 'margin: 15px 0 20px 0;') @@ -177,11 +185,39 @@ module Email style('code', 'background-color: #f1f1ff; padding: 2px 5px;') style('pre code', 'display: block; background-color: #f1f1ff; padding: 5px;') style('.featured-topic a', "text-decoration: none; font-weight: bold; color: #{SiteSetting.email_link_color}; line-height:1.5em;") + style('.summary-email', "-moz-box-sizing:border-box;-ms-text-size-adjust:100%;-webkit-box-sizing:border-box;-webkit-text-size-adjust:100%;box-sizing:border-box;color:#0a0a0a;font-family:Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;line-height:1.3;margin:0;min-width:100%;padding:0;width:100%") + + style('.previous-discussion', 'font-size: 17px; color: #444; margin-bottom:10px;') + style('.notification-date', "text-align:right;color:#999999;padding-right:5px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;font-size:11px") + style('.username', "font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;color:#{SiteSetting.email_link_color};text-decoration:none;font-weight:bold") + style('.user-title', "font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;text-decoration:none;margin-left:7px;color: #999;") + style('.user-name', "font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;text-decoration:none;margin-left:7px;color: #{SiteSetting.email_link_color};font-weight:normal;") + style('.post-wrapper', "margin-bottom:25px;") + style('.user-avatar', 'vertical-align:top;width:55px;') + style('.user-avatar img', nil, width: '45', height: '45') + style('hr', 'background-color: #ddd; height: 1px; border: 1px;') + style('.rtl', 'direction: rtl;') + style('div.body', 'padding-top:5px;') + style('.whisper div.body', 'font-style: italic; color: #9c9c9c;') + style('.lightbox-wrapper .meta', 'display: none') + correct_first_body_margin + correct_footer_style + style('div.undecorated-link-footer a', "font-weight: normal;") + correct_footer_style_hilight_first + reset_tables onebox_styles plugin_styles style('.post-excerpt img', "max-width: 50%; max-height: 400px;") + + format_custom + end + + def format_custom + custom_styles.each do |selector, value| + style(selector, value) + end end # this method is reserved for styles specific to plugin @@ -240,7 +276,7 @@ module Email end def correct_first_body_margin - @fragment.css('.body p').each do |element| + @fragment.css('div.body p').each do |element| element['style'] = "margin-top:0; border: 0;" end end diff --git a/spec/integration/email_style_spec.rb b/spec/integration/email_style_spec.rb new file mode 100644 index 0000000000..e035aec102 --- /dev/null +++ b/spec/integration/email_style_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe EmailStyle do + before do + SiteSetting.email_custom_template = "

    FOR YOU

    %{email_content}
    " + SiteSetting.email_custom_css = 'h1 { color: red; } div.body { color: #FAB; }' + end + + after do + SiteSetting.remove_override!(:email_custom_template) + SiteSetting.remove_override!(:email_custom_css) + end + + context 'invite' do + fab!(:invite) { Fabricate(:invite) } + let(:invite_mail) { InviteMailer.send_invite(invite) } + + subject(:mail_html) { Email::Renderer.new(invite_mail).html } + + it 'applies customizations' do + expect(mail_html.scan('

    FOR YOU

    ').count).to eq(1) + expect(mail_html).to match("#{Discourse.base_url}/invites/#{invite.invite_key}") + end + + it 'can apply RTL attrs' do + SiteSetting.default_locale = 'he' + body_attrs = mail_html.match(/])+/) + expect(body_attrs[0]&.downcase).to match(/text-align:\s*right/) + expect(body_attrs[0]&.downcase).to include('dir="rtl"') + end + end + + context 'user_replied' do + let(:response_by_user) { Fabricate(:user, name: "John Doe") } + let(:category) { Fabricate(:category, name: 'India') } + let(:topic) { Fabricate(:topic, category: category, title: "Super cool topic") } + let(:post) { Fabricate(:post, topic: topic, raw: 'This is My super duper cool topic') } + let(:response) { Fabricate(:basic_reply, topic: post.topic, user: response_by_user) } + let(:user) { Fabricate(:user) } + let(:notification) { Fabricate(:replied_notification, user: user, post: response) } + + let(:mail) do + UserNotifications.user_replied( + user, + post: response, + notification_type: notification.notification_type, + notification_data_hash: notification.data_hash + ) + end + + subject(:mail_html) { Email::Renderer.new(mail).html } + + it "customizations are applied to html part of emails" do + expect(mail_html.scan('

    FOR YOU

    ').count).to eq(1) + matches = mail_html.match(/
    #{post.raw}/) + expect(matches[1]).to include('color: #FAB;') # custom + expect(matches[1]).to include('padding-top:5px;') # div.body + end + + # TODO: translation override + end + + context 'signup' do + let(:signup_mail) { UserNotifications.signup(Fabricate(:user)) } + subject(:mail_html) { Email::Renderer.new(signup_mail).html } + + it "customizations are applied to html part of emails" do + expect(mail_html.scan('

    FOR YOU

    ').count).to eq(1) + expect(mail_html).to include('activate-account') + end + + context 'translation override' do + before do + TranslationOverride.upsert!( + 'en', + 'user_notifications.signup.text_body_template', + "CLICK THAT LINK: %{base_url}/u/activate-account/%{email_token}" + ) + end + + after do + TranslationOverride.revert!('en', ['user_notifications.signup.text_body_template']) + end + + it "applies customizations when translation override exists" do + expect(mail_html.scan('

    FOR YOU

    ').count).to eq(1) + expect(mail_html.scan('CLICK THAT LINK').count).to eq(1) + end + end + + context 'with some bad css' do + before do + SiteSetting.email_custom_css = '@import "nope.css"; h1 {{{ size: really big; ' + end + + it "can render the html" do + expect(mail_html.scan(/FOR YOU<\/h1>/).count).to eq(1) + expect(mail_html).to include('activate-account') + end + end + end + + context 'digest' do + fab!(:popular_topic) { Fabricate(:topic, user: Fabricate(:coding_horror), created_at: 1.hour.ago) } + let(:summary_email) { UserNotifications.digest(Fabricate(:user)) } + subject(:mail_html) { Email::Renderer.new(summary_email).html } + + it "customizations are applied to html part of emails" do + expect(mail_html.scan('

    FOR YOU

    ').count).to eq(1) + expect(mail_html).to include(popular_topic.title) + end + + it "doesn't apply customizations if apply_custom_styles_to_digest is disabled" do + SiteSetting.apply_custom_styles_to_digest = false + expect(mail_html).to_not include('

    FOR YOU

    ') + expect(mail_html).to_not include('FOR YOU') + expect(mail_html).to include(popular_topic.title) + end + end +end diff --git a/spec/mailers/user_notifications_spec.rb b/spec/mailers/user_notifications_spec.rb index 7106d4641f..f86892b328 100644 --- a/spec/mailers/user_notifications_spec.rb +++ b/spec/mailers/user_notifications_spec.rb @@ -260,7 +260,7 @@ describe UserNotifications do expect(mail.subject).to match(/Taggo/) expect(mail.subject).to match(/Taggie/) - mail_html = mail.html_part.to_s + mail_html = mail.html_part.body.to_s expect(mail_html.scan(/My super duper cool topic/).count).to eq(1) expect(mail_html.scan(/In Reply To/).count).to eq(1) @@ -287,7 +287,7 @@ describe UserNotifications do notification_data_hash: notification.data_hash ) - expect(mail.html_part.to_s.scan(/In Reply To/).count).to eq(0) + expect(mail.html_part.body.to_s.scan(/In Reply To/).count).to eq(0) SiteSetting.enable_names = true SiteSetting.display_name_on_posts = true @@ -304,7 +304,7 @@ describe UserNotifications do notification_data_hash: notification.data_hash ) - mail_html = mail.html_part.to_s + mail_html = mail.html_part.body.to_s expect(mail_html.scan(/>Bob Marley/).count).to eq(1) expect(mail_html.scan(/>bobmarley/).count).to eq(0) @@ -317,7 +317,7 @@ describe UserNotifications do notification_data_hash: notification.data_hash ) - mail_html = mail.html_part.to_s + mail_html = mail.html_part.body.to_s expect(mail_html.scan(/>Bob Marley/).count).to eq(0) expect(mail_html.scan(/>bobmarley/).count).to eq(1) end @@ -331,8 +331,8 @@ describe UserNotifications do notification_data_hash: notification.data_hash ) - expect(mail.html_part.to_s).to_not include(response.raw) - expect(mail.html_part.to_s).to_not include(topic.url) + expect(mail.html_part.body.to_s).to_not include(response.raw) + expect(mail.html_part.body.to_s).to_not include(topic.url) expect(mail.text_part.to_s).to_not include(response.raw) expect(mail.text_part.to_s).to_not include(topic.url) end @@ -365,10 +365,10 @@ describe UserNotifications do expect(mail.subject).not_to match(/Uncategorized/) # 1 respond to links as no context by default - expect(mail.html_part.to_s.scan(/to respond/).count).to eq(1) + expect(mail.html_part.body.to_s.scan(/to respond/).count).to eq(1) # 1 unsubscribe link - expect(mail.html_part.to_s.scan(/To unsubscribe/).count).to eq(1) + expect(mail.html_part.body.to_s.scan(/To unsubscribe/).count).to eq(1) # side effect, topic user is updated with post number tu = TopicUser.get(post.topic_id, user) @@ -384,7 +384,7 @@ describe UserNotifications do notification_data_hash: notification.data_hash ) - expect(mail.html_part.to_s).to_not include(response.raw) + expect(mail.html_part.body.to_s).to_not include(response.raw) expect(mail.text_part.to_s).to_not include(response.raw) end @@ -451,13 +451,13 @@ describe UserNotifications do expect(mail.subject).to include("[PM] ") # 1 "visit message" link - expect(mail.html_part.to_s.scan(/Visit Message/).count).to eq(1) + expect(mail.html_part.body.to_s.scan(/Visit Message/).count).to eq(1) # 1 respond to link - expect(mail.html_part.to_s.scan(/to respond/).count).to eq(1) + expect(mail.html_part.body.to_s.scan(/to respond/).count).to eq(1) # 1 unsubscribe link - expect(mail.html_part.to_s.scan(/To unsubscribe/).count).to eq(1) + expect(mail.html_part.body.to_s.scan(/To unsubscribe/).count).to eq(1) # side effect, topic user is updated with post number tu = TopicUser.get(topic.id, user) @@ -473,8 +473,8 @@ describe UserNotifications do notification_data_hash: notification.data_hash ) - expect(mail.html_part.to_s).to_not include(response.raw) - expect(mail.html_part.to_s).to_not include(topic.url) + expect(mail.html_part.body.to_s).to_not include(response.raw) + expect(mail.html_part.body.to_s).to_not include(topic.url) expect(mail.text_part.to_s).to_not include(response.raw) expect(mail.text_part.to_s).to_not include(topic.url) end @@ -635,7 +635,7 @@ describe UserNotifications do # WARNING: you reached the limit of 100 email notifications per day. Further emails will be suppressed. # Consider watching less topics or disabling mailing list mode. - expect(mail.html_part.to_s).to match(I18n.t("user_notifications.reached_limit", count: 2)) + expect(mail.html_part.body.to_s).to match(I18n.t("user_notifications.reached_limit", count: 2)) expect(mail.body.to_s).to match(I18n.t("user_notifications.reached_limit", count: 2)) end diff --git a/spec/requests/admin/email_styles_controller_spec.rb b/spec/requests/admin/email_styles_controller_spec.rb new file mode 100644 index 0000000000..9d1f297e7e --- /dev/null +++ b/spec/requests/admin/email_styles_controller_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Admin::EmailStylesController do + fab!(:admin) { Fabricate(:admin) } + let(:default_html) { File.read("#{Rails.root}/app/views/email/default_template.html") } + let(:default_css) { "" } + + before do + sign_in(admin) + end + + after do + SiteSetting.remove_override!(:email_custom_template) + SiteSetting.remove_override!(:email_custom_css) + end + + describe 'show' do + it 'returns default values' do + get '/admin/customize/email_style.json' + expect(response.status).to eq(200) + + json = ::JSON.parse(response.body)['email_style'] + expect(json['html']).to eq(default_html) + expect(json['css']).to eq(default_css) + end + + it 'returns customized values' do + SiteSetting.email_custom_template = "For you: %{email_content}" + SiteSetting.email_custom_css = ".user-name { font-size: 24px; }" + get '/admin/customize/email_style.json' + expect(response.status).to eq(200) + + json = ::JSON.parse(response.body)['email_style'] + expect(json['html']).to eq("For you: %{email_content}") + expect(json['css']).to eq(".user-name { font-size: 24px; }") + end + end + + describe 'update' do + let(:valid_params) do + { + html: 'For you: %{email_content}', + css: '.user-name { color: purple; }' + } + end + + it 'changes the settings' do + SiteSetting.email_custom_css = ".user-name { font-size: 24px; }" + put '/admin/customize/email_style.json', params: { email_style: valid_params } + expect(response.status).to eq(200) + expect(SiteSetting.email_custom_template).to eq(valid_params[:html]) + expect(SiteSetting.email_custom_css).to eq(valid_params[:css]) + end + + it 'reports errors' do + put '/admin/customize/email_style.json', params: { + email_style: valid_params.merge(html: 'No email content') + } + expect(response.status).to eq(422) + json = JSON.parse(response.body) + expect(json['errors']).to include( + I18n.t( + 'email_style.html_missing_placeholder', + placeholder: '%{email_content}' + ) + ) + end + end +end diff --git a/spec/services/email_style_updater_spec.rb b/spec/services/email_style_updater_spec.rb new file mode 100644 index 0000000000..3ef6f1c7ea --- /dev/null +++ b/spec/services/email_style_updater_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe EmailStyleUpdater do + fab!(:admin) { Fabricate(:admin) } + let(:default_html) { File.read("#{Rails.root}/app/views/email/default_template.html") } + let(:updater) { EmailStyleUpdater.new(admin) } + + describe 'update' do + it 'can change the settings' do + expect( + updater.update( + html: 'For you: %{email_content}', + css: 'h1 { color: blue; }' + ) + ).to eq(true) + expect(SiteSetting.email_custom_template).to eq('For you: %{email_content}') + expect(SiteSetting.email_custom_css).to eq('h1 { color: blue; }') + end + + it 'will not store defaults' do + updater.update(html: default_html, css: '') + expect(SiteSetting.email_custom_template).to_not be_present + expect(SiteSetting.email_custom_css).to_not be_present + end + + it 'can clear settings if defaults given' do + SiteSetting.email_custom_template = 'For you: %{email_content}' + SiteSetting.email_custom_css = 'h1 { color: blue; }' + updater.update(html: default_html, css: '') + expect(SiteSetting.email_custom_template).to_not be_present + expect(SiteSetting.email_custom_css).to_not be_present + end + + it 'fails if html is missing email_content' do + expect(updater.update(html: 'No email content', css: '')).to eq(false) + expect(updater.errors).to include( + I18n.t( + 'email_style.html_missing_placeholder', + placeholder: '%{email_content}' + ) + ) + end + end +end From 43365a2bf1b668834916cc507b98bddacb30b447 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Tue, 30 Jul 2019 16:46:20 -0400 Subject: [PATCH 155/441] Fix some broken styles --- app/views/user_notifications/digest.html.erb | 2 +- lib/email/styles.rb | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/views/user_notifications/digest.html.erb b/app/views/user_notifications/digest.html.erb index dc237d15f0..f24dd64688 100644 --- a/app/views/user_notifications/digest.html.erb +++ b/app/views/user_notifications/digest.html.erb @@ -401,7 +401,7 @@ <%= digest_custom_html("above_footer") %> -