diff --git a/Gemfile b/Gemfile index ca22ff21f3..2e34b733af 100644 --- a/Gemfile +++ b/Gemfile @@ -34,7 +34,7 @@ gem 'redis-namespace' gem 'active_model_serializers', '~> 0.8.3' -gem 'onebox', '1.8.60' +gem 'onebox', '1.8.61' gem 'http_accept_language', '~>2.0.5', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 69567ed4a4..60cc4407f8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -44,20 +44,20 @@ GEM arel (9.0.0) ast (2.4.0) aws-eventstream (1.0.1) - aws-partitions (1.92.0) - aws-sdk-core (3.21.2) + aws-partitions (1.104.0) + aws-sdk-core (3.27.0) aws-eventstream (~> 1.0) aws-partitions (~> 1.0) aws-sigv4 (~> 1.0) jmespath (~> 1.0) - aws-sdk-kms (1.5.0) - aws-sdk-core (~> 3) + aws-sdk-kms (1.9.0) + aws-sdk-core (~> 3, >= 3.26.0) aws-sigv4 (~> 1.0) - aws-sdk-s3 (1.14.0) - aws-sdk-core (~> 3, >= 3.21.2) + aws-sdk-s3 (1.19.0) + aws-sdk-core (~> 3, >= 3.26.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.0) - aws-sigv4 (1.0.2) + aws-sigv4 (1.0.3) barber (0.12.0) ember-source (>= 1.0, < 3.1) execjs (>= 1.2, < 3) @@ -257,7 +257,7 @@ GEM omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack - onebox (1.8.60) + onebox (1.8.61) htmlentities (~> 4.3) moneta (~> 1.0) multi_json (~> 1.11) @@ -510,7 +510,7 @@ DEPENDENCIES omniauth-oauth2 omniauth-openid omniauth-twitter - onebox (= 1.8.60) + onebox (= 1.8.61) openid-redis-store pg pry-nav 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 c2f9df5205..9c92228b8c 100644 --- a/app/assets/javascripts/admin/components/admin-backups-logs.js.es6 +++ b/app/assets/javascripts/admin/components/admin-backups-logs.js.es6 @@ -2,6 +2,7 @@ import debounce from "discourse/lib/debounce"; import { renderSpinner } from "discourse/helpers/loading-spinner"; import { escapeExpression } from "discourse/lib/utilities"; import { bufferedRender } from "discourse-common/lib/buffered-render"; +import { observes, on } from "ember-addons/ember-computed-decorators"; export default Ember.Component.extend( bufferedRender({ @@ -21,30 +22,38 @@ export default Ember.Component.extend( $div.scrollTop = $div.scrollHeight; }, - _updateFormattedLogs: debounce(function() { - const logs = this.get("logs"); - if (logs.length === 0) { + @on("init") + @observes("logs.[]") + _resetFormattedLogs() { + if (this.get("logs").length === 0) { this._reset(); // reset the cached logs whenever the model is reset - } else { - // do the log formatting only once for HELLish performance - let formattedLogs = this.get("formattedLogs"); - for (let i = this.get("index"), length = logs.length; i < length; i++) { - const date = logs[i].get("timestamp"), - message = escapeExpression(logs[i].get("message")); - formattedLogs += "[" + date + "] " + message + "\n"; - } - // update the formatted logs & cache index - this.setProperties({ - formattedLogs: formattedLogs, - index: logs.length - }); - // force rerender this.rerenderBuffer(); } + }, + + @on("init") + @observes("logs.[]") + _updateFormattedLogs: debounce(function() { + const logs = this.get("logs"); + if (logs.length === 0) return; + + // do the log formatting only once for HELLish performance + let formattedLogs = this.get("formattedLogs"); + for (let i = this.get("index"), length = logs.length; i < length; i++) { + const date = logs[i].get("timestamp"), + message = escapeExpression(logs[i].get("message")); + formattedLogs += "[" + date + "] " + message + "\n"; + } + // update the formatted logs & cache index + this.setProperties({ + formattedLogs: formattedLogs, + index: logs.length + }); + // force rerender + this.rerenderBuffer(); + Ember.run.scheduleOnce("afterRender", this, this._scrollDown); - }, 150) - .observes("logs.[]") - .on("init"), + }, 150), buildBuffer(buffer) { const formattedLogs = this.get("formattedLogs"); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-start-backup.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-start-backup.js.es6 index 18237198ba..7bd96b326a 100644 --- a/app/assets/javascripts/admin/controllers/modals/admin-start-backup.js.es6 +++ b/app/assets/javascripts/admin/controllers/modals/admin-start-backup.js.es6 @@ -1,24 +1,17 @@ import ModalFunctionality from "discourse/mixins/modal-functionality"; -import Backup from "admin/models/backup"; export default Ember.Controller.extend(ModalFunctionality, { adminBackupsLogs: Ember.inject.controller(), - _startBackup(withUploads) { - this.currentUser.set("hideReadOnlyAlert", true); - Backup.start(withUploads).then(() => { - this.get("adminBackupsLogs.logs").clear(); - this.send("backupStarted"); - }); - }, - actions: { - startBackup() { - this._startBackup(); + startBackupWithUploads() { + this.send("closeModal"); + this.send("startBackup", true); }, - startBackupWithoutUpload() { - this._startBackup(false); + startBackupWithoutUploads() { + this.send("closeModal"); + this.send("startBackup", false); }, cancel() { diff --git a/app/assets/javascripts/admin/routes/admin-backups.js.es6 b/app/assets/javascripts/admin/routes/admin-backups.js.es6 index 2d9f185cca..a0b9342c61 100644 --- a/app/assets/javascripts/admin/routes/admin-backups.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-backups.js.es6 @@ -10,6 +10,7 @@ export default Discourse.Route.extend({ activate() { this.messageBus.subscribe(LOG_CHANNEL, log => { if (log.message === "[STARTED]") { + Discourse.User.currentProp("hideReadOnlyAlert", true); this.controllerFor("adminBackups").set( "model.isOperationRunning", true @@ -62,15 +63,14 @@ export default Discourse.Route.extend({ }, actions: { - startBackup() { + showStartBackupModal() { showModal("admin-start-backup", { admin: true }); this.controllerFor("modal").set("modalClass", "start-backup-modal"); }, - backupStarted() { - this.controllerFor("adminBackups").set("isOperationRunning", true); + startBackup(withUploads) { this.transitionTo("admin.backups.logs"); - this.send("closeModal"); + Backup.start(withUploads); }, destroyBackup(backup) { @@ -100,17 +100,8 @@ export default Discourse.Route.extend({ I18n.t("yes_value"), function(confirmed) { if (confirmed) { - Discourse.User.currentProp("hideReadOnlyAlert", true); - backup.restore().then(function() { - self - .controllerFor("adminBackupsLogs") - .get("logs") - .clear(); - self - .controllerFor("adminBackups") - .set("model.isOperationRunning", true); - self.transitionTo("admin.backups.logs"); - }); + self.transitionTo("admin.backups.logs"); + backup.restore(); } } ); diff --git a/app/assets/javascripts/admin/templates/backups.hbs b/app/assets/javascripts/admin/templates/backups.hbs index 2dcd6e02ba..fe7861839f 100644 --- a/app/assets/javascripts/admin/templates/backups.hbs +++ b/app/assets/javascripts/admin/templates/backups.hbs @@ -21,7 +21,7 @@ label="admin.backups.operations.cancel.label" icon="times"}} {{else}} - {{d-button action="startBackup" + {{d-button action="showStartBackupModal" class="btn-primary" title="admin.backups.operations.backup.title" label="admin.backups.operations.backup.label" diff --git a/app/assets/javascripts/admin/templates/customize-themes.hbs b/app/assets/javascripts/admin/templates/customize-themes.hbs index 68f12bc404..6d3e0648cf 100644 --- a/app/assets/javascripts/admin/templates/customize-themes.hbs +++ b/app/assets/javascripts/admin/templates/customize-themes.hbs @@ -1,8 +1,6 @@ {{#unless editingTheme}}
- {{{i18n 'topic.change_owner.instructions_warn'}}} -
<% end %> - <%- if @preloaded.present? %> - - <%- end %> - <%= yield :data %> <%= render :partial => "common/discourse_javascript" %> diff --git a/config/application.rb b/config/application.rb index 8687840329..9c23554c14 100644 --- a/config/application.rb +++ b/config/application.rb @@ -119,6 +119,7 @@ module Discourse service-worker.js google-tag-manager.js google-universal-analytics.js + preload-application-data.js } # Precompile all available locales diff --git a/config/locales/client.bs_BA.yml b/config/locales/client.bs_BA.yml index 2593e0c1f1..d6384ef418 100644 --- a/config/locales/client.bs_BA.yml +++ b/config/locales/client.bs_BA.yml @@ -1806,7 +1806,7 @@ bs_BA: action: "spoji izabrane postove" error: "Desila se greška prilikom spajanja označenih objava." change_owner: - title: "Change Owner of Posts" + title: "Change Owner" action: "change ownership" error: "There was an error changing the ownership of the posts." label: "New Owner of Posts" diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 808cc1cc9a..39343bdb12 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2000,15 +2000,13 @@ en: error: "There was an error merging the selected posts." change_owner: - title: "Change Owner of Posts" + title: "Change Owner" action: "change ownership" error: "There was an error changing the ownership of the posts." - label: "New Owner of Posts" placeholder: "username of new owner" instructions: - one: "Please choose the new owner of the post by {{old_user}}." - other: "Please choose the new owner of the {{count}} posts by {{old_user}}." - instructions_warn: "Note that any notifications about this post will not be transferred to the new user retroactively." + one: "Please choose a new owner for the post by @{{old_user}}" + other: "Please choose a new owner for the {{count}} posts by @{{old_user}}" change_timestamp: title: "Change Timestamp..." @@ -3522,6 +3520,7 @@ en: change_badge: "change badge" delete_badge: "delete badge" merge_user: "merge user" + entity_export: "export entity" screened_emails: title: "Screened Emails" description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 2a26a8c303..cf2f6ef211 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1144,6 +1144,7 @@ en: log_search_queries: "Log search queries performed by users" search_query_log_max_size: "Maximum amount of search queries to keep" search_query_log_max_retention_days: "Maximum amount of time to keep search queries, in days." + search_ignore_accents: "Ignore accents when searching for text." allow_uncategorized_topics: "Allow topics to be created without a category. WARNING: If there are any uncategorized topics, you must recategorize them before turning this off." allow_duplicate_topic_titles: "Allow topics with identical, duplicate titles." unique_posts_mins: "How many minutes before a user can make a post with the same content again" diff --git a/config/routes.rb b/config/routes.rb index c661b0a727..631c0dcaaf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -290,9 +290,6 @@ Discourse::Application.routes.draw do post "preview" => "badges#preview" end end - - get "memory_stats" => "diagnostics#memory_stats", constraints: AdminConstraint.new - get "dump_heap" => "diagnostics#dump_heap", constraints: AdminConstraint.new end # admin namespace get "email_preferences" => "email#preferences_redirect", :as => "email_preferences_redirect" @@ -793,7 +790,7 @@ Discourse::Application.routes.draw do end end - resources :tag_groups, except: [:new, :edit] do + resources :tag_groups, constraints: StaffConstraint.new, except: [:new, :edit] do collection do get '/filter/search' => 'tag_groups#search' end diff --git a/config/site_settings.yml b/config/site_settings.yml index 91d17c9730..ac6cb09937 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -934,6 +934,7 @@ files: clean_orphan_uploads_grace_period_hours: 48 purge_deleted_uploads_grace_period_days: default: 30 + shadowed_by_global: true prevent_anons_from_downloading_files: default: false client: true @@ -1431,7 +1432,6 @@ search: zh_TW: 2 ko: 2 ja: 2 - search_tokenize_chinese_japanese_korean: false search_prefer_recent_posts: false search_recent_posts_size: @@ -1446,6 +1446,22 @@ search: search_query_log_max_retention_days: default: 365 # 1 year max: 1825 # 5 years + search_ignore_accents: + default: false + locale_default: + ar: true + ca: true + cs: true + el: true + es: true + fa_IR: true + fr: true + hu: true + pt: true + pt_BR: true + ro: true + sk: true + tr_TR: true uncategorized: version_checks: diff --git a/db/migrate/20180920042415_create_user_uploads.rb b/db/migrate/20180920042415_create_user_uploads.rb new file mode 100644 index 0000000000..034bc02bf6 --- /dev/null +++ b/db/migrate/20180920042415_create_user_uploads.rb @@ -0,0 +1,22 @@ +class CreateUserUploads < ActiveRecord::Migration[5.2] + def up + create_table :user_uploads do |t| + t.integer :upload_id, null: false + t.integer :user_id, null: false + t.datetime :created_at, null: false + end + + add_index :user_uploads, [:upload_id, :user_id], unique: true + + execute <<~SQL + INSERT INTO user_uploads(upload_id, user_id, created_at) + SELECT id, user_id, COALESCE(created_at, current_timestamp) + FROM uploads + WHERE user_id IS NOT NULL + SQL + end + + def down + drop_table :user_uploads + end +end diff --git a/lib/backup_restore/backuper.rb b/lib/backup_restore/backuper.rb index 50b52e08c3..e7fdc04ed6 100644 --- a/lib/backup_restore/backuper.rb +++ b/lib/backup_restore/backuper.rb @@ -54,13 +54,10 @@ module BackupRestore @success = true File.join(@archive_directory, @backup_filename) ensure - begin - notify_user - remove_old - clean_up - rescue => ex - Rails.logger.error("#{ex}\n" + ex.backtrace.join("\n")) - end + remove_old + clean_up + notify_user + log "Finished!" @success ? log("[SUCCESS]") : log("[FAILED]") end @@ -255,6 +252,8 @@ module BackupRestore def remove_old log "Removing old backups..." Backup.remove_old + rescue => ex + log "Something went wrong while removing old backups.", ex end def notify_user @@ -270,6 +269,8 @@ module BackupRestore end post + rescue => ex + log "Something went wrong while notifying user.", ex end def clean_up @@ -279,42 +280,49 @@ module BackupRestore disable_readonly_mode if Discourse.readonly_mode? mark_backup_as_not_running refresh_disk_space - log "Finished!" end def refresh_disk_space - log "Refreshing disk cache..." + log "Refreshing disk stats..." DiskSpace.reset_cached_stats + rescue => ex + log "Something went wrong while refreshing disk stats.", ex end def remove_tar_leftovers log "Removing '.tar' leftovers..." Dir["#{@archive_directory}/*.tar"].each { |filename| File.delete(filename) } + rescue => ex + log "Something went wrong while removing '.tar' leftovers.", ex end def remove_tmp_directory log "Removing tmp '#{@tmp_directory}' directory..." FileUtils.rm_rf(@tmp_directory) if Dir[@tmp_directory].present? - rescue - log "Something went wrong while removing the following tmp directory: #{@tmp_directory}" + rescue => ex + log "Something went wrong while removing the following tmp directory: #{@tmp_directory}", ex end def unpause_sidekiq log "Unpausing sidekiq..." Sidekiq.unpause! - rescue - log "Something went wrong while unpausing Sidekiq." + rescue => ex + log "Something went wrong while unpausing Sidekiq.", ex end def disable_readonly_mode return if @readonly_mode_was_enabled log "Disabling readonly mode..." Discourse.disable_readonly_mode + rescue => ex + log "Something went wrong while disabling readonly mode.", ex end def mark_backup_as_not_running log "Marking backup as finished..." BackupRestore.mark_as_not_running! + rescue => ex + log "Something went wrong while marking backup as finished.", ex end def ensure_directory_exists(directory) @@ -322,11 +330,12 @@ module BackupRestore FileUtils.mkdir_p(directory) end - def log(message) + def log(message, ex = nil) timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S") puts(message) publish_log(message, timestamp) save_log(message, timestamp) + Rails.logger.error("#{ex}\n" + ex.backtrace.join("\n")) if ex end def publish_log(message, timestamp) diff --git a/lib/backup_restore/restorer.rb b/lib/backup_restore/restorer.rb index a546a3072c..e8a98f0581 100644 --- a/lib/backup_restore/restorer.rb +++ b/lib/backup_restore/restorer.rb @@ -103,12 +103,9 @@ module BackupRestore else @success = true ensure - begin - notify_user - clean_up - rescue => ex - Rails.logger.error("#{ex}\n" + ex.backtrace.join("\n")) - end + clean_up + notify_user + log "Finished!" @success ? log("[SUCCESS]") : log("[FAILED]") end @@ -459,6 +456,8 @@ module BackupRestore else log "Could not send notification to '#{@user_info[:username]}' (#{@user_info[:email]}), because the user does not exists..." end + rescue => ex + log "Something went wrong while notifying user.", ex end def clean_up @@ -467,32 +466,35 @@ module BackupRestore unpause_sidekiq disable_readonly_mode if Discourse.readonly_mode? mark_restore_as_not_running - log "Finished!" end def remove_tmp_directory log "Removing tmp '#{@tmp_directory}' directory..." FileUtils.rm_rf(@tmp_directory) if Dir[@tmp_directory].present? - rescue - log "Something went wrong while removing the following tmp directory: #{@tmp_directory}" + rescue => ex + log "Something went wrong while removing the following tmp directory: #{@tmp_directory}", ex end def unpause_sidekiq log "Unpausing sidekiq..." Sidekiq.unpause! - rescue - log "Something went wrong while unpausing Sidekiq." + rescue => ex + log "Something went wrong while unpausing Sidekiq.", ex end def disable_readonly_mode return if @readonly_mode_was_enabled log "Disabling readonly mode..." Discourse.disable_readonly_mode + rescue => ex + log "Something went wrong while disabling readonly mode.", ex end def mark_restore_as_not_running log "Marking restore as finished..." BackupRestore.mark_as_not_running! + rescue => ex + log "Something went wrong while marking restore as finished.", ex end def ensure_directory_exists(directory) @@ -500,11 +502,12 @@ module BackupRestore FileUtils.mkdir_p(directory) end - def log(message) + def log(message, ex = nil) timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S") puts(message) publish_log(message, timestamp) save_log(message, timestamp) + Rails.logger.error("#{ex}\n" + ex.backtrace.join("\n")) if ex end def publish_log(message, timestamp) diff --git a/lib/distributed_mutex.rb b/lib/distributed_mutex.rb index b014a554a3..d47f059d0f 100644 --- a/lib/distributed_mutex.rb +++ b/lib/distributed_mutex.rb @@ -7,15 +7,28 @@ class DistributedMutex def initialize(key, redis = nil) @key = key + @using_global_redis = true if !redis @redis = redis || $redis @mutex = Mutex.new end + CHECK_READONLY_ATTEMPT ||= 10 + # NOTE wrapped in mutex to maintain its semantics def synchronize + @mutex.lock + attempts = 0 + while !try_to_get_lock sleep 0.001 + # in readonly we will never be able to get a lock + if @using_global_redis && Discourse.recently_readonly? + attempts += 1 + if attempts > CHECK_READONLY_ATTEMPT + raise Discourse::ReadOnly + end + end end yield diff --git a/lib/file_store/local_store.rb b/lib/file_store/local_store.rb index 9b7148efc8..6d4e733a35 100644 --- a/lib/file_store/local_store.rb +++ b/lib/file_store/local_store.rb @@ -17,6 +17,7 @@ module FileStore dir = Pathname.new(destination).dirname FileUtils.mkdir_p(dir) unless Dir.exists?(dir) FileUtils.move(source, destination, force: true) + FileUtils.touch(destination) end def has_been_uploaded?(url) diff --git a/lib/guardian/user_guardian.rb b/lib/guardian/user_guardian.rb index 0aef85c991..32dc680908 100644 --- a/lib/guardian/user_guardian.rb +++ b/lib/guardian/user_guardian.rb @@ -1,6 +1,23 @@ # mixin for all Guardian methods dealing with user permissions module UserGuardian + def can_pick_avatar?(user_avatar, upload) + return false unless self.user + + return true if is_admin? + + # can always pick blank avatar + return true if !upload + + return true if user_avatar.contains_upload?(upload.id) + return true if upload.user_id == user_avatar.user_id || upload.user_id == user.id + + UserUpload.exists?( + upload_id: upload.id, + user_id: [upload.user_id, user.id] + ) + end + def can_edit_user?(user) is_me?(user) || is_staff? end diff --git a/lib/memory_diagnostics.rb b/lib/memory_diagnostics.rb deleted file mode 100644 index 3a3fcd60f5..0000000000 --- a/lib/memory_diagnostics.rb +++ /dev/null @@ -1,169 +0,0 @@ -module MemoryDiagnostics - - def self.snapshot_exists? - File.exists?(snapshot_filename) - end - - def self.compare(from = nil, to = nil) - - from ||= snapshot_filename - if !to - filename = snapshot_filename + ".new" - snapshot_current_process(filename) - to = filename - end - - from = Marshal::load(IO.binread(from)); - to = Marshal::load(IO.binread(to)); - - diff = from - to - - require 'objspace' - diff = diff.map do |id| - ObjectSpace._id2ref(id) rescue nil - end - diff.compact! - - report = "#{diff.length} objects have leaked\n" - - report << "Summary:\n" - - summary = {} - diff.each do |obj| - begin - summary[obj.class] ||= 0 - summary[obj.class] += 1 - rescue - # don't care - end - end - - report << summary.sort { |a, b| b[1] <=> a[1] }[0..50].map { |k, v| - "#{k}: #{v}" - }.join("\n") - - report << "\n\nSample Items:\n" - - diff[0..5000].each do |v| - report << "#{v.class}: #{String === v ? v[0..300] : (40 + ObjectSpace.memsize_of(v)).to_s + " bytes"}\n" rescue nil - end - - report - end - - def self.snapshot_path - "#{Rails.root}/tmp/mem_snapshots" - end - - def self.snapshot_filename - "#{snapshot_path}/#{Process.pid}.snapshot" - end - - def self.snapshot_current_process(filename = nil) - filename ||= snapshot_filename - pid = fork do - snapshot(filename) - end - - Process.wait(pid) - end - - def self.snapshot(filename) - require 'objspace' - FileUtils.mkdir_p snapshot_path - object_ids = [] - - full_gc - - ObjectSpace.each_object do |o| - begin - object_ids << o.object_id - rescue - # skip - end - end - - IO.binwrite(filename, Marshal::dump(object_ids)) - end - - def self.memory_report(opts = {}) - begin - # ruby 2.1 - GC.start(full_mark: true) - rescue - GC.start - end - - classes = {} - large_objects = [] - - if opts[:class_report] - require 'objspace' - ObjectSpace.each_object do |o| - begin - classes[o.class] ||= 0 - classes[o.class] += 1 - if (size = ObjectSpace.memsize_of(o)) > 200 - large_objects << [size, o] - end - rescue - # all sorts of stuff can happen here BasicObject etc. - classes[:unknown] ||= 0 - classes[:unknown] += 1 - end - end - classes = classes.sort { |a, b| b[1] <=> a[1] }[0..40].map { |klass, count| "#{klass}: #{count}" } - - classes << "\nLarge Objects (#{large_objects.length} larger than 200 bytes total size #{large_objects.map { |x, _| x }.sum}):\n" - - classes += large_objects.sort { |a, b| b[0] <=> a[0] }[0..800].map do |size, object| - rval = "#{object.class}: size #{size}" - rval << " " << object.to_s[0..500].gsub("\n", "") if (String === object) || (Regexp === object) - rval << "\n" - rval - end - end - - stats = GC.stat.map { |k, v| "#{k}: #{v}" } - counts = ObjectSpace.count_objects.sort { |a, b| b[1] <=> a[1] }.map { |k, v| "#{k}: #{v}" } - - <