diff --git a/.eslintignore b/.eslintignore index 644318acf6..cc014c8b92 100644 --- a/.eslintignore +++ b/.eslintignore @@ -11,7 +11,6 @@ lib/javascripts/messageformat.js lib/javascripts/moment.js lib/javascripts/moment_locale/ lib/highlight_js/ -lib/es6_module_transpiler/support/es6-module-transpiler.js public/javascripts/ spec/phantom_js/smoke_test.js vendor/ diff --git a/.eslintrc b/.eslintrc index b3b29b2727..218cb72b81 100644 --- a/.eslintrc +++ b/.eslintrc @@ -14,23 +14,19 @@ {"Ember":true, "jQuery":true, "$":true, + "QUnit":true, "RSVP":true, "Discourse":true, "Em":true, "Handlebars":true, "I18n":true, "bootbox":true, - "module":true, "moduleFor":true, "moduleForComponent":true, "Pretender":true, "sandbox":true, "controllerFor":true, "test":true, - "ok":true, - "not":true, - "expect":true, - "equal":true, "visit":true, "andThen":true, "click":true, @@ -45,18 +41,21 @@ "visible":true, "invisible":true, "asyncRender":true, - "selectDropdown":true, + "selectKit":true, + "expandSelectKit":true, + "collapseSelectKit":true, + "selectKitSelectRowByValue":true, + "selectKitSelectRowByName":true, + "selectKitSelectRowByIndex":true, + "selectKitSelectNoneRow":true, + "selectKitFillInFilter":true, "asyncTestDiscourse":true, "fixture":true, "find":true, "sinon":true, "moment":true, - "start":true, "_":true, "alert":true, - "containsInstance":true, - "deepEqual":true, - "notEqual":true, "define":true, "require":true, "requirejs":true, @@ -100,7 +99,9 @@ "wrap-iife": [ 2, "inside" - ] + ], + "no-mixed-spaces-and-tabs": 2, + "no-trailing-spaces": 2 }, "parser": "babel-eslint" } diff --git a/.gitignore b/.gitignore index 2b372eaa07..8a1f8652b9 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ config/discourse.conf *.sql.gz /db/*.sqlite3 /db/structure.sql +/db/schema.rb # Ignore all logfiles and tempfiles. /log/*.log @@ -43,6 +44,7 @@ config/discourse.conf /logfile log/ bootsnap-load-path-cache +bootsnap-compile-cache/ # Ignore plugins except for the bundled ones. /plugins/* @@ -52,6 +54,7 @@ bootsnap-load-path-cache !/plugins/discourse-details/ !/plugins/discourse-nginx-performance-report !/plugins/discourse-narrative-bot +!/plugins/discourse-presence /plugins/*/auto_generated/ /spec/fixtures/plugins/my_plugin/auto_generated @@ -116,3 +119,7 @@ vendor/bundle/* #ignore jetbrains ide file *.iml + +# ignore nodejs files +/node_modules +/package-lock.json diff --git a/.image_optim.yml b/.image_optim.yml deleted file mode 100644 index 746b85dc3b..0000000000 --- a/.image_optim.yml +++ /dev/null @@ -1,12 +0,0 @@ -skip_missing_workers: true -allow_lossy: false -# PNG -advpng: false -optipng: - level: 2 -pngcrush: false -pngout: false -pngquant: false -# JPG -jpegrecompress: false -timeout: 15 diff --git a/.overcommit.yml b/.overcommit.yml new file mode 100644 index 0000000000..2de35d6df9 --- /dev/null +++ b/.overcommit.yml @@ -0,0 +1,23 @@ +# Use this file to configure the Overcommit hooks you wish to use. This will +# extend the default configuration defined in: +# https://github.com/brigade/overcommit/blob/master/config/default.yml +# +# At the topmost level of this YAML file is a key representing type of hook +# being run (e.g. pre-commit, commit-msg, etc.). Within each type you can +# customize each hook, such as whether to only run it on certain files (via +# `include`), whether to only display output if it fails (via `quiet`), etc. +# +# For a complete list of hooks, see: +# https://github.com/brigade/overcommit/tree/master/lib/overcommit/hook +# +# For a complete list of options that you can use to customize hooks, see: +# https://github.com/brigade/overcommit#configuration + +PreCommit: + RuboCop: + enabled: true + command: ['bundle', 'exec', 'rubocop'] + EsLint: + enabled: true + command: ['eslint', '--ext', '.es6', '-f', 'compact'] + include: '**/*.es6' diff --git a/.rubocop.yml b/.rubocop.yml index 792d6e22e1..b897ad3b9e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,14 +1,113 @@ AllCops: - TargetRubyVersion: 2.3 + TargetRubyVersion: 2.4 + DisabledByDefault: true + Exclude: + - 'db/schema.rb' + - 'bundle/**/*' + - 'vendor/**/*' + - 'node_modules/**/*' + - 'public/**/*' -Metrics/LineLength: - Max: 120 +# Prefer &&/|| over and/or. +Style/AndOr: + Enabled: true -Metrics/MethodLength: +# Do not use braces for hash literals when they are the last argument of a +# method call. +Style/BracesAroundHashParameters: + Enabled: true + +# Align `when` with `case`. +Layout/CaseIndentation: + Enabled: true + +# Align comments with method definitions. +Layout/CommentIndentation: + Enabled: true + +# No extra empty lines. +Layout/EmptyLines: + Enabled: true + +# Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }. +Style/HashSyntax: + Enabled: true + +# Two spaces, no tabs (for indentation). +Layout/IndentationWidth: + Enabled: true + +Layout/SpaceAfterColon: + Enabled: true + +Layout/SpaceAfterComma: + Enabled: true + +Layout/SpaceAroundEqualsInParameterDefault: + Enabled: true + +Layout/SpaceAroundKeyword: + Enabled: true + +Layout/SpaceAroundOperators: + Enabled: true + +Layout/SpaceBeforeFirstArg: + Enabled: true + +# Defining a method with parameters needs parentheses. +Style/MethodDefParentheses: + Enabled: true + +# Use `foo {}` not `foo{}`. +Layout/SpaceBeforeBlockBraces: + Enabled: true + +# Use `foo { bar }` not `foo {bar}`. +Layout/SpaceInsideBlockBraces: + Enabled: true + +# Use `{ a: 1 }` not `{a:1}`. +Layout/SpaceInsideHashLiteralBraces: + Enabled: true + +Layout/SpaceInsideParens: + Enabled: true + +# Detect hard tabs, no hard tabs. +Layout/Tab: + Enabled: true + +# Blank lines should not have any spaces. +Layout/TrailingBlankLines: + Enabled: true + +# No trailing whitespace. +Layout/TrailingWhitespace: + Enabled: true + +Lint/Debugger: + Enabled: true + +Lint/BlockAlignment: + Enabled: true + +# Align `end` with the matching keyword or starting expression except for +# assignments, where it should be aligned with the LHS. +Lint/EndAlignment: + Enabled: true + EnforcedStyleAlignWith: variable + +# Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg. +Lint/RequireParentheses: + Enabled: true + +Layout/MultilineMethodCallIndentation: + Enabled: true + EnforcedStyle: indented + +Layout/AlignHash: + Enabled: true + +Bundler/OrderedGems: Enabled: false - -Style/Documentation: - Enabled: false - -Style/FrozenStringLiteralComment: - Enabled: False diff --git a/.travis.yml b/.travis.yml index f41ee300dc..73ae46e5b0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,6 +10,7 @@ env: - "RAILS_MASTER=0 QUNIT_RUN=0 RUN_LINT=1" addons: + chrome: stable postgresql: 9.5 apt: packages: @@ -20,8 +21,11 @@ addons: matrix: fast_finish: true + allow_failures: + - rvm: 2.5.0 rvm: + - 2.5.0 - 2.4.2 - 2.3.4 @@ -44,12 +48,14 @@ before_install: - git clone --depth=1 https://github.com/discourse/discourse-canned-replies.git plugins/discourse-canned-replies - 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 - export PATH=$HOME/.yarn/bin:$PATH install: - bash -c "if [ '$RAILS_MASTER' == '1' ]; then bundle update --retry=3 --jobs=3 arel rails seed-fu; fi" - bash -c "if [ '$RAILS_MASTER' == '0' ]; then bundle install --without development --deployment --retry=3 --jobs=3; fi" - bash -c "if [ '$RUN_LINT' == '1' ]; then yarn global add eslint babel-eslint; fi" + - bash -c "if [ '$QUNIT_RUN' == '1' ]; then yarn install --dev; fi" script: - | @@ -65,7 +71,8 @@ script: bundle exec rake db:create db:migrate if [ '$QUNIT_RUN' == '1' ]; then - LOAD_PLUGINS=1 bundle exec rake qunit:test['400000'] + bundle exec rake qunit:test['400000'] && \ + bundle exec rake plugin:spec else bundle exec rspec && bundle exec rake plugin:spec fi diff --git a/.tx/config b/.tx/config index bd09128d4c..26ec9541c4 100644 --- a/.tx/config +++ b/.tx/config @@ -26,12 +26,6 @@ source_file = plugins/poll/config/locales/server.en.yml source_lang = en type = YML -[discourse-org.imgurserverenyml] -file_filter = vendor/gems/discourse_imgur/lib/discourse_imgur/locale/server..yml -source_file = vendor/gems/discourse_imgur/lib/discourse_imgur/locale/server.en.yml -source_lang = en -type = YML - [discourse-org.narrativeclientenyml] file_filter = plugins/discourse-narrative-bot/config/locales/client..yml source_file = plugins/discourse-narrative-bot/config/locales/client.en.yml @@ -44,6 +38,18 @@ source_file = plugins/discourse-narrative-bot/config/locales/server.en.yml source_lang = en type = YML +[discourse-org.discourse-presenceclientenyml] +file_filter = plugins/discourse-presence/config/locales/client..yml +source_file = plugins/discourse-presence/config/locales/client.en.yml +source_lang = en +type = YML + +[discourse-org.discourse-presenceserverenyml] +file_filter = plugins/discourse-presence/config/locales/server..yml +source_file = plugins/discourse-presence/config/locales/server.en.yml +source_lang = en +type = YML + [discourse-org.403html] file_filter = public/403..html source_file = public/403.html diff --git a/Brewfile b/Brewfile index ee7378b71c..ba4f913899 100644 --- a/Brewfile +++ b/Brewfile @@ -14,6 +14,3 @@ brew 'postgresql' # install the Redis datastore brew 'redis' - -# install headless Javascript testing library -brew 'phantomjs' diff --git a/Gemfile b/Gemfile index 5bf31ec332..e558ffb47c 100644 --- a/Gemfile +++ b/Gemfile @@ -14,28 +14,21 @@ if rails_master? gem 'rails', git: 'https://github.com/rails/rails.git' gem 'seed-fu', git: 'https://github.com/SamSaffron/seed-fu.git', branch: 'discourse' else - # Rails 5 is going to ship with Action Cable, we have no use for it as - # we already ship MessageBus, AC introduces dependencies on Event Machine, - # Celluloid and Faye Web Sockets. - # - # Note this means upgrading Rails is more annoying, to do so, comment out the - # explicit dependencies, and add gem 'rails', bundle update rails and then - # comment back the explicit dependencies. Leaving this in a comment till we - # upgrade to Rails 5 - # - # gem 'activesupport' - # gem 'actionpack' - # gem 'activerecord' - # gem 'actionmailer' - # gem 'activejob' - # gem 'railties' - # gem 'sprockets-rails' - gem 'rails', '~> 4.2' - gem 'seed-fu', '~> 2.3.5' + gem 'actionmailer', '~> 5.1' + gem 'actionpack', '~> 5.1' + gem 'actionview', '~> 5.1' + gem 'activemodel', '~> 5.1' + gem 'activerecord', '~> 5.1' + gem 'activesupport', '~> 5.1' + gem 'railties', '~> 5.1' + gem 'sprockets-rails' + gem 'seed-fu' end gem 'mail' gem 'mime-types', require: 'mime/types/columnar' +gem 'mini_mime' +gem 'mini_suffix' gem 'hiredis' gem 'redis', require: ["redis", "redis/connection/hiredis"] @@ -43,7 +36,7 @@ gem 'redis-namespace' gem 'active_model_serializers', '~> 0.8.3' -gem 'onebox' +gem 'onebox', '1.8.33' gem 'http_accept_language', '~>2.0.5', require: false @@ -51,7 +44,6 @@ gem 'ember-rails', '0.18.5' gem 'ember-source' gem 'ember-handlebars-template', '0.7.5' gem 'barber' -gem 'babel-transpiler' gem 'message_bus' @@ -61,28 +53,31 @@ gem 'fast_xs' gem 'fast_xor' -gem 'fastimage', '2.1.0' -gem 'aws-sdk', require: false +# Forked until https://github.com/sdsykes/fastimage/pull/93 is merged +gem 'discourse_fastimage', require: 'fastimage' + +gem 'aws-sdk-s3', require: false gem 'excon', require: false gem 'unf', require: false -gem 'email_reply_trimmer', '0.1.6' +gem 'email_reply_trimmer', '0.1.8' -# TODO Use official image_optim gem once https://github.com/toy/image_optim/pull/149 -# is merged. +# Forked until https://github.com/toy/image_optim/pull/149 is merged gem 'discourse_image_optim', require: 'image_optim' gem 'multi_json' gem 'mustache' gem 'nokogiri' + +# this may end up deprecating nokogiri +gem 'oga', require: false + gem 'omniauth' gem 'omniauth-openid' gem 'openid-redis-store' gem 'omniauth-facebook' gem 'omniauth-twitter' gem 'omniauth-instagram' - -# forked while https://github.com/intridea/omniauth-github/pull/41 is being upstreamd -gem 'omniauth-github-discourse', require: 'omniauth-github' +gem 'omniauth-github' gem 'omniauth-oauth2', require: false @@ -94,21 +89,20 @@ gem 'r2', '~> 0.2.5', require: false gem 'rake' gem 'thor', require: false -gem 'rest-client' gem 'rinku' gem 'sanitize' gem 'sidekiq' # for sidekiq web -gem 'sinatra', require: false +gem 'tilt', require: false + gem 'execjs', require: false gem 'mini_racer' gem 'highline', require: false gem 'rack-protection' # security -# Gems used only for assets and not required -# in production environments by default. -# allow everywhere for now cause we are allowing asset debugging in prd +# Gems used only for assets and not required in production environments by default. +# Allow everywhere for now cause we are allowing asset debugging in production group :assets do gem 'uglifier' gem 'rtlit', require: false # for css rtling @@ -118,9 +112,6 @@ group :test do gem 'webmock', require: false gem 'fakeweb', '~> 1.3.0', require: false gem 'minitest', require: false - gem 'timecop' - # TODO: Remove once we upgrade to Rails 5. - gem 'test_after_commit' end group :test, :development do @@ -137,12 +128,13 @@ group :test, :development do gem 'rspec-rails', require: false gem 'shoulda', require: false gem 'rspec-html-matchers' - gem 'spork-rails' gem 'pry-nav' gem 'byebug', require: ENV['RM_INFO'].nil? + gem 'rubocop', require: false end group :development do + gem 'ruby-prof', require: false gem 'bullet', require: !!ENV['BULLET'] gem 'better_errors' gem 'binding_of_caller' @@ -153,7 +145,7 @@ end # this is an optional gem, it provides a high performance replacement # to String#blank? a method that is called quite frequently in current # ActiveRecord, this may change in the future -gem 'fast_blank' #, github: "SamSaffron/fast_blank" +gem 'fast_blank' # this provides a very efficient lru cache gem 'lru_redux' @@ -173,22 +165,23 @@ gem 'rbtrace', require: false, platform: :mri gem 'gc_tracer', require: false, platform: :mri # required for feed importing and embedding -# gem 'ruby-readability', require: false -gem 'simple-rss', require: false gem 'stackprof', require: false, platform: :mri gem 'memory_profiler', require: false, platform: :mri -gem 'rmmseg-cpp', require: false +gem 'cppjieba_rb', require: false +gem 'lograge', require: false +gem 'logstash-event', require: false +gem 'logstash-logger', require: false gem 'logster' gem 'sassc', require: false - if ENV["IMPORT"] == "1" gem 'mysql2' gem 'redcarpet' gem 'sqlite3', '~> 1.3.13' + gem 'ruby-bbcode-to-md', github: 'nlalonde/ruby-bbcode-to-md' end diff --git a/Gemfile.lock b/Gemfile.lock index a0a2290d93..529aa37e43 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,58 +1,62 @@ GEM remote: https://rubygems.org/ specs: - actionmailer (4.2.8) - actionpack (= 4.2.8) - actionview (= 4.2.8) - activejob (= 4.2.8) + actionmailer (5.1.4) + actionpack (= 5.1.4) + actionview (= 5.1.4) + activejob (= 5.1.4) mail (~> 2.5, >= 2.5.4) - rails-dom-testing (~> 1.0, >= 1.0.5) - actionpack (4.2.8) - actionview (= 4.2.8) - activesupport (= 4.2.8) - rack (~> 1.6) - rack-test (~> 0.6.2) - rails-dom-testing (~> 1.0, >= 1.0.5) + rails-dom-testing (~> 2.0) + actionpack (5.1.4) + actionview (= 5.1.4) + activesupport (= 5.1.4) + rack (~> 2.0) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (4.2.8) - activesupport (= 4.2.8) + actionview (5.1.4) + activesupport (= 5.1.4) builder (~> 3.1) - erubis (~> 2.7.0) - rails-dom-testing (~> 1.0, >= 1.0.5) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) active_model_serializers (0.8.3) activemodel (>= 3.0) - activejob (4.2.8) - activesupport (= 4.2.8) - globalid (>= 0.3.0) - activemodel (4.2.8) - activesupport (= 4.2.8) - builder (~> 3.1) - activerecord (4.2.8) - activemodel (= 4.2.8) - activesupport (= 4.2.8) - arel (~> 6.0) - activesupport (4.2.8) + activejob (5.1.4) + activesupport (= 5.1.4) + globalid (>= 0.3.6) + activemodel (5.1.4) + activesupport (= 5.1.4) + activerecord (5.1.4) + activemodel (= 5.1.4) + activesupport (= 5.1.4) + arel (~> 8.0) + activesupport (5.1.4) + concurrent-ruby (~> 1.0, >= 1.0.2) i18n (~> 0.7) minitest (~> 5.1) - thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) addressable (2.5.1) public_suffix (~> 2.0, >= 2.0.2) - annotate (2.7.1) + annotate (2.7.2) activerecord (>= 3.2, < 6.0) - rake (>= 10.4, < 12.0) - arel (6.0.4) - aws-sdk (2.5.3) - aws-sdk-resources (= 2.5.3) - aws-sdk-core (2.5.3) + rake (>= 10.4, < 13.0) + ansi (1.5.0) + arel (8.0.0) + ast (2.3.0) + aws-partitions (1.24.0) + aws-sdk-core (3.6.0) + aws-partitions (~> 1.0) + aws-sigv4 (~> 1.0) jmespath (~> 1.0) - aws-sdk-resources (2.5.3) - aws-sdk-core (= 2.5.3) - babel-source (5.8.34) - babel-transpiler (0.7.0) - babel-source (>= 4.0, < 6) - execjs (~> 2.0) + aws-sdk-kms (1.2.0) + aws-sdk-core (~> 3) + aws-sigv4 (~> 1.0) + aws-sdk-s3 (1.4.0) + aws-sdk-core (~> 3) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.0) + aws-sigv4 (1.0.2) barber (0.11.2) ember-source (>= 1.0, < 3) execjs (>= 1.2, < 3) @@ -62,33 +66,33 @@ GEM rack (>= 0.9.0) binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) - bootsnap (0.3.0) + bootsnap (1.0.0) msgpack (~> 1.0) builder (3.2.3) - bullet (5.4.2) + bullet (5.5.1) activesupport (>= 3.0.0) uniform_notifier (~> 1.10.0) byebug (9.0.6) certified (1.0.0) coderay (1.1.1) concurrent-ruby (1.0.5) - connection_pool (2.2.0) + connection_pool (2.2.1) + cppjieba_rb (0.3.0) crack (0.4.3) safe_yaml (~> 1.0.0) crass (1.0.2) - debug_inspector (0.0.2) + debug_inspector (0.0.3) diff-lcs (1.3) - discourse-qunit-rails (0.0.9) + discourse-qunit-rails (0.0.11) railties + discourse_fastimage (2.1.0) discourse_image_optim (0.24.5) exifr (~> 1.2, >= 1.2.2) fspath (~> 3.0) image_size (~> 1.5) in_threads (~> 1.3) progress (~> 3.0, >= 3.0.1) - domain_name (0.5.25) - unf (>= 0.0.5, < 1.0.0) - email_reply_trimmer (0.1.6) + email_reply_trimmer (0.1.8) ember-data-source (2.2.1) ember-source (>= 1.8, < 3.0) ember-handlebars-template (0.7.5) @@ -101,9 +105,10 @@ GEM ember-source (>= 1.1.0) jquery-rails (>= 1.0.17) railties (>= 3.1) - ember-source (2.10.0) + ember-source (2.13.3) + erubi (1.6.1) erubis (2.7.0) - excon (0.55.0) + excon (0.56.0) execjs (2.7.0) exifr (1.2.5) fabrication (2.9.8) @@ -115,68 +120,78 @@ GEM rake rake-compiler fast_xs (0.8.0) - fastimage (2.1.0) ffi (1.9.18) flamegraph (0.9.5) - foreman (0.82.0) + foreman (0.84.0) thor (~> 0.19.1) fspath (3.1.0) gc_tracer (1.5.1) - globalid (0.3.7) - activesupport (>= 4.1.0) + globalid (0.4.0) + activesupport (>= 4.2.0) guess_html_encoding (0.0.11) hashdiff (0.3.4) hashie (3.5.5) highline (1.7.8) hiredis (0.6.1) htmlentities (4.3.4) - http-cookie (1.0.2) - domain_name (~> 0.5) http_accept_language (2.0.5) - i18n (0.8.1) + i18n (0.8.6) image_size (1.5.0) in_threads (1.4.0) jmespath (1.3.1) - jquery-rails (4.2.1) + jquery-rails (4.3.1) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) jwt (1.5.6) kgio (2.11.0) - libv8 (5.3.332.38.5) + libv8 (5.9.211.38.1) listen (3.1.5) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) ruby_dep (~> 1.2) - logster (1.2.7) - loofah (2.0.3) + lograge (0.7.1) + actionpack (>= 4, < 5.2) + activesupport (>= 4, < 5.2) + railties (>= 4, < 5.2) + request_store (~> 1.0) + logstash-event (1.2.02) + logstash-logger (0.25.1) + logstash-event (~> 1.2) + logster (1.2.9) + loofah (2.1.1) + crass (~> 1.0.2) nokogiri (>= 1.5.9) lru_redux (1.1.0) - mail (2.6.6.rc1) + mail (2.6.6) mime-types (>= 1.16, < 4) - memory_profiler (0.9.7) - message_bus (2.0.2) + memory_profiler (0.9.8) + message_bus (2.1.1) rack (>= 1.1.3) metaclass (0.0.4) method_source (0.8.2) - mime-types (2.99.3) + mime-types (3.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2016.0521) + mini_mime (0.1.3) mini_portile2 (2.3.0) - mini_racer (0.1.9) - libv8 (~> 5.3) - minitest (5.10.1) - mocha (1.1.0) + mini_racer (0.1.11) + libv8 (~> 5.7) + mini_suffix (0.3.0) + ffi (~> 1.9) + minitest (5.10.3) + mocha (1.2.1) metaclass (~> 0.0.1) - mock_redis (0.15.4) + mock_redis (0.17.3) moneta (1.0.0) msgpack (1.1.0) multi_json (1.12.1) multi_xml (0.6.0) multipart-post (2.0.0) mustache (1.0.5) - netrc (0.11.0) nokogiri (1.8.1) mini_portile2 (~> 2.3.0) - nokogumbo (1.4.10) + nokogumbo (1.4.13) nokogiri oauth (0.5.1) oauth2 (1.3.1) @@ -185,15 +200,18 @@ GEM multi_json (~> 1.3) multi_xml (~> 0.5) rack (>= 1.2, < 3) - oj (3.0.5) + oga (2.10) + ast + ruby-ll (~> 2.1) + oj (3.1.0) omniauth (1.6.1) hashie (>= 3.4.6, < 3.6.0) rack (>= 1.6.2, < 3) omniauth-facebook (4.0.0) omniauth-oauth2 (~> 1.2) - omniauth-github-discourse (1.1.2) - omniauth (~> 1.0) - omniauth-oauth2 (~> 1.1) + omniauth-github (1.3.0) + omniauth (~> 1.5) + omniauth-oauth2 (>= 1.4.0, < 2.0) omniauth-google-oauth2 (0.3.1) jwt (~> 1.0) multi_json (~> 1.3) @@ -214,7 +232,7 @@ GEM omniauth-twitter (1.3.0) omniauth-oauth (~> 1.1) rack - onebox (1.8.12) + onebox (1.8.33) fast_blank (>= 1.0.0) htmlentities (~> 4.3) moneta (~> 1.0) @@ -225,7 +243,11 @@ GEM openid-redis-store (0.0.2) redis ruby-openid - pg (0.19.0) + parallel (1.12.0) + parser (2.4.0.0) + ast (~> 2.2) + pg (0.20.0) + powerpack (0.1.1) progress (3.3.1) pry (0.10.4) coderay (~> 1.1.0) @@ -236,128 +258,117 @@ GEM pry-rails (0.3.4) pry (>= 0.9.10) public_suffix (2.0.5) - puma (3.6.0) + puma (3.9.1) r2 (0.2.6) - rack (1.6.8) - rack-mini-profiler (0.10.4) + rack (2.0.3) + rack-mini-profiler (0.10.7) rack (>= 1.2.0) rack-openid (1.3.1) rack (>= 1.1.0) ruby-openid (>= 2.1.8) - rack-protection (1.5.3) + rack-protection (2.0.0) rack - rack-test (0.6.3) - rack (>= 1.0) - rails (4.2.8) - actionmailer (= 4.2.8) - actionpack (= 4.2.8) - actionview (= 4.2.8) - activejob (= 4.2.8) - activemodel (= 4.2.8) - activerecord (= 4.2.8) - activesupport (= 4.2.8) - bundler (>= 1.3.0, < 2.0) - railties (= 4.2.8) - sprockets-rails - rails-deprecated_sanitizer (1.0.3) - activesupport (>= 4.2.0.alpha) - rails-dom-testing (1.0.8) - activesupport (>= 4.2.0.beta, < 5.0) - nokogiri (~> 1.6) - rails-deprecated_sanitizer (>= 1.0.1) + rack-test (0.7.0) + rack (>= 1.0, < 3) + rails-dom-testing (2.0.3) + activesupport (>= 4.2.0) + nokogiri (>= 1.6) rails-html-sanitizer (1.0.3) loofah (~> 2.0) - rails_multisite (1.0.6) - rails (> 4.2, < 5) - railties (4.2.8) - actionpack (= 4.2.8) - activesupport (= 4.2.8) + rails_multisite (1.1.2) + activerecord (> 4.2, < 6) + railties (> 4.2, < 6) + railties (5.1.4) + actionpack (= 5.1.4) + activesupport (= 5.1.4) + method_source rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) - raindrops (0.18.0) - rake (11.3.0) - rake-compiler (0.9.9) + rainbow (2.2.2) rake - rb-fsevent (0.9.7) - rb-inotify (0.9.7) + raindrops (0.19.0) + rake (12.1.0) + rake-compiler (1.0.4) + rake + rb-fsevent (0.9.8) + rb-inotify (0.9.8) ffi (>= 0.5.0) rbtrace (0.4.8) ffi (>= 1.0.6) msgpack (>= 0.4.3) trollop (>= 1.16.2) - redis (3.3.3) - redis-namespace (1.5.2) + redis (3.3.5) + redis-namespace (1.5.3) redis (~> 3.0, >= 3.0.4) - rest-client (1.8.0) - http-cookie (>= 1.0.2, < 2.0) - mime-types (>= 1.16, < 3.0) - netrc (~> 0.7) - rinku (2.0.0) - rmmseg-cpp (0.2.9) - rspec (3.4.0) - rspec-core (~> 3.4.0) - rspec-expectations (~> 3.4.0) - rspec-mocks (~> 3.4.0) - rspec-core (3.4.4) - rspec-support (~> 3.4.0) - rspec-expectations (3.4.0) + request_store (1.3.2) + rinku (2.0.2) + rspec (3.6.0) + rspec-core (~> 3.6.0) + rspec-expectations (~> 3.6.0) + rspec-mocks (~> 3.6.0) + rspec-core (3.6.0) + rspec-support (~> 3.6.0) + rspec-expectations (3.6.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.4.0) - rspec-html-matchers (0.7.0) + rspec-support (~> 3.6.0) + rspec-html-matchers (0.9.1) nokogiri (~> 1) - rspec (~> 3) - rspec-mocks (3.4.1) + rspec (>= 3.0.0.a, < 4) + rspec-mocks (3.6.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.4.0) - rspec-rails (3.4.2) - actionpack (>= 3.0, < 4.3) - activesupport (>= 3.0, < 4.3) - railties (>= 3.0, < 4.3) - rspec-core (~> 3.4.0) - rspec-expectations (~> 3.4.0) - rspec-mocks (~> 3.4.0) - rspec-support (~> 3.4.0) - rspec-support (3.4.1) + rspec-support (~> 3.6.0) + rspec-rails (3.6.1) + actionpack (>= 3.0) + activesupport (>= 3.0) + railties (>= 3.0) + rspec-core (~> 3.6.0) + rspec-expectations (~> 3.6.0) + rspec-mocks (~> 3.6.0) + rspec-support (~> 3.6.0) + rspec-support (3.6.0) rtlit (0.0.5) + rubocop (0.51.0) + parallel (~> 1.10) + parser (>= 2.3.3.1, < 3.0) + powerpack (~> 0.1) + rainbow (>= 2.2.2, < 3.0) + ruby-progressbar (~> 1.7) + unicode-display_width (~> 1.0, >= 1.0.1) + ruby-ll (2.1.2) + ansi + ast ruby-openid (2.7.0) + ruby-prof (0.16.2) + ruby-progressbar (1.9.0) ruby-readability (0.7.0) guess_html_encoding (>= 0.0.4) nokogiri (>= 1.6.0) ruby_dep (1.5.0) safe_yaml (1.0.4) - sanitize (4.4.0) + sanitize (4.5.0) crass (~> 1.0.2) nokogiri (>= 1.4.4) nokogumbo (~> 1.4.1) - sass (3.4.23) + sass (3.4.24) sassc (1.11.2) bundler ffi (~> 1.9.6) sass (>= 3.3.0) - seed-fu (2.3.5) - activerecord (>= 3.1, < 4.3) - activesupport (>= 3.1, < 4.3) + seed-fu (2.3.7) + activerecord (>= 3.1) + activesupport (>= 3.1) shoulda (3.5.0) shoulda-context (~> 1.0, >= 1.0.1) shoulda-matchers (>= 1.4.1, < 3.0) shoulda-context (1.2.2) shoulda-matchers (2.8.0) activesupport (>= 3.0.0) - sidekiq (4.2.4) + sidekiq (5.0.5) concurrent-ruby (~> 1.0) connection_pool (~> 2.2, >= 2.2.0) rack-protection (>= 1.5.0) - redis (~> 3.2, >= 3.2.1) - simple-rss (1.3.1) - sinatra (1.4.6) - rack (~> 1.4) - rack-protection (~> 1.4) - tilt (>= 1.3, < 3) + redis (>= 3.3.4, < 5) slop (3.6.0) - spork (1.0.0rc4) - spork-rails (4.0.0) - rails (>= 3.0.0, < 5) - spork (>= 1.0rc0) sprockets (3.7.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) @@ -366,21 +377,19 @@ GEM activesupport (>= 4.0) sprockets (>= 3.0.0) stackprof (0.2.10) - test_after_commit (1.1.0) - activerecord (>= 3.2) thor (0.19.4) thread_safe (0.3.6) - tilt (2.0.5) - timecop (0.8.1) + tilt (2.0.7) trollop (2.1.2) tzinfo (1.2.3) thread_safe (~> 0.1) - uglifier (3.0.2) + uglifier (3.2.0) execjs (>= 0.3.0, < 3) unf (0.1.4) unf_ext - unf_ext (0.0.7.1) - unicorn (5.3.0) + unf_ext (0.0.7.4) + unicode-display_width (1.3.0) + unicorn (5.3.1) kgio (~> 2.6) raindrops (~> 0.7) uniform_notifier (1.10.0) @@ -393,10 +402,15 @@ PLATFORMS ruby DEPENDENCIES + actionmailer (~> 5.1) + actionpack (~> 5.1) + actionview (~> 5.1) active_model_serializers (~> 0.8.3) + activemodel (~> 5.1) + activerecord (~> 5.1) + activesupport (~> 5.1) annotate - aws-sdk - babel-transpiler + aws-sdk-s3 barber better_errors binding_of_caller @@ -404,9 +418,11 @@ DEPENDENCIES bullet byebug certified + cppjieba_rb discourse-qunit-rails + discourse_fastimage discourse_image_optim - email_reply_trimmer (= 0.1.6) + email_reply_trimmer (= 0.1.8) ember-handlebars-template (= 0.7.5) ember-rails (= 0.18.5) ember-source @@ -417,7 +433,6 @@ DEPENDENCIES fast_blank fast_xor fast_xs - fastimage (= 2.1.0) flamegraph foreman gc_tracer @@ -426,29 +441,35 @@ DEPENDENCIES htmlentities http_accept_language (~> 2.0.5) listen + lograge + logstash-event + logstash-logger logster lru_redux mail memory_profiler message_bus mime-types + mini_mime mini_racer + mini_suffix minitest mocha mock_redis multi_json mustache nokogiri + oga oj omniauth omniauth-facebook - omniauth-github-discourse + omniauth-github omniauth-google-oauth2 omniauth-instagram omniauth-oauth2 omniauth-openid omniauth-twitter - onebox + onebox (= 1.8.33) openid-redis-store pg pry-nav @@ -457,38 +478,35 @@ DEPENDENCIES r2 (~> 0.2.5) rack-mini-profiler rack-protection - rails (~> 4.2) rails_multisite + railties (~> 5.1) rake rb-fsevent rb-inotify (~> 0.9) rbtrace redis redis-namespace - rest-client rinku - rmmseg-cpp rspec rspec-html-matchers rspec-rails rtlit + rubocop + ruby-prof ruby-readability sanitize sassc - seed-fu (~> 2.3.5) + seed-fu shoulda sidekiq - simple-rss - sinatra - spork-rails + sprockets-rails stackprof - test_after_commit thor - timecop + tilt uglifier unf unicorn webmock BUNDLED WITH - 1.15.4 + 1.16.0 diff --git a/Rakefile b/Rakefile index 179ba560eb..7a7f9ee44b 100755 --- a/Rakefile +++ b/Rakefile @@ -9,4 +9,3 @@ Discourse::Application.load_tasks # this prevents crashes when migrating a database in production in certain # PostgreSQL configuations when trying to create structure.sql Rake::Task["db:structure:dump"].clear if Rails.env.production? - diff --git a/app/assets/images/favicons/google_branding/logo_calendar_48px.png b/app/assets/images/favicons/google_branding/logo_calendar_48px.png new file mode 100644 index 0000000000..bb50bb966d Binary files /dev/null and b/app/assets/images/favicons/google_branding/logo_calendar_48px.png differ diff --git a/app/assets/javascripts/admin/adapters/flagged-post.js.es6 b/app/assets/javascripts/admin/adapters/flagged-post.js.es6 new file mode 100644 index 0000000000..1a1065f638 --- /dev/null +++ b/app/assets/javascripts/admin/adapters/flagged-post.js.es6 @@ -0,0 +1,38 @@ +import RestAdapter from 'discourse/adapters/rest'; + +export default RestAdapter.extend({ + pathFor(store, type, findArgs) { + let args = _.merge({ rest_api: true }, findArgs); + delete args.filter; + return `/admin/flags/${findArgs.filter}.json?${$.param(args)}`; + }, + + afterFindAll(results, helper) { + results.forEach(flag => { + let conversations = []; + flag.post_actions.forEach(pa => { + if (pa.conversation) { + let conversation = { + permalink: pa.permalink, + hasMore: pa.conversation.has_more, + response: { + excerpt: pa.conversation.response.excerpt, + user: helper.lookup('user', pa.conversation.response.user_id) + } + }; + + if (pa.conversation.reply) { + conversation.reply = { + excerpt: pa.conversation.reply.excerpt, + user: helper.lookup('user', pa.conversation.reply.user_id) + }; + } + conversations.push(conversation); + } + }); + flag.set('conversations', conversations); + }); + + return results; + } +}); diff --git a/app/assets/javascripts/admin/components/ace-editor.js.es6 b/app/assets/javascripts/admin/components/ace-editor.js.es6 index 749ce2492d..faad36798a 100644 --- a/app/assets/javascripts/admin/components/ace-editor.js.es6 +++ b/app/assets/javascripts/admin/components/ace-editor.js.es6 @@ -1,13 +1,14 @@ import loadScript from 'discourse/lib/load-script'; import { observes } from 'ember-addons/ember-computed-decorators'; -const LOAD_ASYNC = !Ember.Test; +const LOAD_ASYNC = !Ember.testing; export default Ember.Component.extend({ mode: 'css', classNames: ['ace-wrapper'], _editor: null, _skipContentChangeEvent: null, + disabled: false, @observes('editorId') editorIdChanged() { @@ -30,6 +31,24 @@ export default Ember.Component.extend({ } }, + @observes('disabled') + disabledStateChanged() { + this.changeDisabledState(); + }, + + changeDisabledState() { + const editor = this._editor; + if (editor) { + const disabled = this.get('disabled'); + editor.setOptions({ + readOnly: disabled, + highlightActiveLine: !disabled, + highlightGutterLine: !disabled + }); + editor.container.parentNode.setAttribute("data-disabled", disabled); + } + }, + _destroyEditor: function() { if (this._editor) { this._editor.destroy(); @@ -76,6 +95,7 @@ export default Ember.Component.extend({ this.$().data('editor', editor); this._editor = editor; + this.changeDisabledState(); $(window).off('ace:resize').on('ace:resize', ()=>{ this.appEvents.trigger('ace:resize'); diff --git a/app/assets/javascripts/admin/components/admin-directory-toggle.js.es6 b/app/assets/javascripts/admin/components/admin-directory-toggle.js.es6 index e4d19613b5..8d43474a9d 100644 --- a/app/assets/javascripts/admin/components/admin-directory-toggle.js.es6 +++ b/app/assets/javascripts/admin/components/admin-directory-toggle.js.es6 @@ -1,4 +1,4 @@ -import { iconHTML } from 'discourse-common/helpers/fa-icon'; +import { iconHTML } from 'discourse-common/lib/icon-library'; import { bufferedRender } from 'discourse-common/lib/buffered-render'; export default Ember.Component.extend(bufferedRender({ diff --git a/app/assets/javascripts/admin/components/admin-group-selector.js.es6 b/app/assets/javascripts/admin/components/admin-group-selector.js.es6 deleted file mode 100644 index fd487640d0..0000000000 --- a/app/assets/javascripts/admin/components/admin-group-selector.js.es6 +++ /dev/null @@ -1,43 +0,0 @@ -export default Ember.Component.extend({ - tagName: 'div', - - _init: function(){ - this.$("input").select2({ - multiple: true, - width: '100%', - query: function(opts) { - opts.callback({ - results: this.get("available").filter(function(o) { - return -1 !== o.name.toLowerCase().indexOf(opts.term.toLowerCase()); - }).map(this._format) - }); - }.bind(this) - }).on("change", function(evt) { - if (evt.added){ - this.triggerAction({ - action: "groupAdded", - actionContext: this.get("available").findBy("id", evt.added.id) - }); - } else if (evt.removed) { - this.triggerAction({ - action:"groupRemoved", - actionContext: evt.removed.id - }); - } - }.bind(this)); - - this._refreshOnReset(); - }.on("didInsertElement"), - - _format(item) { - return { - "text": item.name, - "id": item.id, - "locked": item.automatic - }; - }, - - _refreshOnReset: function() { - this.$("input").select2("data", this.get("selected").map(this._format)); - }.observes("selected") -}); diff --git a/app/assets/javascripts/admin/components/admin-nav.js.es6 b/app/assets/javascripts/admin/components/admin-nav.js.es6 new file mode 100644 index 0000000000..9250c1ae73 --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-nav.js.es6 @@ -0,0 +1,3 @@ +export default Ember.Component.extend({ + tagName: '' +}); diff --git a/app/assets/javascripts/admin/components/admin-watched-word.js.es6 b/app/assets/javascripts/admin/components/admin-watched-word.js.es6 new file mode 100644 index 0000000000..3c78becb49 --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-watched-word.js.es6 @@ -0,0 +1,19 @@ +import { iconHTML } from 'discourse-common/lib/icon-library'; +import { bufferedRender } from 'discourse-common/lib/buffered-render'; + +export default Ember.Component.extend(bufferedRender({ + classNames: ['watched-word'], + + buildBuffer(buffer) { + buffer.push(iconHTML('times')); + buffer.push(' ' + this.get('word.word')); + }, + + click() { + this.get('word').destroy().then(() => { + this.sendAction('action', this.get('word')); + }).catch(e => { + bootbox.alert(I18n.t("generic_error_with_reason", {error: `http: ${e.status} - ${e.body}`})); + });; + } +})); 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 d176b5a40a..c5f1a4a244 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 @@ -1,5 +1,5 @@ import computed from 'ember-addons/ember-computed-decorators'; -import { iconHTML } from 'discourse-common/helpers/fa-icon'; +import { iconHTML } from 'discourse-common/lib/icon-library'; import { bufferedRender } from 'discourse-common/lib/buffered-render'; export default Ember.Component.extend(bufferedRender({ diff --git a/app/assets/javascripts/admin/components/flagged-post-response.js.es6 b/app/assets/javascripts/admin/components/flagged-post-response.js.es6 new file mode 100644 index 0000000000..e031f33e03 --- /dev/null +++ b/app/assets/javascripts/admin/components/flagged-post-response.js.es6 @@ -0,0 +1,3 @@ +export default Ember.Component.extend({ + classNames: ['flagged-post-response'] +}); diff --git a/app/assets/javascripts/admin/components/flagged-post.js.es6 b/app/assets/javascripts/admin/components/flagged-post.js.es6 new file mode 100644 index 0000000000..0754952a01 --- /dev/null +++ b/app/assets/javascripts/admin/components/flagged-post.js.es6 @@ -0,0 +1,72 @@ +import showModal from 'discourse/lib/show-modal'; +import computed from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + adminTools: Ember.inject.service(), + expanded: false, + suspended: false, + + tagName: 'div', + classNameBindings: [ + ':flagged-post', + 'flaggedPost.hidden:hidden-post', + 'flaggedPost.deleted' + ], + + @computed('filter') + canAct(filter) { + return filter === 'active'; + }, + + removeAfter(promise) { + return promise.then(() => { + this.attrs.removePost(); + }).catch(() => { + bootbox.alert(I18n.t("admin.flags.error")); + }); + }, + + _spawnModal(name, model, modalClass) { + let controller = showModal(name, { model, admin: true, modalClass }); + controller.removeAfter = (p) => this.removeAfter(p); + }, + + actions: { + removeAfter(promise) { + this.removeAfter(promise); + }, + + disagree() { + this.removeAfter(this.get('flaggedPost').disagreeFlags()); + }, + + defer() { + this.removeAfter(this.get('flaggedPost').deferFlags()); + }, + + expand() { + this.get('flaggedPost').expandHidden().then(() => { + this.set('expanded', true); + }); + }, + + showModerationHistory() { + this.get('adminTools').showModerationHistory({ + filter: 'post', + post_id: this.get('flaggedPost.id') + }); + }, + + showSuspendModal() { + let post = this.get('flaggedPost'); + let user = post.get('user'); + this.get('adminTools').showSuspendModal( + user, + { + post, + successCallback: result => this.set('suspended', result.suspended) + } + ); + } + } +}); diff --git a/app/assets/javascripts/admin/components/ip-lookup.js.es6 b/app/assets/javascripts/admin/components/ip-lookup.js.es6 index 0c9b73d54a..a185e4c0b6 100644 --- a/app/assets/javascripts/admin/components/ip-lookup.js.es6 +++ b/app/assets/javascripts/admin/components/ip-lookup.js.es6 @@ -1,4 +1,5 @@ import { ajax } from 'discourse/lib/ajax'; +import AdminUser from 'admin/models/admin-user'; export default Ember.Component.extend({ classNames: ["ip-lookup"], @@ -44,7 +45,6 @@ export default Ember.Component.extend({ self.set("totalOthersWithSameIP", result.total); }); - const AdminUser = require('admin/models/admin-user').default; AdminUser.findAll("active", data).then(function (users) { self.setProperties({ other_accounts: users, diff --git a/app/assets/javascripts/admin/components/list-setting.js.es6 b/app/assets/javascripts/admin/components/list-setting.js.es6 deleted file mode 100644 index da6c5173d6..0000000000 --- a/app/assets/javascripts/admin/components/list-setting.js.es6 +++ /dev/null @@ -1,54 +0,0 @@ -/** - Provide a nice GUI for a pipe-delimited list in the site settings. - - @param settingValue is a reference to SiteSetting.value. - @param choices is a reference to SiteSetting.choices -**/ -export default Ember.Component.extend({ - - _select2FormatSelection: function(selectedObject, jqueryWrapper, htmlEscaper) { - var text = selectedObject.text; - if (text.length <= 6) { - jqueryWrapper.closest('li.select2-search-choice').css({"border-bottom": '7px solid #'+text}); - } - return htmlEscaper(text); - }, - - _initializeSelect2: function(){ - var options = { - multiple: false, - separator: "|", - tokenSeparators: ["|"], - tags : this.get("choices") || [], - width: 'off', - dropdownCss: this.get("choices") ? {} : {display: 'none'}, - selectOnBlur: this.get("choices") ? false : true - }; - - var settingName = this.get('settingName'); - if (typeof settingName === 'string' && settingName.indexOf('colors') > -1) { - options.formatSelection = this._select2FormatSelection; - } - - var self = this; - this.$("input").select2(options).on("change", function(obj) { - self.set("settingValue", obj.val.join("|")); - self.refreshSortables(); - }); - - this.refreshSortables(); - }.on('didInsertElement'), - - refreshOnReset: function() { - this.$("input").select2("val", this.get("settingValue").split("|")); - }.observes("settingValue"), - - refreshSortables: function() { - var self = this; - this.$("ul.select2-choices").sortable().on('sortupdate', function() { - self.$("input").select2("onSortEnd"); - }); - } -}); - - diff --git a/app/assets/javascripts/admin/components/moderation-history-item.js.es6 b/app/assets/javascripts/admin/components/moderation-history-item.js.es6 new file mode 100644 index 0000000000..b8674a8aaf --- /dev/null +++ b/app/assets/javascripts/admin/components/moderation-history-item.js.es6 @@ -0,0 +1,3 @@ +export default Ember.Component.extend({ + tagName: 'tr', +}); diff --git a/app/assets/javascripts/admin/components/permalink-form.js.es6 b/app/assets/javascripts/admin/components/permalink-form.js.es6 index 2bde845c0c..90dcf2a7b2 100644 --- a/app/assets/javascripts/admin/components/permalink-form.js.es6 +++ b/app/assets/javascripts/admin/components/permalink-form.js.es6 @@ -1,3 +1,5 @@ +import Permalink from 'admin/models/permalink'; + export default Ember.Component.extend({ classNames: ['permalink-form'], formSubmitted: false, @@ -18,8 +20,6 @@ export default Ember.Component.extend({ actions: { submit: function() { - const Permalink = require('admin/models/permalink').default; - if (!this.get('formSubmitted')) { const self = this; self.set('formSubmitted', true); diff --git a/app/assets/javascripts/admin/components/resumable-upload.js.es6 b/app/assets/javascripts/admin/components/resumable-upload.js.es6 index 73a46eded3..36f420d100 100644 --- a/app/assets/javascripts/admin/components/resumable-upload.js.es6 +++ b/app/assets/javascripts/admin/components/resumable-upload.js.es6 @@ -1,3 +1,4 @@ +import { iconHTML } from 'discourse-common/lib/icon-library'; import { bufferedRender } from 'discourse-common/lib/buffered-render'; /*global Resumable:true */ @@ -40,7 +41,7 @@ export default Ember.Component.extend(bufferedRender({ buildBuffer(buffer) { const icon = this.get("isUploading") ? "times" : "upload"; - buffer.push(``); + buffer.push(iconHTML(icon)); buffer.push("" + this.get("text") + ""); buffer.push(""); }, diff --git a/app/assets/javascripts/admin/components/site-setting.js.es6 b/app/assets/javascripts/admin/components/site-setting.js.es6 index 662e4ef684..73e8160a28 100644 --- a/app/assets/javascripts/admin/components/site-setting.js.es6 +++ b/app/assets/javascripts/admin/components/site-setting.js.es6 @@ -2,6 +2,7 @@ import BufferedContent from 'discourse/mixins/buffered-content'; import SiteSetting from 'admin/models/site-setting'; import { propertyNotEqual } from 'discourse/lib/computed'; import computed from 'ember-addons/ember-computed-decorators'; +import { categoryLinkHTML } from 'discourse/helpers/category-link'; const CustomTypes = ['bool', 'enum', 'list', 'url_list', 'host_list', 'category_list', 'value_list']; @@ -11,8 +12,19 @@ export default Ember.Component.extend(BufferedContent, { dirty: propertyNotEqual('buffered.value', 'setting.value'), validationMessage: null, - @computed("setting.preview", "buffered.value") - preview(preview, value) { + @computed("setting", "buffered.value") + preview(setting, value) { + // A bit hacky, but allows us to use helpers + if (setting.get('setting') === 'category_style') { + let category = this.site.get('categories.firstObject'); + if (category) { + return categoryLinkHTML(category, { + categoryStyle: value + }); + } + } + + let preview = setting.get('preview'); if (preview) { return new Handlebars.SafeString("
" + preview.replace(/\{\{value\}\}/g, value) + "
"); } @@ -52,16 +64,16 @@ export default Ember.Component.extend(BufferedContent, { }.on("willDestroyElement"), _save() { - const self = this, - setting = this.get('buffered'); - SiteSetting.update(setting.get('setting'), setting.get('value')).then(function() { - self.set('validationMessage', null); - self.commitBuffer(); - }).catch(function(e) { + const setting = this.get('buffered'), + action = SiteSetting.update(setting.get('setting'), setting.get('value')); + action.then(() => { + this.set('validationMessage', null); + this.commitBuffer(); + }).catch((e) => { if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors) { - self.set('validationMessage', e.jqXHR.responseJSON.errors[0]); + this.set('validationMessage', e.jqXHR.responseJSON.errors[0]); } else { - self.set('validationMessage', I18n.t('generic_error')); + this.set('validationMessage', I18n.t('generic_error')); } }); }, diff --git a/app/assets/javascripts/admin/components/watched-word-form.js.es6 b/app/assets/javascripts/admin/components/watched-word-form.js.es6 new file mode 100644 index 0000000000..1e187bcd53 --- /dev/null +++ b/app/assets/javascripts/admin/components/watched-word-form.js.es6 @@ -0,0 +1,55 @@ +import WatchedWord from 'admin/models/watched-word'; +import { default as computed, on, observes } from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + classNames: ['watched-word-form'], + formSubmitted: false, + actionKey: null, + showSuccessMessage: false, + + @computed('regularExpressions') + placeholderKey(regularExpressions) { + return "admin.watched_words.form.placeholder" + + (regularExpressions ? "_regexp" : ""); + }, + + @observes('word') + removeSuccessMessage() { + if (this.get('showSuccessMessage') && !Ember.isEmpty(this.get('word'))) { + this.set('showSuccessMessage', false); + } + }, + + actions: { + submit() { + if (!this.get('formSubmitted')) { + this.set('formSubmitted', true); + + const watchedWord = WatchedWord.create({ word: this.get('word'), action: this.get('actionKey') }); + + watchedWord.save().then(result => { + this.setProperties({ word: '', formSubmitted: false, showSuccessMessage: true }); + this.sendAction('action', WatchedWord.create(result)); + Ember.run.schedule('afterRender', () => this.$('.watched-word-input').focus()); + }).catch(e => { + this.set('formSubmitted', false); + const msg = (e.responseJSON && e.responseJSON.errors) ? + I18n.t("generic_error_with_reason", {error: e.responseJSON.errors.join('. ')}) : + I18n.t("generic_error"); + bootbox.alert(msg, () => this.$('.watched-word-input').focus()); + }); + } + } + }, + + @on("didInsertElement") + _init() { + Ember.run.schedule('afterRender', () => { + this.$('.watched-word-input').keydown(e => { + if (e.keyCode === 13) { + this.send('submit'); + } + }); + }); + } +}); diff --git a/app/assets/javascripts/admin/components/watched-word-uploader.js.es6 b/app/assets/javascripts/admin/components/watched-word-uploader.js.es6 new file mode 100644 index 0000000000..8d9e6ba0a9 --- /dev/null +++ b/app/assets/javascripts/admin/components/watched-word-uploader.js.es6 @@ -0,0 +1,25 @@ +import computed from "ember-addons/ember-computed-decorators"; +import UploadMixin from "discourse/mixins/upload"; + +export default Em.Component.extend(UploadMixin, { + type: 'csv', + classNames: 'watched-words-uploader', + uploadUrl: '/admin/logs/watched_words/upload', + addDisabled: Em.computed.alias("uploading"), + + validateUploadedFilesOptions() { + return { csvOnly: true }; + }, + + @computed('actionKey') + data(actionKey) { + return { action_key: actionKey }; + }, + + uploadDone() { + if (this) { + bootbox.alert(I18n.t("admin.watched_words.form.upload_successful")); + this.sendAction("done"); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-email-templates-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-email-templates-edit.js.es6 index 1c8eb6b086..4c5a2a2854 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-email-templates-edit.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-email-templates-edit.js.es6 @@ -15,6 +15,7 @@ export default Ember.Controller.extend(bufferedProperty('emailTemplate'), { actions: { saveChanges() { + this.set('saved', false); const buffered = this.get('buffered'); this.get('emailTemplate').save(buffered.getProperties('subject', 'body')).then(() => { this.set('saved', true); diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 index 2e39c15880..d06b8dc06e 100644 --- a/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 @@ -7,7 +7,7 @@ import computed from 'ember-addons/ember-computed-decorators'; const PROBLEMS_CHECK_MINUTES = 1; -const ATTRIBUTES = [ 'disk_space','admins', 'moderators', 'blocked', 'suspended', 'top_traffic_sources', +const ATTRIBUTES = [ 'disk_space','admins', 'moderators', 'silenced', 'suspended', 'top_traffic_sources', 'top_referred_topics', 'updated_at']; const REPORTS = [ 'global_reports', 'page_view_reports', 'private_message_reports', 'http_reports', diff --git a/app/assets/javascripts/admin/controllers/admin-email-preview-digest.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-preview-digest.js.es6 index 8065d732fc..eb03301c92 100644 --- a/app/assets/javascripts/admin/controllers/admin-email-preview-digest.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-email-preview-digest.js.es6 @@ -2,11 +2,13 @@ import EmailPreview from 'admin/models/email-preview'; import { popupAjaxError } from 'discourse/lib/ajax-error'; export default Ember.Controller.extend({ + username: null, + lastSeen: null, - emailEmpty: Em.computed.empty('email'), - sendEmailDisabled: Em.computed.or('emailEmpty', 'sendingEmail'), - showSendEmailForm: Em.computed.notEmpty('model.html_content'), - htmlEmpty: Em.computed.empty('model.html_content'), + emailEmpty: Ember.computed.empty('email'), + sendEmailDisabled: Ember.computed.or('emailEmpty', 'sendingEmail'), + showSendEmailForm: Ember.computed.notEmpty('model.html_content'), + htmlEmpty: Ember.computed.empty('model.html_content'), actions: { refresh() { @@ -14,7 +16,14 @@ export default Ember.Controller.extend({ this.set('loading', true); this.set('sentEmail', false); - EmailPreview.findDigest(this.get('lastSeen'), this.get('username')).then(email => { + + let username = this.get('username'); + if (!username) { + username = this.currentUser.get('username'); + this.set('username', username); + } + + EmailPreview.findDigest(username, this.get('lastSeen')).then(email => { model.setProperties(email.getProperties('html_content', 'text_content')); this.set('loading', false); }); @@ -28,16 +37,14 @@ export default Ember.Controller.extend({ this.set('sendingEmail', true); this.set('sentEmail', false); - const self = this; - - EmailPreview.sendDigest(this.get('lastSeen'), this.get('username'), this.get('email')).then(result => { + EmailPreview.sendDigest(this.get('username'), this.get('lastSeen'), this.get('email')).then(result => { if (result.errors) { bootbox.alert(result.errors); } else { - self.set('sentEmail', true); + this.set('sentEmail', true); } - }).catch(popupAjaxError).finally(function() { - self.set('sendingEmail', false); + }).catch(popupAjaxError).finally(() => { + this.set('sendingEmail', false); }); } } diff --git a/app/assets/javascripts/admin/controllers/admin-flags-list.js.es6 b/app/assets/javascripts/admin/controllers/admin-flags-list.js.es6 deleted file mode 100644 index e3101e2fa3..0000000000 --- a/app/assets/javascripts/admin/controllers/admin-flags-list.js.es6 +++ /dev/null @@ -1,41 +0,0 @@ -import FlaggedPost from 'admin/models/flagged-post'; - -export default Ember.Controller.extend({ - query: null, - - adminOldFlagsView: Em.computed.equal("query", "old"), - adminActiveFlagsView: Em.computed.equal("query", "active"), - - actions: { - disagreeFlags(flaggedPost) { - flaggedPost.disagreeFlags().then(() => { - this.get('model').removeObject(flaggedPost); - }, function () { - bootbox.alert(I18n.t("admin.flags.error")); - }); - }, - - deferFlags(flaggedPost) { - flaggedPost.deferFlags().then(() => { - this.get('model').removeObject(flaggedPost); - }, function () { - bootbox.alert(I18n.t("admin.flags.error")); - }); - }, - - doneTopicFlags(item) { - this.send("disagreeFlags", item); - }, - - loadMore() { - const flags = this.get('model'); - return FlaggedPost.findAll(this.get('query'), flags.length+1).then(data => { - if (data.length===0) { - flags.set("allLoaded",true); - } - flags.addObjects(data); - }); - } - } - -}); diff --git a/app/assets/javascripts/admin/controllers/admin-group.js.es6 b/app/assets/javascripts/admin/controllers/admin-group.js.es6 index a0f4961b62..cab20f908f 100644 --- a/app/assets/javascripts/admin/controllers/admin-group.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-group.js.es6 @@ -15,6 +15,15 @@ export default Ember.Controller.extend({ ]; }.property(), + visibilityLevelOptions: function() { + return [ + { name: I18n.t("groups.visibility_levels.public"), value: 0 }, + { name: I18n.t("groups.visibility_levels.members"), value: 1 }, + { name: I18n.t("groups.visibility_levels.staff"), value: 2 }, + { name: I18n.t("groups.visibility_levels.owners"), value: 3 } + ]; + }.property(), + trustLevelOptions: function() { return [ { name: I18n.t("groups.trust_levels.none"), value: 0 }, @@ -22,14 +31,16 @@ export default Ember.Controller.extend({ ]; }.property(), - @computed('model.visible', 'model.public') - disableMembershipRequestSetting(visible, publicGroup) { - return !visible || publicGroup; + @computed('model.visibility_level', 'model.public_admission') + disableMembershipRequestSetting(visibility_level, publicAdmission) { + visibility_level = parseInt(visibility_level); + return (visibility_level !== 0) || publicAdmission; }, - @computed('model.visible', 'model.allow_membership_requests') - disablePublicSetting(visible, allowMembershipRequests) { - return !visible || allowMembershipRequests; + @computed('model.visibility_level', 'model.allow_membership_requests') + disablePublicSetting(visibility_level, allowMembershipRequests) { + visibility_level = parseInt(visibility_level); + return (visibility_level !== 0) || allowMembershipRequests; }, actions: { diff --git a/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 b/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 index 4899c4b59e..626fe955ce 100644 --- a/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 @@ -30,7 +30,7 @@ export default Ember.Controller.extend({ showInstructions: Ember.computed.gt('model.length', 0), - refresh: function() { + _refresh() { this.set('loading', true); var filters = this.get('filters'), @@ -65,14 +65,18 @@ export default Ember.Controller.extend({ }); }, + scheduleRefresh() { + Ember.run.scheduleOnce('afterRender', this, this._refresh); + }, + resetFilters: function() { this.set('filters', Ember.Object.create()); - this.refresh(); + this.scheduleRefresh(); }.on('init'), _changeFilters: function(props) { this.get('filters').setProperties(props); - this.refresh(); + this.scheduleRefresh(); }, actions: { @@ -91,7 +95,7 @@ export default Ember.Controller.extend({ this._changeFilters(changed); }, - clearAllFilters: function() { + clearAllFilters() { this.set("filterActionId", null); this.resetFilters(); }, diff --git a/app/assets/javascripts/admin/controllers/admin-search-logs-index.js.es6 b/app/assets/javascripts/admin/controllers/admin-search-logs-index.js.es6 new file mode 100644 index 0000000000..83436849fe --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-search-logs-index.js.es6 @@ -0,0 +1,11 @@ +export default Ember.Controller.extend({ + loading: false, + period: "all", + searchType: "all", + + searchTypeOptions: [ + {id: 'all', name: I18n.t('admin.logs.search_logs.types.all_search_types')}, + {id: 'header', name: I18n.t('admin.logs.search_logs.types.header')}, + {id: 'full_page', name: I18n.t('admin.logs.search_logs.types.full_page')} + ] +}); diff --git a/app/assets/javascripts/admin/controllers/admin-search-logs-term.js.es6 b/app/assets/javascripts/admin/controllers/admin-search-logs-term.js.es6 new file mode 100644 index 0000000000..98d061e060 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-search-logs-term.js.es6 @@ -0,0 +1,13 @@ +export default Ember.Controller.extend({ + loading: false, + term: null, + period: "yearly", + searchType: "all", + + searchTypeOptions: [ + {id: 'all', name: I18n.t('admin.logs.search_logs.types.all_search_types')}, + {id: 'header', name: I18n.t('admin.logs.search_logs.types.header')}, + {id: 'full_page', name: I18n.t('admin.logs.search_logs.types.full_page')}, + {id: 'click_through_only', name: I18n.t('admin.logs.search_logs.types.click_through_only')} + ] +}); 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 834ed3912b..266fdbd02c 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 @@ -6,6 +6,7 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; import computed from 'ember-addons/ember-computed-decorators'; export default Ember.Controller.extend(CanCheckEmails, { + adminTools: Ember.inject.service(), editingUsername: false, editingName: false, editingTitle: false, @@ -57,13 +58,22 @@ export default Ember.Controller.extend(CanCheckEmails, { saveTrustLevel() { return this.get("model").saveTrustLevel(); }, restoreTrustLevel() { return this.get("model").restoreTrustLevel(); }, lockTrustLevel(locked) { return this.get("model").lockTrustLevel(locked); }, - unsuspend() { return this.get("model").unsuspend(); }, - unblock() { return this.get("model").unblock(); }, - block() { return this.get("model").block(); }, + unsilence() { return this.get("model").unsilence(); }, + silence() { return this.get("model").silence(); }, deleteAllPosts() { return this.get("model").deleteAllPosts(); }, anonymize() { return this.get('model').anonymize(); }, destroy() { return this.get('model').destroy(); }, + showSuspendModal() { + this.get('adminTools').showSuspendModal(this.get('model')); + }, + unsuspend() { + this.get("model").unsuspend().catch(popupAjaxError); + }, + showSilenceModal() { + this.get('adminTools').showSilenceModal(this.get('model')); + }, + toggleUsernameEdit() { this.set('userUsernameValue', this.get('model.username')); this.toggleProperty('editingUsername'); 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 new file mode 100644 index 0000000000..bd11f15fcf --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-watched-words-action.js.es6 @@ -0,0 +1,65 @@ +import computed from 'ember-addons/ember-computed-decorators'; +import WatchedWord from 'admin/models/watched-word'; + +export default Ember.Controller.extend({ + actionNameKey: null, + adminWatchedWords: Ember.inject.controller(), + showWordsList: Ember.computed.or('adminWatchedWords.filtered', 'adminWatchedWords.showWords'), + + findAction(actionName) { + return (this.get('adminWatchedWords.model') || []).findBy('nameKey', actionName); + }, + + @computed('adminWatchedWords.model', 'actionNameKey') + filteredContent() { + if (!this.get('actionNameKey')) { return []; } + + const a = this.findAction(this.get('actionNameKey')); + return a ? a.words : []; + }, + + @computed('actionNameKey') + actionDescription(actionNameKey) { + return I18n.t('admin.watched_words.action_descriptions.' + actionNameKey); + }, + + actions: { + recordAdded(arg) { + const a = this.findAction(this.get('actionNameKey')); + if (a) { + a.words.unshiftObject(arg); + a.incrementProperty('count'); + Em.run.schedule('afterRender', () => { + // remove from other actions lists + let match = null; + this.get('adminWatchedWords.model').forEach(action => { + if (match) return; + + if (action.nameKey !== this.get('actionNameKey')) { + match = action.words.findBy('id', arg.id); + if (match) { + action.words.removeObject(match); + action.decrementProperty('count'); + } + } + }); + }); + } + }, + + recordRemoved(arg) { + const a = this.findAction(this.get('actionNameKey')); + if (a) { + a.words.removeObject(arg); + a.decrementProperty('count'); + } + }, + + uploadComplete() { + WatchedWord.findAll().then(data => { + this.set('adminWatchedWords.model', data); + }); + } + } + +}); diff --git a/app/assets/javascripts/admin/controllers/admin-watched-words.js.es6 b/app/assets/javascripts/admin/controllers/admin-watched-words.js.es6 new file mode 100644 index 0000000000..dc4428fc72 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-watched-words.js.es6 @@ -0,0 +1,52 @@ +import debounce from 'discourse/lib/debounce'; + +export default Ember.Controller.extend({ + filter: null, + filtered: false, + showWords: false, + disableShowWords: Ember.computed.alias('filtered'), + regularExpressions: null, + + filterContentNow() { + + if (!!Ember.isEmpty(this.get('allWatchedWords'))) return; + + let filter; + if (this.get('filter')) { + filter = this.get('filter').toLowerCase(); + } + + if (filter === undefined || filter.length < 1) { + this.set('model', this.get('allWatchedWords')); + return; + } + + const matchesByAction = []; + + this.get('allWatchedWords').forEach(wordsForAction => { + const wordRecords = wordsForAction.words.filter(wordRecord => { + return (wordRecord.word.indexOf(filter) > -1); + }); + matchesByAction.pushObject( Ember.Object.create({ + nameKey: wordsForAction.nameKey, + name: wordsForAction.name, + words: wordRecords, + count: wordRecords.length + }) ); + }); + + this.set('model', matchesByAction); + }, + + filterContent: debounce(function() { + this.filterContentNow(); + this.set('filtered', !Ember.isEmpty(this.get('filter'))); + }, 250).observes('filter'), + + actions: { + clearFilter() { + this.setProperties({ filter: '' }); + } + } + +}); diff --git a/app/assets/javascripts/admin/controllers/admin.js.es6 b/app/assets/javascripts/admin/controllers/admin.js.es6 index 3bdfbe6b50..0e5d3c6b50 100644 --- a/app/assets/javascripts/admin/controllers/admin.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin.js.es6 @@ -1,5 +1,20 @@ +import computed from 'ember-addons/ember-computed-decorators'; + export default Ember.Controller.extend({ - showBadges: function() { - return this.get('currentUser.admin') && this.siteSettings.enable_badges; - }.property() + application: Ember.inject.controller(), + + @computed + showBadges() { + return this.currentUser.get('admin') && this.siteSettings.enable_badges; + }, + + @computed('application.currentPath') + adminContentsClassName(currentPath) { + return currentPath.split('.').filter(segment => { + return segment !== 'index' && + segment !== 'loading' && + segment !== 'show' && + segment !== 'admin'; + }).map(Ember.String.dasherize).join(' '); + } }); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 index 3d771a91e6..7bbdaa3de5 100644 --- a/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 +++ b/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 @@ -16,16 +16,17 @@ export default Ember.Controller.extend(ModalFunctionality, { @computed('name') nameValid(name) { - return name && name.match(/^[a-zA-Z0-9-_]+$/); + return name && name.match(/^[a-z_][a-z0-9_-]*$/i); }, @observes('name') - uploadChanged(){ - let file = $('#file-input')[0]; + uploadChanged() { + const file = $('#file-input')[0]; this.set('fileSelected', file && file.files[0]); }, actions: { + updateName() { let name = this.get('name'); if (Em.isEmpty(name)) { @@ -34,20 +35,21 @@ export default Ember.Controller.extend(ModalFunctionality, { } this.uploadChanged(); }, - upload() { - let options = { - type: 'POST' + upload() { + const file = $('#file-input')[0].files[0]; + + const options = { + type: 'POST', + processData: false, + contentType: false, + data: new FormData() }; - options.processData = false; - options.contentType = false; - options.data = new FormData(); - let file = $('#file-input')[0].files[0]; options.data.append('file', file); - ajax('/admin/themes/upload_asset', options).then(result=>{ - let upload = { + ajax('/admin/themes/upload_asset', options).then(result => { + const upload = { upload_id: result.upload_id, name: this.get('name'), original_filename: file.name @@ -57,7 +59,6 @@ export default Ember.Controller.extend(ModalFunctionality, { }).catch(e => { popupAjaxError(e); }); - } } }); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-agree-flag.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-agree-flag.js.es6 deleted file mode 100644 index 7f1f4f2e49..0000000000 --- a/app/assets/javascripts/admin/controllers/modals/admin-agree-flag.js.es6 +++ /dev/null @@ -1,24 +0,0 @@ -import ModalFunctionality from 'discourse/mixins/modal-functionality'; - -export default Ember.Controller.extend(ModalFunctionality, { - adminFlagsList: Ember.inject.controller(), - - _agreeFlag: function (actionOnPost) { - const adminFlagController = this.get("adminFlagsList"); - const post = this.get("content"); - - return post.agreeFlags(actionOnPost).then(() => { - adminFlagController.get('model').removeObject(post); - this.send("closeModal"); - }, function () { - bootbox.alert(I18n.t("admin.flags.error")); - }); - }, - - actions: { - agreeFlagHidePost: function () { return this._agreeFlag("hide"); }, - agreeFlagKeepPost: function () { return this._agreeFlag("keep"); }, - agreeFlagRestorePost: function () { return this._agreeFlag("restore"); } - } - -}); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-delete-flag.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-delete-flag.js.es6 deleted file mode 100644 index fc2e062794..0000000000 --- a/app/assets/javascripts/admin/controllers/modals/admin-delete-flag.js.es6 +++ /dev/null @@ -1,31 +0,0 @@ -import ModalFunctionality from 'discourse/mixins/modal-functionality'; - -export default Ember.Controller.extend(ModalFunctionality, { - adminFlagsList: Ember.inject.controller(), - - actions: { - deletePostDeferFlag() { - const adminFlagController = this.get("adminFlagsList"); - const post = this.get("content"); - - return post.deferFlags(true).then(() => { - adminFlagController.get('model').removeObject(post); - this.send("closeModal"); - }, function () { - bootbox.alert(I18n.t("admin.flags.error")); - }); - }, - - deletePostAgreeFlag() { - const adminFlagController = this.get("adminFlagsList"); - const post = this.get("content"); - - return post.agreeFlags("delete").then(() => { - adminFlagController.get('model').removeObject(post); - this.send("closeModal"); - }, function () { - bootbox.alert(I18n.t("admin.flags.error")); - }); - } - } -}); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-moderation-history.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-moderation-history.js.es6 new file mode 100644 index 0000000000..ee017eae88 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/modals/admin-moderation-history.js.es6 @@ -0,0 +1,18 @@ +import ModalFunctionality from 'discourse/mixins/modal-functionality'; + +export default Ember.Controller.extend(ModalFunctionality, { + loading: null, + historyTarget: null, + history: null, + + onShow() { + this.set('loading', true); + this.set('history', null); + }, + + loadHistory(target) { + this.store.findAll('moderation-history', target).then(result => { + this.set('history', result); + }).finally(() => this.set('loading', false)); + } +}); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 new file mode 100644 index 0000000000..9f1aef916f --- /dev/null +++ b/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 @@ -0,0 +1,50 @@ +import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import computed from 'ember-addons/ember-computed-decorators'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; + +export default Ember.Controller.extend(ModalFunctionality, { + silenceUntil: null, + reason: null, + message: null, + silencing: false, + user: null, + post: null, + successCallback: null, + + onShow() { + this.setProperties({ + silenceUntil: null, + reason: null, + message: null, + silencing: false, + loadingUser: true, + post: null, + successCallback: null, + }); + }, + + @computed('silenceUntil', 'reason', 'silencing') + submitDisabled(silenceUntil, reason, silencing) { + return (silencing || Ember.isEmpty(silenceUntil) || !reason || reason.length < 1); + }, + + actions: { + silence() { + if (this.get('submitDisabled')) { return; } + + this.set('silencing', true); + this.get('user').silence({ + silenced_till: this.get('silenceUntil'), + reason: this.get('reason'), + message: this.get('message'), + post_id: this.get('post.id') + }).then(result => { + this.send('closeModal'); + let callback = this.get('successCallback'); + if (callback) { + callback(result); + } + }).catch(popupAjaxError).finally(() => this.set('silencing', false)); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6 index d3e19de569..efcd142670 100644 --- a/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6 +++ b/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6 @@ -1,25 +1,50 @@ import ModalFunctionality from 'discourse/mixins/modal-functionality'; +import computed from 'ember-addons/ember-computed-decorators'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; export default Ember.Controller.extend(ModalFunctionality, { + suspendUntil: null, + reason: null, + message: null, + suspending: false, + user: null, + post: null, + successCallback: null, - submitDisabled: function() { - return (!this.get('reason') || this.get('reason').length < 1); - }.property('reason'), + onShow() { + this.setProperties({ + suspendUntil: null, + reason: null, + message: null, + suspending: false, + loadingUser: true, + post: null, + successCallback: null, + }); + }, + + @computed('suspendUntil', 'reason', 'suspending') + submitDisabled(suspendUntil, reason, suspending) { + return (suspending || Ember.isEmpty(suspendUntil) || !reason || reason.length < 1); + }, actions: { - suspend: function() { - if (this.get('submitDisabled')) return; - var duration = parseInt(this.get('duration'), 10); - if (duration > 0) { - var self = this; - this.send('hideModal'); - this.get('model').suspend(duration, this.get('reason')).then(function() { - window.location.reload(); - }, function(e) { - var error = I18n.t('admin.user.suspend_failed', { error: "http: " + e.status + " - " + e.body }); - bootbox.alert(error, function() { self.send('reopenModal'); }); - }); - } + suspend() { + if (this.get('submitDisabled')) { return; } + + this.set('suspending', true); + this.get('user').suspend({ + suspend_until: this.get('suspendUntil'), + reason: this.get('reason'), + message: this.get('message'), + post_id: this.get('post.id') + }).then(result => { + this.send('closeModal'); + let callback = this.get('successCallback'); + if (callback) { + callback(result); + } + }).catch(popupAjaxError).finally(() => this.set('suspending', false)); } } diff --git a/app/assets/javascripts/admin/helpers/check-icon.js.es6 b/app/assets/javascripts/admin/helpers/check-icon.js.es6 new file mode 100644 index 0000000000..0a68c0be0b --- /dev/null +++ b/app/assets/javascripts/admin/helpers/check-icon.js.es6 @@ -0,0 +1,7 @@ +import { registerUnbound } from 'discourse-common/lib/helpers'; +import { renderIcon } from 'discourse-common/lib/icon-library'; + +registerUnbound('check-icon', function(value) { + let icon = value ? "check" : "times"; + return new Handlebars.SafeString(renderIcon('string', icon)); +}); diff --git a/app/assets/javascripts/admin/helpers/disposition-icon.js.es6 b/app/assets/javascripts/admin/helpers/disposition-icon.js.es6 new file mode 100644 index 0000000000..60473dc18b --- /dev/null +++ b/app/assets/javascripts/admin/helpers/disposition-icon.js.es6 @@ -0,0 +1,15 @@ +import { iconHTML } from 'discourse-common/lib/icon-library'; + +export default Ember.Helper.extend({ + compute([disposition]) { + if (!disposition) { return null; } + let icon; + let title = 'admin.flags.dispositions.' + disposition; + switch (disposition) { + case "deferred": { icon = "external-link"; break; } + case "agreed": { icon = "thumbs-o-up"; break; } + case "disagreed": { icon = "thumbs-o-down"; break; } + } + return iconHTML(icon, { title }).htmlSafe(); + } +}); diff --git a/app/assets/javascripts/admin/helpers/post-action-title.js.es6 b/app/assets/javascripts/admin/helpers/post-action-title.js.es6 new file mode 100644 index 0000000000..ced180d8bb --- /dev/null +++ b/app/assets/javascripts/admin/helpers/post-action-title.js.es6 @@ -0,0 +1,12 @@ +function postActionTitle([id, nameKey]) { + let title = I18n.t(`admin.flags.short_names.${nameKey}`, { defaultValue: null }); + + // TODO: We can remove this once other translations have been updated + if (!title) { + return I18n.t(`admin.flags.summary.action_type_${id}`, { count: 1 }); + } + + return title; +} + +export default Ember.Helper.helper(postActionTitle); diff --git a/app/assets/javascripts/admin/models/admin-user.js.es6 b/app/assets/javascripts/admin/models/admin-user.js.es6 index 0720976e69..e3f1530bad 100644 --- a/app/assets/javascripts/admin/models/admin-user.js.es6 +++ b/app/assets/javascripts/admin/models/admin-user.js.es6 @@ -1,3 +1,4 @@ +import { iconHTML } from 'discourse-common/lib/icon-library'; import { ajax } from 'discourse/lib/ajax'; import computed from 'ember-addons/ember-computed-decorators'; import { propertyNotEqual } from 'discourse/lib/computed'; @@ -7,8 +8,10 @@ import Group from 'discourse/models/group'; import TL3Requirements from 'admin/models/tl3-requirements'; import { userPath } from 'discourse/lib/url'; -const AdminUser = Discourse.User.extend({ +const wrapAdmin = user => user ? AdminUser.create(user) : null; +const AdminUser = Discourse.User.extend({ + adminUserView: true, customGroups: Ember.computed.filter("groups", g => !g.automatic && Group.create(g)), automaticGroups: Ember.computed.filter("groups", g => g.automatic && Group.create(g)), @@ -105,10 +108,10 @@ const AdminUser = Discourse.User.extend({ message = I18n.messageFormat('admin.user.delete_all_posts_confirm_MF', { "POSTS": user.get('post_count'), "TOPICS": user.get('topic_count') }), buttons = [{ "label": I18n.t("composer.cancel"), - "class": "cancel-inline", + "class": "d-modal-cancel", "link": true }, { - "label": ' ' + I18n.t("admin.user.delete_all_posts"), + "label": `${iconHTML('exclamation-triangle')} ` + I18n.t("admin.user.delete_all_posts"), "class": "btn btn-danger", "callback": function() { ajax("/admin/users/" + user.get('id') + "/delete_all_posts", { @@ -231,6 +234,7 @@ const AdminUser = Discourse.User.extend({ }.property('trust_level'), isSuspended: Em.computed.equal('suspended', true), + isSilenced: Ember.computed.equal('silenced', true), canSuspend: Em.computed.not('staff'), suspendDuration: function() { @@ -239,22 +243,17 @@ const AdminUser = Discourse.User.extend({ return suspended_at.format('L') + " - " + suspended_till.format('L'); }.property('suspended_till', 'suspended_at'), - suspend(duration, reason) { - return ajax("/admin/users/" + this.id + "/suspend", { + suspend(data) { + return ajax(`/admin/users/${this.id}/suspend`, { type: 'PUT', - data: { duration: duration, reason: reason } - }); + data + }).then(result => this.setProperties(result.suspension)); }, unsuspend() { - return ajax("/admin/users/" + this.id + "/unsuspend", { + return ajax(`/admin/users/${this.id}/unsuspend`, { type: 'PUT' - }).then(function() { - window.location.reload(); - }).catch(function(e) { - var error = I18n.t('admin.user.unsuspend_failed', { error: "http: " + e.status + " - " + e.body }); - bootbox.alert(error); - }); + }).then(result => this.setProperties(result.suspension)); }, logOut() { @@ -303,46 +302,38 @@ const AdminUser = Discourse.User.extend({ }); }, - unblock() { - this.set('blockingUser', true); - return ajax('/admin/users/' + this.id + '/unblock', { + unsilence() { + this.set('silencingUser', true); + + return ajax(`/admin/users/${this.id}/unsilence`, { type: 'PUT' - }).then(function() { - window.location.reload(); - }).catch(function(e) { - var error = I18n.t('admin.user.unblock_failed', { error: "http: " + e.status + " - " + e.body }); + }).then(result => { + this.setProperties(result.unsilence); + }).catch(e => { + let error = I18n.t('admin.user.unsilence_failed', { + error: `http: ${e.status} - ${e.body}` + }); bootbox.alert(error); + }).finally(() => { + this.set('silencingUser', false); }); }, - block() { - const user = this, - message = I18n.t("admin.user.block_confirm"); - - const performBlock = function() { - user.set('blockingUser', true); - return ajax('/admin/users/' + user.id + '/block', { - type: 'PUT' - }).then(function() { - window.location.reload(); - }).catch(function(e) { - var error = I18n.t('admin.user.block_failed', { error: "http: " + e.status + " - " + e.body }); - bootbox.alert(error); - user.set('blockingUser', false); + silence(data) { + this.set('silencingUser', true); + return ajax(`/admin/users/${this.id}/silence`, { + type: 'PUT', + data + }).then(result => { + this.setProperties(result.silence); + }).catch(e => { + let error = I18n.t('admin.user.silence_failed', { + error: `http: ${e.status} - ${e.body}` }); - }; - - const buttons = [{ - "label": I18n.t("composer.cancel"), - "class": "cancel", - "link": true - }, { - "label": '' + I18n.t('admin.user.block_accept'), - "class": "btn btn-danger", - "callback": function() { performBlock(); } - }]; - - bootbox.dialog(message, buttons, { "classes": "delete-user-modal" }); + bootbox.alert(error); + }).finally(() => { + this.set('silencingUser', false); + }); }, sendActivationEmail() { @@ -386,7 +377,7 @@ const AdminUser = Discourse.User.extend({ "class": "cancel", "link": true }, { - "label": '' + I18n.t('admin.user.anonymize_yes'), + "label": `${iconHTML('exclamation-triangle')} ` + I18n.t('admin.user.anonymize_yes'), "class": "btn btn-danger", "callback": function() { performAnonymize(); } }]; @@ -450,7 +441,7 @@ const AdminUser = Discourse.User.extend({ "class": "btn", "link": true }, { - "label": '' + I18n.t('admin.user.delete_and_block'), + "label": `${iconHTML('exclamation-triangle')} ` + I18n.t('admin.user.delete_and_block'), "class": "btn btn-danger", "callback": function(){ performDestroy(true); } }, { @@ -462,52 +453,6 @@ const AdminUser = Discourse.User.extend({ bootbox.dialog(message, buttons, { "classes": "delete-user-modal" }); }, - deleteAsSpammer(successCallback) { - const user = this; - - user.checkEmail().then(function() { - const data = { - "POSTS": user.get('post_count'), - "TOPICS": user.get('topic_count'), - email: user.get('email') || I18n.t("flagging.hidden_email_address"), - ip_address: user.get('ip_address') || I18n.t("flagging.ip_address_missing") - }; - - const message = I18n.messageFormat('flagging.delete_confirm_MF', data), - buttons = [{ - "label": I18n.t("composer.cancel"), - "class": "cancel-inline", - "link": true - }, { - "label": ' ' + I18n.t("flagging.yes_delete_spammer"), - "class": "btn btn-danger", - "callback": function() { - return ajax("/admin/users/" + user.get('id') + '.json', { - type: 'DELETE', - data: { - delete_posts: true, - block_email: true, - block_urls: true, - block_ip: true, - delete_as_spammer: true, - context: window.location.pathname - } - }).then(function(result) { - if (result.deleted) { - if (successCallback) successCallback(); - } else { - bootbox.alert(I18n.t("admin.user.delete_failed")); - } - }).catch(function() { - bootbox.alert(I18n.t("admin.user.delete_failed")); - }); - } - }]; - - bootbox.dialog(message, buttons, {"classes": "flagging-delete-spammer"}); - }); - }, - loadDetails() { const user = this; @@ -525,17 +470,14 @@ const AdminUser = Discourse.User.extend({ } }.property('tl3_requirements'), - suspendedBy: function() { - if (this.get('suspended_by')) { - return AdminUser.create(this.get('suspended_by')); - } - }.property('suspended_by'), + @computed('suspended_by') + suspendedBy: wrapAdmin, - approvedBy: function() { - if (this.get('approved_by')) { - return AdminUser.create(this.get('approved_by')); - } - }.property('approved_by') + @computed('silenced_by') + silencedBy: wrapAdmin, + + @computed('approved_by') + approvedBy: wrapAdmin, }); diff --git a/app/assets/javascripts/admin/models/api-key.js.es6 b/app/assets/javascripts/admin/models/api-key.js.es6 index 2a7cf86778..024a143392 100644 --- a/app/assets/javascripts/admin/models/api-key.js.es6 +++ b/app/assets/javascripts/admin/models/api-key.js.es6 @@ -1,4 +1,6 @@ +import AdminUser from 'admin/models/admin-user'; import { ajax } from 'discourse/lib/ajax'; + const ApiKey = Discourse.Model.extend({ /** @@ -36,8 +38,7 @@ ApiKey.reopenClass({ @param {...} var_args the properties to initialize this with @returns {ApiKey} the ApiKey instance **/ - create: function() { - const AdminUser = require('admin/models/admin-user').default; + create() { var result = this._super.apply(this, arguments); if (result.user) { result.user = AdminUser.create(result.user); diff --git a/app/assets/javascripts/admin/models/email-preview.js.es6 b/app/assets/javascripts/admin/models/email-preview.js.es6 index acc7462b92..2aaca02d1e 100644 --- a/app/assets/javascripts/admin/models/email-preview.js.es6 +++ b/app/assets/javascripts/admin/models/email-preview.js.es6 @@ -1,42 +1,24 @@ import { ajax } from 'discourse/lib/ajax'; const EmailPreview = Discourse.Model.extend({}); +export function oneWeekAgo() { + return moment().locale('en').subtract(7, 'days').format('YYYY-MM-DD'); +} + EmailPreview.reopenClass({ - findDigest: function(lastSeenAt, username) { - - if (Em.isEmpty(lastSeenAt)) { - lastSeenAt = this.oneWeekAgo(); - } - - if (Em.isEmpty(username)) { - username = Discourse.User.current().username; - } + findDigest(username, lastSeenAt) { return ajax("/admin/email/preview-digest.json", { - data: { last_seen_at: lastSeenAt, username: username } - }).then(function (result) { - return EmailPreview.create(result); - }); + data: { last_seen_at: lastSeenAt || oneWeekAgo(), username } + }).then(result => EmailPreview.create(result)); }, - sendDigest: function(lastSeenAt, username, email) { - if (Em.isEmpty(lastSeenAt)) { - lastSeenAt = this.oneWeekAgo(); - } - - if (Em.isEmpty(username)) { - username = Discourse.User.current().username; - } - + sendDigest(username, lastSeenAt, email) { return ajax("/admin/email/send-digest.json", { - data: { last_seen_at: lastSeenAt, username: username, email: email } + data: { last_seen_at: lastSeenAt || oneWeekAgo(), username, email } }); }, - oneWeekAgo() { - const en = moment().locale('en'); - return en.subtract(7, 'days').format('YYYY-MM-DD'); - } }); export default EmailPreview; diff --git a/app/assets/javascripts/admin/models/flag-type.js.es6 b/app/assets/javascripts/admin/models/flag-type.js.es6 new file mode 100644 index 0000000000..54ef7f26c1 --- /dev/null +++ b/app/assets/javascripts/admin/models/flag-type.js.es6 @@ -0,0 +1,10 @@ +import RestModel from 'discourse/models/rest'; +import computed from 'ember-addons/ember-computed-decorators'; + +export default RestModel.extend({ + @computed('id') + name(id) { + return I18n.t(`admin.flags.summary.action_type_${id}`, { count: 1}); + } +}); + diff --git a/app/assets/javascripts/admin/models/flagged-post.js.es6 b/app/assets/javascripts/admin/models/flagged-post.js.es6 index c7a654608f..acd3a0e0df 100644 --- a/app/assets/javascripts/admin/models/flagged-post.js.es6 +++ b/app/assets/javascripts/admin/models/flagged-post.js.es6 @@ -1,111 +1,50 @@ import { ajax } from 'discourse/lib/ajax'; -import AdminUser from 'admin/models/admin-user'; -import Topic from 'discourse/models/topic'; import Post from 'discourse/models/post'; +import computed from 'ember-addons/ember-computed-decorators'; +import { popupAjaxError } from 'discourse/lib/ajax-error'; +export default Post.extend({ -const FlaggedPost = Post.extend({ - - summary: function () { + @computed + summary() { return _(this.post_actions) .groupBy(function (a) { return a.post_action_type_id; }) .map(function (v,k) { return I18n.t('admin.flags.summary.action_type_' + k, { count: v.length }); }) .join(','); - }.property(), - - flaggers: function () { - var self = this; - var flaggers = []; - - _.each(this.post_actions, function (postAction) { - flaggers.push({ - user: self.userLookup[postAction.user_id], - topic: self.topicLookup[postAction.topic_id], - flagType: I18n.t('admin.flags.summary.action_type_' + postAction.post_action_type_id, { count: 1 }), - flaggedAt: postAction.created_at, - disposedBy: postAction.disposed_by_id ? self.userLookup[postAction.disposed_by_id] : null, - disposedAt: postAction.disposed_at, - dispositionIcon: self.dispositionIcon(postAction.disposition), - tookAction: postAction.staff_took_action - }); - }); - - return flaggers; - }.property(), - - dispositionIcon: function (disposition) { - if (!disposition) { return null; } - var icon, title = I18n.t('admin.flags.dispositions.' + disposition); - switch (disposition) { - case "deferred": { icon = "fa-external-link"; break; } - case "agreed": { icon = "fa-thumbs-o-up"; break; } - case "disagreed": { icon = "fa-thumbs-o-down"; break; } - } - return ""; }, - wasEdited: function () { + @computed('last_revised_at', 'post_actions.@each.created_at') + wasEdited(lastRevisedAt) { if (Ember.isEmpty(this.get("last_revised_at"))) { return false; } - var lastRevisedAt = Date.parse(this.get("last_revised_at")); + lastRevisedAt = Date.parse(lastRevisedAt); return _.some(this.get("post_actions"), function (postAction) { return Date.parse(postAction.created_at) < lastRevisedAt; }); - }.property("last_revised_at", "post_actions.@each.created_at"), + }, - conversations: function () { - var self = this; - var conversations = []; + @computed('post_actions.@each.name_key') + flaggedForSpam() { + return this.get('post_actions').every(action => action.name_key === 'spam'); + }, - _.each(this.post_actions, function (postAction) { - if (postAction.conversation) { - var conversation = { - permalink: postAction.permalink, - hasMore: postAction.conversation.has_more, - response: { - excerpt: postAction.conversation.response.excerpt, - user: self.userLookup[postAction.conversation.response.user_id] - } - }; - - if (postAction.conversation.reply) { - conversation["reply"] = { - excerpt: postAction.conversation.reply.excerpt, - user: self.userLookup[postAction.conversation.reply.user_id] - }; - } - - conversations.push(conversation); - } - }); - - return conversations; - }.property(), - - user: function() { - return this.userLookup[this.user_id]; - }.property(), - - topic: function () { - return this.topicLookup[this.topic_id]; - }.property(), - - flaggedForSpam: function() { - return !_.every(this.get('post_actions'), function(action) { return action.name_key !== 'spam'; }); - }.property('post_actions.@each.name_key'), - - topicFlagged: function() { + @computed('post_actions.@each.targets_topic') + topicFlagged() { return _.any(this.get('post_actions'), function(action) { return action.targets_topic; }); - }.property('post_actions.@each.targets_topic'), + }, - postAuthorFlagged: function() { + @computed('post_actions.@each.targets_topic') + postAuthorFlagged() { return _.any(this.get('post_actions'), function(action) { return !action.targets_topic; }); - }.property('post_actions.@each.targets_topic'), + }, - canDeleteAsSpammer: function() { - return Discourse.User.currentProp('staff') && this.get('flaggedForSpam') && this.get('user.can_delete_all_posts') && this.get('user.can_be_deleted'); - }.property('flaggedForSpam'), + @computed('flaggedForSpam') + canDeleteAsSpammer(flaggedForSpam) { + return flaggedForSpam && + this.get('user.can_delete_all_posts') && + this.get('user.can_be_deleted'); + }, - deletePost: function() { + deletePost() { if (this.get('post_number') === 1) { return ajax('/t/' + this.topic_id, { type: 'DELETE', cache: false }); } else { @@ -113,64 +52,20 @@ const FlaggedPost = Post.extend({ } }, - disagreeFlags: function () { - return ajax('/admin/flags/disagree/' + this.id, { type: 'POST', cache: false }); + disagreeFlags() { + return ajax('/admin/flags/disagree/' + this.id, { type: 'POST', cache: false }).catch(popupAjaxError); }, - deferFlags: function (deletePost) { - return ajax('/admin/flags/defer/' + this.id, { type: 'POST', cache: false, data: { delete_post: deletePost } }); + deferFlags(deletePost) { + return ajax('/admin/flags/defer/' + this.id, { type: 'POST', cache: false, data: { delete_post: deletePost } }).catch(popupAjaxError); }, - agreeFlags: function (actionOnPost) { - return ajax('/admin/flags/agree/' + this.id, { type: 'POST', cache: false, data: { action_on_post: actionOnPost } }); + agreeFlags(actionOnPost) { + return ajax('/admin/flags/agree/' + this.id, { type: 'POST', cache: false, data: { action_on_post: actionOnPost } }).catch(popupAjaxError); }, - postHidden: Em.computed.alias('hidden'), + postHidden: Ember.computed.alias('hidden'), - extraClasses: function() { - var classes = []; - if (this.get('hidden')) { classes.push('hidden-post'); } - if (this.get('deleted')) { classes.push('deleted'); } - return classes.join(' '); - }.property(), - - deleted: Em.computed.or('deleted_at', 'topic_deleted_at') + deleted: Ember.computed.or('deleted_at', 'topic_deleted_at'), }); - -FlaggedPost.reopenClass({ - findAll: function (filter, offset) { - offset = offset || 0; - - var result = Em.A(); - result.set('loading', true); - - return ajax('/admin/flags/' + filter + '.json?offset=' + offset).then(function (data) { - // users - var userLookup = {}; - _.each(data.users, function (user) { - userLookup[user.id] = AdminUser.create(user); - }); - - // topics - var topicLookup = {}; - _.each(data.topics, function (topic) { - topicLookup[topic.id] = Topic.create(topic); - }); - - // posts - _.each(data.posts, function (post) { - var f = FlaggedPost.create(post); - f.userLookup = userLookup; - f.topicLookup = topicLookup; - result.pushObject(f); - }); - - result.set('loading', false); - - return result; - }); - } -}); - -export default FlaggedPost; diff --git a/app/assets/javascripts/admin/models/site-setting.js.es6 b/app/assets/javascripts/admin/models/site-setting.js.es6 index 5103762663..6ed7878796 100644 --- a/app/assets/javascripts/admin/models/site-setting.js.es6 +++ b/app/assets/javascripts/admin/models/site-setting.js.es6 @@ -48,7 +48,7 @@ SiteSetting.reopenClass({ update(key, value) { const data = {}; data[key] = value; - return ajax("/admin/site_settings/" + key, { type: 'PUT', data }); + return ajax(`/admin/site_settings/${key}`, { type: 'PUT', data }); } }); diff --git a/app/assets/javascripts/admin/models/version-check.js.es6 b/app/assets/javascripts/admin/models/version-check.js.es6 index 75f0d9e04f..ce1f420b49 100644 --- a/app/assets/javascripts/admin/models/version-check.js.es6 +++ b/app/assets/javascripts/admin/models/version-check.js.es6 @@ -8,18 +8,6 @@ const VersionCheck = Discourse.Model.extend({ return updatedAt === null; }, - @computed('updated_at', 'version_check_pending') - dataIsOld(updatedAt, versionCheckPending) { - return versionCheckPending || moment().diff(moment(updatedAt), 'hours') >= 48; - }, - - @computed('dataIsOld', 'installed_version', 'latest_version', 'missing_versions_count') - staleData(dataIsOld, installedVersion, latestVersion, missingVersionsCount) { - return dataIsOld || - (installedVersion !== latestVersion && missingVersionsCount === 0) || - (installedVersion === latestVersion && missingVersionsCount !== 0); - }, - @computed('missing_versions_count') upToDate(missingVersionsCount) { return missingVersionsCount === 0 || missingVersionsCount === null; diff --git a/app/assets/javascripts/admin/models/watched-word.js.es6 b/app/assets/javascripts/admin/models/watched-word.js.es6 new file mode 100644 index 0000000000..f2b1ad8a05 --- /dev/null +++ b/app/assets/javascripts/admin/models/watched-word.js.es6 @@ -0,0 +1,43 @@ +import { ajax } from 'discourse/lib/ajax'; + +const WatchedWord = Discourse.Model.extend({ + save() { + return ajax("/admin/logs/watched_words" + (this.id ? '/' + this.id : '') + ".json", { + type: this.id ? 'PUT' : 'POST', + data: {word: this.get('word'), action_key: this.get('action')}, + dataType: 'json' + }); + }, + + destroy() { + return ajax("/admin/logs/watched_words/" + this.get('id') + ".json", {type: 'DELETE'}); + } +}); + +WatchedWord.reopenClass({ + findAll() { + return ajax("/admin/logs/watched_words").then(list => { + const actions = {}; + list.words.forEach(s => { + if (!actions[s.action]) { actions[s.action] = []; } + actions[s.action].pushObject(WatchedWord.create(s)); + }); + + list.actions.forEach(a => { + if (!actions[a]) { actions[a] = []; } + }); + + return Object.keys(actions).map(n => { + return Ember.Object.create({ + nameKey: n, + name: I18n.t('admin.watched_words.actions.' + n), + words: actions[n], + count: actions[n].length, + regularExpressions: list.regular_expressions + }); + }); + }); + } +}); + +export default WatchedWord; diff --git a/app/assets/javascripts/admin/models/web-hook.js.es6 b/app/assets/javascripts/admin/models/web-hook.js.es6 index 325d931045..27d72878b0 100644 --- a/app/assets/javascripts/admin/models/web-hook.js.es6 +++ b/app/assets/javascripts/admin/models/web-hook.js.es6 @@ -37,7 +37,7 @@ export default RestModel.extend({ }, groupFinder(term) { - return Group.findAll({search: term, ignore_automatic: false}); + return Group.findAll({ term: term, ignore_automatic: false }); }, @computed('wildcard_web_hook', 'web_hook_event_types.[]') @@ -82,4 +82,3 @@ export default RestModel.extend({ return this.createProperties(); } }); - diff --git a/app/assets/javascripts/admin/routes/admin-backups.js.es6 b/app/assets/javascripts/admin/routes/admin-backups.js.es6 index 6d1a425190..92ac4652fa 100644 --- a/app/assets/javascripts/admin/routes/admin-backups.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-backups.js.es6 @@ -68,7 +68,7 @@ export default Discourse.Route.extend({ function(confirmed) { if (confirmed) { backup.destroy().then(function() { - self.controllerFor("adminBackupsIndex").removeObject(backup); + self.controllerFor("adminBackupsIndex").get('model').removeObject(backup); }); } } diff --git a/app/assets/javascripts/admin/routes/admin-email-preview-digest.js.es6 b/app/assets/javascripts/admin/routes/admin-email-preview-digest.js.es6 index 7ca2f72772..0ee7b69755 100644 --- a/app/assets/javascripts/admin/routes/admin-email-preview-digest.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-email-preview-digest.js.es6 @@ -1,16 +1,17 @@ -import EmailPreview from 'admin/models/email-preview'; +import { default as EmailPreview, oneWeekAgo } from 'admin/models/email-preview'; export default Discourse.Route.extend({ model() { - return EmailPreview.findDigest(); + return EmailPreview.findDigest(this.currentUser.get('username')); }, afterModel(model) { const controller = this.controllerFor('adminEmailPreviewDigest'); controller.setProperties({ - model: model, - lastSeen: moment().subtract(7, 'days').format('YYYY-MM-DD'), + model, + username: this.currentUser.get('username'), + lastSeen: oneWeekAgo(), showHtml: true }); } diff --git a/app/assets/javascripts/admin/routes/admin-flags-index.js.es6 b/app/assets/javascripts/admin/routes/admin-flags-index.js.es6 index c3f9a6d373..32b70b9d30 100644 --- a/app/assets/javascripts/admin/routes/admin-flags-index.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-flags-index.js.es6 @@ -1,5 +1,6 @@ export default Discourse.Route.extend({ redirect() { - this.replaceWith('adminFlags.list', 'active'); + let segment = this.siteSettings.flags_default_topics ? 'topics' : 'postsActive'; + this.replaceWith(`adminFlags.${segment}`); } }); diff --git a/app/assets/javascripts/admin/routes/admin-flags-list.js.es6 b/app/assets/javascripts/admin/routes/admin-flags-list.js.es6 deleted file mode 100644 index ba00186d90..0000000000 --- a/app/assets/javascripts/admin/routes/admin-flags-list.js.es6 +++ /dev/null @@ -1,27 +0,0 @@ -import showModal from 'discourse/lib/show-modal'; -import FlaggedPost from 'admin/models/flagged-post'; - -export default Discourse.Route.extend({ - model(params) { - this.filter = params.filter; - return FlaggedPost.findAll(params.filter); - }, - - setupController(controller, model) { - controller.set('model', model); - controller.set('query', this.filter); - }, - - actions: { - showAgreeFlagModal(model) { - showModal('admin-agree-flag', { model, admin: true }); - this.controllerFor('modal').set('modalClass', 'agree-flag-modal'); - }, - - showDeleteFlagModal(model) { - showModal('admin-delete-flag', { model, admin: true }); - this.controllerFor('modal').set('modalClass', 'delete-flag-modal'); - } - - } -}); diff --git a/app/assets/javascripts/admin/routes/admin-flags-posts-active.js.es6 b/app/assets/javascripts/admin/routes/admin-flags-posts-active.js.es6 new file mode 100644 index 0000000000..6c8b797603 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-flags-posts-active.js.es6 @@ -0,0 +1,5 @@ +export default Discourse.Route.extend({ + model() { + return this.store.findAll('flagged-post', { filter: 'active' }); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-flags-posts-old.js.es6 b/app/assets/javascripts/admin/routes/admin-flags-posts-old.js.es6 new file mode 100644 index 0000000000..f6743f4857 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-flags-posts-old.js.es6 @@ -0,0 +1,5 @@ +export default Discourse.Route.extend({ + model() { + return this.store.findAll('flagged-post', { filter: 'old' }); + }, +}); diff --git a/app/assets/javascripts/admin/routes/admin-flags-topics-index.js.es6 b/app/assets/javascripts/admin/routes/admin-flags-topics-index.js.es6 new file mode 100644 index 0000000000..bb18c6c56a --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-flags-topics-index.js.es6 @@ -0,0 +1,9 @@ +export default Discourse.Route.extend({ + model() { + return this.store.findAll('flagged-topic'); + }, + + setupController(controller, model) { + controller.set('flaggedTopics', model); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-flags-topics-show.js.es6 b/app/assets/javascripts/admin/routes/admin-flags-topics-show.js.es6 new file mode 100644 index 0000000000..b75799bc8c --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-flags-topics-show.js.es6 @@ -0,0 +1,20 @@ +import { loadTopicView } from 'discourse/models/topic'; + +export default Ember.Route.extend({ + model(params) { + let topicRecord = this.store.createRecord('topic', { id: params.id }); + let topic = loadTopicView(topicRecord).then(() => topicRecord); + + return Ember.RSVP.hash({ + topic, + flaggedPosts: this.store.findAll('flagged-post', { + filter: 'active', + topic_id: params.id + }) + }); + }, + + setupController(controller, hash) { + controller.setProperties(hash); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-group.js.es6 b/app/assets/javascripts/admin/routes/admin-group.js.es6 index 3575496edd..0009f834e1 100644 --- a/app/assets/javascripts/admin/routes/admin-group.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-group.js.es6 @@ -4,7 +4,7 @@ export default Discourse.Route.extend({ model(params) { if (params.name === 'new') { - return Group.create({ automatic: false, visible: true }); + return Group.create({ automatic: false, visibility_level: 0 }); } const group = this.modelFor('adminGroupsType').findBy('name', params.name); diff --git a/app/assets/javascripts/admin/routes/admin-reports.js.es6 b/app/assets/javascripts/admin/routes/admin-reports.js.es6 index 47ece70580..65771110e8 100644 --- a/app/assets/javascripts/admin/routes/admin-reports.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-reports.js.es6 @@ -1,16 +1,9 @@ -/** - Handles routes for admin reports +import Report from 'admin/models/report'; - @class AdminReportsRoute - @extends Discourse.Route - @namespace Discourse - @module Discourse -**/ export default Discourse.Route.extend({ queryParams: { mode: {}, "start_date": {}, "end_date": {}, "category_id": {}, "group_id": {} }, - model: function(params) { - const Report = require('admin/models/report').default; + model(params) { return Report.find(params.type, params['start_date'], params['end_date'], params['category_id'], params['group_id']); }, 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 dd87207156..a6ccabc810 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -54,7 +54,11 @@ export default function() { this.route('adminReports', { path: '/reports/:type', resetNamespace: true }); this.route('adminFlags', { path: '/flags', resetNamespace: true }, function() { - this.route('list', { path: '/:filter' }); + this.route('postsActive', { path: 'active' }); + this.route('postsOld', { path: 'old' }); + this.route('topics', { path: 'topics' }, function() { + this.route('show', { path: ":id" }); + }); }); this.route('adminLogs', { path: '/logs', resetNamespace: true }, function() { @@ -62,6 +66,14 @@ export default function() { this.route('screenedEmails', { path: '/screened_emails' }); this.route('screenedIpAddresses', { path: '/screened_ip_addresses' }); this.route('screenedUrls', { path: '/screened_urls' }); + this.route('adminSearchLogs', { path: '/search_logs', resetNamespace: true}, function() { + this.route('index', { path: '/' }); + this.route('term', { path: '/term/:term' }); + }); + this.route('adminWatchedWords', { path: '/watched_words', resetNamespace: true}, function() { + this.route('index', { path: '/' }); + this.route('action', { path: '/action/:action_id' }); + }); }); this.route('adminGroups', { path: '/groups', resetNamespace: true }, function() { diff --git a/app/assets/javascripts/admin/routes/admin-search-logs-index.js.es6 b/app/assets/javascripts/admin/routes/admin-search-logs-index.js.es6 new file mode 100644 index 0000000000..08beeb5ea6 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-search-logs-index.js.es6 @@ -0,0 +1,20 @@ +import { ajax } from 'discourse/lib/ajax'; + +export default Discourse.Route.extend({ + queryParams: { + period: { refreshModel: true }, + searchType: { refreshModel: true } + }, + + model(params) { + this._params = params; + return ajax('/admin/logs/search_logs.json', { data: { period: params.period, search_type: params.searchType } }).then(search_logs => { + return search_logs.map(sl => Ember.Object.create(sl)); + }); + }, + + setupController(controller, model) { + const params = this._params; + controller.setProperties({ model, period: params.period, searchType: params.searchType }); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-search-logs-term.js.es6 b/app/assets/javascripts/admin/routes/admin-search-logs-term.js.es6 new file mode 100644 index 0000000000..775fec62c5 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-search-logs-term.js.es6 @@ -0,0 +1,33 @@ +import { ajax } from 'discourse/lib/ajax'; + +export default Discourse.Route.extend({ + queryParams: { + period: { refreshModel: true }, + searchType: { refreshModel: true } + }, + + model(params) { + this._params = params; + + return ajax(`/admin/logs/search_logs/term/${params.term}.json`, { + data: { + period: params.period, + search_type: params.searchType + } + }).then(json => { + const model = Ember.Object.create({ type: "search_log_term" }); + model.setProperties(json.term); + return model; + }); + }, + + setupController(controller, model) { + const params = this._params; + controller.setProperties({ + model, + term: params.term, + period: params.period, + searchType: params.searchType + }); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-user-index.js.es6 b/app/assets/javascripts/admin/routes/admin-user-index.js.es6 index 03053efeb9..36d1d8cedd 100644 --- a/app/assets/javascripts/admin/routes/admin-user-index.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-user-index.js.es6 @@ -1,4 +1,3 @@ -import showModal from 'discourse/lib/show-modal'; import Group from 'discourse/models/group'; export default Discourse.Route.extend({ @@ -25,11 +24,6 @@ export default Discourse.Route.extend({ }, actions: { - showSuspendModal(model) { - showModal('admin-suspend-user', { model, admin: true }); - this.controllerFor('modal').set('modalClass', 'suspend-user-modal'); - }, - viewActionLogs(username) { const controller = this.controllerFor('adminLogs.staffActionLogs'); this.transitionTo('adminLogs.staffActionLogs').then(() => { diff --git a/app/assets/javascripts/admin/routes/admin-users-index.js.es6 b/app/assets/javascripts/admin/routes/admin-users-index.js.es6 new file mode 100644 index 0000000000..8bb7adc055 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-users-index.js.es6 @@ -0,0 +1,5 @@ +export default Discourse.Route.extend({ + redirect: function() { + this.transitionTo('adminUsersList'); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-watched-words-action.js.es6 b/app/assets/javascripts/admin/routes/admin-watched-words-action.js.es6 new file mode 100644 index 0000000000..123884d6fc --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-watched-words-action.js.es6 @@ -0,0 +1,11 @@ +export default Discourse.Route.extend({ + model(params) { + this.controllerFor('adminWatchedWordsAction').set('actionNameKey', params.action_id); + let filteredContent = this.controllerFor('adminWatchedWordsAction').get('filteredContent'); + return Ember.Object.create({ + nameKey: params.action_id, + name: I18n.t('admin.watched_words.actions.' + params.action_id), + words: filteredContent + }); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-watched-words-index.js.es6 b/app/assets/javascripts/admin/routes/admin-watched-words-index.js.es6 new file mode 100644 index 0000000000..0103744b52 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-watched-words-index.js.es6 @@ -0,0 +1,5 @@ +export default Discourse.Route.extend({ + beforeModel() { + this.replaceWith('adminWatchedWords.action', this.modelFor('adminWatchedWords')[0].nameKey); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-watched-words.js.es6 b/app/assets/javascripts/admin/routes/admin-watched-words.js.es6 new file mode 100644 index 0000000000..308dfd6544 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-watched-words.js.es6 @@ -0,0 +1,22 @@ +import WatchedWord from 'admin/models/watched-word'; + +export default Discourse.Route.extend({ + queryParams: { + filter: { replace: true } + }, + + model() { + return WatchedWord.findAll(); + }, + + setupController(controller, model) { + controller.set('model', model); + if (model && model.length) { + controller.set('regularExpressions', model[0].get('regularExpressions')); + } + }, + + afterModel(watchedWordsList) { + this.controllerFor('adminWatchedWords').set('allWatchedWords', watchedWordsList); + } +}); diff --git a/app/assets/javascripts/admin/services/admin-tools.js.es6 b/app/assets/javascripts/admin/services/admin-tools.js.es6 new file mode 100644 index 0000000000..439b85cd42 --- /dev/null +++ b/app/assets/javascripts/admin/services/admin-tools.js.es6 @@ -0,0 +1,127 @@ +// A service that can act as a bridge between the front end Discourse application +// and the admin application. Use this if you need front end code to access admin +// modules. Inject it optionally, and if it exists go to town! + +import AdminUser from 'admin/models/admin-user'; +import { iconHTML } from 'discourse-common/lib/icon-library'; +import { ajax } from 'discourse/lib/ajax'; +import showModal from 'discourse/lib/show-modal'; +import { getOwner } from 'discourse-common/lib/get-owner'; + +export default Ember.Service.extend({ + + init() { + this._super(); + + // TODO: Make `siteSettings` a service that can be injected + this.siteSettings = getOwner(this).lookup('site-settings:main'); + }, + + checkSpammer(userId) { + return AdminUser.find(userId).then(au => this.spammerDetails(au)); + }, + + spammerDetails(adminUser) { + return { + deleteUser: () => this._deleteSpammer(adminUser), + canDelete: adminUser.get('can_be_deleted') && adminUser.get('can_delete_all_posts') + }; + }, + + _showControlModal(type, user, opts) { + opts = opts || {}; + + let controller = showModal(`admin-${type}-user`, { + admin: true, + modalClass: `${type}-user-modal` + }); + if (opts.post) { + controller.set('post', opts.post); + } + + let promise = user.adminUserView ? + Ember.RSVP.resolve(user) : + AdminUser.find(user.get('id')); + + promise.then(loadedUser => { + controller.setProperties({ + user: loadedUser, + loadingUser: false, + successCallback: opts.successCallback + }); + }); + }, + + showSilenceModal(user, opts) { + this._showControlModal('silence', user, opts); + }, + + showSuspendModal(user, opts) { + this._showControlModal('suspend', user, opts); + }, + + showModerationHistory(target) { + let controller = showModal('admin-moderation-history', { admin: true }); + controller.loadHistory(target); + }, + + _deleteSpammer(adminUser) { + + // Try loading the email if the site supports it + let tryEmail = this.siteSettings.show_email_on_profile ? + adminUser.checkEmail() : + Ember.RSVP.resolve(); + + return tryEmail.then(() => { + + let message = I18n.messageFormat('flagging.delete_confirm_MF', { + "POSTS": adminUser.get('post_count'), + "TOPICS": adminUser.get('topic_count'), + email: adminUser.get('email') || I18n.t("flagging.hidden_email_address"), + ip_address: adminUser.get('ip_address') || I18n.t("flagging.ip_address_missing") + }); + + let userId = adminUser.get('id'); + + return new Ember.RSVP.Promise((resolve, reject) => { + const buttons = [ + { + label: I18n.t("composer.cancel"), + class: "d-modal-cancel", + link: true + }, + { + label: `${iconHTML('exclamation-triangle')} ` + I18n.t("flagging.yes_delete_spammer"), + class: "btn btn-danger confirm-delete", + callback() { + return ajax(`/admin/users/${userId}.json`, { + type: 'DELETE', + data: { + delete_posts: true, + block_email: true, + block_urls: true, + block_ip: true, + delete_as_spammer: true, + context: window.location.pathname + } + }).then(result => { + if (result.deleted) { + resolve(); + } else { + throw 'failed to delete'; + } + }).catch(() => { + bootbox.alert(I18n.t("admin.user.delete_failed")); + reject(); + }); + } + } + ]; + + bootbox.dialog(message, buttons, {classes: "flagging-delete-spammer"}); + }); + + }); + } + +}); diff --git a/app/assets/javascripts/admin/templates/admin.hbs b/app/assets/javascripts/admin/templates/admin.hbs index 780aad41fc..af4aafde94 100644 --- a/app/assets/javascripts/admin/templates/admin.hbs +++ b/app/assets/javascripts/admin/templates/admin.hbs @@ -20,14 +20,16 @@ {{#if currentUser.admin}} {{nav-item route='adminCustomize' label='admin.customize.title'}} {{nav-item route='adminApi' label='admin.api.title'}} - {{nav-item route='admin.backups' label='admin.backups.title'}} + {{#if siteSettings.enable_backups}} + {{nav-item route='admin.backups' label='admin.backups.title'}} + {{/if}} {{/if}} {{nav-item route='adminPlugins' label='admin.plugins.title'}} {{plugin-outlet name="admin-menu" connectorTagName="li"}}
-
+
{{outlet}}
diff --git a/app/assets/javascripts/admin/templates/api-keys.hbs b/app/assets/javascripts/admin/templates/api-keys.hbs index 60449052bb..b25eebd69a 100644 --- a/app/assets/javascripts/admin/templates/api-keys.hbs +++ b/app/assets/javascripts/admin/templates/api-keys.hbs @@ -29,5 +29,5 @@ {{/if}} {{#unless hasMasterKey}} - + {{/unless}} diff --git a/app/assets/javascripts/admin/templates/api.hbs b/app/assets/javascripts/admin/templates/api.hbs index f3407fe0cc..45bfbd9ca7 100644 --- a/app/assets/javascripts/admin/templates/api.hbs +++ b/app/assets/javascripts/admin/templates/api.hbs @@ -1,10 +1,8 @@ -
- {{#admin-nav}} - {{nav-item route='adminApiKeys' label='admin.api.title'}} - {{nav-item route='adminWebHooks' label='admin.web_hooks.title'}} - {{/admin-nav}} +{{#admin-nav}} + {{nav-item route='adminApiKeys' label='admin.api.title'}} + {{nav-item route='adminWebHooks' label='admin.web_hooks.title'}} +{{/admin-nav}} -
- {{outlet}} -
+
+ {{outlet}}
diff --git a/app/assets/javascripts/admin/templates/backups.hbs b/app/assets/javascripts/admin/templates/backups.hbs index ea8bc18987..5cfd123114 100644 --- a/app/assets/javascripts/admin/templates/backups.hbs +++ b/app/assets/javascripts/admin/templates/backups.hbs @@ -4,6 +4,7 @@
diff --git a/app/assets/javascripts/admin/templates/badges-index.hbs b/app/assets/javascripts/admin/templates/badges-index.hbs index c94f59fe10..674eae8de2 100644 --- a/app/assets/javascripts/admin/templates/badges-index.hbs +++ b/app/assets/javascripts/admin/templates/badges-index.hbs @@ -3,7 +3,7 @@
{{#link-to 'adminBadges.show' 'new' class="btn"}} - {{fa-icon "plus"}} {{i18n 'admin.badges.new'}} + {{d-icon "plus"}} {{i18n 'admin.badges.new'}} {{/link-to}}
{{/d-section}} diff --git a/app/assets/javascripts/admin/templates/badges-show.hbs b/app/assets/javascripts/admin/templates/badges-show.hbs index e0ae7001df..94239f9d03 100644 --- a/app/assets/javascripts/admin/templates/badges-show.hbs +++ b/app/assets/javascripts/admin/templates/badges-show.hbs @@ -26,9 +26,8 @@ {{combo-box name="badge_type_id" value=buffered.badge_type_id content=badgeTypes - optionValuePath="content.id" - optionLabelPath="content.name" - disabled=readOnly}} + allowInitialValueMutation=true + isDisabled=readOnly}}
@@ -36,9 +35,8 @@ {{combo-box name="badge_grouping_id" value=buffered.badge_grouping_id content=badgeGroupings - optionValuePath="content.id" - optionLabelPath="content.displayName"}} -   + nameProperty="name"}} +  
@@ -63,7 +61,7 @@ {{#if siteSettings.enable_badge_sql}}
- {{textarea name="query" value=buffered.query disabled=readOnly}} + {{ace-editor content=buffered.query mode="sql" disabled=readOnly}}
{{#if hasQuery}} diff --git a/app/assets/javascripts/admin/templates/badges.hbs b/app/assets/javascripts/admin/templates/badges.hbs index 3ca7f91294..aeeaac9c9e 100644 --- a/app/assets/javascripts/admin/templates/badges.hbs +++ b/app/assets/javascripts/admin/templates/badges.hbs @@ -15,7 +15,7 @@ {{/each}} {{#link-to 'adminBadges.show' 'new' class="btn"}} - {{fa-icon "plus"}} {{i18n 'admin.badges.new'}} + {{d-icon "plus"}} {{i18n 'admin.badges.new'}} {{/link-to}}

diff --git a/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs b/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs index 304570c0a5..82fbf0d799 100644 --- a/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs +++ b/app/assets/javascripts/admin/templates/components/admin-report-counts.hbs @@ -1,6 +1,6 @@ {{#if report.icon}} - {{fa-icon report.icon}} + {{d-icon report.icon}} {{/if}} {{report.title}} @@ -8,15 +8,15 @@ {{number report.todayCount}} - {{number report.yesterdayCount}} {{fa-icon "caret-up" class="up"}} {{fa-icon "caret-down" class="down"}} + {{number report.yesterdayCount}} {{d-icon "caret-up" class="up"}} {{d-icon "caret-down" class="down"}} - {{number report.lastSevenDaysCount}} {{fa-icon "caret-up" class="up"}} {{fa-icon "caret-down" class="down"}} + {{number report.lastSevenDaysCount}} {{d-icon "caret-up" class="up"}} {{d-icon "caret-down" class="down"}} - {{number report.lastThirtyDaysCount}} {{fa-icon "caret-up" class="up"}} {{fa-icon "caret-down" class="down"}} + {{number report.lastThirtyDaysCount}} {{d-icon "caret-up" class="up"}} {{d-icon "caret-down" class="down"}} {{number report.total}} diff --git a/app/assets/javascripts/admin/templates/components/admin-user-field-item.hbs b/app/assets/javascripts/admin/templates/components/admin-user-field-item.hbs index c5620d84ed..419ef0b69c 100644 --- a/app/assets/javascripts/admin/templates/components/admin-user-field-item.hbs +++ b/app/assets/javascripts/admin/templates/components/admin-user-field-item.hbs @@ -1,6 +1,6 @@ {{#if editing}} {{#admin-form-row label="admin.user_fields.type"}} - {{combo-box content=fieldTypes valueAttribute="id" value=buffered.field_type}} + {{combo-box content=fieldTypes value=buffered.field_type}} {{/admin-form-row}} {{#admin-form-row label="admin.user_fields.name"}} diff --git a/app/assets/javascripts/admin/templates/components/embeddable-host.hbs b/app/assets/javascripts/admin/templates/components/embeddable-host.hbs index f4367abeff..e21685831b 100644 --- a/app/assets/javascripts/admin/templates/components/embeddable-host.hbs +++ b/app/assets/javascripts/admin/templates/components/embeddable-host.hbs @@ -9,7 +9,7 @@ {{input value=buffered.path_whitelist placeholder="/blog/.*" enter="save" class="path-whitelist"}} - {{category-chooser value=categoryId}} + {{category-chooser value=categoryId class="small"}} {{d-button icon="check" action="save" class="btn-primary" disabled=cantSave}} diff --git a/app/assets/javascripts/admin/templates/components/flag-counts.hbs b/app/assets/javascripts/admin/templates/components/flag-counts.hbs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/assets/javascripts/admin/templates/components/flag-user.hbs b/app/assets/javascripts/admin/templates/components/flag-user.hbs new file mode 100644 index 0000000000..92f6871847 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/flag-user.hbs @@ -0,0 +1,18 @@ +
+ {{#link-to 'adminUser' user.id user.username class='flag-user-avatar'}} + {{avatar user imageSize="small"}} + {{/link-to}} +
+
+ {{#link-to 'adminUser' user.id user.username class="flag-user-username"}} + {{user.username}} + {{/link-to}} +
+ {{format-age date}} +
+
+
+ {{yield}} +
+
+
diff --git a/app/assets/javascripts/admin/templates/components/flagged-post-response.hbs b/app/assets/javascripts/admin/templates/components/flagged-post-response.hbs new file mode 100644 index 0000000000..133c72b036 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/flagged-post-response.hbs @@ -0,0 +1,7 @@ +{{#link-to 'adminUser' response.user.id response.user.username class="response-avatar"}} + {{avatar response.user imageSize="small"}} +{{/link-to}} +
{{{response.excerpt}}}
+{{#if hasMore}} + {{i18n 'admin.flags.more'}} +{{/if}} diff --git a/app/assets/javascripts/admin/templates/components/flagged-post.hbs b/app/assets/javascripts/admin/templates/components/flagged-post.hbs new file mode 100644 index 0000000000..2b7f03940f --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/flagged-post.hbs @@ -0,0 +1,163 @@ +
+
+ {{#if flaggedPost.postAuthorFlagged}} + {{#if flaggedPost.user}} + {{#link-to 'adminUser' flaggedPost.user.id flaggedPost.user.username}} + {{avatar flaggedPost.user imageSize="large"}} + {{/link-to}} + {{#if flaggedPost.wasEdited}} +
+ {{d-icon "pencil" title="admin.flags.was_edited"}} +
+ {{/if}} + {{/if}} + {{/if}} + {{#if canAct}} + {{#if flaggedPost.previous_flags_count}} + {{flaggedPost.previous_flags_count}} + {{/if}} + {{/if}} +
+ +
+
+ {{format-username flaggedPost.user.username}} +
+ +
+ {{#unless hideTitle}} +

+ {{#if flaggedPost.topic.isPrivateMessage}} + {{d-icon "envelope"}} + {{/if}} + {{topic-status topic=flaggedPost.topic}} + {{{unbound flaggedPost.topic.fancyTitle}}} +

+ {{/unless}} + {{#if flaggedPost.postAuthorFlagged}} + {{#if expanded}} + {{{flaggedPost.cooked}}} + {{else}} +

+ {{{flaggedPost.excerpt}}} + {{i18n "admin.flags.show_full"}} +

+ {{/if}} + {{/if}} +
+ + {{#if flaggedPost.topicFlagged}} +
+ {{{i18n 'admin.flags.topic_flagged'}}} + {{i18n 'admin.flags.visit_topic'}} +
+ {{/if}} + + {{#each flaggedPost.conversations as |c|}} +
+ {{#if c.response}} + {{flagged-post-response response=c.response}} + {{#if c.reply}} + {{flagged-post-response response=c.reply hasMore=c.hasMore permalink=c.permalink}} + {{/if}} + + {{d-icon "reply"}} + {{i18n "admin.flags.reply_message"}} + + {{/if}} +
+ {{/each}} + +
+
+
+ {{i18n "admin.flags.flagged_by"}} +
+
+ {{#each flaggedPost.post_actions as |postAction|}} + {{#flag-user user=postAction.user date=postAction.created_at}} +
+ {{post-action-title postAction.post_action_type_id postAction.name_key}} +
+ {{/flag-user}} + {{/each}} +
+
+ + {{#if showResolvedBy}} +
+
+ {{i18n "admin.flags.resolved_by"}} +
+
+ {{#each flaggedPost.post_actions as |postAction|}} + {{#flag-user user=postAction.disposed_by date=postAction.disposed_at}} + {{disposition-icon postAction.disposition}} + {{#if postAction.staff_took_action}} + {{d-icon "gavel" title="admin.flags.took_action"}} + {{/if}} + {{/flag-user}} + {{/each}} +
+
+ {{/if}} +
+ + {{#if suspended}} +
+ The user was suspended for this post. +
+ {{/if}} + +
+ {{#if canAct}} + {{admin-agree-flag-dropdown + post=flaggedPost + removeAfter=(action "removeAfter") }} + + {{#if flaggedPost.postHidden}} + {{d-button + title="admin.flags.disagree_flag_unhide_post_title" + class="disagree-flag" + action="disagree" + icon="thumbs-o-down" + label="admin.flags.disagree_flag_unhide_post"}} + {{else}} + {{d-button + title="admin.flags.disagree_flag_title" + class="disagree-flag" + action="disagree" + icon="thumbs-o-down" + label="admin.flags.disagree_flag"}} + {{/if}} + + {{d-button + class="defer-flag" + title="admin.flags.defer_flag_title" + action="defer" + icon="external-link" + label="admin.flags.defer_flag"}} + + {{admin-delete-flag-dropdown post=flaggedPost removeAfter=(action "removeAfter")}} + + {{#unless suspended}} + {{d-button + class="btn-danger suspend-user" + icon="ban" + label="admin.flags.suspend_user" + title="admin.flags.suspend_user_title" + action=(action "showSuspendModal")}} + {{/unless}} + {{/if}} + + {{d-button + icon="list" + label="admin.flags.moderation_history" + action=(action "showModerationHistory")}} +
+ {{plugin-outlet + name="flagged-post-below-controls" + tagName="" + args=(hash flaggedPost=flaggedPost canAct=canAct)}} +
+
diff --git a/app/assets/javascripts/admin/templates/components/flagged-posts.hbs b/app/assets/javascripts/admin/templates/components/flagged-posts.hbs new file mode 100644 index 0000000000..a2f60c2587 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/flagged-posts.hbs @@ -0,0 +1,16 @@ +{{#if flaggedPosts}} + {{#load-more selector=".flagged-post" action=(action "loadMore")}} +
+ {{#each flaggedPosts as |flaggedPost|}} + {{flagged-post + flaggedPost=flaggedPost + filter=filter + showResolvedBy=showResolvedBy + removePost=(action "removePost" flaggedPost) + hideTitle=topic}} + {{/each}} +
+ {{/load-more}} +{{else}} +

{{i18n 'admin.flags.no_results'}}

+{{/if}} diff --git a/app/assets/javascripts/admin/templates/components/flagged-topic-users.hbs b/app/assets/javascripts/admin/templates/components/flagged-topic-users.hbs new file mode 100644 index 0000000000..ad9f40585a --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/flagged-topic-users.hbs @@ -0,0 +1,5 @@ +{{#each users as |u|}} + {{#link-to 'adminUser' u.id u.username class="flagged-topic-user"}} + {{avatar u imageSize="small"}} + {{/link-to}} +{{/each}} diff --git a/app/assets/javascripts/admin/templates/components/moderation-history-item.hbs b/app/assets/javascripts/admin/templates/components/moderation-history-item.hbs new file mode 100644 index 0000000000..bf2ddc0f4c --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/moderation-history-item.hbs @@ -0,0 +1,17 @@ + + {{format-date item.created_at}} + + +
+ {{i18n (concat "admin.moderation_history.actions." item.action_name)}} +
+
{{item.details}}
+ + + {{#if item.acting_user}} + {{#user-link user=item.acting_user}} + {{avatar item.acting_user imageSize="small"}} + {{format-username item.acting_user.username}} + {{/user-link}} + {{/if}} + diff --git a/app/assets/javascripts/admin/templates/components/setting-validation-message.hbs b/app/assets/javascripts/admin/templates/components/setting-validation-message.hbs index 92451dbbf9..88d59816fe 100644 --- a/app/assets/javascripts/admin/templates/components/setting-validation-message.hbs +++ b/app/assets/javascripts/admin/templates/components/setting-validation-message.hbs @@ -1,4 +1,4 @@
- {{fa-icon "times"}} + {{d-icon "times"}} {{message}}
diff --git a/app/assets/javascripts/admin/templates/components/site-setting.hbs b/app/assets/javascripts/admin/templates/components/site-setting.hbs index 32510bef5f..7fd80e4446 100644 --- a/app/assets/javascripts/admin/templates/components/site-setting.hbs +++ b/app/assets/javascripts/admin/templates/components/site-setting.hbs @@ -2,7 +2,7 @@

{{unbound settingName}}

- {{component componentName setting=setting value=buffered.value validationMessage=validationMessage}} + {{component componentName setting=setting value=buffered.value validationMessage=validationMessage preview=preview}}
{{#if dirty}}
diff --git a/app/assets/javascripts/admin/templates/components/site-settings/category-list.hbs b/app/assets/javascripts/admin/templates/components/site-settings/category-list.hbs index 8c0b4eda1e..0aadec18d8 100644 --- a/app/assets/javascripts/admin/templates/components/site-settings/category-list.hbs +++ b/app/assets/javascripts/admin/templates/components/site-settings/category-list.hbs @@ -1,3 +1,3 @@ -{{category-selector categories=selectedCategories blacklist=selectedCategories}} +{{category-selector categories=selectedCategories}}
{{{unbound setting.description}}}
{{setting-validation-message message=validationMessage}} diff --git a/app/assets/javascripts/admin/templates/components/site-settings/enum.hbs b/app/assets/javascripts/admin/templates/components/site-settings/enum.hbs index 765a0e20d1..9b3da3178e 100644 --- a/app/assets/javascripts/admin/templates/components/site-settings/enum.hbs +++ b/app/assets/javascripts/admin/templates/components/site-settings/enum.hbs @@ -1,4 +1,4 @@ -{{combo-box valueAttribute="value" content=setting.validValues value=value none=setting.allowsNone}} +{{combo-box castInteger=true valueAttribute="value" content=setting.validValues value=value none=setting.allowsNone}} {{preview}} {{setting-validation-message message=validationMessage}}
{{{unbound setting.description}}}
diff --git a/app/assets/javascripts/admin/templates/components/site-settings/string.hbs b/app/assets/javascripts/admin/templates/components/site-settings/string.hbs index 71d7216f27..77e254b42f 100644 --- a/app/assets/javascripts/admin/templates/components/site-settings/string.hbs +++ b/app/assets/javascripts/admin/templates/components/site-settings/string.hbs @@ -1,3 +1,8 @@ -{{text-field value=value classNames="input-setting-string"}} +{{#if setting.textarea}} + {{textarea value=value classNames="input-setting-textarea"}} +{{else}} + {{text-field value=value classNames="input-setting-string"}} +{{/if}} + {{setting-validation-message message=validationMessage}}
{{{unbound setting.description}}}
diff --git a/app/assets/javascripts/admin/templates/components/watched-word-form.hbs b/app/assets/javascripts/admin/templates/components/watched-word-form.hbs new file mode 100644 index 0000000000..7d2b460aa0 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/watched-word-form.hbs @@ -0,0 +1,7 @@ +{{i18n 'admin.watched_words.form.label'}} +{{text-field value=word disabled=formSubmitted class="watched-word-input" autocorrect="off" autocapitalize="off" placeholderKey=placeholderKey}} +{{d-button action="submit" disabled=formSubmitted label="admin.watched_words.form.add"}} + +{{#if showSuccessMessage}} + {{i18n 'admin.watched_words.form.success'}} +{{/if}} diff --git a/app/assets/javascripts/admin/templates/components/watched-word-uploader.hbs b/app/assets/javascripts/admin/templates/components/watched-word-uploader.hbs new file mode 100644 index 0000000000..caa74a8615 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/watched-word-uploader.hbs @@ -0,0 +1,7 @@ + +
+One word per line diff --git a/app/assets/javascripts/admin/templates/customize-colors-show.hbs b/app/assets/javascripts/admin/templates/customize-colors-show.hbs index 6b3c8a3e91..e688e13316 100644 --- a/app/assets/javascripts/admin/templates/customize-colors-show.hbs +++ b/app/assets/javascripts/admin/templates/customize-colors-show.hbs @@ -5,13 +5,13 @@ {{#unless model.theme_id}} {{/unless}} - - + + {{#if model.theme_id}} {{i18n "admin.customize.theme_owner"}} {{#link-to "adminCustomizeThemes.show" model.theme_id}}{{model.theme_name}}{{/link-to}} {{else}} - + {{/if}} {{model.savingStatus}}
diff --git a/app/assets/javascripts/admin/templates/customize-colors.hbs b/app/assets/javascripts/admin/templates/customize-colors.hbs index 1ed39b8fcf..920f6f1954 100644 --- a/app/assets/javascripts/admin/templates/customize-colors.hbs +++ b/app/assets/javascripts/admin/templates/customize-colors.hbs @@ -4,12 +4,12 @@ {{#each model as |scheme|}} {{#unless scheme.is_base}}
  • - {{#link-to 'adminCustomize.colors.show' scheme replace=true}}{{fa-icon 'paint-brush'}}{{scheme.description}}{{/link-to}} + {{#link-to 'adminCustomize.colors.show' scheme replace=true}}{{d-icon 'paint-brush'}}{{scheme.description}}{{/link-to}}
  • {{/unless}} {{/each}} - + {{outlet}} diff --git a/app/assets/javascripts/admin/templates/customize-themes-edit.hbs b/app/assets/javascripts/admin/templates/customize-themes-edit.hbs index fafce666a6..83eebb83d0 100644 --- a/app/assets/javascripts/admin/templates/customize-themes-edit.hbs +++ b/app/assets/javascripts/admin/templates/customize-themes-edit.hbs @@ -19,7 +19,7 @@
  • {{#link-to 'adminCustomizeThemes.edit' model.id 'desktop' fieldName replace=true title=field.title}} {{i18n 'admin.customize.theme.desktop'}} - {{fa-icon 'desktop'}} + {{d-icon 'desktop'}} {{/link-to}}
  • {{/if}} @@ -27,7 +27,7 @@
  • {{#link-to 'adminCustomizeThemes.edit' model.id 'mobile' fieldName replace=true title=field.title}} {{i18n 'admin.customize.theme.mobile'}} - {{fa-icon 'mobile'}} + {{d-icon 'mobile'}} {{/link-to}}
  • {{/if}} @@ -46,14 +46,14 @@ {{#each fields as |field|}}
  • {{#link-to 'adminCustomizeThemes.edit' model.id currentTargetName field.name replace=true title=field.title}} - {{#if field.icon}}{{fa-icon field.icon}} {{/if}} + {{#if field.icon}}{{d-icon field.icon}} {{/if}} {{i18n field.key}} {{/link-to}}
  • {{/each}}
  • - + {{d-icon maximizeIcon}}
  • diff --git a/app/assets/javascripts/admin/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/templates/customize-themes-show.hbs index 6e89af4767..7f339d63d7 100644 --- a/app/assets/javascripts/admin/templates/customize-themes-show.hbs +++ b/app/assets/javascripts/admin/templates/customize-themes-show.hbs @@ -1,13 +1,13 @@
    -

    +

    {{#if editingName}} {{text-field value=model.name autofocus="true"}} {{d-button action="finishedEditingName" class="btn-primary btn-small submit-edit" icon="check"}} {{d-button action="cancelEditingName" class="btn-small cancel-edit" icon="times"}} {{else}} - {{model.name}} {{fa-icon "pencil"}} + {{model.name}} {{d-icon "pencil"}} {{/if}} -

    + {{#if model.remote_theme}}

    @@ -15,7 +15,7 @@

    {{#if model.remote_theme.license_url}}

    - {{i18n "admin.customize.theme.license"}} {{fa-icon "copyright"}} + {{i18n "admin.customize.theme.license"}} {{d-icon "copyright"}}

    {{/if}} {{/if}} @@ -37,10 +37,9 @@

    {{i18n "admin.customize.theme.color_scheme"}}

    {{i18n "admin.customize.theme.color_scheme_select"}}

    {{combo-box content=colorSchemes - nameProperty="name" - value=colorSchemeId - selectionIcon="paint-brush" - valueAttribute="id"}} + filterable=true + value=colorSchemeId + icon="paint-brush"}} {{#if colorSchemeChanged}} {{d-button action="changeScheme" class="btn-primary btn-small submit-edit" icon="check"}} {{d-button action="cancelChangeScheme" class="btn-small cancel-edit" icon="times"}} @@ -123,18 +122,15 @@ {{/unless}} {{#if selectableChildThemes}} -

    {{combo-box content=selectableChildThemes - nameProperty="name" - value=selectedChildThemeId - valueAttribute="id"}} - +

    + {{combo-box content=selectableChildThemes value=selectedChildThemeId}} {{#d-button action="addChildTheme" icon="plus"}}{{i18n "admin.customize.theme.add"}}{{/d-button}}

    {{/if}} {{/if}} - {{fa-icon 'desktop'}}{{i18n 'admin.customize.theme.preview'}} - {{fa-icon "download"}} {{i18n 'admin.export_json.button_text'}} + {{d-icon 'desktop'}}{{i18n 'admin.customize.theme.preview'}} + {{d-icon "download"}} {{i18n 'admin.export_json.button_text'}} {{d-button action="destroy" label="admin.customize.delete" icon="trash" class="btn-danger"}}
    diff --git a/app/assets/javascripts/admin/templates/customize-themes.hbs b/app/assets/javascripts/admin/templates/customize-themes.hbs index 43129006a5..c9474917ef 100644 --- a/app/assets/javascripts/admin/templates/customize-themes.hbs +++ b/app/assets/javascripts/admin/templates/customize-themes.hbs @@ -7,10 +7,10 @@ {{#link-to 'adminCustomizeThemes.show' theme replace=true}} {{theme.name}} {{#if theme.user_selectable}} - {{fa-icon "user"}} + {{d-icon "user"}} {{/if}} {{#if theme.default}} - {{fa-icon "asterisk"}} + {{d-icon "asterisk"}} {{/if}} {{/link-to}} diff --git a/app/assets/javascripts/admin/templates/customize.hbs b/app/assets/javascripts/admin/templates/customize.hbs index 3065c09855..d5d3816310 100644 --- a/app/assets/javascripts/admin/templates/customize.hbs +++ b/app/assets/javascripts/admin/templates/customize.hbs @@ -1,16 +1,14 @@ -
    - {{#admin-nav}} - {{nav-item route='adminCustomizeThemes' label='admin.customize.theme.title'}} - {{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='adminUserFields' label='admin.user_fields.title'}} - {{nav-item route='adminEmojis' label='admin.emoji.title'}} - {{nav-item route='adminPermalinks' label='admin.permalink.title'}} - {{nav-item route='adminEmbedding' label='admin.embedding.title'}} - {{/admin-nav}} +{{#admin-nav}} + {{nav-item route='adminCustomizeThemes' label='admin.customize.theme.title'}} + {{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='adminUserFields' label='admin.user_fields.title'}} + {{nav-item route='adminEmojis' label='admin.emoji.title'}} + {{nav-item route='adminPermalinks' label='admin.permalink.title'}} + {{nav-item route='adminEmbedding' label='admin.embedding.title'}} +{{/admin-nav}} -
    - {{outlet}} -
    +
    + {{outlet}}
    diff --git a/app/assets/javascripts/admin/templates/dashboard.hbs b/app/assets/javascripts/admin/templates/dashboard.hbs index c6c4487009..6b4dc88584 100644 --- a/app/assets/javascripts/admin/templates/dashboard.hbs +++ b/app/assets/javascripts/admin/templates/dashboard.hbs @@ -29,16 +29,16 @@
    - + - + - + - - + +
    {{fa-icon "shield"}} {{i18n 'admin.dashboard.admins'}}{{d-icon "shield"}} {{i18n 'admin.dashboard.admins'}} {{#link-to 'adminUsersList.show' 'admins'}}{{admins}}{{/link-to}}{{fa-icon "ban"}} {{i18n 'admin.dashboard.suspended'}}{{d-icon "ban"}} {{i18n 'admin.dashboard.suspended'}} {{#link-to 'adminUsersList.show' 'suspended'}}{{suspended}}{{/link-to}}
    {{fa-icon "shield"}} {{i18n 'admin.dashboard.moderators'}}{{d-icon "shield"}} {{i18n 'admin.dashboard.moderators'}} {{#link-to 'adminUsersList.show' 'moderators'}}{{moderators}}{{/link-to}}{{fa-icon "ban"}} {{i18n 'admin.dashboard.blocked'}}{{#link-to 'adminUsersList.show' 'blocked'}}{{blocked}}{{/link-to}}{{d-icon "ban"}} {{i18n 'admin.dashboard.silenced'}}{{#link-to 'adminUsersList.show' 'silenced'}}{{silenced}}{{/link-to}}
    @@ -87,7 +87,7 @@ - + @@ -176,7 +176,7 @@
    {{#if foundProblems}}
    -
    {{fa-icon "exclamation-triangle"}}
    +
    {{d-icon "exclamation-triangle"}}
    {{#conditional-loading-spinner condition=loadingProblems}}

    diff --git a/app/assets/javascripts/admin/templates/email-preview-digest.hbs b/app/assets/javascripts/admin/templates/email-preview-digest.hbs index 7f31c6e9bf..87372268c7 100644 --- a/app/assets/javascripts/admin/templates/email-preview-digest.hbs +++ b/app/assets/javascripts/admin/templates/email-preview-digest.hbs @@ -1,11 +1,11 @@

    {{i18n 'admin.email.preview_digest_desc'}}

    -
    +
    diff --git a/app/assets/javascripts/admin/templates/emojis.hbs b/app/assets/javascripts/admin/templates/emojis.hbs index 6f35c097a9..675e8249be 100644 --- a/app/assets/javascripts/admin/templates/emojis.hbs +++ b/app/assets/javascripts/admin/templates/emojis.hbs @@ -20,7 +20,7 @@ - + {{/each}} diff --git a/app/assets/javascripts/admin/templates/flags-list.hbs b/app/assets/javascripts/admin/templates/flags-list.hbs deleted file mode 100644 index 7edc1b30f2..0000000000 --- a/app/assets/javascripts/admin/templates/flags-list.hbs +++ /dev/null @@ -1,166 +0,0 @@ -{{#if model.length}} - {{#load-more selector="tbody tr" action="loadMore"}} -
    {{fa-icon "envelope"}} {{i18n 'admin.dashboard.private_messages_short'}}{{d-icon "envelope"}} {{i18n 'admin.dashboard.private_messages_short'}} {{i18n 'admin.dashboard.reports.today'}} {{i18n 'admin.dashboard.reports.yesterday'}} {{i18n 'admin.dashboard.reports.last_7_days'}} - {{#if l.bounced}}{{fa-icon "repeat" title="admin.email.bounced"}}{{/if}} + {{#if l.bounced}}{{d-icon "repeat" title="admin.email.bounced"}}{{/if}} {{l.to_address}} {{l.email_type}}
    :{{e.name}}:
    - - - - - - - - - {{#each content as |flaggedPost|}} - - - - - - - - - - - {{#if flaggedPost.topicFlagged}} - - - - - {{/if}} - - {{#each flaggedPost.conversations as |c|}} - - - - {{/each}} - - {{#unless adminOldFlagsView}} - - - - {{/unless}} - - {{/each}} - - -
    {{i18n 'admin.flags.flagged_by'}}{{#if adminOldFlagsView}}{{i18n 'admin.flags.resolved_by'}}{{/if}}
    - -
    -
    - {{#if flaggedPost.postAuthorFlagged}} - {{#if flaggedPost.user}} - {{#link-to 'adminUser' flaggedPost.user}}{{avatar flaggedPost.user imageSize="large"}}{{/link-to}} - {{#if flaggedPost.wasEdited}}{{/if}} - {{/if}} - {{/if}} - {{#if adminActiveFlagsView}} - {{#if flaggedPost.previous_flags_count}} - {{flaggedPost.previous_flags_count}} - {{/if}} - {{/if}} -
    -
    -

    - {{#if flaggedPost.topic.isPrivateMessage}} - {{fa-icon "envelope"}} - {{/if}} - {{topic-status topic=flaggedPost.topic}} - {{{unbound flaggedPost.topic.fancyTitle}}} -

    - {{#unless site.mobileView}} - {{#if flaggedPost.postAuthorFlagged}} -

    {{{flaggedPost.excerpt}}}

    - {{/if}} - {{/unless}} -
    -
    - - {{#if site.mobileView}} - {{#if flaggedPost.postAuthorFlagged}} -

    {{{flaggedPost.excerpt}}}

    - {{/if}} - {{/if}} -
    - - - {{#each flaggedPost.flaggers as |flagger|}} - - - - - {{/each}} - -
    - {{#link-to 'adminUser' flagger.user}} - {{avatar flagger.user imageSize="medium"}} - {{/link-to}} - - {{#link-to 'adminUser' flagger.user}} - {{flagger.user.username}} - {{/link-to}} - {{format-age flagger.flaggedAt}} -
    - {{flagger.flagType}} -
    -
    - {{#if adminOldFlagsView}} - - - {{#each flaggedPost.flaggers as |flagger|}} - - - - - {{/each}} - -
    - {{#link-to 'adminUser' flagger.disposedBy}} - {{avatar flagger.disposedBy imageSize="medium"}} - {{/link-to}} - - {{format-age flagger.disposedAt}} - {{{flagger.dispositionIcon}}} - {{#if flagger.tookAction}} - - {{/if}} -
    - {{/if}} -
    -
    - {{{i18n 'admin.flags.topic_flagged'}}} {{i18n 'admin.flags.visit_topic'}} -
    -
    -
    - {{#if c.response}} -

    - {{#link-to 'adminUser' c.response.user}}{{avatar c.response.user imageSize="medium"}}{{/link-to}} {{{c.response.excerpt}}} -

    - {{#if c.reply}} -

    - {{#link-to 'adminUser' c.reply.user}}{{avatar c.reply.user imageSize="medium"}}{{/link-to}} {{{c.reply.excerpt}}} - {{#if c.hasMore}} - {{i18n 'admin.flags.more'}} - {{/if}} -

    - {{/if}} - - - - {{/if}} -
    -
    - {{#if adminActiveFlagsView}} - - {{#if flaggedPost.postHidden}} - - {{else}} - - {{/if}} - - - {{/if}} -
    - {{/load-more}} - -{{else}} -

    {{i18n 'admin.flags.no_results'}}

    -{{/if}} diff --git a/app/assets/javascripts/admin/templates/flags-posts-active.hbs b/app/assets/javascripts/admin/templates/flags-posts-active.hbs new file mode 100644 index 0000000000..9f1c21e463 --- /dev/null +++ b/app/assets/javascripts/admin/templates/flags-posts-active.hbs @@ -0,0 +1 @@ +{{flagged-posts flaggedPosts=model filter="active"}} diff --git a/app/assets/javascripts/admin/templates/flags-posts-old.hbs b/app/assets/javascripts/admin/templates/flags-posts-old.hbs new file mode 100644 index 0000000000..b4f5284612 --- /dev/null +++ b/app/assets/javascripts/admin/templates/flags-posts-old.hbs @@ -0,0 +1 @@ +{{flagged-posts flaggedPosts=model filter="old"}} diff --git a/app/assets/javascripts/admin/templates/flags-topics-index.hbs b/app/assets/javascripts/admin/templates/flags-topics-index.hbs new file mode 100644 index 0000000000..b5a7eca133 --- /dev/null +++ b/app/assets/javascripts/admin/templates/flags-topics-index.hbs @@ -0,0 +1,46 @@ +{{plugin-outlet name="flagged-topics-before" noTags=true args=(hash flaggedTopics=flaggedTopics)}} + + + + {{plugin-outlet name="flagged-topic-header-row" noTags=true}} + + + + + + + + {{#each flaggedTopics as |ft|}} + + {{plugin-outlet name="flagged-topic-row" noTags=true args=(hash topic=ft.topic)}} + + + + + + + + {{/each}} +
    {{i18n "admin.flags.flagged_topics.topic"}} {{i18n "admin.flags.flagged_topics.type"}}{{I18n "admin.flags.flagged_topics.users"}}{{i18n "admin.flags.flagged_topics.last_flagged"}}
    + {{replace-emoji ft.topic.fancy_title}} + + {{#each ft.flag_counts as |fc|}} +
    + {{post-action-title fc.post_action_type_id fc.name_key}} + x{{fc.count}} +
    + {{/each}} +
    + {{flagged-topic-users users=ft.users tagName=""}} + + {{format-age ft.last_flag_at}} + + {{#link-to + "adminFlags.topics.show" + ft.id + class="btn d-button no-text btn-small btn-primary show-details" + title=(i18n "admin.flags.show_details")}} + {{d-icon "list"}} + {{i18n "admin.flags.details"}} + {{/link-to}} +
    diff --git a/app/assets/javascripts/admin/templates/flags-topics-show.hbs b/app/assets/javascripts/admin/templates/flags-topics-show.hbs new file mode 100644 index 0000000000..0494780220 --- /dev/null +++ b/app/assets/javascripts/admin/templates/flags-topics-show.hbs @@ -0,0 +1,19 @@ +
    +
    +

    + {{topic-status topic=topic}} + {{#link-to 'topic' topic target="_blank"}} + {{{topic.fancyTitle}}} + {{/link-to}} +

    +
    + + {{plugin-outlet name="flagged-topic-details-header" args=(hash topic=topic)}} +
    + +
    + {{flagged-posts + flaggedPosts=flaggedPosts + filter="active" + topic=topic}} +
    diff --git a/app/assets/javascripts/admin/templates/flags.hbs b/app/assets/javascripts/admin/templates/flags.hbs index b19e8e2276..d13ad994bc 100644 --- a/app/assets/javascripts/admin/templates/flags.hbs +++ b/app/assets/javascripts/admin/templates/flags.hbs @@ -1,6 +1,13 @@ {{#admin-nav}} - {{nav-item route='adminFlags.list' routeParam='active' label='admin.flags.active'}} - {{nav-item route='adminFlags.list' routeParam='old' label='admin.flags.old'}} + {{#if siteSettings.flags_default_topics}} + {{nav-item route='adminFlags.topics' label='admin.flags.topics'}} + {{nav-item route='adminFlags.postsActive' label='admin.flags.active_posts'}} + {{else}} + {{nav-item route='adminFlags.postsActive' label='admin.flags.active_posts'}} + {{nav-item route='adminFlags.topics' label='admin.flags.topics'}} + {{/if}} + + {{nav-item route='adminFlags.postsOld' label='admin.flags.old_posts' class='right'}} {{/admin-nav}}
    diff --git a/app/assets/javascripts/admin/templates/group.hbs b/app/assets/javascripts/admin/templates/group.hbs index 79dbcfc3b6..0689bd30ce 100644 --- a/app/assets/javascripts/admin/templates/group.hbs +++ b/app/assets/javascripts/admin/templates/group.hbs @@ -9,19 +9,18 @@ {{/if}}
    - {{#if model.id}} - {{#unless model.automatic}} -
    - - {{input type='text' name='full_name' value=model.full_name class='group-edit-full-name'}} -
    + {{#unless model.automatic}} +
    + + {{input type='text' name='full_name' value=model.full_name class='group-edit-full-name'}} +
    -
    - - {{d-editor value=model.bio_raw}} -
    +
    + + {{d-editor value=model.bio_raw}} +
    - {{#if model.hasOwners}} + {{#if model.hasOwners}}
    @@ -30,33 +29,55 @@ {{/each}}
    - {{/if}} -
    - - {{user-selector usernames=model.ownerUsernames placeholderKey="admin.groups.selector_placeholder" id="owner-selector"}} - {{d-button action="addOwners" class="add" icon="plus" label="admin.groups.add"}} -
    - {{/unless}} + {{/if}} +
    - {{group-members-input model=model}} + + + {{user-selector usernames=model.ownerUsernames + placeholderKey="admin.groups.selector_placeholder" + id="owner-selector"}} + + {{#if model.id}} + {{d-button + action="addOwners" + class="add" + icon="plus" + label="admin.groups.add"}} + {{/if}}
    - {{/if}} + {{/unless}}
    - + {{group-members-input model=model addButton=model.id}} +
    + +
    + + {{combo-box name="alias" + valueAttribute="value" + value=model.visibility_level + content=visibilityLevelOptions + castInteger=true}}
    {{#unless model.automatic}}
    +
    + +
    +
    @@ -70,8 +91,19 @@
    + {{#if model.allow_membership_requests}} +
    + + + {{expanding-text-area name="membership-request-template" + value=model.membership_request_template}} +
    + {{/if}} +
    -
    diff --git a/app/assets/javascripts/admin/templates/logs.hbs b/app/assets/javascripts/admin/templates/logs.hbs index 5c412949fa..b936904999 100644 --- a/app/assets/javascripts/admin/templates/logs.hbs +++ b/app/assets/javascripts/admin/templates/logs.hbs @@ -3,6 +3,8 @@ {{nav-item route='adminLogs.screenedEmails' label='admin.logs.screened_emails.title'}} {{nav-item route='adminLogs.screenedIpAddresses' label='admin.logs.screened_ips.title'}} {{nav-item route='adminLogs.screenedUrls' label='admin.logs.screened_urls.title'}} + {{nav-item route='adminWatchedWords' label='admin.watched_words.title'}} + {{nav-item route='adminSearchLogs' label='admin.logs.search_logs.title'}} {{#if currentUser.admin}} {{nav-item path='/logs' label='admin.logs.logster.title'}} {{/if}} diff --git a/app/assets/javascripts/admin/templates/logs/screened-emails.hbs b/app/assets/javascripts/admin/templates/logs/screened-emails.hbs index 685d8455c3..f99ff865e1 100644 --- a/app/assets/javascripts/admin/templates/logs/screened-emails.hbs +++ b/app/assets/javascripts/admin/templates/logs/screened-emails.hbs @@ -1,6 +1,6 @@

    {{i18n 'admin.logs.screened_emails.description'}} - +


    diff --git a/app/assets/javascripts/admin/templates/logs/screened-ip-addresses.hbs b/app/assets/javascripts/admin/templates/logs/screened-ip-addresses.hbs index ca6c216f50..00e67a9aa5 100644 --- a/app/assets/javascripts/admin/templates/logs/screened-ip-addresses.hbs +++ b/app/assets/javascripts/admin/templates/logs/screened-ip-addresses.hbs @@ -41,9 +41,9 @@
    {{#if item.isBlocked}} - {{fa-icon "ban"}} + {{d-icon "ban"}} {{else}} - {{fa-icon "check"}} + {{d-icon "check"}} {{/if}} {{item.actionName}}
    diff --git a/app/assets/javascripts/admin/templates/logs/screened-urls.hbs b/app/assets/javascripts/admin/templates/logs/screened-urls.hbs index 40aaceba70..7b1048f1b4 100644 --- a/app/assets/javascripts/admin/templates/logs/screened-urls.hbs +++ b/app/assets/javascripts/admin/templates/logs/screened-urls.hbs @@ -1,6 +1,6 @@

    {{i18n 'admin.logs.screened_urls.description'}} - +


    diff --git a/app/assets/javascripts/admin/templates/logs/staff-action-logs.hbs b/app/assets/javascripts/admin/templates/logs/staff-action-logs.hbs index f469ec266e..5df150c79f 100644 --- a/app/assets/javascripts/admin/templates/logs/staff-action-logs.hbs +++ b/app/assets/javascripts/admin/templates/logs/staff-action-logs.hbs @@ -1,36 +1,36 @@
    {{#if filtersExists}} -
    + {{else}} - {{i18n "admin.logs.staff_actions.filter"}} {{combo-box content=userHistoryActions nameProperty="name" value=filterActionId none="admin.logs.staff_actions.all"}} + {{i18n "admin.logs.staff_actions.filter"}} {{combo-box content=userHistoryActions value=filterActionId none="admin.logs.staff_actions.all"}} {{/if}}
    diff --git a/app/assets/javascripts/admin/templates/modal/admin-add-upload.hbs b/app/assets/javascripts/admin/templates/modal/admin-add-upload.hbs index 5023927af2..2bd1691a32 100644 --- a/app/assets/javascripts/admin/templates/modal/admin-add-upload.hbs +++ b/app/assets/javascripts/admin/templates/modal/admin-add-upload.hbs @@ -10,5 +10,5 @@ diff --git a/app/assets/javascripts/admin/templates/modal/admin-agree-flag.hbs b/app/assets/javascripts/admin/templates/modal/admin-agree-flag.hbs deleted file mode 100644 index 6f845b22c8..0000000000 --- a/app/assets/javascripts/admin/templates/modal/admin-agree-flag.hbs +++ /dev/null @@ -1,13 +0,0 @@ -{{#d-modal-body title="admin.flags.agree_flag_modal_title"}} - {{#if model.user_deleted}} - - {{else}} - {{#unless model.postHidden}} - - {{/unless}} - {{/if}} - - {{#if model.canDeleteAsSpammer}} - - {{/if}} -{{/d-modal-body}} diff --git a/app/assets/javascripts/admin/templates/modal/admin-badge-preview.hbs b/app/assets/javascripts/admin/templates/modal/admin-badge-preview.hbs index c22bd9d99d..70b0c1c3fc 100644 --- a/app/assets/javascripts/admin/templates/modal/admin-badge-preview.hbs +++ b/app/assets/javascripts/admin/templates/modal/admin-badge-preview.hbs @@ -24,7 +24,7 @@ {{#if count_warning}}
    -

    {{i18n 'admin.badges.preview.bad_count_warning.header'}}

    +

    {{d-icon "warning"}} {{i18n 'admin.badges.preview.bad_count_warning.header'}}

    {{i18n 'admin.badges.preview.bad_count_warning.text'}}

    {{/if}} diff --git a/app/assets/javascripts/admin/templates/modal/admin-color-scheme-select-base.hbs b/app/assets/javascripts/admin/templates/modal/admin-color-scheme-select-base.hbs index 5286bbf0b0..371c06df49 100644 --- a/app/assets/javascripts/admin/templates/modal/admin-color-scheme-select-base.hbs +++ b/app/assets/javascripts/admin/templates/modal/admin-color-scheme-select-base.hbs @@ -2,11 +2,11 @@ {{#d-modal-body title="admin.customize.colors.select_base.title"}} {{i18n "admin.customize.colors.select_base.description"}} {{combo-box content=model - nameProperty="name" value=selectedBaseThemeId + allowInitialValueMutation=true valueAttribute="base_scheme_id"}} {{/d-modal-body}}
    diff --git a/app/assets/javascripts/admin/templates/modal/admin-delete-flag.hbs b/app/assets/javascripts/admin/templates/modal/admin-delete-flag.hbs deleted file mode 100644 index c2abeae36d..0000000000 --- a/app/assets/javascripts/admin/templates/modal/admin-delete-flag.hbs +++ /dev/null @@ -1,7 +0,0 @@ -{{#d-modal-body title="admin.flags.delete_flag_modal_title"}} - - - {{#if model.canDeleteAsSpammer}} - - {{/if}} -{{/d-modal-body}} diff --git a/app/assets/javascripts/admin/templates/modal/admin-edit-badge-groupings.hbs b/app/assets/javascripts/admin/templates/modal/admin-edit-badge-groupings.hbs index 80eb0844b3..22a87af0df 100644 --- a/app/assets/javascripts/admin/templates/modal/admin-edit-badge-groupings.hbs +++ b/app/assets/javascripts/admin/templates/modal/admin-edit-badge-groupings.hbs @@ -5,15 +5,15 @@
  • {{#if wc.editing}} {{input value=wc.name}} - + {{else}} {{wc.displayName}} {{/if}}
    - - - - + + + +
  • {{/each}} @@ -24,5 +24,5 @@ diff --git a/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs b/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs index 7c4058d52d..19d7065db6 100644 --- a/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs +++ b/app/assets/javascripts/admin/templates/modal/admin-import-theme.hbs @@ -23,5 +23,5 @@ diff --git a/app/assets/javascripts/admin/templates/modal/admin-moderation-history.hbs b/app/assets/javascripts/admin/templates/modal/admin-moderation-history.hbs new file mode 100644 index 0000000000..4abc13e7f3 --- /dev/null +++ b/app/assets/javascripts/admin/templates/modal/admin-moderation-history.hbs @@ -0,0 +1,23 @@ +{{#d-modal-body title="admin.flags.moderation_history"}} + {{#conditional-loading-spinner condition=loading}} + {{#if history}} + + + + + + + {{#each history as |item|}} + {{moderation-history-item item=item}} + {{/each}} +
    {{i18n "admin.logs.created_at"}}{{i18n "admin.logs.action"}}{{i18n "admin.moderation_history.performed_by"}}
    + {{else}} +
    + {{i18n "admin.moderation_history.no_results"}} +
    + {{/if}} + {{/conditional-loading-spinner}} +{{/d-modal-body}} + diff --git a/app/assets/javascripts/admin/templates/modal/admin-silence-user.hbs b/app/assets/javascripts/admin/templates/modal/admin-silence-user.hbs new file mode 100644 index 0000000000..0abe77544c --- /dev/null +++ b/app/assets/javascripts/admin/templates/modal/admin-silence-user.hbs @@ -0,0 +1,50 @@ +{{#d-modal-body title="admin.user.silence_modal_title"}} + {{#conditional-loading-spinner condition=loadingUser}} + +
    + +
    + +
    + + {{text-field + value=reason + class="silence-reason" + placeholderKey="admin.user.silence_reason_placeholder"}} +
    + + + {{textarea + value=message + class="silence-message" + placeholder=(i18n "admin.user.silence_message_placeholder")}} + + + {{/conditional-loading-spinner}} + +{{/d-modal-body}} + + \ No newline at end of file diff --git a/app/assets/javascripts/admin/templates/modal/admin-staff-action-log-details.hbs b/app/assets/javascripts/admin/templates/modal/admin-staff-action-log-details.hbs index f63be2d098..e8e8cd8d2e 100644 --- a/app/assets/javascripts/admin/templates/modal/admin-staff-action-log-details.hbs +++ b/app/assets/javascripts/admin/templates/modal/admin-staff-action-log-details.hbs @@ -2,5 +2,5 @@
    {{model.details}}
    {{/d-modal-body}} diff --git a/app/assets/javascripts/admin/templates/modal/admin-suspend-user.hbs b/app/assets/javascripts/admin/templates/modal/admin-suspend-user.hbs index 9d9326ad3e..fc6025a6a6 100644 --- a/app/assets/javascripts/admin/templates/modal/admin-suspend-user.hbs +++ b/app/assets/javascripts/admin/templates/modal/admin-suspend-user.hbs @@ -1,16 +1,59 @@ {{#d-modal-body title="admin.user.suspend_modal_title"}} -
    - {{i18n 'admin.user.suspend_duration'}} - {{text-field value=duration maxlength="5" autofocus="autofocus"}} - {{i18n 'admin.user.suspend_duration_units'}}
    -
    - {{{i18n 'admin.user.suspend_reason_label'}}}
    -
    - {{text-field value=reason class="span8"}} -
    + {{#conditional-loading-spinner condition=loadingUser}} + + {{#if user.canSuspend}} +
    + +
    + +
    + + {{text-field + value=reason + class="suspend-reason" + placeholderKey="admin.user.suspend_reason_placeholder"}} +
    + + + {{textarea + value=message + class="suspend-message" + placeholder=(i18n "admin.user.suspend_message_placeholder")}} + {{else}} +
    + {{i18n "admin.user.cant_suspend"}} +
    + {{/if}} + + {{/conditional-loading-spinner}} + {{/d-modal-body}} diff --git a/app/assets/javascripts/admin/templates/reports.hbs b/app/assets/javascripts/admin/templates/reports.hbs index 57acba8f50..3927d1e384 100644 --- a/app/assets/javascripts/admin/templates/reports.hbs +++ b/app/assets/javascripts/admin/templates/reports.hbs @@ -4,10 +4,10 @@ {{i18n 'admin.dashboard.reports.start_date'}} {{date-picker-past value=startDate defaultDate=startDate}} {{i18n 'admin.dashboard.reports.end_date'}} {{date-picker-past value=endDate defaultDate=endDate}} {{#if showCategoryOptions}} - {{combo-box valueAttribute="value" content=categoryOptions value=categoryId}} + {{combo-box filterable=true valueAttribute="value" content=categoryOptions value=categoryId}} {{/if}} {{#if showGroupOptions}} - {{combo-box valueAttribute="value" content=groupOptions value=groupId}} + {{combo-box filterable=true valueAttribute="value" content=groupOptions value=groupId}} {{/if}} {{d-button action="refreshReport" class="btn-primary" label="admin.dashboard.reports.refresh_report" icon="refresh"}} {{d-button action="exportCsv" label="admin.export_csv.button_text" icon="download"}} diff --git a/app/assets/javascripts/admin/templates/search-logs-index.hbs b/app/assets/javascripts/admin/templates/search-logs-index.hbs new file mode 100644 index 0000000000..257155b4b5 --- /dev/null +++ b/app/assets/javascripts/admin/templates/search-logs-index.hbs @@ -0,0 +1,33 @@ +

    + {{period-chooser period=period}} + {{combo-box content=searchTypeOptions value=searchType class='search-logs-filter'}} +

    +
    + +{{#conditional-loading-spinner condition=loading}} + {{#if model.length}} + +
    +
    +
    {{i18n 'admin.logs.search_logs.term'}}
    +
    {{i18n 'admin.logs.search_logs.searches'}}
    +
    {{i18n 'admin.logs.search_logs.click_through'}}
    +
    {{i18n 'admin.logs.search_logs.unique'}}
    +
    + + {{#each model as |item|}} +
    +
    + {{#link-to 'adminSearchLogs.term' item.term}}{{item.term}}{{/link-to}} +
    +
    {{item.searches}}
    +
    {{item.click_through}}
    +
    {{item.unique}}
    +
    + {{/each}} +
    + + {{else}} + {{i18n 'search.no_results'}} + {{/if}} +{{/conditional-loading-spinner}} diff --git a/app/assets/javascripts/admin/templates/search-logs-term.hbs b/app/assets/javascripts/admin/templates/search-logs-term.hbs new file mode 100644 index 0000000000..c405a86d35 --- /dev/null +++ b/app/assets/javascripts/admin/templates/search-logs-term.hbs @@ -0,0 +1,13 @@ +

    + {{period-chooser period=period}} + {{combo-box content=searchTypeOptions value=searchType class='search-logs-filter'}} +

    +
    + +

    + {{#link-to 'full-page-search' (query-params q=term)}}{{term}}{{/link-to}} +

    + +{{#conditional-loading-spinner condition=refreshing}} + {{admin-graph model=model}} +{{/conditional-loading-spinner}} diff --git a/app/assets/javascripts/admin/templates/site-settings.hbs b/app/assets/javascripts/admin/templates/site-settings.hbs index 5fd9545932..4561129eb8 100644 --- a/app/assets/javascripts/admin/templates/site-settings.hbs +++ b/app/assets/javascripts/admin/templates/site-settings.hbs @@ -15,14 +15,14 @@
    diff --git a/app/assets/javascripts/admin/templates/site-text-edit.hbs b/app/assets/javascripts/admin/templates/site-text-edit.hbs index 5e02eb0731..4e134433d8 100644 --- a/app/assets/javascripts/admin/templates/site-text-edit.hbs +++ b/app/assets/javascripts/admin/templates/site-text-edit.hbs @@ -13,7 +13,7 @@ {{/save-controls}} {{#link-to 'adminSiteText.index' class="go-back"}} - {{fa-icon 'arrow-left'}} + {{d-icon 'arrow-left'}} {{i18n 'admin.site_text.go_back'}} {{/link-to}} diff --git a/app/assets/javascripts/admin/templates/user-badges.hbs b/app/assets/javascripts/admin/templates/user-badges.hbs index 393695e842..1f6bae617c 100644 --- a/app/assets/javascripts/admin/templates/user-badges.hbs +++ b/app/assets/javascripts/admin/templates/user-badges.hbs @@ -1,7 +1,7 @@
    @@ -16,7 +16,7 @@
    - {{combo-box valueAttribute="id" value=selectedBadgeId content=grantableBadges nameProperty="name"}} + {{combo-box filterable=true value=selectedBadgeId content=grantableBadges}}
    {{/if}} @@ -174,6 +174,8 @@ {{/if}} +{{plugin-outlet name="admin-user-details" args=(hash model=model)}} +

    {{i18n 'admin.user.permissions'}}

    @@ -221,6 +223,12 @@
    +
    +
    {{i18n 'admin.user.staged'}}
    +
    {{i18n-yes-no model.staged}}
    +
    {{i18n 'admin.user.staged_explanation'}}
    +
    + {{#if currentUser.admin}}
    {{i18n 'admin.api.key'}}
    @@ -280,10 +288,12 @@
    {{#if model.canLockTrustLevel}} - {{#if model.trust_level_locked}} - {{d-button action="lockTrustLevel" actionParam=false label="admin.user.unlock_trust_level"}} + {{#if model.manual_locked_trust_level}} + {{d-icon "lock" title="admin.user.trust_level_locked_tip"}} + {{d-button action="lockTrustLevel" actionParam=false label="admin.user.unlock_trust_level"}} {{else}} - {{d-button action="lockTrustLevel" actionParam=true label="admin.user.lock_trust_level"}} + {{d-icon "unlock" title="admin.user.trust_level_unlocked_tip"}} + {{d-button action="lockTrustLevel" actionParam=true label="admin.user.lock_trust_level"}} {{/if}} {{/if}} {{#if model.tl3Requirements}} @@ -292,25 +302,39 @@
    -
    -
    {{i18n 'admin.user.suspended'}}
    -
    {{i18n-yes-no model.isSuspended}}
    -
    - {{#if model.isSuspended}} - {{d-button class="btn-danger" action="unsuspend" icon="ban" label="admin.user.unsuspend"}} - {{suspendDuration}} - {{i18n 'admin.user.suspended_explanation'}} - {{else}} - {{#if model.canSuspend}} - {{d-button class="btn-danger" action="showSuspendModal" actionParam=model icon="ban" label="admin.user.suspend"}} +
    +
    {{i18n 'admin.user.suspended'}}
    +
    + {{i18n-yes-no model.isSuspended}} + {{#if model.isSuspended}} + {{#unless model.suspendedForever}} + {{i18n "admin.user.suspended_until" until=model.suspendedTillDate}} + {{/unless}} + {{/if}} +
    +
    + {{#if model.isSuspended}} + {{d-button + class="btn-danger unsuspend-user" + action=(action "unsuspend") + icon="ban" + label="admin.user.unsuspend"}} {{i18n 'admin.user.suspended_explanation'}} + {{else}} + {{#if model.canSuspend}} + {{d-button + class="btn-danger suspend-user" + action=(action "showSuspendModal") + icon="ban" + label="admin.user.suspend"}} + {{i18n 'admin.user.suspended_explanation'}} + {{/if}} {{/if}} - {{/if}}
    {{#if model.isSuspended}} -
    +
    {{i18n 'admin.user.suspended_by'}}
    {{#link-to 'adminUser' suspendedBy}}{{avatar model.suspendedBy imageSize="tiny"}}{{/link-to}} @@ -318,32 +342,56 @@
    {{i18n 'admin.user.suspend_reason'}}: - {{model.suspend_reason}} +
    {{model.full_suspend_reason}}
    {{/if}} -
    -
    {{i18n 'admin.user.blocked'}}
    -
    {{i18n-yes-no model.blocked}}
    +
    +
    {{i18n 'admin.user.silenced'}}
    +
    + {{i18n-yes-no model.silenced}} + {{#if model.isSilenced}} + {{#unless model.silencedForever}} + {{i18n "admin.user.suspended_until" until=model.silencedTillDate}} + {{/unless}} + {{/if}} +
    - {{#conditional-loading-spinner size="small" condition=model.blockingUser}} - {{#if model.blocked}} - {{d-button action="unblock" icon="thumbs-o-up" label="admin.user.unblock"}} - {{i18n 'admin.user.block_explanation'}} + {{#conditional-loading-spinner size="small" condition=model.silencingUser}} + {{#if model.silenced}} + {{d-button + class="btn-danger unsilence-user" + action="unsilence" + icon="microphone-slash" + label="admin.user.unsilence"}} + {{i18n 'admin.user.silence_explanation'}} {{else}} - {{d-button action="block" icon="ban" label="admin.user.block"}} - {{i18n 'admin.user.block_explanation'}} + {{d-button + class="btn-danger silence-user" + action=(action "showSilenceModal") + icon="microphone-slash" + label="admin.user.silence"}} + {{i18n 'admin.user.silence_explanation'}} {{/if}} {{/conditional-loading-spinner}}
    -
    -
    {{i18n 'admin.user.staged'}}
    -
    {{i18n-yes-no model.staged}}
    -
    {{i18n 'admin.user.staged_explanation'}}
    -
    + {{#if model.isSilenced}} +
    +
    {{i18n 'admin.user.silenced_by'}}
    +
    + {{#link-to 'adminUser' silencedBy}}{{avatar model.silencedBy imageSize="tiny"}}{{/link-to}} + {{#link-to 'adminUser' silencedBy}}{{model.silencedBy.username}}{{/link-to}} +
    +
    + {{i18n 'admin.user.silence_reason'}}: +
    {{model.silence_reason}}
    +
    +
    + {{/if}} + {{#if currentUser.admin}} @@ -361,7 +409,7 @@
    {{#if model.customGroups}} {{i18n 'admin.groups.primary'}} - {{combo-box content=model.customGroups value=model.primary_group_id nameProperty="name" none="admin.groups.no_primary"}} + {{combo-box content=model.customGroups value=model.primary_group_id none="admin.groups.no_primary"}} {{/if}} {{#if primaryGroupDirty}} {{d-button icon="check" class="ok" action="savePrimaryGroup"}} @@ -381,7 +429,7 @@
    {{i18n 'admin.users.last_emailed'}}
    -
    {{format-date model.last_emailed_at leaveAgo="true"}}
    +
    {{format-date model.last_emailed_at}}
    {{i18n 'last_seen'}}
    @@ -426,7 +474,7 @@
    {{i18n 'admin.user.time_read'}}
    -
    {{{model.time_read}}}
    +
    {{{format-duration model.time_read}}}
    {{i18n 'user.invited.days_visited'}}
    @@ -464,7 +512,7 @@ {{/if}}
    -
    +
    {{#unless model.anonymizeForbidden}} {{d-button label="admin.user.anonymize" @@ -485,9 +533,9 @@ {{#if model.deleteExplanation}}
    -
    +
    - {{fa-icon "exclamation-triangle"}} {{model.deleteExplanation}} + {{d-icon "exclamation-triangle"}} {{model.deleteExplanation}}
    {{/if}}
    diff --git a/app/assets/javascripts/admin/templates/user-tl3-requirements.hbs b/app/assets/javascripts/admin/templates/user-tl3-requirements.hbs index e0536ddd08..1561a95670 100644 --- a/app/assets/javascripts/admin/templates/user-tl3-requirements.hbs +++ b/app/assets/javascripts/admin/templates/user-tl3-requirements.hbs @@ -1,7 +1,7 @@
    @@ -24,7 +24,7 @@ {{i18n 'admin.user.tl3_requirements.visits'}} - + {{check-icon model.tl3Requirements.met.days_visited}} {{model.tl3Requirements.days_visited_percent}}% ({{model.tl3Requirements.days_visited}} / {{model.tl3Requirements.time_period}} {{i18n 'admin.user.tl3_requirements.days'}}) @@ -32,67 +32,67 @@ {{i18n 'admin.user.tl3_requirements.topics_replied_to'}} - + {{check-icon model.tl3Requirements.met.topics_replied_to}} {{model.tl3Requirements.num_topics_replied_to}} {{model.tl3Requirements.min_topics_replied_to}} {{i18n 'admin.user.tl3_requirements.topics_viewed'}} - + {{check-icon model.tl3Requirements.met.topics_viewed}} {{model.tl3Requirements.topics_viewed}} {{model.tl3Requirements.min_topics_viewed}} {{i18n 'admin.user.tl3_requirements.topics_viewed_all_time'}} - + {{check-icon model.tl3Requirements.met.topics_viewed_all_time}} {{model.tl3Requirements.topics_viewed_all_time}} {{model.tl3Requirements.min_topics_viewed_all_time}} {{i18n 'admin.user.tl3_requirements.posts_read'}} - + {{check-icon model.tl3Requirements.met.posts_read}} {{model.tl3Requirements.posts_read}} {{model.tl3Requirements.min_posts_read}} {{i18n 'admin.user.tl3_requirements.posts_read_all_time'}} - + {{check-icon model.tl3Requirements.met.posts_read_all_time}} {{model.tl3Requirements.posts_read_all_time}} {{model.tl3Requirements.min_posts_read_all_time}} {{i18n 'admin.user.tl3_requirements.flagged_posts'}} - + {{check-icon model.tl3Requirements.met.flagged_posts}} {{model.tl3Requirements.num_flagged_posts}} {{i18n 'max_of_count' count=model.tl3Requirements.max_flagged_posts}} {{i18n 'admin.user.tl3_requirements.flagged_by_users'}} - + {{check-icon model.tl3Requirements.met.flagged_by_users}} {{model.tl3Requirements.num_flagged_by_users}} {{i18n 'max_of_count' count=model.tl3Requirements.max_flagged_by_users}} {{i18n 'admin.user.tl3_requirements.likes_given'}} - + {{check-icon model.tl3Requirements.met.likes_given}} {{model.tl3Requirements.num_likes_given}} {{model.tl3Requirements.min_likes_given}} {{i18n 'admin.user.tl3_requirements.likes_received'}} - + {{check-icon model.tl3Requirements.met.likes_received}} {{model.tl3Requirements.num_likes_received}} {{model.tl3Requirements.min_likes_received}} {{i18n 'admin.user.tl3_requirements.likes_received_days'}} - + {{check-icon model.tl3Requirements.met.likes_received_days}} {{model.tl3Requirements.num_likes_received_days}} {{model.tl3Requirements.min_likes_received_days}} {{i18n 'admin.user.tl3_requirements.likes_received_users'}} - + {{check-icon model.tl3Requirements.met.likes_received_users}} {{model.tl3Requirements.num_likes_received_users}} {{model.tl3Requirements.min_likes_received_users}} @@ -105,16 +105,16 @@ {{#if model.tl3Requirements.requirements_lost}} {{! tl implicitly not locked }} {{#if model.tl3Requirements.on_grace_period}} - {{i18n 'admin.user.tl3_requirements.on_grace_period'}} + {{d-icon "times"}} {{i18n 'admin.user.tl3_requirements.on_grace_period'}} {{else}} {{! not on grace period }} - {{i18n 'admin.user.tl3_requirements.does_not_qualify'}} + {{d-icon "times"}} {{i18n 'admin.user.tl3_requirements.does_not_qualify'}} {{i18n 'admin.user.tl3_requirements.will_be_demoted'}} {{/if}} {{else}} {{! requirements not lost - remains tl3 }} {{#if model.tl3Requirements.trust_level_locked}} - {{i18n 'admin.user.tl3_requirements.locked_will_not_be_demoted'}} + {{d-icon "lock"}} {{i18n 'admin.user.tl3_requirements.locked_will_not_be_demoted'}} {{else}} {{! tl not locked }} - {{i18n 'admin.user.tl3_requirements.qualifies'}} + {{d-icon "check"}} {{i18n 'admin.user.tl3_requirements.qualifies'}} {{#if model.tl3Requirements.on_grace_period}} {{i18n 'admin.user.tl3_requirements.on_grace_period'}} {{/if}} @@ -123,13 +123,13 @@ {{else}} {{! is not tl3 }} {{#if model.tl3Requirements.requirements_met}} {{! met & not tl3 - will be promoted}} - {{i18n 'admin.user.tl3_requirements.qualifies'}} + {{d-icon "check"}} {{i18n 'admin.user.tl3_requirements.qualifies'}} {{i18n 'admin.user.tl3_requirements.will_be_promoted'}} {{else}} {{! requirements not met - remains regular }} {{#if model.tl3Requirements.trust_level_locked}} - {{i18n 'admin.user.tl3_requirements.locked_will_not_be_promoted'}} + {{d-icon "lock"}} {{i18n 'admin.user.tl3_requirements.locked_will_not_be_promoted'}} {{else}} - {{i18n 'admin.user.tl3_requirements.does_not_qualify'}} + {{d-icon "times"}} {{i18n 'admin.user.tl3_requirements.does_not_qualify'}} {{/if}} {{/if}} {{/if}} diff --git a/app/assets/javascripts/admin/templates/users-list-show.hbs b/app/assets/javascripts/admin/templates/users-list-show.hbs index cc0c5ce09e..0ccaee0251 100644 --- a/app/assets/javascripts/admin/templates/users-list-show.hbs +++ b/app/assets/javascripts/admin/templates/users-list-show.hbs @@ -6,15 +6,13 @@ {{/if}}
    -
    -

    {{title}}

    -
    +

    {{title}}

    {{text-field value=listFilter placeholder=searchHint}} {{#unless showEmails}}
    - +
    {{/unless}}
    @@ -28,7 +26,7 @@ {{/if}}   {{i18n 'username'}} - {{i18n 'email'}} + {{i18n 'email'}} {{i18n 'admin.users.last_emailed'}} {{admin-directory-toggle field="seen" i18nKey='last_seen' order=order ascending=ascending}} {{admin-directory-toggle field="topics_viewed" i18nKey="admin.user.topics_entered" order=order ascending=ascending}} @@ -55,17 +53,20 @@ {{avatar user imageSize="small"}} - + {{#link-to 'adminUser' user}}{{unbound user.username}}{{/link-to}} + {{#if user.staged}} + {{d-icon "envelope-o" title="user.staged" }} + {{/if}} {{unbound user.email}} - {{{unbound user.last_emailed_age}}} + {{{format-duration user.last_emailed_age}}} - {{{unbound user.last_seen_age}}} + {{{format-duration user.last_seen_age}}} {{number user.topics_entered}} @@ -74,11 +75,11 @@ {{number user.posts_read_count}} - {{{unbound user.time_read}}} + {{{format-duration user.time_read}}} - {{{unbound user.created_at_age}}} + {{{format-duration user.created_at_age}}} {{#if showApproval}} @@ -92,10 +93,10 @@ {{/if}} {{#if user.admin}} - {{fa-icon "shield" title="admin.title" }} + {{d-icon "shield" title="admin.title" }} {{/if}} {{#if user.moderator}} - {{fa-icon "shield" title="admin.moderator" }} + {{d-icon "shield" title="admin.moderator" }} {{/if}} diff --git a/app/assets/javascripts/admin/templates/users-list.hbs b/app/assets/javascripts/admin/templates/users-list.hbs index d37a9ae267..398732685b 100644 --- a/app/assets/javascripts/admin/templates/users-list.hbs +++ b/app/assets/javascripts/admin/templates/users-list.hbs @@ -8,7 +8,7 @@ {{/if}} {{nav-item route='adminUsersList.show' routeParam='staff' label='admin.users.nav.staff'}} {{nav-item route='adminUsersList.show' routeParam='suspended' label='admin.users.nav.suspended'}} - {{nav-item route='adminUsersList.show' routeParam='blocked' label='admin.users.nav.blocked'}} + {{nav-item route='adminUsersList.show' routeParam='silenced' label='admin.users.nav.silenced'}} {{nav-item route='adminUsersList.show' routeParam='suspect' label='admin.users.nav.suspect'}}
    diff --git a/app/assets/javascripts/admin/templates/version-checks.hbs b/app/assets/javascripts/admin/templates/version-checks.hbs index 25c560ad6a..a8724b7e5a 100644 --- a/app/assets/javascripts/admin/templates/version-checks.hbs +++ b/app/assets/javascripts/admin/templates/version-checks.hbs @@ -18,19 +18,19 @@ {{#if versionCheck.noCheckPerformed}} — - {{fa-icon "frown-o"}} + {{d-icon "frown-o"}} {{i18n 'admin.dashboard.no_check_performed'}} {{else}} - {{#if versionCheck.staleData}} + {{#if versionCheck.stale_data}} {{#if versionCheck.version_check_pending}}{{dash-if-empty versionCheck.installed_version}}{{/if}} {{#if versionCheck.version_check_pending}} - {{fa-icon "smile-o"}} + {{d-icon "smile-o"}} {{else}} - {{fa-icon "frown-o"}} + {{d-icon "frown-o"}} {{/if}} @@ -46,13 +46,13 @@ {{dash-if-empty versionCheck.latest_version}} {{#if versionCheck.upToDate }} - {{fa-icon "smile-o"}} + {{d-icon "smile-o"}} {{else}} {{#if versionCheck.behindByOneVersion}} - {{fa-icon "meh-o"}} + {{d-icon "meh-o"}} {{else}} - {{fa-icon "frown-o"}} + {{d-icon "frown-o"}} {{/if}} {{/if}} diff --git a/app/assets/javascripts/admin/templates/watched-words-action.hbs b/app/assets/javascripts/admin/templates/watched-words-action.hbs new file mode 100644 index 0000000000..8377c28bd5 --- /dev/null +++ b/app/assets/javascripts/admin/templates/watched-words-action.hbs @@ -0,0 +1,21 @@ +

    {{model.name}}

    + +

    {{actionDescription}}

    + +{{watched-word-form + actionKey=actionNameKey + action="recordAdded" + regularExpressions=adminWatchedWords.regularExpressions}} + +{{watched-word-uploader uploading=uploading actionKey=actionNameKey done="uploadComplete"}} + +
    +
    + {{#if showWordsList}} + {{#each filteredContent as |word| }} +
    {{admin-watched-word word=word action="recordRemoved"}}
    + {{/each}} + {{else}} + {{i18n 'admin.watched_words.word_count' count=model.words.length}} + {{/if}} +
    diff --git a/app/assets/javascripts/admin/templates/watched-words.hbs b/app/assets/javascripts/admin/templates/watched-words.hbs new file mode 100644 index 0000000000..7bec3d8812 --- /dev/null +++ b/app/assets/javascripts/admin/templates/watched-words.hbs @@ -0,0 +1,31 @@ +
    + +
    + {{text-field value=filter placeholderKey="admin.watched_words.search" class="no-blur"}} + {{d-button action="clearFilter" label="admin.watched_words.clear_filter"}} +
    +
    + +
    + +
    + +
    + {{outlet}} +
    + +
    diff --git a/app/assets/javascripts/admin/templates/web-hooks-show-events.hbs b/app/assets/javascripts/admin/templates/web-hooks-show-events.hbs index f3b8825e5b..e0d0609871 100644 --- a/app/assets/javascripts/admin/templates/web-hooks-show-events.hbs +++ b/app/assets/javascripts/admin/templates/web-hooks-show-events.hbs @@ -1,10 +1,10 @@
    {{#link-to 'adminWebHooks' tagName='button' classNames='btn'}} - {{fa-icon 'list'}} {{i18n 'admin.web_hooks.events.go_list'}} + {{d-icon 'list'}} {{i18n 'admin.web_hooks.events.go_list'}} {{/link-to}} {{d-button icon="send" label="admin.web_hooks.events.ping" action="ping" disabled=pingDisabled}} {{#link-to 'adminWebHooks.show' model.extras.web_hook_id tagName='button' classNames='btn'}} - {{fa-icon 'edit'}} {{i18n 'admin.web_hooks.events.go_details'}} + {{d-icon 'edit'}} {{i18n 'admin.web_hooks.events.go_details'}} {{/link-to}}
    diff --git a/app/assets/javascripts/admin/templates/web-hooks-show.hbs b/app/assets/javascripts/admin/templates/web-hooks-show.hbs index c7a8e03088..ef9c478774 100644 --- a/app/assets/javascripts/admin/templates/web-hooks-show.hbs +++ b/app/assets/javascripts/admin/templates/web-hooks-show.hbs @@ -1,5 +1,5 @@ {{#link-to 'adminWebHooks' class="go-back"}} - {{fa-icon 'arrow-left'}} + {{d-icon 'arrow-left'}} {{i18n 'admin.web_hooks.go_back'}} {{/link-to}} @@ -16,8 +16,6 @@ {{combo-box content=contentTypes name="content-type" - nameProperty="name" - valueAttribute="id" value=model.content_type}}
    @@ -49,12 +47,12 @@
    - - {{category-selector categories=model.categories blacklist=model.categories}} + + {{category-selector categories=model.categories}}
    {{i18n 'admin.web_hooks.categories_filter_instructions'}}
    - + {{group-selector groupNames=model.groupsFilterInName groupFinder=model.groupFinder}}
    {{i18n 'admin.web_hooks.groups_filter_instructions'}}
    diff --git a/app/assets/javascripts/admin/templates/web-hooks.hbs b/app/assets/javascripts/admin/templates/web-hooks.hbs index 082cf170b4..81d4b86e4d 100644 --- a/app/assets/javascripts/admin/templates/web-hooks.hbs +++ b/app/assets/javascripts/admin/templates/web-hooks.hbs @@ -1,6 +1,6 @@
    {{#link-to 'adminWebHooks.show' 'new' tagName='button' classNames='btn'}} - {{fa-icon 'plus'}} {{i18n 'admin.web_hooks.new'}} + {{d-icon 'plus'}} {{i18n 'admin.web_hooks.new'}} {{/link-to}}
    @@ -24,7 +24,7 @@ {{#link-to 'adminWebHooks.show' webHook}}{{webHook.payload_url}}{{/link-to}} {{webHook.description}} - {{#link-to 'adminWebHooks.show' webHook tagName='button' classNames='btn btn-default no-text'}}{{fa-icon 'edit'}}{{/link-to}} + {{#link-to 'adminWebHooks.show' webHook tagName='button' classNames='btn btn-default no-text'}}{{d-icon 'edit'}}{{/link-to}} {{d-button class="destroy btn-danger" action='destroy' actionParam=webHook icon="remove"}} diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 29c8e300b1..6b0d64be84 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -4,6 +4,7 @@ //= require ./ember-addons/ember-computed-decorators //= require ./ember-addons/fmt //= require_tree ./discourse-common +//= require_tree ./select-kit //= require ./discourse //= require ./deprecated @@ -27,15 +28,21 @@ //= require ./discourse/lib/eyeline //= require ./discourse/lib/show-modal //= require ./discourse/mixins/scrolling +//= require ./discourse/lib/ajax-error //= require ./discourse/models/model //= require ./discourse/models/rest +//= require ./discourse/models/result-set +//= require ./discourse/models/store +//= require ./discourse/models/action-summary +//= require ./discourse/models/topic +//= require ./discourse/models/draft +//= require ./discourse/models/composer //= require ./discourse/models/badge-grouping //= require ./discourse/models/badge //= require ./discourse/models/permission-type //= require ./discourse/models/user-action-group //= require ./discourse/models/category //= require ./discourse/models/input-validation -//= require ./discourse/lib/ajax-error //= require ./discourse/lib/search //= require ./discourse/lib/user-search //= require ./discourse/lib/export-csv @@ -44,10 +51,7 @@ //= require ./discourse/lib/debounce //= require ./discourse/lib/safari-hacks //= require_tree ./discourse/adapters -//= require ./discourse/models/result-set -//= require ./discourse/models/store //= require ./discourse/models/post-action-type -//= require ./discourse/models/action-summary //= require ./discourse/models/post //= require ./discourse/lib/posts-with-placeholders //= require ./discourse/models/post-stream @@ -63,11 +67,8 @@ //= require ./discourse/controllers/navigation/default //= require ./discourse/components/edit-category-panel //= require ./discourse/components/dropdown-button -//= require ./discourse/components/notifications-button //= require ./discourse/lib/link-mentions //= require ./discourse/components/site-header -//= require ./discourse/lib/emoji/groups -//= require ./discourse/lib/emoji/toolbar //= require ./discourse/components/d-editor //= require ./discourse/lib/screen-track //= require ./discourse/routes/discourse diff --git a/app/assets/javascripts/deprecated.js b/app/assets/javascripts/deprecated.js index 151ed975d0..e96105b581 100644 --- a/app/assets/javascripts/deprecated.js +++ b/app/assets/javascripts/deprecated.js @@ -1,7 +1,4 @@ +// ensure Discourse is added as a global (function() { - var Discourse = require('discourse').default; - - Discourse.dialect_deprecated = true; - - window.Discourse = Discourse; + window.Discourse = requirejs('discourse').default; })(); diff --git a/app/assets/javascripts/discourse-common/components/combo-box.js.es6 b/app/assets/javascripts/discourse-common/components/combo-box.js.es6 deleted file mode 100644 index e5828e8fc7..0000000000 --- a/app/assets/javascripts/discourse-common/components/combo-box.js.es6 +++ /dev/null @@ -1,145 +0,0 @@ -import { bufferedRender } from 'discourse-common/lib/buffered-render'; -import { on, observes } from 'ember-addons/ember-computed-decorators'; - -export default Ember.Component.extend(bufferedRender({ - tagName: 'select', - attributeBindings: ['tabindex', 'disabled'], - classNames: ['combobox'], - valueAttribute: 'id', - nameProperty: 'name', - - buildBuffer(buffer) { - const nameProperty = this.get('nameProperty'); - const none = this.get('none'); - let noneValue = null; - - // Add none option if required - if (typeof none === "string") { - buffer.push('"); - } else if (typeof none === "object") { - noneValue = Em.get(none, this.get('valueAttribute')); - buffer.push(``); - } - - let selected = this.get('value'); - if (!Em.isNone(selected)) { selected = selected.toString(); } - - let selectedFound = false; - let firstVal = undefined; - const content = this.get('content'); - - if (content) { - let first = true; - content.forEach(o => { - let val = o[this.get('valueAttribute')]; - if (typeof val === "undefined") { val = o; } - if (!Em.isNone(val)) { val = val.toString(); } - - const selectedText = (val === selected) ? "selected" : ""; - const name = Handlebars.Utils.escapeExpression(Ember.get(o, nameProperty) || o); - - if (val === selected) { - selectedFound = true; - } - if (first) { - firstVal = val; - first = false; - } - buffer.push(``); - }); - } - - if (!selectedFound && !noneValue) { - if (none) { - this.set('value', null); - } else { - this.set('value', firstVal); - } - } - - Ember.run.scheduleOnce('afterRender', this, this._updateSelect2); - }, - - @observes('value') - valueChanged() { - const $combo = this.$(), - val = this.get('value'); - - if (val !== undefined && val !== null) { - $combo.select2('val', val.toString()); - } else { - $combo.select2('val', null); - } - }, - - @observes('content.[]') - _rerenderOnChange() { - this.rerenderBuffer(); - }, - - didInsertElement() { - this._super(); - - // Workaround for https://github.com/emberjs/ember.js/issues/9813 - // Can be removed when fixed. Without it, the wrong option is selected - this.$('option').each((i, o) => o.selected = !!$(o).attr('selected')); - - // observer for item names changing (optional) - if (this.get('nameChanges')) { - this.addObserver('content.@each.' + this.get('nameProperty'), this.rerenderBuffer); - } - - const $elem = this.$(); - const caps = this.capabilities; - const minimumResultsForSearch = this.get('minimumResultsForSearch') || ((caps && caps.isIOS) ? -1 : 5); - - if (!this.get("selectionTemplate") && this.get("selectionIcon")) { - this.selectionTemplate = (item) => { - let name = Em.get(item, 'text'); - name = Handlebars.escapeExpression(name); - return `${name}`; - }; - } - - const options = { - minimumResultsForSearch, - width: this.get('width') || 'resolve', - allowClear: true - }; - - if (this.comboTemplate) { - options.formatResult = this.comboTemplate.bind(this); - } - - if (this.selectionTemplate) { - options.formatSelection = this.selectionTemplate.bind(this); - } - - $elem.select2(options); - - const castInteger = this.get('castInteger'); - $elem.on("change", e => { - let val = $(e.target).val(); - if (val && val.length && castInteger) { - val = parseInt(val, 10); - } - this.set('value', val); - }); - - Ember.run.scheduleOnce('afterRender', this, this._triggerChange); - }, - - _updateSelect2() { - this.$().trigger('change.select2'); - }, - - _triggerChange() { - this.$().trigger('change'); - }, - - @on('willDestroyElement') - _destroyDropdown() { - this.$().select2('destroy'); - } - -})); diff --git a/app/assets/javascripts/discourse-common/helpers/d-icon.js.es6 b/app/assets/javascripts/discourse-common/helpers/d-icon.js.es6 new file mode 100644 index 0000000000..b85b9ebf26 --- /dev/null +++ b/app/assets/javascripts/discourse-common/helpers/d-icon.js.es6 @@ -0,0 +1,6 @@ +import { registerUnbound } from 'discourse-common/lib/helpers'; +import { renderIcon } from 'discourse-common/lib/icon-library'; + +registerUnbound('d-icon', function(id, params) { + return new Handlebars.SafeString(renderIcon('string', id, params)); +}); diff --git a/app/assets/javascripts/discourse-common/helpers/fa-icon.js.es6 b/app/assets/javascripts/discourse-common/helpers/fa-icon.js.es6 index 3c1f8b9b5a..bd736c8482 100644 --- a/app/assets/javascripts/discourse-common/helpers/fa-icon.js.es6 +++ b/app/assets/javascripts/discourse-common/helpers/fa-icon.js.es6 @@ -1,25 +1,12 @@ import { registerUnbound } from 'discourse-common/lib/helpers'; +import { renderIcon } from 'discourse-common/lib/icon-library'; +import deprecated from 'discourse-common/lib/deprecated'; -export function iconClasses(icon, params) { - let classes = "fa fa-" + icon; - if (params.modifier) { classes += " fa-" + params.modifier; } - if (params['class']) { classes += ' ' + params['class']; } - return classes; -} - -export function iconHTML(icon, params) { - params = params || {}; - - var html = ""; - } - return html; +export function iconHTML(id, params) { + return renderIcon('string', id, params); } registerUnbound('fa-icon', function(icon, params) { + deprecated("Use `{{d-icon}}` instead of `{{fa-icon}}"); return new Handlebars.SafeString(iconHTML(icon, params)); }); diff --git a/app/assets/javascripts/discourse-common/lib/get-owner.js.es6 b/app/assets/javascripts/discourse-common/lib/get-owner.js.es6 index ada334f075..09f23c7b0d 100644 --- a/app/assets/javascripts/discourse-common/lib/get-owner.js.es6 +++ b/app/assets/javascripts/discourse-common/lib/get-owner.js.es6 @@ -15,7 +15,11 @@ export function getRegister(obj) { const register = { lookup: (...args) => owner.lookup(...args), lookupFactory: (...args) => { - return owner.lookupFactory ? owner.lookupFactory(...args) : owner._lookupFactory(...args); + if (owner.factoryFor) { + return owner.factoryFor(...args); + } else if (owner._lookupFactory) { + return owner._lookupFactory(...args); + } }, deprecateContainer(target) { diff --git a/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 b/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 new file mode 100644 index 0000000000..84242d03f6 --- /dev/null +++ b/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 @@ -0,0 +1,113 @@ +import { h } from 'virtual-dom'; +let _renderers = []; + +const REPLACEMENTS = { + 'd-tracking': 'circle', + 'd-muted': 'times-circle', + 'd-regular': 'circle-o', + 'd-watching': 'exclamation-circle', + 'd-watching-first': 'dot-circle-o', + 'd-drop-expanded': 'caret-down', + 'd-drop-collapsed': 'caret-right', + 'd-unliked': 'heart', + 'd-liked': 'heart', + 'notification.mentioned': "at", + 'notification.group_mentioned': "at", + 'notification.quoted': "quote-right", + 'notification.replied': "reply", + 'notification.posted': "reply", + 'notification.edited': "pencil", + 'notification.liked': "heart", + 'notification.liked_2': "heart", + 'notification.liked_many': "heart", + 'notification.private_message': "envelope-o", + 'notification.invited_to_private_message': "envelope-o", + 'notification.invited_to_topic': "hand-o-right", + 'notification.invitee_accepted': "user", + 'notification.moved_post': "sign-out", + 'notification.linked': "link", + 'notification.granted_badge': "certificate", + 'notification.topic_reminder': "hand-o-right", + 'notification.watching_first_post': "dot-circle-o", + 'notification.group_message_summary': "group" +}; + +export function replaceIcon(source, destination) { + REPLACEMENTS[source] = destination; +} + +export function renderIcon(renderType, id, params) { + for (let i=0; i<_renderers.length; i++) { + let renderer = _renderers[i]; + let rendererForType = renderer[renderType]; + + if (rendererForType) { + const icon = { id, replacementId: REPLACEMENTS[id] }; + let result = rendererForType(icon, params || {}); + if (result) { + return result; + } + } + } +} + +export function iconHTML(id, params) { + return renderIcon('string', id, params); +} + +export function iconNode(id, params) { + return renderIcon('node', id, params); +} + +// TODO: Improve how helpers are registered for vdom compliation +if (typeof Discourse !== "undefined") { + Discourse.__widget_helpers.iconNode = iconNode; +} + +export function registerIconRenderer(renderer) { + _renderers.unshift(renderer); +} + +// Support for font awesome icons +function faClasses(icon, params) { + let classNames = `fa fa-${icon.replacementId || icon.id} d-icon d-icon-${icon.id}`; + + if (params) { + if (params.modifier) { classNames += " fa-" + params.modifier; } + if (params['class']) { classNames += ' ' + params['class']; } + } + return classNames; +} + +// default resolver is font awesome +registerIconRenderer({ + name: 'font-awesome', + + string(icon, params) { + let tagName = params.tagName || 'i'; + let html = `<${tagName} class='${faClasses(icon, params)}'`; + if (params.title) { html += ` title='${I18n.t(params.title)}'`; } + if (params.label) { html += " aria-hidden='true'"; } + html += `>`; + if (params.label) { + html += "" + I18n.t(params.label) + ""; + } + return html; + }, + + node(icon, params) { + let tagName = params.tagName || 'i'; + + const properties = { + className: faClasses(icon, params), + attributes: { "aria-hidden": true } + }; + + if (params.title) { properties.attributes.title = params.title; } + if (params.label) { + return h(tagName, properties, h('span.sr-only', I18n.t(params.label))); + } else { + return h(tagName, properties); + } + } +}); diff --git a/app/assets/javascripts/discourse-common/resolver.js.es6 b/app/assets/javascripts/discourse-common/resolver.js.es6 index b400d0b788..4c2dec2f0b 100644 --- a/app/assets/javascripts/discourse-common/resolver.js.es6 +++ b/app/assets/javascripts/discourse-common/resolver.js.es6 @@ -38,7 +38,7 @@ export function buildResolver(baseName) { resolveRouter(parsedName) { const routerPath = `${baseName}/router`; if (requirejs.entries[routerPath]) { - const module = require(routerPath, null, null, true); + const module = requirejs(routerPath, null, null, true); return module.default; } return this._super(parsedName); @@ -79,7 +79,7 @@ export function buildResolver(baseName) { var module; if (moduleName) { - module = require(moduleName, null, null, true /* force sync */); + module = requirejs(moduleName, null, null, true /* force sync */); if (module && module['default']) { module = module['default']; } } return module; @@ -194,7 +194,6 @@ export function buildResolver(baseName) { // (similar to how discourse lays out templates) findAdminTemplate(parsedName) { var decamelized = parsedName.fullNameWithoutType.decamelize(); - if (decamelized.indexOf('components') === 0) { const compTemplate = Ember.TEMPLATES['admin/templates/' + decamelized]; if (compTemplate) { return compTemplate; } diff --git a/app/assets/javascripts/discourse-loader.js b/app/assets/javascripts/discourse-loader.js index 2d9f1d9f35..dee43e5a9c 100644 --- a/app/assets/javascripts/discourse-loader.js +++ b/app/assets/javascripts/discourse-loader.js @@ -1,4 +1,4 @@ -var define, require, requirejs; +var define, requirejs; (function() { @@ -54,7 +54,7 @@ var define, require, requirejs; var name = this.name; return this._require || (this._require = function(dep) { - return require(resolve(dep, name)); + return requirejs(resolve(dep, name)); }); }; @@ -127,7 +127,7 @@ var define, require, requirejs; if (!mod) { throw new Error('Could not find module `' + name + '` imported from `' + origin + '`'); } - return require(name); + return requirejs(name); } function missingModule(name) { diff --git a/app/assets/javascripts/discourse.js.es6 b/app/assets/javascripts/discourse.js.es6 index 5ca85473c5..e8f252c5a9 100644 --- a/app/assets/javascripts/discourse.js.es6 +++ b/app/assets/javascripts/discourse.js.es6 @@ -7,6 +7,11 @@ const Discourse = Ember.Application.extend({ rootElement: '#main', _docTitle: document.title, RAW_TEMPLATES: {}, + __widget_helpers: {}, + showingSignup: false, + customEvents: { + paste: 'paste' + }, getURL(url) { if (!url) return url; @@ -102,7 +107,7 @@ const Discourse = Ember.Application.extend({ Object.keys(requirejs._eak_seen).forEach(function(key) { if (/\/pre\-initializers\//.test(key)) { - const module = require(key, null, null, true); + const module = requirejs(key, null, null, true); if (!module) { throw new Error(key + ' must export an initializer.'); } const init = module.default; @@ -117,7 +122,7 @@ const Discourse = Ember.Application.extend({ Object.keys(requirejs._eak_seen).forEach(function(key) { if (/\/initializers\//.test(key)) { - const module = require(key, null, null, true); + const module = requirejs(key, null, null, true); if (!module) { throw new Error(key + ' must export an initializer.'); } const init = module.default; @@ -131,7 +136,7 @@ const Discourse = Ember.Application.extend({ }); // Plugins that are registered via `".html_safe end @@ -72,9 +93,17 @@ module ApplicationHelper end def body_classes + result = ApplicationHelper.extra_body_classes.to_a + if @category && @category.url.present? - "category-#{@category.url.sub(/^\/c\//, '').gsub(/\//, '-')}" + result << "category-#{@category.url.sub(/^\/c\//, '').gsub(/\//, '-')}" end + + if current_user.present? && primary_group_name = current_user.primary_group&.name + result << "primary-group-#{primary_group_name.downcase}" + end + + result.join(' ') end def rtl_class @@ -147,7 +176,7 @@ module ApplicationHelper end # Creates open graph and twitter card meta data - def crawlable_meta_data(opts=nil) + def crawlable_meta_data(opts = nil) opts ||= {} opts[:url] ||= "#{Discourse.base_url_no_prefix}#{request.fullpath}" @@ -187,10 +216,9 @@ module ApplicationHelper [:url, :title, :description].each do |property| if opts[property].present? - escape = (property != :image) content = (property == :url ? opts[property] : gsub_emoji_to_unicode(opts[property])) - result << tag(:meta, { property: "og:#{property}", content: content }, nil, escape) - result << tag(:meta, { name: "twitter:#{property}", content: content }, nil, escape) + result << tag(:meta, { property: "og:#{property}", content: content }, nil, true) + result << tag(:meta, { name: "twitter:#{property}", content: content }, nil, true) end end @@ -201,6 +229,10 @@ module ApplicationHelper result << tag(:meta, name: 'twitter:data2', value: "#{opts[:like_count]} ❤") end + if opts[:ignore_canonical] + result << tag(:meta, property: 'og:ignore_canonical', content: true) + end + result.join("\n") end @@ -219,9 +251,7 @@ module ApplicationHelper end def gsub_emoji_to_unicode(str) - if str - str.gsub(/:([\w\-+]*):/) { |name| Emoji.lookup_unicode($1) || name } - end + Emoji.gsub_emoji_to_unicode(str) end def application_logo_url @@ -233,11 +263,11 @@ module ApplicationHelper end def mobile_view? - MobileDetection.resolve_mobile_view!(request.user_agent,params,session) + MobileDetection.resolve_mobile_view!(request.user_agent, params, session) end def crawler_layout? - controller.try(:use_crawler_layout?) + controller&.use_crawler_layout? end def include_crawler_content? @@ -274,7 +304,7 @@ module ApplicationHelper controller.class.name.split("::").first == "Admin" end - def category_badge(category, opts=nil) + def category_badge(category, opts = nil) CategoryBadge.html_for(category, opts).html_safe end @@ -288,11 +318,11 @@ module ApplicationHelper return "" if Rails.env.test? matcher = Regexp.new("/connectors/#{name}/.*\.html\.erb$") - erbs = ApplicationHelper.all_connectors.select {|c| c =~ matcher } + erbs = ApplicationHelper.all_connectors.select { |c| c =~ matcher } return "" if erbs.blank? result = "" - erbs.each {|erb| result << render(file: erb) } + erbs.each { |erb| result << render(file: erb) } result.html_safe end @@ -316,17 +346,31 @@ module ApplicationHelper end end + def current_homepage + current_user&.user_option&.homepage || SiteSetting.anonymous_homepage + end + def build_plugin_html(name) return "" unless allow_plugins? DiscoursePluginRegistry.build_html(name, controller) || "" end + # If there is plugin HTML return that, otherwise yield to the template + def replace_plugin_html(name) + if (html = build_plugin_html(name)).present? + html + else + yield + nil + end + end + def theme_lookup(name) lookup = Theme.lookup_field(theme_key, mobile_view? ? :mobile : :desktop, name) lookup.html_safe if lookup end - def discourse_stylesheet_link_tag(name, opts={}) + def discourse_stylesheet_link_tag(name, opts = {}) if opts.key?(:theme_key) key = opts[:theme_key] unless customization_disabled? else diff --git a/app/helpers/common_helper.rb b/app/helpers/common_helper.rb index e610736903..1d8317554c 100644 --- a/app/helpers/common_helper.rb +++ b/app/helpers/common_helper.rb @@ -5,12 +5,6 @@ module CommonHelper end end - def render_google_analytics_code - if Rails.env.production? && SiteSetting.ga_tracking_code.present? - render partial: "common/google_analytics" - end - end - def render_google_tag_manager_code if Rails.env.production? && SiteSetting.gtm_container_id.present? render partial: "common/google_tag_manager" diff --git a/app/helpers/list_helper.rb b/app/helpers/list_helper.rb index a7cf7766ac..9839401f9e 100644 --- a/app/helpers/list_helper.rb +++ b/app/helpers/list_helper.rb @@ -6,7 +6,7 @@ module ListHelper return if total_pages < 2 - page = [total_pages - (max_pages+1), 2].max + page = [total_pages - (max_pages + 1), 2].max result = "(" while page <= total_pages diff --git a/app/helpers/topics_helper.rb b/app/helpers/topics_helper.rb index ad96e94177..b50b923bb8 100644 --- a/app/helpers/topics_helper.rb +++ b/app/helpers/topics_helper.rb @@ -2,7 +2,7 @@ module TopicsHelper include ApplicationHelper def render_topic_title(topic) - link_to(gsub_emoji_to_unicode(topic.title),topic.relative_url) + link_to(Emoji.gsub_emoji_to_unicode(topic.title), topic.relative_url) end def categories_breadcrumb(topic) diff --git a/app/helpers/user_notifications_helper.rb b/app/helpers/user_notifications_helper.rb index 044b4eb312..2e18c702ee 100644 --- a/app/helpers/user_notifications_helper.rb +++ b/app/helpers/user_notifications_helper.rb @@ -1,6 +1,6 @@ module UserNotificationsHelper - def indent(text, by=2) + def indent(text, by = 2) spacer = " " * by result = "" text.each_line do |line| @@ -48,9 +48,9 @@ module UserNotificationsHelper doc.css('div').first end - def email_excerpt(html_arg, posts_count=nil) + def email_excerpt(html_arg, posts_count = nil) # only include 1st paragraph when more than 1 posts - html = (posts_count.nil? || posts_count > 1) ? (first_paragraph_from(html_arg)||html_arg).to_s : html_arg + html = (posts_count.nil? || posts_count > 1) ? (first_paragraph_from(html_arg) || html_arg).to_s : html_arg PrettyText.format_for_email(html).html_safe end diff --git a/app/jobs/base.rb b/app/jobs/base.rb index 4445c78aa9..fa16e3179b 100644 --- a/app/jobs/base.rb +++ b/app/jobs/base.rb @@ -71,11 +71,11 @@ module Jobs ctx end - def self.delayed_perform(opts={}) + def self.delayed_perform(opts = {}) self.new.perform(opts) end - def execute(opts={}) + def execute(opts = {}) raise "Overwrite me!" end @@ -114,7 +114,6 @@ module Jobs end end - dbs = if opts[:current_site_id] [opts[:current_site_id]] @@ -125,49 +124,28 @@ module Jobs exceptions = [] dbs.each do |db| begin - thread_exception = {} - # NOTE: This looks odd, in fact it looks crazy but there is a reason - # A bug in therubyracer means that under certain conditions running in a fiber - # can cause the whole v8 context to corrupt so much that it will hang sidekiq - # - # If you are brave and want to try to fix this either in celluloid or therubyracer, the repro is: - # - # 1. Create a big Discourse db: (you can start from script/profile_db_generator.rb) - # 2. Queue a ton of jobs, eg: User.pluck(:id).each{|id| Jobs.enqueue(:user_email, type: :digest, user_id: id)}; - # 3. Run sidekiq - # - # The issue only happens in Ruby 2.0 for some reason, you start getting V8::Error with no context - # - # See: https://github.com/cowboyd/therubyracer/issues/206 - # - # The restricted stack space of fibers opens a bunch of risks up, by avoiding them altogether - # we can mitigate giving up a very marginal amount of throughput - # - # Ideally we could just tell sidekiq to avoid fibers + exception = {} - t = Thread.new do + RailsMultisite::ConnectionManagement.with_connection(db) do begin - RailsMultisite::ConnectionManagement.establish_connection(db: db) I18n.locale = SiteSetting.default_locale || "en" I18n.ensure_all_loaded! begin execute(opts) rescue => e - thread_exception[:ex] = e - thread_exception[:other] = { problem_db: db } + exception[:ex] = e + exception[:other] = { problem_db: db } end rescue => e - thread_exception[:ex] = e - thread_exception[:message] = "While establishing database connection to #{db}" - thread_exception[:other] = { problem_db: db } + exception[:ex] = e + exception[:message] = "While establishing database connection to #{db}" + exception[:other] = { problem_db: db } ensure - ActiveRecord::Base.connection_handler.clear_active_connections! total_db_time += Instrumenter.stats.duration_ms end end - t.join - exceptions << thread_exception unless thread_exception.empty? + exceptions << exception unless exception.empty? end end @@ -203,7 +181,7 @@ module Jobs end end - def self.enqueue(job_name, opts={}) + def self.enqueue(job_name, opts = {}) klass = "Jobs::#{job_name.to_s.camelcase}".constantize # Unless we want to work on all sites @@ -222,25 +200,31 @@ module Jobs # Otherwise execute the job right away opts.delete(:delay_for) opts[:sync_exec] = true - klass.new.perform(opts) + if Rails.env == "development" + Scheduler::Defer.later("job") do + klass.new.perform(opts) + end + else + klass.new.perform(opts) + end end end - def self.enqueue_in(secs, job_name, opts={}) + def self.enqueue_in(secs, job_name, opts = {}) enqueue(job_name, opts.merge!(delay_for: secs)) end - def self.enqueue_at(datetime, job_name, opts={}) + def self.enqueue_at(datetime, job_name, opts = {}) secs = [(datetime - Time.zone.now).to_i, 0].max enqueue_in(secs, job_name, opts) end - def self.cancel_scheduled_job(job_name, opts={}) + def self.cancel_scheduled_job(job_name, opts = {}) scheduled_for(job_name, opts).each(&:delete) end - def self.scheduled_for(job_name, opts={}) + def self.scheduled_for(job_name, opts = {}) opts = opts.with_indifferent_access unless opts.delete(:all_sites) opts[:current_site_id] ||= RailsMultisite::ConnectionManagement.current_db @@ -265,6 +249,6 @@ module Jobs end end -Dir["#{Rails.root}/app/jobs/onceoff/*.rb"].each {|file| require_dependency file } -Dir["#{Rails.root}/app/jobs/regular/*.rb"].each {|file| require_dependency file } -Dir["#{Rails.root}/app/jobs/scheduled/*.rb"].each {|file| require_dependency file } +Dir["#{Rails.root}/app/jobs/onceoff/*.rb"].each { |file| require_dependency file } +Dir["#{Rails.root}/app/jobs/regular/*.rb"].each { |file| require_dependency file } +Dir["#{Rails.root}/app/jobs/scheduled/*.rb"].each { |file| require_dependency file } diff --git a/app/jobs/onceoff/create_tags_search_index.rb b/app/jobs/onceoff/create_tags_search_index.rb new file mode 100644 index 0000000000..3e673bf2e6 --- /dev/null +++ b/app/jobs/onceoff/create_tags_search_index.rb @@ -0,0 +1,9 @@ +module Jobs + class CreateTagsSearchIndex < Jobs::Onceoff + def execute_onceoff(args) + Tag.exec_sql('select id, name from tags').each do |t| + SearchIndexer.update_tags_index(t['id'], t['name']) + end + end + end +end diff --git a/app/jobs/onceoff/fix_featured_link_for_topics.rb b/app/jobs/onceoff/fix_featured_link_for_topics.rb new file mode 100644 index 0000000000..3ccd0f29d6 --- /dev/null +++ b/app/jobs/onceoff/fix_featured_link_for_topics.rb @@ -0,0 +1,15 @@ +module Jobs + class FixFeaturedLinkForTopics < Jobs::Onceoff + def execute_onceoff(args) + Topic.where("featured_link IS NOT NULL").find_each do |topic| + featured_link = topic.featured_link + + begin + URI.parse(featured_link) + rescue URI::InvalidURIError + topic.update_attributes(featured_link: URI.extract(featured_link).first) + end + end + end + end +end diff --git a/app/jobs/onceoff/fix_posts_read.rb b/app/jobs/onceoff/fix_posts_read.rb new file mode 100644 index 0000000000..2505038723 --- /dev/null +++ b/app/jobs/onceoff/fix_posts_read.rb @@ -0,0 +1,21 @@ +module Jobs + class FixPostsRead < Jobs::Onceoff + def execute_onceoff(args) + # Skipping to the last post in a topic used to count all posts in the topic + # as read in user stats. Cap the posts read count to 50 * topics_entered. + + sql = <<~SQL +UPDATE user_stats + SET posts_read_count = topics_entered * 50 + WHERE user_id IN ( + SELECT us2.user_id + FROM user_stats us2 + WHERE us2.topics_entered > 0 + AND us2.posts_read_count / us2.topics_entered > 50 + ) + SQL + + UserStat.exec_sql(sql) + end + end +end diff --git a/app/jobs/onceoff/fix_primary_emails_for_staged_users.rb b/app/jobs/onceoff/fix_primary_emails_for_staged_users.rb new file mode 100644 index 0000000000..d024688da9 --- /dev/null +++ b/app/jobs/onceoff/fix_primary_emails_for_staged_users.rb @@ -0,0 +1,49 @@ +require_dependency 'user_destroyer' + +module Jobs + class FixPrimaryEmailsForStagedUsers < Jobs::Onceoff + def execute_onceoff(args) + users = User.where(active: false, staged: true).joins(:email_tokens) + acting_user = Discourse.system_user + destroyer = UserDestroyer.new(acting_user) + + users.group("email_tokens.email") + .having("COUNT(email_tokens.email) > 1") + .count + .each_key do |email| + + query = users.where("email_tokens.email = ?", email).order(id: :asc) + + original_user = query.first + + query.offset(1).each do |user| + user.posts.each do |post| + post.set_owner(original_user, acting_user) + end + destroyer.destroy(user, context: I18n.t("user.destroy_reasons.fixed_primary_email")) + end + end + + User.exec_sql <<~SQL + INSERT INTO user_emails ( + user_id, + email, + "primary", + created_at, + updated_at + ) SELECT DISTINCT + users.id, + email_tokens.email, + TRUE, + users.created_at, + users.updated_at + FROM users + LEFT JOIN user_emails ON user_emails.user_id = users.id + LEFT JOIN email_tokens ON email_tokens.user_id = users.id + WHERE staged + AND NOT active + AND user_emails.id IS NULL + SQL + end + end +end diff --git a/app/jobs/onceoff/grant_emoji.rb b/app/jobs/onceoff/grant_emoji.rb index 696fd1f952..39d9ececf8 100644 --- a/app/jobs/onceoff/grant_emoji.rb +++ b/app/jobs/onceoff/grant_emoji.rb @@ -6,11 +6,11 @@ module Jobs to_award = {} Post.secured(Guardian.new) - .select(:id, :created_at, :cooked, :user_id) - .visible - .public_posts - .where("cooked LIKE '%emoji%'") - .find_in_batches do |group| + .select(:id, :created_at, :cooked, :user_id) + .visible + .public_posts + .where("cooked LIKE '%emoji%'") + .find_in_batches do |group| group.each do |p| doc = Nokogiri::HTML::fragment(p.cooked) if (doc.css("img.emoji") - doc.css(".quote img")).size > 0 diff --git a/app/jobs/onceoff/grant_first_reply_by_email.rb b/app/jobs/onceoff/grant_first_reply_by_email.rb index ae769f681c..ef8bcc6469 100644 --- a/app/jobs/onceoff/grant_first_reply_by_email.rb +++ b/app/jobs/onceoff/grant_first_reply_by_email.rb @@ -7,12 +7,12 @@ module Jobs to_award = {} Post.select(:id, :created_at, :user_id) - .secured(Guardian.new) - .visible - .public_posts - .where(via_email: true) - .where("post_number > 1") - .find_in_batches do |group| + .secured(Guardian.new) + .visible + .public_posts + .where(via_email: true) + .where("post_number > 1") + .find_in_batches do |group| group.each do |p| to_award[p.user_id] ||= { post_id: p.id, created_at: p.created_at } end diff --git a/app/jobs/onceoff/grant_onebox.rb b/app/jobs/onceoff/grant_onebox.rb index 0c63e6b7b8..259dfe9702 100644 --- a/app/jobs/onceoff/grant_onebox.rb +++ b/app/jobs/onceoff/grant_onebox.rb @@ -8,11 +8,11 @@ module Jobs to_award = {} Post.secured(Guardian.new) - .select(:id, :created_at, :raw, :user_id) - .visible - .public_posts - .where("raw LIKE '%http%'") - .find_in_batches do |group| + .select(:id, :created_at, :raw, :user_id) + .visible + .public_posts + .where("raw LIKE '%http%'") + .find_in_batches do |group| group.each do |p| begin # Note we can't use `p.cooked` here because oneboxes have been cooked out diff --git a/app/jobs/onceoff/migrate_censored_words.rb b/app/jobs/onceoff/migrate_censored_words.rb new file mode 100644 index 0000000000..03ce4b358c --- /dev/null +++ b/app/jobs/onceoff/migrate_censored_words.rb @@ -0,0 +1,12 @@ +module Jobs + class MigrateCensoredWords < Jobs::Onceoff + def execute_onceoff(args) + row = WatchedWord.exec_sql("SELECT value FROM site_settings WHERE name = 'censored_words'") + if row.count > 0 + row.first["value"].split('|').each do |word| + WatchedWord.create(word: word, action: WatchedWord.actions[:censor]) + end + end + end + end +end diff --git a/app/jobs/onceoff/migrate_tagging_plugin.rb b/app/jobs/onceoff/migrate_tagging_plugin.rb index f0bb91e7e4..9d94b8e88f 100644 --- a/app/jobs/onceoff/migrate_tagging_plugin.rb +++ b/app/jobs/onceoff/migrate_tagging_plugin.rb @@ -4,7 +4,7 @@ module Jobs def execute_onceoff(args) all_tags = TopicCustomField.where(name: "tags").select('DISTINCT value').all.map(&:value) - tag_id_lookup = Tag.create(all_tags.map { |tag_name| {name: tag_name} }).inject({}) { |h,v| h[v.name] = v.id; h } + tag_id_lookup = Tag.create(all_tags.map { |tag_name| { name: tag_name } }).inject({}) { |h, v| h[v.name] = v.id; h } TopicCustomField.where(name: "tags").find_each do |tcf| TopicTag.create(topic_id: tcf.topic_id, tag_id: tag_id_lookup[tcf.value] || Tag.find_by_name(tcf.value).try(:id)) diff --git a/app/jobs/onceoff/migrate_upload_extensions.rb b/app/jobs/onceoff/migrate_upload_extensions.rb new file mode 100644 index 0000000000..38bcd3a1ea --- /dev/null +++ b/app/jobs/onceoff/migrate_upload_extensions.rb @@ -0,0 +1,11 @@ +module Jobs + + class MigrateUploadExtensions < Jobs::Onceoff + def execute_onceoff(args) + Upload.find_each do |upload| + upload.extension = File.extname(upload.original_filename)[1..10] + upload.save + end + end + end +end diff --git a/app/jobs/onceoff/retro_grant_anniversary.rb b/app/jobs/onceoff/retro_grant_anniversary.rb index 2b338e6dcd..9cbdddd810 100644 --- a/app/jobs/onceoff/retro_grant_anniversary.rb +++ b/app/jobs/onceoff/retro_grant_anniversary.rb @@ -14,4 +14,3 @@ module Jobs end end - diff --git a/app/jobs/onceoff/retro_recent_time_read.rb b/app/jobs/onceoff/retro_recent_time_read.rb new file mode 100644 index 0000000000..1dd1ce0289 --- /dev/null +++ b/app/jobs/onceoff/retro_recent_time_read.rb @@ -0,0 +1,21 @@ +module Jobs + class RetroRecentTimeRead < Jobs::Onceoff + def execute_onceoff(args) + # update past records by evenly distributing total time reading among each post read + sql = <<~SQL + UPDATE user_visits uv1 + SET time_read = ( + SELECT ( + uv1.posts_read + / (SELECT CAST(sum(uv2.posts_read) AS FLOAT) FROM user_visits uv2 where uv2.user_id = uv1.user_id) + * COALESCE((SELECT us.time_read FROM user_stats us WHERE us.user_id = uv1.user_id), 0) + ) + ) + WHERE EXISTS (SELECT 1 FROM user_stats stats WHERE stats.user_id = uv1.user_id AND stats.posts_read_count > 0 LIMIT 1) + AND EXISTS (SELECT 1 FROM user_visits visits WHERE visits.user_id = uv1.user_id AND visits.posts_read > 0 LIMIT 1) + SQL + + UserVisit.exec_sql(sql) + end + end +end diff --git a/app/jobs/regular/automatic_group_membership.rb b/app/jobs/regular/automatic_group_membership.rb index dbe679889b..4e688d8400 100644 --- a/app/jobs/regular/automatic_group_membership.rb +++ b/app/jobs/regular/automatic_group_membership.rb @@ -13,14 +13,15 @@ module Jobs domains = group.automatic_membership_email_domains.gsub('.', '\.') - User.where("email ~* '@(#{domains})$'") - .where("users.id NOT IN (SELECT user_id FROM group_users WHERE group_users.group_id = ?)", group_id) - .activated - .where(staged: false) - .find_each do |user| - - group.add(user) - GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(user) + User.joins(:user_emails) + .where("user_emails.email ~* '@(#{domains})$'") + .where("users.id NOT IN (SELECT user_id FROM group_users WHERE group_users.group_id = ?)", group_id) + .activated + .where(staged: false) + .find_each do |user| + next unless user.email_confirmed? + group.add(user) + GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(user) end Group.reset_counters(group.id, :group_users) diff --git a/app/jobs/regular/crawl_topic_link.rb b/app/jobs/regular/crawl_topic_link.rb index 0532108181..15c0c01ad0 100644 --- a/app/jobs/regular/crawl_topic_link.rb +++ b/app/jobs/regular/crawl_topic_link.rb @@ -1,50 +1,12 @@ require 'open-uri' require 'nokogiri' require 'excon' -require 'final_destination' +require_dependency 'retrieve_title' +require_dependency 'topic_link' module Jobs class CrawlTopicLink < Jobs::Base - class ReadEnough < StandardError; end - - def self.max_chunk_size(uri) - # Amazon leaves the title until very late. Normally it's a bad idea to make an exception for - # one host but amazon is a big one. - return 80 if uri.host =~ /amazon\.(com|ca|co\.uk|es|fr|de|it|com\.au|com\.br|cn|in|co\.jp|com\.mx)$/ - - # Default is 10k - 10 - end - - # Fetch the beginning of a HTML document at a url - def self.fetch_beginning(url) - # Never crawl in test mode - return if Rails.env.test? - - fd = FinalDestination.new(url) - uri = fd.resolve - return "" unless uri - - result = "" - streamer = lambda do |chunk, _, _| - result << chunk - - # Using exceptions for flow control is really bad, but there really seems to - # be no sane way to get a stream to stop reading in Excon (or Net::HTTP for - # that matter!) - raise ReadEnough.new if result.size > (CrawlTopicLink.max_chunk_size(uri) * 1024) - end - Excon.get(uri.to_s, response_block: streamer, read_timeout: 20, headers: fd.request_headers) - result - - rescue Excon::Errors::SocketError => ex - return result if ex.socket_error.is_a?(ReadEnough) - raise - rescue ReadEnough - result - end - def execute(args) raise Discourse::InvalidParameters.new(:topic_link_id) unless args[:topic_link_id].present? @@ -64,7 +26,7 @@ module Jobs # Special case: Images # If the link is to an image, put the filename as the title - if topic_link.url =~ /\.(jpg|gif|png)$/ + if FileHelper.is_image?(topic_link.url) uri = URI(topic_link.url) filename = File.basename(uri.path) crawled = (TopicLink.where(id: topic_link.id).update_all(["title = ?, crawled_at = CURRENT_TIMESTAMP", filename]) == 1) @@ -72,18 +34,9 @@ module Jobs unless crawled # Fetch the beginning of the document to find the title - result = CrawlTopicLink.fetch_beginning(topic_link.url) - doc = Nokogiri::HTML(result) - if doc - title = doc.at('title').try(:inner_text) - if title.present? - title.gsub!(/\n/, ' ') - title.gsub!(/ +/, ' ') - title.strip! - if title.present? - crawled = (TopicLink.where(id: topic_link.id).update_all(['title = ?, crawled_at = CURRENT_TIMESTAMP', title[0..254]]) == 1) - end - end + title = RetrieveTitle.crawl(topic_link.url) + if title.present? + crawled = (TopicLink.where(id: topic_link.id).update_all(['title = ?, crawled_at = CURRENT_TIMESTAMP', title[0..254]]) == 1) end end rescue Exception diff --git a/app/jobs/regular/create_avatar_thumbnails.rb b/app/jobs/regular/create_avatar_thumbnails.rb index 651dd2b0f2..ef081cee95 100644 --- a/app/jobs/regular/create_avatar_thumbnails.rb +++ b/app/jobs/regular/create_avatar_thumbnails.rb @@ -3,12 +3,13 @@ module Jobs class CreateAvatarThumbnails < Jobs::Base def execute(args) + return if Rails.env.test? upload_id = args[:upload_id] raise Discourse::InvalidParameters.new(:upload_id) if upload_id.blank? return unless upload = Upload.find_by(id: upload_id) - return unless user = User.find(args[:user_id] || upload.user_id) + return unless user = User.find_by(id: args[:user_id] || upload.user_id) Discourse.avatar_sizes.each do |size| OptimizedImage.create_for(upload, size, size, filename: upload.original_filename, allow_animation: SiteSetting.allow_animated_avatars) diff --git a/app/jobs/regular/create_backup.rb b/app/jobs/regular/create_backup.rb index 1f5313b071..f9327567c6 100644 --- a/app/jobs/regular/create_backup.rb +++ b/app/jobs/regular/create_backup.rb @@ -5,7 +5,7 @@ module Jobs sidekiq_options retry: false def execute(args) - BackupRestore.backup!(Discourse.system_user.id, publish_to_message_bus: false, with_uploads: SiteSetting.backup_with_uploads) + BackupRestore.backup!(Discourse.system_user.id, publish_to_message_bus: false, with_uploads: SiteSetting.backup_with_uploads, fork: false) end end end diff --git a/app/jobs/regular/delete_topic.rb b/app/jobs/regular/delete_topic.rb index e50cd9c2da..bfb23f568c 100644 --- a/app/jobs/regular/delete_topic.rb +++ b/app/jobs/regular/delete_topic.rb @@ -12,7 +12,7 @@ module Jobs if Guardian.new(topic_timer.user).can_delete?(topic) first_post = topic.ordered_posts.first - PostDestroyer.new(topic_timer.user, first_post, { context: I18n.t("topic_statuses.auto_deleted_by_timer") }).destroy + PostDestroyer.new(topic_timer.user, first_post, context: I18n.t("topic_statuses.auto_deleted_by_timer")).destroy topic_timer.trash!(Discourse.system_user) end end diff --git a/app/jobs/regular/download_avatar_from_url.rb b/app/jobs/regular/download_avatar_from_url.rb index 83e687fed9..be5f683986 100644 --- a/app/jobs/regular/download_avatar_from_url.rb +++ b/app/jobs/regular/download_avatar_from_url.rb @@ -12,7 +12,15 @@ module Jobs return unless user = User.find_by(id: user_id) - UserAvatar.import_url_for_user(url, user, override_gravatar: args[:override_gravatar]) + begin + UserAvatar.import_url_for_user( + url, + user, + override_gravatar: args[:override_gravatar] + ) + rescue Discourse::InvalidParameters => e + raise e unless e.message == 'url' + end end end diff --git a/app/jobs/regular/emit_web_hook_event.rb b/app/jobs/regular/emit_web_hook_event.rb index 46ce3f43bd..f58b558ebb 100644 --- a/app/jobs/regular/emit_web_hook_event.rb +++ b/app/jobs/regular/emit_web_hook_event.rb @@ -8,7 +8,7 @@ module Jobs end web_hook = WebHook.find_by(id: args[:web_hook_id]) - raise Discourse::InvalidParameters(:web_hook_id) if web_hook.blank? + raise Discourse::InvalidParameters.new(:web_hook_id) if web_hook.blank? unless ping_event?(args[:event_type]) return unless web_hook.active? @@ -71,7 +71,7 @@ module Jobs end def web_hook_request(args, web_hook) - uri = URI(web_hook.payload_url) + uri = URI(web_hook.payload_url.strip) conn = Excon.new( uri.to_s, @@ -83,12 +83,13 @@ module Jobs web_hook_event = WebHookEvent.create!(web_hook_id: web_hook.id) begin - content_type = case web_hook.content_type - when WebHook.content_types['application/x-www-form-urlencoded'] - 'application/x-www-form-urlencoded' - else - 'application/json' - end + content_type = + case web_hook.content_type + when WebHook.content_types['application/x-www-form-urlencoded'] + 'application/x-www-form-urlencoded' + else + 'application/json' + end headers = { 'Accept' => '*/*', diff --git a/app/jobs/regular/export_csv_file.rb b/app/jobs/regular/export_csv_file.rb index 336c0bfdcc..5eca7dd91b 100644 --- a/app/jobs/regular/export_csv_file.rb +++ b/app/jobs/regular/export_csv_file.rb @@ -8,18 +8,18 @@ module Jobs sidekiq_options retry: false - HEADER_ATTRS_FOR ||= HashWithIndifferentAccess.new({ - user_archive: ['topic_title','category','sub_category','is_pm','post','like_count','reply_count','url','created_at'], - user_list: ['id','name','username','email','title','created_at','last_seen_at','last_posted_at','last_emailed_at','trust_level','approved','suspended_at','suspended_till','blocked','active','admin','moderator','ip_address'], - user_stats: ['topics_entered','posts_read_count','time_read','topic_count','post_count','likes_given','likes_received'], - user_profile: ['location','website','views'], - user_sso: ['external_id','external_email', 'external_username', 'external_name', 'external_avatar_url'], - staff_action: ['staff_user','action','subject','created_at','details', 'context'], - screened_email: ['email','action','match_count','last_match_at','created_at','ip_address'], - screened_ip: ['ip_address','action','match_count','last_match_at','created_at'], - screened_url: ['domain','action','match_count','last_match_at','created_at'], - report: ['date', 'value'], - }) + HEADER_ATTRS_FOR ||= HashWithIndifferentAccess.new( + user_archive: ['topic_title', 'category', 'sub_category', 'is_pm', 'post', 'like_count', 'reply_count', 'url', 'created_at'], + user_list: ['id', 'name', 'username', 'email', 'title', 'created_at', 'last_seen_at', 'last_posted_at', 'last_emailed_at', 'trust_level', 'approved', 'suspended_at', 'suspended_till', 'silenced_till', 'active', 'admin', 'moderator', 'ip_address', 'staged'], + user_stats: ['topics_entered', 'posts_read_count', 'time_read', 'topic_count', 'post_count', 'likes_given', 'likes_received'], + user_profile: ['location', 'website', 'views'], + user_sso: ['external_id', 'external_email', 'external_username', 'external_name', 'external_avatar_url'], + staff_action: ['staff_user', 'action', 'subject', 'created_at', 'details', 'context'], + screened_email: ['email', 'action', 'match_count', 'last_match_at', 'created_at', 'ip_address'], + screened_ip: ['ip_address', 'action', 'match_count', 'last_match_at', 'created_at'], + screened_url: ['domain', 'action', 'match_count', 'last_match_at', 'created_at'], + report: ['date', 'value'] + ) def execute(args) @entity = args[:entity] @@ -58,11 +58,11 @@ module Jobs return enum_for(:user_archive_export) unless block_given? Post.includes(topic: :category) - .where(user_id: @current_user.id) - .select(:topic_id, :post_number, :raw, :like_count, :reply_count, :created_at) - .order(:created_at) - .with_deleted - .each do |user_archive| + .where(user_id: @current_user.id) + .select(:topic_id, :post_number, :raw, :like_count, :reply_count, :created_at) + .order(:created_at) + .with_deleted + .each do |user_archive| yield get_user_archive_fields(user_archive) end end @@ -132,9 +132,9 @@ module Jobs return enum_for(:screened_url_export) unless block_given? ScreenedUrl.select("domain, sum(match_count) as match_count, max(last_match_at) as last_match_at, min(created_at) as created_at") - .group(:domain) - .order('last_match_at DESC') - .each do |screened_url| + .group(:domain) + .order('last_match_at DESC') + .each do |screened_url| yield get_screened_url_fields(screened_url) end end @@ -142,10 +142,10 @@ module Jobs def report_export return enum_for(:report_export) unless block_given? - @extra[:start_date] = @extra[:start_date].to_date if @extra[:start_date].is_a?(String) - @extra[:end_date] = @extra[:end_date].to_date if @extra[:end_date].is_a?(String) - @extra[:category_id] = @extra[:category_id].to_i if @extra[:category_id] - @extra[:group_id] = @extra[:group_id].to_i if @extra[:group_id] + @extra[:start_date] = @extra[:start_date].to_date if @extra[:start_date].is_a?(String) + @extra[:end_date] = @extra[:end_date].to_date if @extra[:end_date].is_a?(String) + @extra[:category_id] = @extra[:category_id].present? ? @extra[:category_id].to_i : nil + @extra[:group_id] = @extra[:group_id].present? ? @extra[:group_id].to_i : nil Report.find(@extra[:name], @extra).data.each do |row| yield [row[:x].to_s(:db), row[:y].to_s(:db)] end @@ -181,14 +181,14 @@ module Jobs def get_base_user_array(user) user_array = [] - user_array.push(user.id,escape_comma(user.name),user.username,user.email,escape_comma(user.title),user.created_at,user.last_seen_at,user.last_posted_at,user.last_emailed_at,user.trust_level,user.approved,user.suspended_at,user.suspended_till,user.blocked,user.active,user.admin,user.moderator,user.ip_address,user.user_stat.topics_entered,user.user_stat.posts_read_count,user.user_stat.time_read,user.user_stat.topic_count,user.user_stat.post_count,user.user_stat.likes_given,user.user_stat.likes_received,escape_comma(user.user_profile.location),user.user_profile.website,user.user_profile.views) + user_array.push(user.id, escape_comma(user.name), user.username, user.email, escape_comma(user.title), user.created_at, user.last_seen_at, user.last_posted_at, user.last_emailed_at, user.trust_level, user.approved, user.suspended_at, user.suspended_till, user.silenced_till, user.active, user.admin, user.moderator, user.ip_address, user.staged, user.user_stat.topics_entered, user.user_stat.posts_read_count, user.user_stat.time_read, user.user_stat.topic_count, user.user_stat.post_count, user.user_stat.likes_given, user.user_stat.likes_received, escape_comma(user.user_profile.location), user.user_profile.website, user.user_profile.views) end def add_single_sign_on(user, user_info_array) if user.single_sign_on_record - user_info_array.push(user.single_sign_on_record.external_id,user.single_sign_on_record.external_email,user.single_sign_on_record.external_username,escape_comma(user.single_sign_on_record.external_name),user.single_sign_on_record.external_avatar_url) + user_info_array.push(user.single_sign_on_record.external_id, user.single_sign_on_record.external_email, user.single_sign_on_record.external_username, escape_comma(user.single_sign_on_record.external_name), user.single_sign_on_record.external_avatar_url) else - user_info_array.push(nil,nil,nil,nil,nil) + user_info_array.push(nil, nil, nil, nil, nil) end user_info_array end @@ -218,13 +218,15 @@ module Jobs topic_data = Topic.with_deleted.find_by(id: user_archive['topic_id']) if topic_data.nil? return user_archive_array if topic_data.nil? category = topic_data.category - sub_category = "-" + sub_category_name = "-" if category category_name = category.name - if !category.parent_category_id.nil? + if category.parent_category_id.present? # sub category - category_name = Category.find_by(id: category.parent_category_id).name - sub_category = category.name + if parent_category = Category.find_by(id: category.parent_category_id) + category_name = parent_category.name + sub_category_name = category.name + end end else # PM @@ -233,7 +235,7 @@ module Jobs is_pm = topic_data.archetype == "private_message" ? I18n.t("csv_export.boolean_yes") : I18n.t("csv_export.boolean_no") url = "#{Discourse.base_url}/t/#{topic_data.slug}/#{topic_data.id}/#{user_archive['post_number']}" - topic_hash = {"post" => user_archive['raw'], "topic_title" => topic_data.title, "category" => category_name, "sub_category" => sub_category, "is_pm" => is_pm, "url" => url} + topic_hash = { "post" => user_archive['raw'], "topic_title" => topic_data.title, "category" => category_name, "sub_category" => sub_category_name, "is_pm" => is_pm, "url" => url } user_archive.merge!(topic_hash) HEADER_ATTRS_FOR['user_archive'].each do |attr| diff --git a/app/jobs/regular/invite_email.rb b/app/jobs/regular/invite_email.rb index cd8ecb91af..f373134a2b 100644 --- a/app/jobs/regular/invite_email.rb +++ b/app/jobs/regular/invite_email.rb @@ -9,7 +9,7 @@ module Jobs raise Discourse::InvalidParameters.new(:invite_id) unless args[:invite_id].present? invite = Invite.find_by(id: args[:invite_id]) - message = InviteMailer.send_invite(invite, args[:custom_message]) + message = InviteMailer.send_invite(invite) Email::Sender.new(message, :invite).send end diff --git a/app/jobs/regular/notify_mailing_list_subscribers.rb b/app/jobs/regular/notify_mailing_list_subscribers.rb index 393b324dc1..f966dbaa19 100644 --- a/app/jobs/regular/notify_mailing_list_subscribers.rb +++ b/app/jobs/regular/notify_mailing_list_subscribers.rb @@ -1,3 +1,5 @@ +require_dependency 'post' + module Jobs class NotifyMailingListSubscribers < Jobs::Base @@ -13,25 +15,30 @@ module Jobs return if !post || post.trashed? || post.user_deleted? || !post.topic users = - User.activated.not_blocked.not_suspended.real - .joins(:user_option) - .where('user_options.mailing_list_mode AND user_options.mailing_list_mode_frequency > 0') - .where('NOT EXISTS ( + User.activated.not_silenced.not_suspended.real + .joins(:user_option) + .where('user_options.mailing_list_mode AND user_options.mailing_list_mode_frequency > 0') + .where('NOT EXISTS ( SELECT 1 FROM muted_users mu WHERE mu.muted_user_id = ? AND mu.user_id = users.id )', post.user_id) - .where('NOT EXISTS ( + .where('NOT EXISTS ( SELECT 1 FROM topic_users tu WHERE tu.topic_id = ? AND tu.user_id = users.id AND tu.notification_level = ? )', post.topic_id, TopicUser.notification_levels[:muted]) - .where('NOT EXISTS ( + .where('NOT EXISTS ( SELECT 1 FROM category_users cu WHERE cu.category_id = ? AND cu.user_id = users.id AND cu.notification_level = ? )', post.topic.category_id, CategoryUser.notification_levels[:muted]) + if SiteSetting.must_approve_users + users = users.where(approved: true) + end + + DiscourseEvent.trigger(:notify_mailing_list_subscribers, users, post) users.find_each do |user| if Guardian.new(user).can_see?(post) if EmailLog.reached_max_emails?(user) @@ -56,7 +63,7 @@ module Jobs end end rescue => e - Discourse.handle_job_exception(e, error_context(args, "Sending post to mailing list subscribers", { user_id: user.id, user_email: user.email })) + Discourse.handle_job_exception(e, error_context(args, "Sending post to mailing list subscribers", user_id: user.id, user_email: user.email)) end end end diff --git a/app/jobs/regular/notify_moved_posts.rb b/app/jobs/regular/notify_moved_posts.rb index da1300e86c..9916bab8e7 100644 --- a/app/jobs/regular/notify_moved_posts.rb +++ b/app/jobs/regular/notify_moved_posts.rb @@ -17,8 +17,8 @@ module Jobs p.user.notifications.create(notification_type: Notification.types[:moved_post], topic_id: p.topic_id, post_number: p.post_number, - data: {topic_title: p.topic.title, - display_username: moved_by.username}.to_json) + data: { topic_title: p.topic.title, + display_username: moved_by.username }.to_json) users_notified << p.user_id end end diff --git a/app/jobs/regular/post_alert.rb b/app/jobs/regular/post_alert.rb index cc93aad285..6fb5fb0616 100644 --- a/app/jobs/regular/post_alert.rb +++ b/app/jobs/regular/post_alert.rb @@ -1,3 +1,5 @@ +require_dependency 'post_alerter' + module Jobs class PostAlert < Jobs::Base diff --git a/app/jobs/regular/process_post.rb b/app/jobs/regular/process_post.rb index ddfb0ff3d5..88e340501a 100644 --- a/app/jobs/regular/process_post.rb +++ b/app/jobs/regular/process_post.rb @@ -37,6 +37,14 @@ module Jobs post.publish_change_to_clients! :revised end end + + if !post.user&.staff? && !post.user&.staged? + s = post.cooked + s << " #{post.topic.title}" if post.post_number == 1 + if !args[:bypass_bump] && WordWatcher.new(s).should_flag? + PostAction.act(Discourse.system_user, post, PostActionType.types[:inappropriate]) rescue PostAction::AlreadyActed + end + end end # onebox may have added some links, so extract them now diff --git a/app/jobs/regular/publish_topic_to_category.rb b/app/jobs/regular/publish_topic_to_category.rb index 9127b6841e..a98f0dddff 100644 --- a/app/jobs/regular/publish_topic_to_category.rb +++ b/app/jobs/regular/publish_topic_to_category.rb @@ -2,7 +2,7 @@ module Jobs class PublishTopicToCategory < Jobs::Base def execute(args) topic_timer = TopicTimer.find_by(id: args[:topic_timer_id] || args[:topic_status_update_id]) - raise Discourse::InvalidParameters.new(:topic_timer_id) if topic_timer.blank? + return if topic_timer.blank? topic = topic_timer.topic return if topic.blank? diff --git a/app/jobs/regular/pull_hotlinked_images.rb b/app/jobs/regular/pull_hotlinked_images.rb index c14dae92fe..b920c60590 100644 --- a/app/jobs/regular/pull_hotlinked_images.rb +++ b/app/jobs/regular/pull_hotlinked_images.rb @@ -5,14 +5,34 @@ require_dependency 'upload_creator' module Jobs class PullHotlinkedImages < Jobs::Base - sidekiq_options queue: 'low' def initialize - # maximum size of the file in bytes @max_size = SiteSetting.max_image_size_kb.kilobytes end + def download(src) + downloaded = nil + + begin + retries ||= 3 + + downloaded = FileHelper.download( + src, + max_file_size: @max_size, + tmp_file_name: "discourse-hotlinked", + follow_redirect: true + ) + rescue + if (retries -= 1) > 0 + sleep 1 + retry + end + end + + downloaded + end + def execute(args) return unless SiteSetting.download_remote_images_to_local? @@ -24,36 +44,44 @@ module Jobs raw = post.raw.dup start_raw = raw.dup + downloaded_urls = {} + large_images = JSON.parse(post.custom_fields[Post::LARGE_IMAGES].presence || "[]") rescue [] + broken_images = JSON.parse(post.custom_fields[Post::BROKEN_IMAGES].presence || "[]") rescue [] + downloaded_images = JSON.parse(post.custom_fields[Post::DOWNLOADED_IMAGES].presence || "{}") rescue {} + + has_new_large_image = false + has_new_broken_image = false + has_downloaded_image = false + extract_images_from(post.cooked).each do |image| src = original_src = image['src'] - src = "http:" + src if src.start_with?("//") + src = "#{SiteSetting.force_https ? "https" : "http"}:#{src}" if src.start_with?("//") if is_valid_image_url(src) - hotlinked = nil begin # have we already downloaded that file? - unless downloaded_urls.include?(src) - begin - hotlinked = FileHelper.download( - src, - max_file_size: @max_size, - tmp_file_name: "discourse-hotlinked", - follow_redirect: true - ) - rescue Discourse::InvalidParameters - end - if hotlinked + unless downloaded_images.include?(src) || large_images.include?(src) || broken_images.include?(src) + if hotlinked = download(src) if File.size(hotlinked.path) <= @max_size filename = File.basename(URI.parse(src).path) + filename << File.extname(hotlinked.path) unless filename["."] upload = UploadCreator.new(hotlinked, filename, origin: src).create_for(post.user_id) - downloaded_urls[src] = upload.url + if upload.persisted? + downloaded_urls[src] = upload.url + downloaded_images[src.sub(/^https?:/i, "")] = upload.id + has_downloaded_image = true + else + log(:info, "Failed to pull hotlinked image for post: #{post_id}: #{src} - #{upload.errors.full_messages.join("\n")}") + end else - Rails.logger.info("Failed to pull hotlinked image for post: #{post_id}: #{src} - Image is bigger than #{@max_size}") + large_images << original_src.sub(/^https?:/i, "") + has_new_large_image = true end else - Rails.logger.error("There was an error while downloading '#{src}' locally for post: #{post_id}") + broken_images << original_src.sub(/^https?:/i, "") + has_new_broken_image = true end end # have we successfully downloaded that file? @@ -79,27 +107,33 @@ module Jobs raw.gsub!(/^#{escaped_src}(\s?)$/) { "#{$1}" } end rescue => e - Rails.logger.info("Failed to pull hotlinked image: #{src} post:#{post_id}\n" + e.message + "\n" + e.backtrace.join("\n")) - ensure - # close & delete the temp file - hotlinked && hotlinked.close! + log(:error, "Failed to pull hotlinked image (#{src}) post: #{post_id}\n" + e.message + "\n" + e.backtrace.join("\n")) end end - end + large_images.uniq! + broken_images.uniq! + + post.custom_fields[Post::LARGE_IMAGES] = large_images.to_json if large_images.present? + post.custom_fields[Post::BROKEN_IMAGES] = broken_images.to_json if broken_images.present? + post.custom_fields[Post::DOWNLOADED_IMAGES] = downloaded_images.to_json if downloaded_images.present? + # only save custom fields if there are any + post.save_custom_fields if large_images.present? || broken_images.present? || downloaded_images.present? + post.reload + if start_raw == post.raw && raw != post.raw changes = { raw: raw, edit_reason: I18n.t("upload.edit_reason") } - # we never want that job to bump the topic - options = { bypass_bump: true } - post.revise(Discourse.system_user, changes, options) + post.revise(Discourse.system_user, changes, bypass_bump: true) + elsif has_downloaded_image || has_new_large_image || has_new_broken_image + post.trigger_post_process(true) end end def extract_images_from(html) doc = Nokogiri::HTML::fragment(html) - doc.css("img[src]") - doc.css(".onebox-result img") - doc.css("img.avatar") + doc.css("img[src]") - doc.css("img.avatar") end def is_valid_image_url(src) @@ -109,21 +143,33 @@ module Jobs return false if Discourse.store.has_been_uploaded?(src) # we don't want to pull relative images return false if src =~ /\A\/[^\/]/i + # parse the src begin uri = URI.parse(src) rescue URI::InvalidURIError return false end + + hostname = uri.hostname + return false unless hostname + # we don't want to pull images hosted on the CDN (if we use one) - return false if Discourse.asset_host.present? && URI.parse(Discourse.asset_host).hostname == uri.hostname - return false if SiteSetting.s3_cdn_url.present? && URI.parse(SiteSetting.s3_cdn_url).hostname == uri.hostname + return false if Discourse.asset_host.present? && URI.parse(Discourse.asset_host).hostname == hostname + return false if SiteSetting.Upload.s3_cdn_url.present? && URI.parse(SiteSetting.Upload.s3_cdn_url).hostname == hostname # we don't want to pull images hosted on the main domain - return false if URI.parse(Discourse.base_url_no_prefix).hostname == uri.hostname + return false if URI.parse(Discourse.base_url_no_prefix).hostname == hostname # check the domains blacklist SiteSetting.should_download_images?(src) end + def log(log_level, message) + Rails.logger.public_send( + log_level, + "#{RailsMultisite::ConnectionManagement.current_db}: #{message}" + ) + end + end end diff --git a/app/jobs/regular/push_notification.rb b/app/jobs/regular/push_notification.rb index 651a7486bb..965acb963b 100644 --- a/app/jobs/regular/push_notification.rb +++ b/app/jobs/regular/push_notification.rb @@ -13,15 +13,20 @@ module Jobs } clients = args["clients"] - clients.group_by{|r| r[1]}.each do |push_url, group| + clients.group_by { |r| r[1] }.each do |push_url, group| notifications = group.map do |client_id, _| - notification.merge({ - client_id: client_id - }) + notification.merge(client_id: client_id) end - RestClient.send :post, push_url, payload.merge({notifications: notifications}).to_json, content_type: :json, accept: :json + result = Excon.post(push_url, + body: payload.merge(notifications: notifications).to_json, + headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' } + ) + if result.status != 200 + # we failed to push a notification ... log it + Rails.logger.warn("Failed to push a notification to #{push_url} Status: #{result.status}: #{result.status_line}") + end end end diff --git a/app/jobs/regular/retrieve_topic.rb b/app/jobs/regular/retrieve_topic.rb index 2820f05e31..9d5d4e248c 100644 --- a/app/jobs/regular/retrieve_topic.rb +++ b/app/jobs/regular/retrieve_topic.rb @@ -19,5 +19,3 @@ module Jobs end end - - diff --git a/app/jobs/regular/send_system_message.rb b/app/jobs/regular/send_system_message.rb index d0dc41ec6f..3eb9f002d7 100644 --- a/app/jobs/regular/send_system_message.rb +++ b/app/jobs/regular/send_system_message.rb @@ -13,7 +13,7 @@ module Jobs return if user.blank? system_message = SystemMessage.new(user) - system_message.create(args[:message_type]) + system_message.create(args[:message_type], args[:message_options]&.symbolize_keys || {}) end end diff --git a/app/jobs/regular/update_top_redirection.rb b/app/jobs/regular/update_top_redirection.rb index 776cdfca7d..592dc6b840 100644 --- a/app/jobs/regular/update_top_redirection.rb +++ b/app/jobs/regular/update_top_redirection.rb @@ -3,9 +3,12 @@ module Jobs class UpdateTopRedirection < Jobs::Base def execute(args) - if user = User.find_by(id: args[:user_id]) - user.user_option.update_column(:last_redirected_to_top_at, args[:redirected_at]) - end + return if args[:user_id].blank? || args[:redirected_at].blank? + + UserOption + .where(user_id: args[:user_id]) + .limit(1) + .update_all(last_redirected_to_top_at: args[:redirected_at]) end end diff --git a/app/jobs/regular/user_email.rb b/app/jobs/regular/user_email.rb index f9def1f451..0eeea9ecbb 100644 --- a/app/jobs/regular/user_email.rb +++ b/app/jobs/regular/user_email.rb @@ -1,4 +1,5 @@ require_dependency 'email/sender' +require_dependency 'user_notifications' module Jobs @@ -28,14 +29,13 @@ module Jobs notification = Notification.find_by(id: args[:notification_id]) end - message, skip_reason = message_for_email(user, - post, - type, - notification, - args[:notification_type], - args[:notification_data_hash], - args[:email_token], - args[:to_address]) + message, skip_reason = message_for_email( + user, + post, + type, + notification, + args + ) if message Email::Sender.new(message, type, user).send @@ -56,11 +56,21 @@ module Jobs quoted } - def message_for_email(user, post, type, notification, notification_type=nil, notification_data_hash=nil, email_token=nil, to_address=nil) + def message_for_email(user, post, type, notification, args = nil) + args ||= {} + + notification_type = args[:notification_type] + notification_data_hash = args[:notification_data_hash] + email_token = args[:email_token] + to_address = args[:to_address] + set_skip_context(type, user.id, to_address || user.email, post.try(:id)) return skip_message(I18n.t("email_log.anonymous_user")) if user.anonymous? - return skip_message(I18n.t("email_log.suspended_not_pm")) if user.suspended? && type.to_s != "user_private_message" + + if user.suspended? && !["user_private_message", "account_suspended"].include?(type.to_s) + return skip_message(I18n.t("email_log.suspended_not_pm")) + end return if user.staged && type.to_s == "digest" @@ -90,8 +100,8 @@ module Jobs user.user_option.mailing_list_mode_frequency > 0 && # don't catch notifications for users on daily mailing list mode (!post.try(:topic).try(:private_message?)) && NOTIFICATIONS_SENT_BY_MAILING_LIST.include?(email_args[:notification_type]) - # no need to log a reason when the mail was already sent via the mailing list job - return [nil, nil] + # no need to log a reason when the mail was already sent via the mailing list job + return [nil, nil] end unless user.user_option.email_always? @@ -107,8 +117,8 @@ module Jobs # Make sure that mailer exists raise Discourse::InvalidParameters.new("type=#{type}") unless UserNotifications.respond_to?(type) - email_args[:email_token] = email_token if email_token.present? - email_args[:new_email] = user.email if type.to_s == "notify_old_email" + email_args[:email_token] = email_token if email_token.present? + email_args[:new_email] = user.email if type.to_s == "notify_old_email" if EmailLog.reached_max_emails?(user, type.to_s) return skip_message(I18n.t('email_log.exceeded_emails_limit')) @@ -118,6 +128,10 @@ module Jobs return skip_message(I18n.t('email_log.exceeded_bounces_limit')) end + if args[:user_history_id] + email_args[:user_history] = UserHistory.where(id: args[:user_history_id]).first + end + message = EmailLog.unique_email_per_post(post, user) do UserNotifications.send(type, user, email_args) end @@ -141,7 +155,7 @@ module Jobs # extracted from sidekiq def self.seconds_to_delay(count) - (count ** 4) + 15 + (rand(30) * (count + 1)) + (count**4) + 15 + (rand(30) * (count + 1)) end private diff --git a/app/jobs/scheduled/auto_queue_handler.rb b/app/jobs/scheduled/auto_queue_handler.rb new file mode 100644 index 0000000000..6e201b06ce --- /dev/null +++ b/app/jobs/scheduled/auto_queue_handler.rb @@ -0,0 +1,31 @@ +# This job will automatically act on records that have gone unhandled on a +# queue for a long time. +module Jobs + class AutoQueueHandler < Jobs::Scheduled + + every 1.day + + def execute(args) + return unless SiteSetting.auto_handle_queued_age.to_i > 0 + + guardian = Guardian.new(Discourse.system_user) + + # Flags + flags = FlagQuery.flagged_post_actions(filter: 'active') + .where('post_actions.created_at < ?', SiteSetting.auto_handle_queued_age.to_i.days.ago) + + Post.where(id: flags.pluck(:post_id).uniq).each do |post| + PostAction.defer_flags!(post, Discourse.system_user) + end + + # Posts + queued_posts = QueuedPost.visible + .where(state: QueuedPost.states[:new]) + .where('created_at < ?', SiteSetting.auto_handle_queued_age.to_i.days.ago) + + queued_posts.each do |queued_post| + queued_post.reject!(Discourse.system_user) + end + end + end +end diff --git a/app/jobs/scheduled/badge_grant.rb b/app/jobs/scheduled/badge_grant.rb index dc5def0684..a9d9b915a7 100644 --- a/app/jobs/scheduled/badge_grant.rb +++ b/app/jobs/scheduled/badge_grant.rb @@ -15,7 +15,7 @@ module Jobs BadgeGranter.backfill(b) rescue => ex # TODO - expose errors in UI - Discourse.handle_job_exception(ex, error_context({}, code_desc: 'Exception granting badges', extra: {badge_id: b.id})) + Discourse.handle_job_exception(ex, error_context({}, code_desc: 'Exception granting badges', extra: { badge_id: b.id })) end end diff --git a/app/jobs/scheduled/clean_up_email_logs.rb b/app/jobs/scheduled/clean_up_email_logs.rb index 30305c4d9a..48421c1cfa 100644 --- a/app/jobs/scheduled/clean_up_email_logs.rb +++ b/app/jobs/scheduled/clean_up_email_logs.rb @@ -9,8 +9,8 @@ module Jobs threshold = SiteSetting.delete_email_logs_after_days.days.ago EmailLog.where(reply_key: nil) - .where("created_at < ?", threshold) - .delete_all + .where("created_at < ?", threshold) + .delete_all end end diff --git a/app/jobs/scheduled/clean_up_search_logs.rb b/app/jobs/scheduled/clean_up_search_logs.rb new file mode 100644 index 0000000000..bd3bc2e04a --- /dev/null +++ b/app/jobs/scheduled/clean_up_search_logs.rb @@ -0,0 +1,9 @@ +module Jobs + class CleanUpSearchLogs < Jobs::Scheduled + every 1.week + + def execute(args) + SearchLog.clean_up + end + end +end diff --git a/app/jobs/scheduled/clean_up_unmatched_emails.rb b/app/jobs/scheduled/clean_up_unmatched_emails.rb index 6ce62f3f23..4fff997ef8 100644 --- a/app/jobs/scheduled/clean_up_unmatched_emails.rb +++ b/app/jobs/scheduled/clean_up_unmatched_emails.rb @@ -7,8 +7,8 @@ module Jobs last_match_threshold = SiteSetting.max_age_unmatched_emails.days.ago ScreenedEmail.where(action_type: ScreenedEmail.actions[:block]) - .where("last_match_at < ?", last_match_threshold) - .destroy_all + .where("last_match_at < ?", last_match_threshold) + .destroy_all end end diff --git a/app/jobs/scheduled/clean_up_unmatched_ips.rb b/app/jobs/scheduled/clean_up_unmatched_ips.rb index abd3cdc442..a9b4e4d5d3 100644 --- a/app/jobs/scheduled/clean_up_unmatched_ips.rb +++ b/app/jobs/scheduled/clean_up_unmatched_ips.rb @@ -11,8 +11,8 @@ module Jobs # remove old unmatched IP addresses ScreenedIpAddress.where(action_type: ScreenedIpAddress.actions[:block]) - .where("last_match_at < ? OR (last_match_at IS NULL AND created_at < ?)", last_match_threshold, last_match_threshold) - .destroy_all + .where("last_match_at < ? OR (last_match_at IS NULL AND created_at < ?)", last_match_threshold, last_match_threshold) + .destroy_all end end diff --git a/app/jobs/scheduled/clean_up_unsubscribe_keys.rb b/app/jobs/scheduled/clean_up_unsubscribe_keys.rb index 0535866671..606a13390b 100644 --- a/app/jobs/scheduled/clean_up_unsubscribe_keys.rb +++ b/app/jobs/scheduled/clean_up_unsubscribe_keys.rb @@ -10,4 +10,3 @@ module Jobs end end - diff --git a/app/jobs/scheduled/clean_up_unused_staged_users.rb b/app/jobs/scheduled/clean_up_unused_staged_users.rb index 074b09e978..ad4f1b20d4 100644 --- a/app/jobs/scheduled/clean_up_unused_staged_users.rb +++ b/app/jobs/scheduled/clean_up_unused_staged_users.rb @@ -13,7 +13,7 @@ module Jobs .find_each do |user| begin - destroyer.destroy(user) + destroyer.destroy(user, context: I18n.t("user.destroy_reasons.unused_staged_user")) rescue => e Discourse.handle_job_exception(e, message: "Cleaning up unused staged user", diff --git a/app/jobs/scheduled/clean_up_uploads.rb b/app/jobs/scheduled/clean_up_uploads.rb index 87e4acad28..e2b9353ede 100644 --- a/app/jobs/scheduled/clean_up_uploads.rb +++ b/app/jobs/scheduled/clean_up_uploads.rb @@ -5,13 +5,29 @@ module Jobs def execute(args) return unless SiteSetting.clean_up_uploads? + base_url = Discourse.store.internal? ? Discourse.store.relative_base_url : Discourse.store.absolute_base_url + s3_hostname = URI.parse(base_url).hostname + s3_cdn_hostname = URI.parse(SiteSetting.Upload.s3_cdn_url || "").hostname + # Any URLs in site settings are fair game ignore_urls = [ SiteSetting.logo_url, SiteSetting.logo_small_url, SiteSetting.favicon_url, - SiteSetting.apple_touch_icon_url - ] + SiteSetting.apple_touch_icon_url, + ].map do |url| + if url.present? + url = url.dup + + if s3_cdn_hostname.present? && s3_hostname.present? + url.gsub!(s3_cdn_hostname, s3_hostname) + end + + url[base_url] && url[url.index(base_url)..-1] + else + nil + end + end.compact.uniq grace_period = [SiteSetting.clean_orphan_uploads_grace_period_hours, 1].max @@ -19,7 +35,7 @@ module Jobs .where("uploads.created_at < ?", grace_period.hour.ago) .joins("LEFT JOIN post_uploads pu ON pu.upload_id = uploads.id") .joins("LEFT JOIN users u ON u.uploaded_avatar_id = uploads.id") - .joins("LEFT JOIN user_avatars ua ON (ua.gravatar_upload_id = uploads.id OR ua.custom_upload_id = uploads.id)") + .joins("LEFT JOIN user_avatars ua ON ua.gravatar_upload_id = uploads.id OR ua.custom_upload_id = uploads.id") .joins("LEFT JOIN user_profiles up ON up.profile_background = uploads.url OR up.card_background = uploads.url") .joins("LEFT JOIN categories c ON c.uploaded_logo_id = uploads.id OR c.uploaded_background_id = uploads.id") .joins("LEFT JOIN custom_emojis ce ON ce.upload_id = uploads.id") @@ -29,13 +45,20 @@ module Jobs .where("ua.gravatar_upload_id IS NULL AND ua.custom_upload_id IS NULL") .where("up.profile_background IS NULL AND up.card_background IS NULL") .where("c.uploaded_logo_id IS NULL AND c.uploaded_background_id IS NULL") - .where("ce.upload_id IS NULL AND tf.upload_id IS NULL") - .where("uploads.url NOT IN (?)", ignore_urls) + .where("ce.upload_id IS NULL") + .where("tf.upload_id IS NULL") + + result = result.where("uploads.url NOT IN (?)", ignore_urls) if ignore_urls.present? result.find_each do |upload| - next if QueuedPost.where("raw LIKE '%#{upload.sha1}%'").exists? - next if Draft.where("data LIKE '%#{upload.sha1}%'").exists? - upload.destroy + if upload.sha1.present? + encoded_sha = Base62.encode(upload.sha1.hex) + next if QueuedPost.where("raw LIKE '%#{upload.sha1}%' OR raw LIKE '%#{encoded_sha}%'").exists? + next if Draft.where("data LIKE '%#{upload.sha1}%' OR data LIKE '%#{encoded_sha}%'").exists? + upload.destroy + else + upload.delete + end end end end diff --git a/app/jobs/scheduled/create_missing_avatars.rb b/app/jobs/scheduled/create_missing_avatars.rb index 2d4befe245..d973e9a0be 100644 --- a/app/jobs/scheduled/create_missing_avatars.rb +++ b/app/jobs/scheduled/create_missing_avatars.rb @@ -5,11 +5,11 @@ module Jobs def execute(args) # backfill in batches of 5000 an hour UserAvatar.includes(:user) - .joins(:user) - .where(last_gravatar_download_attempt: nil) - .order("users.last_posted_at DESC") - .limit(5000) - .each do |u| + .joins(:user) + .where(last_gravatar_download_attempt: nil) + .order("users.last_posted_at DESC") + .limit(5000) + .each do |u| u.user.refresh_avatar end end diff --git a/app/jobs/scheduled/dashboard_stats.rb b/app/jobs/scheduled/dashboard_stats.rb index 2b35fc4380..9556bd7ea1 100644 --- a/app/jobs/scheduled/dashboard_stats.rb +++ b/app/jobs/scheduled/dashboard_stats.rb @@ -1,3 +1,7 @@ +require_dependency 'admin_dashboard_data' +require_dependency 'group' +require_dependency 'group_message' + module Jobs class DashboardStats < Jobs::Scheduled every 30.minutes @@ -7,7 +11,7 @@ module Jobs if problems_started_at && problems_started_at < 2.days.ago # If there have been problems reported on the dashboard for a while, # send a message to admins no more often than once per week. - GroupMessage.create(Group[:admins].name, :dashboard_problems, {limit_once_per: 7.days.to_i}) + GroupMessage.create(Group[:admins].name, :dashboard_problems, limit_once_per: 7.days.to_i) end AdminDashboardData.refresh_stats diff --git a/app/jobs/scheduled/directory_refresh_older.rb b/app/jobs/scheduled/directory_refresh_older.rb index 3c25c8151f..93147d6c88 100644 --- a/app/jobs/scheduled/directory_refresh_older.rb +++ b/app/jobs/scheduled/directory_refresh_older.rb @@ -4,7 +4,7 @@ module Jobs def execute(args) periods = DirectoryItem.period_types.keys - [:daily] - periods.each {|p| DirectoryItem.refresh_period!(p)} + periods.each { |p| DirectoryItem.refresh_period!(p) } end end end diff --git a/app/jobs/scheduled/enqueue_digest_emails.rb b/app/jobs/scheduled/enqueue_digest_emails.rb index 1bd4ed4fdf..23db70d4e5 100644 --- a/app/jobs/scheduled/enqueue_digest_emails.rb +++ b/app/jobs/scheduled/enqueue_digest_emails.rb @@ -14,15 +14,15 @@ module Jobs def target_user_ids # Users who want to receive digest email within their chosen digest email frequency query = User.real - .not_suspended - .activated - .where(staged: false) - .joins(:user_option, :user_stat) - .where("user_options.email_digests") - .where("user_stats.bounce_score < #{SiteSetting.bounce_score_threshold}") - .where("COALESCE(last_emailed_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 MINUTE'::INTERVAL * user_options.digest_after_minutes)") - .where("COALESCE(last_seen_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 MINUTE'::INTERVAL * user_options.digest_after_minutes)") - .where("COALESCE(last_seen_at, '2010-01-01') >= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * #{SiteSetting.suppress_digest_email_after_days})") + .not_suspended + .activated + .where(staged: false) + .joins(:user_option, :user_stat) + .where("user_options.email_digests") + .where("user_stats.bounce_score < #{SiteSetting.bounce_score_threshold}") + .where("COALESCE(last_emailed_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 MINUTE'::INTERVAL * user_options.digest_after_minutes)") + .where("COALESCE(last_seen_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 MINUTE'::INTERVAL * user_options.digest_after_minutes)") + .where("COALESCE(last_seen_at, '2010-01-01') >= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * #{SiteSetting.suppress_digest_email_after_days})") # If the site requires approval, make sure the user is approved query = query.where("approved OR moderator OR admin") if SiteSetting.must_approve_users? diff --git a/app/jobs/scheduled/grant_anniversary_badges.rb b/app/jobs/scheduled/grant_anniversary_badges.rb index e5e3287cb0..6d0e24bd12 100644 --- a/app/jobs/scheduled/grant_anniversary_badges.rb +++ b/app/jobs/scheduled/grant_anniversary_badges.rb @@ -4,6 +4,8 @@ module Jobs def execute(args) return unless SiteSetting.enable_badges? + badge = Badge.find_by(id: Badge::Anniversary, enabled: true) + return unless badge start_date = args[:start_date] || 1.year.ago end_date = start_date + 1.year @@ -20,7 +22,7 @@ module Jobs ub.badge_id = #{Badge::Anniversary} AND ub.granted_at BETWEEN '#{fmt_start_date}' AND '#{fmt_end_date}' WHERE u.active AND - NOT u.blocked AND + u.silenced_till IS NULL AND NOT p.hidden AND p.deleted_at IS NULL AND t.visible AND @@ -31,14 +33,12 @@ module Jobs HAVING COUNT(p.id) > 0 AND COUNT(ub.id) = 0 SQL - badge = Badge.find(Badge::Anniversary) - user_ids = results.map {|r| r['user_id'].to_i } + user_ids = results.map { |r| r['user_id'].to_i } - User.where(id: user_ids).each do |user| + User.where(id: user_ids).find_each do |user| BadgeGranter.grant(badge, user, created_at: end_date) end end end end - diff --git a/app/jobs/scheduled/grant_new_user_of_the_month_badges.rb b/app/jobs/scheduled/grant_new_user_of_the_month_badges.rb index c2190d1605..c3db40fbf0 100644 --- a/app/jobs/scheduled/grant_new_user_of_the_month_badges.rb +++ b/app/jobs/scheduled/grant_new_user_of_the_month_badges.rb @@ -8,7 +8,7 @@ module Jobs def execute(args) badge = Badge.find(Badge::NewUserOfTheMonth) - return unless SiteSetting.enable_badges? and badge.enabled? + return unless SiteSetting.enable_badges? && badge.enabled? # Don't award it if a month hasn't gone by return if UserBadge.where("badge_id = ? AND granted_at >= ?", @@ -22,9 +22,11 @@ module Jobs user = User.find(user_id) if user.badges.where(id: Badge::NewUserOfTheMonth).blank? BadgeGranter.grant(badge, user) - SystemMessage.new(user).create('new_user_of_the_month', { - month_year: Time.now.strftime("%B %Y") - }) + + SystemMessage.new(user).create('new_user_of_the_month', + month_year: Time.now.strftime("%B %Y"), + url: "#{Discourse.base_url}/badges" + ) end end end @@ -61,10 +63,12 @@ module Jobs LEFT OUTER JOIN post_actions AS pa ON pa.post_id = p.id AND pa.post_action_type_id = :like LEFT OUTER JOIN users AS liked_by ON liked_by.id = pa.user_id + LEFT OUTER JOIN topics AS t ON t.id = p.topic_id WHERE u.active AND u.id > 0 AND NOT(u.admin) AND NOT(u.moderator) AND + t.archetype <> '#{Archetype.private_message}' AND u.created_at >= CURRENT_TIMESTAMP - '1 month'::INTERVAL AND u.id NOT IN (#{current_owners.join(',')}) GROUP BY u.id @@ -75,10 +79,11 @@ module Jobs LIMIT :max_awarded SQL - User.exec_sql(sql, { + User.exec_sql( + sql, like: PostActionType.types[:like], max_awarded: MAX_AWARDED - }).each do |row| + ).each do |row| scores[row['id'].to_i] = row['score'].to_f end scores diff --git a/app/jobs/scheduled/migrate_upload_scheme.rb b/app/jobs/scheduled/migrate_upload_scheme.rb index a5e21f3e0d..6be51da699 100644 --- a/app/jobs/scheduled/migrate_upload_scheme.rb +++ b/app/jobs/scheduled/migrate_upload_scheme.rb @@ -9,8 +9,8 @@ module Jobs # clean up failed uploads Upload.where("created_at < ?", 1.hour.ago) - .where("LENGTH(COALESCE(url, '')) = 0") - .destroy_all + .where("LENGTH(COALESCE(url, '')) = 0") + .destroy_all # migrate uploads to new scheme problems = Upload.migrate_to_new_scheme(50) diff --git a/app/jobs/scheduled/pending_flags_reminder.rb b/app/jobs/scheduled/pending_flags_reminder.rb index 2a33c51785..817b735b1b 100644 --- a/app/jobs/scheduled/pending_flags_reminder.rb +++ b/app/jobs/scheduled/pending_flags_reminder.rb @@ -25,8 +25,8 @@ module Jobs target_group_names: Group[:moderators].name, archetype: Archetype.private_message, subtype: TopicSubtype.system_message, - title: I18n.t('flags_reminder.subject_template', { count: flagged_posts_count }), - raw: mentions + I18n.t('flags_reminder.flags_were_submitted', { count: SiteSetting.notify_about_flags_after }) + title: I18n.t('flags_reminder.subject_template', count: flagged_posts_count), + raw: mentions + I18n.t('flags_reminder.flags_were_submitted', count: SiteSetting.notify_about_flags_after) ) self.last_notified_id = flag_ids.max @@ -35,7 +35,7 @@ module Jobs end def pending_flag_ids - FlagQuery.flagged_post_actions('active') + FlagQuery.flagged_post_actions(filter: 'active') .where('post_actions.created_at < ?', SiteSetting.notify_about_flags_after.to_i.hours.ago) .pluck(:id) end @@ -54,10 +54,10 @@ module Jobs def active_moderator_usernames User.where(moderator: true) - .human_users - .order('last_seen_at DESC') - .limit(3) - .pluck(:username) + .human_users + .order('last_seen_at DESC') + .limit(3) + .pluck(:username) end end diff --git a/app/jobs/scheduled/pending_queued_posts_reminder.rb b/app/jobs/scheduled/pending_queued_posts_reminder.rb index 37b9afe6f4..867918dcef 100644 --- a/app/jobs/scheduled/pending_queued_posts_reminder.rb +++ b/app/jobs/scheduled/pending_queued_posts_reminder.rb @@ -4,13 +4,20 @@ module Jobs every 1.hour def execute(args) - return true unless SiteSetting.notify_about_queued_posts_after > 0 && SiteSetting.contact_email + return true unless SiteSetting.notify_about_queued_posts_after > 0 queued_post_ids = should_notify_ids if queued_post_ids.size > 0 && last_notified_id.to_i < queued_post_ids.max - message = PendingQueuedPostsMailer.notify(count: queued_post_ids.size) - Email::Sender.new(message, :pending_queued_posts_reminder).send + PostCreator.create( + Discourse.system_user, + target_group_names: Group[:moderators].name, + archetype: Archetype.private_message, + subtype: TopicSubtype.system_message, + title: I18n.t('system_messages.queued_posts_reminder.subject_template', count: queued_post_ids.size), + raw: I18n.t('system_messages.queued_posts_reminder.text_body_template', base_url: Discourse.base_url) + ) + self.last_notified_id = queued_post_ids.max end diff --git a/app/jobs/scheduled/pending_users_reminder.rb b/app/jobs/scheduled/pending_users_reminder.rb index c2e392d65a..88c2fa4db3 100644 --- a/app/jobs/scheduled/pending_users_reminder.rb +++ b/app/jobs/scheduled/pending_users_reminder.rb @@ -7,24 +7,28 @@ module Jobs def execute(args) if SiteSetting.must_approve_users && SiteSetting.pending_users_reminder_delay >= 0 - query = AdminUserIndexQuery.new({query: 'pending', stats: false}).find_users_query # default order is: users.created_at DESC + query = AdminUserIndexQuery.new(query: 'pending', stats: false).find_users_query # default order is: users.created_at DESC if SiteSetting.pending_users_reminder_delay > 0 query = query.where('users.created_at < ?', SiteSetting.pending_users_reminder_delay.hours.ago) end - newest_username = query.limit(1).pluck(:username).first + newest_username = query.limit(1).select(:username).first&.username return true if newest_username == previous_newest_username # already notified count = query.count if count > 0 - target_usernames = Group[:moderators].users.map do |u| - u.id > 0 && u.notifications.joins(:topic) - .where("notifications.id > ?", u.seen_notification_id) - .where("notifications.read = false") - .where("topics.subtype = '#{TopicSubtype.pending_users_reminder}'") - .count == 0 ? u.username : nil + target_usernames = Group[:moderators].users.map do |user| + next if user.id < 0 + + unseen_count = user.notifications.joins(:topic) + .where("notifications.id > ?", user.seen_notification_id) + .where("notifications.read = false") + .where("topics.subtype = ?", TopicSubtype.pending_users_reminder) + .count + + unseen_count == 0 ? user.username : nil end.compact unless target_usernames.empty? @@ -33,8 +37,8 @@ module Jobs target_usernames: target_usernames, archetype: Archetype.private_message, subtype: TopicSubtype.pending_users_reminder, - title: I18n.t("system_messages.pending_users_reminder.subject_template", {count: count}), - raw: I18n.t("system_messages.pending_users_reminder.text_body_template", {count: count, base_url: Discourse.base_url}) + title: I18n.t("system_messages.pending_users_reminder.subject_template", count: count), + raw: I18n.t("system_messages.pending_users_reminder.text_body_template", count: count, base_url: Discourse.base_url) ) self.previous_newest_username = newest_username diff --git a/app/jobs/scheduled/periodical_updates.rb b/app/jobs/scheduled/periodical_updates.rb index 98751dffc3..ca54bf3b18 100644 --- a/app/jobs/scheduled/periodical_updates.rb +++ b/app/jobs/scheduled/periodical_updates.rb @@ -17,10 +17,10 @@ module Jobs def execute(args) # Feature topics in categories - CategoryFeaturedTopic.feature_topics + CategoryFeaturedTopic.feature_topics(batched: true) # Update the scores of posts - args = {min_topic_age: 1.day.ago} + args = { min_topic_age: 1.day.ago } args[:max_topic_length] = 500 unless self.class.should_update_long_topics? ScoreCalculator.new.calculate(args) diff --git a/app/jobs/scheduled/poll_feed.rb b/app/jobs/scheduled/poll_feed.rb index fcd0243aae..2f885c69a2 100644 --- a/app/jobs/scheduled/poll_feed.rb +++ b/app/jobs/scheduled/poll_feed.rb @@ -2,9 +2,13 @@ # Creates and Updates Topics based on an RSS or ATOM feed. # require 'digest/sha1' +require 'excon' +require 'rss' +require_dependency 'feed_item_accessor' +require_dependency 'feed_element_installer' +require_dependency 'final_destination' require_dependency 'post_creator' require_dependency 'post_revisor' -require 'open-uri' module Jobs class PollFeed < Jobs::Scheduled @@ -46,17 +50,11 @@ module Jobs def import_topic(topic) if topic.user - TopicEmbed.import(topic.user, topic.url, topic.title, CGI.unescapeHTML(topic.content.scrub)) + TopicEmbed.import(topic.user, topic.url, topic.title, CGI.unescapeHTML(topic.content)) end end class Feed - require 'simple-rss' - - if SiteSetting.embed_username_key_from_feed.present? - SimpleRSS.item_tags << SiteSetting.embed_username_key_from_feed.to_sym - end - def initialize @feed_url = SiteSetting.feed_polling_url @feed_url = "http://#{@feed_url}" if @feed_url !~ /^https?\:\/\// @@ -65,7 +63,7 @@ module Jobs def topics feed_topics = [] - rss = fetch_rss + rss = parsed_feed return feed_topics unless rss.present? rss.items.each do |i| @@ -78,34 +76,56 @@ module Jobs private - def fetch_rss - SimpleRSS.parse open(@feed_url, allow_redirections: :all) - rescue OpenURI::HTTPError, SimpleRSSError + def parsed_feed + raw_feed = fetch_rss + return nil if raw_feed.blank? + + if SiteSetting.embed_username_key_from_feed.present? + FeedElementInstaller.install(SiteSetting.embed_username_key_from_feed, raw_feed) + end + + RSS::Parser.parse(raw_feed) + rescue RSS::NotWellFormedError, RSS::InvalidRSSError nil end + def fetch_rss + final_destination = FinalDestination.new(@feed_url, verbose: true) + feed_final_url = final_destination.resolve + return nil unless final_destination.status == :resolved + + Excon.new(feed_final_url.to_s).request(method: :get, expects: 200).body + rescue Excon::Error::HTTPStatus + nil + end end class FeedTopic def initialize(article_rss_item) - @article_rss_item = article_rss_item + @accessor = FeedItemAccessor.new(article_rss_item) end def url - link = @article_rss_item.link + link = @accessor.link if url?(link) return link else - return @article_rss_item.id + return @accessor.element_content(:id) end end def content - @article_rss_item.content.try(:force_encoding, "UTF-8").try(:scrub) || @article_rss_item.description.try(:force_encoding, "UTF-8").try(:scrub) + content = nil + + %i[content_encoded content description].each do |content_element_name| + content ||= @accessor.element_content(content_element_name) + end + + content&.force_encoding('UTF-8')&.scrub end def title - @article_rss_item.title.force_encoding("UTF-8").scrub + @accessor.element_content(:title).force_encoding('UTF-8').scrub end def user @@ -123,11 +143,7 @@ module Jobs end def author_username - begin - @article_rss_item.send(SiteSetting.embed_username_key_from_feed.to_sym) - rescue - nil - end + @accessor.element_content(SiteSetting.embed_username_key_from_feed.sub(':', '_')) end def default_user @@ -143,9 +159,6 @@ module Jobs def find_user(user_name) User.where(username_lower: user_name).first end - end - end - end diff --git a/app/jobs/scheduled/purge_inactive.rb b/app/jobs/scheduled/purge_inactive.rb index a6615f5fe2..21417bef21 100644 --- a/app/jobs/scheduled/purge_inactive.rb +++ b/app/jobs/scheduled/purge_inactive.rb @@ -7,4 +7,3 @@ module Jobs end end end - diff --git a/app/jobs/scheduled/purge_old_web_hook_events.rb b/app/jobs/scheduled/purge_old_web_hook_events.rb new file mode 100644 index 0000000000..4bb8c5b7ba --- /dev/null +++ b/app/jobs/scheduled/purge_old_web_hook_events.rb @@ -0,0 +1,9 @@ +module Jobs + class PurgeOldWebHookEvents < Jobs::Scheduled + every 1.day + + def execute(_) + WebHookEvent.purge_old + end + end +end diff --git a/app/jobs/scheduled/reindex_search.rb b/app/jobs/scheduled/reindex_search.rb index bfeda3733a..e354b36d00 100644 --- a/app/jobs/scheduled/reindex_search.rb +++ b/app/jobs/scheduled/reindex_search.rb @@ -4,7 +4,102 @@ module Jobs every 1.day def execute(args) - Search.rebuild_problem_posts + rebuild_problem_topics + rebuild_problem_posts + rebuild_problem_categories + rebuild_problem_users + rebuild_problem_tags + end + + def rebuild_problem_categories(limit = 500) + category_ids = load_problem_category_ids(limit) + + category_ids.each do |id| + category = Category.find_by(id: id) + SearchIndexer.index(category, force: true) if category + end + end + + def rebuild_problem_users(limit = 10000) + user_ids = load_problem_user_ids(limit) + + user_ids.each do |id| + user = User.find_by(id: id) + SearchIndexer.index(user, force: true) if user + end + end + + def rebuild_problem_topics(limit = 10000) + topic_ids = load_problem_topic_ids(limit) + + topic_ids.each do |id| + topic = Topic.find_by(id: id) + SearchIndexer.index(topic, force: true) if topic + end + end + + def rebuild_problem_posts(limit = 10000) + post_ids = load_problem_post_ids(limit) + + post_ids.each do |id| + post = Post.find_by(id: id) + # could be deleted while iterating through batch + SearchIndexer.index(post, force: true) if post + end + end + + def rebuild_problem_tags(limit = 10000) + tag_ids = load_problem_tag_ids(limit) + + tag_ids.each do |id| + tag = Tag.find_by(id: id) + SearchIndexer.index(tag, force: true) if tag + end + end + + private + + def load_problem_post_ids(limit) + Post.joins(:topic) + .where('posts.id IN ( + SELECT p2.id FROM posts p2 + LEFT JOIN post_search_data pd ON pd.locale = ? AND pd.version = ? AND p2.id = pd.post_id + WHERE pd.post_id IS NULL + )', SiteSetting.default_locale, Search::INDEX_VERSION) + .limit(limit) + .pluck(:id) + end + + def load_problem_category_ids(limit) + Category.joins(:category_search_data) + .where('category_search_data.locale != ? + OR category_search_data.version != ?', SiteSetting.default_locale, Search::INDEX_VERSION) + .limit(limit) + .pluck(:id) + end + + def load_problem_topic_ids(limit) + Topic.joins(:topic_search_data) + .where('topic_search_data.locale != ? + OR topic_search_data.version != ?', SiteSetting.default_locale, Search::INDEX_VERSION) + .limit(limit) + .pluck(:id) + end + + def load_problem_user_ids(limit) + User.joins(:user_search_data) + .where('user_search_data.locale != ? + OR user_search_data.version != ?', SiteSetting.default_locale, Search::INDEX_VERSION) + .limit(limit) + .pluck(:id) + end + + def load_problem_tag_ids(limit) + Tag.joins(:tag_search_data) + .where('tag_search_data.locale != ? + OR tag_search_data.version != ?', SiteSetting.default_locale, Search::INDEX_VERSION) + .limit(limit) + .pluck(:id) end end end diff --git a/app/jobs/scheduled/schedule_backup.rb b/app/jobs/scheduled/schedule_backup.rb index 047c8a1141..48ebe73fc2 100644 --- a/app/jobs/scheduled/schedule_backup.rb +++ b/app/jobs/scheduled/schedule_backup.rb @@ -5,7 +5,7 @@ module Jobs sidekiq_options retry: false def execute(args) - return unless SiteSetting.automatic_backups_enabled? + return unless SiteSetting.enable_backups? && SiteSetting.automatic_backups_enabled? if latest_backup = Backup.all[0] date = File.ctime(latest_backup.path).getutc.to_date diff --git a/app/jobs/scheduled/tl3_promotions.rb b/app/jobs/scheduled/tl3_promotions.rb index 37acfcec15..94c3d42fc7 100644 --- a/app/jobs/scheduled/tl3_promotions.rb +++ b/app/jobs/scheduled/tl3_promotions.rb @@ -6,7 +6,11 @@ module Jobs def execute(args) # Demotions demoted_user_ids = [] - User.real.where(trust_level: TrustLevel[3], trust_level_locked: false).find_each do |u| + User.real.where( + trust_level: TrustLevel[3], + manual_locked_trust_level: nil, + group_locked_trust_level: nil + ).find_each do |u| # Don't demote too soon after being promoted next if u.on_tl3_grace_period? @@ -17,9 +21,11 @@ module Jobs end # Promotions - User.real.where(trust_level: TrustLevel[2], - trust_level_locked: false) - .where.not(id: demoted_user_ids).find_each do |u| + User.real.where( + trust_level: TrustLevel[2], + manual_locked_trust_level: nil, + group_locked_trust_level: nil + ).where.not(id: demoted_user_ids).find_each do |u| Promotion.new(u).review_tl2 end end diff --git a/app/jobs/scheduled/version_check.rb b/app/jobs/scheduled/version_check.rb index 9fb39252ee..51b08d379d 100644 --- a/app/jobs/scheduled/version_check.rb +++ b/app/jobs/scheduled/version_check.rb @@ -6,7 +6,7 @@ module Jobs every 1.day def execute(args) - if SiteSetting.version_checks? and (DiscourseUpdates.updated_at.nil? or DiscourseUpdates.updated_at < 1.minute.ago) + if SiteSetting.version_checks? && (DiscourseUpdates.updated_at.nil? || DiscourseUpdates.updated_at < (1.minute.ago)) begin prev_missing_versions_count = DiscourseUpdates.missing_versions_count || 0 @@ -18,10 +18,10 @@ module Jobs DiscourseUpdates.updated_at = Time.zone.now DiscourseUpdates.missing_versions = json['versions'] - if GlobalSetting.new_version_emails and - SiteSetting.new_version_emails and - json['missingVersionsCount'] > 0 and - prev_missing_versions_count < json['missingVersionsCount'].to_i + if GlobalSetting.new_version_emails && + SiteSetting.new_version_emails && + json['missingVersionsCount'] > (0) && + prev_missing_versions_count < (json['missingVersionsCount'].to_i) message = VersionMailer.send_notice Email::Sender.new(message, :new_version).send diff --git a/app/mailers/admin_confirmation_mailer.rb b/app/mailers/admin_confirmation_mailer.rb index ea4d444ad8..4f7679d4bf 100644 --- a/app/mailers/admin_confirmation_mailer.rb +++ b/app/mailers/admin_confirmation_mailer.rb @@ -8,8 +8,7 @@ class AdminConfirmationMailer < ActionMailer::Base to_address, template: 'admin_confirmation_mailer', target_username: target_username, - admin_confirm_url: confirm_admin_url(token: token, host: Discourse.base_url_no_prefix) + admin_confirm_url: confirm_admin_url(token: token, host: Discourse.base_url) ) end end - diff --git a/app/mailers/invite_mailer.rb b/app/mailers/invite_mailer.rb index 3614123716..df371b79a3 100644 --- a/app/mailers/invite_mailer.rb +++ b/app/mailers/invite_mailer.rb @@ -8,14 +8,14 @@ class InviteMailer < ActionMailer::Base include EmailHelper end - def send_invite(invite, custom_message=nil) + def send_invite(invite) # Find the first topic they were invited to first_topic = invite.topics.order(:created_at).first # get invitee name (based on site setting) - invitee_name = invite.invited_by.username + inviter_name = invite.invited_by.username if SiteSetting.enable_names && invite.invited_by.name.present? - invitee_name = "#{invite.invited_by.name} (#{invite.invited_by.username})" + inviter_name = "#{invite.invited_by.name} (#{invite.invited_by.username})" end # If they were invited to a topic @@ -27,7 +27,7 @@ class InviteMailer < ActionMailer::Base end template = 'invite_mailer' - if custom_message.present? + if invite.custom_message.present? template = 'custom_invite_mailer' end @@ -39,28 +39,28 @@ class InviteMailer < ActionMailer::Base build_email(invite.email, template: template, - invitee_name: invitee_name, + inviter_name: inviter_name, site_domain_name: Discourse.current_hostname, invite_link: "#{Discourse.base_url}/invites/#{invite.invite_key}", topic_title: topic_title, topic_excerpt: topic_excerpt, site_description: SiteSetting.site_description, site_title: SiteSetting.title, - user_custom_message: custom_message) + user_custom_message: invite.custom_message) else template = 'invite_forum_mailer' - if custom_message.present? + if invite.custom_message.present? template = 'custom_invite_forum_mailer' end build_email(invite.email, template: template, - invitee_name: invitee_name, + inviter_name: inviter_name, site_domain_name: Discourse.current_hostname, invite_link: "#{Discourse.base_url}/invites/#{invite.invite_key}", site_description: SiteSetting.site_description, site_title: SiteSetting.title, - user_custom_message: custom_message) + user_custom_message: invite.custom_message) end end diff --git a/app/mailers/pending_flags_mailer.rb b/app/mailers/pending_flags_mailer.rb deleted file mode 100644 index c0f67d2bf2..0000000000 --- a/app/mailers/pending_flags_mailer.rb +++ /dev/null @@ -1,39 +0,0 @@ -require_dependency 'email/message_builder' -require_dependency 'flag_query' - -class PendingFlagsMailer < ActionMailer::Base - include Email::BuildEmailHelper - - def notify - return unless SiteSetting.contact_email - - @posts, @topics, @users = FlagQuery.flagged_posts_report(Discourse.system_user, 'active', 0, 20) - - @posts.each do |post| # Note: post is a Hash, not a Post. - topic = @topics.select { |t| t[:id] == post[:topic_id] }.first - - post[:title] = topic[:title] - post[:url] = "#{Discourse.base_url}#{Post.url(topic[:slug], topic[:id], post[:post_number])}" - post[:user] = @users.select { |u| u[:id] == post[:user_id] }.first - - counts = flag_reason_counts(post) - post[:reason_counts] = counts.map { |reason, count| "#{I18n.t('post_action_types.' + reason.to_s + '.title')}: #{count}" }.join(', ') - post[:html_reason_counts] = counts.map { |reason, count| "#{I18n.t('post_action_types.' + reason.to_s + '.title')}: #{count}" }.join(', ') - end - - @hours = SiteSetting.notify_about_flags_after - - subject = "[#{SiteSetting.title}] " + I18n.t('flags_reminder.subject_template', { count: PostAction.flagged_posts_count }) - build_email(SiteSetting.contact_email, subject: subject) - end - - private - - def flag_reason_counts(post) - post[:post_actions].inject({}) do |h,v| - h[v[:name_key]] ||= 0 - h[v[:name_key]] += 1 - h - end - end -end diff --git a/app/mailers/pending_queued_posts_mailer.rb b/app/mailers/pending_queued_posts_mailer.rb deleted file mode 100644 index fec7c3ba72..0000000000 --- a/app/mailers/pending_queued_posts_mailer.rb +++ /dev/null @@ -1,10 +0,0 @@ -require_dependency 'email/message_builder' - -class PendingQueuedPostsMailer < ActionMailer::Base - include Email::BuildEmailHelper - - def notify(opts={}) - return unless SiteSetting.contact_email - build_email(SiteSetting.contact_email, template: 'queued_posts_reminder', count: opts[:count]) - end -end diff --git a/app/mailers/subscription_mailer.rb b/app/mailers/subscription_mailer.rb index 51a66797a6..d8ea40d2fb 100644 --- a/app/mailers/subscription_mailer.rb +++ b/app/mailers/subscription_mailer.rb @@ -3,7 +3,7 @@ require_dependency 'email/message_builder' class SubscriptionMailer < ActionMailer::Base include Email::BuildEmailHelper - def confirm_unsubscribe(user, opts={}) + def confirm_unsubscribe(user, opts = {}) unsubscribe_key = UnsubscribeKey.create_key_for(user, "all") build_email user.email, template: "unsubscribe_mailer", diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index 44d1cc7bd2..236acc0dab 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -1,6 +1,7 @@ require_dependency 'markdown_linker' require_dependency 'email/message_builder' require_dependency 'age_words' +require_dependency 'rtl' class UserNotifications < ActionMailer::Base include UserNotificationsHelper @@ -10,14 +11,14 @@ class UserNotifications < ActionMailer::Base include Email::BuildEmailHelper - def signup(user, opts={}) + def signup(user, opts = {}) build_email(user.email, template: "user_notifications.signup", locale: user_locale(user), email_token: opts[:email_token]) end - def signup_after_approval(user, opts={}) + def signup_after_approval(user, opts = {}) build_email(user.email, template: 'user_notifications.signup_after_approval', locale: user_locale(user), @@ -25,48 +26,87 @@ class UserNotifications < ActionMailer::Base new_user_tips: I18n.t('system_messages.usage_tips.text_body_template', base_url: Discourse.base_url, locale: locale)) end - def notify_old_email(user, opts={}) + def notify_old_email(user, opts = {}) build_email(user.email, template: "user_notifications.notify_old_email", locale: user_locale(user), new_email: opts[:new_email]) end - def confirm_old_email(user, opts={}) + def confirm_old_email(user, opts = {}) build_email(user.email, template: "user_notifications.confirm_old_email", locale: user_locale(user), email_token: opts[:email_token]) end - def confirm_new_email(user, opts={}) + def confirm_new_email(user, opts = {}) build_email(user.email, template: "user_notifications.confirm_new_email", locale: user_locale(user), email_token: opts[:email_token]) end - def forgot_password(user, opts={}) + def forgot_password(user, opts = {}) build_email(user.email, template: user.has_password? ? "user_notifications.forgot_password" : "user_notifications.set_password", locale: user_locale(user), email_token: opts[:email_token]) end - def admin_login(user, opts={}) + def admin_login(user, opts = {}) build_email(user.email, template: "user_notifications.admin_login", locale: user_locale(user), email_token: opts[:email_token]) end - def account_created(user, opts={}) + def account_created(user, opts = {}) build_email(user.email, template: "user_notifications.account_created", locale: user_locale(user), email_token: opts[:email_token]) end + def account_silenced(user, opts = nil) + opts ||= {} + + return unless user_history = opts[:user_history] + + build_email( + user.email, + template: "user_notifications.account_silenced", + locale: user_locale(user), + reason: user_history.details, + message: user_history.context, + silenced_till: I18n.l(user.silenced_till, format: :long) + ) + end + + def account_suspended(user, opts = nil) + opts ||= {} + + return unless user_history = opts[:user_history] + + build_email( + user.email, + template: "user_notifications.account_suspended", + locale: user_locale(user), + reason: user_history.details, + message: user_history.context, + suspended_till: I18n.l(user.suspended_till, format: :long) + ) + end + + def account_exists(user, opts = {}) + build_email( + user.email, + template: 'user_notifications.account_exists', + locale: user_locale(user), + email: user.email + ) + end + def short_date(dt) if dt.year == Time.now.year I18n.l(dt, format: :short_no_year) @@ -75,30 +115,31 @@ class UserNotifications < ActionMailer::Base end end - def digest(user, opts={}) + def digest(user, opts = {}) build_summary_for(user) min_date = opts[:since] || user.last_emailed_at || user.last_seen_at || 1.month.ago # Fetch some topics and posts to show - digest_opts = {limit: SiteSetting.digest_topics + SiteSetting.digest_other_topics, top_order: true} + digest_opts = { limit: SiteSetting.digest_topics + SiteSetting.digest_other_topics, top_order: true } topics_for_digest = Topic.for_digest(user, min_date, digest_opts).to_a if topics_for_digest.empty? && !user.user_option.try(:include_tl0_in_digests) # Find some topics from new users that are at least 24 hours old topics_for_digest = Topic.for_digest(user, min_date, digest_opts.merge(include_tl0: true)).where('topics.created_at < ?', 24.hours.ago).to_a end - @popular_topics = topics_for_digest[0,SiteSetting.digest_topics] + @popular_topics = topics_for_digest[0, SiteSetting.digest_topics] if @popular_topics.present? @other_new_for_you = topics_for_digest.size > SiteSetting.digest_topics ? topics_for_digest[SiteSetting.digest_topics..-1] : [] @popular_posts = if SiteSetting.digest_posts > 0 Post.order("posts.score DESC") - .for_mailing_list(user, min_date) - .where('posts.post_type = ?', Post.types[:regular]) - .where('posts.deleted_at IS NULL AND posts.hidden = false AND posts.user_deleted = false') - .where("posts.post_number > ? AND posts.score > ?", 1, ScoreCalculator.default_score_weights[:like_score] * 5.0) - .limit(SiteSetting.digest_posts) + .for_mailing_list(user, min_date) + .where('posts.post_type = ?', Post.types[:regular]) + .where('posts.deleted_at IS NULL AND posts.hidden = false AND posts.user_deleted = false') + .where("posts.post_number > ? AND posts.score > ?", 1, ScoreCalculator.default_score_weights[:like_score] * 5.0) + .where('posts.created_at < ?', (SiteSetting.editing_grace_period || 0).seconds.ago) + .limit(SiteSetting.digest_posts) else [] end @@ -116,19 +157,19 @@ class UserNotifications < ActionMailer::Base # We used topics from new users instead, so count should match new_topics_count = topics_for_digest.size end - @counts = [{label_key: 'user_notifications.digest.new_topics', - value: new_topics_count, - href: "#{Discourse.base_url}/new"}] + @counts = [{ label_key: 'user_notifications.digest.new_topics', + value: new_topics_count, + href: "#{Discourse.base_url}/new" }] value = user.unread_notifications - @counts << {label_key: 'user_notifications.digest.unread_notifications', value: value, href: "#{Discourse.base_url}/my/notifications"} if value > 0 + @counts << { label_key: 'user_notifications.digest.unread_notifications', value: value, href: "#{Discourse.base_url}/my/notifications" } if value > 0 value = user.unread_private_messages - @counts << {label_key: 'user_notifications.digest.unread_messages', value: value, href: "#{Discourse.base_url}/my/messages"} if value > 0 + @counts << { label_key: 'user_notifications.digest.unread_messages', value: value, href: "#{Discourse.base_url}/my/messages" } if value > 0 if @counts.size < 3 value = user.unread_notifications_of_type(Notification.types[:liked]) - @counts << {label_key: 'user_notifications.digest.liked_received', value: value, href: "#{Discourse.base_url}/my/notifications"} if value > 0 + @counts << { label_key: 'user_notifications.digest.liked_received', value: value, href: "#{Discourse.base_url}/my/notifications" } if value > 0 end if @counts.size < 3 @@ -156,7 +197,6 @@ class UserNotifications < ActionMailer::Base end end - def user_replied(user, opts) opts[:allow_reply_by_email] = true opts[:use_site_subject] = true @@ -251,7 +291,7 @@ class UserNotifications < ActionMailer::Base (user.locale.present? && I18n.available_locales.include?(user.locale.to_sym)) ? user.locale : nil end - def email_post_markdown(post, add_posted_by=false) + def email_post_markdown(post, add_posted_by = false) result = "#{post.raw}\n\n" if add_posted_by result << "#{I18n.t('user_notifications.posted_by', username: post.username, post_date: post.created_at.strftime("%m/%d/%Y"))}\n\n" @@ -274,12 +314,12 @@ class UserNotifications < ActionMailer::Base allowed_post_types << Post.types[:whisper] if topic_user.try(:user).try(:staff?) context_posts = Post.where(topic_id: post.topic_id) - .where("post_number < ?", post.post_number) - .where(user_deleted: false) - .where(hidden: false) - .where(post_type: allowed_post_types) - .order('created_at desc') - .limit(SiteSetting.email_posts_context) + .where("post_number < ?", post.post_number) + .where(user_deleted: false) + .where(hidden: false) + .where(post_type: allowed_post_types) + .order('created_at desc') + .limit(SiteSetting.email_posts_context) if topic_user && topic_user.last_emailed_post_number && user.user_option.email_previous_replies == UserOption.previous_replies_type[:unless_emailed] context_posts = context_posts.where("post_number > ?", topic_user.last_emailed_post_number) @@ -310,7 +350,7 @@ class UserNotifications < ActionMailer::Base allow_reply_by_email = opts[:allow_reply_by_email] unless user.suspended? original_username = notification_data[:original_username] || notification_data[:display_username] - send_notification_email( + email_options = { title: notification_data[:topic_title], post: post, username: original_username, @@ -322,7 +362,13 @@ class UserNotifications < ActionMailer::Base notification_type: notification_type, use_invite_template: opts[:use_invite_template], user: user - ) + } + + if group_id = notification_data[:group_id] + email_options[:group_name] = Group.find_by(id: group_id)&.name + end + + send_notification_email(email_options) end def send_notification_email(opts) @@ -336,12 +382,18 @@ class UserNotifications < ActionMailer::Base from_alias = opts[:from_alias] notification_type = opts[:notification_type] user = opts[:user] + group_name = opts[:group_name] locale = user_locale(user) template = "user_notifications.user_#{notification_type}" if post.topic.private_message? template << "_pm" - template << "_staged" if user.staged? + + if group_name + template << "_group" + elsif user.staged + template << "_staged" + end end # category name @@ -375,24 +427,40 @@ class UserNotifications < ActionMailer::Base end end - translation_override_exists = TranslationOverride.where(locale: SiteSetting.default_locale, translation_key: "#{template}.text_body_template").exists? + translation_override_exists = TranslationOverride.where( + locale: SiteSetting.default_locale, + translation_key: "#{template}.text_body_template" + ).exists? if opts[:use_invite_template] - if post.topic.private_message? - invite_template = "user_notifications.invited_to_private_message_body" - else - invite_template = "user_notifications.invited_to_topic_body" - end + invite_template = "user_notifications.invited" + invite_template << "_group" if group_name + + invite_template << + if post.topic.private_message? + "_to_private_message_body" + else + "_to_topic_body" + end + topic_excerpt = post.excerpt.tr("\n", " ") if post.is_first_post? && post.excerpt topic_excerpt = "" if SiteSetting.private_email? - message = I18n.t(invite_template, username: username, topic_title: gsub_emoji_to_unicode(title), topic_excerpt: topic_excerpt, site_title: SiteSetting.title, site_description: SiteSetting.site_description) + + message = I18n.t(invite_template, + username: username, + group_name: group_name, + topic_title: gsub_emoji_to_unicode(title), + topic_excerpt: topic_excerpt, + site_title: SiteSetting.title, + site_description: SiteSetting.site_description + ) unless translation_override_exists html = UserNotificationRenderer.new(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 + classes: Rtl.new(user).css_class } ) end @@ -400,7 +468,7 @@ class UserNotifications < ActionMailer::Base reached_limit = SiteSetting.max_emails_per_day_per_user > 0 reached_limit &&= (EmailLog.where(user_id: user.id, skipped: false) .where('created_at > ?', 1.day.ago) - .count) >= (SiteSetting.max_emails_per_day_per_user-1) + .count) >= (SiteSetting.max_emails_per_day_per_user - 1) in_reply_to_post = post.reply_to_post if user.user_option.email_in_reply_to if SiteSetting.private_email? @@ -409,7 +477,6 @@ class UserNotifications < ActionMailer::Base message = email_post_markdown(post) + (reached_limit ? "\n\n#{I18n.t "user_notifications.reached_limit", count: SiteSetting.max_emails_per_day_per_user}" : ""); end - unless translation_override_exists html = UserNotificationRenderer.new(Rails.configuration.paths["app/views"]).render( template: 'email/notification', @@ -418,14 +485,14 @@ class UserNotifications < ActionMailer::Base reached_limit: reached_limit, post: post, in_reply_to_post: in_reply_to_post, - classes: RTL.new(user).css_class + classes: Rtl.new(user).css_class } ) end end email_opts = { - topic_title: gsub_emoji_to_unicode(title), + topic_title: Emoji.gsub_emoji_to_unicode(title), topic_title_url_encoded: title ? URI.encode(title) : title, message: message, url: post.url(without_slug: SiteSetting.private_email?), @@ -433,6 +500,7 @@ class UserNotifications < ActionMailer::Base topic_id: post.topic_id, context: context, username: username, + group_name: group_name, add_unsubscribe_link: !user.staged, mailing_list_mode: user.user_option.mailing_list_mode, unsubscribe_url: post.unsubscribe_url(user), diff --git a/app/mailers/version_mailer.rb b/app/mailers/version_mailer.rb index b39136933c..dad4dbd448 100644 --- a/app/mailers/version_mailer.rb +++ b/app/mailers/version_mailer.rb @@ -6,17 +6,17 @@ class VersionMailer < ActionMailer::Base def send_notice if SiteSetting.contact_email.present? missing_versions = DiscourseUpdates.missing_versions - if missing_versions.present? and missing_versions.first['notes'].present? - build_email( SiteSetting.contact_email, + if missing_versions.present? && missing_versions.first['notes'].present? + build_email(SiteSetting.contact_email, template: 'new_version_mailer_with_notes', notes: missing_versions.first['notes'], new_version: DiscourseUpdates.latest_version, - installed_version: Discourse::VERSION::STRING ) + installed_version: Discourse::VERSION::STRING) else - build_email( SiteSetting.contact_email, + build_email(SiteSetting.contact_email, template: 'new_version_mailer', new_version: DiscourseUpdates.latest_version, - installed_version: Discourse::VERSION::STRING ) + installed_version: Discourse::VERSION::STRING) end end end diff --git a/app/models/about.rb b/app/models/about.rb index 0d670183ba..8392df3c48 100644 --- a/app/models/about.rb +++ b/app/models/about.rb @@ -35,8 +35,8 @@ class About def moderators @moderators ||= User.where(moderator: true, admin: false) - .human_users - .order(:username_lower) + .human_users + .order(:username_lower) end def admins diff --git a/app/models/admin_dashboard_data.rb b/app/models/admin_dashboard_data.rb index b4113d609e..57a4394c27 100644 --- a/app/models/admin_dashboard_data.rb +++ b/app/models/admin_dashboard_data.rb @@ -31,7 +31,7 @@ class AdminDashboardData USER_REPORTS ||= ['users_by_trust_level'] - MOBILE_REPORTS ||= ['mobile_visits'] + ApplicationRequest.req_types.keys.select {|r| r =~ /mobile/}.map { |r| r + "_reqs" } + MOBILE_REPORTS ||= ['mobile_visits'] + ApplicationRequest.req_types.keys.select { |r| r =~ /mobile/ }.map { |r| r + "_reqs" } def self.add_problem_check(*syms, &blk) @problem_syms.push(*syms) if syms @@ -120,7 +120,7 @@ class AdminDashboardData $redis.get(problem_message_key(i18n_key)) ? I18n.t(i18n_key) : nil end - def self.add_problem_message(i18n_key, expire_seconds=nil) + def self.add_problem_message(i18n_key, expire_seconds = nil) if expire_seconds.to_i > 0 $redis.setex problem_message_key(i18n_key), expire_seconds.to_i, 1 else @@ -147,7 +147,7 @@ class AdminDashboardData admins: User.admins.count, moderators: User.moderators.count, suspended: User.suspended.count, - blocked: User.blocked.count, + silenced: User.silenced.count, top_referrers: IncomingLinksReport.find('top_referrers').as_json, top_traffic_sources: IncomingLinksReport.find('top_traffic_sources').as_json, top_referred_topics: IncomingLinksReport.find('top_referred_topics').as_json, diff --git a/app/models/api_key.rb b/app/models/api_key.rb index 9145ce4372..b3a8a68bf0 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -1,7 +1,8 @@ class ApiKey < ActiveRecord::Base belongs_to :user - belongs_to :created_by, class_name: User + belongs_to :created_by, class_name: 'User' + validates :user_id, uniqueness: true validates_presence_of :key def regenerate!(updated_by) diff --git a/app/models/application_request.rb b/app/models/application_request.rb index 8f146f10fd..f13e7275a7 100644 --- a/app/models/application_request.rb +++ b/app/models/application_request.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true class ApplicationRequest < ActiveRecord::Base enum req_type: %i(http_total http_2xx @@ -19,9 +20,13 @@ class ApplicationRequest < ActiveRecord::Base self.autoflush_seconds = 5.minutes self.last_flush = Time.now.utc - def self.increment!(type, opts=nil) + def self.increment!(type, opts = nil) key = redis_key(type) val = $redis.incr(key).to_i + + # readonly mode it is going to be 0, skip + return if val == 0 + # 3.days, see: https://github.com/rails/rails/issues/21296 $redis.expire(key, 259200) @@ -36,7 +41,13 @@ class ApplicationRequest < ActiveRecord::Base end end - def self.write_cache!(date=nil) + GET_AND_RESET = <<~LUA + local val = redis.call('get', KEYS[1]) + redis.call('set', KEYS[1], '0') + return val + LUA + + def self.write_cache!(date = nil) if date.nil? write_cache!(Time.now.utc) write_cache!(Time.now.utc.yesterday) @@ -49,42 +60,37 @@ class ApplicationRequest < ActiveRecord::Base # this may seem a bit fancy but in so it allows # for concurrent calls without double counting - req_types.each do |req_type,_| - key = redis_key(req_type,date) - val = $redis.get(key).to_i + req_types.each do |req_type, _| + key = redis_key(req_type, date) + namespaced_key = $redis.namespace_key(key) + val = $redis.without_namespace.eval(GET_AND_RESET, keys: [namespaced_key]).to_i next if val == 0 - new_val = $redis.incrby(key, -val).to_i - - if new_val < 0 - # undo and flush next time - $redis.incrby(key, val) - next - end - - id = req_id(date,req_type) - + id = req_id(date, req_type) where(id: id).update_all(["count = count + ?", val]) end + rescue Redis::CommandError => e + raise unless e.message =~ /READONLY/ + nil end - def self.clear_cache!(date=nil) + def self.clear_cache!(date = nil) if date.nil? clear_cache!(Time.now.utc) clear_cache!(Time.now.utc.yesterday) return end - req_types.each do |req_type,_| - key = redis_key(req_type,date) + req_types.each do |req_type, _| + key = redis_key(req_type, date) $redis.del key end end protected - def self.req_id(date,req_type,retries=0) + def self.req_id(date, req_type, retries = 0) req_type_id = req_types[req_type] @@ -94,13 +100,13 @@ class ApplicationRequest < ActiveRecord::Base rescue # primary key violation if retries == 0 - req_id(date,req_type,1) + req_id(date, req_type, 1) else raise end end - def self.redis_key(req_type, time=Time.now.utc) + def self.redis_key(req_type, time = Time.now.utc) "app_req_#{req_type}#{time.strftime('%Y%m%d')}" end diff --git a/app/models/backup.rb b/app/models/backup.rb index d23d459d00..f8e6a6321f 100644 --- a/app/models/backup.rb +++ b/app/models/backup.rb @@ -10,9 +10,9 @@ class Backup def self.all Dir.glob(File.join(Backup.base_directory, "*.{gz,tgz}")) - .sort_by { |file| File.mtime(file) } - .reverse - .map { |backup| Backup.create_from_filename(File.basename(backup)) } + .sort_by { |file| File.mtime(file) } + .reverse + .map { |backup| Backup.create_from_filename(File.basename(backup)) } end def self.[](filename) @@ -31,6 +31,7 @@ class Backup def after_create_hook upload_to_s3 if SiteSetting.enable_s3_backups? + DiscourseEvent.trigger(:backup_created) end def after_remove_hook @@ -45,7 +46,7 @@ class Backup def s3 require "s3_helper" unless defined? S3Helper - @s3_helper ||= S3Helper.new(s3_bucket) + @s3_helper ||= S3Helper.new(s3_bucket, '', S3Helper.s3_options(SiteSetting)) end def upload_to_s3 diff --git a/app/models/badge.rb b/app/models/badge.rb index ac1b3a4d49..a2f17409a7 100644 --- a/app/models/badge.rb +++ b/app/models/badge.rb @@ -56,14 +56,21 @@ class Badge < ActiveRecord::Base GivesBack = 32 Empathetic = 39 + Enthusiast = 45 + Aficionado = 46 + Devotee = 47 + NewUserOfTheMonth = 44 # other consts AutobiographerMinBioLength = 10 + # used by serializer + attr_accessor :has_badge + def self.trigger_hash Hash[*( - Badge::Trigger.constants.map{|k| + Badge::Trigger.constants.map { |k| [k.to_s.underscore, Badge::Trigger.const_get(k)] }.flatten )] @@ -100,7 +107,7 @@ class Badge < ActiveRecord::Base validates :allow_title, inclusion: [true, false] validates :multiple_grant, inclusion: [true, false] - scope :enabled, ->{ where(enabled: true) } + scope :enabled, -> { where(enabled: true) } before_create :ensure_not_system @@ -190,7 +197,7 @@ class Badge < ActiveRecord::Base def long_description key = "badges.#{i18n_name}.long_description" - I18n.t(key, default: self[:long_description] || '') + I18n.t(key, default: self[:long_description] || '', base_uri: Discourse.base_uri) end def long_description=(val) @@ -200,7 +207,7 @@ class Badge < ActiveRecord::Base def description key = "badges.#{i18n_name}.description" - I18n.t(key, default: self[:description] || '') + I18n.t(key, default: self[:description] || '', base_uri: Discourse.base_uri) end def description=(val) @@ -208,7 +215,6 @@ class Badge < ActiveRecord::Base val end - def slug Slug.for(self.display_name, '-') end @@ -230,7 +236,7 @@ end # Table name: badges # # id :integer not null, primary key -# name :string not null +# name :string(255) not null # description :text # badge_type_id :integer not null # grant_count :integer default(0), not null @@ -238,7 +244,7 @@ end # updated_at :datetime not null # allow_title :boolean default(FALSE), not null # multiple_grant :boolean default(FALSE), not null -# icon :string default("fa-certificate") +# icon :string(255) default("fa-certificate") # listable :boolean default(TRUE) # target_posts :boolean default(FALSE) # query :text @@ -253,6 +259,5 @@ end # # Indexes # -# index_badges_on_badge_type_id (badge_type_id) -# index_badges_on_name (name) UNIQUE +# index_badges_on_name (name) UNIQUE # diff --git a/app/models/badge_grouping.rb b/app/models/badge_grouping.rb index 4fdcef66e3..f1201e7214 100644 --- a/app/models/badge_grouping.rb +++ b/app/models/badge_grouping.rb @@ -22,7 +22,7 @@ end # Table name: badge_groupings # # id :integer not null, primary key -# name :string not null +# name :string(255) not null # description :text # position :integer not null # created_at :datetime not null diff --git a/app/models/badge_type.rb b/app/models/badge_type.rb index 11d4a4b655..4648e3ad58 100644 --- a/app/models/badge_type.rb +++ b/app/models/badge_type.rb @@ -3,7 +3,6 @@ class BadgeType < ActiveRecord::Base Silver = 2 Bronze = 3 - has_many :badges validates :name, presence: true, uniqueness: true end @@ -13,7 +12,7 @@ end # Table name: badge_types # # id :integer not null, primary key -# name :string not null +# name :string(255) not null # created_at :datetime not null # updated_at :datetime not null # diff --git a/app/models/category.rb b/app/models/category.rb index 02956b4eef..2589903c2c 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -1,7 +1,7 @@ require_dependency 'distributed_cache' class Category < ActiveRecord::Base - + include Searchable include Positionable include HasCustomFields include CategoryHashtag @@ -32,7 +32,7 @@ class Category < ActiveRecord::Base has_and_belongs_to_many :web_hooks validates :user_id, presence: true - validates :name, if: Proc.new { |c| c.new_record? || c.name_changed? }, + validates :name, if: Proc.new { |c| c.new_record? || c.will_save_change_to_name? }, presence: true, uniqueness: { scope: :parent_category_id, case_sensitive: false }, length: { in: 1..50 } @@ -60,10 +60,9 @@ class Category < ActiveRecord::Base after_create :delete_category_permalink - after_update :rename_category_definition, if: :name_changed? - after_update :create_category_permalink, if: :slug_changed? + after_update :rename_category_definition, if: :saved_change_to_name? + after_update :create_category_permalink, if: :saved_change_to_slug? - has_one :category_search_data belongs_to :parent_category, class_name: 'Category' has_many :subcategories, class_name: 'Category', foreign_key: 'parent_category_id' @@ -72,11 +71,11 @@ class Category < ActiveRecord::Base has_many :category_tag_groups, dependent: :destroy has_many :tag_groups, through: :category_tag_groups - scope :latest, -> { order('topic_count DESC') } scope :secured, -> (guardian = nil) { ids = guardian.secure_category_ids if guardian + if ids.present? where("NOT categories.read_restricted OR categories.id IN (:cats)", cats: ids).references(:categories) else @@ -109,10 +108,6 @@ class Category < ActiveRecord::Base Category.reset_topic_ids_cache end - def self.last_updated_at - order('updated_at desc').limit(1).pluck(:updated_at).first.to_i - end - def self.scoped_to_permissions(guardian, permission_types) if guardian.try(:is_admin?) all @@ -141,10 +136,10 @@ class Category < ActiveRecord::Base def self.update_stats topics_with_post_count = Topic - .select("topics.category_id, COUNT(*) topic_count, SUM(topics.posts_count) post_count") - .where("topics.id NOT IN (select cc.topic_id from categories cc WHERE topic_id IS NOT NULL)") - .group("topics.category_id") - .visible.to_sql + .select("topics.category_id, COUNT(*) topic_count, SUM(topics.posts_count) post_count") + .where("topics.id NOT IN (select cc.topic_id from categories cc WHERE topic_id IS NOT NULL)") + .group("topics.category_id") + .visible.to_sql Category.exec_sql <<-SQL UPDATE categories c @@ -183,13 +178,12 @@ SQL end end - def visible_posts query = Post.joins(:topic) - .where(['topics.category_id = ?', self.id]) - .where('topics.visible = true') - .where('posts.deleted_at IS NULL') - .where('posts.user_deleted = false') + .where(['topics.category_id = ?', self.id]) + .where('topics.visible = true') + .where('posts.deleted_at IS NULL') + .where('posts.user_deleted = false') self.topic_id ? query.where(['topics.id <> ?', self.topic_id]) : query end @@ -202,7 +196,7 @@ SQL t = Topic.new(title: I18n.t("category.topic_prefix", category: name), user: user, pinned_at: Time.now, category_id: id) t.skip_callbacks = true t.ignore_category_auto_close = true - t.set_or_create_timer(TopicTimer.types[:close], nil) + t.delete_topic_timer(TopicTimer.types[:close]) t.save!(validate: false) update_column(:topic_id, t.id) t.posts.create(raw: post_template, user: user) @@ -256,11 +250,11 @@ SQL def publish_category group_ids = self.groups.pluck(:id) if self.read_restricted - MessageBus.publish('/categories', {categories: ActiveModel::ArraySerializer.new([self]).as_json}, group_ids: group_ids) + MessageBus.publish('/categories', { categories: ActiveModel::ArraySerializer.new([self]).as_json }, group_ids: group_ids) end def publish_category_deletion - MessageBus.publish('/categories', {deleted_categories: [self.id]}) + MessageBus.publish('/categories', deleted_categories: [self.id]) end def parent_category_validator @@ -321,7 +315,7 @@ SQL end def allowed_tags=(tag_names_arg) - DiscourseTagging.add_or_create_tags_by_name(self, tag_names_arg, {unlimited: true}) + DiscourseTagging.add_or_create_tags_by_name(self, tag_names_arg, unlimited: true) end def allowed_tag_groups=(group_names) @@ -359,21 +353,21 @@ SQL def update_latest latest_post_id = Post - .order("posts.created_at desc") - .where("NOT hidden") - .joins("join topics on topics.id = topic_id") - .where("topics.category_id = :id", id: self.id) - .limit(1) - .pluck("posts.id") - .first + .order("posts.created_at desc") + .where("NOT hidden") + .joins("join topics on topics.id = topic_id") + .where("topics.category_id = :id", id: self.id) + .limit(1) + .pluck("posts.id") + .first latest_topic_id = Topic - .order("topics.created_at desc") - .where("visible") - .where("topics.category_id = :id", id: self.id) - .limit(1) - .pluck("topics.id") - .first + .order("topics.created_at desc") + .where("visible") + .where("topics.category_id = :id", id: self.id) + .limit(1) + .pluck("topics.id") + .first self.update_attributes(latest_topic_id: latest_topic_id, latest_post_id: latest_post_id) end @@ -384,7 +378,7 @@ SQL everyone = Group::AUTO_GROUPS[:everyone] full = CategoryGroup.permission_types[:full] - mapped = permissions.map do |group,permission| + mapped = permissions.map do |group, permission| group = group.id if group.is_a?(Group) # subtle, using Group[] ensures the group exists in the DB @@ -459,7 +453,7 @@ SQL # If the name changes, try and update the category definition topic too if it's # an exact match def rename_category_definition - old_name = changed_attributes["name"] + old_name = saved_changes.transform_values(&:first)["name"] return unless topic.present? if topic.title == I18n.t("category.topic_prefix", category: old_name) topic.update_attribute(:title, I18n.t("category.topic_prefix", category: name)) @@ -467,7 +461,7 @@ SQL end def create_category_permalink - old_slug = changed_attributes["slug"] + old_slug = saved_changes.transform_values(&:first)["slug"] if self.parent_category url = "c/#{self.parent_category.slug}/#{old_slug}" else @@ -498,7 +492,7 @@ SQL SearchIndexer.index(self) end - def self.find_by_slug(category_slug, parent_category_slug=nil) + def self.find_by_slug(category_slug, parent_category_slug = nil) if parent_category_slug parent_category_id = self.where(slug: parent_category_slug, parent_category_id: nil).pluck(:id).first self.where(slug: category_slug, parent_category_id: parent_category_id).first @@ -527,7 +521,7 @@ end # topics_year :integer default(0) # topics_month :integer default(0) # topics_week :integer default(0) -# slug :string not null +# slug :string(255) not null # description :text # text_color :string(6) default("FFFFFF"), not null # read_restricted :boolean default(FALSE), not null @@ -540,7 +534,7 @@ end # posts_year :integer default(0) # posts_month :integer default(0) # posts_week :integer default(0) -# email_in :string +# email_in :string(255) # email_in_allow_strangers :boolean default(FALSE) # topics_day :integer default(0) # posts_day :integer default(0) @@ -561,10 +555,11 @@ end # default_view :string(50) # subcategory_list_style :string(50) default("rows_with_featured_topics") # default_top_period :string(20) default("all") +# mailinglist_mirror :boolean default(FALSE), not null # # Indexes # -# index_categories_on_email_in (email_in) UNIQUE -# index_categories_on_topic_count (topic_count) -# unique_index_categories_on_name (name) UNIQUE +# index_categories_on_email_in (email_in) UNIQUE +# index_categories_on_forum_thread_count (topic_count) +# unique_index_categories_on_name ((COALESCE(parent_category_id, '-1'::integer)), name) UNIQUE # diff --git a/app/models/category_featured_topic.rb b/app/models/category_featured_topic.rb index 78331574db..aef63075fa 100644 --- a/app/models/category_featured_topic.rb +++ b/app/models/category_featured_topic.rb @@ -2,18 +2,45 @@ class CategoryFeaturedTopic < ActiveRecord::Base belongs_to :category belongs_to :topic - # Populates the category featured topics - def self.feature_topics + NEXT_CATEGORY_ID_KEY = 'category-featured-topic:next-category-id'.freeze + DEFAULT_BATCH_SIZE = 100 + + # Populates the category featured topics. + def self.feature_topics(batched: false, batch_size: nil) current = {} CategoryFeaturedTopic.select(:topic_id, :category_id).order(:rank).each do |f| (current[f.category_id] ||= []) << f.topic_id end - Category.select(:id, :topic_id, :num_featured_topics).find_each do |c| + + batch_size ||= DEFAULT_BATCH_SIZE + + next_category_id = batched ? $redis.get(NEXT_CATEGORY_ID_KEY).to_i : 0 + + categories = Category.select(:id, :topic_id, :num_featured_topics) + .where('id >= ?', next_category_id) + .order('id ASC') + .limit(batch_size) + .to_a + + if batched + if categories.length == batch_size + next_id = Category.where('id > ?', categories.last.id).order('id asc').limit(1).pluck(:id)[0] + next_id ? $redis.setex(NEXT_CATEGORY_ID_KEY, 1.day, next_id) : clear_batch! + else + clear_batch! + end + end + + categories.each do |c| CategoryFeaturedTopic.feature_topics_for(c, current[c.id] || []) end end - def self.feature_topics_for(c, existing=nil) + def self.clear_batch! + $redis.del(NEXT_CATEGORY_ID_KEY) + end + + def self.feature_topics_for(c, existing = nil) return if c.blank? query_opts = { @@ -23,18 +50,24 @@ class CategoryFeaturedTopic < ActiveRecord::Base no_definitions: true } - # Add topics, even if they're in secured categories: + # It may seem a bit odd that we are running 2 queries here, when admin + # can clearly pull out all the topics needed. + # We do so, so anonymous will ALWAYS get some topics + # If we only fetched as admin we may have a situation where anon can see + # no featured topics (all the previous 2x topics are only visible to admins) + + # Add topics, even if they're in secured categories or invisible query = TopicQuery.new(CategoryFeaturedTopic.fake_admin, query_opts) results = query.list_category_topic_ids(c).uniq # Add some topics that are visible to everyone: - anon_query = TopicQuery.new(nil, query_opts.merge({except_topic_ids: [c.topic_id] + results})) + anon_query = TopicQuery.new(nil, query_opts.merge(except_topic_ids: [c.topic_id] + results)) results += anon_query.list_category_topic_ids(c).uniq return if results == existing CategoryFeaturedTopic.transaction do - CategoryFeaturedTopic.delete_all(category_id: c.id) + CategoryFeaturedTopic.where(category_id: c.id).delete_all if results results.each_with_index do |topic_id, idx| begin diff --git a/app/models/category_featured_user.rb b/app/models/category_featured_user.rb index fb75cca87b..638af79213 100644 --- a/app/models/category_featured_user.rb +++ b/app/models/category_featured_user.rb @@ -30,13 +30,14 @@ class CategoryFeaturedUser < ActiveRecord::Base LIMIT :max_featured_users; ", category_id: category_id, max_featured_users: max_featured_users - user_ids = most_recent_user_ids.map{|uc| uc['user_id'].to_i} + user_ids = most_recent_user_ids.map { |uc| uc['user_id'].to_i } current = CategoryFeaturedUser.where(category_id: category_id).order(:id).pluck(:user_id) return if current == user_ids transaction do - CategoryFeaturedUser.delete_all category_id: category_id + CategoryFeaturedUser.where(category_id: category_id).delete_all + user_ids.each do |user_id| create(category_id: category_id, user_id: user_id) end diff --git a/app/models/category_list.rb b/app/models/category_list.rb index 39507a2a8a..ccc23d88b6 100644 --- a/app/models/category_list.rb +++ b/app/models/category_list.rb @@ -3,13 +3,16 @@ require_dependency 'pinned_check' class CategoryList include ActiveModel::Serialization + cattr_accessor :preloaded_topic_custom_fields + self.preloaded_topic_custom_fields = Set.new + attr_accessor :categories, :uncategorized, :draft, :draft_key, :draft_sequence - def initialize(guardian=nil, options={}) + def initialize(guardian = nil, options = {}) @guardian = guardian || Guardian.new @options = options @@ -20,6 +23,19 @@ class CategoryList find_user_data sort_unpinned trim_results + + if preloaded_topic_custom_fields.present? + displayable_topics = @categories.map(&:displayable_topics) + displayable_topics.flatten! + displayable_topics.compact! + + if displayable_topics.present? + Topic.preload_custom_fields( + displayable_topics, + preloaded_topic_custom_fields + ) + end + end end def preload_key @@ -63,21 +79,23 @@ class CategoryList @categories = @categories.order(:position, :id) else @categories = @categories.order('COALESCE(categories.posts_week, 0) DESC') - .order('COALESCE(categories.posts_month, 0) DESC') - .order('COALESCE(categories.posts_year, 0) DESC') - .order('id ASC') + .order('COALESCE(categories.posts_month, 0) DESC') + .order('COALESCE(categories.posts_year, 0) DESC') + .order('id ASC') end @categories = @categories.to_a category_user = {} + default_notification_level = nil unless @guardian.anonymous? category_user = Hash[*CategoryUser.where(user: @guardian.user).pluck(:category_id, :notification_level).flatten] + default_notification_level = CategoryUser.notification_levels[:regular] end allowed_topic_create = Set.new(Category.topic_create_allowed(@guardian).pluck(:id)) @categories.each do |category| - category.notification_level = category_user[category.id] + category.notification_level = category_user[category.id] || default_notification_level category.permission = CategoryGroup.permission_types[:full] if allowed_topic_create.include?(category.id) category.has_children = category.subcategories.present? end diff --git a/app/models/category_search_data.rb b/app/models/category_search_data.rb index bfbd1b316a..b23bf93604 100644 --- a/app/models/category_search_data.rb +++ b/app/models/category_search_data.rb @@ -1,7 +1,5 @@ class CategorySearchData < ActiveRecord::Base - belongs_to :category - - validates_presence_of :search_data + include HasSearchData end # == Schema Information @@ -12,6 +10,7 @@ end # search_data :tsvector # raw_data :text # locale :text +# version :integer default(0) # # Indexes # diff --git a/app/models/category_user.rb b/app/models/category_user.rb index 1cb6f502b3..8d0cf15912 100644 --- a/app/models/category_user.rb +++ b/app/models/category_user.rb @@ -62,7 +62,7 @@ class CategoryUser < ActiveRecord::Base auto_track(user_id: user.id) end - def self.auto_track(opts={}) + def self.auto_track(opts = {}) builder = SqlBuilder.new < '0f82af', "quaternary" => 'c14924', "header_background" => '111111', - "header_primary" => '333333', + "header_primary" => 'dddddd', "highlight" => 'a87137', "danger" => 'e45735', "success" => '1ca551', @@ -27,8 +27,8 @@ class ColorScheme < ActiveRecord::Base { id: 'default', colors: base_with_hash } ] - CUSTOM_SCHEMES.each do |k,v| - list.push({id: k.to_s, colors: v}) + CUSTOM_SCHEMES.each do |k, v| + list.push(id: k.to_s, colors: v) end list end @@ -75,7 +75,7 @@ class ColorScheme < ActiveRecord::Base def self.base_color_schemes base_color_scheme_colors.map do |hash| scheme = new(name: I18n.t("color_schemes.#{hash[:id]}"), base_scheme_id: hash[:id]) - scheme.colors = hash[:colors].map{|k,v| {name: k.to_s, hex: v.sub("#","")}} + scheme.colors = hash[:colors].map { |k, v| { name: k.to_s, hex: v.sub("#", "") } } scheme.is_base = true scheme end @@ -84,7 +84,7 @@ class ColorScheme < ActiveRecord::Base def self.base return @base_color_scheme if @base_color_scheme @base_color_scheme = new(name: I18n.t('color_schemes.base_theme_name')) - @base_color_scheme.colors = base_colors.map { |name, hex| {name: name, hex: hex} } + @base_color_scheme.colors = base_colors.map { |name, hex| { name: name, hex: hex } } @base_color_scheme.is_base = true @base_color_scheme end @@ -96,13 +96,13 @@ class ColorScheme < ActiveRecord::Base new_color_scheme.base_scheme_id = params[:base_scheme_id] colors = CUSTOM_SCHEMES[params[:base_scheme_id].to_sym]&.map do |name, hex| - {name: name, hex: hex} + { name: name, hex: hex } end if params[:base_scheme_id] colors ||= base.colors_hashes # Override base values params[:colors].each do |name, hex| - c = colors.find {|x| x[:name].to_s == name.to_s} + c = colors.find { |x| x[:name].to_s == name.to_s } c[:hex] = hex end if params[:colors] @@ -113,7 +113,7 @@ class ColorScheme < ActiveRecord::Base def self.lookup_hex_for_name(name) enabled_color_scheme = Theme.where(key: SiteSetting.default_theme_key).first&.color_scheme - (enabled_color_scheme || base).colors.find {|c| c.name == name }.try(:hex) || :nil + (enabled_color_scheme || base).colors.find { |c| c.name == name }.try(:hex) || :nil end def self.hex_for_name(name) @@ -124,12 +124,12 @@ class ColorScheme < ActiveRecord::Base def colors=(arr) @colors_by_name = nil arr.each do |c| - self.color_scheme_colors << ColorSchemeColor.new( name: c[:name], hex: c[:hex] ) + self.color_scheme_colors << ColorSchemeColor.new(name: c[:name], hex: c[:hex]) end end def colors_by_name - @colors_by_name ||= self.colors.inject({}) { |sum,c| sum[c.name] = c; sum; } + @colors_by_name ||= self.colors.inject({}) { |sum, c| sum[c.name] = c; sum; } end def clear_colors_cache @colors_by_name = nil @@ -137,7 +137,7 @@ class ColorScheme < ActiveRecord::Base def colors_hashes color_scheme_colors.map do |c| - {name: c.name, hex: c.hex} + { name: c.name, hex: c.hex } end end @@ -187,7 +187,7 @@ end # Table name: color_schemes # # id :integer not null, primary key -# name :string not null +# name :string(255) not null # version :integer default(1), not null # created_at :datetime not null # updated_at :datetime not null diff --git a/app/models/color_scheme_color.rb b/app/models/color_scheme_color.rb index 51e0c4ae8d..39a7b51eec 100644 --- a/app/models/color_scheme_color.rb +++ b/app/models/color_scheme_color.rb @@ -9,8 +9,8 @@ end # Table name: color_scheme_colors # # id :integer not null, primary key -# name :string not null -# hex :string not null +# name :string(255) not null +# hex :string(255) not null # color_scheme_id :integer not null # created_at :datetime not null # updated_at :datetime not null diff --git a/app/models/concerns/has_custom_fields.rb b/app/models/concerns/has_custom_fields.rb index f1c3ec2863..519f8deb51 100644 --- a/app/models/concerns/has_custom_fields.rb +++ b/app/models/concerns/has_custom_fields.rb @@ -18,8 +18,8 @@ module HasCustomFields return unless types sorted_types = types.keys.select { |k| k.end_with?("*") } - .sort_by(&:length) - .reverse + .sort_by(&:length) + .reverse sorted_types.each do |t| return types[t] if key =~ /^#{t}/i @@ -32,9 +32,9 @@ module HasCustomFields return value unless type = get_custom_field_type(types, key) case type - when :boolean then !!CUSTOM_FIELD_TRUE.include?(value) - when :integer then value.to_i - when :json then ::JSON.parse(value) + when :boolean then !!CUSTOM_FIELD_TRUE.include?(value) + when :integer then value.to_i + when :json then ::JSON.parse(value) else value end @@ -42,7 +42,7 @@ module HasCustomFields end included do - has_many :_custom_fields, dependent: :destroy, :class_name => "#{name}CustomField" + has_many :_custom_fields, dependent: :destroy, class_name: "#{name}CustomField" after_save :save_custom_fields attr_accessor :preloaded_custom_fields @@ -58,7 +58,7 @@ module HasCustomFields return result if whitelisted_fields.blank? klass.where(foreign_key => ids, :name => whitelisted_fields) - .pluck(foreign_key, :name, :value).each do |cf| + .pluck(foreign_key, :name, :value).each do |cf| result[cf[0]] ||= {} append_custom_field(result[cf[0]], cf[1], cf[2]) end @@ -96,7 +96,7 @@ module HasCustomFields .where("name in (?)", fields) .pluck(fk, :name, :value).each do |id, name, value| - preloaded = map[id].preloaded_custom_fields + preloaded = map[id].preloaded_custom_fields if preloaded[name].nil? preloaded.delete(name) @@ -139,7 +139,6 @@ module HasCustomFields end def custom_fields - if @preloaded_custom_fields return @preloaded_proxy ||= PreloadedProxy.new(@preloaded_custom_fields) end @@ -156,13 +155,13 @@ module HasCustomFields !@custom_fields || @custom_fields_orig == @custom_fields end - def save_custom_fields(force=false) + def save_custom_fields(force = false) if force || !custom_fields_clean? dup = @custom_fields.dup array_fields = {} - _custom_fields.each do |f| + _custom_fields.reload.each do |f| if dup[f.name].is_a? Array # we need to collect Arrays fully before we can compare them if !array_fields.has_key?(f.name) @@ -172,13 +171,16 @@ module HasCustomFields end elsif dup[f.name].is_a? Hash if dup[f.name].to_json != f.value - f.destroy + f.destroy! else dup.delete(f.name) end else - if dup[f.name] != f.value - f.destroy + t = {} + self.class.append_custom_field(t, f.name, f.value) + + if dup[f.name] != t[f.name] + f.destroy! else dup.delete(f.name) end @@ -194,13 +196,14 @@ module HasCustomFields end end - dup.each do |k,v| + dup.each do |k, v| if v.is_a? Array - v.each { |subv| _custom_fields.create(name: k, value: subv) } - elsif v.is_a? Hash - _custom_fields.create(name: k, value: v.to_json) + v.each { |subv| _custom_fields.create!(name: k, value: subv) } else - _custom_fields.create(name: k, value: v) + _custom_fields.create!( + name: k, + value: v.is_a?(Hash) ? v.to_json : v + ) end end @@ -212,7 +215,7 @@ module HasCustomFields def refresh_custom_fields_from_db target = Hash.new - _custom_fields.pluck(:name,:value).each do |key, value| + _custom_fields.order('id asc').pluck(:name, :value).each do |key, value| self.class.append_custom_field(target, key, value) end @custom_fields_orig = target diff --git a/app/models/concerns/has_search_data.rb b/app/models/concerns/has_search_data.rb new file mode 100644 index 0000000000..acf8f2cf53 --- /dev/null +++ b/app/models/concerns/has_search_data.rb @@ -0,0 +1,10 @@ +module HasSearchData + extend ActiveSupport::Concern + + included do + _asscoiated_record_name = self.name.sub('SearchData', '').underscore + self.primary_key = "#{_asscoiated_record_name}_id" + belongs_to _asscoiated_record_name.to_sym + validates_presence_of :search_data + end +end diff --git a/app/models/concerns/positionable.rb b/app/models/concerns/positionable.rb index 335b8ce0ac..68e71f1b14 100644 --- a/app/models/concerns/positionable.rb +++ b/app/models/concerns/positionable.rb @@ -11,18 +11,18 @@ module Positionable position = [[position_arg, 0].max, self.class.count - 1].min - if self.position.nil? or position > self.position + if self.position.nil? || position > (self.position) self.exec_sql " UPDATE #{self.class.table_name} SET position = position - 1 WHERE position > :current_position and position <= :new_position", - {current_position: self.position, new_position: position} + current_position: self.position, new_position: position elsif position < self.position self.exec_sql " UPDATE #{self.class.table_name} SET position = position + 1 WHERE position >= :new_position and position < :current_position", - {current_position: self.position, new_position: position} + current_position: self.position, new_position: position else # Not moving to a new position return @@ -31,6 +31,6 @@ module Positionable self.exec_sql " UPDATE #{self.class.table_name} SET position = :position - WHERE id = :id", {id: id, position: position} + WHERE id = :id", id: id, position: position end end diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb new file mode 100644 index 0000000000..dd67af0547 --- /dev/null +++ b/app/models/concerns/searchable.rb @@ -0,0 +1,7 @@ +module Searchable + extend ActiveSupport::Concern + + included do + has_one "#{self.name.underscore}_search_data".to_sym, dependent: :destroy + end +end diff --git a/app/models/concerns/trashable.rb b/app/models/concerns/trashable.rb index 9325f6849e..ffd753a805 100644 --- a/app/models/concerns/trashable.rb +++ b/app/models/concerns/trashable.rb @@ -8,7 +8,6 @@ module Trashable belongs_to :deleted_by, class_name: 'User' end - module ClassMethods def with_deleted # lifted from acts_as_paranoid, works around https://github.com/rails/rails/issues/4306 @@ -17,7 +16,7 @@ module Trashable # scope = self.all - scope.where_values.delete(with_deleted_scope_sql) + scope.where_clause.send(:predicates).delete(with_deleted_scope_sql) scope end @@ -30,7 +29,7 @@ module Trashable deleted_at.present? end - def trash!(trashed_by=nil) + def trash!(trashed_by = nil) # note, an argument could be made that the column should probably called trashed_at # however, deleted_at is the terminology used in the UI # @@ -44,7 +43,6 @@ module Trashable trash_update(nil, nil) end - private def trash_update(deleted_at, deleted_by_id) diff --git a/app/models/directory_item.rb b/app/models/directory_item.rb index 9bdcdf50ac..a31453bc02 100644 --- a/app/models/directory_item.rb +++ b/app/models/directory_item.rb @@ -22,32 +22,34 @@ class DirectoryItem < ActiveRecord::Base end def self.refresh! - period_types.each_key {|p| refresh_period!(p)} + period_types.each_key { |p| refresh_period!(p) } end - def self.refresh_period!(period_type) + def self.refresh_period!(period_type, force: false) # Don't calculate it if the user directory is disabled - return unless SiteSetting.enable_user_directory? + return unless SiteSetting.enable_user_directory? || force - since = case period_type - when :daily then 1.day.ago - when :weekly then 1.week.ago - when :monthly then 1.month.ago - when :quarterly then 3.months.ago - when :yearly then 1.year.ago - else 1000.years.ago - end + since = + case period_type + when :daily then 1.day.ago + when :weekly then 1.week.ago + when :monthly then 1.month.ago + when :quarterly then 3.months.ago + when :yearly then 1.year.ago + else 1000.years.ago + end ActiveRecord::Base.transaction do + # Delete records that belonged to users who have been deleted exec_sql "DELETE FROM directory_items USING directory_items di - LEFT JOIN users u ON u.id = user_id + LEFT JOIN users u ON (u.id = user_id AND u.active AND u.silenced_till IS NULL AND u.id > 0) WHERE di.id = directory_items.id AND u.id IS NULL AND di.period_type = :period_type", period_type: period_types[period_type] - + # Create new records for users who don't have one yet exec_sql "INSERT INTO directory_items(period_type, user_id, likes_received, likes_given, topics_entered, days_visited, posts_read, topic_count, post_count) SELECT :period_type, @@ -61,31 +63,32 @@ class DirectoryItem < ActiveRecord::Base 0 FROM users u LEFT JOIN directory_items di ON di.user_id = u.id AND di.period_type = :period_type - WHERE di.id IS NULL AND u.id > 0 + WHERE di.id IS NULL AND u.id > 0 AND u.silenced_till IS NULL and u.active ", period_type: period_types[period_type] + # Calculate new values and update records + # + # + # TODO + # WARNING: post_count is a wrong name, it should be reply_count (excluding topic post) + # exec_sql "WITH x AS (SELECT u.id user_id, - SUM(CASE WHEN ua.action_type = :was_liked_type THEN 1 ELSE 0 END) likes_received, - SUM(CASE WHEN ua.action_type = :like_type THEN 1 ELSE 0 END) likes_given, + SUM(CASE WHEN p.id IS NOT NULL AND t.id IS NOT NULL AND ua.action_type = :was_liked_type THEN 1 ELSE 0 END) likes_received, + SUM(CASE WHEN p.id IS NOT NULL AND t.id IS NOT NULL AND ua.action_type = :like_type THEN 1 ELSE 0 END) likes_given, COALESCE((SELECT COUNT(topic_id) FROM topic_views AS v WHERE v.user_id = u.id AND v.viewed_at >= :since), 0) topics_entered, COALESCE((SELECT COUNT(id) FROM user_visits AS uv WHERE uv.user_id = u.id AND uv.visited_at >= :since), 0) days_visited, COALESCE((SELECT SUM(posts_read) FROM user_visits AS uv2 WHERE uv2.user_id = u.id AND uv2.visited_at >= :since), 0) posts_read, - SUM(CASE WHEN ua.action_type = :new_topic_type THEN 1 ELSE 0 END) topic_count, - SUM(CASE WHEN ua.action_type = :reply_type THEN 1 ELSE 0 END) post_count + SUM(CASE WHEN t2.id IS NOT NULL AND ua.action_type = :new_topic_type THEN 1 ELSE 0 END) topic_count, + SUM(CASE WHEN p.id IS NOT NULL AND t.id IS NOT NULL AND ua.action_type = :reply_type THEN 1 ELSE 0 END) post_count FROM users AS u - LEFT OUTER JOIN user_actions AS ua ON ua.user_id = u.id - LEFT OUTER JOIN topics AS t ON ua.target_topic_id = t.id AND t.archetype = 'regular' - LEFT OUTER JOIN posts AS p ON ua.target_post_id = p.id + LEFT OUTER JOIN user_actions AS ua ON ua.user_id = u.id AND COALESCE(ua.created_at, :since) >= :since + LEFT OUTER JOIN posts AS p ON ua.target_post_id = p.id AND p.deleted_at IS NULL AND p.post_type = :regular_post_type AND NOT p.hidden + LEFT OUTER JOIN topics AS t ON p.topic_id = t.id AND t.archetype = 'regular' AND t.deleted_at IS NULL AND t.visible + LEFT OUTER JOIN topics AS t2 ON t2.id = ua.target_topic_id AND t2.archetype = 'regular' AND t2.deleted_at IS NULL AND t2.visible LEFT OUTER JOIN categories AS c ON t.category_id = c.id WHERE u.active - AND NOT u.blocked - AND COALESCE(ua.created_at, :since) >= :since - AND t.deleted_at IS NULL - AND COALESCE(t.visible, true) - AND p.deleted_at IS NULL - AND (NOT (COALESCE(p.hidden, false))) - AND COALESCE(p.post_type, :regular_post_type) = :regular_post_type + AND u.silenced_till IS NULL AND u.id > 0 GROUP BY u.id) UPDATE directory_items di SET @@ -117,7 +120,6 @@ class DirectoryItem < ActiveRecord::Base reply_type: UserAction::REPLY, regular_post_type: Post.types[:regular] - if period_type == :all exec_sql < 0 - Group.where('name in (?) AND NOT automatic', split).pluck(:id).each do |id| + Group.where('LOWER(name) in (?) AND NOT automatic', split).pluck(:id).each do |id| unless GroupUser.where(group_id: id, user_id: user.id).exists? GroupUser.create(group_id: id, user_id: user.id) end @@ -118,12 +119,12 @@ class DiscourseSingleSignOn < SingleSignOn end if remove_groups - split = remove_groups.split(",") + split = remove_groups.split(",").map(&:downcase) if split.length > 0 GroupUser - .where(user_id: user.id) - .where('group_id IN (SELECT id FROM groups WHERE name in (?))',split) - .destroy_all + .where(user_id: user.id) + .where('group_id IN (SELECT id FROM groups WHERE LOWER(name) in (?))', split) + .destroy_all end end end @@ -141,6 +142,10 @@ class DiscourseSingleSignOn < SingleSignOn } user = User.create!(user_params) + + if SiteSetting.verbose_sso_logging + Rails.logger.warn("Verbose SSO log: New User (user_id: #{user.id}) Created with #{user_params}") + end end if user @@ -148,8 +153,15 @@ class DiscourseSingleSignOn < SingleSignOn sso_record.last_payload = unsigned_payload sso_record.external_id = external_id else - Jobs.enqueue(:download_avatar_from_url, url: avatar_url, user_id: user.id, override_gravatar: SiteSetting.sso_overrides_avatar) if avatar_url.present? - user.create_single_sign_on_record( + if avatar_url.present? + Jobs.enqueue(:download_avatar_from_url, + url: avatar_url, + user_id: user.id, + override_gravatar: SiteSetting.sso_overrides_avatar + ) + end + + user.create_single_sign_on_record!( last_payload: unsigned_payload, external_id: external_id, external_username: username, @@ -164,7 +176,7 @@ class DiscourseSingleSignOn < SingleSignOn end def change_external_attributes_and_override(sso_record, user) - if SiteSetting.sso_overrides_email && user.email != email + if SiteSetting.sso_overrides_email && user.email != Email.downcase(email) user.email = email user.active = false if require_activation end diff --git a/app/models/discourse_version_check.rb b/app/models/discourse_version_check.rb index 91aabe84c6..084c8dc6da 100644 --- a/app/models/discourse_version_check.rb +++ b/app/models/discourse_version_check.rb @@ -1,5 +1,14 @@ class DiscourseVersionCheck include ActiveModel::Model - attr_accessor :latest_version, :critical_updates, :installed_version, :installed_sha, :installed_describe, :missing_versions_count, :git_branch, :updated_at, :version_check_pending + attr_accessor :latest_version, + :critical_updates, + :installed_version, + :installed_sha, + :installed_describe, + :missing_versions_count, + :git_branch, + :updated_at, + :version_check_pending, + :stale_data end diff --git a/app/models/draft.rb b/app/models/draft.rb index 437ad298fe..48bd38ce13 100644 --- a/app/models/draft.rb +++ b/app/models/draft.rb @@ -4,7 +4,7 @@ class Draft < ActiveRecord::Base EXISTING_TOPIC = 'topic_' def self.set(user, key, sequence, data) - d = find_draft(user,key) + d = find_draft(user, key) if d return if d.sequence > sequence exec_sql("UPDATE drafts @@ -18,14 +18,14 @@ class Draft < ActiveRecord::Base end def self.get(user, key, sequence) - d = find_draft(user,key) + d = find_draft(user, key) if d && d.sequence == sequence d.data end end def self.clear(user, key, sequence) - d = find_draft(user,key) + d = find_draft(user, key) if d && d.sequence <= sequence d.destroy end @@ -59,7 +59,7 @@ end # # id :integer not null, primary key # user_id :integer not null -# draft_key :string not null +# draft_key :string(255) not null # data :text not null # created_at :datetime not null # updated_at :datetime not null diff --git a/app/models/draft_sequence.rb b/app/models/draft_sequence.rb index af4eb647c9..863b00aa70 100644 --- a/app/models/draft_sequence.rb +++ b/app/models/draft_sequence.rb @@ -1,5 +1,5 @@ class DraftSequence < ActiveRecord::Base - def self.next!(user,key) + def self.next!(user, key) user_id = user user_id = user.id unless user.is_a?(Integer) @@ -10,7 +10,7 @@ class DraftSequence < ActiveRecord::Base c ||= DraftSequence.new(h) c.sequence ||= 0 c.sequence += 1 - c.save + c.save! exec_sql("DELETE FROM drafts WHERE user_id = :user_id AND draft_key = :draft_key AND sequence < :sequence", draft_key: key, user_id: user_id, sequence: c.sequence) c.sequence end @@ -23,8 +23,7 @@ class DraftSequence < ActiveRecord::Base # perf critical path r = exec_sql('select sequence from draft_sequences where user_id = ? and draft_key = ?', user_id, key).values - - r.length.zero? ? 0 : r[0][0].to_i + r.length.zero? ? 0 : r[0][0] end end @@ -34,7 +33,7 @@ end # # id :integer not null, primary key # user_id :integer not null -# draft_key :string not null +# draft_key :string(255) not null # sequence :integer not null # # Indexes diff --git a/app/models/email_log.rb b/app/models/email_log.rb index d17ec8c3a6..c17878c93d 100644 --- a/app/models/email_log.rb +++ b/app/models/email_log.rb @@ -39,21 +39,21 @@ class EmailLog < ActiveRecord::Base end end - def self.reached_max_emails?(user, email_type=nil) + def self.reached_max_emails?(user, email_type = nil) return false if SiteSetting.max_emails_per_day_per_user == 0 || CRITICAL_EMAIL_TYPES.include?(email_type) count = sent.where('created_at > ?', 1.day.ago) - .where(user_id: user.id) - .count + .where(user_id: user.id) + .count count >= SiteSetting.max_emails_per_day_per_user end def self.count_per_day(start_date, end_date) sent.where("created_at BETWEEN ? AND ?", start_date, end_date) - .group("DATE(created_at)") - .order("DATE(created_at)") - .count + .group("DATE(created_at)") + .order("DATE(created_at)") + .count end def self.for(reply_key) @@ -62,9 +62,9 @@ class EmailLog < ActiveRecord::Base def self.last_sent_email_address self.where(email_type: "signup") - .order(created_at: :desc) - .first - .try(:to_address) + .order(created_at: :desc) + .first + .try(:to_address) end end @@ -74,8 +74,8 @@ end # Table name: email_logs # # id :integer not null, primary key -# to_address :string not null -# email_type :string not null +# to_address :string(255) not null +# email_type :string(255) not null # user_id :integer # created_at :datetime not null # updated_at :datetime not null @@ -83,7 +83,7 @@ end # post_id :integer # topic_id :integer # skipped :boolean default(FALSE) -# skipped_reason :string +# skipped_reason :string(255) # bounce_key :string # bounced :boolean default(FALSE), not null # message_id :string diff --git a/app/models/email_token.rb b/app/models/email_token.rb index 820ea078f9..2338108ac4 100644 --- a/app/models/email_token.rb +++ b/app/models/email_token.rb @@ -11,8 +11,8 @@ class EmailToken < ActiveRecord::Base after_create do # Expire the previous tokens EmailToken.where(user_id: self.user_id) - .where("id != ?", self.id) - .update_all(expired: true) + .where("id != ?", self.id) + .update_all(expired: true) end def self.token_length @@ -36,7 +36,7 @@ class EmailToken < ActiveRecord::Base end def self.valid_token_format?(token) - token.present? && token =~ /\h{#{token.length/2}}/i + token.present? && token =~ /\h{#{token.length / 2}}/i end def self.atomic_confirm(token) @@ -64,14 +64,14 @@ class EmailToken < ActiveRecord::Base if result[:success] # If we are activating the user, send the welcome message user.send_welcome_message = !user.active? - user.active = true user.email = result[:email_token].email user.save! + user.set_automatic_groups end if user - return User.find_by(email: Email.downcase(user.email)) if Invite.redeem_from_email(user.email).present? + return User.find_by_email(user.email) if Invite.redeem_from_email(user.email).present? user end end @@ -81,10 +81,10 @@ class EmailToken < ActiveRecord::Base def self.confirmable(token) EmailToken.where(token: token) - .where(expired: false, confirmed: false) - .where("created_at >= ?", EmailToken.valid_after) - .includes(:user) - .first + .where(expired: false, confirmed: false) + .where("created_at >= ?", EmailToken.valid_after) + .includes(:user) + .first end end @@ -94,8 +94,8 @@ end # # id :integer not null, primary key # user_id :integer not null -# email :string not null -# token :string not null +# email :string(255) not null +# token :string(255) not null # confirmed :boolean default(FALSE), not null # expired :boolean default(FALSE), not null # created_at :datetime not null diff --git a/app/models/embeddable_host.rb b/app/models/embeddable_host.rb index 7c32a82879..b76a6725b8 100644 --- a/app/models/embeddable_host.rb +++ b/app/models/embeddable_host.rb @@ -1,3 +1,5 @@ +require_dependency 'url_helper' + class EmbeddableHost < ActiveRecord::Base validate :host_must_be_valid belongs_to :category @@ -10,7 +12,7 @@ class EmbeddableHost < ActiveRecord::Base def self.record_for_url(uri) if uri.is_a?(String) - uri = URI(URI.encode(uri)) rescue nil + uri = URI(UrlHelper.escape_uri(uri)) rescue nil end return false unless uri.present? @@ -25,14 +27,20 @@ class EmbeddableHost < ActiveRecord::Base path << "?" << uri.query if uri.query.present? where("lower(host) = ?", host).each do |eh| - return eh if eh.path_whitelist.blank? || !Regexp.new(eh.path_whitelist).match(path).nil? + return eh if eh.path_whitelist.blank? + + path_regexp = Regexp.new(eh.path_whitelist) + return eh if path_regexp.match(path) || path_regexp.match(URI.unescape(path)) end nil end def self.url_allowed?(url) - uri = URI(URI.encode(url)) rescue nil + # Work around IFRAME reload on WebKit where the referer will be set to the Forum URL + return true if url&.starts_with?(Discourse.base_url) + + uri = URI(UrlHelper.escape_uri(url)) rescue nil uri.present? && record_for_url(uri).present? end @@ -52,7 +60,7 @@ end # Table name: embeddable_hosts # # id :integer not null, primary key -# host :string not null +# host :string(255) not null # category_id :integer not null # created_at :datetime # updated_at :datetime diff --git a/app/models/embedding.rb b/app/models/embedding.rb index f11de96f4f..a0be5ceea8 100644 --- a/app/models/embedding.rb +++ b/app/models/embedding.rb @@ -38,7 +38,7 @@ class Embedding < OpenStruct def self.find embedding_args = { id: 'default' } - Embedding.settings.each {|s| embedding_args[s] = SiteSetting.send(s) } + Embedding.settings.each { |s| embedding_args[s] = SiteSetting.send(s) } Embedding.new(embedding_args) end end diff --git a/app/models/emoji.rb b/app/models/emoji.rb index ab61a60257..dd01b21ed8 100644 --- a/app/models/emoji.rb +++ b/app/models/emoji.rb @@ -1,15 +1,14 @@ class Emoji # update this to clear the cache - EMOJI_VERSION = "v3" + EMOJI_VERSION = "5" + + FITZPATRICK_SCALE ||= [ "1f3fb", "1f3fc", "1f3fd", "1f3fe", "1f3ff" ] include ActiveModel::SerializerSupport attr_reader :path attr_accessor :name, :url - # whitelist emojis so that new user can post emojis - Post::white_listed_image_classes << "emoji" - def initialize(path = nil) @path = path end @@ -23,13 +22,17 @@ class Emoji end def self.aliases - Discourse.cache.fetch(cache_key("aliases_emojis")) { load_aliases } + Discourse.cache.fetch(cache_key("aliases_emojis")) { db['aliases'] } end def self.custom Discourse.cache.fetch(cache_key("custom_emojis")) { load_custom } end + def self.tonable_emojis + Discourse.cache.fetch(cache_key("tonable_emojis")) { db['tonableEmojis'] } + end + def self.exists?(name) Emoji[name].present? end @@ -40,22 +43,25 @@ class Emoji def self.create_from_db_item(emoji) name = emoji["name"] - filename = "#{emoji['filename'] || name}.png" + filename = emoji['filename'] || name Emoji.new.tap do |e| e.name = name - e.url = "#{Discourse.base_uri}/images/emoji/#{SiteSetting.emoji_set}/#{filename}" + e.url = Emoji.url_for(filename) end end + def self.url_for(name) + "#{Discourse.base_uri}/images/emoji/#{SiteSetting.emoji_set}/#{name}.png?v=#{EMOJI_VERSION}" + end + def self.cache_key(name) - "#{name}:#{EMOJI_VERSION}:#{Plugin::CustomEmoji.cache_key}" + "#{name}:v#{EMOJI_VERSION}:#{Plugin::CustomEmoji.cache_key}" end def self.clear_cache - Discourse.cache.delete(cache_key("custom_emojis")) - Discourse.cache.delete(cache_key("standard_emojis")) - Discourse.cache.delete(cache_key("aliases_emojis")) - Discourse.cache.delete(cache_key("all_emojis")) + %w{custom standard aliases all tonable}.each do |key| + Discourse.cache.delete(cache_key("#{key}_emojis")) + end end def self.db_file @@ -63,36 +69,17 @@ class Emoji end def self.db - return @db if @db - @db = File.open(db_file, "r:UTF-8") { |f| JSON.parse(f.read) } - - # Small tweak to `emoji.json` from Emoji one - @db['emojis'] << {"code" => "1f44d", "name" => "+1", "filename" => "thumbsup"} - @db['emojis'] << {"code" => "1f44e", "name" => "-1", "filename" => "thumbsdown"} - - @db + @db ||= File.open(db_file, "r:UTF-8") { |f| JSON.parse(f.read) } end def self.load_standard - db['emojis'].map {|e| Emoji.create_from_db_item(e) } - end - - def self.load_aliases - return @aliases if @aliases - - @aliases ||= db['aliases'] - - # Fix how `slightly_smiling` was mislabeled - @aliases['slight_smile'] ||= [] - @aliases['slight_smile'] << 'slightly_smiling' - - @aliases + db['emojis'].map { |e| Emoji.create_from_db_item(e) } end def self.load_custom result = [] - CustomEmoji.order(:name).all.each do |emoji| + CustomEmoji.includes(:upload).order(:name).each do |emoji| result << Emoji.new.tap do |e| e.name = emoji.name e.url = emoji.upload&.url @@ -120,21 +107,32 @@ class Emoji end def self.replacement_code(code) - hexes = code.split('-').map(&:hex) - + hexes = code.split('-'.freeze).map!(&:hex) # Don't replace digits, letters and some symbols - return hexes.pack("U" * hexes.size) if hexes[0] > 255 + hexes.pack("U*".freeze) if hexes[0] > 255 end def self.unicode_replacements return @unicode_replacements if @unicode_replacements - @unicode_replacements = {} + is_tonable_emojis = Emoji.tonable_emojis + fitzpatrick_scales = FITZPATRICK_SCALE.map { |scale| scale.to_i(16) } + db['emojis'].each do |e| - next if e['name'] == 'tm' + name = e['name'] + next if name == 'tm'.freeze + code = replacement_code(e['code']) - @unicode_replacements[code] = e['name'] if code + next unless code + + @unicode_replacements[code] = name + if is_tonable_emojis.include?(name) + fitzpatrick_scales.each_with_index do |scale, index| + toned_code = code.codepoints.insert(1, scale).pack("U*".freeze) + @unicode_replacements[toned_code] = "#{name}:t#{index + 2}" + end + end end @unicode_replacements["\u{2639}"] = 'frowning' @@ -156,14 +154,39 @@ class Emoji end.join end + def self.gsub_emoji_to_unicode(str) + if str + str.gsub(/:([\w\-+]*(?::t\d)?):/) { |name| Emoji.lookup_unicode($1) || name } + end + end + def self.lookup_unicode(name) @reverse_map ||= begin map = {} + is_tonable_emojis = Emoji.tonable_emojis + db['emojis'].each do |e| next if e['name'] == 'tm' + code = replacement_code(e['code']) - map[e['name']] = code if code + next unless code + + map[e['name']] = code + if is_tonable_emojis.include?(e['name']) + FITZPATRICK_SCALE.each_with_index do |scale, index| + toned_code = (code.codepoints.insert(1, scale.to_i(16))).pack("U*") + map["#{e['name']}:t#{index + 2}"] = toned_code + end + end end + + Emoji.aliases.each do |key, alias_names| + next unless alias_code = map[key] + alias_names.each do |alias_name| + map[alias_name] = alias_code + end + end + map end @reverse_map[name] diff --git a/app/models/emoji_set_site_setting.rb b/app/models/emoji_set_site_setting.rb index b930860ad2..952bd37a0d 100644 --- a/app/models/emoji_set_site_setting.rb +++ b/app/models/emoji_set_site_setting.rb @@ -7,7 +7,7 @@ class EmojiSetSiteSetting < EnumSiteSetting if site_setting.name.to_s == "emoji_set" && site_setting.value_changed? Emoji.clear_cache - previous_value = site_setting.value_was || SiteSetting.defaults[:emoji_set] + previous_value = site_setting.attribute_in_database(:value) || SiteSetting.defaults[:emoji_set] before = "/images/emoji/#{previous_value}/" after = "/images/emoji/#{site_setting.value}/" @@ -31,7 +31,9 @@ class EmojiSetSiteSetting < EnumSiteSetting { name: 'emoji_set.google', value: 'google' }, { name: 'emoji_set.twitter', value: 'twitter' }, { name: 'emoji_set.emoji_one', value: 'emoji_one' }, - { name: 'emoji_set.win10', value: 'win10' } + { name: 'emoji_set.win10', value: 'win10' }, + { name: 'emoji_set.google_classic', value: 'google_classic' }, + { name: 'emoji_set.facebook_messenger', value: 'facebook_messenger' }, ] end diff --git a/app/models/facebook_user_info.rb b/app/models/facebook_user_info.rb index eb99611310..7b099e03bb 100644 --- a/app/models/facebook_user_info.rb +++ b/app/models/facebook_user_info.rb @@ -9,13 +9,13 @@ end # id :integer not null, primary key # user_id :integer not null # facebook_user_id :integer not null -# username :string -# first_name :string -# last_name :string -# email :string -# gender :string -# name :string -# link :string +# username :string(255) +# first_name :string(255) +# last_name :string(255) +# email :string(255) +# gender :string(255) +# name :string(255) +# link :string(255) # created_at :datetime not null # updated_at :datetime not null # avatar_url :string diff --git a/app/models/github_user_info.rb b/app/models/github_user_info.rb index 8e776f33ab..c79a3b0e91 100644 --- a/app/models/github_user_info.rb +++ b/app/models/github_user_info.rb @@ -8,7 +8,7 @@ end # # id :integer not null, primary key # user_id :integer not null -# screen_name :string not null +# screen_name :string(255) not null # github_user_id :integer not null # created_at :datetime not null # updated_at :datetime not null diff --git a/app/models/given_daily_like.rb b/app/models/given_daily_like.rb index 87da419453..ccc2974237 100644 --- a/app/models/given_daily_like.rb +++ b/app/models/given_daily_like.rb @@ -16,8 +16,8 @@ class GivenDailyLike < ActiveRecord::Base create(user_id: user_id, given_date: given_date, likes_given: 1) else find_for(user_id, given_date) - .where('limit_reached = false AND likes_given >= :limit', limit: SiteSetting.max_likes_per_day) - .update_all(limit_reached: true) + .where('limit_reached = false AND likes_given >= :limit', limit: SiteSetting.max_likes_per_day) + .update_all(limit_reached: true) end end @@ -28,8 +28,8 @@ class GivenDailyLike < ActiveRecord::Base find_for(user_id, given_date).update_all('likes_given = likes_given - 1') find_for(user_id, given_date) - .where('limit_reached = true AND likes_given < :limit', limit: SiteSetting.max_likes_per_day) - .update_all(limit_reached: false) + .where('limit_reached = true AND likes_given < :limit', limit: SiteSetting.max_likes_per_day) + .update_all(limit_reached: false) end end diff --git a/app/models/global_setting.rb b/app/models/global_setting.rb index 8ecfd75004..fabc777690 100644 --- a/app/models/global_setting.rb +++ b/app/models/global_setting.rb @@ -6,12 +6,12 @@ class GlobalSetting end end - VALID_SECRET_KEY = /^[0-9a-f]{128}$/ + VALID_SECRET_KEY ||= /^[0-9a-f]{128}$/ # this is named SECRET_TOKEN as opposed to SECRET_KEY_BASE # for legacy reasons - REDIS_SECRET_KEY = 'SECRET_TOKEN' + REDIS_SECRET_KEY ||= 'SECRET_TOKEN' - REDIS_VALIDATE_SECONDS = 30 + REDIS_VALIDATE_SECONDS ||= 30 # In Rails secret_key_base is used to encrypt the cookie store # the cookie store contains session data @@ -40,7 +40,7 @@ class GlobalSetting token = $redis.without_namespace.get(REDIS_SECRET_KEY) unless token && token =~ VALID_SECRET_KEY token = SecureRandom.hex(64) - $redis.without_namespace.set(REDIS_SECRET_KEY,token) + $redis.without_namespace.set(REDIS_SECRET_KEY, token) end end if !secret_key_base.blank? && token != secret_key_base @@ -48,6 +48,8 @@ class GlobalSetting end token end + rescue Redis::CommandError => e + @safe_secret_key_base = SecureRandom.hex(64) if e.message =~ /READONLY/ end def self.load_defaults @@ -73,9 +75,24 @@ class GlobalSetting end end + def self.use_s3? + (@use_s3 ||= + begin + s3_bucket && + s3_region && ( + s3_use_iam_profile || (s3_access_key_id && s3_secret_access_key) + ) ? :true : :false + end) == :true + end + + # for testing + def self.reset_s3_cache! + @use_s3 = nil + end + def self.database_config - hash = {"adapter" => "postgresql"} - %w{pool timeout socket host port username password replica_host replica_port}.each do |s| + hash = { "adapter" => "postgresql" } + %w{pool connect_timeout timeout socket host port username password replica_host replica_port}.each do |s| if val = self.send("db_#{s}") hash[s] = val end @@ -91,7 +108,7 @@ class GlobalSetting hash["prepared_statements"] = !!self.db_prepared_statements - {"production" => hash} + { "production" => hash } end # For testing purposes @@ -116,17 +133,18 @@ class GlobalSetting c[:db] = redis_db if redis_db != 0 c[:db] = 1 if Rails.env == "test" - if redis_sentinels.present? - c[:sentinels] = redis_sentinels.split(",").map do |address| - host,port = address.split(":") - {host: host, port: port} - end.to_a - end - c.freeze end end + def self.add_default(name, default) + unless self.respond_to? name + define_singleton_method(name) do + default + end + end + end + class BaseProvider def self.coerce(setting) return setting == "true" if setting == "true" || setting == "false" @@ -134,7 +152,6 @@ class GlobalSetting setting end - def resolve(current, default) BaseProvider.coerce( if current.present? @@ -167,8 +184,7 @@ class GlobalSetting end end - - def lookup(key,default) + def lookup(key, default) var = @data[key] resolve(var, var.nil? ? default : "") end @@ -193,7 +209,7 @@ class GlobalSetting end def keys - ENV.keys.select{|k| k =~ /^DISCOURSE_/}.map{|k| k[10..-1].downcase.to_sym} + ENV.keys.select { |k| k =~ /^DISCOURSE_/ }.map { |k| k[10..-1].downcase.to_sym } end end @@ -207,7 +223,6 @@ class GlobalSetting end end - class << self attr_accessor :provider end diff --git a/app/models/google_user_info.rb b/app/models/google_user_info.rb index 343fe9945f..26f3dda50d 100644 --- a/app/models/google_user_info.rb +++ b/app/models/google_user_info.rb @@ -8,15 +8,15 @@ end # # id :integer not null, primary key # user_id :integer not null -# google_user_id :string not null -# first_name :string -# last_name :string -# email :string -# gender :string -# name :string -# link :string -# profile_link :string -# picture :string +# google_user_id :string(255) not null +# first_name :string(255) +# last_name :string(255) +# email :string(255) +# gender :string(255) +# name :string(255) +# link :string(255) +# profile_link :string(255) +# picture :string(255) # created_at :datetime not null # updated_at :datetime not null # diff --git a/app/models/group.rb b/app/models/group.rb index 0ec202b94b..a86d60c581 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -1,7 +1,14 @@ +# frozen_string_literal: true + +require_dependency 'enum' + class Group < ActiveRecord::Base include HasCustomFields include AnonCacheInvalidator + cattr_accessor :preloaded_custom_field_names + self.preloaded_custom_field_names = Set.new + has_many :category_groups, dependent: :destroy has_many :group_users, dependent: :destroy has_many :group_mentions, dependent: :destroy @@ -23,7 +30,7 @@ class Group < ActiveRecord::Base after_save :update_title after_save :enqueue_update_mentions_job, - if: Proc.new { |g| g.name_was && g.name_changed? } + if: Proc.new { |g| g.name_before_last_save && g.saved_change_to_name? } after_save :expire_cache after_destroy :expire_cache @@ -33,52 +40,110 @@ class Group < ActiveRecord::Base end validate :name_format_validator - validates_uniqueness_of :name, case_sensitive: false + validates :name, presence: true, uniqueness: { case_sensitive: false } validate :automatic_membership_email_domains_format_validator validate :incoming_email_validator - validates :flair_url, url: true, if: Proc.new { |g| g.flair_url && g.flair_url[0,3] != 'fa-' } + validate :can_allow_membership_requests, if: :allow_membership_requests + validates :flair_url, url: true, if: Proc.new { |g| g.flair_url && g.flair_url[0, 3] != 'fa-' } AUTO_GROUPS = { - :everyone => 0, - :admins => 1, - :moderators => 2, - :staff => 3, - :trust_level_0 => 10, - :trust_level_1 => 11, - :trust_level_2 => 12, - :trust_level_3 => 13, - :trust_level_4 => 14 + everyone: 0, + admins: 1, + moderators: 2, + staff: 3, + trust_level_0: 10, + trust_level_1: 11, + trust_level_2: 12, + trust_level_3: 13, + trust_level_4: 14 } AUTO_GROUP_IDS = Hash[*AUTO_GROUPS.to_a.flatten.reverse] STAFF_GROUPS = [:admins, :moderators, :staff] ALIAS_LEVELS = { - :nobody => 0, - :only_admins => 1, - :mods_and_admins => 2, - :members_mods_and_admins => 3, - :everyone => 99 + nobody: 0, + only_admins: 1, + mods_and_admins: 2, + members_mods_and_admins: 3, + everyone: 99 } - validates :alias_level, inclusion: { in: ALIAS_LEVELS.values} + def self.visibility_levels + @visibility_levels = Enum.new( + public: 0, + members: 1, + staff: 2, + owners: 3 + ) + end + + validates :mentionable_level, inclusion: { in: ALIAS_LEVELS.values } + validates :messageable_level, inclusion: { in: ALIAS_LEVELS.values } scope :visible_groups, ->(user) { groups = Group.order(name: :asc).where("groups.id > 0") - if !user || !user.admin - owner_group_ids = GroupUser.where(user: user, owner: true).pluck(:group_id) + unless user&.admin + sql = <<~SQL + groups.id IN ( + SELECT g.id FROM groups g WHERE g.visibility_level = :public + + UNION ALL + + SELECT g.id FROM groups g + JOIN group_users gu ON gu.group_id = g.id AND + gu.user_id = :user_id + WHERE g.visibility_level = :members + + UNION ALL + + SELECT g.id FROM groups g + LEFT JOIN group_users gu ON gu.group_id = g.id AND + gu.user_id = :user_id AND + gu.owner + WHERE g.visibility_level = :staff AND (gu.id IS NOT NULL OR :is_staff) + + UNION ALL + + SELECT g.id FROM groups g + JOIN group_users gu ON gu.group_id = g.id AND + gu.user_id = :user_id AND + gu.owner + WHERE g.visibility_level = :owners + + ) + SQL + + groups = groups.where( + sql, + Group.visibility_levels.to_h.merge(user_id: user&.id, is_staff: !!user&.staff?) + ) - groups = groups.where(" - (groups.automatic = false AND groups.visible = true) OR groups.id IN (?) - ", owner_group_ids) end groups } - scope :mentionable, lambda {|user| + scope :mentionable, lambda { |user| + where("mentionable_level in (:levels) OR + ( + mentionable_level = #{ALIAS_LEVELS[:members_mods_and_admins]} AND id in ( + SELECT group_id FROM group_users WHERE user_id = :user_id) + )", levels: alias_levels(user), user_id: user && user.id) + } + + scope :messageable, lambda { |user| + + where("messageable_level in (:levels) OR + ( + messageable_level = #{ALIAS_LEVELS[:members_mods_and_admins]} AND id in ( + SELECT group_id FROM group_users WHERE user_id = :user_id) + )", levels: alias_levels(user), user_id: user && user.id) + } + + def self.alias_levels(user) levels = [ALIAS_LEVELS[:everyone]] if user && user.admin? @@ -92,12 +157,8 @@ class Group < ActiveRecord::Base ALIAS_LEVELS[:members_mods_and_admins]] end - where("alias_level in (:levels) OR - ( - alias_level = #{ALIAS_LEVELS[:members_mods_and_admins]} AND id in ( - SELECT group_id FROM group_users WHERE user_id = :user_id) - )", levels: levels, user_id: user && user.id ) - } + levels + end def downcase_incoming_email self.incoming_email = (incoming_email || "").strip.downcase.presence @@ -124,66 +185,57 @@ class Group < ActiveRecord::Base end end - def posts_for(guardian, before_post_id=nil) + def posts_for(guardian, opts = nil) + opts ||= {} user_ids = group_users.map { |gu| gu.user_id } result = Post.includes(:user, :topic, topic: :category) - .references(:posts, :topics, :category) - .where(user_id: user_ids) - .where('topics.archetype <> ?', Archetype.private_message) - .where(post_type: Post.types[:regular]) + .references(:posts, :topics, :category) + .where(user_id: user_ids) + .where('topics.archetype <> ?', Archetype.private_message) + .where(post_type: Post.types[:regular]) + + if opts[:category_id].present? + result = result.where('topics.category_id = ?', opts[:category_id].to_i) + end result = guardian.filter_allowed_categories(result) - result = result.where('posts.id < ?', before_post_id) if before_post_id + result = result.where('posts.id < ?', opts[:before_post_id].to_i) if opts[:before_post_id] result.order('posts.created_at desc') end - def messages_for(guardian, before_post_id=nil) + def messages_for(guardian, opts = nil) + opts ||= {} + result = Post.includes(:user, :topic, topic: :category) - .references(:posts, :topics, :category) - .where('topics.archetype = ?', Archetype.private_message) - .where(post_type: Post.types[:regular]) - .where('topics.id IN (SELECT topic_id FROM topic_allowed_groups WHERE group_id = ?)', self.id) + .references(:posts, :topics, :category) + .where('topics.archetype = ?', Archetype.private_message) + .where(post_type: Post.types[:regular]) + .where('topics.id IN (SELECT topic_id FROM topic_allowed_groups WHERE group_id = ?)', self.id) + + if opts[:category_id].present? + result = result.where('topics.category_id = ?', opts[:category_id].to_i) + end result = guardian.filter_allowed_categories(result) - result = result.where('posts.id < ?', before_post_id) if before_post_id + result = result.where('posts.id < ?', opts[:before_post_id].to_i) if opts[:before_post_id] result.order('posts.created_at desc') end - def mentioned_posts_for(guardian, before_post_id=nil) + def mentioned_posts_for(guardian, opts = nil) + opts ||= {} result = Post.joins(:group_mentions) - .includes(:user, :topic, topic: :category) - .references(:posts, :topics, :category) - .where('topics.archetype <> ?', Archetype.private_message) - .where(post_type: Post.types[:regular]) - .where('group_mentions.group_id = ?', self.id) + .includes(:user, :topic, topic: :category) + .references(:posts, :topics, :category) + .where('topics.archetype <> ?', Archetype.private_message) + .where(post_type: Post.types[:regular]) + .where('group_mentions.group_id = ?', self.id) + + if opts[:category_id].present? + result = result.where('topics.category_id = ?', opts[:category_id].to_i) + end result = guardian.filter_allowed_categories(result) - result = result.where('posts.id < ?', before_post_id) if before_post_id - result.order('posts.created_at desc') - end - - def messages_for(guardian, before_post_id=nil) - result = Post.includes(:user, :topic, topic: :category) - .references(:posts, :topics, :category) - .where('topics.archetype = ?', Archetype.private_message) - .where(post_type: Post.types[:regular]) - .where('topics.id IN (SELECT topic_id FROM topic_allowed_groups WHERE group_id = ?)', self.id) - - result = guardian.filter_allowed_categories(result) - result = result.where('posts.id < ?', before_post_id) if before_post_id - result.order('posts.created_at desc') - end - - def mentioned_posts_for(guardian, before_post_id=nil) - result = Post.joins(:group_mentions) - .includes(:user, :topic, topic: :category) - .references(:posts, :topics, :category) - .where('topics.archetype <> ?', Archetype.private_message) - .where(post_type: Post.types[:regular]) - .where('group_mentions.group_id = ?', self.id) - - result = guardian.filter_allowed_categories(result) - result = result.where('posts.id < ?', before_post_id) if before_post_id + result = result.where('posts.id < ?', opts[:before_post_id].to_i) if opts[:before_post_id] result.order('posts.created_at desc') end @@ -196,7 +248,12 @@ class Group < ActiveRecord::Base unless group = self.lookup_group(name) group = Group.new(name: name.to_s, automatic: true) - group.default_notification_level = 2 if AUTO_GROUPS[:moderators] == id + + if AUTO_GROUPS[:moderators] == id + group.default_notification_level = 2 + group.messageable_level = ALIAS_LEVELS[:everyone] + end + group.id = id group.save! end @@ -211,23 +268,27 @@ class Group < ActiveRecord::Base # the everyone group is special, it can include non-users so there is no # way to have the membership in a table - if name == :everyone - group.visible = false + case name + when :everyone + group.visibility_level = Group.visibility_levels[:owners] group.save! return group + when :moderators + group.update!(messageable_level: ALIAS_LEVELS[:everyone]) end # Remove people from groups they don't belong in. - remove_subquery = case name - when :admins - "SELECT id FROM users WHERE NOT admin" - when :moderators - "SELECT id FROM users WHERE NOT moderator" - when :staff - "SELECT id FROM users WHERE NOT admin AND NOT moderator" - when :trust_level_0, :trust_level_1, :trust_level_2, :trust_level_3, :trust_level_4 - "SELECT id FROM users WHERE trust_level < #{id - 10}" - end + remove_subquery = + case name + when :admins + "SELECT id FROM users WHERE NOT admin" + when :moderators + "SELECT id FROM users WHERE NOT moderator" + when :staff + "SELECT id FROM users WHERE NOT admin AND NOT moderator" + when :trust_level_0, :trust_level_1, :trust_level_2, :trust_level_3, :trust_level_4 + "SELECT id FROM users WHERE trust_level < #{id - 10}" + end exec_sql <<-SQL DELETE FROM group_users @@ -237,18 +298,19 @@ class Group < ActiveRecord::Base SQL # Add people to groups - insert_subquery = case name - when :admins - "SELECT id FROM users WHERE admin" - when :moderators - "SELECT id FROM users WHERE moderator" - when :staff - "SELECT id FROM users WHERE moderator OR admin" - when :trust_level_1, :trust_level_2, :trust_level_3, :trust_level_4 - "SELECT id FROM users WHERE trust_level >= #{id - 10}" - when :trust_level_0 - "SELECT id FROM users" - end + insert_subquery = + case name + when :admins + "SELECT id FROM users WHERE admin" + when :moderators + "SELECT id FROM users WHERE moderator" + when :staff + "SELECT id FROM users WHERE moderator OR admin" + when :trust_level_1, :trust_level_2, :trust_level_3, :trust_level_4 + "SELECT id FROM users WHERE trust_level >= #{id - 10}" + when :trust_level_0 + "SELECT id FROM users" + end exec_sql <<-SQL INSERT INTO group_users (group_id, user_id, created_at, updated_at) @@ -269,6 +331,7 @@ class Group < ActiveRecord::Base def self.ensure_consistency! reset_all_counters! refresh_automatic_groups! + refresh_has_messages! end def self.reset_all_counters! @@ -292,6 +355,18 @@ class Group < ActiveRecord::Base args.each { |group| refresh_automatic_group!(group) } end + def self.refresh_has_messages! + exec_sql <<-SQL + UPDATE groups g SET has_messages = false + WHERE NOT EXISTS (SELECT tg.id + FROM topic_allowed_groups tg + INNER JOIN topics t ON t.id = tg.topic_id + WHERE tg.group_id = g.id + AND t.deleted_at IS NULL) + AND g.has_messages = true + SQL + end + def self.ensure_automatic_groups! AUTO_GROUPS.each_key do |name| refresh_automatic_group!(name) unless lookup_group(name) @@ -302,8 +377,10 @@ class Group < ActiveRecord::Base lookup_group(name) || refresh_automatic_group!(name) end - def self.search_group(name) - Group.where(visible: true).where("name ILIKE :term_like", term_like: "#{name}%") + def self.search_groups(name, groups: nil) + (groups || Group).where( + "name ILIKE :term_like OR full_name ILIKE :term_like", term_like: "%#{name}%" + ) end def self.lookup_group(name) @@ -317,22 +394,19 @@ class Group < ActiveRecord::Base end end - def self.lookup_group_ids(opts) - if group_ids = opts[:group_ids] - group_ids = group_ids.split(",").map(&:to_i) - group_ids = Group.where(id: group_ids).pluck(:id) + def self.lookup_groups(group_ids: [], group_names: []) + if group_ids.present? + group_ids = group_ids.split(",") + group_ids.map!(&:to_i) + groups = Group.where(id: group_ids) if group_ids.present? end - group_ids ||= [] - - if group_names = opts[:group_names] + if group_names.present? group_names = group_names.split(",") - if group_names.present? - group_ids += Group.where(name: group_names).pluck(:id) - end + groups = (groups || Group).where(name: group_names) if group_names.present? end - group_ids + groups || [] end def self.desired_trust_level_groups(trust_level) @@ -370,11 +444,11 @@ class Group < ActiveRecord::Base additions = expected - current deletions = current - expected - map = Hash[*User.where(username: additions+deletions) - .select('id,username') - .map{|u| [u.username,u.id]}.flatten] + map = Hash[*User.where(username: additions + deletions) + .select('id,username') + .map { |u| [u.username, u.id] }.flatten] - deletions = Set.new(deletions.map{|d| map[d]}) + deletions = Set.new(deletions.map { |d| map[d] }) @deletions = [] group_users.each do |gu| @@ -391,8 +465,19 @@ class Group < ActiveRecord::Base users.pluck(:username).join(",") end + PUBLISH_CATEGORIES_LIMIT = 10 + def add(user) self.users.push(user) unless self.users.include?(user) + + if self.categories.count < PUBLISH_CATEGORIES_LIMIT + MessageBus.publish('/categories', { + categories: ActiveModel::ArraySerializer.new(self.categories).as_json + }, user_ids: [user.id]) + else + Discourse.request_refresh!(user_ids: [user.id]) + end + self end @@ -403,9 +488,9 @@ class Group < ActiveRecord::Base def add_owner(user) if group_user = self.group_users.find_by(user: user) - group_user.update_attributes!(owner: true) if !group_user.owner + group_user.update!(owner: true) if !group_user.owner else - GroupUser.create!(user: user, group: self, owner: true) + self.group_users.create!(user: user, owner: true) end end @@ -414,32 +499,51 @@ class Group < ActiveRecord::Base end def bulk_add(user_ids) - if user_ids.present? - Group.exec_sql("INSERT INTO group_users - (group_id, user_id, created_at, updated_at) - SELECT #{self.id}, - u.id, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP - FROM users AS u - WHERE u.id IN (#{user_ids.join(', ')}) - AND NOT EXISTS(SELECT 1 FROM group_users AS gu - WHERE gu.user_id = u.id AND - gu.group_id = #{self.id})") + return unless user_ids.present? + + Group.transaction do + sql = <<~SQL + INSERT INTO group_users + (group_id, user_id, created_at, updated_at) + SELECT + #{self.id}, + u.id, + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + FROM users AS u + WHERE u.id IN (:user_ids) + AND NOT EXISTS ( + SELECT 1 FROM group_users AS gu + WHERE gu.user_id = u.id AND + gu.group_id = :group_id + ) + SQL + + Group.exec_sql(sql, group_id: self.id, user_ids: user_ids) + + user_attributes = {} if self.primary_group? - User.where(id: user_ids).update_all(primary_group_id: self.id) + user_attributes[:primary_group_id] = self.id end if self.title.present? - User.where(id: user_ids).update_all(title: self.title) + user_attributes[:title] = self.title end - if self.grant_trust_level.present? - Jobs.enqueue(:bulk_grant_trust_level, user_ids: user_ids, trust_level: self.grant_trust_level) + if user_attributes.present? + User.where(id: user_ids).update_all(user_attributes) end end - true + + if self.grant_trust_level.present? + Jobs.enqueue(:bulk_grant_trust_level, + user_ids: user_ids, + trust_level: self.grant_trust_level + ) + end + + self end def staff? @@ -449,6 +553,7 @@ class Group < ActiveRecord::Base protected def name_format_validator + self.name.strip! UsernameValidator.perform_validation(self, 'name') end @@ -484,7 +589,7 @@ class Group < ActiveRecord::Base def update_title return if new_record? && !self.title.present? - if self.title_changed? + if self.saved_change_to_title? sql = <<-SQL.squish UPDATE users SET title = :title @@ -493,19 +598,19 @@ class Group < ActiveRecord::Base AND id IN (SELECT user_id FROM group_users WHERE group_id = :id) SQL - self.class.exec_sql(sql, title: title, title_was: title_was, id: id) + self.class.exec_sql(sql, title: title, title_was: title_before_last_save, id: id) end end def update_primary_group return if new_record? && !self.primary_group? - if self.primary_group_changed? - sql = < { where("NOT is_bounce AND LENGTH(COALESCE(error,'')) > 0") } + scope :errored, -> { where("NOT is_bounce AND error IS NOT NULL") } + + scope :addressed_to, -> (email) { where('incoming_emails.to_addresses ILIKE :email OR incoming_emails.cc_addresses ILIKE :email', email: "%#{email}%") } end # == Schema Information diff --git a/app/models/incoming_link.rb b/app/models/incoming_link.rb index fd3e9e8e8d..0b7e7c0ed6 100644 --- a/app/models/incoming_link.rb +++ b/app/models/incoming_link.rb @@ -35,11 +35,10 @@ class IncomingLink < ActiveRecord::Base post_id = opts[:post_id] post_id ||= Post.where(topic_id: opts[:topic_id], post_number: opts[:post_number] || 1) - .pluck(:id).first + .pluck(:id).first cid = current_user ? (current_user.id) : (nil) - unless cid && cid == user_id create(referer: referer, @@ -53,7 +52,6 @@ class IncomingLink < ActiveRecord::Base end - def referer=(referer) self.incoming_referer_id = nil @@ -67,8 +65,8 @@ class IncomingLink < ActiveRecord::Base if parsed.scheme == "http" || parsed.scheme == "https" domain = IncomingDomain.add!(parsed) - referer = IncomingReferer.add!(path: parsed.path, incoming_domain: domain) if domain - self.incoming_referer_id = referer.id if referer + referer_record = IncomingReferer.add!(path: parsed.path, incoming_domain: domain) if domain + self.incoming_referer_id = referer_record.id if referer_record end rescue URI::InvalidURIError @@ -87,7 +85,6 @@ class IncomingLink < ActiveRecord::Base end end - # Internal: Update appropriate link counts. def update_link_counts exec_sql("UPDATE topics diff --git a/app/models/incoming_links_report.rb b/app/models/incoming_links_report.rb index 388ea57cc9..cf28011b8d 100644 --- a/app/models/incoming_links_report.rb +++ b/app/models/incoming_links_report.rb @@ -30,24 +30,24 @@ class IncomingLinksReport # Return top 10 users who brought traffic to the site within the last 30 days def self.report_top_referrers(report) - report.y_titles[:num_clicks] = I18n.t("reports.#{report.type}.num_clicks") + report.y_titles[:num_clicks] = I18n.t("reports.#{report.type}.num_clicks") report.y_titles[:num_topics] = I18n.t("reports.#{report.type}.num_topics") - num_clicks = link_count_per_user + num_clicks = link_count_per_user num_topics = topic_count_per_user - user_id_lookup = User.where(username: num_clicks.keys).select(:id, :username).inject({}) {|sum,v| sum[v.username] = v.id; sum;} + user_id_lookup = User.where(username: num_clicks.keys).select(:id, :username).inject({}) { |sum, v| sum[v.username] = v.id; sum; } report.data = [] num_clicks.each_key do |username| - report.data << {username: username, user_id: user_id_lookup[username], num_clicks: num_clicks[username], num_topics: num_topics[username]} + report.data << { username: username, user_id: user_id_lookup[username], num_clicks: num_clicks[username], num_topics: num_topics[username] } end - report.data = report.data.sort_by {|x| x[:num_clicks]}.reverse[0,10] + report.data = report.data.sort_by { |x| x[:num_clicks] }.reverse[0, 10] end def self.per_user @per_user_query ||= IncomingLink - .where('incoming_links.created_at > ? AND incoming_links.user_id IS NOT NULL', 30.days.ago) - .joins(:user) - .group('users.username') + .where('incoming_links.created_at > ? AND incoming_links.user_id IS NOT NULL', 30.days.ago) + .joins(:user) + .group('users.username') end def self.link_count_per_user @@ -58,35 +58,34 @@ class IncomingLinksReport per_user.joins(:post).count("DISTINCT posts.topic_id") end - # Return top 10 domains that brought traffic to the site within the last 30 days def self.report_top_traffic_sources(report) - report.y_titles[:num_clicks] = I18n.t("reports.#{report.type}.num_clicks") + report.y_titles[:num_clicks] = I18n.t("reports.#{report.type}.num_clicks") report.y_titles[:num_topics] = I18n.t("reports.#{report.type}.num_topics") report.y_titles[:num_users] = I18n.t("reports.#{report.type}.num_users") - num_clicks = link_count_per_domain + num_clicks = link_count_per_domain num_topics = topic_count_per_domain(num_clicks.keys) report.data = [] num_clicks.each_key do |domain| - report.data << {domain: domain, num_clicks: num_clicks[domain], num_topics: num_topics[domain]} + report.data << { domain: domain, num_clicks: num_clicks[domain], num_topics: num_topics[domain] } end - report.data = report.data.sort_by {|x| x[:num_clicks]}.reverse[0,10] + report.data = report.data.sort_by { |x| x[:num_clicks] }.reverse[0, 10] end - def self.link_count_per_domain(limit=10) + def self.link_count_per_domain(limit = 10) IncomingLink.where('incoming_links.created_at > ?', 30.days.ago) - .joins(:incoming_referer => :incoming_domain) - .group('incoming_domains.name') - .order('count_all DESC') - .limit(limit).count + .joins(incoming_referer: :incoming_domain) + .group('incoming_domains.name') + .order('count_all DESC') + .limit(limit).count end def self.per_domain(domains) IncomingLink - .joins(:incoming_referer => :incoming_domain) - .where('incoming_links.created_at > ? AND incoming_domains.name IN (?)', 30.days.ago, domains) - .group('incoming_domains.name') + .joins(incoming_referer: :incoming_domain) + .where('incoming_links.created_at > ? AND incoming_domains.name IN (?)', 30.days.ago, domains) + .group('incoming_domains.name') end def self.topic_count_per_domain(domains) @@ -94,17 +93,16 @@ class IncomingLinksReport per_domain(domains).joins(:post).count("DISTINCT posts.topic_id") end - def self.report_top_referred_topics(report) - report.y_titles[:num_clicks] = I18n.t("reports.#{report.type}.num_clicks") - num_clicks = link_count_per_topic - num_clicks = num_clicks.to_a.sort_by {|x| x[1]}.last(10).reverse # take the top 10 + report.y_titles[:num_clicks] = I18n.t("reports.#{report.type}.num_clicks") + num_clicks = link_count_per_topic + num_clicks = num_clicks.to_a.sort_by { |x| x[1] }.last(10).reverse # take the top 10 report.data = [] - topics = Topic.select('id, slug, title').where('id in (?)', num_clicks.map {|z| z[0]}) + topics = Topic.select('id, slug, title').where('id in (?)', num_clicks.map { |z| z[0] }) num_clicks.each do |topic_id, num_clicks_element| - topic = topics.find {|t| t.id == topic_id} + topic = topics.find { |t| t.id == topic_id } if topic - report.data << {topic_id: topic_id, topic_title: topic.title, topic_slug: topic.slug, num_clicks: num_clicks_element} + report.data << { topic_id: topic_id, topic_title: topic.title, topic_slug: topic.slug, num_clicks: num_clicks_element } end end report.data @@ -112,8 +110,8 @@ class IncomingLinksReport def self.link_count_per_topic IncomingLink.joins(:post) - .where('incoming_links.created_at > ? AND topic_id IS NOT NULL', 30.days.ago) - .group('topic_id') - .count + .where('incoming_links.created_at > ? AND topic_id IS NOT NULL', 30.days.ago) + .group('topic_id') + .count end end diff --git a/app/models/invite.rb b/app/models/invite.rb index c667cafc94..b9fcb44e24 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -32,8 +32,9 @@ class Invite < ActiveRecord::Base def user_doesnt_already_exist @email_already_exists = false return if email.blank? - u = User.find_by("email = ?", Email.downcase(email)) - if u && u.id != self.user_id + user = User.find_by_email(email) + + if user && user.id != self.user_id @email_already_exists = true errors.add(:email) end @@ -52,8 +53,8 @@ class Invite < ActiveRecord::Base invalidated_at.nil? end - def redeem(username: nil, name: nil, password: nil) - InviteRedeemer.new(self, username, name, password).redeem unless expired? || destroyed? || !link_valid? + def redeem(username: nil, name: nil, password: nil, user_custom_fields: nil) + InviteRedeemer.new(self, username, name, password, user_custom_fields).redeem unless expired? || destroyed? || !link_valid? end def self.extend_permissions(topic, user, invited_by) @@ -69,48 +70,47 @@ class Invite < ActiveRecord::Base end end - def self.invite_by_email(email, invited_by, topic=nil, group_ids=nil, custom_message=nil) - create_invite_by_email(email, invited_by, { + def self.invite_by_email(email, invited_by, topic = nil, group_ids = nil, custom_message = nil) + create_invite_by_email(email, invited_by, topic: topic, group_ids: group_ids, custom_message: custom_message, send_email: true - }) + ) end # generate invite link - def self.generate_invite_link(email, invited_by, topic=nil, group_ids=nil) - invite = create_invite_by_email(email, invited_by, { + def self.generate_invite_link(email, invited_by, topic = nil, group_ids = nil) + invite = create_invite_by_email(email, invited_by, topic: topic, group_ids: group_ids, send_email: false - }) - return "#{Discourse.base_url}/invites/#{invite.invite_key}" if invite + ) + + "#{Discourse.base_url}/invites/#{invite.invite_key}" if invite end # Create an invite for a user, supplying an optional topic # # Return the previously existing invite if already exists. Returns nil if the invite can't be created. - def self.create_invite_by_email(email, invited_by, opts=nil) + def self.create_invite_by_email(email, invited_by, opts = nil) opts ||= {} topic = opts[:topic] group_ids = opts[:group_ids] send_email = opts[:send_email].nil? ? true : opts[:send_email] custom_message = opts[:custom_message] - lower_email = Email.downcase(email) - user = User.find_by(email: lower_email) - if user + if user = User.find_by_email(lower_email) extend_permissions(topic, user, invited_by) if topic raise UserExists.new I18n.t("invite.user_exists", email: lower_email, username: user.username) end invite = Invite.with_deleted - .where(email: lower_email, invited_by_id: invited_by.id) - .order('created_at DESC') - .first + .where(email: lower_email, invited_by_id: invited_by.id) + .order('created_at DESC') + .first if invite && (invite.expired? || invite.deleted_at) invite.destroy @@ -122,6 +122,7 @@ class Invite < ActiveRecord::Base if !invite create_args = { invited_by: invited_by, email: lower_email } create_args[:moderator] = true if opts[:moderator] + create_args[:custom_message] = custom_message if custom_message invite = Invite.create!(create_args) end @@ -143,7 +144,7 @@ class Invite < ActiveRecord::Base end end - Jobs.enqueue(:invite_email, invite_id: invite.id, custom_message: custom_message) if send_email + Jobs.enqueue(:invite_email, invite_id: invite.id) if send_email invite.reload invite @@ -161,32 +162,32 @@ class Invite < ActiveRecord::Base group_ids end - def self.find_all_invites_from(inviter, offset=0, limit=SiteSetting.invites_per_page) + def self.find_all_invites_from(inviter, offset = 0, limit = SiteSetting.invites_per_page) Invite.where(invited_by_id: inviter.id) - .where('invites.email IS NOT NULL') - .includes(:user => :user_stat) - .order('CASE WHEN invites.user_id IS NOT NULL THEN 0 ELSE 1 END', + .where('invites.email IS NOT NULL') + .includes(user: :user_stat) + .order('CASE WHEN invites.user_id IS NOT NULL THEN 0 ELSE 1 END', 'user_stats.time_read DESC', 'invites.redeemed_at DESC') - .limit(limit) - .offset(offset) - .references('user_stats') + .limit(limit) + .offset(offset) + .references('user_stats') end - def self.find_pending_invites_from(inviter, offset=0) + def self.find_pending_invites_from(inviter, offset = 0) find_all_invites_from(inviter, offset).where('invites.user_id IS NULL').order('invites.created_at DESC') end - def self.find_redeemed_invites_from(inviter, offset=0) + def self.find_redeemed_invites_from(inviter, offset = 0) find_all_invites_from(inviter, offset).where('invites.user_id IS NOT NULL').order('invites.redeemed_at DESC') end def self.find_pending_invites_count(inviter) - find_all_invites_from(inviter, 0, nil).where('invites.user_id IS NULL').count + find_all_invites_from(inviter, 0, nil).where('invites.user_id IS NULL').reorder(nil).count end def self.find_redeemed_invites_count(inviter) - find_all_invites_from(inviter, 0, nil).where('invites.user_id IS NOT NULL').count + find_all_invites_from(inviter, 0, nil).where('invites.user_id IS NOT NULL').reorder(nil).count end def self.filter_by(email_or_username) @@ -217,16 +218,6 @@ class Invite < ActiveRecord::Base invite end - def self.redeem_from_token(token, email, username=nil, name=nil, topic_id=nil) - invite = Invite.find_by(invite_key: token) - if invite - invite.update_column(:email, email) - invite.topic_invites.create!(invite_id: invite.id, topic_id: topic_id) if topic_id && Topic.find_by_id(topic_id) && !invite.topic_invites.pluck(:topic_id).include?(topic_id) - user = InviteRedeemer.new(invite, username, name).redeem - end - user - end - def resend_invite self.update_columns(created_at: Time.zone.now, updated_at: Time.zone.now) Jobs.enqueue(:invite_email, invite_id: self.id) @@ -234,7 +225,13 @@ class Invite < ActiveRecord::Base def self.resend_all_invites_from(user_id) Invite.where('invites.user_id IS NULL AND invites.email IS NOT NULL AND invited_by_id = ?', user_id).find_each do |invite| - invite.resend_invite unless invite.blank? + invite.resend_invite + end + end + + def self.rescind_all_invites_from(user) + Invite.where('invites.user_id IS NULL AND invites.email IS NOT NULL AND invited_by_id = ?', user.id).find_each do |invite| + invite.trash!(user) end end @@ -261,7 +258,7 @@ end # # id :integer not null, primary key # invite_key :string(32) not null -# email :string +# email :string(255) # invited_by_id :integer not null # user_id :integer # redeemed_at :datetime @@ -271,6 +268,7 @@ end # deleted_by_id :integer # invalidated_at :datetime # moderator :boolean default(FALSE), not null +# custom_message :text # # Indexes # diff --git a/app/models/invite_redeemer.rb b/app/models/invite_redeemer.rb index 8b60872d8b..37a37a978c 100644 --- a/app/models/invite_redeemer.rb +++ b/app/models/invite_redeemer.rb @@ -1,4 +1,4 @@ -InviteRedeemer = Struct.new(:invite, :username, :name, :password) do +InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_fields) do def redeem Invite.transaction do @@ -18,10 +18,7 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password) do end # extracted from User cause it is very specific to invites - def self.create_user_from_invite(invite, username, name, password=nil) - user_exists = User.where(admin: false).find_by_email(invite.email) - return user if user_exists - + def self.create_user_from_invite(invite, username, name, password = nil, user_custom_fields = nil) if username && UsernameValidator.new(username).valid_format? && User.username_available?(username) available_username = username else @@ -31,21 +28,33 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password) do user = User.new(email: invite.email, username: available_username, name: available_name, active: true, trust_level: SiteSetting.default_invitee_trust_level) - if password - user.password_required! - user.password = password - end - if !SiteSetting.must_approve_users? || (SiteSetting.must_approve_users? && invite.invited_by.staff?) user.approved = true user.approved_by_id = invite.invited_by_id user.approved_at = Time.zone.now end - user.moderator = true if invite.moderator? && invite.invited_by.staff? - user.save! + user_fields = UserField.all + if user_custom_fields.present? && user_fields.present? + field_params = user_custom_fields || {} + fields = user.custom_fields - user + user_fields.each do |f| + field_val = field_params[f.id.to_s] + fields["user_field_#{f.id}"] = field_val[0...UserField.max_length] unless field_val.blank? + end + user.custom_fields = fields + end + + user.moderator = true if invite.moderator? && invite.invited_by.staff? + + if password + user.password = password + user.password_required! + end + + user.save! + User.find(user.id) end private @@ -76,16 +85,15 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password) do def get_invited_user result = get_existing_user - result ||= InviteRedeemer.create_user_from_invite(invite, username, name, password) + result ||= InviteRedeemer.create_user_from_invite(invite, username, name, password, user_custom_fields) result.send_welcome_message = false result end def get_existing_user - User.find_by(email: invite.email) + User.where(admin: false).find_by_email(invite.email) end - def add_to_private_topics_if_invited invite.topics.private_messages.each do |t| t.topic_allowed_users.create(user_id: invited_user.id) @@ -93,7 +101,7 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password) do end def add_user_to_invited_topics - Invite.where('invites.email = ? and invites.id != ?', invite.email, invite.id).includes(:topics).where(topics: {archetype: Archetype::private_message}).each do |i| + Invite.where('invites.email = ? and invites.id != ?', invite.email, invite.id).includes(:topics).where(topics: { archetype: Archetype::private_message }).each do |i| i.topics.each do |t| t.topic_allowed_users.create(user_id: invited_user.id) end @@ -121,8 +129,8 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password) do def notify_invitee if inviter = invite.invited_by - inviter.notifications.create(notification_type: Notification.types[:invitee_accepted], - data: {display_username: invited_user.username}.to_json) + inviter.notifications.create(notification_type: Notification.types[:invitee_accepted], + data: { display_username: invited_user.username }.to_json) end end diff --git a/app/models/locale_site_setting.rb b/app/models/locale_site_setting.rb index 1cd9246418..b38b5f85ac 100644 --- a/app/models/locale_site_setting.rb +++ b/app/models/locale_site_setting.rb @@ -9,7 +9,7 @@ class LocaleSiteSetting < EnumSiteSetting def self.values supported_locales.map do |l| lang = language_names[l] || language_names[l[0..1]] - {name: lang ? lang['nativeName'] : l, value: l} + { name: lang ? lang['nativeName'] : l, value: l } end end @@ -25,8 +25,37 @@ class LocaleSiteSetting < EnumSiteSetting def self.supported_locales @lock.synchronize do - @supported_locales ||= Dir.glob( File.join(Rails.root, 'config', 'locales', 'client.*.yml') ).map {|x| x.split('.')[-2]}.sort + @supported_locales ||= begin + app_client_files = Dir.glob( + File.join(Rails.root, 'config', 'locales', 'client.*.yml') + ) + + unless ignore_plugins? + app_client_files += Dir.glob( + File.join(Rails.root, 'plugins', '*', 'config', 'locales', 'client.*.yml') + ) + end + + app_client_files.map { |x| x.split('.')[-2] } + .uniq + .select { |locale| valid_locale?(locale) } + .sort + end end end + def self.valid_locale?(locale) + assets = Rails.configuration.assets + + assets.precompile.grep(/locales\/#{locale}(?:\.js)?/).present? && + (Dir.glob(File.join(Rails.root, 'app', 'assets', 'javascripts', 'locales', "#{locale}.js.erb")).present? || + Dir.glob(File.join(Rails.root, 'plugins', '*', 'assets', 'locales', "#{locale}.js.erb")).present?) + end + + def self.ignore_plugins? + Rails.env.test? && ENV['LOAD_PLUGINS'] != "1" + end + + private_class_method :valid_locale? + private_class_method :ignore_plugins? end diff --git a/app/models/notification.rb b/app/models/notification.rb index 7db7a5b8bd..48e3293c5c 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -1,4 +1,5 @@ require_dependency 'enum' +require_dependency 'notification_emailer' class Notification < ActiveRecord::Base belongs_to :user @@ -8,17 +9,14 @@ class Notification < ActiveRecord::Base validates_presence_of :notification_type scope :unread, lambda { where(read: false) } - scope :recent, lambda { |n=nil| n ||= 10; order('notifications.created_at desc').limit(n) } + scope :recent, lambda { |n = nil| n ||= 10; order('notifications.created_at desc').limit(n) } scope :visible , lambda { joins('LEFT JOIN topics ON notifications.topic_id = topics.id') - .where('topics.id IS NULL OR topics.deleted_at IS NULL') } + .where('topics.id IS NULL OR topics.deleted_at IS NULL') } attr_accessor :skip_send_email after_commit :send_email, on: :create - # This is super weird because the tests fail if we don't specify `on: :destroy` - # TODO: Revert back to default in Rails 5 - after_commit :refresh_notification_count, on: :destroy - after_commit :refresh_notification_count, on: [:create, :update] + after_commit :refresh_notification_count, on: [:create, :update, :destroy] def self.ensure_consistency! Notification.exec_sql <<-SQL @@ -76,20 +74,20 @@ class Notification < ActiveRecord::Base def self.read(user, notification_ids) count = Notification.where(user_id: user.id) - .where(id: notification_ids) - .where(read: false) - .update_all(read: true) + .where(id: notification_ids) + .where(read: false) + .update_all(read: true) user.publish_notifications_state if count > 0 end def self.interesting_after(min_date) - result = where("created_at > ?", min_date) - .includes(:topic) - .visible - .unread - .limit(20) - .order("CASE WHEN notification_type = #{Notification.types[:replied]} THEN 1 + result = where("created_at > ?", min_date) + .includes(:topic) + .visible + .unread + .limit(20) + .order("CASE WHEN notification_type = #{Notification.types[:replied]} THEN 1 WHEN notification_type = #{Notification.types[:mentioned]} THEN 2 ELSE 3 END, created_at DESC").to_a @@ -107,7 +105,7 @@ class Notification < ActiveRecord::Base seen[r.notification_type] << r.topic_id end end - result.reject! {|r| to_remove.include?(r.id) } + result.reject! { |r| to_remove.include?(r.id) } end result @@ -145,9 +143,9 @@ class Notification < ActiveRecord::Base count ||= 10 notifications = user.notifications - .visible - .recent(count) - .includes(:topic) + .visible + .recent(count) + .includes(:topic) if user.user_option.like_notification_frequency == UserOption.like_notification_frequency_type[:never] notifications = notifications.where('notification_type <> ?', Notification.types[:liked]) @@ -165,7 +163,7 @@ class Notification < ActiveRecord::Base NOT read ORDER BY n.id ASC LIMIT #{count.to_i} - ").values.map do |x,_| + ").values.map do |x, _| x.to_i end @@ -178,7 +176,7 @@ class Notification < ActiveRecord::Base .limit(count) end - notifications.uniq(&:id).sort do |x,y| + notifications.uniq(&:id).sort do |x, y| if x.unread_pm? && !y.unread_pm? -1 elsif y.unread_pm? && !x.unread_pm? diff --git a/app/models/oauth2_user_info.rb b/app/models/oauth2_user_info.rb index 5b27d0cf71..7ce0cace41 100644 --- a/app/models/oauth2_user_info.rb +++ b/app/models/oauth2_user_info.rb @@ -9,10 +9,10 @@ end # # id :integer not null, primary key # user_id :integer not null -# uid :string not null -# provider :string not null -# email :string -# name :string +# uid :string(255) not null +# provider :string(255) not null +# email :string(255) +# name :string(255) # created_at :datetime not null # updated_at :datetime not null # diff --git a/app/models/optimized_image.rb b/app/models/optimized_image.rb index ad3c025fa0..addf554b0f 100644 --- a/app/models/optimized_image.rb +++ b/app/models/optimized_image.rb @@ -9,7 +9,7 @@ class OptimizedImage < ActiveRecord::Base # BUMP UP if optimized image algorithm changes VERSION = 1 - def self.create_for(upload, width, height, opts={}) + def self.create_for(upload, width, height, opts = {}) return unless width > 0 && height > 0 return if upload.try(:sha1).blank? @@ -90,7 +90,7 @@ class OptimizedImage < ActiveRecord::Base end def local? - !(url =~ /^(https?:)?\/\//) + !(url =~ /^(https?:)?\/\//) end def self.safe_path?(path) @@ -107,8 +107,11 @@ class OptimizedImage < ActiveRecord::Base end end + def self.thumbnail_or_resize + SiteSetting.strip_image_metadata ? "thumbnail" : "resize" + end - def self.resize_instructions(from, to, dimensions, opts={}) + def self.resize_instructions(from, to, dimensions, opts = {}) ensure_safe_paths!(from, to) # NOTE: ORDER is important! @@ -118,17 +121,18 @@ class OptimizedImage < ActiveRecord::Base -auto-orient -gravity center -background transparent - -thumbnail #{dimensions}^ + -#{thumbnail_or_resize} #{dimensions}^ -extent #{dimensions} -interpolate bicubic -unsharp 2x0.5+0.7+0 + -interlace none -quality 98 -profile #{File.join(Rails.root, 'vendor', 'data', 'RT_sRGB.icm')} #{to} } end - def self.resize_instructions_animated(from, to, dimensions, opts={}) + def self.resize_instructions_animated(from, to, dimensions, opts = {}) ensure_safe_paths!(from, to) %W{ @@ -141,7 +145,7 @@ class OptimizedImage < ActiveRecord::Base } end - def self.crop_instructions(from, to, dimensions, opts={}) + def self.crop_instructions(from, to, dimensions, opts = {}) ensure_safe_paths!(from, to) %W{ @@ -150,16 +154,17 @@ class OptimizedImage < ActiveRecord::Base -auto-orient -gravity north -background transparent - -thumbnail #{opts[:width]} + -#{thumbnail_or_resize} #{opts[:width]} -crop #{dimensions}+0+0 -unsharp 2x0.5+0.7+0 + -interlace none -quality 98 -profile #{File.join(Rails.root, 'vendor', 'data', 'RT_sRGB.icm')} #{to} } end - def self.crop_instructions_animated(from, to, dimensions, opts={}) + def self.crop_instructions_animated(from, to, dimensions, opts = {}) ensure_safe_paths!(from, to) %W{ @@ -172,7 +177,7 @@ class OptimizedImage < ActiveRecord::Base } end - def self.downsize_instructions(from, to, dimensions, opts={}) + def self.downsize_instructions(from, to, dimensions, opts = {}) ensure_safe_paths!(from, to) %W{ @@ -181,30 +186,31 @@ class OptimizedImage < ActiveRecord::Base -auto-orient -gravity center -background transparent + -interlace none -resize #{dimensions} -profile #{File.join(Rails.root, 'vendor', 'data', 'RT_sRGB.icm')} #{to} } end - def self.downsize_instructions_animated(from, to, dimensions, opts={}) + def self.downsize_instructions_animated(from, to, dimensions, opts = {}) resize_instructions_animated(from, to, dimensions, opts) end - def self.resize(from, to, width, height, opts={}) + def self.resize(from, to, width, height, opts = {}) optimize("resize", from, to, "#{width}x#{height}", opts) end - def self.crop(from, to, width, height, opts={}) + def self.crop(from, to, width, height, opts = {}) opts[:width] = width optimize("crop", from, to, "#{width}x#{height}", opts) end - def self.downsize(from, to, dimensions, opts={}) + def self.downsize(from, to, dimensions, opts = {}) optimize("downsize", from, to, dimensions, opts) end - def self.optimize(operation, from, to, dimensions, opts={}) + def self.optimize(operation, from, to, dimensions, opts = {}) method_name = "#{operation}_instructions" if !!opts[:allow_animation] && (from =~ /\.GIF$/i || opts[:filename] =~ /\.GIF$/i) method_name += "_animated" @@ -220,14 +226,14 @@ class OptimizedImage < ActiveRecord::Base return false end - ImageOptim.new.optimize_image!(to) + FileHelper.optimize_image!(to) true rescue Rails.logger.error("Could not optimize image: #{to}") false end - def self.migrate_to_new_scheme(limit=nil) + def self.migrate_to_new_scheme(limit = nil) problems = [] if SiteSetting.migrate_to_new_scheme @@ -265,7 +271,7 @@ class OptimizedImage < ActiveRecord::Base optimized_image.sha1 = Upload.generate_digest(path) end # optimize if image - ImageOptim.new.optimize_image!(path) + FileHelper.optimize_image!(path) # store to new location & update the filesize File.open(path) do |f| optimized_image.url = Discourse.store.store_optimized_image(f, optimized_image) @@ -304,7 +310,7 @@ end # width :integer not null # height :integer not null # upload_id :integer not null -# url :string not null +# url :string(255) not null # # Indexes # diff --git a/app/models/permalink.rb b/app/models/permalink.rb index e7427d20ba..4bfaa5a9cd 100644 --- a/app/models/permalink.rb +++ b/app/models/permalink.rb @@ -44,8 +44,8 @@ class Permalink < ActiveRecord::Base def normalize(url) return url unless @rules - @rules.each do |(regex,sub)| - url = url.sub(regex,sub) + @rules.each do |(regex, sub)| + url = url.sub(regex, sub) end url @@ -56,10 +56,10 @@ class Permalink < ActiveRecord::Base def self.normalize_url(url) if url url = url.strip - url = url[1..-1] if url[0,1] == '/' + url = url[1..-1] if url[0, 1] == '/' end - normalizations = SiteSetting.permalink_normalizations + normalizations = SiteSetting.permalink_normalizations @normalizer = Normalizer.new(normalizations) unless @normalizer && @normalizer.source == normalizations @normalizer.normalize(url) @@ -81,10 +81,10 @@ class Permalink < ActiveRecord::Base nil end - def self.filter_by(url=nil) + def self.filter_by(url = nil) permalinks = Permalink - .includes(:topic, :post, :category) - .order('permalinks.created_at desc') + .includes(:topic, :post, :category) + .order('permalinks.created_at desc') permalinks.where!('url ILIKE :url OR external_url ILIKE :url', url: "%#{url}%") if url.present? permalinks.limit!(100) diff --git a/app/models/plugin_store.rb b/app/models/plugin_store.rb index 5a7675344e..72d9859e70 100644 --- a/app/models/plugin_store.rb +++ b/app/models/plugin_store.rb @@ -1,5 +1,23 @@ # API to wrap up plugin store rows class PluginStore + attr_reader :plugin_name + + def initialize(plugin_name) + @plugin_name = plugin_name + end + + def get(key) + self.class.get(plugin_name, key) + end + + def set(key, value) + self.class.set(plugin_name, key, value) + end + + def remove(key) + self.class.remove(plugin_name, key) + end + def self.get(plugin_name, key) if row = PluginStoreRow.find_by(plugin_name: plugin_name, key: key) cast_value(row.type_name, row.value) @@ -7,7 +25,7 @@ class PluginStore end def self.set(plugin_name, key, value) - hash = {plugin_name: plugin_name, key: key} + hash = { plugin_name: plugin_name, key: key } row = PluginStoreRow.find_by(hash) || PluginStoreRow.new(hash) row.type_name = determine_type(value) @@ -34,7 +52,7 @@ class PluginStore if item.is_a? Hash ActiveSupport::HashWithIndifferentAccess.new item elsif item.is_a? Array - item.map { |subitem| map_json subitem} + item.map { |subitem| map_json subitem } else item end diff --git a/app/models/plugin_store_row.rb b/app/models/plugin_store_row.rb index d9bb1c05db..db1d9d3fda 100644 --- a/app/models/plugin_store_row.rb +++ b/app/models/plugin_store_row.rb @@ -6,9 +6,9 @@ end # Table name: plugin_store_rows # # id :integer not null, primary key -# plugin_name :string not null -# key :string not null -# type_name :string not null +# plugin_name :string(255) not null +# key :string(255) not null +# type_name :string(255) not null # value :text # # Indexes diff --git a/app/models/post.rb b/app/models/post.rb index 4f8ba9770f..8312129479 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -1,4 +1,3 @@ -require_dependency 'jobs/base' require_dependency 'pretty_text' require_dependency 'rate_limiter' require_dependency 'post_revisor' @@ -6,7 +5,6 @@ require_dependency 'enum' require_dependency 'post_analyzer' require_dependency 'validators/post_validator' require_dependency 'plugin/filter' -require_dependency 'email_cook' require 'archetype' require 'digest/sha1' @@ -14,11 +12,17 @@ require 'digest/sha1' class Post < ActiveRecord::Base include RateLimiter::OnCreateRecord include Trashable + include Searchable include HasCustomFields include LimitedEdit + cattr_accessor :permitted_create_params + self.permitted_create_params = Set.new + # increase this number to force a system wide post rebake - BAKED_VERSION = 1 + # Version 1, was the initial version + # Version 2 15-12-2017, introduces CommonMark and a huge number of onebox fixes + BAKED_VERSION = 2 rate_limit rate_limit :limit_posts_per_day @@ -37,7 +41,6 @@ class Post < ActiveRecord::Base has_many :post_uploads has_many :uploads, through: :post_uploads - has_one :post_search_data has_one :post_stat has_one :incoming_email @@ -57,7 +60,11 @@ class Post < ActiveRecord::Base # We can pass several creating options to a post via attributes attr_accessor :image_sizes, :quoted_post_numbers, :no_bump, :invalidate_oneboxes, :cooking_options, :skip_unique_check - SHORT_POST_CHARS = 1200 + LARGE_IMAGES ||= "large_images".freeze + BROKEN_IMAGES ||= "broken_images".freeze + DOWNLOADED_IMAGES ||= "downloaded_images".freeze + + SHORT_POST_CHARS ||= 1200 scope :private_posts_for_user, ->(user) { where("posts.topic_id IN (SELECT topic_id @@ -71,24 +78,34 @@ class Post < ActiveRecord::Base user_id: user.id) } - scope :by_newest, -> { order('created_at desc, id desc') } + scope :by_newest, -> { order('created_at DESC, id DESC') } scope :by_post_number, -> { order('post_number ASC') } scope :with_user, -> { includes(:user) } - scope :created_since, lambda { |time_ago| where('posts.created_at > ?', time_ago) } + scope :created_since, -> (time_ago) { where('posts.created_at > ?', time_ago) } scope :public_posts, -> { joins(:topic).where('topics.archetype <> ?', Archetype.private_message) } scope :private_posts, -> { joins(:topic).where('topics.archetype = ?', Archetype.private_message) } scope :with_topic_subtype, ->(subtype) { joins(:topic).where('topics.subtype = ?', subtype) } scope :visible, -> { joins(:topic).where('topics.visible = true').where(hidden: false) } - scope :secured, lambda { |guardian| where('posts.post_type in (?)', Topic.visible_post_types(guardian && guardian.user))} + scope :secured, -> (guardian) { where('posts.post_type IN (?)', Topic.visible_post_types(guardian&.user)) } scope :for_mailing_list, ->(user, since) { q = created_since(since) .joins(:topic) - .where(topic: Topic.for_digest(user, 100.years.ago)) # we want all topics with new content, regardless when they were created + .where(topic: Topic.for_digest(user, Time.at(0))) # we want all topics with new content, regardless when they were created q = q.where.not(post_type: Post.types[:whisper]) unless user.staff? q.order('posts.created_at ASC') } + scope :raw_match, -> (pattern, type = 'string') { + type = type&.downcase + + case type + when 'string' + where('raw ILIKE ?', "%#{pattern}%") + when 'regex' + where('raw ~ ?', "(?n)#{pattern}") + end + } delegate :username, to: :user @@ -160,7 +177,7 @@ class Post < ActiveRecord::Base end end - def trash!(trashed_by=nil) + def trash!(trashed_by = nil) self.topic_links.each(&:destroy) super(trashed_by) end @@ -188,7 +205,7 @@ class Post < ActiveRecord::Base def matches_recent_post? post_id = $redis.get(unique_post_key) - post_id != nil and post_id.to_i != id + post_id != (nil) && post_id.to_i != (id) end def raw_hash @@ -197,7 +214,7 @@ class Post < ActiveRecord::Base end def self.white_listed_image_classes - @white_listed_image_classes ||= ['avatar', 'favicon', 'thumbnail'] + @white_listed_image_classes ||= ['avatar', 'favicon', 'thumbnail', 'emoji'] end def post_analyzer @@ -219,37 +236,32 @@ class Post < ActiveRecord::Base !add_nofollow? end - def cook(*args) + def cook(raw, opts = {}) # For some posts, for example those imported via RSS, we support raw HTML. In that # case we can skip the rendering pipeline. return raw if cook_method == Post.cook_methods[:raw_html] - cooked = nil - if cook_method == Post.cook_methods[:email] - cooked = EmailCook.new(raw).cook + options = opts.dup + options[:cook_method] = cook_method + + post_user = self.user + options[:user_id] = post_user.id if post_user + + if add_nofollow? + cooked = post_analyzer.cook(raw, options) else - cloned = args.dup - cloned[1] ||= {} - - post_user = self.user - cloned[1][:user_id] = post_user.id if post_user - - cooked = if add_nofollow? - post_analyzer.cook(*args) - else - # At trust level 3, we don't apply nofollow to links - cloned[1][:omit_nofollow] = true - post_analyzer.cook(*cloned) - end + # At trust level 3, we don't apply nofollow to links + options[:omit_nofollow] = true + cooked = post_analyzer.cook(raw, options) end new_cooked = Plugin::Filter.apply(:after_post_cook, self, cooked) if post_type == Post.types[:regular] if new_cooked != cooked && new_cooked.blank? - Rails.logger.debug("Plugin is blanking out post: #{self.url}\nraw: #{self.raw}") + Rails.logger.debug("Plugin is blanking out post: #{self.url}\nraw: #{raw}") elsif new_cooked.blank? - Rails.logger.debug("Blank post detected post: #{self.url}\nraw: #{self.raw}") + Rails.logger.debug("Blank post detected post: #{self.url}\nraw: #{raw}") end end @@ -273,10 +285,10 @@ class Post < ActiveRecord::Base def whitelisted_spam_hosts hosts = SiteSetting - .white_listed_spam_host_domains - .split('|') - .map{|h| h.strip} - .reject{|h| !h.include?('.')} + .white_listed_spam_host_domains + .split('|') + .map { |h| h.strip } + .reject { |h| !h.include?('.') } hosts << GlobalSetting.hostname hosts << RailsMultisite::ConnectionManagement.current_hostname @@ -296,9 +308,9 @@ class Post < ActiveRecord::Base return hosts if hosts.length == 0 TopicLink.where(domain: hosts.keys, user_id: acting_user.id) - .group(:domain, :post_id) - .count - .each_key do |tuple| + .group(:domain, :post_id) + .count + .each_key do |tuple| domain = tuple[0] hosts[domain] = (hosts[domain] || 0) + 1 end @@ -309,12 +321,13 @@ class Post < ActiveRecord::Base # Prevent new users from posting the same hosts too many times. def has_host_spam? return false if acting_user.present? && (acting_user.staged? || acting_user.has_trust_level?(TrustLevel[1])) + return false if topic&.private_message? total_hosts_usage.values.any? { |count| count >= SiteSetting.newuser_spam_host_threshold } end def archetype - topic.archetype + topic&.archetype end def self.regular_order @@ -325,7 +338,7 @@ class Post < ActiveRecord::Base order('sort_order desc, post_number desc') end - def self.summary(topic_id=nil) + def self.summary(topic_id = nil) # PERF: if you pass in nil it is WAY slower # pg chokes getting a reasonable plan topic_id = topic_id ? topic_id.to_i : "posts.topic_id" @@ -398,11 +411,11 @@ class Post < ActiveRecord::Base end def is_flagged? - post_actions.where(post_action_type_id: PostActionType.flag_types.values, deleted_at: nil).count != 0 + post_actions.where(post_action_type_id: PostActionType.flag_types_without_custom.values, deleted_at: nil).count != 0 end def has_active_flag? - post_actions.active.where(post_action_type_id: PostActionType.flag_types.values).count != 0 + post_actions.active.where(post_action_type_id: PostActionType.flag_types_without_custom.values).count != 0 end def unhide! @@ -416,7 +429,7 @@ class Post < ActiveRecord::Base "#{Discourse.base_url}#{url}" end - def url(opts=nil) + def url(opts = nil) opts ||= {} if topic @@ -430,7 +443,7 @@ class Post < ActiveRecord::Base "#{Discourse.base_url}/email/unsubscribe/#{UnsubscribeKey.create_key_for(user, self)}" end - def self.url(slug, topic_id, post_number, opts=nil) + def self.url(slug, topic_id, post_number, opts = nil) opts ||= {} result = "/t/" @@ -440,12 +453,12 @@ class Post < ActiveRecord::Base end def self.urls(post_ids) - ids = post_ids.map{|u| u} + ids = post_ids.map { |u| u } if ids.length > 0 urls = {} Topic.joins(:posts).where('posts.id' => ids). - select(['posts.id as post_id','post_number', 'topics.slug', 'topics.title', 'topics.id']). - each do |t| + select(['posts.id as post_id', 'post_number', 'topics.slug', 'topics.title', 'topics.id']). + each do |t| urls[t.post_id.to_i] = url(t.slug, t.id, t.post_number) end urls @@ -454,24 +467,36 @@ class Post < ActiveRecord::Base end end - def revise(updated_by, changes={}, opts={}) + def revise(updated_by, changes = {}, opts = {}) PostRevisor.new(self).revise!(updated_by, changes, opts) end def self.rebake_old(limit) problems = [] Post.where('baked_version IS NULL OR baked_version < ?', BAKED_VERSION) - .limit(limit).each do |p| + .order('id desc') + .limit(limit).each do |p| begin p.rebake! rescue => e - problems << {post: p, ex: e} + problems << { post: p, ex: e } + + attempts = p.custom_fields["rebake_attempts"].to_i + + if attempts > 3 + p.update_columns(baked_version: BAKED_VERSION) + Discourse.warn_exception(e, message: "Can not rebake post# #{p.id} after 3 attempts, giving up") + else + p.custom_fields["rebake_attempts"] = attempts + 1 + p.save_custom_fields + end + end end problems end - def rebake!(opts=nil) + def rebake!(opts = nil) opts ||= {} new_cooked = cook(raw, topic_id: topic_id, invalidate_oneboxes: opts.fetch(:invalidate_oneboxes, false)) @@ -491,19 +516,25 @@ class Post < ActiveRecord::Base new_cooked != old_cooked end - def set_owner(new_user, actor, skip_revision=false) + def set_owner(new_user, actor, skip_revision = false) return if user_id == new_user.id - edit_reason = I18n.t('change_owner.post_revision_text', - old_user: (self.user.username_lower rescue nil) || I18n.t('change_owner.deleted_user'), - new_user: new_user.username_lower + edit_reason = I18n.with_locale(SiteSetting.default_locale) do + I18n.t('change_owner.post_revision_text', + old_user: (self.user.username_lower rescue nil) || I18n.t('change_owner.deleted_user'), + new_user: new_user.username_lower + ) + end + + revise( + actor, + { raw: self.raw, user_id: new_user.id, edit_reason: edit_reason }, + bypass_bump: true, skip_revision: skip_revision ) - revise(actor, {raw: self.raw, user_id: new_user.id, edit_reason: edit_reason}, {bypass_bump: true, skip_revision: skip_revision}) if post_number == topic.highest_post_number topic.update_columns(last_post_user_id: new_user.id) end - end before_create do @@ -522,7 +553,7 @@ class Post < ActiveRecord::Base # This calculates the geometric mean of the post timings and stores it along with # each post. - def self.calculate_avg_time(min_topic_age=nil) + def self.calculate_avg_time(min_topic_age = nil) retry_lock_error do builder = SqlBuilder.new("UPDATE posts SET avg_time = (x.gmean / 1000) @@ -553,7 +584,7 @@ class Post < ActiveRecord::Base before_save do self.last_editor_id ||= user_id - if !new_record? && raw_changed? + if !new_record? && will_save_change_to_raw? self.cooked = cook(raw, topic_id: topic_id) end @@ -583,7 +614,6 @@ class Post < ActiveRecord::Base self.quote_count = temp_collector.size end - def save_reply_relationships add_to_quoted_post_numbers(reply_to_post_number) return if self.quoted_post_numbers.blank? @@ -608,8 +638,9 @@ class Post < ActiveRecord::Base DiscourseEvent.trigger(:after_trigger_post_process, self) end - def self.public_posts_count_per_day(start_date, end_date, category_id=nil) + def self.public_posts_count_per_day(start_date, end_date, category_id = nil) result = public_posts.where('posts.created_at >= ? AND posts.created_at <= ?', start_date, end_date) + .where(post_type: Post.types[:regular]) result = result.where('topics.category_id = ?', category_id) if category_id result.group('date(posts.created_at)').order('date(posts.created_at)').count end @@ -618,7 +649,7 @@ class Post < ActiveRecord::Base private_posts.with_topic_subtype(topic_subtype).where('posts.created_at >= ? AND posts.created_at <= ?', start_date, end_date).group('date(posts.created_at)').order('date(posts.created_at)').count end - def reply_history(max_replies=100, guardian=nil) + def reply_history(max_replies = 100, guardian = nil) post_ids = Post.exec_sql("WITH RECURSIVE breadcrumb(id, reply_to_post_number) AS ( SELECT p.id, p.reply_to_post_number FROM posts AS p WHERE p.id = :post_id @@ -628,15 +659,44 @@ class Post < ActiveRecord::Base AND p.topic_id = :topic_id ) SELECT id from breadcrumb ORDER by id", post_id: id, topic_id: topic_id).to_a - post_ids.map! {|r| r['id'].to_i } - .reject! {|post_id| post_id == id} + post_ids.map! { |r| r['id'].to_i } + .reject! { |post_id| post_id == id } # [1,2,3][-10,-1] => nil - post_ids = (post_ids[(0-max_replies)..-1] || post_ids) + post_ids = (post_ids[(0 - max_replies)..-1] || post_ids) Post.secured(guardian).where(id: post_ids).includes(:user, :topic).order(:id).to_a end + MAX_REPLY_LEVEL ||= 1000 + + def reply_ids(guardian = nil) + replies = Post.exec_sql(" + WITH RECURSIVE breadcrumb(id, level) AS ( + SELECT :post_id, 0 + UNION + SELECT reply_id, level + 1 + FROM post_replies, breadcrumb + WHERE post_id = id + AND post_id <> reply_id + AND level < #{MAX_REPLY_LEVEL} + ), breadcrumb_with_count AS ( + SELECT id, level, COUNT(*) + FROM post_replies, breadcrumb + WHERE reply_id = id + AND reply_id <> post_id + GROUP BY id, level + ) + SELECT id, level FROM breadcrumb_with_count WHERE level > 0 AND count = 1 ORDER BY id + ", post_id: id).to_a + + replies.map! { |r| { id: r["id"].to_i, level: r["level"].to_i } } + + secured_ids = Post.secured(guardian).where(id: replies.map { |r| r[:id] }).pluck(:id).to_set + + replies.reject { |r| !secured_ids.include?(r[:id]) } + end + def revert_to(number) return if number >= version post_revision = PostRevision.find_by(post_id: id, number: (number + 1)) @@ -743,7 +803,7 @@ end # notify_user_count :integer default(0), not null # like_score :integer default(0), not null # deleted_by_id :integer -# edit_reason :string +# edit_reason :string(255) # word_count :integer # version :integer default(1), not null # cook_method :integer default(1), not null @@ -756,7 +816,7 @@ end # via_email :boolean default(FALSE), not null # raw_email :text # public_version :integer default(1), not null -# action_code :string +# action_code :string(255) # image_url :string # # Indexes diff --git a/app/models/post_action.rb b/app/models/post_action.rb index 73fcf5ed37..617d0c72b3 100644 --- a/app/models/post_action.rb +++ b/app/models/post_action.rb @@ -42,27 +42,27 @@ class PostAction < ActiveRecord::Base nil end - def self.flag_count_by_date(start_date, end_date, category_id=nil) + def self.flag_count_by_date(start_date, end_date, category_id = nil) result = where('post_actions.created_at >= ? AND post_actions.created_at <= ?', start_date, end_date) - result = result.where(post_action_type_id: PostActionType.flag_types.values) + result = result.where(post_action_type_id: PostActionType.flag_types_without_custom.values) result = result.joins(post: :topic).where("topics.category_id = ?", category_id) if category_id result.group('date(post_actions.created_at)') - .order('date(post_actions.created_at)') - .count + .order('date(post_actions.created_at)') + .count end def self.update_flagged_posts_count posts_flagged_count = PostAction.active - .flags - .joins(post: :topic) - .where('posts.deleted_at' => nil) - .where('topics.deleted_at' => nil) - .where('posts.user_id > 0') - .count('DISTINCT posts.id') + .flags + .joins(post: :topic) + .where('posts.deleted_at' => nil) + .where('topics.deleted_at' => nil) + .where('posts.user_id > 0') + .count('DISTINCT posts.id') $redis.set('posts_flagged_count', posts_flagged_count) user_ids = User.staff.pluck(:id) - MessageBus.publish('/flagged_counts', { total: posts_flagged_count }, { user_ids: user_ids }) + MessageBus.publish('/flagged_counts', { total: posts_flagged_count }, user_ids: user_ids) end def self.flagged_posts_count @@ -107,7 +107,6 @@ SQL (map[row.topic_id] ||= []) << row.post_number end - map end @@ -128,21 +127,21 @@ SQL user_actions end - def self.count_per_day_for_type(post_action_type, opts=nil) + def self.count_per_day_for_type(post_action_type, opts = nil) opts ||= {} result = unscoped.where(post_action_type_id: post_action_type) result = result.where('post_actions.created_at >= ?', opts[:start_date] || (opts[:since_days_ago] || 30).days.ago) result = result.where('post_actions.created_at <= ?', opts[:end_date]) if opts[:end_date] result = result.joins(post: :topic).where('topics.category_id = ?', opts[:category_id]) if opts[:category_id] result.group('date(post_actions.created_at)') - .order('date(post_actions.created_at)') - .count + .order('date(post_actions.created_at)') + .count end - def self.agree_flags!(post, moderator, delete_post=false) + def self.agree_flags!(post, moderator, delete_post = false) actions = PostAction.active - .where(post_id: post.id) - .where(post_action_type_id: PostActionType.flag_types.values) + .where(post_id: post.id) + .where(post_action_type_id: PostActionType.flag_types.values) trigger_spam = false actions.each do |action| @@ -165,11 +164,11 @@ SQL if moderator.id == Discourse::SYSTEM_USER_ID PostActionType.auto_action_flag_types.values else - PostActionType.flag_types.values + PostActionType.notify_flag_type_ids end actions = PostAction.where(post_id: post.id) - .where(post_action_type_id: action_type_ids) + .where(post_action_type_id: action_type_ids) actions.each do |action| action.disagreed_at = Time.zone.now @@ -180,16 +179,21 @@ SQL end # reset all cached counters - f = action_type_ids.map { |t| ["#{PostActionType.types[t]}_count", 0] } - Post.with_deleted.where(id: post.id).update_all(Hash[*f.flatten]) + cached = {} + action_type_ids.each do |atid| + column = "#{PostActionType.types[atid]}_count" + cached[column] = 0 if ActiveRecord::Base.connection.column_exists?(:posts, column) + end + + Post.with_deleted.where(id: post.id).update_all(cached) update_flagged_posts_count end - def self.defer_flags!(post, moderator, delete_post=false) + def self.defer_flags!(post, moderator, delete_post = false) actions = PostAction.active - .where(post_id: post.id) - .where(post_action_type_id: PostActionType.flag_types.values) + .where(post_id: post.id) + .where(post_action_type_id: PostActionType.notify_flag_type_ids) actions.each do |action| action.deferred_at = Time.zone.now @@ -202,13 +206,16 @@ SQL update_flagged_posts_count end - def add_moderator_post_if_needed(moderator, disposition, delete_post=false) + def add_moderator_post_if_needed(moderator, disposition, delete_post = false) return if !SiteSetting.auto_respond_to_flag_actions return if related_post.nil? || related_post.topic.nil? return if staff_already_replied?(related_post.topic) message_key = "flags_dispositions.#{disposition}" message_key << "_and_deleted" if delete_post - related_post.topic.add_moderator_post(moderator, I18n.t(message_key)) + + I18n.with_locale(SiteSetting.default_locale) do + related_post.topic.add_moderator_post(moderator, I18n.t(message_key)) + end end def staff_already_replied?(topic) @@ -237,33 +244,34 @@ SQL opts[:target_group_names] = target_moderators else opts[:subtype] = TopicSubtype.notify_user - opts[:target_usernames] = if post_action_type == :notify_user - post.user.username - elsif post_action_type != :notify_moderators - # this is a hack to allow a PM with no recipients, we should think through - # a cleaner technique, a PM with myself is valid for flagging - 'x' - end + + opts[:target_usernames] = + if post_action_type == :notify_user + post.user.username + elsif post_action_type != :notify_moderators + # this is a hack to allow a PM with no recipients, we should think through + # a cleaner technique, a PM with myself is valid for flagging + 'x' + end end - PostCreator.new(user, opts).create.try(:id) + PostCreator.new(user, opts).create!&.id end - def self.limit_action!(user,post,post_action_type_id) + def self.limit_action!(user, post, post_action_type_id) RateLimiter.new(user, "post_action-#{post.id}_#{post_action_type_id}", 4, 1.minute).performed! end def self.act(user, post, post_action_type_id, opts = {}) - - limit_action!(user,post,post_action_type_id) + limit_action!(user, post, post_action_type_id) related_post_id = create_message_for_post_action(user, post, post_action_type_id, opts) staff_took_action = opts[:take_action] || false - targets_topic = if opts[:flag_topic] && post.topic - post.topic.reload - post.topic.posts_count != 1 - end + targets_topic = + if opts[:flag_topic] && post.topic + post.topic.reload.posts_count != 1 + end where_attrs = { post_id: post.id, @@ -279,9 +287,9 @@ SQL # First try to revive a trashed record post_action = PostAction.where(where_attrs) - .with_deleted - .where("deleted_at IS NOT NULL") - .first + .with_deleted + .where("deleted_at IS NOT NULL") + .first if post_action post_action.recover! @@ -324,7 +332,7 @@ SQL def self.remove_act(user, post, post_action_type_id) - limit_action!(user,post,post_action_type_id) + limit_action!(user, post, post_action_type_id) finder = PostAction.where(post_id: post.id, user_id: user.id, post_action_type_id: post_action_type_id) finder = finder.with_deleted.includes(:post) if user.try(:staff?) @@ -352,7 +360,7 @@ SQL end def is_flag? - PostActionType.flag_types.values.include?(post_action_type_id) + !!PostActionType.flag_types[post_action_type_id] end def is_private_message? @@ -374,24 +382,24 @@ SQL multiplier = SiteSetting.send("tl#{user.trust_level}_additional_likes_per_day_multiplier").to_f multiplier = 1.0 if multiplier < 1.0 - limit = (limit * multiplier ).to_i + limit = (limit * multiplier).to_i end - @rate_limiter = RateLimiter.new(user, "create_#{type}",limit, 1.day.to_i) + @rate_limiter = RateLimiter.new(user, "create_#{type}", limit, 1.day.to_i) return @rate_limiter end end end before_create do - post_action_type_ids = is_flag? ? PostActionType.flag_types.values : post_action_type_id + post_action_type_ids = is_flag? ? PostActionType.flag_types_without_custom.values : post_action_type_id raise AlreadyActed if PostAction.where(user_id: user_id) - .where(post_id: post_id) - .where(post_action_type_id: post_action_type_ids) - .where(deleted_at: nil) - .where(disagreed_at: nil) - .where(targets_topic: targets_topic) - .exists? + .where(post_id: post_id) + .where(post_action_type_id: post_action_type_ids) + .where(deleted_at: nil) + .where(disagreed_at: nil) + .where(targets_topic: targets_topic) + .exists? end # Returns the flag counts for a post, taking into account that some users @@ -427,8 +435,8 @@ SQL # Update denormalized counts column = "#{post_action_type_key}_count" count = PostAction.where(post_id: post_id) - .where(post_action_type_id: post_action_type_id) - .count + .where(post_action_type_id: post_action_type_id) + .count # We probably want to refactor this method to something cleaner. case post_action_type_key @@ -438,18 +446,19 @@ SQL when :like # 'like_score' is weighted higher for staff accounts score = PostAction.joins(:user) - .where(post_id: post_id) - .sum("CASE WHEN users.moderator OR users.admin THEN #{SiteSetting.staff_like_weight} ELSE 1 END") + .where(post_id: post_id) + .sum("CASE WHEN users.moderator OR users.admin THEN #{SiteSetting.staff_like_weight} ELSE 1 END") Post.where(id: post_id).update_all ["like_count = :count, like_score = :score", count: count, score: score] else - Post.where(id: post_id).update_all ["#{column} = ?", count] + if ActiveRecord::Base.connection.column_exists?(:posts, column) + Post.where(id: post_id).update_all ["#{column} = ?", count] + end end - topic_id = Post.with_deleted.where(id: post_id).pluck(:topic_id).first # topic_user - if [:like,:bookmark].include? post_action_type_key + if [:like, :bookmark].include? post_action_type_key TopicUser.update_post_action_cache(user_id: user_id, topic_id: topic_id, post_action_type: post_action_type_key) @@ -501,12 +510,12 @@ SQL return if topic.nil? || topic.closed? flags = PostAction.active - .flags - .joins(:post) - .where("posts.topic_id = ?", topic.id) - .where("post_actions.user_id > 0") - .group("post_actions.user_id") - .pluck("post_actions.user_id, COUNT(post_id)") + .flags + .joins(:post) + .where("posts.topic_id = ?", topic.id) + .where("post_actions.user_id > 0") + .group("post_actions.user_id") + .pluck("post_actions.user_id, COUNT(post_id)") # we need a minimum number of unique flaggers return if flags.count < SiteSetting.num_flaggers_to_close_topic @@ -535,7 +544,7 @@ SQL acting_user.has_trust_level?(TrustLevel[3]) && post.user.trust_level == TrustLevel[0] - hide_post!(post, post_action_type, Post.hidden_reasons[:flagged_by_tl3_user]) + hide_post!(post, post_action_type, Post.hidden_reasons[:flagged_by_tl3_user]) elsif PostActionType.auto_action_flag_types.include?(post_action_type) && SiteSetting.flags_required_to_hide_post > 0 @@ -548,7 +557,7 @@ SQL end end - def self.hide_post!(post, post_action_type, reason=nil) + def self.hide_post!(post, post_action_type, reason = nil) return if post.hidden unless reason @@ -569,7 +578,8 @@ SQL edit_delay: SiteSetting.cooldown_minutes_after_hiding_posts, flag_reason: I18n.t("flag_reasons.#{post_action_type}"), } - SystemMessage.create(post.user, :post_hidden, options) + + Jobs.enqueue_in(5.seconds, :send_system_message, user_id: post.user.id, message_type: :post_hidden, message_options: options) end end @@ -580,7 +590,7 @@ SQL end def self.post_action_type_for_post(post_id) - post_action = PostAction.find_by(deferred_at: nil, post_id: post_id, post_action_type_id: PostActionType.flag_types.values, deleted_at: nil) + post_action = PostAction.find_by(deferred_at: nil, post_id: post_id, post_action_type_id: PostActionType.notify_flag_types.values, deleted_at: nil) PostActionType.types[post_action.post_action_type_id] end diff --git a/app/models/post_action_type.rb b/app/models/post_action_type.rb index 6af07c53af..9f757aa013 100644 --- a/app/models/post_action_type.rb +++ b/app/models/post_action_type.rb @@ -1,5 +1,6 @@ require_dependency 'enum' require_dependency 'distributed_cache' +require_dependency 'flag_settings' class PostActionType < ActiveRecord::Base after_save :expire_cache @@ -14,23 +15,72 @@ class PostActionType < ActiveRecord::Base class << self + def flag_settings + unless @flag_settings + @flag_settings = FlagSettings.new + @flag_settings.add( + 3, + :off_topic, + notify_type: true, + auto_action_type: true + ) + @flag_settings.add( + 4, + :inappropriate, + topic_type: true, + notify_type: true, + auto_action_type: true + ) + @flag_settings.add( + 8, + :spam, + topic_type: true, + notify_type: true, + auto_action_type: true + ) + @flag_settings.add( + 6, + :notify_user, + topic_type: false, + notify_type: false, + custom_type: true + ) + @flag_settings.add( + 7, + :notify_moderators, + topic_type: true, + notify_type: true, + custom_type: true + ) + end + + @flag_settings + end + + def replace_flag_settings(settings) + @flag_settings = settings + @types = nil + end + def ordered order('position asc') end def types - @types ||= Enum.new(bookmark: 1, - like: 2, - off_topic: 3, - inappropriate: 4, - vote: 5, - notify_user: 6, - notify_moderators: 7, - spam: 8) + unless @types + @types = Enum.new( + bookmark: 1, + like: 2, + vote: 5 + ) + @types.merge!(flag_settings.flag_types) + end + + @types end def auto_action_flag_types - @auto_action_flag_types ||= flag_types.except(:notify_user, :notify_moderators) + flag_settings.auto_action_types end def public_types @@ -41,17 +91,29 @@ class PostActionType < ActiveRecord::Base @public_type_ids ||= public_types.values end + def flag_types_without_custom + flag_settings.without_custom_types + end + def flag_types - @flag_types ||= types.only(:off_topic, :spam, :inappropriate, :notify_moderators) + flag_settings.flag_types end # flags resulting in mod notifications def notify_flag_type_ids - @notify_flag_type_ids ||= types.only(:off_topic, :spam, :inappropriate, :notify_moderators).values + notify_flag_types.values + end + + def notify_flag_types + flag_settings.notify_types end def topic_flag_types - @topic_flag_types ||= types.only(:spam, :inappropriate, :notify_moderators) + flag_settings.topic_flag_types + end + + def custom_types + flag_settings.custom_types end def is_flag?(sym) diff --git a/app/models/post_analyzer.rb b/app/models/post_analyzer.rb index 53296c8e59..0d6c2120ae 100644 --- a/app/models/post_analyzer.rb +++ b/app/models/post_analyzer.rb @@ -1,9 +1,10 @@ require_dependency 'oneboxer' +require_dependency 'email_cook' class PostAnalyzer def initialize(raw, topic_id) - @raw = raw + @raw = raw @topic_id = topic_id @found_oneboxes = false end @@ -13,12 +14,19 @@ class PostAnalyzer end # What we use to cook posts - def cook(*args) - cooked = PrettyText.cook(*args) + def cook(raw, opts = {}) + cook_method = opts[:cook_method] + return raw if cook_method == Post.cook_methods[:raw_html] + + if cook_method == Post.cook_methods[:email] + cooked = EmailCook.new(raw).cook(opts) + else + cooked = PrettyText.cook(raw, opts) + end result = Oneboxer.apply(cooked, topic_id: @topic_id) do |url, _| @found_oneboxes = true - Oneboxer.invalidate(url) if args.last[:invalidate_oneboxes] + Oneboxer.invalidate(url) if opts[:invalidate_oneboxes] Oneboxer.cached_onebox(url) end @@ -51,16 +59,16 @@ class PostAnalyzer return @raw_mentions if @raw_mentions.present? raw_mentions = cooked_stripped.css('.mention, .mention-group').map do |e| - if name = e.inner_text - name = name[1..-1] - name.downcase! if name - name - end + if name = e.inner_text + name = name[1..-1] + name.downcase! if name + name + end end raw_mentions.compact! raw_mentions.uniq! - @raw_mention = raw_mentions + @raw_mentions = raw_mentions end # from rack ... compat with ruby 2.2 diff --git a/app/models/post_custom_field.rb b/app/models/post_custom_field.rb index 713d0affbc..999500de52 100644 --- a/app/models/post_custom_field.rb +++ b/app/models/post_custom_field.rb @@ -15,6 +15,6 @@ end # # Indexes # -# index_post_custom_fields_on_name_and_value (name) +# index_post_custom_fields_on_name_and_value (name, "left"(value, 200)) # index_post_custom_fields_on_post_id_and_name (post_id,name) # diff --git a/app/models/post_detail.rb b/app/models/post_detail.rb index c43219749c..8f88851673 100644 --- a/app/models/post_detail.rb +++ b/app/models/post_detail.rb @@ -11,8 +11,8 @@ end # # id :integer not null, primary key # post_id :integer -# key :string -# value :string +# key :string(255) +# value :string(255) # extra :text # created_at :datetime not null # updated_at :datetime not null diff --git a/app/models/post_mover.rb b/app/models/post_mover.rb index 9dff1988fb..42e8ad42b5 100644 --- a/app/models/post_mover.rb +++ b/app/models/post_mover.rb @@ -19,7 +19,7 @@ class PostMover end end - def to_new_topic(title, category_id=nil) + def to_new_topic(title, category_id = nil) @move_type = PostMover.move_types[:new_topic] post = Post.find_by(id: post_ids.first) @@ -79,7 +79,9 @@ 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 - PostReply.delete_all(reply_id: post_reply.reply.id, post_id: post_reply.post.id) + PostReply + .where(reply_id: post_reply.reply.id, post_id: post_reply.post.id) + .delete_all end end end @@ -90,35 +92,66 @@ class PostMover raw: post.raw, topic_id: destination_topic.id, acting_user: user, - skip_validations: true + cook_method: post.cook_method, + via_email: post.via_email, + raw_email: post.raw_email, + skip_validations: true, + guardian: Guardian.new(user) ) + move_incoming_emails(post, new_post) + move_email_logs(post, new_post) + PostAction.copy(post, new_post) new_post.update_column(:reply_count, @reply_count[1] || 0) + new_post.custom_fields = post.custom_fields + new_post.save_custom_fields + + DiscourseEvent.trigger(:post_moved, new_post, original_topic.id) + + new_post end def move(post) @first_post_number_moved ||= post.post_number - Post.where(id: post.id, topic_id: original_topic.id).update_all( - [ - ['post_number = :post_number', - 'reply_to_post_number = :reply_to_post_number', - 'topic_id = :topic_id', - 'sort_order = :post_number', - 'reply_count = :reply_count', - ].join(', '), - reply_count: @reply_count[post.post_number] || 0, - post_number: @move_map[post.post_number], - reply_to_post_number: @move_map[post.reply_to_post_number], - topic_id: destination_topic.id - ] - ) + update = { + reply_count: @reply_count[post.post_number] || 0, + post_number: @move_map[post.post_number], + reply_to_post_number: @move_map[post.reply_to_post_number], + topic_id: destination_topic.id, + sort_order: @move_map[post.post_number] + } + + unless @move_map[post.reply_to_post_number] + update[:reply_to_user_id] = nil + end + + post.update(update) + + move_incoming_emails(post, post) + move_email_logs(post, post) + + DiscourseEvent.trigger(:post_moved, post, original_topic.id) # Move any links from the post to the new topic post.topic_links.update_all(topic_id: destination_topic.id) end + def move_incoming_emails(old_post, new_post) + return if old_post.incoming_email.nil? + + email = old_post.incoming_email + email.update_columns(topic_id: new_post.topic_id, post_id: new_post.id) + new_post.incoming_email = email + end + + def move_email_logs(old_post, new_post) + EmailLog + .where(post_id: old_post.id) + .update_all(topic_id: new_post.topic_id, post_id: new_post.id) + end + def update_statistics destination_topic.update_statistics original_topic.update_statistics @@ -146,11 +179,14 @@ class PostMover def create_moderator_post_in_original_topic move_type_str = PostMover.move_types[@move_type].to_s - original_topic.add_moderator_post( - user, + message = I18n.with_locale(SiteSetting.default_locale) do I18n.t("move_posts.#{move_type_str}_moderator_post", - count: post_ids.count, - topic_link: "[#{destination_topic.title}](#{destination_topic.relative_url})"), + count: posts.length, + topic_link: "[#{destination_topic.title}](#{destination_topic.relative_url})") + end + + original_topic.add_moderator_post( + user, message, post_type: Post.types[:small_action], action_code: "split_topic", post_number: @first_post_number_moved @@ -159,7 +195,10 @@ class PostMover def posts @posts ||= begin - Post.where(topic: @original_topic, id: post_ids).order(:created_at).tap do |posts| + Post.where(topic: @original_topic, id: post_ids) + .where.not(post_type: Post.types[:small_action]) + .order(:created_at).tap do |posts| + raise Discourse::InvalidParameters.new(:post_ids) if posts.empty? end end diff --git a/app/models/post_search_data.rb b/app/models/post_search_data.rb index f9ee72b885..e890e87547 100644 --- a/app/models/post_search_data.rb +++ b/app/models/post_search_data.rb @@ -1,7 +1,5 @@ class PostSearchData < ActiveRecord::Base - belongs_to :post - - validates_presence_of :search_data + include HasSearchData end # == Schema Information @@ -11,7 +9,8 @@ end # post_id :integer not null, primary key # search_data :tsvector # raw_data :text -# locale :string +# locale :string(255) +# version :integer default(0) # # Indexes # diff --git a/app/models/post_timing.rb b/app/models/post_timing.rb index 8f68209020..b32c80ccfc 100644 --- a/app/models/post_timing.rb +++ b/app/models/post_timing.rb @@ -1,3 +1,5 @@ +require_dependency 'archetype' + class PostTiming < ActiveRecord::Base belongs_to :topic belongs_to :user @@ -5,7 +7,6 @@ class PostTiming < ActiveRecord::Base validates_presence_of :post_number validates_presence_of :msecs - def self.pretend_read(topic_id, actual_read_post_number, pretend_read_post_number) # This is done in SQL cause the logic is quite tricky and we want to do this in one db hit # @@ -39,9 +40,9 @@ class PostTiming < ActiveRecord::Base AND post_number = :post_number)", args) rescue PG::UniqueViolation - # concurrency is hard, we are not running serialized so this can possibly - # still happen, if it happens we just don't care, its an invalid record anyway - return + # concurrency is hard, we are not running serialized so this can possibly + # still happen, if it happens we just don't care, its an invalid record anyway + return end Post.where(['topic_id = :topic_id and post_number = :post_number', args]).update_all 'reads = reads + 1' @@ -60,23 +61,28 @@ class PostTiming < ActiveRecord::Base record_new_timing(args) if rows == 0 end - def self.destroy_for(user_id, topic_ids) PostTiming.transaction do - PostTiming.delete_all(['user_id = ? and topic_id in (?)', user_id, topic_ids]) - TopicUser.delete_all(['user_id = ? and topic_id in (?)', user_id, topic_ids]) + PostTiming + .where('user_id = ? and topic_id in (?)', user_id, topic_ids) + .delete_all + + TopicUser + .where('user_id = ? and topic_id in (?)', user_id, topic_ids) + .delete_all end end - MAX_READ_TIME_PER_BATCH = 60*1000.0 + MAX_READ_TIME_PER_BATCH = 60 * 1000.0 - def self.process_timings(current_user, topic_id, topic_time, timings, opts={}) + def self.process_timings(current_user, topic_id, topic_time, timings, opts = {}) current_user.user_stat.update_time_read! max_time_per_post = ((Time.now - current_user.created_at) * 1000.0) max_time_per_post = MAX_READ_TIME_PER_BATCH if max_time_per_post > MAX_READ_TIME_PER_BATCH highest_seen = 1 + new_posts_read = 0 join_table = [] @@ -89,9 +95,8 @@ class PostTiming < ActiveRecord::Base timings.each_with_index do |(post_number, time), index| - join_table << "SELECT #{topic_id.to_i} topic_id, #{post_number.to_i} post_number, - #{current_user.id.to_i} user_id, #{time.to_i} msecs, #{index} idx" - + join_table << "SELECT #{topic_id.to_i} topic_id, #{post_number.to_i} post_number, + #{current_user.id.to_i} user_id, #{time.to_i} msecs, #{index} idx" highest_seen = post_number.to_i > highest_seen ? post_number.to_i : highest_seen @@ -112,25 +117,26 @@ SQL result = exec_sql(sql) result.type_map = SqlBuilder.pg_type_map existing = Set.new(result.column_values(0)) + new_posts_read = timings.size - existing.size if Topic.where(id: topic_id, archetype: Archetype.default).exists? - timings.each_with_index do |(post_number, time),index| + timings.each_with_index do |(post_number, time), index| unless existing.include?(index) PostTiming.record_new_timing(topic_id: topic_id, - post_number: post_number, - user_id: current_user.id, - msecs: time) + post_number: post_number, + user_id: current_user.id, + msecs: time) end end end total_changed = 0 if timings.length > 0 - total_changed = Notification.mark_posts_read(current_user, topic_id, timings.map{|t| t[0]}) + total_changed = Notification.mark_posts_read(current_user, topic_id, timings.map { |t| t[0] }) end topic_time = max_time_per_post if topic_time > max_time_per_post - TopicUser.update_last_read(current_user, topic_id, highest_seen, topic_time, opts) + TopicUser.update_last_read(current_user, topic_id, highest_seen, new_posts_read, topic_time, opts) if total_changed > 0 current_user.reload diff --git a/app/models/queued_post.rb b/app/models/queued_post.rb index e1736c8548..50f2997541 100644 --- a/app/models/queued_post.rb +++ b/app/models/queued_post.rb @@ -51,7 +51,7 @@ class QueuedPost < ActiveRecord::Base end def create_options - opts = {raw: raw} + opts = { raw: raw } opts.merge!(post_options.symbolize_keys) opts[:cooking_options].symbolize_keys! if opts[:cooking_options] @@ -62,11 +62,16 @@ class QueuedPost < ActiveRecord::Base def approve!(approved_by) created_post = nil - creator = PostCreator.new(user, create_options.merge(skip_validations: true, skip_jobs: true)) + creator = PostCreator.new(user, create_options.merge( + skip_validations: true, + skip_jobs: true, + skip_events: true + )) + QueuedPost.transaction do change_to!(:approved, approved_by) - UserBlocker.unblock(user, approved_by) if user.blocked? + UserSilencer.unsilence(user, approved_by) if user.silenced? created_post = creator.create @@ -77,6 +82,7 @@ class QueuedPost < ActiveRecord::Base # Do sidekiq work outside of the transaction creator.enqueue_jobs + creator.trigger_after_events DiscourseEvent.trigger(:approved_post, self, created_post) created_post @@ -102,7 +108,7 @@ class QueuedPost < ActiveRecord::Base end # Update the record in memory too, and clear the dirty flag - updates.each {|k, v| send("#{k}=", v) } + updates.each { |k, v| send("#{k}=", v) } changes_applied QueuedPost.broadcast_new! if visible? @@ -115,7 +121,7 @@ end # Table name: queued_posts # # id :integer not null, primary key -# queue :string not null +# queue :string(255) not null # state :integer not null # user_id :integer not null # raw :text not null diff --git a/app/models/quoted_post.rb b/app/models/quoted_post.rb index 2bd06fce81..ecfe19e73d 100644 --- a/app/models/quoted_post.rb +++ b/app/models/quoted_post.rb @@ -17,8 +17,8 @@ class QuotedPost < ActiveRecord::Base post_number = a['data-post'].to_i next if topic_id == 0 || post_number == 0 - next if uniq[[topic_id,post_number]] - uniq[[topic_id,post_number]] = true + next if uniq[[topic_id, post_number]] + uniq[[topic_id, post_number]] = true begin # It would be so much nicer if we used post_id in quotes diff --git a/app/models/remote_theme.rb b/app/models/remote_theme.rb index b426030dbe..3030b4aa22 100644 --- a/app/models/remote_theme.rb +++ b/app/models/remote_theme.rb @@ -7,7 +7,7 @@ class RemoteTheme < ActiveRecord::Base has_one :theme - def self.import_theme(url, user=Discourse.system_user) + def self.import_theme(url, user = Discourse.system_user) importer = GitImporter.new(url) importer.import! @@ -37,7 +37,7 @@ class RemoteTheme < ActiveRecord::Base self.remote_version, self.commits_behind = importer.commits_since(remote_version) end - def update_from_remote(importer=nil) + def update_from_remote(importer = nil) return unless remote_url cleanup = false @@ -70,9 +70,9 @@ class RemoteTheme < ActiveRecord::Base end theme.set_field(target: info["target"] || :common, - name: name, - value: info["value"], - type: info["type"] || :theme_var) + name: name, + value: info["value"], + type: info["type"] || :theme_var) end Theme.targets.keys.each do |target| diff --git a/app/models/report.rb b/app/models/report.rb index 76c162ffb1..83f046cc36 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -14,7 +14,7 @@ class Report @end_date ||= Time.zone.now.end_of_day end - def as_json(options=nil) + def as_json(options = nil) { type: type, title: I18n.t("reports.#{type}.title"), @@ -34,7 +34,7 @@ class Report singleton_class.instance_eval { define_method("report_#{name}", &block) } end - def self.find(type, opts=nil) + def self.find(type, opts = nil) opts ||= {} # Load the report @@ -56,11 +56,11 @@ class Report report end - def self.req_report(report, filter=nil) + def self.req_report(report, filter = nil) data = if filter == :page_view_total ApplicationRequest.where(req_type: [ - ApplicationRequest.req_types.reject{|k,v| k =~ /mobile/}.map{|k,v| v if k =~ /page_view/}.compact + ApplicationRequest.req_types.reject { |k, v| k =~ /mobile/ }.map { |k, v| v if k =~ /page_view/ }.compact ].flatten) else ApplicationRequest.where(req_type: ApplicationRequest.req_types[filter]) @@ -68,18 +68,18 @@ class Report report.data = [] data.where('date >= ? AND date <= ?', report.start_date.to_date, report.end_date.to_date) - .order(date: :asc) - .group(:date) - .sum(:count) - .each do |date, count| + .order(date: :asc) + .group(:date) + .sum(:count) + .each do |date, count| report.data << { x: date, y: count } end report.total = data.sum(:count) report.prev30Days = data.where('date >= ? AND date <= ?', (report.start_date - 31.days).to_date, - (report.end_date - 31.days).to_date ) - .sum(:count) + (report.end_date - 31.days).to_date) + .sum(:count) end def self.report_visits(report) @@ -128,7 +128,7 @@ class Report def self.report_time_to_first_response(report) report.data = [] - Topic.time_to_first_response_per_day(report.start_date, report.end_date, {category_id: report.category_id}).each do |r| + Topic.time_to_first_response_per_day(report.start_date, report.end_date, category_id: report.category_id).each do |r| report.data << { x: Date.parse(r["date"]), y: r["hours"].to_f.round(2) } end report.total = Topic.time_to_first_response_total(category_id: report.category_id) @@ -175,7 +175,7 @@ class Report # Post action counts: def self.report_flags(report) basic_report_about report, PostAction, :flag_count_by_date, report.start_date, report.end_date, report.category_id - countable = PostAction.where(post_action_type_id: PostActionType.flag_types.values) + countable = PostAction.where(post_action_type_id: PostActionType.flag_types_without_custom.values) countable = countable.joins(post: :topic).where("topics.category_id = ?", report.category_id) if report.category_id add_counts report, countable, 'post_actions.created_at' end diff --git a/app/models/rtl.rb b/app/models/rtl.rb deleted file mode 100644 index 79f48603e0..0000000000 --- a/app/models/rtl.rb +++ /dev/null @@ -1,29 +0,0 @@ -class RTL - - attr_reader :user - - def initialize(user) - @user = user - end - - def enabled? - site_locale_rtl? || current_user_rtl? - end - - def current_user_rtl? - SiteSetting.allow_user_locale && user.try(:locale).in?(rtl_locales) - end - - def site_locale_rtl? - !SiteSetting.allow_user_locale && SiteSetting.default_locale.in?(rtl_locales) - end - - def rtl_locales - %w(he ar ur fa_IR) - end - - def css_class - enabled? ? 'rtl' : '' - end - -end diff --git a/app/models/s3_region_site_setting.rb b/app/models/s3_region_site_setting.rb index 153f92171b..8bb9513fd2 100644 --- a/app/models/s3_region_site_setting.rb +++ b/app/models/s3_region_site_setting.rb @@ -1,6 +1,7 @@ require_dependency 'enum_site_setting' class S3RegionSiteSetting < EnumSiteSetting + def self.valid_value?(val) valid_values.include? val end @@ -10,20 +11,22 @@ class S3RegionSiteSetting < EnumSiteSetting end def self.valid_values - [ 'us-east-1', - 'us-west-1', - 'us-west-2', - 'us-gov-west-1', - 'eu-west-1', - 'eu-west-2', - 'eu-central-1', - 'ap-southeast-1', - 'ap-southeast-2', - 'ap-south-1', + [ 'ap-northeast-1', 'ap-northeast-2', + 'ap-south-1', + 'ap-southeast-1', + 'ap-southeast-2', + 'cn-north-1', + 'eu-central-1', + 'eu-west-1', + 'eu-west-2', 'sa-east-1', - 'cn-north-1' + 'us-east-1', + 'us-east-2', + 'us-gov-west-1', + 'us-west-1', + 'us-west-2', ] end diff --git a/app/models/screened_email.rb b/app/models/screened_email.rb index ee8b005728..74c0c9593e 100644 --- a/app/models/screened_email.rb +++ b/app/models/screened_email.rb @@ -18,8 +18,8 @@ class ScreenedEmail < ActiveRecord::Base self.email = email.downcase end - def self.block(email, opts={}) - find_by_email(Email.downcase(email)) || create(opts.slice(:action_type, :ip_address).merge({email: email})) + def self.block(email, opts = {}) + find_by_email(Email.downcase(email)) || create(opts.slice(:action_type, :ip_address).merge(email: email)) end def self.should_block?(email) @@ -30,8 +30,8 @@ class ScreenedEmail < ActiveRecord::Base max_distance = SiteSetting.levenshtein_distance_spammer_emails screened_email = screened_emails.select { |se| distances[se.email] <= max_distance } - .sort { |se| distances[se.email] } - .first + .sort { |se| distances[se.email] } + .first screened_email.record_match! if screened_email @@ -46,13 +46,13 @@ class ScreenedEmail < ActiveRecord::Base (1..second.length).each do |i| (1..first.length).each do |j| - if first[j-1] == second[i-1] - matrix[i][j] = matrix[i-1][j-1] + if first[j - 1] == second[i - 1] + matrix[i][j] = matrix[i - 1][j - 1] else matrix[i][j] = [ - matrix[i-1][j], - matrix[i][j-1], - matrix[i-1][j-1], + matrix[i - 1][j], + matrix[i][j - 1], + matrix[i - 1][j - 1], ].min + 1 end end @@ -67,7 +67,7 @@ end # Table name: screened_emails # # id :integer not null, primary key -# email :string not null +# email :string(255) not null # action_type :integer not null # match_count :integer default(0), not null # last_match_at :datetime @@ -77,6 +77,6 @@ end # # Indexes # -# index_screened_emails_on_email (email) UNIQUE -# index_screened_emails_on_last_match_at (last_match_at) +# index_blocked_emails_on_email (email) UNIQUE +# index_blocked_emails_on_last_match_at (last_match_at) # diff --git a/app/models/screened_ip_address.rb b/app/models/screened_ip_address.rb index 248ad97703..f0e51ab045 100644 --- a/app/models/screened_ip_address.rb +++ b/app/models/screened_ip_address.rb @@ -11,7 +11,7 @@ class ScreenedIpAddress < ActiveRecord::Base validates :ip_address, ip_address_format: true, presence: true - def self.watch(ip_address, opts={}) + def self.watch(ip_address, opts = {}) match_for_ip_address(ip_address) || create(opts.slice(:action_type).merge(ip_address: ip_address)) end @@ -67,10 +67,10 @@ class ScreenedIpAddress < ActiveRecord::Base exists_for_ip_address_and_action?(ip_address, actions[:do_nothing]) end - def self.exists_for_ip_address_and_action?(ip_address, action_type, opts={}) + def self.exists_for_ip_address_and_action?(ip_address, action_type, opts = {}) b = match_for_ip_address(ip_address) - found = (!!b and b.action_type == action_type) - b.record_match! if found and opts[:record_match] != false + found = (!!b && b.action_type == (action_type)) + b.record_match! if found && opts[:record_match] != (false) found end @@ -125,7 +125,7 @@ class ScreenedIpAddress < ActiveRecord::Base ScreenedIpAddress.exec_sql(star_star_subnets_query, min_count: 10, roll_up_weight: weight).values.flatten end - def self.roll_up(current_user=Discourse.system_user) + def self.roll_up(current_user = Discourse.system_user) # 1 - retrieve all subnets that needs roll up subnets = [star_subnets, star_star_subnets].flatten @@ -158,9 +158,9 @@ class ScreenedIpAddress < ActiveRecord::Base # 5 - remove old matches ScreenedIpAddress.where(action_type: ScreenedIpAddress.actions[:block]) - .where("family(ip_address) = 4") - .where("ip_address << ?", subnet) - .delete_all + .where("family(ip_address) = 4") + .where("ip_address << ?", subnet) + .delete_all end # return the subnets diff --git a/app/models/screened_url.rb b/app/models/screened_url.rb index 3bd7cc1b89..be97e0d842 100644 --- a/app/models/screened_url.rb +++ b/app/models/screened_url.rb @@ -21,7 +21,7 @@ class ScreenedUrl < ActiveRecord::Base self.domain = self.domain.downcase.sub(/^www\./, '') if self.domain end - def self.watch(url, domain, opts={}) + def self.watch(url, domain, opts = {}) find_match(url) || create(opts.slice(:action_type, :ip_address).merge(url: url, domain: domain)) end @@ -42,8 +42,8 @@ end # Table name: screened_urls # # id :integer not null, primary key -# url :string not null -# domain :string not null +# url :string(255) not null +# domain :string(255) not null # action_type :integer not null # match_count :integer default(0), not null # last_match_at :datetime diff --git a/app/models/search_log.rb b/app/models/search_log.rb new file mode 100644 index 0000000000..d91b1ae7a1 --- /dev/null +++ b/app/models/search_log.rb @@ -0,0 +1,131 @@ +require_dependency 'enum' + +class SearchLog < ActiveRecord::Base + validates_presence_of :term, :ip_address + + def self.search_types + @search_types ||= Enum.new( + header: 1, + full_page: 2 + ) + end + + def self.search_result_types + @search_result_types ||= Enum.new( + topic: 1, + user: 2, + category: 3, + tag: 4 + ) + end + + def self.log(term:, search_type:, ip_address:, user_id: nil) + + search_type = search_types[search_type] + return [:error] unless search_type.present? && ip_address.present? + + update_sql = <<~SQL + UPDATE search_logs + SET term = :term, + created_at = :created_at + WHERE created_at > :timeframe AND + position(term IN :term) = 1 AND + ((:user_id IS NULL AND ip_address = :ip_address) OR + (user_id = :user_id)) + RETURNING id + SQL + + rows = exec_sql( + update_sql, + term: term, + created_at: Time.zone.now, + timeframe: 5.seconds.ago, + user_id: user_id, + ip_address: ip_address + ) + + if rows.cmd_tuples == 0 + result = create( + term: term, + search_type: search_type, + ip_address: ip_address, + user_id: user_id + ) + [:created, result.id] + else + [:updated, rows[0]['id'].to_i] + end + end + + def self.term_details(term, period = :weekly, search_type = :all) + details = [] + + result = SearchLog.select("COUNT(*) AS count, created_at::date AS date") + .where('term LIKE ?', term) + .where('created_at > ?', start_of(period)) + + result = result.where('search_type = ?', search_types[search_type]) if search_type == :header || search_type == :full_page + result = result.where('search_result_id IS NOT NULL') if search_type == :click_through_only + + result.group(:term) + .order("date") + .group("date") + .each do |record| + details << { x: Date.parse(record['date'].to_s), y: record['count'] } + end + + return { + type: "search_log_term", + title: I18n.t("search_logs.graph_title"), + data: details + } + end + + def self.trending(period = :all, search_type = :all) + result = SearchLog.select("term, + COUNT(*) AS searches, + SUM(CASE + WHEN search_result_id IS NOT NULL THEN 1 + ELSE 0 + END) AS click_through, + COUNT(DISTINCT ip_address) AS unique") + .where('created_at > ?', start_of(period)) + + result = result.where('search_type = ?', search_types[search_type]) unless search_type == :all + result = result.group(:term) + .order('COUNT(DISTINCT ip_address) DESC, COUNT(*) DESC') + .limit(100).to_a + end + + def self.start_of(period) + case period + when :yearly then 1.year.ago + when :monthly then 1.month.ago + when :quarterly then 3.months.ago + when :weekly then 1.week.ago + when :daily then 1.day.ago + else 1000.years.ago + end + end + + def self.clean_up + search_id = SearchLog.order(:id).offset(SiteSetting.search_query_log_max_size).limit(1).pluck(:id) + if search_id.present? + SearchLog.where('id < ?', search_id[0]).delete_all + end + end +end + +# == Schema Information +# +# Table name: search_logs +# +# id :integer not null, primary key +# term :string not null +# user_id :integer +# ip_address :inet not null +# search_result_id :integer +# search_type :integer not null +# created_at :datetime not null +# search_result_type :integer +# diff --git a/app/models/single_sign_on_record.rb b/app/models/single_sign_on_record.rb index df2394dbda..f6acfa9ddc 100644 --- a/app/models/single_sign_on_record.rb +++ b/app/models/single_sign_on_record.rb @@ -1,5 +1,7 @@ class SingleSignOnRecord < ActiveRecord::Base belongs_to :user + + validates :external_id, uniqueness: true end # == Schema Information @@ -8,13 +10,13 @@ end # # id :integer not null, primary key # user_id :integer not null -# external_id :string not null +# external_id :string(255) not null # last_payload :text not null # created_at :datetime not null # updated_at :datetime not null -# external_username :string -# external_email :string -# external_name :string +# external_username :string(255) +# external_email :string(255) +# external_name :string(255) # external_avatar_url :string(1000) # # Indexes diff --git a/app/models/site.rb b/app/models/site.rb index 37312625ba..9d90b2c1bc 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -121,7 +121,7 @@ class Site def self.clear_anon_cache! # publishing forces the sequence up # the cache is validated based on the sequence - MessageBus.publish('/site_json','') + MessageBus.publish('/site_json', '') end end diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb index ef88e14516..46951fa379 100644 --- a/app/models/site_setting.rb +++ b/app/models/site_setting.rb @@ -14,11 +14,7 @@ class SiteSetting < ActiveRecord::Base def self.load_settings(file) SiteSettings::YamlLoader.new(file).load do |category, name, default, opts| - if opts.delete(:client) - client_setting(name, default, opts.merge(category: category)) - else - setting(name, default, opts.merge(category: category)) - end + setting(name, default, opts.merge(category: category)) end end @@ -34,7 +30,7 @@ class SiteSetting < ActiveRecord::Base client_settings << :available_locales def self.available_locales - LocaleSiteSetting.values.map{ |e| e[:value] }.join('|') + LocaleSiteSetting.values.to_json end def self.topic_title_length @@ -71,8 +67,8 @@ class SiteSetting < ActiveRecord::Base def self.anonymous_homepage top_menu_items.map { |item| item.name } - .select { |item| anonymous_menu_items.include?(item) } - .first + .select { |item| anonymous_menu_items.include?(item) } + .first end def self.should_download_images?(src) @@ -117,6 +113,43 @@ class SiteSetting < ActiveRecord::Base def self.attachment_filename_blacklist_regex @attachment_filename_blacklist_regex ||= Regexp.union(SiteSetting.attachment_filename_blacklist.split("|")) end + + # helpers for getting s3 settings that fallback to global + class Upload + def self.s3_cdn_url + SiteSetting.enable_s3_uploads ? SiteSetting.s3_cdn_url : GlobalSetting.s3_cdn_url + end + + def self.s3_region + SiteSetting.enable_s3_uploads ? SiteSetting.s3_region : GlobalSetting.s3_region + end + + def self.s3_upload_bucket + SiteSetting.enable_s3_uploads ? SiteSetting.s3_upload_bucket : GlobalSetting.s3_bucket + end + + def self.enable_s3_uploads + SiteSetting.enable_s3_uploads || GlobalSetting.use_s3? + end + + def self.absolute_base_url + bucket = SiteSetting.enable_s3_uploads ? Discourse.store.s3_bucket_name : GlobalSetting.s3_bucket + + # cf. http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region + if SiteSetting.Upload.s3_region == "us-east-1" + "//#{bucket}.s3.amazonaws.com" + elsif SiteSetting.Upload.s3_region == 'cn-north-1' + "//#{bucket}.s3.cn-north-1.amazonaws.com.cn" + else + "//#{bucket}.s3-#{SiteSetting.Upload.s3_region}.amazonaws.com" + end + end + end + + def self.Upload + SiteSetting::Upload + end + end # == Schema Information @@ -124,7 +157,7 @@ end # Table name: site_settings # # id :integer not null, primary key -# name :string not null +# name :string(255) not null # data_type :integer not null # value :text # created_at :datetime not null diff --git a/app/models/slug_setting.rb b/app/models/slug_setting.rb index b2be27fcbd..86dc666d91 100644 --- a/app/models/slug_setting.rb +++ b/app/models/slug_setting.rb @@ -10,7 +10,7 @@ class SlugSetting < EnumSiteSetting def self.values VALUES.map do |l| - {name: l, value: l} + { name: l, value: l } end end diff --git a/app/models/stylesheet_cache.rb b/app/models/stylesheet_cache.rb index 2c568072cb..0d318ce0db 100644 --- a/app/models/stylesheet_cache.rb +++ b/app/models/stylesheet_cache.rb @@ -3,21 +3,26 @@ class StylesheetCache < ActiveRecord::Base MAX_TO_KEEP = 50 - def self.add(target,digest,content,source_map) + def self.add(target, digest, content, source_map) + old_logger = ActiveRecord::Base.logger return false if where(target: target, digest: digest).exists? + if Rails.env.development? + ActiveRecord::Base.logger = nil + end + success = create(target: target, digest: digest, content: content, source_map: source_map) count = StylesheetCache.count if count > MAX_TO_KEEP remove_lower = StylesheetCache - .where(target: target) - .limit(MAX_TO_KEEP) - .order('id desc') - .pluck(:id) - .last + .where(target: target) + .limit(MAX_TO_KEEP) + .order('id desc') + .pluck(:id) + .last exec_sql("DELETE FROM stylesheet_cache where id < :id", id: remove_lower) end @@ -25,6 +30,10 @@ class StylesheetCache < ActiveRecord::Base success rescue ActiveRecord::RecordNotUnique false + ensure + if Rails.env.development? && old_logger + ActiveRecord::Base.logger = old_logger + end end end @@ -34,8 +43,8 @@ end # Table name: stylesheet_cache # # id :integer not null, primary key -# target :string not null -# digest :string not null +# target :string(255) not null +# digest :string(255) not null # content :text not null # created_at :datetime # updated_at :datetime diff --git a/app/models/tag.rb b/app/models/tag.rb index 95a944776c..7d5b4f4139 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,4 +1,6 @@ class Tag < ActiveRecord::Base + include Searchable + validates :name, presence: true, uniqueness: true has_many :tag_users # notification settings @@ -12,29 +14,31 @@ class Tag < ActiveRecord::Base has_many :tag_group_memberships has_many :tag_groups, through: :tag_group_memberships - COUNT_ARG = "topic_tags.id" + after_save :index_search + + COUNT_ARG = "topics.id" # Apply more activerecord filters to the tags_by_count_query, and then # fetch the result with .count(Tag::COUNT_ARG). # # e.g., Tag.tags_by_count_query.where("topics.category_id = ?", category.id).count(Tag::COUNT_ARG) - def self.tags_by_count_query(opts={}) + def self.tags_by_count_query(opts = {}) q = Tag.joins("LEFT JOIN topic_tags ON tags.id = topic_tags.tag_id") - .joins("LEFT JOIN topics ON topics.id = topic_tags.topic_id") - .group("tags.id, tags.name") - .order('count_topic_tags_id DESC') + .joins("LEFT JOIN topics ON topics.id = topic_tags.topic_id AND topics.deleted_at IS NULL") + .group("tags.id, tags.name") + .order('count_topics_id DESC') q = q.limit(opts[:limit]) if opts[:limit] q end - def self.category_tags_by_count_query(category, opts={}) + def self.category_tags_by_count_query(category, opts = {}) tags_by_count_query(opts).where("tags.id in (select tag_id from category_tags where category_id = ?)", category.id) - .where("topics.category_id = ?", category.id) + .where("topics.category_id = ?", category.id) end def self.top_tags(limit_arg: nil, category: nil, guardian: nil) limit = limit_arg || SiteSetting.max_tags_in_filter_list - scope_category_ids = (guardian||Guardian.new).allowed_category_ids + scope_category_ids = (guardian || Guardian.new).allowed_category_ids if category scope_category_ids &= ([category.id] + category.subcategories.pluck(:id)) @@ -46,7 +50,7 @@ class Tag < ActiveRecord::Base category: category ) - tags.count(COUNT_ARG).map {|name, _| name} + tags.count(COUNT_ARG).map { |name, _| name } end def self.include_tags? @@ -56,6 +60,10 @@ class Tag < ActiveRecord::Base def full_url "#{Discourse.base_url}/tags/#{self.name}" end + + def index_search + SearchIndexer.index(self) + end end # == Schema Information diff --git a/app/models/tag_search_data.rb b/app/models/tag_search_data.rb new file mode 100644 index 0000000000..d853da634b --- /dev/null +++ b/app/models/tag_search_data.rb @@ -0,0 +1,18 @@ +class TagSearchData < ActiveRecord::Base + include HasSearchData +end + +# == Schema Information +# +# Table name: tag_search_data +# +# tag_id :integer not null, primary key +# search_data :tsvector +# raw_data :text +# locale :text +# version :integer default(0) +# +# Indexes +# +# idx_search_tag (search_data) +# diff --git a/app/models/theme.rb b/app/models/theme.rb index ab1fceb91f..5fc90241d9 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -28,15 +28,13 @@ class Theme < ActiveRecord::Base changed_fields.each(&:save!) changed_fields.clear - Theme.expire_site_cache! if user_selectable_changed? + Theme.expire_site_cache! if saved_change_to_user_selectable? || saved_change_to_name? @dependant_themes = nil @included_themes = nil - end - after_save do remove_from_cache! - notify_scheme_change if color_scheme_id_changed? + notify_scheme_change if saved_change_to_color_scheme_id? end after_destroy do @@ -46,16 +44,17 @@ class Theme < ActiveRecord::Base end if self.id - ColorScheme .where(theme_id: self.id) .where("id NOT IN (SELECT color_scheme_id FROM themes where color_scheme_id IS NOT NULL)") - .destroy_all + .destroy_all ColorScheme .where(theme_id: self.id) .update_all(theme_id: nil) end + + Theme.expire_site_cache! end after_commit ->(theme) do @@ -114,7 +113,7 @@ class Theme < ActiveRecord::Base (@cache[cache_key] = val || "").html_safe end - def self.remove_from_cache!(themes=nil) + def self.remove_from_cache!(themes = nil) clear_cache! end @@ -122,13 +121,11 @@ class Theme < ActiveRecord::Base @cache.clear end - def self.targets @targets ||= Enum.new(common: 0, desktop: 1, mobile: 2) end - - def notify_scheme_change(clear_manager_cache=true) + def notify_scheme_change(clear_manager_cache = true) Stylesheet::Manager.cache.clear if clear_manager_cache message = refresh_message_for_targets(["desktop", "mobile", "admin"], self) MessageBus.publish('/file-change', message) @@ -140,7 +137,7 @@ class Theme < ActiveRecord::Base themes = [self] + dependant_themes message = themes.map do |theme| - refresh_message_for_targets([:mobile_theme,:desktop_theme], theme) + refresh_message_for_targets([:mobile_theme, :desktop_theme], theme) end.compact.flatten MessageBus.publish('/file-change', message) end @@ -168,7 +165,7 @@ class Theme < ActiveRecord::Base def resolve_dependant_themes(direction) - select_field,where_field=nil + select_field, where_field = nil if direction == :up select_field = "parent_theme_id" @@ -212,7 +209,7 @@ class Theme < ActiveRecord::Base end def resolve_baked_field(target, name) - list_baked_fields(target,name).map{|f| f.value_baked || f.value}.join("\n") + list_baked_fields(target, name).map { |f| f.value_baked || f.value }.join("\n") end def list_baked_fields(target, name) @@ -221,12 +218,15 @@ class Theme < ActiveRecord::Base theme_ids = [self.id] + (included_themes.map(&:id) || []) fields = ThemeField.where(target_id: [Theme.targets[target], Theme.targets[:common]]) - .where(name: name.to_s) - .includes(:theme) - .joins("JOIN ( - SELECT #{theme_ids.map.with_index{|id,idx| "#{id} AS theme_id, #{idx} AS sort_column"}.join(" UNION ALL SELECT ")} - ) as X ON X.theme_id = theme_fields.theme_id") - .order('sort_column, target_id') + .where(name: name.to_s) + .includes(:theme) + .joins(" + JOIN ( + SELECT #{theme_ids.map.with_index { |id, idx| "#{id} AS theme_id, #{idx} AS sort_column" }.join(" UNION ALL SELECT ")} + ) as X ON X.theme_id = theme_fields.theme_id" + ) + .order('sort_column, target_id') + fields.each(&:ensure_baked!) fields end @@ -254,7 +254,7 @@ class Theme < ActiveRecord::Base value ||= "" - field = theme_fields.find{|f| f.name==name && f.target_id == target_id && f.type_id == type_id} + field = theme_fields.find { |f| f.name == name && f.target_id == target_id && f.type_id == type_id } if field if value.blank? && !upload_id theme_fields.delete field.destroy @@ -270,6 +270,18 @@ class Theme < ActiveRecord::Base end end + def all_theme_variables + fields = {} + ([self] + (included_themes || [])).each do |theme| + theme&.theme_fields.each do |field| + next unless ThemeField.theme_var_type_ids.include?(field.type_id) + next if fields.key?(field.name) + fields[field.name] = field + end + end + fields.values + end + def add_child_theme!(theme) child_theme_relation.create!(child_theme_id: theme.id) @included_themes = nil @@ -283,9 +295,9 @@ end # Table name: themes # # id :integer not null, primary key -# name :string not null +# name :string(255) not null # user_id :integer not null -# key :string not null +# key :string(255) not null # created_at :datetime not null # updated_at :datetime not null # compiler_version :integer default(0), not null diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb index 928a175609..386b0fb903 100644 --- a/app/models/theme_field.rb +++ b/app/models/theme_field.rb @@ -11,15 +11,18 @@ class ThemeField < ActiveRecord::Base end def self.theme_var_type_ids - @theme_var_type_ids ||= [2,3,4] + @theme_var_type_ids ||= [2, 3, 4] end + 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) } + COMPILER_VERSION = 5 belongs_to :theme def transpile(es6_source, version) - template = Tilt::ES6ModuleTranspilerTemplate.new {} + template = Tilt::ES6ModuleTranspilerTemplate.new {} wrapped = < { #{es6_source} @@ -37,7 +40,7 @@ PLUGIN_API_JS name = node["name"] || node["data-template-name"] || "broken" is_raw = name =~ /\.raw$/ if is_raw - template = "require('discourse-common/lib/raw-handlebars').template(#{Barber::Precompiler.compile(node.inner_html)})" + template = "requirejs('discourse-common/lib/raw-handlebars').template(#{Barber::Precompiler.compile(node.inner_html)})" node.replace < (function() { @@ -90,15 +93,16 @@ COMPILED @scss_fields ||= %w(scss embedded_scss) end - def ensure_baked! if ThemeField.html_fields.include?(self.name) if !self.value_baked || compiler_version != COMPILER_VERSION - self.value_baked, self.error = process_html(self.value) self.compiler_version = COMPILER_VERSION - if self.value_baked_changed? || compiler_version.changed? || self.error_changed? + if self.will_save_change_to_value_baked? || + self.will_save_change_to_compiler_version? || + self.will_save_change_to_error? + self.update_columns(value_baked: value_baked, compiler_version: compiler_version, error: error) @@ -120,10 +124,9 @@ COMPILED self.error = e.message end - if error_changed? + if will_save_change_to_error? update_columns(error: self.error) end - end end @@ -132,7 +135,7 @@ COMPILED end before_save do - if value_changed? && !value_baked_changed? + if will_save_change_to_value? && !will_save_change_to_value_baked? self.value_baked = nil end end diff --git a/app/models/top_topic.rb b/app/models/top_topic.rb index 6172f7cf0a..709148167c 100644 --- a/app/models/top_topic.rb +++ b/app/models/top_topic.rb @@ -16,7 +16,7 @@ class TopTopic < ActiveRecord::Base # We don't have to refresh these as often def self.refresh_older! - older_periods = periods - [:daily,:all] + older_periods = periods - [:daily, :all] transaction do older_periods.each do |period| @@ -32,7 +32,6 @@ class TopTopic < ActiveRecord::Base refresh_older! end - def self.periods @@periods ||= [:all, :yearly, :quarterly, :monthly, :weekly, :daily].freeze end @@ -203,11 +202,11 @@ class TopTopic < ActiveRecord::Base def self.start_of(period) case period - when :yearly then 1.year.ago - when :monthly then 1.month.ago - when :quarterly then 3.months.ago - when :weekly then 1.week.ago - when :daily then 1.day.ago + when :yearly then 1.year.ago + when :monthly then 1.month.ago + when :quarterly then 3.months.ago + when :weekly then 1.week.ago + when :daily then 1.day.ago end end diff --git a/app/models/topic.rb b/app/models/topic.rb index 8533894aa0..b6138ac975 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -7,7 +7,10 @@ require_dependency 'text_cleaner' require_dependency 'archetype' require_dependency 'html_prettify' require_dependency 'discourse_tagging' -require_dependency 'search' +require_dependency 'search_indexer' +require_dependency 'list_controller' +require_dependency 'topic_posters_summary' +require_dependency 'topic_featured_users' class Topic < ActiveRecord::Base class UserExists < StandardError; end @@ -15,6 +18,7 @@ class Topic < ActiveRecord::Base include RateLimiter::OnCreateRecord include HasCustomFields include Trashable + include Searchable include LimitedEdit extend Forwardable @@ -29,15 +33,27 @@ class Topic < ActiveRecord::Base attr_accessor :allowed_user_ids, :tags_changed + DiscourseEvent.on(:site_setting_saved) do |site_setting| + if site_setting.name.to_s == "slug_generation_method" && site_setting.saved_change_to_value? + Scheduler::Defer.later("Null topic slug") do + Topic.update_all(slug: nil) + end + end + end + def self.max_sort_order - @max_sort_order ||= (2 ** 31) - 1 + @max_sort_order ||= (2**31) - 1 + end + + def self.max_fancy_title_length + 400 end def featured_users @featured_users ||= TopicFeaturedUsers.new(self) end - def trash!(trashed_by=nil) + def trash!(trashed_by = nil) update_category_topic_count_by(-1) if deleted_at.nil? super(trashed_by) update_flagged_posts_count @@ -57,37 +73,38 @@ class Topic < ActiveRecord::Base rate_limit :limit_topics_per_day rate_limit :limit_private_messages_per_day - validates :title, :if => Proc.new { |t| t.new_record? || t.title_changed? }, - :presence => true, - :topic_title_length => true, - :censored_words => true, - :quality_title => { :unless => :private_message? }, - :unique_among => { :unless => Proc.new { |t| (SiteSetting.allow_duplicate_topic_titles? || t.private_message?) }, - :message => :has_already_been_used, - :allow_blank => true, - :case_sensitive => false, - :collection => Proc.new{ Topic.listable_topics } } + validates :title, if: Proc.new { |t| t.new_record? || t.title_changed? }, + presence: true, + topic_title_length: true, + censored_words: true, + quality_title: { unless: :private_message? }, + unique_among: { unless: Proc.new { |t| (SiteSetting.allow_duplicate_topic_titles? || t.private_message?) }, + message: :has_already_been_used, + allow_blank: true, + case_sensitive: false, + collection: Proc.new { Topic.listable_topics } } validates :category_id, - :presence => true, - :exclusion => { - :in => Proc.new{[SiteSetting.uncategorized_category_id]} + presence: true, + exclusion: { + in: Proc.new { [SiteSetting.uncategorized_category_id] } }, - :if => Proc.new { |t| - (t.new_record? || t.category_id_changed?) && - !SiteSetting.allow_uncategorized_topics && - (t.archetype.nil? || t.archetype == Archetype.default) && - (!t.user_id || !t.user.staff?) + if: Proc.new { |t| + (t.new_record? || t.category_id_changed?) && + !SiteSetting.allow_uncategorized_topics && + (t.archetype.nil? || t.archetype == Archetype.default) && + (!t.user_id || !t.user.staff?) } - validates :featured_link, allow_nil: true, format: URI::regexp(%w(http https)) + validates :featured_link, allow_nil: true, url: true validate if: :featured_link do - errors.add(:featured_link, :invalid_category) unless !featured_link_changed? || Guardian.new.can_edit_featured_link?(category_id) + errors.add(:featured_link, :invalid_category) unless !featured_link_changed? || + Guardian.new.can_edit_featured_link?(category_id) end before_validation do self.title = TextCleaner.clean_title(TextSentinel.title_sentinel(title).text) if errors[:title].empty? - self.featured_link.strip! if self.featured_link + self.featured_link = self.featured_link.strip.presence if self.featured_link end belongs_to :category @@ -100,8 +117,8 @@ class Topic < ActiveRecord::Base has_many :group_archived_messages, dependent: :destroy has_many :user_archived_messages, dependent: :destroy - has_many :allowed_group_users, through: :allowed_groups, source: :users has_many :allowed_groups, through: :topic_allowed_groups, source: :group + has_many :allowed_group_users, through: :allowed_groups, source: :users has_many :allowed_users, through: :topic_allowed_users, source: :user has_many :queued_posts @@ -124,8 +141,8 @@ class Topic < ActiveRecord::Base has_many :topic_timers, dependent: :destroy has_one :user_warning - has_one :first_post, -> {where post_number: 1}, class_name: Post - + has_one :first_post, -> { where post_number: 1 }, class_name: 'Post' + has_one :topic_search_data has_one :topic_embed, dependent: :destroy # When we want to temporarily attach some data to a forum topic (usually before serialization) @@ -152,7 +169,7 @@ class Topic < ActiveRecord::Base scope :created_since, lambda { |time_ago| where('topics.created_at > ?', time_ago) } - scope :secured, lambda { |guardian=nil| + scope :secured, lambda { |guardian = nil| ids = guardian.secure_category_ids if guardian # Query conditions @@ -195,7 +212,7 @@ class Topic < ActiveRecord::Base after_save do banner = "banner".freeze - if archetype_was == banner || archetype == banner + if archetype_before_last_save == banner || archetype == banner ApplicationController.banner_json_cache.clear end @@ -215,7 +232,8 @@ class Topic < ActiveRecord::Base end def inherit_auto_close_from_category - if !@ignore_category_auto_close && + if !self.closed && + !@ignore_category_auto_close && self.category && self.category.auto_close_hours && !public_topic_timer&.execute_at @@ -242,10 +260,10 @@ class Topic < ActiveRecord::Base end end - def self.visible_post_types(viewed_by=nil) + def self.visible_post_types(viewed_by = nil) types = Post.types result = [types[:regular], types[:moderator_action], types[:small_action]] - result << types[:whisper] if viewed_by.try(:staff?) + result << types[:whisper] if viewed_by&.staff? result end @@ -266,9 +284,9 @@ class Topic < ActiveRecord::Base end def has_flags? - FlagQuery.flagged_post_actions("active") - .where("topics.id" => id) - .exists? + FlagQuery.flagged_post_actions(filter: "active") + .where("topics.id" => id) + .exists? end def is_official_warning? @@ -298,18 +316,18 @@ class Topic < ActiveRecord::Base def self.fancy_title(title) escaped = ERB::Util.html_escape(title) return unless escaped - Emoji.unicode_unescape(HtmlPrettify.render(escaped)) + fancy_title = Emoji.unicode_unescape(HtmlPrettify.render(escaped)) + fancy_title.length > Topic.max_fancy_title_length ? title : fancy_title end def fancy_title return ERB::Util.html_escape(title) unless SiteSetting.title_fancy_entities? unless fancy_title = read_attribute(:fancy_title) - fancy_title = Topic.fancy_title(title) write_attribute(:fancy_title, fancy_title) - unless new_record? + if !new_record? && !Discourse.readonly_mode? # make sure data is set in table, this also allows us to change algorithm # by simply nulling this column exec_sql("UPDATE topics SET fancy_title = :fancy_title where id = :id", id: self.id, fancy_title: fancy_title) @@ -324,21 +342,22 @@ class Topic < ActiveRecord::Base end # Returns hot topics since a date for display in email digest. - def self.for_digest(user, since, opts=nil) + def self.for_digest(user, since, opts = nil) opts = opts || {} score = "#{ListController.best_period_for(since)}_score" topics = Topic - .visible - .secured(Guardian.new(user)) - .joins("LEFT OUTER JOIN topic_users ON topic_users.topic_id = topics.id AND topic_users.user_id = #{user.id.to_i}") - .joins("LEFT OUTER JOIN category_users ON category_users.category_id = topics.category_id AND category_users.user_id = #{user.id.to_i}") - .joins("LEFT OUTER JOIN users ON users.id = topics.user_id") - .where(closed: false, archived: false) - .where("COALESCE(topic_users.notification_level, 1) <> ?", TopicUser.notification_levels[:muted]) - .created_since(since) - .listable_topics - .includes(:category) + .visible + .secured(Guardian.new(user)) + .joins("LEFT OUTER JOIN topic_users ON topic_users.topic_id = topics.id AND topic_users.user_id = #{user.id.to_i}") + .joins("LEFT OUTER JOIN category_users ON category_users.category_id = topics.category_id AND category_users.user_id = #{user.id.to_i}") + .joins("LEFT OUTER JOIN users ON users.id = topics.user_id") + .where(closed: false, archived: false) + .where("COALESCE(topic_users.notification_level, 1) <> ?", TopicUser.notification_levels[:muted]) + .created_since(since) + .where('topics.created_at < ?', (SiteSetting.editing_grace_period || 0).seconds.ago) + .listable_topics + .includes(:category) unless opts[:include_tl0] || user.user_option.try(:include_tl0_in_digests) topics = topics.where("COALESCE(users.trust_level, 0) > 0") @@ -346,7 +365,7 @@ class Topic < ActiveRecord::Base if !!opts[:top_order] topics = topics.joins("LEFT OUTER JOIN top_topics ON top_topics.topic_id = topics.id") - .order(TopicQuerySQL.order_top_with_notification_levels(score)) + .order(TopicQuerySQL.order_top_with_notification_levels(score)) end if opts[:limit] @@ -372,15 +391,18 @@ class Topic < ActiveRecord::Base # Remove muted tags muted_tag_ids = TagUser.lookup(user, :muted).pluck(:tag_id) unless muted_tag_ids.empty? - topics = topics.joins("LEFT OUTER JOIN topic_tags ON topic_tags.topic_id = topics.id") - .where("topic_tags.tag_id NOT IN (?)", muted_tag_ids) + # If multiple tags per topic, include topics with tags that aren't muted, + # and don't forget untagged topics. + topics = topics.where( + "EXISTS ( SELECT 1 FROM topic_tags WHERE topic_tags.topic_id = topics.id AND tag_id NOT IN (?) ) + OR NOT EXISTS (SELECT 1 FROM topic_tags WHERE topic_tags.topic_id = topics.id)", muted_tag_ids) end topics end # Using the digest query, figure out what's new for a user since last seen - def self.new_since_last_seen(user, since, featured_topic_ids=nil) + def self.new_since_last_seen(user, since, featured_topic_ids = nil) topics = Topic.for_digest(user, since) featured_topic_ids ? topics.where("topics.id NOT IN (?)", featured_topic_ids) : topics end @@ -398,9 +420,10 @@ class Topic < ActiveRecord::Base save end - def reload(options=nil) + def reload(options = nil) @post_numbers = nil @public_topic_timer = nil + @private_topic_timer = nil super(options) end @@ -420,7 +443,7 @@ class Topic < ActiveRecord::Base custom_fields[key.to_s] end - def self.listable_count_per_day(start_date, end_date, category_id=nil) + def self.listable_count_per_day(start_date, end_date, category_id = nil) result = listable_topics.where('created_at >= ? and created_at <= ?', start_date, end_date) result = result.where(category_id: category_id) if category_id result.group('date(created_at)').order('date(created_at)').count @@ -430,52 +453,56 @@ class Topic < ActiveRecord::Base archetype == Archetype.private_message end - MAX_SIMILAR_BODY_LENGTH = 200 - # Search for similar topics - def self.similar_to(title, raw, user=nil) - return [] unless title.present? - return [] unless raw.present? + MAX_SIMILAR_BODY_LENGTH ||= 200 - filter_words = Search.prepare_data(title + " " + raw[0...MAX_SIMILAR_BODY_LENGTH]); + def self.similar_to(title, raw, user = nil) + return [] if title.blank? + raw = raw.presence || "" + + search_data = "#{title} #{raw[0...MAX_SIMILAR_BODY_LENGTH]}".strip + filter_words = Search.prepare_data(search_data) ts_query = Search.ts_query(filter_words, nil, "|") - # Exclude category definitions from similar topic suggestions - - candidates = Topic.visible - .secured(Guardian.new(user)) - .listable_topics - .joins('JOIN topic_search_data s ON topics.id = s.topic_id') - .where("search_data @@ #{ts_query}") - .order("ts_rank(search_data, #{ts_query}) DESC") - .limit(SiteSetting.max_similar_results * 3) - - exclude_topic_ids = Category.pluck(:topic_id).compact! - if exclude_topic_ids.present? - candidates = candidates.where("topics.id NOT IN (?)", exclude_topic_ids) - end + candidates = Topic + .visible + .listable_topics + .secured(Guardian.new(user)) + .joins("JOIN topic_search_data s ON topics.id = s.topic_id") + .joins("LEFT JOIN categories c ON topics.id = c.topic_id") + .where("search_data @@ #{ts_query}") + .where("c.topic_id IS NULL") + .order("ts_rank(search_data, #{ts_query}) DESC") + .limit(SiteSetting.max_similar_results * 3) candidate_ids = candidates.pluck(:id) - return [] unless candidate_ids.present? + return [] if candidate_ids.blank? - similar = Topic.select(sanitize_sql_array(["topics.*, similarity(topics.title, :title) + similarity(topics.title, :raw) AS similarity, p.cooked as blurb", title: title, raw: raw])) - .joins("JOIN posts AS p ON p.topic_id = topics.id AND p.post_number = 1") - .limit(SiteSetting.max_similar_results) - .where("topics.id IN (?)", candidate_ids) - .where("similarity(topics.title, :title) + similarity(topics.title, :raw) > 0.2", raw: raw, title: title) - .order('similarity desc') + similars = Topic + .joins("JOIN posts AS p ON p.topic_id = topics.id AND p.post_number = 1") + .where("topics.id IN (?)", candidate_ids) + .order("similarity DESC") + .limit(SiteSetting.max_similar_results) - similar + if raw.present? + similars + .select(sanitize_sql_array(["topics.*, similarity(topics.title, :title) + similarity(p.raw, :raw) AS similarity, p.cooked AS blurb", title: title, raw: raw])) + .where("similarity(topics.title, :title) + similarity(p.raw, :raw) > 0.2", title: title, raw: raw) + else + similars + .select(sanitize_sql_array(["topics.*, similarity(topics.title, :title) AS similarity, p.cooked AS blurb", title: title])) + .where("similarity(topics.title, :title) > 0.2", title: title) + end end - def update_status(status, enabled, user, opts={}) + def update_status(status, enabled, user, opts = {}) TopicStatusUpdater.new(self, user).update!(status, enabled, opts) DiscourseEvent.trigger(:topic_status_updated, self.id, status, enabled) end # Atomically creates the next post number def self.next_post_number(topic_id, reply = false, whisper = false) - highest = exec_sql("select coalesce(max(post_number),0) as max from posts where topic_id = ?", topic_id).first['max'].to_i + highest = exec_sql("SELECT coalesce(max(post_number),0) AS max FROM posts WHERE topic_id = ?", topic_id).first['max'].to_i if whisper @@ -501,7 +528,6 @@ class Topic < ActiveRecord::Base end end - def self.reset_all_highest! exec_sql < ?", NotificationLevels.all[:muted] + ).find_each do |u| + + u.notifications.create!( + notification_type: Notification.types[:invited_to_private_message], + topic_id: self.id, + post_number: 1, + data: { + topic_title: self.title, + display_username: user.username, + group_id: group_id + }.to_json + ) + end end true end # Invite a user to the topic by username or email. Returns success/failure - def invite(invited_by, username_or_email, group_ids=nil, custom_message=nil) + def invite(invited_by, username_or_email, group_ids = nil, custom_message = nil) + user = User.find_by_username_or_email(username_or_email) + if private_message? # If the user exists, add them to the message. - user = User.find_by_username_or_email(username_or_email) raise UserExists.new I18n.t("topic_invite.user_exists") if user.present? && topic_allowed_users.where(user_id: user.id).exists? if user && topic_allowed_users.create!(user_id: user.id) @@ -746,18 +797,27 @@ SQL end if username_or_email =~ /^.+@.+$/ && Guardian.new(invited_by).can_invite_via_email?(self) - # rate limit topic invite RateLimiter.new(invited_by, "topic-invitations-per-day", SiteSetting.max_topic_invitations_per_day, 1.day.to_i).performed! - # NOTE callers expect an invite object if an invite was sent via email - invite_by_email(invited_by, username_or_email, group_ids, custom_message) + if user.present? + # add existing users + Invite.extend_permissions(self, user, invited_by) + + # Notify the user they've been invited + user.notifications.create(notification_type: Notification.types[:invited_to_topic], + topic_id: id, + post_number: 1, + data: { topic_title: title, + display_username: invited_by.username }.to_json) + return true + else + # NOTE callers expect an invite object if an invite was sent via email + invite_by_email(invited_by, username_or_email, group_ids, custom_message) + end else - # invite existing member to a topic - user = User.find_by_username(username_or_email) raise UserExists.new I18n.t("topic_invite.user_exists") if user.present? && topic_allowed_users.where(user_id: user.id).exists? if user && topic_allowed_users.create!(user_id: user.id) - # rate limit topic invite RateLimiter.new(invited_by, "topic-invitations-per-day", SiteSetting.max_topic_invitations_per_day, 1.day.to_i).performed! # Notify the user they've been invited @@ -773,16 +833,16 @@ SQL end end - def invite_by_email(invited_by, email, group_ids=nil, custom_message=nil) + def invite_by_email(invited_by, email, group_ids = nil, custom_message = nil) Invite.invite_by_email(email, invited_by, self, group_ids, custom_message) end def email_already_exists_for?(invite) - invite.email_already_exists and private_message? + invite.email_already_exists && private_message? end def grant_permission_to_user(lower_email) - user = User.find_by(email: lower_email) + user = User.find_by_email(lower_email) topic_allowed_users.create!(user_id: user.id) end @@ -877,7 +937,7 @@ SQL slug = Slug.for(t.to_s) write_attribute(:slug, slug) write_attribute(:fancy_title, nil) - write_attribute(:title,t) + write_attribute(:title, t) end # NOTE: These are probably better off somewhere else. @@ -886,7 +946,7 @@ SQL "#{Discourse.base_uri}/t/#{slug}/#{id}/#{posts_count}" end - def self.url(id, slug, post_number=nil) + def self.url(id, slug, post_number = nil) url = "#{Discourse.base_url}/t/#{slug}/#{id}" url << "/#{post_number}" if post_number.to_i > 1 url @@ -896,7 +956,7 @@ SQL self.class.url id, slug, post_number end - def self.relative_url(id, slug, post_number=nil) + def self.relative_url(id, slug, post_number = nil) url = "#{Discourse.base_uri}/t/" url << "#{slug}/" if slug.present? url << id.to_s @@ -904,11 +964,11 @@ SQL url end - def slugless_url(post_number=nil) + def slugless_url(post_number = nil) Topic.relative_url(id, nil, post_number) end - def relative_url(post_number=nil) + def relative_url(post_number = nil) Topic.relative_url(id, slug, post_number) end @@ -926,7 +986,7 @@ SQL TopicUser.change(user.id, id, cleared_pinned_at: nil) end - def update_pinned(status, global=false, pinned_until=nil) + def update_pinned(status, global = false, pinned_until = nil) pinned_until = Time.parse(pinned_until) rescue nil update_columns( @@ -962,27 +1022,34 @@ SQL @public_topic_timer ||= topic_timers.find_by(deleted_at: nil, public_type: true) end + def private_topic_timer(user) + @private_topic_Timer ||= topic_timers.find_by(deleted_at: nil, public_type: false, user_id: user.id) + end + + def delete_topic_timer(status_type, by_user: Discourse.system_user) + options = { status_type: status_type } + options.merge!(user: by_user) unless TopicTimer.public_types[status_type] + self.topic_timers.find_by(options)&.trash!(by_user) + nil + end + # Valid arguments for the time: # * An integer, which is the number of hours from now to update the topic's status. - # * A time, like "12:00", which is the time at which the topic's status will update in the current day - # or the next day if that time has already passed today. # * A timestamp, like "2013-11-25 13:00", when the topic's status should update. # * A timestamp with timezone in JSON format. (e.g., "2013-11-26T21:00:00.000Z") # * `nil` to delete the topic's status update. # Options: # * by_user: User who is setting the topic's status update. - # * timezone_offset: (Integer) offset from UTC in minutes of the given argument. # * based_on_last_post: True if time should be based on timestamp of the last post. # * category_id: Category that the update will apply to. - def set_or_create_timer(status_type, time, by_user: nil, timezone_offset: 0, based_on_last_post: false, category_id: SiteSetting.uncategorized_category_id) - topic_timer_options = { status_type: status_type, topic: self } - topic_timer_options.merge!(user: by_user) unless TopicTimer.public_types[status_type] - topic_timer = TopicTimer.find_or_initialize_by(topic_timer_options) + def set_or_create_timer(status_type, time, by_user: nil, based_on_last_post: false, category_id: SiteSetting.uncategorized_category_id) + return delete_topic_timer(status_type, by_user: by_user) if time.blank? - if time.blank? - topic_timer.trash!(trashed_by: by_user || Discourse.system_user) - return - end + public_topic_timer = !!TopicTimer.public_types[status_type] + topic_timer_options = { topic: self, public_type: public_topic_timer } + topic_timer_options.merge!(user: by_user) unless public_topic_timer + topic_timer = TopicTimer.find_or_initialize_by(topic_timer_options) + topic_timer.status_type = status_type time_now = Time.zone.now topic_timer.based_on_last_post = !based_on_last_post.blank? @@ -1007,7 +1074,6 @@ SQL if is_timestamp && time.include?("-") && timestamp = utc.parse(time) # a timestamp in client's time zone, like "2015-5-27 12:00" topic_timer.execute_at = timestamp - topic_timer.execute_at += timezone_offset * 60 if timezone_offset topic_timer.errors.add(:execute_at, :invalid) if timestamp < now else num_hours = time.to_f @@ -1106,7 +1172,7 @@ SQL ) t SQL - def self.time_to_first_response(sql, opts=nil) + def self.time_to_first_response(sql, opts = nil) opts ||= {} builder = SqlBuilder.new(sql) builder.where("t.created_at >= :start_date", start_date: opts[:start_date]) if opts[:start_date] @@ -1117,17 +1183,17 @@ SQL builder.where("p.deleted_at IS NULL") builder.where("p.post_number > 1") builder.where("p.user_id != t.user_id") - builder.where("p.user_id in (:user_ids)", {user_ids: opts[:user_ids]}) if opts[:user_ids] + builder.where("p.user_id in (:user_ids)", user_ids: opts[:user_ids]) if opts[:user_ids] builder.where("p.post_type = :post_type", post_type: Post.types[:regular]) builder.where("EXTRACT(EPOCH FROM p.created_at - t.created_at) > 0") builder.exec end - def self.time_to_first_response_per_day(start_date, end_date, opts={}) - time_to_first_response(TIME_TO_FIRST_RESPONSE_SQL, opts.merge({start_date: start_date, end_date: end_date})) + def self.time_to_first_response_per_day(start_date, end_date, opts = {}) + time_to_first_response(TIME_TO_FIRST_RESPONSE_SQL, opts.merge(start_date: start_date, end_date: end_date)) end - def self.time_to_first_response_total(opts=nil) + def self.time_to_first_response_total(opts = nil) total = time_to_first_response(TIME_TO_FIRST_RESPONSE_TOTAL_SQL, opts) total.first["hours"].to_f.round(2) end @@ -1146,7 +1212,7 @@ SQL ORDER BY tt.created_at SQL - def self.with_no_response_per_day(start_date, end_date, category_id=nil) + def self.with_no_response_per_day(start_date, end_date, category_id = nil) builder = SqlBuilder.new(WITH_NO_RESPONSE_SQL) builder.where("t.created_at >= :start_date", start_date: start_date) if start_date builder.where("t.created_at < :end_date", end_date: end_date) if end_date @@ -1168,7 +1234,7 @@ SQL WHERE tt.first_reply IS NULL OR tt.first_reply < 2 SQL - def self.with_no_response_total(opts={}) + def self.with_no_response_total(opts = {}) builder = SqlBuilder.new(WITH_NO_RESPONSE_TOTAL_SQL) builder.where("t.category_id = :category_id", category_id: opts[:category_id]) if opts[:category_id] builder.where("t.archetype <> '#{Archetype.private_message}'") @@ -1189,12 +1255,25 @@ SQL end def pm_with_non_human_user? - Topic.private_messages - .joins("LEFT JOIN topic_allowed_groups ON topics.id = topic_allowed_groups.topic_id") - .where("topic_allowed_groups.topic_id IS NULL") - .where("topics.id = ?", self.id) - .where("(SELECT COUNT(*) FROM topic_allowed_users WHERE topic_allowed_users.topic_id = ? AND topic_allowed_users.user_id > 0) = 1", self.id) - .exists? + sql = <<~SQL + SELECT 1 FROM topics + LEFT JOIN topic_allowed_groups ON topics.id = topic_allowed_groups.topic_id + WHERE topic_allowed_groups.topic_id IS NULL + AND topics.archetype = :private_message + AND topics.id = :topic_id + AND ( + SELECT COUNT(*) FROM topic_allowed_users + WHERE topic_allowed_users.topic_id = :topic_id + AND topic_allowed_users.user_id > 0 + ) = 1 + SQL + + result = Topic.exec_sql(sql, private_message: Archetype.private_message, topic_id: self.id) + result.ntuples != 0 + end + + def featured_link_root_domain + MiniSuffix.domain(URI.parse(self.featured_link).hostname) end private @@ -1215,69 +1294,65 @@ SQL end - # == Schema Information # # Table name: topics # -# id :integer not null, primary key -# title :string not null -# last_posted_at :datetime -# created_at :datetime not null -# updated_at :datetime not null -# views :integer default(0), not null -# posts_count :integer default(0), not null -# user_id :integer -# last_post_user_id :integer not null -# reply_count :integer default(0), not null -# featured_user1_id :integer -# featured_user2_id :integer -# featured_user3_id :integer -# avg_time :integer -# deleted_at :datetime -# highest_post_number :integer default(0), not null -# image_url :string -# like_count :integer default(0), not null -# incoming_link_count :integer default(0), not null -# category_id :integer -# visible :boolean default(TRUE), not null -# moderator_posts_count :integer default(0), not null -# closed :boolean default(FALSE), not null -# archived :boolean default(FALSE), not null -# bumped_at :datetime not null -# has_summary :boolean default(FALSE), not null -# vote_count :integer default(0), not null -# archetype :string default("regular"), not null -# featured_user4_id :integer -# notify_moderators_count :integer default(0), not null -# spam_count :integer default(0), not null -# pinned_at :datetime -# score :float -# percent_rank :float default(1.0), not null -# subtype :string -# slug :string -# auto_close_at :datetime -# auto_close_user_id :integer -# auto_close_started_at :datetime -# deleted_by_id :integer -# participant_count :integer default(1) -# word_count :integer -# excerpt :string(1000) -# pinned_globally :boolean default(FALSE), not null -# auto_close_based_on_last_post :boolean default(FALSE) -# auto_close_hours :float -# pinned_until :datetime -# fancy_title :string(400) -# highest_staff_post_number :integer default(0), not null -# featured_link :string +# id :integer not null, primary key +# title :string(255) not null +# last_posted_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# views :integer default(0), not null +# posts_count :integer default(0), not null +# user_id :integer +# last_post_user_id :integer not null +# reply_count :integer default(0), not null +# featured_user1_id :integer +# featured_user2_id :integer +# featured_user3_id :integer +# avg_time :integer +# deleted_at :datetime +# highest_post_number :integer default(0), not null +# image_url :string(255) +# like_count :integer default(0), not null +# incoming_link_count :integer default(0), not null +# category_id :integer +# visible :boolean default(TRUE), not null +# moderator_posts_count :integer default(0), not null +# closed :boolean default(FALSE), not null +# archived :boolean default(FALSE), not null +# bumped_at :datetime not null +# has_summary :boolean default(FALSE), not null +# vote_count :integer default(0), not null +# archetype :string(255) default("regular"), not null +# featured_user4_id :integer +# notify_moderators_count :integer default(0), not null +# spam_count :integer default(0), not null +# pinned_at :datetime +# score :float +# percent_rank :float default(1.0), not null +# subtype :string(255) +# slug :string(255) +# deleted_by_id :integer +# participant_count :integer default(1) +# word_count :integer +# excerpt :string(1000) +# pinned_globally :boolean default(FALSE), not null +# pinned_until :datetime +# fancy_title :string(400) +# highest_staff_post_number :integer default(0), not null +# featured_link :string # # Indexes # # idx_topics_front_page (deleted_at,visible,archetype,category_id,id) # idx_topics_user_id_deleted_at (user_id) -# index_topics_on_bumped_at (bumped_at) +# idxtopicslug (slug) +# index_forum_threads_on_bumped_at (bumped_at) # index_topics_on_created_at_and_visible (created_at,visible) # index_topics_on_id_and_deleted_at (id,deleted_at) +# index_topics_on_lower_title (lower((title)::text)) # index_topics_on_pinned_at (pinned_at) # index_topics_on_pinned_globally (pinned_globally) # diff --git a/app/models/topic_converter.rb b/app/models/topic_converter.rb index dd5bc30559..5ebe651625 100644 --- a/app/models/topic_converter.rb +++ b/app/models/topic_converter.rb @@ -17,12 +17,28 @@ class TopicConverter else Category.where(read_restricted: false) .where.not(id: SiteSetting.uncategorized_category_id) - .first.id + .order('id asc') + .pluck(:id).first end @topic.archetype = Archetype.default @topic.save update_user_stats + + # TODO: Every post in a PRIVATE MESSAGE looks the same: each is a UserAction::NEW_PRIVATE_MESSAGE. + # So we need to remove all those user actions and re-log all the posts. + # Post counting depends on the correct UserActions (NEW_TOPIC, REPLY), so once a private topic + # becomes a public topic, post counts are wrong. The reverse is not so bad because + # we don't count NEW_PRIVATE_MESSAGE in any public stats. + # TBD: why do so many specs fail with this change? + + # UserAction.where(target_topic_id: @topic.id, action_type: [UserAction::GOT_PRIVATE_MESSAGE, UserAction::NEW_PRIVATE_MESSAGE]).find_each do |ua| + # UserAction.remove_action!(ua.attributes.symbolize_keys.slice(:action_type, :user_id, :acting_user_id, :target_topic_id, :target_post_id)) + # end + # @topic.posts.each do |post| + # UserActionCreator.log_post(post) unless post.post_number == 1 + # end + watch_topic(topic) end @topic @@ -44,7 +60,7 @@ class TopicConverter def update_user_stats @topic.posts.where(deleted_at: nil).each do |p| user = User.find(p.user_id) - # update posts count + # update posts count. NOTE that DirectoryItem.refresh will overwrite this by counting UserAction records. user.user_stat.post_count += 1 user.user_stat.save! end @@ -57,7 +73,7 @@ class TopicConverter @topic.posts.where(deleted_at: nil).each do |p| user = User.find(p.user_id) @topic.topic_allowed_users.build(user_id: user.id) unless @topic.topic_allowed_users.where(user_id: user.id).exists? - # update posts count + # update posts count. NOTE that DirectoryItem.refresh will overwrite this by counting UserAction records. user.user_stat.post_count -= 1 user.user_stat.save! end @@ -70,7 +86,7 @@ class TopicConverter def watch_topic(topic) @topic.notifier.watch_topic!(topic.user_id) - @topic.topic_allowed_users(true).each do |tau| + @topic.reload.topic_allowed_users.each do |tau| next if tau.user_id < 0 || tau.user_id == topic.user_id topic.notifier.watch!(tau.user_id) end diff --git a/app/models/topic_embed.rb b/app/models/topic_embed.rb index 0f1cc78f7d..1692b1a727 100644 --- a/app/models/topic_embed.rb +++ b/app/models/topic_embed.rb @@ -1,4 +1,5 @@ require_dependency 'nokogiri' +require_dependency 'url_helper' class TopicEmbed < ActiveRecord::Base include Trashable @@ -77,7 +78,8 @@ class TopicEmbed < ActiveRecord::Base def self.find_remote(url) require 'ruby-readability' - original_uri = URI.parse(URI.encode(url)) + url = UrlHelper.escape_uri(url) + original_uri = URI.parse(url) opts = { tags: %w[div p code pre h1 h2 h3 b em i strong a img ul li ol blockquote], attributes: %w[href src class], @@ -90,8 +92,8 @@ class TopicEmbed < ActiveRecord::Base response = FetchResponse.new begin - html = open(URI.encode(url), allow_redirections: :safe).read - rescue OpenURI::HTTPError + html = open(url, allow_redirections: :safe).read + rescue OpenURI::HTTPError, Net::OpenTimeout return end @@ -113,26 +115,26 @@ class TopicEmbed < ActiveRecord::Base response.title = title doc = Nokogiri::HTML(read_doc.content) - tags = {'img' => 'src', 'script' => 'src', 'a' => 'href'} + tags = { 'img' => 'src', 'script' => 'src', 'a' => 'href' } doc.search(tags.keys.join(',')).each do |node| url_param = tags[node.name] src = node[url_param] unless (src.nil? || src.empty?) begin - uri = URI.parse(URI.encode(src)) + uri = URI.parse(UrlHelper.escape_uri(src)) unless uri.host uri.scheme = original_uri.scheme uri.host = original_uri.host node[url_param] = uri.to_s end - rescue URI::InvalidURIError + rescue URI::InvalidURIError, URI::InvalidComponentError # If there is a mistyped URL, just do nothing end end # only allow classes in the whitelist allowed_classes = if embed_classname_whitelist.blank? then [] else embed_classname_whitelist.split(/[ ,]+/i) end doc.search('[class]:not([class=""])').each do |classnode| - classes = classnode[:class].split(' ').select{ |classname| allowed_classes.include?(classname) } + classes = classnode[:class].split(' ').select { |classname| allowed_classes.include?(classname) } if classes.length === 0 classnode.delete('class') else @@ -145,9 +147,11 @@ class TopicEmbed < ActiveRecord::Base response end - def self.import_remote(import_user, url, opts=nil) + def self.import_remote(import_user, url, opts = nil) opts = opts || {} response = find_remote(url) + return if response.nil? + response.title = opts[:title] if opts[:title].present? import_user = response.author if response.author.present? @@ -157,7 +161,7 @@ class TopicEmbed < ActiveRecord::Base # Convert any relative URLs to absolute. RSS is annoying for this. def self.absolutize_urls(url, contents) url = normalize_url(url) - uri = URI(URI.encode(url)) + uri = URI(UrlHelper.escape_uri(url)) prefix = "#{uri.scheme}://#{uri.host}" prefix << ":#{uri.port}" if uri.port != 80 && uri.port != 443 diff --git a/app/models/topic_featured_users.rb b/app/models/topic_featured_users.rb index f28c555b4c..d3f705149b 100644 --- a/app/models/topic_featured_users.rb +++ b/app/models/topic_featured_users.rb @@ -10,7 +10,7 @@ class TopicFeaturedUsers end # Chooses which topic users to feature - def choose(args={}) + def choose(args = {}) self.class.ensure_consistency!(topic.id.to_i) update_participant_count end @@ -22,7 +22,7 @@ class TopicFeaturedUsers topic.featured_user4_id].uniq.compact end - def self.ensure_consistency!(topic_id=nil) + def self.ensure_consistency!(topic_id = nil) filter = "#{"AND t.id = #{topic_id.to_i}" if topic_id}" filter2 = "#{"AND tt.id = #{topic_id.to_i}" if topic_id}" diff --git a/app/models/topic_link.rb b/app/models/topic_link.rb index b5e186930f..a55cd5ca48 100644 --- a/app/models/topic_link.rb +++ b/app/models/topic_link.rb @@ -1,5 +1,6 @@ require 'uri' require_dependency 'slug' +require_dependency 'discourse' class TopicLink < ActiveRecord::Base @@ -57,6 +58,8 @@ SQL builder.where('ftl.topic_id = :topic_id', topic_id: topic_id) builder.where('ft.deleted_at IS NULL') + # note that ILIKE means "case insensitive LIKE" + builder.where("NOT(ftl.url ILIKE '%.png' OR ftl.url ILIKE '%.jpg' OR ftl.url ILIKE '%.gif')") builder.where("COALESCE(ft.archetype, 'regular') <> :archetype", archetype: Archetype.private_message) builder.secure_category(guardian.secure_category_ids) @@ -65,7 +68,7 @@ SQL end - def self.counts_for(guardian,topic, posts) + def self.counts_for(guardian, topic, posts) return {} if posts.blank? # Sam: I don't know how to write this cleanly in AR, @@ -91,19 +94,18 @@ SQL builder.where('l.post_id IN (:post_ids)', post_ids: posts.map(&:id)) builder.secure_category(guardian.secure_category_ids) - builder.map_exec(OpenStruct).each_with_object({}) do |l,result| + builder.map_exec(OpenStruct).each_with_object({}) do |l, result| result[l.post_id] ||= [] - result[l.post_id] << {url: l.url, - clicks: l.clicks, - title: l.title, - internal: l.internal, - reflection: l.reflection} + result[l.post_id] << { url: l.url, + clicks: l.clicks, + title: l.title, + internal: l.internal, + reflection: l.reflection } end end - # Extract any urls in body def self.extract_from(post) - return unless post.present? && !post.whisper? + return if post.blank? || post.whisper? added_urls = [] TopicLink.transaction do @@ -122,17 +124,12 @@ SQL internal = false topic_id = nil post_number = nil - parsed_path = parsed.path || "" if Discourse.store.has_been_uploaded?(url) internal = Discourse.store.internal? - elsif (parsed.host == Discourse.current_hostname && parsed_path.start_with?(Discourse.base_uri)) || !parsed.host + elsif route = Discourse.route_for(parsed) internal = true - parsed_path.slice!(Discourse.base_uri) - - route = Rails.application.routes.recognize_path(parsed_path) - # We aren't interested in tracking internal links to users next if route[:controller] == 'users' @@ -147,7 +144,6 @@ SQL url = "#{Discourse.base_url_no_prefix}#{topic.relative_url}" url << "/#{post_number}" if post_number.to_i > 1 end - end # Skip linking to ourselves @@ -164,6 +160,7 @@ SQL added_urls << url unless TopicLink.exists?(topic_id: post.topic_id, post_id: post.id, url: url) + file_extension = File.extname(parsed.path)[1..10].downcase unless parsed.path.nil? || File.extname(parsed.path).empty? begin TopicLink.create!(post_id: post.id, user_id: post.user_id, @@ -173,7 +170,8 @@ SQL internal: internal, link_topic_id: topic_id, link_post_id: reflected_post.try(:id), - quote: link.is_quote) + quote: link.is_quote, + extension: file_extension) rescue ActiveRecord::RecordNotUnique, PG::UniqueViolation # it's fine end @@ -183,7 +181,7 @@ SQL if topic_id.present? topic = Topic.find_by(id: topic_id) - if topic && post.topic && post.topic.archetype != 'private_message' && topic.archetype != 'private_message' + if topic && post.topic && topic.archetype != 'private_message' && post.topic.archetype != 'private_message' && post.topic.visible? prefix = Discourse.base_url_no_prefix reflected_url = "#{prefix}#{post.topic.relative_url(post.post_number)}" tl = TopicLink.find_by(topic_id: topic_id, @@ -192,14 +190,14 @@ SQL unless tl tl = TopicLink.create(user_id: post.user_id, - topic_id: topic_id, - post_id: reflected_post.try(:id), - url: reflected_url, - domain: Discourse.current_hostname, - reflection: true, - internal: true, - link_topic_id: post.topic_id, - link_post_id: post.id) + topic_id: topic_id, + post_id: reflected_post.try(:id), + url: reflected_url, + domain: Discourse.current_hostname, + reflection: true, + internal: true, + link_topic_id: post.topic_id, + link_post_id: post.id) end @@ -216,17 +214,29 @@ SQL # Remove links that aren't there anymore if added_urls.present? - TopicLink.delete_all ["(url not in (:urls)) AND (post_id = :post_id AND NOT reflection)", urls: added_urls, post_id: post.id] + TopicLink.where( + "(url not in (:urls)) AND (post_id = :post_id AND NOT reflection)", + urls: added_urls, post_id: post.id + ).delete_all reflected_ids.compact! if reflected_ids.present? - TopicLink.delete_all ["(id not in (:reflected_ids)) AND (link_post_id = :post_id AND reflection)", - reflected_ids: reflected_ids, post_id: post.id] + TopicLink.where( + "(id not in (:reflected_ids)) AND (link_post_id = :post_id AND reflection)", + reflected_ids: reflected_ids, post_id: post.id + ).delete_all else - TopicLink.delete_all ["link_post_id = :post_id AND reflection", post_id: post.id] + TopicLink + .where("link_post_id = :post_id AND reflection", post_id: post.id) + .delete_all end else - TopicLink.delete_all ["(post_id = :post_id AND NOT reflection) OR (link_post_id = :post_id AND reflection)", post_id: post.id] + TopicLink + .where( + "(post_id = :post_id AND NOT reflection) OR (link_post_id = :post_id AND reflection)", + post_id: post.id + ) + .delete_all end end end @@ -238,11 +248,11 @@ SQL def self.duplicate_lookup(topic) results = TopicLink - .includes(:post, :user) - .joins(:post, :user) - .where("posts.id IS NOT NULL AND users.id IS NOT NULL") - .where(topic_id: topic.id, reflection: false) - .last(200) + .includes(:post, :user) + .joins(:post, :user) + .where("posts.id IS NOT NULL AND users.id IS NOT NULL") + .where(topic_id: topic.id, reflection: false) + .last(200) lookup = {} results.each do |tl| @@ -274,14 +284,16 @@ end # reflection :boolean default(FALSE) # clicks :integer default(0), not null # link_post_id :integer -# title :string +# title :string(255) # crawled_at :datetime # quote :boolean default(FALSE), not null +# extension :string(10) # # Indexes # -# index_topic_links_on_link_post_id_and_reflection (link_post_id,reflection) -# index_topic_links_on_post_id (post_id) -# index_topic_links_on_topic_id (topic_id) -# unique_post_links (topic_id,post_id,url) UNIQUE +# index_forum_thread_links_on_forum_thread_id (topic_id) +# index_forum_thread_links_on_forum_thread_id_and_post_id_and_url (topic_id,post_id,url) UNIQUE +# index_topic_links_on_extension (extension) +# index_topic_links_on_link_post_id_and_reflection (link_post_id,reflection) +# index_topic_links_on_post_id (post_id) # diff --git a/app/models/topic_link_click.rb b/app/models/topic_link_click.rb index 6bd932c796..3afce0bfbd 100644 --- a/app/models/topic_link_click.rb +++ b/app/models/topic_link_click.rb @@ -12,7 +12,7 @@ class TopicLinkClick < ActiveRecord::Base WHITELISTED_REDIRECT_HOSTNAMES = Set.new(%W{www.youtube.com youtu.be}) # Create a click from a URL and post_id - def self.create_from(args={}) + def self.create_from(args = {}) url = args[:url][0...TopicLink.max_url_length] return nil if url.blank? @@ -31,7 +31,13 @@ class TopicLinkClick < ActiveRecord::Base query = url.index('?') unless query.nil? endpos = url.index('#') || url.size - urls << url[0..query-1] + url[endpos..-1] + urls << url[0..query - 1] + url[endpos..-1] + end + + # link can have query params, and analytics can add more to the end: + i = url.length + while i = url.rindex('&', i - 1) + urls << url[0...i] end # add a cdn link @@ -44,8 +50,8 @@ class TopicLinkClick < ActiveRecord::Base end end - if SiteSetting.s3_cdn_url.present? - cdn_uri = URI.parse(SiteSetting.s3_cdn_url) rescue nil + if SiteSetting.Upload.s3_cdn_url.present? + cdn_uri = URI.parse(SiteSetting.Upload.s3_cdn_url) rescue nil if cdn_uri && cdn_uri.hostname == uri.hostname && uri.path.starts_with?(cdn_uri.path) is_cdn_link = true path = uri.path[cdn_uri.path.length..-1] @@ -112,5 +118,5 @@ end # # Indexes # -# by_link (topic_link_id) +# index_forum_thread_link_clicks_on_forum_thread_link_id (topic_link_id) # diff --git a/app/models/topic_list.rb b/app/models/topic_list.rb index efa8089202..375659dc5c 100644 --- a/app/models/topic_list.rb +++ b/app/models/topic_list.rb @@ -22,7 +22,7 @@ class TopicList def self.preload(topics, object) if @preload - @preload.each{|preload| preload.call(topics, object)} + @preload.each { |preload| preload.call(topics, object) } end end @@ -37,7 +37,7 @@ class TopicList :tags, :current_user - def initialize(filter, current_user, topics, opts=nil) + def initialize(filter, current_user, topics, opts = nil) @filter = filter @current_user = current_user @topics_input = topics @@ -86,7 +86,7 @@ class TopicList # Include bookmarks if you have bookmarked topics if @current_user && !post_action_type - post_action_type = PostActionType.types[:bookmark] if @topic_lookup.any?{|_,tu| tu && tu.bookmarked} + post_action_type = PostActionType.types[:bookmark] if @topic_lookup.any? { |_, tu| tu && tu.bookmarked } end # Data for bookmarks or likes @@ -105,7 +105,7 @@ class TopicList ft.user_data = @topic_lookup[ft.id] if @topic_lookup.present? if ft.user_data && post_action_lookup && actions = post_action_lookup[ft.id] - ft.user_data.post_action_data = {post_action_type => actions} + ft.user_data.post_action_data = { post_action_type => actions } end ft.posters = ft.posters_summary( @@ -127,6 +127,6 @@ class TopicList end def attributes - {'more_topics_url' => page} + { 'more_topics_url' => page } end end diff --git a/app/models/topic_notifier.rb b/app/models/topic_notifier.rb index db30a8e471..46d43c3755 100644 --- a/app/models/topic_notifier.rb +++ b/app/models/topic_notifier.rb @@ -3,10 +3,10 @@ class TopicNotifier @topic = topic end - { :watch! => :watching, - :track! => :tracking, - :regular! => :regular, - :mute! => :muted }.each_pair do |method_name, level| + { watch!: :watching, + track!: :tracking, + regular!: :regular, + mute!: :muted }.each_pair do |method_name, level| define_method method_name do |user_id| change_level user_id, level @@ -34,8 +34,8 @@ class TopicNotifier @notification_levels ||= TopicUser.notification_levels end - def change_level(user_id, level, reason=nil) - attrs = {notification_level: levels[level]} + def change_level(user_id, level, reason = nil) + attrs = { notification_level: levels[level] } attrs.merge!(notifications_reason_id: TopicUser.notification_reasons[reason]) if reason TopicUser.change(user_id, @topic.id, attrs) end diff --git a/app/models/topic_posters_summary.rb b/app/models/topic_posters_summary.rb index 7723bf338c..5fa5b2cefc 100644 --- a/app/models/topic_posters_summary.rb +++ b/app/models/topic_posters_summary.rb @@ -1,4 +1,6 @@ # This is used in topic lists +require_dependency 'topic_poster' + class TopicPostersSummary attr_reader :topic, :options @@ -40,7 +42,7 @@ class TopicPostersSummary def shuffle_last_poster_to_back_in(summary) unless last_poster_is_topic_creator? - summary.reject!{ |u| u.id == topic.last_post_user_id } + summary.reject! { |u| u.id == topic.last_post_user_id } summary << avatar_lookup[topic.last_post_user_id] end summary diff --git a/app/models/topic_search_data.rb b/app/models/topic_search_data.rb new file mode 100644 index 0000000000..641d79246c --- /dev/null +++ b/app/models/topic_search_data.rb @@ -0,0 +1,18 @@ +class TopicSearchData < ActiveRecord::Base + include HasSearchData +end + +# == Schema Information +# +# Table name: topic_search_data +# +# topic_id :integer not null, primary key +# raw_data :text +# locale :string(255) not null +# search_data :tsvector +# version :integer default(0) +# +# Indexes +# +# idx_search_topic (search_data) +# diff --git a/app/models/topic_timer.rb b/app/models/topic_timer.rb index 2db32b4765..160f6c40a7 100644 --- a/app/models/topic_timer.rb +++ b/app/models/topic_timer.rb @@ -19,13 +19,16 @@ class TopicTimer < ActiveRecord::Base self.created_at ||= Time.zone.now if execute_at self.public_type = self.public_type? - if (execute_at_changed? && !execute_at_was.nil?) || user_id_changed? + if (will_save_change_to_execute_at? && + !attribute_in_database(:execute_at).nil?) || + will_save_change_to_user_id? + self.send("cancel_auto_#{self.class.types[status_type]}_job") end end after_save do - if (execute_at_changed? || user_id_changed?) + if (saved_change_to_execute_at? || saved_change_to_user_id?) now = Time.zone.now time = execute_at < now ? now : execute_at diff --git a/app/models/topic_tracking_state.rb b/app/models/topic_tracking_state.rb index ff30630353..a7d48758ba 100644 --- a/app/models/topic_tracking_state.rb +++ b/app/models/topic_tracking_state.rb @@ -38,7 +38,7 @@ class TopicTrackingState publish_read(topic.id, 1, topic.user_id) end - def self.publish_latest(topic, staff_only=false) + def self.publish_latest(topic, staff_only = false) return unless topic.archetype == "regular" message = { @@ -73,9 +73,9 @@ class TopicTrackingState end TopicUser - .tracking(post.topic_id) - .select([:user_id,:last_read_post_number, :notification_level]) - .each do |tu| + .tracking(post.topic_id) + .select([:user_id, :last_read_post_number, :notification_level]) + .each do |tu| message = { topic_id: post.topic_id, @@ -125,7 +125,7 @@ class TopicTrackingState MessageBus.publish("/delete", message.as_json, group_ids: group_ids) end - def self.publish_read(topic_id, last_read_post_number, user_id, notification_level=nil) + def self.publish_read(topic_id, last_read_post_number, user_id, notification_level = nil) highest_post_number = Topic.where(id: topic_id).pluck(:highest_post_number).first @@ -155,7 +155,7 @@ class TopicTrackingState always: User::NewTopicDuration::ALWAYS, default_duration: SiteSetting.default_other_new_topic_duration_minutes, min_date: Time.at(SiteSetting.min_new_topics_time).to_datetime - ).where_values[0] + ).where_clause.send(:predicates)[0] end def self.report(user, topic_id = nil) @@ -179,21 +179,24 @@ class TopicTrackingState end - - def self.report_raw_sql(opts=nil) + def self.report_raw_sql(opts = nil) unread = if opts && opts[:skip_unread] "1=0" else - TopicQuery.unread_filter(Topic, -999, staff: opts && opts[:staff]).where_values.join(" AND ").sub("-999", ":user_id") + TopicQuery + .unread_filter(Topic, -999, staff: opts && opts[:staff]) + .where_clause.send(:predicates) + .join(" AND ") + .gsub("-999", ":user_id") end new = if opts && opts[:skip_new] "1=0" else - TopicQuery.new_filter(Topic, "xxx").where_values.join(" AND ").gsub!("'xxx'", treat_as_new_topic_clause) + TopicQuery.new_filter(Topic, "xxx").where_clause.send(:predicates).join(" AND ").gsub!("'xxx'", treat_as_new_topic_clause) end select = (opts && opts[:select]) || " @@ -205,7 +208,6 @@ class TopicTrackingState c.id AS category_id, tu.notification_level" - sql = <= :tracking", + .where("COALESCE(topic_users.notification_level, :regular) >= :tracking", regular: TopicUser.notification_levels[:regular], tracking: TopicUser.notification_levels[:tracking]) } @@ -38,26 +38,19 @@ class TopicUser < ActiveRecord::Base end def auto_notification(user_id, topic_id, reason, notification_level) - if TopicUser.where("user_id = :user_id AND topic_id = :topic_id AND (notifications_reason_id IS NULL OR - (notification_level < :notification_level AND notification_level > :normal_notification_level))", - user_id: user_id, topic_id: topic_id, notification_level: notification_level, - normal_notification_level: notification_levels[:regular]).exists? - change(user_id, topic_id, - notification_level: notification_level, - notifications_reason_id: reason - ) - end + should_change = TopicUser + .where(user_id: user_id, topic_id: topic_id) + .where("notifications_reason_id IS NULL OR (notification_level < :min AND notification_level > :max)", min: notification_level, max: notification_levels[:regular]) + .exists? + + change(user_id, topic_id, notification_level: notification_level, notifications_reason_id: reason) if should_change end - def auto_notification_for_staging(user_id, topic_id, reason) - topic_user = TopicUser.find_or_initialize_by(user_id: user_id, topic_id: topic_id) - topic_user.notification_level = notification_levels[:watching] - topic_user.notifications_reason_id = reason - topic_user.save + def auto_notification_for_staging(user_id, topic_id, reason, notification_level = notification_levels[:watching]) + change(user_id, topic_id, notification_level: notification_level, notifications_reason_id: reason) end def unwatch_categories!(user, category_ids) - track_threshold = user.user_option.auto_track_topics_after_msecs sql = < category_notification_level)) attrs[:notification_level] = category_notification_level @@ -191,7 +184,6 @@ SQL TopicUser.notification_reasons[:auto_track_tag] end - end unless attrs[:notification_level] @@ -203,7 +195,7 @@ SQL end end - TopicUser.create(attrs.merge!(user_id: user_id, topic_id: topic_id, first_visited_at: now ,last_visited_at: now)) + TopicUser.create(attrs.merge!(user_id: user_id, topic_id: topic_id, first_visited_at: now , last_visited_at: now)) end def track_visit!(topic_id, user_id) @@ -256,7 +248,7 @@ SQL INSERT_TOPIC_USER_SQL_STAFF = INSERT_TOPIC_USER_SQL.gsub("highest_post_number", "highest_staff_post_number") - def update_last_read(user, topic_id, post_number, msecs, opts={}) + def update_last_read(user, topic_id, post_number, new_posts_read, msecs, opts = {}) return if post_number.blank? msecs = 0 if msecs.to_i < 0 @@ -276,11 +268,12 @@ SQL # ... user visited the topic but did not read the posts # # 86400000 = 1 day - rows = if user.staff? - exec_sql(UPDATE_TOPIC_USER_SQL_STAFF,args).values - else - exec_sql(UPDATE_TOPIC_USER_SQL,args).values - end + rows = + if user.staff? + exec_sql(UPDATE_TOPIC_USER_SQL_STAFF, args).values + else + exec_sql(UPDATE_TOPIC_USER_SQL, args).values + end if rows.length == 1 before = rows[0][1].to_i @@ -291,7 +284,10 @@ SQL if before_last_read < post_number # The user read at least one new post TopicTrackingState.publish_read(topic_id, post_number, user.id, after) - user.update_posts_read!(post_number - before_last_read, mobile: opts[:mobile]) + end + + if new_posts_read > 0 + user.update_posts_read!(new_posts_read, mobile: opts[:mobile]) end if before != after @@ -307,7 +303,7 @@ SQL end TopicTrackingState.publish_read(topic_id, post_number, user.id, args[:new_status]) - user.update_posts_read!(post_number, mobile: opts[:mobile]) + user.update_posts_read!(new_posts_read, mobile: opts[:mobile]) begin if user.staff? @@ -322,7 +318,7 @@ SQL raise else opts[:retry] = true - update_last_read(user, topic_id, post_number, msecs, opts) + update_last_read(user, topic_id, post_number, new_posts_read, msecs, opts) end end @@ -332,7 +328,7 @@ SQL end - def self.update_post_action_cache(opts={}) + def self.update_post_action_cache(opts = {}) user_id = opts[:user_id] post_id = opts[:post_id] topic_id = opts[:topic_id] @@ -412,7 +408,7 @@ SQL TopicUser.exec_sql(sql, user_id: user_id, count: count) end - def self.ensure_consistency!(topic_id=nil) + def self.ensure_consistency!(topic_id = nil) update_post_action_cache(topic_id: topic_id) # TODO this needs some reworking, when we mark stuff skipped @@ -478,6 +474,6 @@ end # # Indexes # -# index_topic_users_on_topic_id_and_user_id (topic_id,user_id) UNIQUE -# index_topic_users_on_user_id_and_topic_id (user_id,topic_id) UNIQUE +# index_forum_thread_users_on_forum_thread_id_and_user_id (topic_id,user_id) UNIQUE +# index_topic_users_on_user_id_and_topic_id (user_id,topic_id) UNIQUE # diff --git a/app/models/topic_view_item.rb b/app/models/topic_view_item.rb index 2b6fa6b49c..04b231fa9b 100644 --- a/app/models/topic_view_item.rb +++ b/app/models/topic_view_item.rb @@ -6,7 +6,7 @@ class TopicViewItem < ActiveRecord::Base belongs_to :user validates_presence_of :topic_id, :ip_address, :viewed_at - def self.add(topic_id, ip, user_id=nil, at=nil, skip_redis=false) + def self.add(topic_id, ip, user_id = nil, at = nil, skip_redis = false) # Only store a view once per day per thing per user per ip at ||= Date.today redis_key = "view:#{topic_id}:#{at}" @@ -28,7 +28,6 @@ class TopicViewItem < ActiveRecord::Base /*where*/ )" - builder = SqlBuilder.new(sql) if !user_id diff --git a/app/models/translation_override.rb b/app/models/translation_override.rb index d39c48221b..4c2ac9b82c 100644 --- a/app/models/translation_override.rb +++ b/app/models/translation_override.rb @@ -1,9 +1,21 @@ require 'js_locale_helper' +require "i18n/i18n_interpolation_keys_finder" class TranslationOverride < ActiveRecord::Base + # Whitelist i18n interpolation keys that can be included when customizing translations + CUSTOM_INTERPOLATION_KEYS_WHITELIST = { + "user_notifications.user_" => %w{ + topic_title_url_encoded + site_title_url_encoded + context + } + } + validates_uniqueness_of :translation_key, scope: :locale validates_presence_of :locale, :translation_key, :value + validate :check_interpolation_keys + def self.upsert!(locale, key, value) params = { locale: locale, translation_key: key } @@ -12,9 +24,10 @@ class TranslationOverride < ActiveRecord::Base data[:compiled_js] = JsLocaleHelper.compile_message_format(locale, value) end - row_count = where(params).update_all(data) - create!(params.merge(data)) if row_count == 0 - i18n_changed + translation_override = find_or_initialize_by(params) + params.merge!(data) if translation_override.new_record? + i18n_changed if translation_override.update(data) + translation_override end def self.revert!(locale, *keys) @@ -22,11 +35,43 @@ class TranslationOverride < ActiveRecord::Base i18n_changed end - protected + private def self.i18n_changed I18n.reload! - MessageBus.publish('/i18n-flush', { refresh: true }) + MessageBus.publish('/i18n-flush', refresh: true) + end + + def check_interpolation_keys + original_text = I18n.overrides_disabled do + I18n.backend.send(:lookup, self.locale, self.translation_key) + end + + if original_text + original_interpolation_keys = I18nInterpolationKeysFinder.find(original_text) + new_interpolation_keys = I18nInterpolationKeysFinder.find(value) + + custom_interpolation_keys = [] + + CUSTOM_INTERPOLATION_KEYS_WHITELIST.select do |key, value| + if self.translation_key.start_with?(key) + custom_interpolation_keys = value + end + end + + invalid_keys = (original_interpolation_keys | new_interpolation_keys) - + original_interpolation_keys - + custom_interpolation_keys + + if invalid_keys.present? + self.errors.add(:base, I18n.t( + 'activerecord.errors.models.translation_overrides.attributes.value.invalid_interpolation_keys', + keys: invalid_keys.join(', ') + )) + + return false + end + end end end diff --git a/app/models/trust_level3_requirements.rb b/app/models/trust_level3_requirements.rb index 2579216f86..6307e563b0 100644 --- a/app/models/trust_level3_requirements.rb +++ b/app/models/trust_level3_requirements.rb @@ -63,7 +63,7 @@ class TrustLevel3Requirements end def trust_level_locked - @user.trust_level_locked + !@user.manual_locked_trust_level.nil? end def on_grace_period @@ -130,11 +130,11 @@ class TrustLevel3Requirements def num_flagged_posts PostAction.with_deleted - .where(post_id: flagged_post_ids) - .where.not(user_id: @user.id) - .where.not(agreed_at: nil) - .pluck(:post_id) - .uniq.count + .where(post_id: flagged_post_ids) + .where.not(user_id: @user.id) + .where.not(agreed_at: nil) + .pluck(:post_id) + .uniq.count end def max_flagged_posts @@ -143,11 +143,11 @@ class TrustLevel3Requirements def num_flagged_by_users @_num_flagged_by_users ||= PostAction.with_deleted - .where(post_id: flagged_post_ids) - .where.not(user_id: @user.id) - .where.not(agreed_at: nil) - .pluck(:user_id) - .uniq.count + .where(post_id: flagged_post_ids) + .where.not(user_id: @user.id) + .where.not(agreed_at: nil) + .pluck(:user_id) + .uniq.count end def max_flagged_by_users @@ -194,13 +194,11 @@ class TrustLevel3Requirements (min_likes_received.to_f / 4.0).ceil end - def self.clear_cache $redis.del NUM_TOPICS_KEY $redis.del NUM_POSTS_KEY end - CACHE_DURATION = 1.day.seconds - 60 NUM_TOPICS_KEY = "tl3_num_topics" NUM_POSTS_KEY = "tl3_num_posts" @@ -223,8 +221,8 @@ class TrustLevel3Requirements def flagged_post_ids @_flagged_post_ids ||= @user.posts - .with_deleted - .where('created_at > ? AND (spam_count > 0 OR inappropriate_count > 0)', time_period.days.ago) - .pluck(:id) + .with_deleted + .where('created_at > ? AND (spam_count > 0 OR inappropriate_count > 0)', time_period.days.ago) + .pluck(:id) end end diff --git a/app/models/twitter_user_info.rb b/app/models/twitter_user_info.rb index 0927c009ae..42d4f4d83c 100644 --- a/app/models/twitter_user_info.rb +++ b/app/models/twitter_user_info.rb @@ -8,7 +8,7 @@ end # # id :integer not null, primary key # user_id :integer not null -# screen_name :string not null +# screen_name :string(255) not null # twitter_user_id :integer not null # created_at :datetime not null # updated_at :datetime not null diff --git a/app/models/upload.rb b/app/models/upload.rb index d6e1916c72..4d2e396a06 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -4,6 +4,7 @@ require_dependency "url_helper" require_dependency "db_helper" require_dependency "validators/upload_validator" require_dependency "file_store/local_store" +require_dependency "base62" class Upload < ActiveRecord::Base belongs_to :user @@ -13,8 +14,9 @@ class Upload < ActiveRecord::Base has_many :optimized_images, dependent: :destroy - attr_accessor :is_attachment_for_group_message + attr_accessor :for_group_message attr_accessor :for_theme + attr_accessor :for_private_message validates_presence_of :filesize validates_presence_of :original_filename @@ -29,7 +31,7 @@ class Upload < ActiveRecord::Base thumbnail(width, height).present? end - def create_thumbnail!(width, height, crop=false) + def create_thumbnail!(width, height, crop = false) return unless SiteSetting.create_thumbnails? opts = { @@ -52,8 +54,20 @@ class Upload < ActiveRecord::Base end end - def extension - File.extname(original_filename) + def short_url + "upload://#{Base62.encode(sha1.hex)}.#{extension}" + end + + def self.sha1_from_short_url(url) + if url =~ /(upload:\/\/)?([a-zA-Z0-9]+)(\..*)?/ + sha1 = Base62.decode($2).to_s(16) + + if sha1.length > 40 + nil + else + sha1.rjust(40, '0') + end + end end def self.generate_digest(path) @@ -65,7 +79,7 @@ class Upload < ActiveRecord::Base # we store relative urls, so we need to remove any host/cdn url = url.sub(Discourse.asset_host, "") if Discourse.asset_host.present? # when using s3, we need to replace with the absolute base url - url = url.sub(SiteSetting.s3_cdn_url, Discourse.store.absolute_base_url) if SiteSetting.s3_cdn_url.present? + url = url.sub(SiteSetting.Upload.s3_cdn_url, Discourse.store.absolute_base_url) if SiteSetting.Upload.s3_cdn_url.present? # always try to get the path uri = URI(url) rescue nil @@ -74,7 +88,7 @@ class Upload < ActiveRecord::Base Upload.find_by(url: url) end - def self.migrate_to_new_scheme(limit=nil) + def self.migrate_to_new_scheme(limit = nil) problems = [] if SiteSetting.migrate_to_new_scheme @@ -108,9 +122,7 @@ class Upload < ActiveRecord::Base upload.sha1 = Upload.generate_digest(path) end # optimize if image - if FileHelper.is_image?(File.basename(path)) - ImageOptim.new.optimize_image!(path) - end + FileHelper.optimize_image!(path) if FileHelper.is_image?(File.basename(path)) # store to new location & update the filesize File.open(path) do |f| upload.url = Discourse.store.store_upload(f, upload) @@ -144,19 +156,21 @@ end # # id :integer not null, primary key # user_id :integer not null -# original_filename :string not null +# original_filename :string(255) not null # filesize :integer not null # width :integer # height :integer -# url :string not null +# url :string(255) not null # created_at :datetime not null # updated_at :datetime not null # sha1 :string(40) # origin :string(1000) # retain_hours :integer +# extension :string(10) # # Indexes # +# index_uploads_on_extension (lower((extension)::text)) # index_uploads_on_id_and_url (id,url) # index_uploads_on_sha1 (sha1) UNIQUE # index_uploads_on_url (url) diff --git a/app/models/user.rb b/app/models/user.rb index f3d7e325c6..28dc1e8552 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,3 +1,4 @@ +require_dependency 'jobs/base' require_dependency 'email' require_dependency 'email_token' require_dependency 'email_validator' @@ -10,11 +11,17 @@ require_dependency 'pretty_text' require_dependency 'url_helper' require_dependency 'letter_avatar' require_dependency 'promotion' +require_dependency 'password_validator' +require_dependency 'notification_serializer' class User < ActiveRecord::Base + include Searchable include Roleable include HasCustomFields + # TODO: Remove this after 7th Jan 2018 + self.ignored_columns = %w{email} + has_many :posts has_many :notifications, dependent: :destroy has_many :topic_users, dependent: :destroy @@ -42,7 +49,9 @@ class User < ActiveRecord::Base has_many :email_change_requests, dependent: :destroy has_many :directory_items, dependent: :delete_all has_many :user_auth_tokens, dependent: :destroy + has_many :user_emails, dependent: :destroy + has_one :primary_email, -> { where(primary: true) }, class_name: 'UserEmail', dependent: :destroy has_one :user_option, dependent: :destroy has_one :user_avatar, dependent: :destroy @@ -64,29 +73,27 @@ class User < ActiveRecord::Base has_many :muted_user_records, class_name: 'MutedUser' has_many :muted_users, through: :muted_user_records - has_one :user_search_data, dependent: :destroy has_one :api_key, dependent: :destroy belongs_to :uploaded_avatar, class_name: 'Upload' - has_many :acting_group_histories, dependent: :destroy, foreign_key: :acting_user_id, class_name: GroupHistory - has_many :targeted_group_histories, dependent: :destroy, foreign_key: :target_user_id, class_name: GroupHistory + has_many :acting_group_histories, dependent: :destroy, foreign_key: :acting_user_id, class_name: 'GroupHistory' + has_many :targeted_group_histories, dependent: :destroy, foreign_key: :target_user_id, class_name: 'GroupHistory' - delegate :last_sent_email_address, :to => :email_logs - - before_validation :strip_downcase_email + delegate :last_sent_email_address, to: :email_logs validates_presence_of :username - validate :username_validator, if: :username_changed? - validates :email, presence: true, uniqueness: true - validates :email, format: { with: EmailValidator.email_regex }, if: :email_changed? - validates :email, email: true, if: :should_validate_email? + validate :username_validator, if: :will_save_change_to_username? validate :password_validator - validates :name, user_full_name: true, if: :name_changed?, length: { maximum: 255 } - validates :ip_address, allowed_ip_address: {on: :create, message: :signup_not_allowed} + validates :name, user_full_name: true, if: :will_save_change_to_name?, length: { maximum: 255 } + validates :ip_address, allowed_ip_address: { on: :create, message: :signup_not_allowed } + validates :primary_email, presence: true + validates_associated :primary_email, message: -> (_, user_email) { user_email[:value]&.errors[:email]&.first } after_initialize :add_trust_level + before_validation :set_skip_validate_email + after_create :create_email_token after_create :create_user_stat after_create :create_user_option @@ -98,7 +105,6 @@ class User < ActiveRecord::Base before_save :ensure_password_is_hashed after_save :expire_tokens_if_password_changed - after_save :automatic_group_membership after_save :clear_global_notice_if_needed after_save :refresh_avatar after_save :badge_grant @@ -108,8 +114,8 @@ class User < ActiveRecord::Base before_destroy do # These tables don't have primary keys, so destroying them with activerecord is tricky: - PostTiming.delete_all(user_id: self.id) - TopicViewItem.delete_all(user_id: self.id) + PostTiming.where(user_id: self.id).delete_all + TopicViewItem.where(user_id: self.id).delete_all end # Skip validating email, for example from a particular auth provider plugin @@ -124,6 +130,8 @@ class User < ActiveRecord::Base # set to true to optimize creation and save for imports attr_accessor :import_mode + scope :with_email, ->(email) { joins(:user_emails).where(user_emails: { email: email }) } + scope :human_users, -> { where('users.id > 0') } # excluding fake users like the system user or anonymous users @@ -136,13 +144,11 @@ class User < ActiveRecord::Base ucf.value::int > 0 )', 'master_id') } - scope :staff, -> { where("admin OR moderator") } - # TODO-PERF: There is no indexes on any of these # and NotifyMailingListSubscribers does a select-all-and-loop - # may want to create an index on (active, blocked, suspended_till)? - scope :blocked, -> { where(blocked: true) } - scope :not_blocked, -> { where(blocked: false) } + # may want to create an index on (active, silence, suspended_till)? + scope :silenced, -> { where("silenced_till IS NOT NULL AND silenced_till > ?", Time.zone.now) } + scope :not_silenced, -> { where("silenced_till IS NULL OR silenced_till <= ?", Time.zone.now) } scope :suspended, -> { where('suspended_till IS NOT NULL AND suspended_till > ?', Time.zone.now) } scope :not_suspended, -> { where('suspended_till IS NULL OR suspended_till <= ?', Time.zone.now) } scope :activated, -> { where(active: true) } @@ -160,9 +166,12 @@ class User < ActiveRecord::Base SiteSetting.min_username_length.to_i..SiteSetting.max_username_length.to_i end - def self.username_available?(username) + def self.username_available?(username, email = nil) lower = username.downcase - !reserved_username?(lower) && !User.where(username_lower: lower).exists? + return false if reserved_username?(lower) + return true if !User.exists?(username_lower: lower) + # staged users can use the same username since they will take over the account + email.present? && User.joins(:user_emails).exists?(staged: true, username_lower: lower, user_emails: { primary: true, email: email }) end def self.reserved_username?(username) @@ -233,20 +242,19 @@ class User < ActiveRecord::Base end def self.find_by_email(email) - find_by(email: Email.downcase(email)) + self.with_email(Email.downcase(email)).first end def self.find_by_username(username) find_by(username_lower: username.downcase) end - def enqueue_welcome_message(message_type) return unless SiteSetting.send_welcome_message? Jobs.enqueue(:send_system_message, user_id: id, message_type: message_type) end - def change_username(new_username, actor=nil) + def change_username(new_username, actor = nil) UsernameChanger.change(self, new_username, actor) end @@ -268,12 +276,12 @@ class User < ActiveRecord::Base used_invite.try(:invited_by) end - def should_validate_email? - return !skip_email_validation && !staged? && email_changed? + def should_validate_email_address? + !skip_email_validation && !staged? end # Approve this user - def approve(approved_by, send_mail=true) + def approve(approved_by, send_mail = true) self.approved = true if approved_by.is_a?(Integer) @@ -304,6 +312,7 @@ class User < ActiveRecord::Base @unread_notifications = nil @unread_total_notifications = nil @unread_pms = nil + @user_fields = nil super end @@ -318,7 +327,7 @@ class User < ActiveRecord::Base n.user_id = :user_id AND NOT read" - User.exec_sql(sql, user_id: id, type: notification_type).getvalue(0,0).to_i + User.exec_sql(sql, user_id: id, type: notification_type).getvalue(0, 0).to_i end def unread_private_messages @@ -342,7 +351,7 @@ class User < ActiveRecord::Base User.exec_sql(sql, user_id: id, seen_notification_id: seen_notification_id, pm: Notification.types[:private_message]) - .getvalue(0,0).to_i + .getvalue(0, 0).to_i end end @@ -377,7 +386,6 @@ class User < ActiveRecord::Base notification = notifications.visible.order('notifications.id desc').first json = NotificationSerializer.new(notification).as_json if notification - sql = " SELECT * FROM ( SELECT n.id, n.read FROM notifications n @@ -403,19 +411,21 @@ class User < ActiveRecord::Base ) AS y " - recent = User.exec_sql(sql, user_id: id, - type: Notification.types[:private_message]).values.map do |id, read| - [id.to_i, read == 't'.freeze] + recent = User.exec_sql(sql, + user_id: id, + type: Notification.types[:private_message] + ).values.map! do |id, read| + [id.to_i, read] end MessageBus.publish("/notification/#{id}", - {unread_notifications: unread_notifications, - unread_private_messages: unread_private_messages, - total_unread_notifications: total_unread_notifications, - read_first_notification: read_first_notification?, - last_notification: json, - recent: recent, - seen_notification_id: seen_notification_id + { unread_notifications: unread_notifications, + unread_private_messages: unread_private_messages, + total_unread_notifications: total_unread_notifications, + read_first_notification: read_first_notification?, + last_notification: json, + recent: recent, + seen_notification_id: seen_notification_id }, user_ids: [id] # only publish the notification to this user ) @@ -446,6 +456,10 @@ class User < ActiveRecord::Base !!@password_required end + def password_validation_required? + password_required? || @raw_password.present? + end + def has_password? password_hash.present? end @@ -475,7 +489,7 @@ class User < ActiveRecord::Base last_seen_at.present? end - def create_visit_record!(date, opts={}) + def create_visit_record!(date, opts = {}) user_stat.update_column(:days_visited, user_stat.days_visited + 1) user_visits.create!(visited_at: date, posts_read: opts[:posts_read] || 0, mobile: opts[:mobile] || false) end @@ -488,7 +502,7 @@ class User < ActiveRecord::Base create_visit_record!(date) unless visit_record_for(date) end - def update_posts_read!(num_posts, opts={}) + def update_posts_read!(num_posts, opts = {}) now = opts[:at] || Time.zone.now _retry = opts[:retry] || false @@ -502,7 +516,7 @@ class User < ActiveRecord::Base create_visit_record!(now.to_date, posts_read: num_posts, mobile: opts.fetch(:mobile, false)) rescue ActiveRecord::RecordNotUnique if !_retry - update_posts_read!(num_posts, opts.merge( retry: true )) + update_posts_read!(num_posts, opts.merge(retry: true)) else raise end @@ -516,7 +530,7 @@ class User < ActiveRecord::Base end end - def update_last_seen!(now=Time.zone.now) + def update_last_seen!(now = Time.zone.now) now_date = now.to_date # Only update last seen once every minute redis_key = "user:#{id}:#{now_date}" @@ -574,6 +588,7 @@ class User < ActiveRecord::Base # TODO it may be worth caching this in a distributed cache, should be benched if SiteSetting.external_system_avatars_enabled url = SiteSetting.external_system_avatars_url.dup + url = "#{Discourse::base_uri}#{url}" unless url =~ /^https?:\/\// url.gsub! "{color}", letter_avatar_color(username.downcase) url.gsub! "{username}", username url.gsub! "{first_letter}", username[0].downcase @@ -610,7 +625,7 @@ class User < ActiveRecord::Base end def flags_given_count - PostAction.where(user_id: id, post_action_type_id: PostActionType.flag_types.values).count + PostAction.where(user_id: id, post_action_type_id: PostActionType.flag_types_without_custom.values).count end def warnings_received_count @@ -618,7 +633,7 @@ class User < ActiveRecord::Base end def flags_received_count - posts.includes(:post_actions).where('post_actions.post_action_type_id' => PostActionType.flag_types.values).count + posts.includes(:post_actions).where('post_actions.post_action_type_id' => PostActionType.flag_types_without_custom.values).count end def private_topics_count @@ -650,15 +665,39 @@ class User < ActiveRecord::Base end def suspended? - suspended_till && suspended_till > DateTime.now + !!(suspended_till && suspended_till > Time.zone.now) + end + + def silenced? + !!(silenced_till && silenced_till > Time.zone.now) + end + + def silenced_record + UserHistory.for(self, :silence_user).order('id DESC').first + end + + def silence_reason + silenced_record.try(:details) if silenced? + end + + def silenced_at + silenced_record.try(:created_at) if silenced? end def suspend_record UserHistory.for(self, :suspend_user).order('id DESC').first end + def full_suspend_reason + return suspend_record.try(:details) if suspended? + end + def suspend_reason - suspend_record.try(:details) if suspended? + if details = full_suspend_reason + return details.split("\n")[0] + end + + nil end # Use this helper to determine if the user has a particular trust level. @@ -689,21 +728,18 @@ class User < ActiveRecord::Base end def activate - email_token = self.email_tokens.active.first - if email_token + if email_token = self.email_tokens.active.where(email: self.email).first EmailToken.confirm(email_token.token) else - self.active = true - save + self.update!(active: true) end end def deactivate - self.active = false - save + self.update!(active: false) end - def change_trust_level!(level, opts=nil) + def change_trust_level!(level, opts = nil) Promotion.new(self).change_trust_level!(level, opts) end @@ -716,26 +752,26 @@ class User < ActiveRecord::Base user_badges.select('distinct badge_id').count end - def featured_user_badges(limit=3) + def featured_user_badges(limit = 3) tl_badge_ids = Badge.trust_level_badge_ids query = user_badges - .group(:badge_id) - .select(UserBadge.attribute_names.map { |x| "MAX(user_badges.#{x}) AS #{x}" }, + .group(:badge_id) + .select(UserBadge.attribute_names.map { |x| "MAX(user_badges.#{x}) AS #{x}" }, 'COUNT(*) AS "count"', 'MAX(badges.badge_type_id) AS badges_badge_type_id', 'MAX(badges.grant_count) AS badges_grant_count') - .joins(:badge) - .order('badges_badge_type_id ASC, badges_grant_count ASC, badge_id DESC') - .includes(:user, :granted_by, { badge: :badge_type }, { post: :topic }) + .joins(:badge) + .order('badges_badge_type_id ASC, badges_grant_count ASC, badge_id DESC') + .includes(:user, :granted_by, { badge: :badge_type }, post: :topic) tl_badge = query.where("user_badges.badge_id IN (:tl_badge_ids)", tl_badge_ids: tl_badge_ids) - .limit(1) + .limit(1) other_badges = query.where("user_badges.badge_id NOT IN (:tl_badge_ids)", tl_badge_ids: tl_badge_ids) - .limit(limit) + .limit(limit) (tl_badge + other_badges).take(limit) end @@ -750,7 +786,6 @@ class User < ActiveRecord::Base result.group('date(users.created_at)').order('date(users.created_at)').count end - def secure_category_ids cats = self.admin? ? Category.where(read_restricted: true) : secure_categories.references(:categories) cats.pluck('categories.id').sort @@ -760,16 +795,15 @@ class User < ActiveRecord::Base Category.topic_create_allowed(self.id).select(:id) end - # Flag all posts from a user as spam def flag_linked_posts_as_spam disagreed_flag_post_ids = PostAction.where(post_action_type_id: PostActionType.types[:spam]) - .where.not(disagreed_at: nil) - .pluck(:post_id) + .where.not(disagreed_at: nil) + .pluck(:post_id) topic_links.includes(:post) - .where.not(post_id: disagreed_flag_post_ids) - .each do |tl| + .where.not(post_id: disagreed_flag_post_ids) + .each do |tl| begin message = I18n.t('flag_reason.spam_hosts', domain: tl.domain) PostAction.act(Discourse.system_user, tl.post, PostActionType.types[:spam], message: message) @@ -812,7 +846,6 @@ class User < ActiveRecord::Base .exists? end - def refresh_avatar return if @import_mode @@ -824,7 +857,7 @@ class User < ActiveRecord::Base end # mark all the user's quoted posts as "needing a rebake" - Post.rebake_all_quoted_posts(self.id) if self.uploaded_avatar_id_changed? + Post.rebake_all_quoted_posts(self.id) if self.will_save_change_to_uploaded_avatar_id? end def first_post_created_at @@ -868,25 +901,25 @@ class User < ActiveRecord::Base def number_of_deleted_posts Post.with_deleted - .where(user_id: self.id) - .where.not(deleted_at: nil) - .count + .where(user_id: self.id) + .where.not(deleted_at: nil) + .count end def number_of_flagged_posts Post.with_deleted - .where(user_id: self.id) - .where(id: PostAction.where(post_action_type_id: PostActionType.notify_flag_type_ids) + .where(user_id: self.id) + .where(id: PostAction.where(post_action_type_id: PostActionType.notify_flag_type_ids) .where(disagreed_at: nil) .select(:post_id)) - .count + .count end def number_of_flags_given PostAction.where(user_id: self.id) - .where(disagreed_at: nil) - .where(post_action_type_id: PostActionType.notify_flag_type_ids) - .count + .where(disagreed_at: nil) + .where(post_action_type_id: PostActionType.notify_flag_type_ids) + .count end def number_of_suspensions @@ -912,6 +945,48 @@ class User < ActiveRecord::Base DiscourseEvent.trigger(:user_logged_out, self) end + def logged_in + DiscourseEvent.trigger(:user_logged_in, self) + + if !self.seen_before? + DiscourseEvent.trigger(:user_first_logged_in, self) + end + end + + def set_automatic_groups + return unless active && email_confirmed? && !staged + + Group.where(automatic: false) + .where("LENGTH(COALESCE(automatic_membership_email_domains, '')) > 0") + .each do |group| + + domains = group.automatic_membership_email_domains.gsub('.', '\.') + + if email =~ Regexp.new("@(#{domains})$", true) && !group.users.include?(self) + group.add(self) + GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(self) + end + end + end + + def email + primary_email.email + end + + def email=(email) + if primary_email + new_record? ? primary_email.email = email : primary_email.update(email: email) + else + build_primary_email(email: email) + end + end + + def recent_time_read + self.created_at && self.created_at < 60.days.ago ? + self.user_visits.where('visited_at >= ?', 60.days.ago).sum(:time_read) : + self.user_stat&.time_read + end + protected def badge_grant @@ -919,7 +994,7 @@ class User < ActiveRecord::Base end def expire_old_email_tokens - if password_hash_changed? && !id_changed? + if saved_change_to_password_hash? && !saved_change_to_id? email_tokens.where('not expired').update_all(expired: true) end end @@ -941,23 +1016,6 @@ class User < ActiveRecord::Base Group.user_trust_level_change!(id, trust_level) end - def automatic_group_membership - user = User.find(self.id) - return unless user && user.active && user.email_confirmed? && !user.staged - - Group.where(automatic: false) - .where("LENGTH(COALESCE(automatic_membership_email_domains, '')) > 0") - .each do |group| - - domains = group.automatic_membership_email_domains.gsub('.', '\.') - - if user.email =~ Regexp.new("@(#{domains})$", true) && !group.users.include?(user) - group.add(user) - GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(user) - end - end - end - def create_user_stat stat = UserStat.new(new_since: Time.now) stat.user_id = id @@ -987,6 +1045,7 @@ class User < ActiveRecord::Base UserAuthToken.where(user_id: id).destroy_all # We should not carry this around after save @raw_password = nil + @password_required = false end end @@ -1005,18 +1064,11 @@ class User < ActiveRecord::Base self.username_lower = username.downcase end - def strip_downcase_email - if self.email - self.email = self.email.strip - self.email = self.email.downcase - end - end - def username_validator username_format_validator || begin lower = username.downcase existing = User.find_by(username_lower: lower) - if username_changed? && existing && existing.id != self.id + if will_save_change_to_username? && existing && existing.id != self.id errors.add(:username, I18n.t(:'user.username.unique')) end end @@ -1051,11 +1103,13 @@ class User < ActiveRecord::Base # Delete unactivated accounts (without verified email) that are over a week old def self.purge_unactivated + return [] if SiteSetting.purge_unactivated_users_grace_period_days <= 0 + to_destroy = User.where(active: false) - .joins('INNER JOIN user_stats AS us ON us.user_id = users.id') - .where("created_at < ?", SiteSetting.purge_unactivated_users_grace_period_days.days.ago) - .where('NOT admin AND NOT moderator') - .limit(200) + .joins('INNER JOIN user_stats AS us ON us.user_id = users.id') + .where("created_at < ?", SiteSetting.purge_unactivated_users_grace_period_days.days.ago) + .where('NOT admin AND NOT moderator') + .limit(200) destroyer = UserDestroyer.new(Discourse.system_user) to_destroy.each do |u| @@ -1085,48 +1139,56 @@ class User < ActiveRecord::Base true end + def set_skip_validate_email + if self.primary_email + self.primary_email.skip_validate_email = !should_validate_email_address? + end + + true + end + end # == Schema Information # # Table name: users # -# id :integer not null, primary key -# username :string(60) not null -# created_at :datetime not null -# updated_at :datetime not null -# name :string -# seen_notification_id :integer default(0), not null -# last_posted_at :datetime -# email :string(513) not null -# password_hash :string(64) -# salt :string(32) -# active :boolean default(FALSE), not null -# username_lower :string(60) not null -# last_seen_at :datetime -# admin :boolean default(FALSE), not null -# last_emailed_at :datetime -# trust_level :integer not null -# approved :boolean default(FALSE), not null -# approved_by_id :integer -# approved_at :datetime -# previous_visit_at :datetime -# suspended_at :datetime -# suspended_till :datetime -# date_of_birth :date -# views :integer default(0), not null -# flag_level :integer default(0), not null -# ip_address :inet -# moderator :boolean default(FALSE) -# blocked :boolean default(FALSE) -# title :string -# uploaded_avatar_id :integer -# locale :string(10) -# primary_group_id :integer -# registration_ip_address :inet -# trust_level_locked :boolean default(FALSE), not null -# staged :boolean default(FALSE), not null -# first_seen_at :datetime +# id :integer not null, primary key +# username :string(60) not null +# created_at :datetime not null +# updated_at :datetime not null +# name :string(255) +# seen_notification_id :integer default(0), not null +# last_posted_at :datetime +# password_hash :string(64) +# salt :string(32) +# active :boolean default(FALSE), not null +# username_lower :string(60) not null +# last_seen_at :datetime +# admin :boolean default(FALSE), not null +# last_emailed_at :datetime +# trust_level :integer not null +# approved :boolean default(FALSE), not null +# approved_by_id :integer +# approved_at :datetime +# previous_visit_at :datetime +# suspended_at :datetime +# suspended_till :datetime +# date_of_birth :date +# views :integer default(0), not null +# flag_level :integer default(0), not null +# ip_address :inet +# moderator :boolean default(FALSE) +# title :string(255) +# uploaded_avatar_id :integer +# primary_group_id :integer +# locale :string(10) +# registration_ip_address :inet +# staged :boolean default(FALSE), not null +# first_seen_at :datetime +# silenced_till :datetime +# group_locked_trust_level :integer +# manual_locked_trust_level :integer # # Indexes # diff --git a/app/models/user_action.rb b/app/models/user_action.rb index 5e3599a381..02123a1d44 100644 --- a/app/models/user_action.rb +++ b/app/models/user_action.rb @@ -11,7 +11,7 @@ class UserAction < ActiveRecord::Base BOOKMARK = 3 NEW_TOPIC = 4 REPLY = 5 - RESPONSE= 6 + RESPONSE = 6 MENTION = 7 QUOTE = 9 EDIT = 11 @@ -75,7 +75,7 @@ SQL apply_common_filters(builder, user_id, guardian) results = builder.exec.to_a - results.sort! { |a,b| ORDER[a.action_type] <=> ORDER[b.action_type] } + results.sort! { |a, b| ORDER[a.action_type] <=> ORDER[b.action_type] } results end @@ -109,10 +109,10 @@ SQL GROUP BY g.name SQL - result = { all: all, mine: mine, unread: unread} + result = { all: all, mine: mine, unread: unread } exec_sql(sql, user_id: user_id).each do |row| - (result[:groups] ||= []) << {name: row["name"], count: row["count"].to_i} + (result[:groups] ||= []) << { name: row["name"], count: row["count"].to_i } end result @@ -123,7 +123,7 @@ SQL stream(action_id: action_id, guardian: guardian).first end - def self.stream_queued(opts=nil) + def self.stream_queued(opts = nil) opts ||= {} offset = opts[:offset] || 0 @@ -156,7 +156,7 @@ SQL .map_exec(UserActionRow) end - def self.stream(opts=nil) + def self.stream(opts = nil) opts ||= {} action_types = opts[:action_types] @@ -167,6 +167,18 @@ SQL offset = opts[:offset] || 0 limit = opts[:limit] || 60 + # Acting user columns. Can be extended by plugins to include custom avatar + # columns + acting_cols = [ + 'u.id AS acting_user_id', + 'u.name AS acting_name' + ] + + AvatarLookup.lookup_columns.each do |c| + next if c == :id || c['.'] + acting_cols << "u.#{c} AS acting_#{c}" + end + # The weird thing is that target_post_id can be null, so it makes everything # ever so more complex. Should we allow this, not sure. builder = SqlBuilder.new <<-SQL @@ -179,8 +191,7 @@ SQL p.reply_to_post_number, pu.username, pu.name, pu.id user_id, pu.uploaded_avatar_id, - u.username acting_username, u.name acting_name, u.id acting_user_id, - u.uploaded_avatar_id acting_uploaded_avatar_id, + #{acting_cols.join(', ')}, coalesce(p.cooked, p2.cooked) cooked, CASE WHEN coalesce(p.deleted_at, p2.deleted_at, t.deleted_at) IS NULL THEN false ELSE true END deleted, p.hidden, @@ -209,6 +220,11 @@ SQL else builder.where("a.user_id = :user_id", user_id: user_id.to_i) builder.where("a.action_type in (:action_types)", action_types: action_types) if action_types && action_types.length > 0 + + unless SiteSetting.enable_mentions? + builder.where("a.action_type <> :mention_type", mention_type: UserAction::MENTION) + end + builder .order_by("a.created_at desc") .offset(offset.to_i) @@ -276,7 +292,7 @@ SQL require_parameters(hash, :action_type, :user_id, :acting_user_id, :target_topic_id, :target_post_id) if action = UserAction.find_by(hash.except(:created_at)) action.destroy - MessageBus.publish("/user/#{hash[:user_id]}", {user_action_id: action.id, remove: true}) + MessageBus.publish("/user/#{hash[:user_id]}", user_action_id: action.id, remove: true) end if !Topic.where(id: hash[:target_topic_id], archetype: Archetype.private_message).exists? @@ -331,7 +347,7 @@ SQL end end - def self.apply_common_filters(builder,user_id,guardian,ignore_private_messages=false) + def self.apply_common_filters(builder, user_id, guardian, ignore_private_messages = false) # We never return deleted topics in activity builder.where("t.deleted_at is null") @@ -341,7 +357,7 @@ SQL current_user_id = -2 current_user_id = guardian.user.id if guardian.user - builder.where("NOT COALESCE(p.hidden, false) OR p.user_id = :current_user_id", current_user_id: current_user_id ) + builder.where("NOT COALESCE(p.hidden, false) OR p.user_id = :current_user_id", current_user_id: current_user_id) end visible_post_types = Topic.visible_post_types(guardian.user) @@ -381,7 +397,7 @@ SQL if allowed.present? builder.where("( c.read_restricted IS NULL OR NOT c.read_restricted OR - (c.read_restricted and c.id in (:cats)) )", cats: guardian.secure_category_ids ) + (c.read_restricted and c.id in (:cats)) )", cats: guardian.secure_category_ids) else builder.where("(c.read_restricted IS NULL OR NOT c.read_restricted)") end @@ -412,9 +428,9 @@ end # # Indexes # -# idx_unique_rows (action_type,user_id,target_topic_id,target_post_id,acting_user_id) UNIQUE -# idx_user_actions_speed_up_user_all (user_id,created_at,action_type) -# index_user_actions_on_acting_user_id (acting_user_id) -# index_user_actions_on_target_post_id (target_post_id) -# index_user_actions_on_user_id_and_action_type (user_id,action_type) +# idx_unique_rows (action_type,user_id,target_topic_id,target_post_id,acting_user_id) UNIQUE +# idx_user_actions_speed_up_user_all (user_id,created_at,action_type) +# index_actions_on_acting_user_id (acting_user_id) +# index_actions_on_user_id_and_action_type (user_id,action_type) +# index_user_actions_on_target_post_id (target_post_id) # diff --git a/app/models/user_api_key.rb b/app/models/user_api_key.rb index be0e85b7f8..39f8d4a80f 100644 --- a/app/models/user_api_key.rb +++ b/app/models/user_api_key.rb @@ -30,7 +30,7 @@ class UserApiKey < ActiveRecord::Base # not a rails route, special handling return true if action == "message_bus" && env["PATH_INFO"] =~ /^\/message-bus\/.*\/poll/ - params = env['action_dispatch.request.path_parameters'] + params = env['action_dispatch.request.path_parameters'] return false unless params diff --git a/app/models/user_archived_message.rb b/app/models/user_archived_message.rb index 1cb45c8165..36c3ccd1ac 100644 --- a/app/models/user_archived_message.rb +++ b/app/models/user_archived_message.rb @@ -11,21 +11,21 @@ class UserArchivedMessage < ActiveRecord::Base UserArchivedMessage.where(user_id: user_id, topic_id: topic_id).destroy_all trigger(:move_to_inbox, user_id, topic_id) - MessageBus.publish("/topic/#{topic_id}", {type: "move_to_inbox"}, user_ids: [user_id]) + MessageBus.publish("/topic/#{topic_id}", { type: "move_to_inbox" }, user_ids: [user_id]) end def self.archive!(user_id, topic_id) UserArchivedMessage.where(user_id: user_id, topic_id: topic_id).destroy_all UserArchivedMessage.create!(user_id: user_id, topic_id: topic_id) trigger(:archive_message, user_id, topic_id) - MessageBus.publish("/topic/#{topic_id}", {type: "archived"}, user_ids: [user_id]) + MessageBus.publish("/topic/#{topic_id}", { type: "archived" }, user_ids: [user_id]) end def self.trigger(event, user_id, topic_id) user = User.find_by(id: user_id) topic = Topic.find_by(id: topic_id) if user && topic - DiscourseEvent.trigger(event, {user: user, topic: topic}) + DiscourseEvent.trigger(event, user: user, topic: topic) end end end diff --git a/app/models/user_auth_token.rb b/app/models/user_auth_token.rb index 7a22ea5931..5597d52367 100644 --- a/app/models/user_auth_token.rb +++ b/app/models/user_auth_token.rb @@ -40,7 +40,7 @@ class UserAuthToken < ActiveRecord::Base user_auth_token end - def self.lookup(unhashed_token, opts=nil) + def self.lookup(unhashed_token, opts = nil) mark_seen = opts && opts[:seen] @@ -123,7 +123,7 @@ class UserAuthToken < ActiveRecord::Base end - def rotate!(info=nil) + def rotate!(info = nil) user_agent = (info && info[:user_agent] || self.user_agent) client_ip = (info && info[:client_ip] || self.client_ip) diff --git a/app/models/user_avatar.rb b/app/models/user_avatar.rb index 0c49693ddb..a247bb4752 100644 --- a/app/models/user_avatar.rb +++ b/app/models/user_avatar.rb @@ -19,13 +19,18 @@ class UserAvatar < ActiveRecord::Base self.last_gravatar_download_attempt = Time.new max = Discourse.avatar_sizes.max - gravatar_url = "http://www.gravatar.com/avatar/#{email_hash}.png?s=#{max}&d=404" + gravatar_url = "https://www.gravatar.com/avatar/#{email_hash}.png?s=#{max}&d=404" + + # follow redirects in case gravatar change rules on us tempfile = FileHelper.download( gravatar_url, max_file_size: SiteSetting.max_image_size_kb.kilobytes, tmp_file_name: "gravatar", - skip_rate_limit: true + skip_rate_limit: true, + verbose: false, + follow_redirect: true ) + if tempfile upload = UploadCreator.new(tempfile, 'gravatar.png', origin: gravatar_url, type: "avatar").create_for(user_id) @@ -67,7 +72,7 @@ class UserAvatar < ActiveRecord::Base "#{upload_id}_#{OptimizedImage::VERSION}" end - def self.import_url_for_user(avatar_url, user, options=nil) + def self.import_url_for_user(avatar_url, user, options = nil) tempfile = FileHelper.download( avatar_url, max_file_size: SiteSetting.max_image_size_kb.kilobytes, diff --git a/app/models/user_badge.rb b/app/models/user_badge.rb index a50bf1be72..71a065e0e0 100644 --- a/app/models/user_badge.rb +++ b/app/models/user_badge.rb @@ -5,7 +5,11 @@ class UserBadge < ActiveRecord::Base belongs_to :notification, dependent: :destroy belongs_to :post - validates :badge_id, presence: true, uniqueness: {scope: :user_id}, if: 'badge.single_grant?' + validates :badge_id, + presence: true, + uniqueness: { scope: :user_id }, + if: :single_grant_badge? + validates :user_id, presence: true validates :granted_at, presence: true validates :granted_by, presence: true @@ -19,6 +23,12 @@ class UserBadge < ActiveRecord::Base Badge.decrement_counter 'grant_count', self.badge_id DiscourseEvent.trigger(:user_badge_removed, self.badge_id, self.user_id) end + + private + + def single_grant_badge? + self.badge.single_grant? + end end # == Schema Information @@ -39,5 +49,4 @@ end # index_user_badges_on_badge_id_and_user_id (badge_id,user_id) # index_user_badges_on_badge_id_and_user_id_and_post_id (badge_id,user_id,post_id) UNIQUE # index_user_badges_on_badge_id_and_user_id_and_seq (badge_id,user_id,seq) UNIQUE -# index_user_badges_on_user_id (user_id) # diff --git a/app/models/user_badges.rb b/app/models/user_badges.rb index 187ee1bc9f..bd68c4c037 100644 --- a/app/models/user_badges.rb +++ b/app/models/user_badges.rb @@ -4,7 +4,7 @@ class UserBadges attr_accessor :user_badges, :username, :grant_count - def initialize(opts={}) + def initialize(opts = {}) @user_badges = opts[:user_badges] @username = opts[:username] @grant_count = opts[:grant_count] diff --git a/app/models/user_email.rb b/app/models/user_email.rb new file mode 100644 index 0000000000..426d2fe828 --- /dev/null +++ b/app/models/user_email.rb @@ -0,0 +1,48 @@ +require_dependency 'email_validator' + +class UserEmail < ActiveRecord::Base + belongs_to :user + + attr_accessor :skip_validate_email + + before_validation :strip_downcase_email + + validates :email, presence: true, uniqueness: true + + validates :email, email: true, format: { with: EmailValidator.email_regex }, + if: :validate_email? + + validates :primary, uniqueness: { scope: [:user_id] } + + private + + def strip_downcase_email + if self.email + self.email = self.email.strip + self.email = self.email.downcase + end + end + + def validate_email? + return false if self.skip_validate_email + email_changed? + end +end + +# == Schema Information +# +# Table name: user_emails +# +# id :integer not null, primary key +# user_id :integer not null +# email :string(513) not null +# primary :boolean default(FALSE), not null +# created_at :datetime +# updated_at :datetime +# +# Indexes +# +# index_user_emails_on_email (lower((email)::text)) UNIQUE +# index_user_emails_on_user_id (user_id) +# index_user_emails_on_user_id_and_primary (user_id,primary) UNIQUE +# diff --git a/app/models/user_export.rb b/app/models/user_export.rb index 71c1143bdf..20269d9b92 100644 --- a/app/models/user_export.rb +++ b/app/models/user_export.rb @@ -26,7 +26,7 @@ end # Table name: user_exports # # id :integer not null, primary key -# file_name :string not null +# file_name :string(255) not null # user_id :integer not null # created_at :datetime # updated_at :datetime diff --git a/app/models/user_field.rb b/app/models/user_field.rb index 6505982b9c..6183f63401 100644 --- a/app/models/user_field.rb +++ b/app/models/user_field.rb @@ -16,12 +16,12 @@ end # Table name: user_fields # # id :integer not null, primary key -# name :string not null -# field_type :string not null +# name :string(255) not null +# field_type :string(255) not null # created_at :datetime # updated_at :datetime # editable :boolean default(FALSE), not null -# description :string not null +# description :string(255) not null # required :boolean default(TRUE), not null # show_on_profile :boolean default(FALSE), not null # position :integer default(0) diff --git a/app/models/user_field_option.rb b/app/models/user_field_option.rb index 56299f3ee9..2b3036fa40 100644 --- a/app/models/user_field_option.rb +++ b/app/models/user_field_option.rb @@ -7,7 +7,7 @@ end # # id :integer not null, primary key # user_field_id :integer not null -# value :string not null +# value :string(255) not null # created_at :datetime # updated_at :datetime # diff --git a/app/models/user_history.rb b/app/models/user_history.rb index e177cd8de4..d2f7f25a94 100644 --- a/app/models/user_history.rb +++ b/app/models/user_history.rb @@ -11,7 +11,7 @@ class UserHistory < ActiveRecord::Base validates_presence_of :action - scope :only_staff_actions, ->{ where("action IN (?)", UserHistory.staff_action_ids) } + scope :only_staff_actions, -> { where("action IN (?)", UserHistory.staff_action_ids) } before_save :set_admin_only @@ -45,8 +45,8 @@ class UserHistory < ActiveRecord::Base delete_category: 27, create_category: 28, change_site_text: 29, - block_user: 30, - unblock_user: 31, + silence_user: 30, + unsilence_user: 31, grant_admin: 32, revoke_admin: 33, grant_moderation: 34, @@ -90,8 +90,8 @@ class UserHistory < ActiveRecord::Base :change_category_settings, :delete_category, :create_category, - :block_user, - :unblock_user, + :silence_user, + :unsilence_user, :grant_admin, :revoke_admin, :grant_moderation, @@ -121,7 +121,7 @@ class UserHistory < ActiveRecord::Base query = query.where(custom_type: filters[:custom_type]) if filters[:custom_type].present? [:acting_user, :target_user].each do |key| - if filters[key] and obj_id = User.where(username_lower: filters[key].downcase).pluck(:id) + if filters[key] && (obj_id = User.where(username_lower: filters[key].downcase).pluck(:id)) query = query.where("#{key}_id = ?", obj_id) end end @@ -133,7 +133,7 @@ class UserHistory < ActiveRecord::Base self.where(target_user_id: user.id, action: UserHistory.actions[action_type]) end - def self.exists_for_user?(user, action_type, opts=nil) + def self.exists_for_user?(user, action_type, opts = nil) opts = opts || {} result = self.where(target_user_id: user.id, action: UserHistory.actions[action_type]) result = result.where(topic_id: opts[:topic_id]) if opts[:topic_id] @@ -144,14 +144,13 @@ class UserHistory < ActiveRecord::Base [:action_id, :custom_type, :acting_user, :target_user, :subject] end - def self.staff_action_records(viewer, opts=nil) + def self.staff_action_records(viewer, opts = nil) opts ||= {} query = self.with_filters(opts.slice(*staff_filters)).only_staff_actions.limit(200).order('id DESC').includes(:acting_user, :target_user) query = query.where(admin_only: false) unless viewer && viewer.admin? query end - def set_admin_only self.admin_only = UserHistory.admin_only_action_ids.include?(self.action) self @@ -177,23 +176,23 @@ end # details :text # created_at :datetime not null # updated_at :datetime not null -# context :string -# ip_address :string -# email :string +# context :string(255) +# ip_address :string(255) +# email :string(255) # subject :text # previous_value :text # new_value :text # topic_id :integer # admin_only :boolean default(FALSE) # post_id :integer -# custom_type :string +# custom_type :string(255) # category_id :integer # # Indexes # +# index_staff_action_logs_on_action_and_id (action,id) +# index_staff_action_logs_on_subject_and_id (subject,id) +# index_staff_action_logs_on_target_user_id_and_id (target_user_id,id) # index_user_histories_on_acting_user_id_and_action_and_id (acting_user_id,action,id) -# index_user_histories_on_action_and_id (action,id) # index_user_histories_on_category_id (category_id) -# index_user_histories_on_subject_and_id (subject,id) -# index_user_histories_on_target_user_id_and_id (target_user_id,id) # diff --git a/app/models/user_open_id.rb b/app/models/user_open_id.rb index 188a04ef15..183f241482 100644 --- a/app/models/user_open_id.rb +++ b/app/models/user_open_id.rb @@ -11,8 +11,8 @@ end # # id :integer not null, primary key # user_id :integer not null -# email :string not null -# url :string not null +# email :string(255) not null +# url :string(255) not null # created_at :datetime not null # updated_at :datetime not null # active :boolean not null diff --git a/app/models/user_option.rb b/app/models/user_option.rb index 551380335d..e882e4ccf3 100644 --- a/app/models/user_option.rb +++ b/app/models/user_option.rb @@ -8,7 +8,7 @@ class UserOption < ActiveRecord::Base def self.ensure_consistency! exec_sql("SELECT u.id FROM users u LEFT JOIN user_options o ON o.user_id = u.id - WHERE o.user_id IS NULL").values.each do |id,_| + WHERE o.user_id IS NULL").values.each do |id, _| UserOption.create(user_id: id.to_i) end end @@ -59,11 +59,6 @@ class UserOption < ActiveRecord::Base super end - def update_tracked_topics - return unless auto_track_topics_after_msecs_changed? - TrackedTopicsUpdater.new(id, auto_track_topics_after_msecs).call - end - def redirected_to_top_yet? last_redirected_to_top_at.present? end @@ -77,7 +72,7 @@ class UserOption < ActiveRecord::Base $redis.expire(key, delay) # delay the update - Jobs.enqueue_in(delay / 2, :update_top_redirection, user_id: self.id, redirected_at: Time.zone.now) + Jobs.enqueue_in(delay / 2, :update_top_redirection, user_id: self.user_id, redirected_at: Time.zone.now) end def should_be_redirected_to_top @@ -92,11 +87,10 @@ class UserOption < ActiveRecord::Base return if user.trust_level > 0 && user.last_seen_at && user.last_seen_at > 1.month.ago # top must be in the top_menu - return unless SiteSetting.top_menu =~ /(^|\|)top(\||$)/i - + return unless SiteSetting.top_menu[/\btop\b/i] # not enough topics - return unless period = SiteSetting.min_redirected_to_top_period(1.days.ago) + return unless period = SiteSetting.min_redirected_to_top_period(1.day.ago) if !user.seen_before? || (user.trust_level == 0 && !redirected_to_top_yet?) update_last_redirected_to_top! @@ -118,18 +112,40 @@ class UserOption < ActiveRecord::Base def treat_as_new_topic_start_date duration = new_topic_duration_minutes || SiteSetting.default_other_new_topic_duration_minutes.to_i - times = [case duration + times = [ + case duration when User::NewTopicDuration::ALWAYS user.created_at when User::NewTopicDuration::LAST_VISIT user.previous_visit_at || user.user_stat.new_since else duration.minutes.ago - end, user.user_stat.new_since, Time.at(SiteSetting.min_new_topics_time).to_datetime] + end, + user.user_stat.new_since, + Time.at(SiteSetting.min_new_topics_time).to_datetime + ] times.max end + def homepage + case homepage_id + when 1 then "latest" + when 2 then "categories" + when 3 then "unread" + when 4 then "new" + when 5 then "top" + else SiteSetting.homepage + end + end + + private + + def update_tracked_topics + return unless saved_change_to_auto_track_topics_after_msecs? + TrackedTopicsUpdater.new(id, auto_track_topics_after_msecs).call + end + end # == Schema Information @@ -159,6 +175,8 @@ end # notification_level_when_replying :integer # theme_key :string # theme_key_seq :integer default(0), not null +# allow_private_messages :boolean default(TRUE), not null +# homepage_id :integer # # Indexes # diff --git a/app/models/user_profile.rb b/app/models/user_profile.rb index a6ceec4858..d10b63f034 100644 --- a/app/models/user_profile.rb +++ b/app/models/user_profile.rb @@ -1,10 +1,8 @@ class UserProfile < ActiveRecord::Base belongs_to :user, inverse_of: :user_profile - WEBSITE_REGEXP = /(^$)|(^(http|https):\/\/[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,10}(([0-9]{1,5})?\/.*)?$)/ix - validates :bio_raw, length: { maximum: 3000 } - validates :website, format: { with: WEBSITE_REGEXP }, allow_blank: true, if: Proc.new { |c| c.new_record? || c.website_changed? } + validates :website, url: true, allow_blank: true, if: Proc.new { |c| c.new_record? || c.website_changed? } validates :user, presence: true before_save :cook after_save :trigger_badges @@ -19,7 +17,7 @@ class UserProfile < ActiveRecord::Base BAKED_VERSION = 1 - def bio_excerpt(length=350, opts={}) + def bio_excerpt(length = 350, opts = {}) excerpt = PrettyText.excerpt(bio_cooked, length, opts).sub(/
    $/, '') return excerpt if excerpt.blank? || (user.has_trust_level?(TrustLevel[1]) && !user.suspended?) PrettyText.strip_links(excerpt) @@ -63,11 +61,11 @@ class UserProfile < ActiveRecord::Base def self.rebake_old(limit) problems = [] UserProfile.where('bio_cooked_version IS NULL OR bio_cooked_version < ?', BAKED_VERSION) - .limit(limit).each do |p| + .limit(limit).each do |p| begin p.rebake! rescue => e - problems << {profile: p, ex: e} + problems << { profile: p, ex: e } end end problems @@ -119,12 +117,12 @@ end # Table name: user_profiles # # user_id :integer not null, primary key -# location :string -# website :string +# location :string(255) +# website :string(255) # bio_raw :text # bio_cooked :text -# profile_background :string(255) # dismissed_banner_key :integer +# profile_background :string(255) # bio_cooked_version :integer # badge_granted_title :boolean default(FALSE) # card_background :string(255) diff --git a/app/models/user_profile_view.rb b/app/models/user_profile_view.rb index efe43cd42e..716b26c07c 100644 --- a/app/models/user_profile_view.rb +++ b/app/models/user_profile_view.rb @@ -3,7 +3,7 @@ class UserProfileView < ActiveRecord::Base belongs_to :user_profile - def self.add(user_profile_id, ip, user_id=nil, at=nil, skip_redis=false) + def self.add(user_profile_id, ip, user_id = nil, at = nil, skip_redis = false) at ||= Time.zone.now redis_key = "user-profile-view:#{user_profile_id}:#{at.to_date}" if user_id @@ -41,7 +41,7 @@ class UserProfileView < ActiveRecord::Base end end - def self.profile_views_by_day(start_date, end_date, group_id=nil) + def self.profile_views_by_day(start_date, end_date, group_id = nil) profile_views = self.where("viewed_at >= ? AND viewed_at < ?", start_date, end_date + 1.day) if group_id profile_views = profile_views.joins("INNER JOIN users ON users.id = user_profile_views.user_id") diff --git a/app/models/user_search.rb b/app/models/user_search.rb index 9b6c02c6ac..8a2013b5b5 100644 --- a/app/models/user_search.rb +++ b/app/models/user_search.rb @@ -3,12 +3,13 @@ require_dependency 'search' class UserSearch - def initialize(term, opts={}) + def initialize(term, opts = {}) @term = term @term_like = "#{term.downcase.gsub("_", "\\_")}%" @topic_id = opts[:topic_id] @topic_allowed_users = opts[:topic_allowed_users] @searching_user = opts[:searching_user] + @include_staged_users = opts[:include_staged_users] || false @limit = opts[:limit] || 20 @group = opts[:group] @guardian = Guardian.new(@searching_user) @@ -16,7 +17,8 @@ class UserSearch end def scoped_users - users = User.where(active: true, staged: false) + users = User.where(active: true) + users = users.where(staged: false) unless @include_staged_users if @group users = users.where('users.id IN ( @@ -34,8 +36,8 @@ class UserSearch if topic.category && topic.category.read_restricted users = users.includes(:secure_categories) - .where("users.admin = TRUE OR categories.id = ?", topic.category.id) - .references(:categories) + .where("users.admin = TRUE OR categories.id = ?", topic.category.id) + .references(:categories) end end @@ -50,9 +52,9 @@ class UserSearch query = Search.ts_query(@term, "simple") users = users.includes(:user_search_data) - .references(:user_search_data) - .where("user_search_data.search_data @@ #{query}") - .order(User.sql_fragment("CASE WHEN username_lower LIKE ? THEN 0 ELSE 1 END ASC", @term_like)) + .references(:user_search_data) + .where("user_search_data.search_data @@ #{query}") + .order(User.sql_fragment("CASE WHEN username_lower LIKE ? THEN 0 ELSE 1 END ASC", @term_like)) else users = users.where("username_lower LIKE :term_like", term_like: @term_like) @@ -68,9 +70,9 @@ class UserSearch # 1. exact username matches if @term.present? scoped_users.where(username_lower: @term.downcase) - .limit(@limit) - .pluck(:id) - .each { |id| users << id } + .limit(@limit) + .pluck(:id) + .each { |id| users << id } end @@ -79,19 +81,19 @@ class UserSearch # 2. in topic if @topic_id filtered_by_term_users.where('users.id IN (SELECT p.user_id FROM posts p WHERE topic_id = ?)', @topic_id) - .order('last_seen_at DESC') - .limit(@limit - users.length) - .pluck(:id) - .each { |id| users << id } + .order('last_seen_at DESC') + .limit(@limit - users.length) + .pluck(:id) + .each { |id| users << id } end return users.to_a if users.length >= @limit # 3. global matches filtered_by_term_users.order('last_seen_at DESC') - .limit(@limit - users.length) - .pluck(:id) - .each { |id| users << id } + .limit(@limit - users.length) + .pluck(:id) + .each { |id| users << id } users.to_a end @@ -103,7 +105,7 @@ class UserSearch User.joins("JOIN (SELECT unnest uid, row_number() OVER () AS rn FROM unnest('{#{ids.join(",")}}'::int[]) ) x on uid = users.id") - .order("rn") + .order("rn") end end diff --git a/app/models/user_search_data.rb b/app/models/user_search_data.rb index fab5337291..2de3be31cb 100644 --- a/app/models/user_search_data.rb +++ b/app/models/user_search_data.rb @@ -1,6 +1,5 @@ class UserSearchData < ActiveRecord::Base - belongs_to :user - validates_presence_of :search_data + include HasSearchData end # == Schema Information @@ -11,6 +10,7 @@ end # search_data :tsvector # raw_data :text # locale :text +# version :integer default(0) # # Indexes # diff --git a/app/models/user_stat.rb b/app/models/user_stat.rb index 50761737e3..8e7b3da43b 100644 --- a/app/models/user_stat.rb +++ b/app/models/user_stat.rb @@ -10,8 +10,8 @@ class UserStat < ActiveRecord::Base def self.reset_bounce_scores UserStat.where("reset_bounce_score_after < now()") - .where("bounce_score > 0") - .update_all(bounce_score: 0) + .where("bounce_score > 0") + .update_all(bounce_score: 0) end # Updates the denormalized view counts for all users @@ -52,17 +52,18 @@ class UserStat < ActiveRecord::Base ", seen_at: last_seen end + # topic_reply_count is a count of posts in other users' topics def update_topic_reply_count self.topic_reply_count = Topic - .where(['id in ( + .where(['id in ( SELECT topic_id FROM posts p JOIN topics t2 ON t2.id = p.topic_id WHERE p.deleted_at IS NULL AND t2.user_id <> p.user_id AND p.user_id = ? )', self.user_id]) - .count + .count end MAX_TIME_READ_DIFF = 100 @@ -71,7 +72,9 @@ class UserStat < ActiveRecord::Base if last_seen = last_seen_cached diff = (Time.now.to_f - last_seen.to_f).round if diff > 0 && diff < MAX_TIME_READ_DIFF - UserStat.where(user_id: id, time_read: time_read).update_all ["time_read = time_read + ?", diff] + update_args = ["time_read = time_read + ?", diff] + UserStat.where(user_id: id, time_read: time_read).update_all(update_args) + UserVisit.where(user_id: id, visited_at: Time.zone.now.to_date).update_all(update_args) end end cache_last_seen(Time.now.to_f) diff --git a/app/models/user_summary.rb b/app/models/user_summary.rb index abfc9b8f51..6eb177382d 100644 --- a/app/models/user_summary.rb +++ b/app/models/user_summary.rb @@ -53,18 +53,18 @@ class UserSummary def most_liked_by_users likers = {} UserAction.joins(:target_topic, :target_post) - .merge(Topic.listable_topics.visible.secured(@guardian)) - .where(user: @user) - .where(action_type: UserAction::WAS_LIKED) - .group(:acting_user_id) - .order('COUNT(*) DESC') - .limit(MAX_SUMMARY_RESULTS) - .pluck('acting_user_id, COUNT(*)') - .each { |l| likers[l[0].to_s] = l[1] } + .merge(Topic.listable_topics.visible.secured(@guardian)) + .where(user: @user) + .where(action_type: UserAction::WAS_LIKED) + .group(:acting_user_id) + .order('COUNT(*) DESC') + .limit(MAX_SUMMARY_RESULTS) + .pluck('acting_user_id, COUNT(*)') + .each { |l| likers[l[0].to_s] = l[1] } User.where(id: likers.keys) - .pluck(:id, :username, :name, :uploaded_avatar_id) - .map do |u| + .pluck(:id, :username, :name, :uploaded_avatar_id) + .map do |u| UserWithCount.new( id: u[0], username: u[1], @@ -78,18 +78,18 @@ class UserSummary def most_liked_users liked_users = {} UserAction.joins(:target_topic, :target_post) - .merge(Topic.listable_topics.visible.secured(@guardian)) - .where(action_type: UserAction::WAS_LIKED) - .where(acting_user_id: @user.id) - .group(:user_id) - .order('COUNT(*) DESC') - .limit(MAX_SUMMARY_RESULTS) - .pluck('user_actions.user_id, COUNT(*)') - .each { |l| liked_users[l[0].to_s] = l[1] } + .merge(Topic.listable_topics.visible.secured(@guardian)) + .where(action_type: UserAction::WAS_LIKED) + .where(acting_user_id: @user.id) + .group(:user_id) + .order('COUNT(*) DESC') + .limit(MAX_SUMMARY_RESULTS) + .pluck('user_actions.user_id, COUNT(*)') + .each { |l| liked_users[l[0].to_s] = l[1] } User.where(id: liked_users.keys) - .pluck(:id, :username, :name, :uploaded_avatar_id) - .map do |u| + .pluck(:id, :username, :name, :uploaded_avatar_id) + .map do |u| UserWithCount.new( id: u[0], username: u[1], @@ -120,8 +120,8 @@ class UserSummary .each { |r| replied_users[r[0].to_s] = r[1] } User.where(id: replied_users.keys) - .pluck(:id, :username, :name, :uploaded_avatar_id) - .map do |u| + .pluck(:id, :username, :name, :uploaded_avatar_id) + .map do |u| UserWithCount.new( id: u[0], username: u[1], @@ -151,9 +151,14 @@ class UserSummary .count end + def recent_time_read + @user.recent_time_read + end + delegate :likes_given, :likes_received, :days_visited, + :topics_entered, :posts_read_count, :topic_count, :post_count, diff --git a/app/models/user_visit.rb b/app/models/user_visit.rb index 5ebe059167..c0f5a46452 100644 --- a/app/models/user_visit.rb +++ b/app/models/user_visit.rb @@ -1,6 +1,6 @@ class UserVisit < ActiveRecord::Base - def self.counts_by_day_query(start_date, end_date, group_id=nil) + def self.counts_by_day_query(start_date, end_date, group_id = nil) result = where('visited_at >= ? and visited_at <= ?', start_date.to_date, end_date.to_date) if group_id @@ -12,11 +12,11 @@ class UserVisit < ActiveRecord::Base end # A count of visits in a date range by day - def self.by_day(start_date, end_date, group_id=nil) + def self.by_day(start_date, end_date, group_id = nil) counts_by_day_query(start_date, end_date, group_id).count end - def self.mobile_by_day(start_date, end_date, group_id=nil) + def self.mobile_by_day(start_date, end_date, group_id = nil) counts_by_day_query(start_date, end_date, group_id).where(mobile: true).count end @@ -43,9 +43,11 @@ end # visited_at :date not null # posts_read :integer default(0) # mobile :boolean default(FALSE) +# time_read :integer default(0), not null # # Indexes # -# index_user_visits_on_user_id_and_visited_at (user_id,visited_at) UNIQUE -# index_user_visits_on_visited_at_and_mobile (visited_at,mobile) +# index_user_visits_on_user_id_and_visited_at (user_id,visited_at) UNIQUE +# index_user_visits_on_user_id_and_visited_at_and_time_read (user_id,visited_at,time_read) +# index_user_visits_on_visited_at_and_mobile (visited_at,mobile) # diff --git a/app/models/watched_word.rb b/app/models/watched_word.rb new file mode 100644 index 0000000000..d9af0071de --- /dev/null +++ b/app/models/watched_word.rb @@ -0,0 +1,68 @@ +require_dependency 'enum' + +class WatchedWord < ActiveRecord::Base + + def self.actions + @actions ||= Enum.new( + block: 1, + censor: 2, + require_approval: 3, + flag: 4 + ) + end + + MAX_WORDS_PER_ACTION = 1000 + + before_validation do + self.word = self.class.normalize_word(self.word) + end + + validates :word, presence: true, uniqueness: true, length: { maximum: 50 } + validates :action, presence: true + validates_each :word do |record, attr, val| + if WatchedWord.where(action: record.action).count >= MAX_WORDS_PER_ACTION + record.errors.add(:word, :too_many) + end + end + + after_save :clear_cache + after_destroy :clear_cache + + scope :by_action, -> { order("action ASC, word ASC") } + + def self.normalize_word(w) + w.strip.downcase.squeeze('*') + end + + def self.create_or_update_word(params) + w = find_or_initialize_by(word: normalize_word(params[:word])) + w.action_key = params[:action_key] if params[:action_key] + w.action = params[:action] if params[:action] + w.save + w + end + + def action_key=(arg) + self.action = self.class.actions[arg.to_sym] + end + + def clear_cache + WordWatcher.clear_cache! + end + +end + +# == Schema Information +# +# Table name: watched_words +# +# id :integer not null, primary key +# word :string not null +# action :integer not null +# created_at :datetime +# updated_at :datetime +# +# Indexes +# +# index_watched_words_on_action_and_word (action,word) UNIQUE +# diff --git a/app/models/web_hook.rb b/app/models/web_hook.rb index 40fed09de6..de0db8bbf9 100644 --- a/app/models/web_hook.rb +++ b/app/models/web_hook.rb @@ -13,6 +13,8 @@ class WebHook < ActiveRecord::Base validates_presence_of :last_delivery_status validates_presence_of :web_hook_event_types, unless: :wildcard_web_hook? + before_save :strip_url + def self.content_types @content_types ||= Enum.new('application/json' => 1, 'application/x-www-form-urlencoded' => 2) @@ -30,8 +32,9 @@ class WebHook < ActiveRecord::Base def self.find_by_type(type) WebHook.where(active: true) - .joins(:web_hook_event_types) - .where("web_hooks.wildcard_web_hook = ? OR web_hook_event_types.name = ?", true, type.to_s) + .joins(:web_hook_event_types) + .where("web_hooks.wildcard_web_hook = ? OR web_hook_event_types.name = ?", true, type.to_s) + .uniq end def self.enqueue_hooks(type, opts = {}) @@ -40,13 +43,17 @@ class WebHook < ActiveRecord::Base end end - def self.enqueue_topic_hooks(event, topic, user=nil) + def self.enqueue_topic_hooks(event, topic, user = nil) WebHook.enqueue_hooks(:topic, topic_id: topic.id, category_id: topic&.category_id, event_name: event.to_s) end - def self.enqueue_post_hooks(event, post, user=nil) + def self.enqueue_post_hooks(event, post, user = nil) WebHook.enqueue_hooks(:post, post_id: post.id, category_id: post&.topic&.category_id, event_name: event.to_s) end + + def strip_url + self.payload_url = (payload_url || "").strip.presence + end end # == Schema Information diff --git a/app/models/web_hook_event.rb b/app/models/web_hook_event.rb index f10b5cde31..dc3e56d28b 100644 --- a/app/models/web_hook_event.rb +++ b/app/models/web_hook_event.rb @@ -5,13 +5,21 @@ class WebHookEvent < ActiveRecord::Base default_scope { order('created_at DESC') } + def self.purge_old + where( + 'created_at < ?', SiteSetting.retain_web_hook_events_period_days.days.ago + ).delete_all + end + def update_web_hook_delivery_status - web_hook.last_delivery_status = case status - when 200..299 - WebHook.last_delivery_statuses[:successful] - else - WebHook.last_delivery_statuses[:failed] - end + web_hook.last_delivery_status = + case status + when 200..299 + WebHook.last_delivery_statuses[:successful] + else + WebHook.last_delivery_statuses[:failed] + end + web_hook.save! end end diff --git a/app/serializers/admin_badge_serializer.rb b/app/serializers/admin_badge_serializer.rb index 535d27d1bd..4f57896d90 100644 --- a/app/serializers/admin_badge_serializer.rb +++ b/app/serializers/admin_badge_serializer.rb @@ -2,6 +2,6 @@ class AdminBadgeSerializer < BadgeSerializer attributes :query, :trigger, :target_posts, :auto_revoke, :show_posts def include_long_description? - true + true end end diff --git a/app/serializers/admin_detailed_user_serializer.rb b/app/serializers/admin_detailed_user_serializer.rb index 60c9ea55f2..8cc9316cb5 100644 --- a/app/serializers/admin_detailed_user_serializer.rb +++ b/app/serializers/admin_detailed_user_serializer.rb @@ -16,7 +16,9 @@ class AdminDetailedUserSerializer < AdminUserSerializer :can_delete_all_posts, :can_be_deleted, :can_be_anonymized, - :suspend_reason, + :full_suspend_reason, + :suspended_till, + :silence_reason, :primary_group_id, :badge_count, :warnings_received_count, @@ -28,6 +30,7 @@ class AdminDetailedUserSerializer < AdminUserSerializer has_one :approved_by, serializer: BasicUserSerializer, embed: :objects has_one :api_key, serializer: ApiKeySerializer, embed: :objects has_one :suspended_by, serializer: BasicUserSerializer, embed: :objects + has_one :silenced_by, serializer: BasicUserSerializer, embed: :objects has_one :tl3_requirements, serializer: TrustLevel3RequirementsSerializer, embed: :objects has_many :groups, embed: :object, serializer: BasicGroupSerializer @@ -71,6 +74,14 @@ class AdminDetailedUserSerializer < AdminUserSerializer object.suspend_record.try(:acting_user) end + def silence_reason + object.silence_reason + end + + def silenced_by + object.silenced_record.try(:acting_user) + end + def include_tl3_requirements? object.has_trust_level?(TrustLevel[2]) end diff --git a/app/serializers/admin_post_serializer.rb b/app/serializers/admin_post_serializer.rb deleted file mode 100644 index a06328d832..0000000000 --- a/app/serializers/admin_post_serializer.rb +++ /dev/null @@ -1,72 +0,0 @@ -class AdminPostSerializer < ApplicationSerializer - - attributes :id, - :created_at, - :post_number, - :name, :username, :avatar_template, - :topic_id, :topic_slug, :topic_title, - :category_id, - :excerpt, - :hidden, - :moderator_action, - :deleted_at, :deleted_by, - :reply_to_post_number, - :action_type - - def name - object.user.name - end - - def include_name? - SiteSetting.enable_names? - end - - def username - object.user.username - end - - def avatar_template - object.user.avatar_template - end - - def topic_slug - topic.slug - end - - def topic_title - topic.title - end - - def category_id - topic.category_id - end - - def moderator_action - object.post_type == Post.types[:moderator_action] || object.post_type == Post.types[:small_action] - end - - def deleted_by - BasicUserSerializer.new(object.deleted_by, root: false).as_json - end - - def include_deleted_by? - object.trashed? - end - - def action_type - object.user_actions.select { |ua| ua.user_id = object.user_id } - .select { |ua| [UserAction::REPLY, UserAction::RESPONSE].include? ua.action_type } - .first.try(:action_type) - end - - private - - # we need this to handle deleted topics which aren't loaded via the .includes(:topic) - # because Rails 4 "unscoped" support is bugged (cf. https://github.com/rails/rails/issues/13775) - def topic - return @topic if @topic - @topic = object.topic || Topic.with_deleted.find(object.topic_id) - @topic - end - -end diff --git a/app/serializers/admin_user_action_serializer.rb b/app/serializers/admin_user_action_serializer.rb new file mode 100644 index 0000000000..dfe2cbc631 --- /dev/null +++ b/app/serializers/admin_user_action_serializer.rb @@ -0,0 +1,92 @@ +require_relative 'post_item_excerpt' + +class AdminUserActionSerializer < ApplicationSerializer + include PostItemExcerpt + + attributes( + :id, + :created_at, + :post_number, + :post_id, + :name, + :username, + :avatar_template, + :topic_id, + :slug, + :title, + :category_id, + :truncated, + :hidden, + :moderator_action, + :deleted, + :deleted_at, + :deleted_by, + :reply_to_post_number, + :action_type + ) + + def post_id + object.id + end + + def deleted + deleted_at.present? + end + + def name + object.user.name + end + + def include_name? + SiteSetting.enable_names? + end + + def username + object.user.username + end + + def avatar_template + object.user.avatar_template + end + + def slug + topic.slug + end + + def title + topic.title + end + + def category_id + topic.category_id + end + + def moderator_action + object.post_type == Post.types[:moderator_action] || object.post_type == Post.types[:small_action] + end + + def deleted_by + BasicUserSerializer.new(object.deleted_by, root: false).as_json + end + + def include_deleted_by? + object.trashed? + end + + def action_type + object.user_actions.select { |ua| ua.user_id = object.user_id } + .select { |ua| [UserAction::REPLY, UserAction::RESPONSE].include? ua.action_type } + .first.try(:action_type) + end + + private + + # we need this to handle deleted topics which aren't loaded via the .includes(:topic) + # because Rails 4 "unscoped" support is bugged (cf. https://github.com/rails/rails/issues/13775) + def topic + return @topic if @topic + @topic = object.topic || Topic.with_deleted.find(object.topic_id) + @topic + end + +end diff --git a/app/serializers/admin_user_list_serializer.rb b/app/serializers/admin_user_list_serializer.rb index ba3953e5bb..e4a95b3efb 100644 --- a/app/serializers/admin_user_list_serializer.rb +++ b/app/serializers/admin_user_list_serializer.rb @@ -12,7 +12,7 @@ class AdminUserListSerializer < BasicUserSerializer :created_at_age, :username_lower, :trust_level, - :trust_level_locked, + :manual_locked_trust_level, :flag_level, :username, :title, @@ -22,7 +22,8 @@ class AdminUserListSerializer < BasicUserSerializer :suspended_at, :suspended_till, :suspended, - :blocked, + :silenced, + :silenced_till, :time_read, :staged @@ -35,15 +36,40 @@ class AdminUserListSerializer < BasicUserSerializer def include_email? # staff members can always see their email - (scope.is_staff? && object.id == scope.user.id) || scope.can_see_emails? + (scope.is_staff? && object.id == scope.user.id) || scope.can_see_emails? || + (scope.is_staff? && object.staged?) end alias_method :include_associated_accounts?, :include_email? + def silenced + object.silenced? + end + + def include_silenced? + object.silenced? + end + + def silenced_till + object.silenced_till + end + + def include_silenced_till? + object.silenced_till? + end + def suspended object.suspended? end + def include_suspended_at? + object.suspended? + end + + def include_suspended_till? + object.suspended? + end + def can_impersonate scope.can_impersonate?(object) end @@ -55,7 +81,7 @@ class AdminUserListSerializer < BasicUserSerializer def last_emailed_age return nil if object.last_emailed_at.blank? - AgeWords.age_words(Time.now - object.last_emailed_at) + Time.now - object.last_emailed_at end def last_seen_at @@ -65,16 +91,16 @@ class AdminUserListSerializer < BasicUserSerializer def last_seen_age return nil if object.last_seen_at.blank? - AgeWords.age_words(Time.now - object.last_seen_at) + Time.now - object.last_seen_at end def time_read return nil if object.user_stat.time_read.blank? - AgeWords.age_words(object.user_stat.time_read) + object.user_stat.time_read end def created_at_age - AgeWords.age_words(Time.now - object.created_at) + Time.now - object.created_at end def can_approve diff --git a/app/serializers/admin_user_serializer.rb b/app/serializers/admin_user_serializer.rb index 0fa30b658e..df7f279f61 100644 --- a/app/serializers/admin_user_serializer.rb +++ b/app/serializers/admin_user_serializer.rb @@ -8,8 +8,7 @@ class AdminUserSerializer < AdminUserListSerializer :can_activate, :can_deactivate, :ip_address, - :registration_ip_address, - :can_send_activation_email + :registration_ip_address has_one :single_sign_on_record, serializer: SingleSignOnRecordSerializer, embed: :objects diff --git a/app/serializers/badge_serializer.rb b/app/serializers/badge_serializer.rb index a726a4dde4..d3bbd87339 100644 --- a/app/serializers/badge_serializer.rb +++ b/app/serializers/badge_serializer.rb @@ -1,10 +1,18 @@ class BadgeSerializer < ApplicationSerializer attributes :id, :name, :description, :grant_count, :allow_title, :multiple_grant, :icon, :image, :listable, :enabled, :badge_grouping_id, - :system, :long_description, :slug + :system, :long_description, :slug, :has_badge has_one :badge_type + def include_has_badge? + object.has_badge + end + + def has_badge + true + end + def system object.system? end diff --git a/app/serializers/basic_group_serializer.rb b/app/serializers/basic_group_serializer.rb index d86d0606fc..a09bce0d3b 100644 --- a/app/serializers/basic_group_serializer.rb +++ b/app/serializers/basic_group_serializer.rb @@ -4,8 +4,9 @@ class BasicGroupSerializer < ApplicationSerializer :name, :display_name, :user_count, - :alias_level, - :visible, + :mentionable_level, + :messageable_level, + :visibility_level, :automatic_membership_email_domains, :automatic_membership_retroactive, :primary_group, @@ -18,10 +19,12 @@ class BasicGroupSerializer < ApplicationSerializer :flair_color, :bio_raw, :bio_cooked, - :public, + :public_admission, + :public_exit, :allow_membership_requests, :full_name, - :default_notification_level + :default_notification_level, + :membership_request_template def include_display_name? object.automatic @@ -37,7 +40,7 @@ class BasicGroupSerializer < ApplicationSerializer staff? end - def include_has_messsages + def include_has_messages staff? end diff --git a/app/serializers/basic_group_user_serializer.rb b/app/serializers/basic_group_user_serializer.rb index fa2015b8c4..3b40272470 100644 --- a/app/serializers/basic_group_user_serializer.rb +++ b/app/serializers/basic_group_user_serializer.rb @@ -1,3 +1,7 @@ class BasicGroupUserSerializer < ApplicationSerializer - attributes :group_id, :user_id, :notification_level + attributes :group_id, :user_id, :notification_level, :owner + + def include_owner? + object.user_id == scope&.user&.id + end end diff --git a/app/serializers/basic_post_serializer.rb b/app/serializers/basic_post_serializer.rb index 70153bca9b..bcc75f776e 100644 --- a/app/serializers/basic_post_serializer.rb +++ b/app/serializers/basic_post_serializer.rb @@ -31,7 +31,7 @@ class BasicPostSerializer < ApplicationSerializer def cooked if cooked_hidden if scope.current_user && object.user_id == scope.current_user.id - I18n.t('flagging.you_must_edit') + I18n.t('flagging.you_must_edit', path: "/my/messages") else I18n.t('flagging.user_must_edit') end diff --git a/app/serializers/basic_user_serializer.rb b/app/serializers/basic_user_serializer.rb index 8880c8dbd7..d14877e6da 100644 --- a/app/serializers/basic_user_serializer.rb +++ b/app/serializers/basic_user_serializer.rb @@ -9,7 +9,7 @@ class BasicUserSerializer < ApplicationSerializer if Hash === object User.avatar_template(user[:username], user[:uploaded_avatar_id]) else - user.try(:avatar_template) + user&.avatar_template end end diff --git a/app/serializers/category_and_topic_lists_serializer.rb b/app/serializers/category_and_topic_lists_serializer.rb index 239bc07981..173b417d46 100644 --- a/app/serializers/category_and_topic_lists_serializer.rb +++ b/app/serializers/category_and_topic_lists_serializer.rb @@ -5,7 +5,7 @@ class CategoryAndTopicListsSerializer < ApplicationSerializer def users users = object.topic_list.topics.map do |t| - t.posters.map{|poster| poster.try(:user)} + t.posters.map { |poster| poster.try(:user) } end users.flatten! users.compact! diff --git a/app/serializers/category_serializer.rb b/app/serializers/category_serializer.rb index beb7ae1d07..3c5f98a97b 100644 --- a/app/serializers/category_serializer.rb +++ b/app/serializers/category_serializer.rb @@ -8,6 +8,7 @@ class CategorySerializer < BasicCategorySerializer :position, :email_in, :email_in_allow_strangers, + :mailinglist_mirror, :suppress_from_homepage, :all_topics_wiki, :can_delete, @@ -35,7 +36,7 @@ class CategorySerializer < BasicCategorySerializer end def available_groups - Group.order(:name).pluck(:name) - group_permissions.map{|g| g[:group_name]} + Group.order(:name).pluck(:name) - group_permissions.map { |g| g[:group_name] } end def can_delete @@ -43,8 +44,8 @@ class CategorySerializer < BasicCategorySerializer end def include_is_special? - [SiteSetting.lounge_category_id, SiteSetting.meta_category_id, SiteSetting.staff_category_id, SiteSetting.uncategorized_category_id] - .include? object.id + [SiteSetting.meta_category_id, SiteSetting.staff_category_id, SiteSetting.uncategorized_category_id] + .include? object.id end def is_special @@ -76,7 +77,7 @@ class CategorySerializer < BasicCategorySerializer end def notification_level - user = scope && scope.user + user = scope && scope.user object.notification_level || (user && CategoryUser.where(user: user, category: object).first.try(:notification_level)) end diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb index 80de2e205c..46fa8fe05c 100644 --- a/app/serializers/current_user_serializer.rb +++ b/app/serializers/current_user_serializer.rb @@ -18,6 +18,7 @@ class CurrentUserSerializer < BasicUserSerializer :external_links_in_new_tab, :dynamic_favicon, :trust_level, + :can_send_private_email_messages, :can_edit, :can_invite_to_forum, :no_password, @@ -35,7 +36,14 @@ class CurrentUserSerializer < BasicUserSerializer :automatically_unpin_topics, :mailing_list_mode, :previous_visit_at, - :seen_notification_id + :seen_notification_id, + :primary_group_id, + :primary_group_name, + :can_create_topic + + def can_create_topic + scope.can_create_topic?(nil) + end def include_site_flagged_posts_count? object.staff? @@ -85,6 +93,10 @@ class CurrentUserSerializer < BasicUserSerializer PostAction.flagged_posts_count end + def can_send_private_email_messages + scope.cand_send_private_messages_to_email? + end + def can_edit true end @@ -128,7 +140,7 @@ class CurrentUserSerializer < BasicUserSerializer end if fields.present? - User.custom_fields_for_ids([object.id], fields)[object.id] + User.custom_fields_for_ids([object.id], fields)[object.id] || {} else {} end @@ -137,7 +149,7 @@ class CurrentUserSerializer < BasicUserSerializer def muted_category_ids @muted_category_ids ||= CategoryUser.where(user_id: object.id, notification_level: TopicUser.notification_levels[:muted]) - .pluck(:category_id) + .pluck(:category_id) end def dismissed_banner_key @@ -168,4 +180,16 @@ class CurrentUserSerializer < BasicUserSerializer object.user_option.mailing_list_mode end + def include_primary_group_id? + object.primary_group_id.present? + end + + def primary_group_name + object.primary_group.name.downcase + end + + def include_primary_group_name? + object.primary_group&.name.present? + end + end diff --git a/app/serializers/detailed_user_badge_serializer.rb b/app/serializers/detailed_user_badge_serializer.rb index 1fa408844d..b32f72b6b8 100644 --- a/app/serializers/detailed_user_badge_serializer.rb +++ b/app/serializers/detailed_user_badge_serializer.rb @@ -10,7 +10,6 @@ class DetailedUserBadgeSerializer < BasicUserBadgeSerializer alias :include_topic_id? :include_post_number? alias :include_topic_title? :include_post_number? - def post_number object.post.post_number if object.post end diff --git a/app/serializers/directory_item_serializer.rb b/app/serializers/directory_item_serializer.rb index e241dc6f6c..753d7a8ad7 100644 --- a/app/serializers/directory_item_serializer.rb +++ b/app/serializers/directory_item_serializer.rb @@ -11,7 +11,7 @@ class DirectoryItemSerializer < ApplicationSerializer end def time_read - AgeWords.age_words(object.user_stat.time_read) + object.user_stat.time_read end def include_time_read? diff --git a/app/serializers/discourse_version_check_serializer.rb b/app/serializers/discourse_version_check_serializer.rb index ef53a64615..e6f38e8698 100644 --- a/app/serializers/discourse_version_check_serializer.rb +++ b/app/serializers/discourse_version_check_serializer.rb @@ -1,5 +1,10 @@ class DiscourseVersionCheckSerializer < ApplicationSerializer - attributes :latest_version, :critical_updates, :installed_version, :installed_sha, :missing_versions_count, :updated_at + attributes :latest_version, + :critical_updates, + :installed_version, + :installed_sha, + :missing_versions_count, + :updated_at self.root = false end diff --git a/app/serializers/flagged_topic_summary_serializer.rb b/app/serializers/flagged_topic_summary_serializer.rb new file mode 100644 index 0000000000..f29c365a46 --- /dev/null +++ b/app/serializers/flagged_topic_summary_serializer.rb @@ -0,0 +1,29 @@ +class FlaggedTopicSummarySerializer < ActiveModel::Serializer + + attributes( + :id, + :flag_counts, + :user_ids, + :last_flag_at + ) + + has_one :topic, serializer: FlaggedTopicSerializer + + def id + topic.id + end + + def flag_counts + object.flag_counts.map do |k, v| + { post_action_type_id: k, count: v, name_key: PostActionType.types[k] } + end + end + + def user_ids + object.user_ids + end + + def last_flag_at + object.last_flag_at + end +end diff --git a/app/serializers/group_post_serializer.rb b/app/serializers/group_post_serializer.rb index 1fb6305e59..f21802cfbd 100644 --- a/app/serializers/group_post_serializer.rb +++ b/app/serializers/group_post_serializer.rb @@ -1,10 +1,15 @@ +require_relative 'post_item_excerpt' + class GroupPostSerializer < ApplicationSerializer + include PostItemExcerpt + attributes :id, - :excerpt, :created_at, :title, :url, - :category + :category_id, + :post_number, + :topic_id has_one :user, serializer: GroupPostUserSerializer, embed: :object has_one :topic, serializer: BasicTopicSerializer, embed: :object @@ -17,7 +22,7 @@ class GroupPostSerializer < ApplicationSerializer SiteSetting.enable_names? end - def category - object.topic.category + def category_id + object.topic.category_id end end diff --git a/app/serializers/group_show_serializer.rb b/app/serializers/group_show_serializer.rb index dc9e0c7649..e97d49e9eb 100644 --- a/app/serializers/group_show_serializer.rb +++ b/app/serializers/group_show_serializer.rb @@ -1,8 +1,8 @@ class GroupShowSerializer < BasicGroupSerializer - attributes :is_group_user, :is_group_owner + attributes :is_group_user, :is_group_owner, :mentionable, :messageable def include_is_group_user? - scope.authenticated? + authenticated? end def is_group_user @@ -10,15 +10,35 @@ class GroupShowSerializer < BasicGroupSerializer end def include_is_group_owner? - scope.authenticated? + authenticated? end def is_group_owner scope.is_admin? || fetch_group_user&.owner end + def include_mentionable? + authenticated? + end + + def include_messageable? + authenticated? + end + + def mentionable + Group.mentionable(scope.user).exists?(id: object.id) + end + + def messageable + Group.messageable(scope.user).exists?(id: object.id) + end + private + def authenticated? + scope.authenticated? + end + def fetch_group_user @group_user ||= object.group_users.find_by(user: scope.user) end diff --git a/app/serializers/grouped_search_result_serializer.rb b/app/serializers/grouped_search_result_serializer.rb index ff216c8a23..b9dcb5b62c 100644 --- a/app/serializers/grouped_search_result_serializer.rb +++ b/app/serializers/grouped_search_result_serializer.rb @@ -2,5 +2,23 @@ class GroupedSearchResultSerializer < ApplicationSerializer has_many :posts, serializer: SearchPostSerializer has_many :users, serializer: SearchResultUserSerializer has_many :categories, serializer: BasicCategorySerializer - attributes :more_posts, :more_users, :more_categories + has_many :tags, serializer: TagSerializer + attributes :more_posts, :more_users, :more_categories, :term, :search_log_id, :more_full_page_results, :can_create_topic + + def search_log_id + object.search_log_id + end + + def include_search_log_id? + search_log_id.present? + end + + def include_tags? + SiteSetting.tagging_enabled + end + + def can_create_topic + scope.can_create?(Topic) + end + end diff --git a/app/serializers/incoming_email_details_serializer.rb b/app/serializers/incoming_email_details_serializer.rb index 2ca27de78a..d147215a39 100644 --- a/app/serializers/incoming_email_details_serializer.rb +++ b/app/serializers/incoming_email_details_serializer.rb @@ -16,7 +16,7 @@ class IncomingEmailDetailsSerializer < ApplicationSerializer EMAIL_RECEIVER_ERROR_PREFIX = "Email::Receiver::".freeze def error - @error_string + @error_string.presence || I18n.t("emails.incoming.unrecognized_error") end def error_description diff --git a/app/serializers/incoming_email_serializer.rb b/app/serializers/incoming_email_serializer.rb index 53916e24cd..006ea9c330 100644 --- a/app/serializers/incoming_email_serializer.rb +++ b/app/serializers/incoming_email_serializer.rb @@ -29,4 +29,8 @@ class IncomingEmailSerializer < ApplicationSerializer object.cc_addresses.split(";") end + def error + @object.error.presence || I18n.t("emails.incoming.unrecognized_error") + end + end diff --git a/app/serializers/invited_user_serializer.rb b/app/serializers/invited_user_serializer.rb index ce3fe90623..3c1cb69d3c 100644 --- a/app/serializers/invited_user_serializer.rb +++ b/app/serializers/invited_user_serializer.rb @@ -10,7 +10,7 @@ class InvitedUserSerializer < BasicUserSerializer attr_accessor :invited_by def time_read - AgeWords.age_words(object.user_stat.time_read) + object.user_stat.time_read end def include_time_read? diff --git a/app/serializers/listable_topic_serializer.rb b/app/serializers/listable_topic_serializer.rb index 13a87235c1..aeecddbeae 100644 --- a/app/serializers/listable_topic_serializer.rb +++ b/app/serializers/listable_topic_serializer.rb @@ -22,10 +22,19 @@ class ListableTopicSerializer < BasicTopicSerializer :is_warning, :notification_level, :bookmarked, - :liked + :liked, + :unicode_title has_one :last_poster, serializer: BasicUserSerializer, embed: :objects + def include_unicode_title? + object.title.match?(/:[\w\-+]+:/) + end + + def unicode_title + Emoji.gsub_emoji_to_unicode(object.title) + end + def highest_post_number (scope.is_staff? && object.highest_staff_post_number) || object.highest_post_number end diff --git a/app/serializers/post_action_type_serializer.rb b/app/serializers/post_action_type_serializer.rb index 4e66641105..718c84a5b7 100644 --- a/app/serializers/post_action_type_serializer.rb +++ b/app/serializers/post_action_type_serializer.rb @@ -2,13 +2,25 @@ require_dependency 'configurable_urls' class PostActionTypeSerializer < ApplicationSerializer - attributes :name_key, :name, :description, :short_description, :long_form, :is_flag, :icon, :id, :is_custom_flag + attributes( + :id, + :name_key, + :name, + :description, + :short_description, + :long_form, + :is_flag, + :is_custom_flag + ) include ConfigurableUrls def is_custom_flag - object.id == PostActionType.types[:notify_user] || - object.id == PostActionType.types[:notify_moderators] + !!PostActionType.custom_types[object.id] + end + + def is_flag + !!PostActionType.flag_types[object.id] end def name @@ -20,17 +32,21 @@ class PostActionTypeSerializer < ApplicationSerializer end def description - i18n('description', {tos_url: tos_path}) + i18n('description', tos_url: tos_path) end def short_description - i18n('short_description', {tos_url: tos_path}) + i18n('short_description', tos_url: tos_path) + end + + def name_key + PostActionType.types[object.id] end protected - def i18n(field, vars=nil) - key = "post_action_types.#{object.name_key}.#{field}" + def i18n(field, vars = nil) + key = "post_action_types.#{name_key}.#{field}" vars ? I18n.t(key, vars) : I18n.t(key) end diff --git a/app/serializers/post_item_excerpt.rb b/app/serializers/post_item_excerpt.rb new file mode 100644 index 0000000000..7f0cc6322b --- /dev/null +++ b/app/serializers/post_item_excerpt.rb @@ -0,0 +1,24 @@ +module PostItemExcerpt + + def self.included(base) + base.attributes(:excerpt, :truncated) + end + + def cooked + @cooked ||= object.cooked || PrettyText.cook(object.raw) + end + + def excerpt + return nil unless cooked + @excerpt ||= PrettyText.excerpt(cooked, 300, keep_emoji_images: true) + end + + def truncated + true + end + + def include_truncated? + cooked.length > 300 + end + +end diff --git a/app/serializers/post_revision_serializer.rb b/app/serializers/post_revision_serializer.rb index 5c1a05f603..11b13677f4 100644 --- a/app/serializers/post_revision_serializer.rb +++ b/app/serializers/post_revision_serializer.rb @@ -27,7 +27,6 @@ class PostRevisionSerializer < ApplicationSerializer :wiki, :can_edit - # Creates a field called field_name_changes with previous and # current members if a field has changed in this revision def self.add_compared_field(field) @@ -59,8 +58,8 @@ class PostRevisionSerializer < ApplicationSerializer def previous_revision @previous_revision ||= revisions.select { |r| r["revision"] >= first_revision } - .select { |r| r["revision"] < current_revision } - .last.try(:[], "revision") + .select { |r| r["revision"] < current_revision } + .last.try(:[], "revision") end def current_revision @@ -69,8 +68,8 @@ class PostRevisionSerializer < ApplicationSerializer def next_revision @next_revision ||= revisions.select { |r| r["revision"] <= last_revision } - .select { |r| r["revision"] > current_revision } - .first.try(:[], "revision") + .select { |r| r["revision"] > current_revision } + .first.try(:[], "revision") end def last_revision diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index ea2eec14b6..d756ee1dd3 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -1,7 +1,7 @@ class PostSerializer < BasicPostSerializer # To pass in additional information we might need - INSTANCE_VARS = [ + INSTANCE_VARS ||= [ :topic_view, :parent_post, :add_raw, @@ -68,7 +68,8 @@ class PostSerializer < BasicPostSerializer :via_email, :is_auto_generated, :action_code, - :action_code_who + :action_code_who, + :last_wiki_edit def initialize(object, opts) super(object, opts) @@ -108,15 +109,15 @@ class PostSerializer < BasicPostSerializer end def moderator? - !!(object.try(:user).try(:moderator?)) + !!(object&.user&.moderator?) end def admin? - !!(object.try(:user).try(:admin?)) + !!(object&.user&.admin?) end def staff? - !!(object.try(:user).try(:staff?)) + !!(object&.user&.staff?) end def yours @@ -140,7 +141,7 @@ class PostSerializer < BasicPostSerializer end def display_username - object.user.try(:name) + object.user&.name end def primary_group_name @@ -154,15 +155,15 @@ class PostSerializer < BasicPostSerializer end def primary_group_flair_url - object.user.try(:primary_group).try(:flair_url) + object.user&.primary_group&.flair_url end def primary_group_flair_bg_color - object.user.try(:primary_group).try(:flair_bg_color) + object.user&.primary_group&.flair_bg_color end def primary_group_flair_color - object.user.try(:primary_group).try(:flair_color) + object.user&.primary_group&.flair_color end def link_counts @@ -189,11 +190,11 @@ class PostSerializer < BasicPostSerializer end def user_title - object.try(:user).try(:title) + object&.user&.title end def trust_level - object.try(:user).try(:trust_level) + object&.user&.trust_level end def reply_to_user @@ -225,14 +226,18 @@ class PostSerializer < BasicPostSerializer # Summary of the actions taken on this post def actions_summary result = [] - PostActionType.types.each do |sym, id| - next if [:bookmark].include?(sym) + can_see_post = scope.can_see_post?(object) + + PostActionType.types.except(:bookmark).each do |sym, id| count_col = "#{sym}_count".to_sym count = object.send(count_col) if object.respond_to?(count_col) summary = { id: id, count: count } summary[:hidden] = true if sym == :vote - summary[:can_act] = true if scope.post_can_act?(object, sym, taken_actions: actions) + + if scope.post_can_act?(object, sym, opts: { taken_actions: actions }, can_see_post: can_see_post) + summary[:can_act] = true + end if sym == :notify_user && scope.current_user.present? && scope.current_user == object.user summary.delete(:can_act) @@ -241,7 +246,7 @@ class PostSerializer < BasicPostSerializer # The following only applies if you're logged in if summary[:can_act] && scope.current_user.present? summary[:can_defer_flags] = true if scope.is_staff? && - PostActionType.flag_types.values.include?(id) && + PostActionType.flag_types_without_custom.values.include?(id) && active_flags.present? && active_flags.has_key?(id) && active_flags[id].count > 0 end @@ -276,7 +281,7 @@ class PostSerializer < BasicPostSerializer end def include_raw? - @add_raw.present? && (!object.hidden || scope.user.try(:staff?) || yours) + @add_raw.present? && (!object.hidden || scope.user&.staff? || yours) end def include_link_counts? @@ -310,9 +315,7 @@ class PostSerializer < BasicPostSerializer end def include_user_custom_fields? - return if @topic_view.blank? - custom_fields = @topic_view.user_custom_fields - custom_fields && custom_fields[object.user_id] + (@topic_view&.user_custom_fields || {})[object.user_id] end def static_doc @@ -328,7 +331,7 @@ class PostSerializer < BasicPostSerializer end def is_auto_generated - object.incoming_email.try(:is_auto_generated) + object.incoming_email&.is_auto_generated end def include_is_auto_generated? @@ -351,23 +354,32 @@ class PostSerializer < BasicPostSerializer include_action_code? && action_code_who.present? end + def last_wiki_edit + object.revisions.last.updated_at + end + + def include_last_wiki_edit? + object.wiki && + object.post_number == 1 && + object.revisions.size > 0 + end + private def post_actions - @post_actions ||= (@topic_view.present? && @topic_view.all_post_actions.present?) ? @topic_view.all_post_actions[object.id] : nil + @post_actions ||= (@topic_view&.all_post_actions || {})[object.id] end def active_flags - @active_flags ||= (@topic_view.present? && @topic_view.all_active_flags.present?) ? @topic_view.all_active_flags[object.id] : nil + @active_flags ||= (@topic_view&.all_active_flags || {})[object.id] end def post_custom_fields - @post_custom_fields ||= - if @topic_view - (@topic_view.post_custom_fields && @topic_view.post_custom_fields[object.id]) || {} - else - object.custom_fields - end + @post_custom_fields ||= if @topic_view + (@topic_view.post_custom_fields || {})[object.id] || {} + else + object.custom_fields + end end end diff --git a/app/serializers/post_stream_serializer_mixin.rb b/app/serializers/post_stream_serializer_mixin.rb index d87f2feaa6..26213f059c 100644 --- a/app/serializers/post_stream_serializer_mixin.rb +++ b/app/serializers/post_stream_serializer_mixin.rb @@ -20,19 +20,17 @@ module PostStreamSerializerMixin end def posts - return @posts if @posts.present? - @posts = [] - if object.posts - object.posts.each do |p| - ps = PostSerializer.new(p, scope: scope, root: false) - ps.add_raw = true if @options[:include_raw] - ps.topic_view = object - p.topic = object.topic + @posts ||= begin + (object.posts || []).map do |post| + post.topic = object.topic - @posts << ps.as_json + serializer = PostSerializer.new(post, scope: scope, root: false) + serializer.add_raw = true if @options[:include_raw] + serializer.topic_view = object + + serializer.as_json end end - @posts end end diff --git a/app/serializers/search_logs_serializer.rb b/app/serializers/search_logs_serializer.rb new file mode 100644 index 0000000000..f059c33cb6 --- /dev/null +++ b/app/serializers/search_logs_serializer.rb @@ -0,0 +1,6 @@ +class SearchLogsSerializer < ApplicationSerializer + attributes :term, + :searches, + :click_through, + :unique +end diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index 5ea12e8cbe..850a8dd8dd 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -25,7 +25,8 @@ class SiteSerializer < ApplicationSerializer :top_tags, :wizard_required, :topic_featured_link_allowed_category_ids, - :user_themes + :user_themes, + :censored_words has_many :categories, serializer: BasicCategorySerializer, embed: :objects has_many :trust_levels, embed: :objects @@ -36,29 +37,30 @@ class SiteSerializer < ApplicationSerializer cache_fragment("user_themes") do Theme.where('key = :default OR user_selectable', default: SiteSetting.default_theme_key) - .order(:name) - .pluck(:key, :name) - .map{|k,n| {theme_key: k, name: n, default: k == SiteSetting.default_theme_key}} - .as_json + .order(:name) + .pluck(:key, :name) + .map { |k, n| { theme_key: k, name: n, default: k == SiteSetting.default_theme_key } } + .as_json end end def groups cache_fragment("group_names") do - Group.order(:name).pluck(:id,:name).map { |id,name| { id: id, name: name } }.as_json + Group.order(:name).pluck(:id, :name).map { |id, name| { id: id, name: name } }.as_json end end def post_action_types cache_fragment("post_action_types_#{I18n.locale}") do - ActiveModel::ArraySerializer.new(PostActionType.ordered).as_json + types = PostActionType.types.values.map { |id| PostActionType.new(id: id) } + ActiveModel::ArraySerializer.new(types).as_json end end def topic_flag_types cache_fragment("post_action_flag_types_#{I18n.locale}") do - flags = PostActionType.ordered.where(name_key: ['inappropriate', 'spam', 'notify_moderators']) - ActiveModel::ArraySerializer.new(flags, each_serializer: TopicFlagTypeSerializer).as_json + types = PostActionType.topic_flag_types.values.map { |id| PostActionType.new(id: id) } + ActiveModel::ArraySerializer.new(types, each_serializer: TopicFlagTypeSerializer).as_json end end @@ -142,4 +144,8 @@ class SiteSerializer < ApplicationSerializer def topic_featured_link_allowed_category_ids scope.topic_featured_link_allowed_category_ids end + + def censored_words + WordWatcher.words_for_action(:censor).join('|') + end end diff --git a/app/serializers/site_text_serializer.rb b/app/serializers/site_text_serializer.rb index c686a08008..f0e3c1e4c3 100644 --- a/app/serializers/site_text_serializer.rb +++ b/app/serializers/site_text_serializer.rb @@ -19,4 +19,3 @@ class SiteTextSerializer < ApplicationSerializer alias_method :can_revert?, :overridden? end - diff --git a/app/serializers/suggested_topics_mixin.rb b/app/serializers/suggested_topics_mixin.rb new file mode 100644 index 0000000000..361a7f62a8 --- /dev/null +++ b/app/serializers/suggested_topics_mixin.rb @@ -0,0 +1,15 @@ +module SuggestedTopicsMixin + def self.included(klass) + klass.attributes :suggested_topics + end + + def include_suggested_topics? + object.next_page.nil? && object.suggested_topics&.topics.present? + end + + def suggested_topics + object.suggested_topics.topics.map do |t| + SuggestedTopicSerializer.new(t, scope: scope, root: false) + end + end +end diff --git a/app/serializers/tag_serializer.rb b/app/serializers/tag_serializer.rb new file mode 100644 index 0000000000..206a0256fa --- /dev/null +++ b/app/serializers/tag_serializer.rb @@ -0,0 +1,3 @@ +class TagSerializer < ApplicationSerializer + attributes :id, :name +end diff --git a/app/serializers/theme_serializer.rb b/app/serializers/theme_serializer.rb index 4f8864158a..78532e399a 100644 --- a/app/serializers/theme_serializer.rb +++ b/app/serializers/theme_serializer.rb @@ -1,3 +1,5 @@ +require 'base64' + class ThemeFieldSerializer < ApplicationSerializer attributes :name, :target, :value, :error, :type_id, :upload_id, :url, :filename @@ -68,3 +70,28 @@ class ThemeSerializer < ChildThemeSerializer object.child_themes.order(:name) end end + +class ThemeFieldWithEmbeddedUploadsSerializer < ThemeFieldSerializer + attributes :raw_upload + + def include_raw_upload? + object.upload + end + + def raw_upload + filename = Discourse.store.path_for(object.upload) + raw = nil + + if filename + raw = File.read(filename) + else + raw = Discourse.store.download(object.upload).read + end + + Base64.encode64(raw) + end +end + +class ThemeWithEmbeddedUploadsSerializer < ThemeSerializer + has_many :theme_fields, serializer: ThemeFieldWithEmbeddedUploadsSerializer, embed: :objects +end diff --git a/app/serializers/topic_flag_type_serializer.rb b/app/serializers/topic_flag_type_serializer.rb index a764d1a820..c0ac6951bd 100644 --- a/app/serializers/topic_flag_type_serializer.rb +++ b/app/serializers/topic_flag_type_serializer.rb @@ -2,9 +2,9 @@ class TopicFlagTypeSerializer < PostActionTypeSerializer protected - def i18n(field, vars=nil) - key = "topic_flag_types.#{object.name_key}.#{field}" - vars ? I18n.t(key,vars) : I18n.t(key) + def i18n(field, vars = nil) + key = "topic_flag_types.#{name_key}.#{field}" + vars ? I18n.t(key, vars) : I18n.t(key) end end diff --git a/app/serializers/topic_link_serializer.rb b/app/serializers/topic_link_serializer.rb index 1f64aec4be..42367857c5 100644 --- a/app/serializers/topic_link_serializer.rb +++ b/app/serializers/topic_link_serializer.rb @@ -8,7 +8,8 @@ class TopicLinkSerializer < ApplicationSerializer :reflection, :clicks, :user_id, - :domain + :domain, + :root_domain, def url object['url'] @@ -50,4 +51,8 @@ class TopicLinkSerializer < ApplicationSerializer object['domain'] end + def root_domain + MiniSuffix.domain(domain) + end + end diff --git a/app/serializers/topic_list_item_serializer.rb b/app/serializers/topic_list_item_serializer.rb index 9e1e642a15..0ce4f4cd50 100644 --- a/app/serializers/topic_list_item_serializer.rb +++ b/app/serializers/topic_list_item_serializer.rb @@ -11,7 +11,8 @@ class TopicListItemSerializer < ListableTopicSerializer :bookmarked_post_numbers, :liked_post_numbers, :tags, - :featured_link + :featured_link, + :featured_link_root_domain has_many :posters, serializer: TopicPosterSerializer, embed: :objects has_many :participants, serializer: TopicPosterSerializer, embed: :objects @@ -77,8 +78,8 @@ class TopicListItemSerializer < ListableTopicSerializer SiteSetting.topic_featured_link_enabled end - def featured_link - object.featured_link + def include_featured_link_root_domain? + SiteSetting.topic_featured_link_enabled && object.featured_link.present? end end diff --git a/app/serializers/topic_view_posts_serializer.rb b/app/serializers/topic_view_posts_serializer.rb index 0bba907794..98c6cabff7 100644 --- a/app/serializers/topic_view_posts_serializer.rb +++ b/app/serializers/topic_view_posts_serializer.rb @@ -1,5 +1,6 @@ class TopicViewPostsSerializer < ApplicationSerializer include PostStreamSerializerMixin + include SuggestedTopicsMixin attributes :id diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index f9a548372e..839a931134 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -3,6 +3,7 @@ require_dependency 'new_post_manager' class TopicViewSerializer < ApplicationSerializer include PostStreamSerializerMixin + include SuggestedTopicsMixin include ApplicationHelper def self.attributes_from_topic(*list) @@ -21,7 +22,6 @@ class TopicViewSerializer < ApplicationSerializer :created_at, :views, :reply_count, - :participant_count, :like_count, :last_posted_at, :visible, @@ -35,17 +35,19 @@ class TopicViewSerializer < ApplicationSerializer :deleted_at, :pending_posts_count, :user_id, - :pm_with_non_human_user? + :pm_with_non_human_user?, + :featured_link, + :featured_link_root_domain, + :pinned_globally, + :pinned_at, + :pinned_until attributes :draft, :draft_key, :draft_sequence, :posted, :unpinned, - :pinned_globally, - :pinned, # Is topic pinned and viewer hasn't cleared the pin? - :pinned_at, # Ignores clear pin - :pinned_until, + :pinned, :details, :highest_post_number, :last_read_post_number, @@ -59,10 +61,11 @@ class TopicViewSerializer < ApplicationSerializer :bookmarked, :message_archived, :tags, - :featured_link, :topic_timer, + :private_topic_timer, :unicode_title, - :message_bus_last_id + :message_bus_last_id, + :participant_count # TODO: Split off into proper object / serializer def details @@ -83,21 +86,16 @@ class TopicViewSerializer < ApplicationSerializer result[:allowed_users] = object.topic.allowed_users.select do |user| !allowed_user_ids.include?(user.id) - end.map do |user| + end.map! do |user| BasicUserSerializer.new(user, scope: scope, root: false) end end if object.post_counts_by_user.present? - result[:participants] = object.post_counts_by_user.map do |pc| - TopicPostCountSerializer.new({user: object.participants[pc[0]], post_count: pc[1]}, scope: scope, root: false) - end - end - - if object.suggested_topics.try(:topics).present? - result[:suggested_topics] = object.suggested_topics.topics.map do |topic| - SuggestedTopicSerializer.new(topic, scope: scope, root: false) + participants = object.post_counts_by_user.reject { |p| object.participants[p].blank? }.map do |pc| + TopicPostCountSerializer.new({ user: object.participants[pc[0]], post_count: pc[1] }, scope: scope, root: false) end + result[:participants] = participants if participants.length > 0 end if object.links.present? @@ -118,11 +116,13 @@ class TopicViewSerializer < ApplicationSerializer result[:can_delete] = true if scope.can_delete?(object.topic) result[:can_recover] = true if scope.can_recover_topic?(object.topic) result[:can_remove_allowed_users] = true if scope.can_remove_allowed_users?(object.topic) + result[:can_remove_self_id] = scope.user.id if scope.can_remove_allowed_users?(object.topic, scope.user) result[:can_invite_to] = true if scope.can_invite_to?(object.topic) result[:can_invite_via_email] = true if scope.can_invite_via_email?(object.topic) result[:can_create_post] = true if scope.can_create?(Post, object.topic) result[:can_reply_as_new_topic] = true if scope.can_reply_as_new_topic?(object.topic) result[:can_flag_topic] = actions_summary.any? { |a| a[:can_act] } + result[:can_convert_topic] = true if scope.can_convert_topic?(object.topic) result end @@ -193,10 +193,6 @@ class TopicViewSerializer < ApplicationSerializer end alias_method :include_posted?, :has_topic_user? - def pinned_globally - object.topic.pinned_globally - end - def pinned PinnedCheck.pinned?(object.topic, object.topic_user) end @@ -205,22 +201,14 @@ class TopicViewSerializer < ApplicationSerializer PinnedCheck.unpinned?(object.topic, object.topic_user) end - def pinned_at - object.topic.pinned_at - end - - def pinned_until - object.topic.pinned_until - end - def actions_summary result = [] - return [] unless post = object.posts.try(:first) + return [] unless post = object.posts&.first PostActionType.topic_flag_types.each do |sym, id| result << { id: id, count: 0, hidden: false, - can_act: scope.post_can_act?(post, sym)} + can_act: scope.post_can_act?(post, sym) } # TODO: other keys? :can_defer_flags, :acted, :can_undo end result @@ -243,7 +231,7 @@ class TopicViewSerializer < ApplicationSerializer end def bookmarked - object.topic_user.try(:bookmarked) + object.topic_user&.bookmarked end def include_pending_posts_count? @@ -255,9 +243,16 @@ class TopicViewSerializer < ApplicationSerializer end def topic_timer - TopicTimerSerializer.new( - object.topic.public_topic_timer, root: false - ) + TopicTimerSerializer.new(object.topic.public_topic_timer, root: false) + end + + def include_private_topic_timer? + scope.user + end + + def private_topic_timer + timer = object.topic.private_topic_timer(scope.user) + TopicTimerSerializer.new(timer, root: false) end def tags @@ -268,22 +263,26 @@ class TopicViewSerializer < ApplicationSerializer SiteSetting.topic_featured_link_enabled end - def featured_link - object.topic.featured_link + def include_featured_link_root_domain? + SiteSetting.topic_featured_link_enabled && object.topic.featured_link end def include_unicode_title? - !!(object.topic.title =~ /:([\w\-+]*):/) + object.topic.title.match?(/:[\w\-+]+:/) end def unicode_title - gsub_emoji_to_unicode(object.topic.title) + Emoji.gsub_emoji_to_unicode(object.topic.title) end def include_pm_with_non_human_user? private_message?(object.topic) end + def participant_count + object.participant_count + end + private def private_message?(topic) diff --git a/app/serializers/upload_serializer.rb b/app/serializers/upload_serializer.rb index 9e0d866248..a02fbef9b0 100644 --- a/app/serializers/upload_serializer.rb +++ b/app/serializers/upload_serializer.rb @@ -1,5 +1,11 @@ class UploadSerializer < ApplicationSerializer - - attributes :id, :url, :original_filename, :filesize, :width, :height - + attributes :id, + :url, + :original_filename, + :filesize, + :width, + :height, + :extension, + :short_url, + :retain_hours end diff --git a/app/serializers/user_action_serializer.rb b/app/serializers/user_action_serializer.rb index 965f4e61e4..c6abea6a5c 100644 --- a/app/serializers/user_action_serializer.rb +++ b/app/serializers/user_action_serializer.rb @@ -1,8 +1,10 @@ +require_relative 'post_item_excerpt' + class UserActionSerializer < ApplicationSerializer + include PostItemExcerpt attributes :action_type, :created_at, - :excerpt, :avatar_template, :acting_avatar_template, :slug, @@ -29,11 +31,6 @@ class UserActionSerializer < ApplicationSerializer :closed, :archived - def excerpt - cooked = object.cooked || PrettyText.cook(object.raw) - PrettyText.excerpt(cooked, 300, keep_emoji_images: true) if cooked - end - def avatar_template User.avatar_template(object.username, object.uploaded_avatar_id) end diff --git a/app/serializers/user_option_serializer.rb b/app/serializers/user_option_serializer.rb index a6a7caeb23..6bccbe8176 100644 --- a/app/serializers/user_option_serializer.rb +++ b/app/serializers/user_option_serializer.rb @@ -20,8 +20,9 @@ class UserOptionSerializer < ApplicationSerializer :like_notification_frequency, :include_tl0_in_digests, :theme_key, - :theme_key_seq - + :theme_key_seq, + :allow_private_messages, + :homepage_id, def auto_track_topics_after_msecs object.auto_track_topics_after_msecs || SiteSetting.default_other_auto_track_topics_after_msecs diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index e7f6073ea3..30bb1038c6 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -66,10 +66,13 @@ class UserSerializer < BasicUserSerializer :topic_post_count, :pending_count, :profile_view_count, + :time_read, + :recent_time_read, :primary_group_name, :primary_group_flair_url, :primary_group_flair_bg_color, - :primary_group_flair_color + :primary_group_flair_color, + :staged has_one :invited_by, embed: :object, serializer: BasicUserSerializer has_many :groups, embed: :object, serializer: BasicGroupSerializer @@ -115,6 +118,7 @@ class UserSerializer < BasicUserSerializer :bio_excerpt, :location, :website, + :website_name, :profile_background, :card_background @@ -124,17 +128,12 @@ class UserSerializer < BasicUserSerializer def mailing_list_posts_per_day val = Post.estimate_posts_per_day - [val,SiteSetting.max_emails_per_day_per_user].min + [val, SiteSetting.max_emails_per_day_per_user].min end def groups - groups = object.groups.order(:id) - - if scope.is_admin? || object.id == scope.user.try(:id) - groups - else - groups.where(visible: true) - end + object.groups.order(:id) + .visible_groups(scope.user) end def group_users @@ -142,20 +141,20 @@ class UserSerializer < BasicUserSerializer end def include_email? - object.id && object.id == scope.user.try(:id) + (object.id && object.id == scope.user.try(:id)) || + (scope.is_staff? && object.staged?) end def can_change_bio !(SiteSetting.enable_sso && SiteSetting.sso_overrides_bio) end - def user_api_keys keys = object.user_api_keys.where(revoked_at: nil).map do |k| { id: k.id, application_name: k.application_name, - scopes: k.scopes.map{|s| I18n.t("user_api_key.scopes.#{s}")}, + scopes: k.scopes.map { |s| I18n.t("user_api_key.scopes.#{s}") }, created_at: k.created_at } end @@ -182,7 +181,7 @@ class UserSerializer < BasicUserSerializer def website_name uri = URI(website.to_s) rescue nil return if uri.nil? || uri.host.nil? - uri.host.sub(/^www\./,'') + uri.path + uri.host.sub(/^www\./, '') + uri.path end def include_website_name @@ -248,15 +247,15 @@ class UserSerializer < BasicUserSerializer end def can_send_private_message_to_user - scope.can_send_private_message?(object) + scope.can_send_private_message?(object) && scope.current_user != object end def bio_excerpt - object.user_profile.bio_excerpt(350 , { keep_newlines: true, keep_emoji_images: true }) + object.user_profile.bio_excerpt(350 , keep_newlines: true, keep_emoji_images: true) end def include_suspend_reason? - object.suspended? + scope.can_see_suspension_reason?(object) && object.suspended? end def include_suspended_till? @@ -369,7 +368,7 @@ class UserSerializer < BasicUserSerializer end def has_title_badges - object.badges.where(allow_title: true).count > 0 + object.badges.where(allow_title: true).exists? end def user_fields @@ -392,7 +391,7 @@ class UserSerializer < BasicUserSerializer end if fields.present? - User.custom_fields_for_ids([object.id], fields)[object.id] + User.custom_fields_for_ids([object.id], fields)[object.id] || {} else {} end @@ -406,4 +405,16 @@ class UserSerializer < BasicUserSerializer object.user_profile.views end + def time_read + object.user_stat&.time_read + end + + def recent_time_read + time = object.recent_time_read + end + + def include_staged? + scope.is_staff? + end + end diff --git a/app/serializers/user_summary_serializer.rb b/app/serializers/user_summary_serializer.rb index dca6dc544d..c641843796 100644 --- a/app/serializers/user_summary_serializer.rb +++ b/app/serializers/user_summary_serializer.rb @@ -1,7 +1,7 @@ class UserSummarySerializer < ApplicationSerializer - class TopicSerializer < ApplicationSerializer - attributes :id, :created_at, :fancy_title, :slug, :like_count + class TopicSerializer < ListableTopicSerializer + attributes :category_id, :like_count end class ReplySerializer < ApplicationSerializer @@ -32,11 +32,13 @@ class UserSummarySerializer < ApplicationSerializer attributes :likes_given, :likes_received, + :topics_entered, :posts_read_count, :days_visited, :topic_count, :post_count, :time_read, + :recent_time_read, :bookmark_count def include_badges? @@ -48,6 +50,10 @@ class UserSummarySerializer < ApplicationSerializer end def time_read - AgeWords.age_words(object.time_read) + object.time_read + end + + def recent_time_read + object.recent_time_read end end diff --git a/app/serializers/watched_word_list_serializer.rb b/app/serializers/watched_word_list_serializer.rb new file mode 100644 index 0000000000..16fba3ea50 --- /dev/null +++ b/app/serializers/watched_word_list_serializer.rb @@ -0,0 +1,19 @@ +class WatchedWordListSerializer < ApplicationSerializer + attributes :actions, :words, :regular_expressions + + def actions + WatchedWord.actions.keys + end + + def words + object.map do |word| + WatchedWordSerializer.new(word, root: false) + end + end + + # No point making this site setting `client: true` when it's only used + # in the admin section + def regular_expressions + SiteSetting.watched_words_regular_expressions? + end +end diff --git a/app/serializers/watched_word_serializer.rb b/app/serializers/watched_word_serializer.rb new file mode 100644 index 0000000000..ffa46da512 --- /dev/null +++ b/app/serializers/watched_word_serializer.rb @@ -0,0 +1,7 @@ +class WatchedWordSerializer < ApplicationSerializer + attributes :id, :word, :action + + def action + WatchedWord.actions[object.action] + end +end diff --git a/app/services/anonymous_shadow_creator.rb b/app/services/anonymous_shadow_creator.rb index 08c1d6a338..7cb27438a6 100644 --- a/app/services/anonymous_shadow_creator.rb +++ b/app/services/anonymous_shadow_creator.rb @@ -17,7 +17,7 @@ class AnonymousShadowCreator if (shadow_id = user.custom_fields["shadow_id"].to_i) > 0 shadow = User.find_by(id: shadow_id) - if shadow && shadow.post_count > 0 && + if shadow && (shadow.post_count + shadow.topic_count) > 0 && shadow.last_posted_at < SiteSetting.anonymous_account_duration_minutes.minutes.ago shadow = nil end @@ -39,7 +39,7 @@ class AnonymousShadowCreator username: username, active: true, trust_level: 1, - trust_level_locked: true, + manual_locked_trust_level: 1, created_at: 1.day.ago # bypass new user restrictions ) diff --git a/app/services/badge_granter.rb b/app/services/badge_granter.rb index 33923050b2..5e88683cff 100644 --- a/app/services/badge_granter.rb +++ b/app/services/badge_granter.rb @@ -1,17 +1,17 @@ class BadgeGranter - def initialize(badge, user, opts={}) + def initialize(badge, user, opts = {}) @badge, @user, @opts = badge, user, opts @granted_by = opts[:granted_by] || Discourse.system_user @post_id = opts[:post_id] end - def self.grant(badge, user, opts={}) + def self.grant(badge, user, opts = {}) BadgeGranter.new(badge, user, opts).grant end def grant - return if @granted_by and !Guardian.new(@granted_by).can_grant_badges?(@user) + return if @granted_by && !Guardian.new(@granted_by).can_grant_badges?(@user) return unless @badge.enabled? find_by = { badge_id: @badge.id, user_id: @user.id } @@ -51,7 +51,8 @@ class BadgeGranter data: { badge_id: @badge.id, badge_name: @badge.display_name, badge_slug: @badge.slug, - username: @user.username}.to_json + badge_title: @badge.allow_title, + username: @user.username }.to_json ) user_badge.update_attributes notification_id: notification.id end @@ -63,7 +64,7 @@ class BadgeGranter user_badge end - def self.revoke(user_badge, options={}) + def self.revoke(user_badge, options = {}) UserBadge.transaction do user_badge.destroy! if options[:revoked_by] @@ -178,7 +179,7 @@ class BadgeGranter # :trigger - the Badge::Trigger id # :explain - return the EXPLAIN query def self.preview(sql, opts = {}) - params = {user_ids: [], post_ids: [], backfill: true} + params = { user_ids: [], post_ids: [], backfill: true } BadgeGranter.contract_checks!(sql, opts) @@ -188,19 +189,19 @@ class BadgeGranter grants_sql = if opts[:target_posts] - "SELECT u.id, u.username, q.post_id, t.title, q.granted_at - FROM(#{sql}) q - JOIN users u on u.id = q.user_id - LEFT JOIN badge_posts p on p.id = q.post_id - LEFT JOIN topics t on t.id = p.topic_id - WHERE :backfill = :backfill - LIMIT 10" + "SELECT u.id, u.username, q.post_id, t.title, q.granted_at + FROM(#{sql}) q + JOIN users u on u.id = q.user_id + LEFT JOIN badge_posts p on p.id = q.post_id + LEFT JOIN topics t on t.id = p.topic_id + WHERE :backfill = :backfill + LIMIT 10" else - "SELECT u.id, u.username, q.granted_at - FROM(#{sql}) q - JOIN users u on u.id = q.user_id - WHERE :backfill = :backfill - LIMIT 10" + "SELECT u.id, u.username, q.granted_at + FROM(#{sql}) q + JOIN users u on u.id = q.user_id + WHERE :backfill = :backfill + LIMIT 10" end query_plan = nil @@ -218,13 +219,13 @@ class BadgeGranter end end - {grant_count: grant_count, sample: sample, query_plan: query_plan} + { grant_count: grant_count, sample: sample, query_plan: query_plan } rescue => e - {errors: e.message} + { errors: e.message } end - MAX_ITEMS_FOR_DELTA = 200 - def self.backfill(badge, opts=nil) + MAX_ITEMS_FOR_DELTA ||= 200 + def self.backfill(badge, opts = nil) return unless SiteSetting.enable_badges return unless badge.enabled return unless badge.query.present? @@ -304,14 +305,15 @@ class BadgeGranter user_ids: user_ids || [-2]).each do |row| # old bronze badges do not matter - next if badge.badge_type_id == BadgeType::Bronze and row.granted_at < 2.days.ago + next if badge.badge_type_id == (BadgeType::Bronze) && row.granted_at < (2.days.ago) # Try to use user locale in the badge notification if possible without too much resources - notification_locale = if SiteSetting.allow_user_locale && row.locale.present? - row.locale - else - SiteSetting.default_locale - end + notification_locale = + if SiteSetting.allow_user_locale && row.locale.present? + row.locale + else + SiteSetting.default_locale + end # Make this variable in this scope notification = nil @@ -326,8 +328,9 @@ class BadgeGranter badge_id: badge.id, badge_name: badge.display_name, badge_slug: badge.slug, + badge_title: badge.allow_title, username: row.username - }.to_json ) + }.to_json) end Badge.exec_sql("UPDATE user_badges SET notification_id = :notification_id WHERE id = :id", diff --git a/app/services/color_scheme_revisor.rb b/app/services/color_scheme_revisor.rb index ce66c37332..0057dedfa5 100644 --- a/app/services/color_scheme_revisor.rb +++ b/app/services/color_scheme_revisor.rb @@ -1,6 +1,6 @@ class ColorSchemeRevisor - def initialize(color_scheme, params={}) + def initialize(color_scheme, params = {}) @color_scheme = color_scheme @params = params end @@ -12,7 +12,7 @@ class ColorSchemeRevisor def revise ColorScheme.transaction do - @color_scheme.name = @params[:name] if @params.has_key?(:name) + @color_scheme.name = @params[:name] if @params.has_key?(:name) @color_scheme.base_scheme_id = @params[:base_scheme_id] if @params.has_key?(:base_scheme_id) has_colors = @params[:colors] @@ -27,7 +27,12 @@ class ColorSchemeRevisor @color_scheme.clear_colors_cache end - @color_scheme.save if has_colors || @color_scheme.name_changed? || @color_scheme.base_scheme_id_changed? + if has_colors || + @color_scheme.saved_change_to_name? || + @color_scheme.saved_change_to_base_scheme_id? + + @color_scheme.save + end end @color_scheme end diff --git a/app/services/group_action_logger.rb b/app/services/group_action_logger.rb index 9f1f429a8f..58674016c3 100644 --- a/app/services/group_action_logger.rb +++ b/app/services/group_action_logger.rb @@ -24,7 +24,7 @@ class GroupActionLogger end def log_add_user_to_group(target_user) - @group.public || can_edit? + (target_user == @acting_user && @group.public_admission) || can_edit? GroupHistory.create!(default_params.merge( action: GroupHistory.actions[:add_user_to_group], @@ -33,7 +33,7 @@ class GroupActionLogger end def log_remove_user_from_group(target_user) - @group.public || can_edit? + (target_user == @acting_user && @group.public_exit) || can_edit? GroupHistory.create!(default_params.merge( action: GroupHistory.actions[:remove_user_from_group], diff --git a/app/services/group_message.rb b/app/services/group_message.rb index 16e18ddbb8..4e5a55f99a 100644 --- a/app/services/group_message.rb +++ b/app/services/group_message.rb @@ -16,11 +16,11 @@ class GroupMessage include Rails.application.routes.url_helpers - def self.create(group_name, message_type, opts={}) + def self.create(group_name, message_type, opts = {}) GroupMessage.new(group_name, message_type, opts).create end - def initialize(group_name, message_type, opts={}) + def initialize(group_name, message_type, opts = {}) @group_name = group_name @message_type = message_type @opts = opts @@ -47,10 +47,8 @@ class GroupMessage @message_params ||= begin h = { base_url: Discourse.base_url }.merge(@opts[:message_params] || {}) if @opts[:user] - h.merge!({ - username: @opts[:user].username, - user_url: user_path(@opts[:user].username) - }) + h.merge!(username: @opts[:user].username, + user_url: user_path(@opts[:user].username)) end h end diff --git a/app/services/handle_chunk_upload.rb b/app/services/handle_chunk_upload.rb index c71a656dad..de5bf7837d 100644 --- a/app/services/handle_chunk_upload.rb +++ b/app/services/handle_chunk_upload.rb @@ -1,6 +1,6 @@ class HandleChunkUpload - def initialize(chunk, params={}) + def initialize(chunk, params = {}) @chunk = chunk @params = params end diff --git a/app/services/notification_emailer.rb b/app/services/notification_emailer.rb index 534d4037a1..9b593f3a17 100644 --- a/app/services/notification_emailer.rb +++ b/app/services/notification_emailer.rb @@ -66,12 +66,12 @@ class NotificationEmailer EMAILABLE_POST_TYPES ||= Set.new [Post.types[:regular], Post.types[:whisper]] - def enqueue(type, delay=default_delay) + def enqueue(type, delay = default_delay) return unless notification.user.user_option.email_direct? perform_enqueue(type, delay) end - def enqueue_private(type, delay=private_delay) + def enqueue_private(type, delay = private_delay) return unless notification.user.user_option.email_private_messages? perform_enqueue(type, delay) end @@ -79,7 +79,7 @@ class NotificationEmailer def perform_enqueue(type, delay) user = notification.user return unless user.active? || user.staged? - return if SiteSetting.must_approve_users? && !user.approved? + return if SiteSetting.must_approve_users? && !user.approved? && !user.staged? return unless EMAILABLE_POST_TYPES.include?(post_type) diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 9672512e1b..63e33aa67b 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -1,4 +1,5 @@ require_dependency 'distributed_mutex' +require_dependency 'user_action_creator' class PostAlerter def self.post_created(post, opts = {}) @@ -37,6 +38,10 @@ class PostAlerter allowed_group_users(post) end + def notify_about_reply?(post) + post.post_type == Post.types[:regular] || post.post_type == Post.types[:whisper] + end + def after_save_post(post, new_record = false) notified = [post.user] @@ -48,11 +53,11 @@ class PostAlerter if post.last_editor_id != post.user_id # Mention comes from an edit by someone else, so notification should say who added the mention. editor = post.last_editor - mentioned_opts = {user_id: editor.id, original_username: editor.username, display_username: editor.username} + mentioned_opts = { user_id: editor.id, original_username: editor.username, display_username: editor.username } end expand_group_mentions(mentioned_groups, post) do |group, users| - notify_non_pm_users(users - notified, :group_mentioned, post, mentioned_opts.merge({group: group})) + notify_non_pm_users(users - notified, :group_mentioned, post, mentioned_opts.merge(group: group)) notified += users end @@ -65,7 +70,7 @@ class PostAlerter # replies reply_to_user = post.reply_notification_target - if new_record && reply_to_user && !notified.include?(reply_to_user) && post.post_type == Post.types[:regular] + if new_record && reply_to_user && !notified.include?(reply_to_user) && notify_about_reply?(post) notify_non_pm_users(reply_to_user, :replied, post) notified += [reply_to_user] end @@ -84,14 +89,18 @@ class PostAlerter if new_record if post.topic.private_message? # users that aren't part of any mentioned groups - directly_targeted_users(post).each do |user| + users = directly_targeted_users(post) + DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) + users.each do |user| notification_level = TopicUser.get(post.topic, user).try(:notification_level) - if notified.include?(user) || notification_level == TopicUser.notification_levels[:watching] + if notified.include?(user) || notification_level == TopicUser.notification_levels[:watching] || user.staged? create_notification(user, Notification.types[:private_message], post) end end # users that are part of all mentionned groups - indirectly_targeted_users(post).each do |user| + users = indirectly_targeted_users(post) + DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) + users.each do |user| # only create a notification when watching the group notification_level = TopicUser.get(post.topic, user).try(:notification_level) @@ -118,17 +127,17 @@ class PostAlerter if topic.present? cat_watchers = topic.category_users - .where(notification_level: CategoryUser.notification_levels[:watching_first_post]) - .pluck(:user_id) + .where(notification_level: CategoryUser.notification_levels[:watching_first_post]) + .pluck(:user_id) tag_watchers = topic.tag_users - .where(notification_level: TagUser.notification_levels[:watching_first_post]) - .pluck(:user_id) + .where(notification_level: TagUser.notification_levels[:watching_first_post]) + .pluck(:user_id) group_ids = topic.allowed_groups.pluck(:group_id) group_watchers = GroupUser.where(group_id: group_ids, notification_level: GroupUser.notification_levels[:watching_first_post]) - .pluck(:user_id) + .pluck(:user_id) watchers = [cat_watchers, tag_watchers, group_watchers].flatten @@ -145,7 +154,9 @@ class PostAlerter # Don't notify the OP user_ids -= [post.user_id] - User.where(id: user_ids).each do |u| + users = User.where(id: user_ids) + DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) + users.each do |u| create_notification(u, Notification.types[:watching_first_post], post) end end @@ -161,17 +172,17 @@ class PostAlerter def unread_posts(user, topic) Post.secured(Guardian.new(user)) - .where('post_number > COALESCE(( + .where('post_number > COALESCE(( SELECT last_read_post_number FROM topic_users tu WHERE tu.user_id = ? AND tu.topic_id = ? ),0)', user.id, topic.id) - .where('reply_to_user_id = ? OR exists( + .where('reply_to_user_id = ? OR exists( SELECT 1 from topic_users tu WHERE tu.user_id = ? AND tu.topic_id = ? AND notification_level = ? )', user.id, user.id, topic.id, TopicUser.notification_levels[:watching]) - .where(topic_id: topic.id) + .where(topic_id: topic.id) end def first_unread_post(user, topic) @@ -192,7 +203,7 @@ class PostAlerter user.reload end - NOTIFIABLE_TYPES = [:mentioned, :replied, :quoted, :posted, :linked, :private_message, :group_mentioned].map{ |t| + NOTIFIABLE_TYPES = [:mentioned, :replied, :quoted, :posted, :linked, :private_message, :group_mentioned].map { |t| Notification.types[t] } @@ -211,18 +222,18 @@ class PostAlerter end end - def notify_group_summary(user,post) + def notify_group_summary(user, post) @group_stats ||= {} stats = (@group_stats[post.topic_id] ||= group_stats(post.topic)) return unless stats group_id = post.topic - .topic_allowed_groups - .where(group_id: user.groups.pluck(:id)) - .pluck(:group_id).first + .topic_allowed_groups + .where(group_id: user.groups.pluck(:id)) + .pluck(:group_id).first - stat = stats.find{|s| s[:group_id] == group_id} + stat = stats.find { |s| s[:group_id] == group_id } return unless stat && stat[:inbox_count] > 0 notification_type = Notification.types[:group_message_summary] @@ -277,6 +288,8 @@ class PostAlerter def create_notification(user, type, post, opts = {}) opts = @default_opts.merge(opts) + DiscourseEvent.trigger(:before_create_notification, user, type, post, opts) + return if user.blank? return if user.id < 0 @@ -285,13 +298,15 @@ class PostAlerter # Make sure the user can see the post return unless Guardian.new(user).can_see?(post) + return if user.staged? && post.topic.category&.mailinglist_mirror? + notifier_id = opts[:user_id] || post.user_id # xxxxx look at revision history # apply muting here return if notifier_id && MutedUser.where(user_id: user.id, muted_user_id: notifier_id) - .joins(:muted_user) - .where('NOT admin AND NOT moderator') - .exists? + .joins(:muted_user) + .where('NOT admin AND NOT moderator') + .exists? # skip if muted on the topic return if TopicUser.where( @@ -311,10 +326,10 @@ class PostAlerter # Don't notify the same user about the same notification on the same post existing_notification = user.notifications - .order("notifications.id DESC") - .find_by(topic_id: post.topic_id, - post_number: post.post_number, - notification_type: type) + .order("notifications.id DESC") + .find_by(topic_id: post.topic_id, + post_number: post.post_number, + notification_type: type) return if existing_notification && !should_notify_previous?(user, existing_notification, opts) @@ -368,40 +383,45 @@ class PostAlerter end end - notification_data.merge!({ - topic_title: topic_title, - original_post_id: original_post.id, - original_post_type: original_post.post_type, - original_username: original_username, - display_username: opts[:display_username] || post.user.username - }) + notification_data.merge!(topic_title: topic_title, + original_post_id: original_post.id, + original_post_type: original_post.post_type, + original_username: original_username, + display_username: opts[:display_username] || post.user.username) if group = opts[:group] notification_data[:group_id] = group.id notification_data[:group_name] = group.name end + if original_post.via_email && (incoming_email = original_post.incoming_email) + skip_send_email = contains_email_address?(incoming_email.to_addresses, user) || + contains_email_address?(incoming_email.cc_addresses, user) + else + skip_send_email = opts[:skip_send_email] + end + # Create the notification user.notifications.create(notification_type: type, topic_id: post.topic_id, post_number: post.post_number, post_action_id: opts[:post_action_id], data: notification_data.to_json, - skip_send_email: opts[:skip_send_email]) + skip_send_email: skip_send_email) if !existing_notification && NOTIFIABLE_TYPES.include?(type) && !user.suspended? # we may have an invalid post somehow, dont blow up post_url = original_post.url rescue nil if post_url - payload = { - notification_type: type, - post_number: original_post.post_number, - topic_title: original_post.topic.title, - topic_id: original_post.topic.id, - excerpt: original_post.excerpt(400, text_entities: true, strip_links: true, remap_emoji: true), - username: original_username, - post_url: post_url - } + payload = { + notification_type: type, + post_number: original_post.post_number, + topic_title: original_post.topic.title, + topic_id: original_post.topic.id, + excerpt: original_post.excerpt(400, text_entities: true, strip_links: true, remap_emoji: true), + username: original_username, + post_url: post_url + } MessageBus.publish("/notification-alert/#{user.id}", payload, user_ids: [user.id]) push_notification(user, payload) @@ -411,12 +431,17 @@ class PostAlerter end + def contains_email_address?(addresses, user) + return false if addresses.blank? + addresses.split(";").include?(user.email) + end + def push_notification(user, payload) if SiteSetting.allow_user_api_key_scopes.split("|").include?("push") && SiteSetting.allowed_user_api_push_urls.present? clients = user.user_api_keys - .where("('push' = ANY(scopes) OR 'notifications' = ANY(scopes)) AND push_url IS NOT NULL AND position(push_url in ?) > 0 AND revoked_at IS NULL", + .where("('push' = ANY(scopes) OR 'notifications' = ANY(scopes)) AND push_url IS NOT NULL AND position(push_url in ?) > 0 AND revoked_at IS NULL", SiteSetting.allowed_user_api_push_urls) - .pluck(:client_id, :push_url) + .pluck(:client_id, :push_url) if clients.length > 0 Jobs.enqueue(:push_notification, clients: clients, payload: payload, user_id: user.id) @@ -450,7 +475,6 @@ class PostAlerter [groups, users] end - # TODO: Move to post-analyzer? # Returns a list of users who were quoted in the post def extract_quoted_users(post) @@ -463,7 +487,7 @@ class PostAlerter post.topic_links.where(reflection: false).map do |link| linked_post = link.link_post if !linked_post && topic = link.link_topic - linked_post = topic.posts(post_number: 1).first + linked_post = topic.posts.find_by(post_number: 1) end (linked_post && post.user_id != linked_post.user_id && linked_post.user) || nil end.compact @@ -476,6 +500,7 @@ class PostAlerter users = [users] unless users.is_a?(Array) + DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) users.each do |u| create_notification(u, Notification.types[type], post, opts) end @@ -521,6 +546,7 @@ SQL exclude_user_ids = notified.map(&:id) notify = notify.where("id NOT IN (?)", exclude_user_ids) if exclude_user_ids.present? + DiscourseEvent.trigger(:before_create_notifications_for_users, notify, post) notify.each do |user| create_notification(user, Notification.types[:posted], post) end diff --git a/app/services/post_owner_changer.rb b/app/services/post_owner_changer.rb index 2a9c71b21e..f419289faa 100644 --- a/app/services/post_owner_changer.rb +++ b/app/services/post_owner_changer.rb @@ -22,10 +22,15 @@ class PostOwnerChanger end post.topic = @topic post.set_owner(@new_owner, @acting_user, @skip_revision) + PostAction.remove_act(@new_owner, post, PostActionType.types[:like]) end @topic.update_statistics - @new_owner.user_stat.update(first_post_created_at: @new_owner.posts(true).order('created_at ASC').first.try(:created_at)) + + @new_owner.user_stat.update( + first_post_created_at: @new_owner.reload.posts.order('created_at ASC').first&.created_at + ) + @topic.save! end end diff --git a/app/services/random_topic_selector.rb b/app/services/random_topic_selector.rb index 9446c4fcbb..4aaa7d3343 100644 --- a/app/services/random_topic_selector.rb +++ b/app/services/random_topic_selector.rb @@ -3,8 +3,8 @@ class RandomTopicSelector BACKFILL_SIZE = 3000 BACKFILL_LOW_WATER_MARK = 500 - def self.backfill(category=nil) - exclude = category.try(:topic_id) + def self.backfill(category = nil) + exclude = category&.topic_id # don't leak private categories into the "everything" group user = category ? CategoryFeaturedTopic.fake_admin : nil @@ -20,24 +20,26 @@ class RandomTopicSelector query = TopicQuery.new(user, options) - results = query.latest_results.order('RANDOM()') - .where(closed: false, archived: false) - .where("topics.created_at > ?", SiteSetting.suggested_topics_max_days_old.days.ago) - .limit(BACKFILL_SIZE) - .reorder('RANDOM()') - .pluck(:id) + .where(closed: false, archived: false) + .where("topics.created_at > ?", SiteSetting.suggested_topics_max_days_old.days.ago) + .limit(BACKFILL_SIZE) + .reorder('RANDOM()') + .pluck(:id) key = cache_key(category) - results.each do |id| - $redis.rpush(key, id) + + if results.present? + $redis.multi do + $redis.rpush(key, results) + $redis.expire(key, 2.days) + end end - $redis.expire(key, 2.days) results end - def self.next(count, category=nil) + def self.next(count, category = nil) key = cache_key(category) results = [] @@ -45,12 +47,12 @@ class RandomTopicSelector return results if count < 1 results = $redis.multi do - $redis.lrange(key, 0, count-1) + $redis.lrange(key, 0, count - 1) $redis.ltrim(key, count, -1) end if !results.is_a?(Array) # Redis is in readonly mode - results = $redis.lrange(key, 0, count-1) + results = $redis.lrange(key, 0, count - 1) else results = results[0] end @@ -77,8 +79,8 @@ class RandomTopicSelector results end - def self.cache_key(category=nil) - "random_topic_cache_#{category.try(:id)}" + def self.cache_key(category = nil) + "random_topic_cache_#{category&.id}" end end diff --git a/app/services/search_indexer.rb b/app/services/search_indexer.rb index 0847826567..fdf6b50669 100644 --- a/app/services/search_indexer.rb +++ b/app/services/search_indexer.rb @@ -15,13 +15,14 @@ class SearchIndexer end def self.update_index(table, id, raw_data) - raw_data = Search.prepare_data(raw_data) + raw_data = Search.prepare_data(raw_data, :index) table_name = "#{table}_search_data" foreign_key = "#{table}_id" # insert some extra words for I.am.a.word so "word" is tokenized - search_data = raw_data.gsub(/\p{L}*\.\p{L}*/) do |with_dot| + # I.am.a.word becomes I.am.a.word am a word + search_data = raw_data.gsub(/[^[:space:]]*[\.]+[^[:space:]]*/) do |with_dot| split = with_dot.split(".") if split.length > 1 with_dot + (" " << split[1..-1].join(" ")) @@ -31,7 +32,7 @@ class SearchIndexer end # for user login and name use "simple" lowercase stemmer - stemmer = table == "user" ? "simple" : Search.long_locale + stemmer = table == "user" ? "simple" : Search.ts_config # Would be nice to use AR here but not sure how to execut Postgres functions # when inserting data like this. @@ -39,20 +40,23 @@ class SearchIndexer SET raw_data = :raw_data, locale = :locale, - search_data = TO_TSVECTOR('#{stemmer}', :search_data) + search_data = TO_TSVECTOR('#{stemmer}', :search_data), + version = :version WHERE #{foreign_key} = :id", raw_data: raw_data, search_data: search_data, id: id, - locale: SiteSetting.default_locale) + locale: SiteSetting.default_locale, + version: Search::INDEX_VERSION) if rows == 0 Post.exec_sql("INSERT INTO #{table_name} - (#{foreign_key}, search_data, locale, raw_data) - VALUES (:id, TO_TSVECTOR('#{stemmer}', :search_data), :locale, :raw_data)", + (#{foreign_key}, search_data, locale, raw_data, version) + VALUES (:id, TO_TSVECTOR('#{stemmer}', :search_data), :locale, :raw_data, :version)", raw_data: raw_data, search_data: search_data, id: id, - locale: SiteSetting.default_locale) + locale: SiteSetting.default_locale, + version: Search::INDEX_VERSION) end rescue # don't allow concurrency to mess up saving a post @@ -78,10 +82,14 @@ class SearchIndexer update_index('category', category_id, name) end - def self.index(obj) + def self.update_tags_index(tag_id, name) + update_index('tag', tag_id, name) + end + + def self.index(obj, force: false) return if @disabled - if obj.class == Post && obj.cooked_changed? + if obj.class == Post && (obj.saved_change_to_cooked? || force) if obj.topic category_name = obj.topic.category.name if obj.topic.category SearchIndexer.update_posts_index(obj.id, obj.cooked, obj.topic.title, category_name) @@ -90,11 +98,12 @@ class SearchIndexer Rails.logger.warn("Orphan post skipped in search_indexer, topic_id: #{obj.topic_id} post_id: #{obj.id} raw: #{obj.raw}") end end - if obj.class == User && (obj.username_changed? || obj.name_changed?) + + if obj.class == User && (obj.saved_change_to_username? || obj.saved_change_to_name? || force) SearchIndexer.update_users_index(obj.id, obj.username_lower || '', obj.name ? obj.name.downcase : '') end - if obj.class == Topic && obj.title_changed? + if obj.class == Topic && (obj.saved_change_to_title? || force) if obj.posts post = obj.posts.find_by(post_number: 1) if post @@ -105,11 +114,14 @@ class SearchIndexer end end - if obj.class == Category && obj.name_changed? + if obj.class == Category && (obj.saved_change_to_name? || force) SearchIndexer.update_categories_index(obj.id, obj.name) end - end + if obj.class == Tag && (obj.saved_change_to_name? || force) + SearchIndexer.update_tags_index(obj.id, obj.name) + end + end class HtmlScrubber < Nokogiri::XML::SAX::Document attr_reader :scrubbed @@ -130,7 +142,7 @@ class SearchIndexer me.scrubbed end - def start_element(name, attributes=[]) + def start_element(name, attributes = []) attributes = Hash[*attributes.flatten] if attributes["alt"] scrubbed << " " @@ -151,4 +163,3 @@ class SearchIndexer end end end - diff --git a/app/services/spam_rule/auto_block.rb b/app/services/spam_rule/auto_block.rb deleted file mode 100644 index a44ac9a115..0000000000 --- a/app/services/spam_rule/auto_block.rb +++ /dev/null @@ -1,80 +0,0 @@ -class SpamRule::AutoBlock - - def initialize(user) - @user = user - end - - def self.block?(user) - self.new(user).block? - end - - def self.punish!(user) - self.new(user).block_user - end - - def perform - block_user if block? - end - - def block? - return true if @user.blocked? - return false if @user.staged? - return false if @user.has_trust_level?(TrustLevel[1]) - - if SiteSetting.num_spam_flags_to_block_new_user > 0 and - SiteSetting.num_users_to_block_new_user > 0 and - num_spam_flags_against_user >= SiteSetting.num_spam_flags_to_block_new_user and - num_users_who_flagged_spam_against_user >= SiteSetting.num_users_to_block_new_user - return true - end - - if SiteSetting.num_tl3_flags_to_block_new_user > 0 and - SiteSetting.num_tl3_users_to_block_new_user > 0 and - num_tl3_flags_against_user >= SiteSetting.num_tl3_flags_to_block_new_user and - num_tl3_users_who_flagged >= SiteSetting.num_tl3_users_to_block_new_user - return true - end - - false - end - - def num_spam_flags_against_user - Post.where(user_id: @user.id).sum(:spam_count) - end - - def num_users_who_flagged_spam_against_user - post_ids = Post.where('user_id = ? and spam_count > 0', @user.id).pluck(:id) - return 0 if post_ids.empty? - PostAction.spam_flags.where(post_id: post_ids).uniq.pluck(:user_id).size - end - - def num_tl3_flags_against_user - if flagged_post_ids.empty? - 0 - else - PostAction.where(post_id: flagged_post_ids).joins(:user).where('users.trust_level >= ?', 3).count - end - end - - def num_tl3_users_who_flagged - if flagged_post_ids.empty? - 0 - else - PostAction.where(post_id: flagged_post_ids).joins(:user).where('users.trust_level >= ?', 3).pluck(:user_id).uniq.size - end - end - - def flagged_post_ids - Post.where(user_id: @user.id) - .where('spam_count > ? OR off_topic_count > ? OR inappropriate_count > ?', 0, 0, 0) - .pluck(:id) - end - - def block_user - Post.transaction do - if UserBlocker.block(@user, Discourse.system_user, message: :too_many_spam_flags) && SiteSetting.notify_mods_when_user_blocked - GroupMessage.create(Group[:moderators].name, :user_automatically_blocked, {user: @user, limit_once_per: false}) - end - end - end -end diff --git a/app/services/spam_rule/auto_silence.rb b/app/services/spam_rule/auto_silence.rb new file mode 100644 index 0000000000..ac5173ef2c --- /dev/null +++ b/app/services/spam_rule/auto_silence.rb @@ -0,0 +1,80 @@ +class SpamRule::AutoSilence + + def initialize(user) + @user = user + end + + def self.silence?(user) + self.new(user).silence? + end + + def self.punish!(user) + self.new(user).silence_user + end + + def perform + silence_user if silence? + end + + def silence? + return true if @user.silenced? + return false if @user.staged? + return false if @user.has_trust_level?(TrustLevel[1]) + + if SiteSetting.num_spam_flags_to_silence_new_user > (0) && + SiteSetting.num_users_to_silence_new_user > (0) && + num_spam_flags_against_user >= (SiteSetting.num_spam_flags_to_silence_new_user) && + num_users_who_flagged_spam_against_user >= (SiteSetting.num_users_to_silence_new_user) + return true + end + + if SiteSetting.num_tl3_flags_to_silence_new_user > (0) && + SiteSetting.num_tl3_users_to_silence_new_user > (0) && + num_tl3_flags_against_user >= (SiteSetting.num_tl3_flags_to_silence_new_user) && + num_tl3_users_who_flagged >= (SiteSetting.num_tl3_users_to_silence_new_user) + return true + end + + false + end + + def num_spam_flags_against_user + Post.where(user_id: @user.id).sum(:spam_count) + end + + def num_users_who_flagged_spam_against_user + post_ids = Post.where('user_id = ? and spam_count > 0', @user.id).pluck(:id) + return 0 if post_ids.empty? + PostAction.spam_flags.where(post_id: post_ids).pluck(:user_id).uniq.size + end + + def num_tl3_flags_against_user + if flagged_post_ids.empty? + 0 + else + PostAction.where(post_id: flagged_post_ids).joins(:user).where('users.trust_level >= ?', 3).count + end + end + + def num_tl3_users_who_flagged + if flagged_post_ids.empty? + 0 + else + PostAction.where(post_id: flagged_post_ids).joins(:user).where('users.trust_level >= ?', 3).pluck(:user_id).uniq.size + end + end + + def flagged_post_ids + Post.where(user_id: @user.id) + .where('spam_count > 0 OR off_topic_count > 0 OR inappropriate_count > 0') + .pluck(:id) + end + + def silence_user + Post.transaction do + if UserSilencer.silence(@user, Discourse.system_user, message: :too_many_spam_flags) && SiteSetting.notify_mods_when_user_silenced + GroupMessage.create(Group[:moderators].name, :user_automatically_silenced, user: @user, limit_once_per: false) + end + end + end +end diff --git a/app/services/spam_rules_enforcer.rb b/app/services/spam_rules_enforcer.rb index f306cfdc57..ecfc2a1f6e 100644 --- a/app/services/spam_rules_enforcer.rb +++ b/app/services/spam_rules_enforcer.rb @@ -12,7 +12,7 @@ class SpamRulesEnforcer end def enforce! - SpamRule::AutoBlock.new(@user).perform if @user + SpamRule::AutoSilence.new(@user).perform if @user SpamRule::FlagSockpuppets.new(@post).perform if @post true end diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb index 1cd09176af..924bed26ef 100644 --- a/app/services/staff_action_logger.rb +++ b/app/services/staff_action_logger.rb @@ -10,16 +10,14 @@ class StaffActionLogger raise Discourse::InvalidParameters.new(:admin) unless @admin && @admin.is_a?(User) end - def log_user_deletion(deleted_user, opts={}) + def log_user_deletion(deleted_user, opts = {}) raise Discourse::InvalidParameters.new(:deleted_user) unless deleted_user && deleted_user.is_a?(User) - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:delete_user], - ip_address: deleted_user.ip_address.to_s, - details: [:id, :username, :name, :created_at, :trust_level, :last_seen_at, :last_emailed_at].map { |x| "#{x}: #{deleted_user.send(x)}" }.join("\n") - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:delete_user], + ip_address: deleted_user.ip_address.to_s, + details: [:id, :username, :name, :created_at, :trust_level, :last_seen_at, :last_emailed_at].map { |x| "#{x}: #{deleted_user.send(x)}" }.join("\n"))) end - def log_custom(custom_type, details=nil) + def log_custom(custom_type, details = nil) raise Discourse::InvalidParameters.new(:custom_type) unless custom_type details ||= {} @@ -28,7 +26,7 @@ class StaffActionLogger StaffActionLogger.base_attrs.each do |attr| attrs[attr] = details.delete(attr) if details.has_key?(attr) end - attrs[:details] = details.map {|r| "#{r[0]}: #{r[1]}"}.join("\n") + attrs[:details] = details.map { |r| "#{r[0]}: #{r[1]}" }.join("\n") attrs[:acting_user_id] = @admin.id attrs[:action] = UserHistory.actions[:custom_staff] attrs[:custom_type] = custom_type @@ -36,7 +34,7 @@ class StaffActionLogger UserHistory.create(attrs) end - def log_post_deletion(deleted_post, opts={}) + def log_post_deletion(deleted_post, opts = {}) raise Discourse::InvalidParameters.new(:deleted_post) unless deleted_post && deleted_post.is_a?(Post) topic = deleted_post.topic || Topic.with_deleted.find_by(id: deleted_post.topic_id) @@ -54,14 +52,12 @@ class StaffActionLogger "raw: #{deleted_post.raw}" ] - UserHistory.create(params(opts).merge({ - action: UserHistory.actions[:delete_post], - post_id: deleted_post.id, - details: details.join("\n") - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:delete_post], + post_id: deleted_post.id, + details: details.join("\n"))) end - def log_topic_deletion(deleted_topic, opts={}) + def log_topic_deletion(deleted_topic, opts = {}) raise Discourse::InvalidParameters.new(:deleted_topic) unless deleted_topic && deleted_topic.is_a?(Topic) user = deleted_topic.user ? "#{deleted_topic.user.username} (#{deleted_topic.user.name})" : "(deleted user)" @@ -77,48 +73,40 @@ class StaffActionLogger details << "raw: #{first_post.raw}" end - UserHistory.create(params(opts).merge({ - action: UserHistory.actions[:delete_topic], - topic_id: deleted_topic.id, - details: details.join("\n") - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:delete_topic], + topic_id: deleted_topic.id, + details: details.join("\n"))) end - def log_trust_level_change(user, old_trust_level, new_trust_level, opts={}) + def log_trust_level_change(user, old_trust_level, new_trust_level, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user && user.is_a?(User) raise Discourse::InvalidParameters.new(:old_trust_level) unless TrustLevel.valid? old_trust_level raise Discourse::InvalidParameters.new(:new_trust_level) unless TrustLevel.valid? new_trust_level - UserHistory.create!( params(opts).merge({ - action: UserHistory.actions[:change_trust_level], - target_user_id: user.id, - details: "old trust level: #{old_trust_level}\nnew trust level: #{new_trust_level}" - })) + UserHistory.create!(params(opts).merge(action: UserHistory.actions[:change_trust_level], + target_user_id: user.id, + details: "old trust level: #{old_trust_level}\nnew trust level: #{new_trust_level}")) end - def log_lock_trust_level(user, opts={}) + def log_lock_trust_level(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user && user.is_a?(User) - UserHistory.create!( params(opts).merge({ - action: UserHistory.actions[user.trust_level_locked ? :lock_trust_level : :unlock_trust_level], - target_user_id: user.id - })) + UserHistory.create!(params(opts).merge(action: UserHistory.actions[user.manual_locked_trust_level.nil? ? :unlock_trust_level : :lock_trust_level], + target_user_id: user.id)) end - def log_site_setting_change(setting_name, previous_value, new_value, opts={}) + def log_site_setting_change(setting_name, previous_value, new_value, opts = {}) raise Discourse::InvalidParameters.new(:setting_name) unless setting_name.present? && SiteSetting.respond_to?(setting_name) - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:change_site_setting], - subject: setting_name, - previous_value: previous_value, - new_value: new_value - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:change_site_setting], + subject: setting_name, + previous_value: previous_value, + new_value: new_value)) end def theme_json(theme) - ThemeSerializer.new(theme, root:false).to_json + ThemeSerializer.new(theme, root: false).to_json end - def strip_duplicates(old,cur) - return [old,cur] unless old && cur + def strip_duplicates(old, cur) + return [old, cur] unless old && cur old = JSON.parse(old) cur = JSON.parse(cur) @@ -135,144 +123,124 @@ class StaffActionLogger [old.to_json, cur.to_json] end - def log_theme_change(old_json, new_theme, opts={}) + def log_theme_change(old_json, new_theme, opts = {}) raise Discourse::InvalidParameters.new(:new_theme) unless new_theme new_json = theme_json(new_theme) - old_json,new_json = strip_duplicates(old_json,new_json) + old_json, new_json = strip_duplicates(old_json, new_json) - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:change_theme], - subject: new_theme.name, - previous_value: old_json, - new_value: new_json - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:change_theme], + subject: new_theme.name, + previous_value: old_json, + new_value: new_json)) end - def log_theme_destroy(theme, opts={}) + def log_theme_destroy(theme, opts = {}) raise Discourse::InvalidParameters.new(:theme) unless theme - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:delete_theme], - subject: theme.name, - previous_value: theme_json(theme) - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:delete_theme], + subject: theme.name, + previous_value: theme_json(theme))) end - def log_site_text_change(subject, new_text=nil, old_text=nil, opts={}) + def log_site_text_change(subject, new_text = nil, old_text = nil, opts = {}) raise Discourse::InvalidParameters.new(:subject) unless subject.present? - UserHistory.create!( params(opts).merge({ - action: UserHistory.actions[:change_site_text], - subject: subject, - previous_value: old_text, - new_value: new_text - })) + UserHistory.create!(params(opts).merge(action: UserHistory.actions[:change_site_text], + subject: subject, + previous_value: old_text, + new_value: new_text)) end - def log_username_change(user, old_username, new_username, opts={}) + def log_username_change(user, old_username, new_username, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:change_username], - target_user_id: user.id, - previous_value: old_username, - new_value: new_username - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:change_username], + target_user_id: user.id, + previous_value: old_username, + new_value: new_username)) end - def log_name_change(user_id, old_name, new_name, opts={}) + def log_name_change(user_id, old_name, new_name, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user_id - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:change_name], - target_user_id: user_id, - previous_value: old_name, - new_value: new_name - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:change_name], + target_user_id: user_id, + previous_value: old_name, + new_value: new_name)) end - def log_user_suspend(user, reason, opts={}) + def log_user_suspend(user, reason, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create( params(opts).merge({ + + details = (reason || '').dup + details << "\n\n#{opts[:message]}" if opts[:message].present? + + args = params(opts).merge( action: UserHistory.actions[:suspend_user], target_user_id: user.id, - details: reason - })) + details: details + ) + args[:post_id] = opts[:post_id] if opts[:post_id] + UserHistory.create(args) end - def log_user_unsuspend(user, opts={}) + def log_user_unsuspend(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:unsuspend_user], - target_user_id: user.id - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:unsuspend_user], + target_user_id: user.id)) end - def log_badge_grant(user_badge, opts={}) + def log_badge_grant(user_badge, opts = {}) raise Discourse::InvalidParameters.new(:user_badge) unless user_badge - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:grant_badge], - target_user_id: user_badge.user_id, - details: user_badge.badge.name - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:grant_badge], + target_user_id: user_badge.user_id, + details: user_badge.badge.name)) end - def log_badge_revoke(user_badge, opts={}) + def log_badge_revoke(user_badge, opts = {}) raise Discourse::InvalidParameters.new(:user_badge) unless user_badge - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:revoke_badge], - target_user_id: user_badge.user_id, - details: user_badge.badge.name - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:revoke_badge], + target_user_id: user_badge.user_id, + details: user_badge.badge.name)) end - def log_check_email(user, opts={}) + def log_check_email(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge({ - action: UserHistory.actions[:check_email], - target_user_id: user.id - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:check_email], + target_user_id: user.id)) end - def log_show_emails(users, opts={}) + def log_show_emails(users, opts = {}) return if users.blank? - UserHistory.create(params(opts).merge({ - action: UserHistory.actions[:check_email], - details: users.map { |u| "[#{u.id}] #{u.username}"}.join("\n") - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:check_email], + details: users.map { |u| "[#{u.id}] #{u.username}" }.join("\n"))) end - def log_impersonate(user, opts={}) + def log_impersonate(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge({ - action: UserHistory.actions[:impersonate], - target_user_id: user.id - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:impersonate], + target_user_id: user.id)) end - def log_roll_up(subnets, opts={}) - UserHistory.create(params(opts).merge({ - action: UserHistory.actions[:roll_up], - details: subnets.join(", ") - })) + def log_roll_up(subnets, opts = {}) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:roll_up], + details: subnets.join(", "))) end - def log_category_settings_change(category, category_params, old_permissions=nil) + def log_category_settings_change(category, category_params, old_permissions = nil) validate_category(category) changed_attributes = category.previous_changes.slice(*category_params.keys) if !old_permissions.empty? && (old_permissions != category_params[:permissions]) - changed_attributes.merge!({ permissions: [old_permissions.to_json, category_params[:permissions].to_json] }) + changed_attributes.merge!(permissions: [old_permissions.to_json, category_params[:permissions].to_json]) end changed_attributes.each do |key, value| - UserHistory.create(params.merge({ - action: UserHistory.actions[:change_category_settings], - category_id: category.id, - context: category.url, - subject: key, - previous_value: value[0], - new_value: value[1] - })) + UserHistory.create(params.merge(action: UserHistory.actions[:change_category_settings], + category_id: category.id, + context: category.url, + subject: key, + previous_value: value[0], + new_value: value[1])) end end @@ -289,12 +257,10 @@ class StaffActionLogger details << "parent_category: #{parent_category.name}" end - UserHistory.create(params.merge({ - action: UserHistory.actions[:delete_category], - category_id: category.id, - details: details.join("\n"), - context: category.url - })) + UserHistory.create(params.merge(action: UserHistory.actions[:delete_category], + category_id: category.id, + details: details.join("\n"), + context: category.url)) end def log_category_creation(category) @@ -305,132 +271,108 @@ class StaffActionLogger "name: #{category.name}" ] - UserHistory.create(params.merge({ - action: UserHistory.actions[:create_category], - details: details.join("\n"), - category_id: category.id, - context: category.url - })) + UserHistory.create(params.merge(action: UserHistory.actions[:create_category], + details: details.join("\n"), + category_id: category.id, + context: category.url)) end - def log_block_user(user, opts={}) + def log_silence_user(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:block_user], - target_user_id: user.id - })) + + UserHistory.create( + params(opts).merge( + action: UserHistory.actions[:silence_user], + target_user_id: user.id, + details: opts[:details] + ) + ) end - def log_unblock_user(user, opts={}) + def log_unsilence_user(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:unblock_user], - target_user_id: user.id - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:unsilence_user], + target_user_id: user.id)) end - def log_grant_admin(user, opts={}) + def log_grant_admin(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:grant_admin], - target_user_id: user.id - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:grant_admin], + target_user_id: user.id)) end - def log_revoke_admin(user, opts={}) + def log_revoke_admin(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:revoke_admin], - target_user_id: user.id - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:revoke_admin], + target_user_id: user.id)) end - def log_grant_moderation(user, opts={}) + def log_grant_moderation(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:grant_moderation], - target_user_id: user.id - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:grant_moderation], + target_user_id: user.id)) end - def log_revoke_moderation(user, opts={}) + def log_revoke_moderation(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create( params(opts).merge({ - action: UserHistory.actions[:revoke_moderation], - target_user_id: user.id - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:revoke_moderation], + target_user_id: user.id)) end - def log_backup_create(opts={}) - UserHistory.create(params(opts).merge({ - action: UserHistory.actions[:backup_create], - ip_address: @admin.ip_address.to_s - })) + def log_backup_create(opts = {}) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:backup_create], + ip_address: @admin.ip_address.to_s)) end - def log_backup_download(backup, opts={}) + def log_backup_download(backup, opts = {}) raise Discourse::InvalidParameters.new(:backup) unless backup - UserHistory.create(params(opts).merge({ - action: UserHistory.actions[:backup_download], - ip_address: @admin.ip_address.to_s, - details: backup.filename - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:backup_download], + ip_address: @admin.ip_address.to_s, + details: backup.filename)) end - def log_backup_destroy(backup, opts={}) + def log_backup_destroy(backup, opts = {}) raise Discourse::InvalidParameters.new(:backup) unless backup - UserHistory.create(params(opts).merge({ - action: UserHistory.actions[:backup_destroy], - ip_address: @admin.ip_address.to_s, - details: backup.filename - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:backup_destroy], + ip_address: @admin.ip_address.to_s, + details: backup.filename)) end - def log_revoke_email(user, reason, opts={}) - UserHistory.create(params(opts).merge({ - action: UserHistory.actions[:revoke_email], - target_user_id: user.id, - details: reason - })) + def log_revoke_email(user, reason, opts = {}) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:revoke_email], + target_user_id: user.id, + details: reason)) end - def log_user_deactivate(user, reason, opts={}) + def log_user_deactivate(user, reason, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge({ - action: UserHistory.actions[:deactivate_user], - target_user_id: user.id, - details: reason - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:deactivate_user], + target_user_id: user.id, + details: reason)) end - def log_user_activate(user, reason, opts={}) + def log_user_activate(user, reason, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user - UserHistory.create(params(opts).merge({ - action: UserHistory.actions[:activate_user], - target_user_id: user.id, - details: reason - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:activate_user], + target_user_id: user.id, + details: reason)) end - def log_wizard_step(step, opts={}) + def log_wizard_step(step, opts = {}) raise Discourse::InvalidParameters.new(:step) unless step - UserHistory.create(params(opts).merge({ - action: UserHistory.actions[:wizard_step], - context: step.id - })) + UserHistory.create(params(opts).merge(action: UserHistory.actions[:wizard_step], + context: step.id)) end def log_change_readonly_mode(state) - UserHistory.create(params.merge({ - action: UserHistory.actions[:change_readonly_mode], - previous_value: !state, - new_value: state - })) + UserHistory.create(params.merge(action: UserHistory.actions[:change_readonly_mode], + previous_value: !state, + new_value: state)) end private - def params(opts=nil) + def params(opts = nil) opts ||= {} { acting_user_id: @admin.id, context: opts[:context] } end diff --git a/app/services/topic_status_updater.rb b/app/services/topic_status_updater.rb index a2fc8f4dd6..cd1ea0d94f 100644 --- a/app/services/topic_status_updater.rb +++ b/app/services/topic_status_updater.rb @@ -1,5 +1,5 @@ TopicStatusUpdater = Struct.new(:topic, :user) do - def update!(status, enabled, opts={}) + def update!(status, enabled, opts = {}) status = Status.new(status, enabled) @topic_status_update = topic.public_topic_timer @@ -19,7 +19,7 @@ TopicStatusUpdater = Struct.new(:topic, :user) do private - def change(status, opts={}) + def change(status, opts = {}) result = true if status.pinned? || status.pinned_globally? @@ -30,17 +30,21 @@ TopicStatusUpdater = Struct.new(:topic, :user) do result = false if rc == 0 else rc = Topic.where(:id => topic.id, status.name => !status.enabled) - .update_all(status.name => status.enabled?) + .update_all(status.name => status.enabled?) topic.send("#{status.name}=", status.enabled?) result = false if rc == 0 end + if status.manually_closing_topic? + DiscourseEvent.trigger(:topic_closed, topic) + end + if @topic_status_update if status.manually_closing_topic? || status.closing_topic? - topic.set_or_create_timer(TopicTimer.types[:close], nil) + topic.delete_topic_timer(TopicTimer.types[:close]) elsif status.manually_opening_topic? || status.opening_topic? - topic.set_or_create_timer(TopicTimer.types[:open], nil) + topic.delete_topic_timer(TopicTimer.types[:open]) end end @@ -55,7 +59,7 @@ TopicStatusUpdater = Struct.new(:topic, :user) do result end - def create_moderator_post_for(status, message=nil) + def create_moderator_post_for(status, message = nil) topic.add_moderator_post(user, message || message_for(status), options_for(status)) topic.reload end diff --git a/app/services/tracked_topics_updater.rb b/app/services/tracked_topics_updater.rb index 2222fc4c63..0ffb32e7e7 100644 --- a/app/services/tracked_topics_updater.rb +++ b/app/services/tracked_topics_updater.rb @@ -8,11 +8,10 @@ class TrackedTopicsUpdater def call topic_users = TopicUser.where(notifications_reason_id: nil, user_id: @id) if @threshold < 0 - topic_users.update_all({notification_level: TopicUser.notification_levels[:regular]}) + topic_users.update_all(notification_level: TopicUser.notification_levels[:regular]) else topic_users.update_all(["notification_level = CASE WHEN total_msecs_viewed < ? THEN ? ELSE ? END", @threshold, TopicUser.notification_levels[:regular], TopicUser.notification_levels[:tracking]]) end end end - diff --git a/app/services/trust_level_granter.rb b/app/services/trust_level_granter.rb index 1a826ba18d..23e9ef50ff 100644 --- a/app/services/trust_level_granter.rb +++ b/app/services/trust_level_granter.rb @@ -11,7 +11,6 @@ class TrustLevelGranter def grant if @user.trust_level < @trust_level @user.change_trust_level!(@trust_level) - @user.trust_level_locked = true @user.save! end end diff --git a/app/services/user_action_creator.rb b/app/services/user_action_creator.rb index bbf21f9fb9..7b1c92562b 100644 --- a/app/services/user_action_creator.rb +++ b/app/services/user_action_creator.rb @@ -7,19 +7,19 @@ class UserActionCreator @disabled = false end - def self.log_notification(post, user, notification_type, acting_user_id=nil) + def self.log_notification(post, user, notification_type, acting_user_id = nil) return if @disabled action = case notification_type - when Notification.types[:quoted] - UserAction::QUOTE - when Notification.types[:replied] - UserAction::RESPONSE - when Notification.types[:mentioned] - UserAction::MENTION - when Notification.types[:edited] - UserAction::EDIT + when Notification.types[:quoted] + UserAction::QUOTE + when Notification.types[:replied] + UserAction::RESPONSE + when Notification.types[:mentioned] + UserAction::MENTION + when Notification.types[:edited] + UserAction::EDIT end # skip any invalid items, eg failed to save post and so on @@ -93,6 +93,10 @@ class UserActionCreator created_at: model.created_at } + UserAction.remove_action!(row.merge( + action_type: model.archetype == Archetype.private_message ? UserAction::NEW_TOPIC : UserAction::NEW_PRIVATE_MESSAGE + )) + rows = [row] if model.private_message? diff --git a/app/services/user_activator.rb b/app/services/user_activator.rb index 6b151874c5..d65fba83c2 100644 --- a/app/services/user_activator.rb +++ b/app/services/user_activator.rb @@ -16,6 +16,10 @@ class UserActivator @message = activator.activate end + def success_message + activator.success_message + end + private def activator @@ -24,10 +28,11 @@ class UserActivator def factory invite = Invite.find_by(email: Email.downcase(@user.email)) - if SiteSetting.must_approve_users? && !(invite.present? && !invite.expired? && !invite.destroyed? && invite.link_valid?) - ApprovalActivator - elsif !user.active? + + if !user.active? EmailActivator + elsif SiteSetting.must_approve_users? && !(invite.present? && !invite.expired? && !invite.destroyed? && invite.link_valid?) + ApprovalActivator else LoginActivator end @@ -37,6 +42,10 @@ end class ApprovalActivator < UserActivator def activate + success_message + end + + def success_message I18n.t("login.wait_approval") end end @@ -51,6 +60,11 @@ class EmailActivator < UserActivator user_id: user.id, email_token: email_token.token ) + + success_message + end + + def success_message I18n.t("login.activate_email", email: Rack::Utils.escape_html(user.email)) end end @@ -61,6 +75,10 @@ class LoginActivator < UserActivator def activate log_on_user(user) user.enqueue_welcome_message('welcome_user') + success_message + end + + def success_message I18n.t("login.active") end end diff --git a/app/services/user_anonymizer.rb b/app/services/user_anonymizer.rb index ac692d28d7..dbd58624bf 100644 --- a/app/services/user_anonymizer.rb +++ b/app/services/user_anonymizer.rb @@ -1,10 +1,10 @@ class UserAnonymizer - def initialize(user, actor=nil) + def initialize(user, actor = nil) @user = user @actor = actor end - def self.make_anonymous(user, actor=nil) + def self.make_anonymous(user, actor = nil) self.new(user, actor).make_anonymous end @@ -48,11 +48,11 @@ class UserAnonymizer @user.user_open_ids.find_each { |x| x.destroy } @user.api_key.try(:destroy) - UserHistory.create( action: UserHistory.actions[:anonymize_user], - target_user_id: @user.id, - acting_user_id: @actor ? @actor.id : @user.id, - email: prev_email, - details: "username: #{prev_username}" ) + UserHistory.create(action: UserHistory.actions[:anonymize_user], + target_user_id: @user.id, + acting_user_id: @actor ? @actor.id : @user.id, + email: prev_email, + details: "username: #{prev_username}") end @user end diff --git a/app/services/user_blocker.rb b/app/services/user_blocker.rb deleted file mode 100644 index fe0091425e..0000000000 --- a/app/services/user_blocker.rb +++ /dev/null @@ -1,47 +0,0 @@ -class UserBlocker - - def initialize(user, by_user=nil, opts={}) - @user, @by_user, @opts = user, by_user, opts - end - - def self.block(user, by_user=nil, opts={}) - UserBlocker.new(user, by_user, opts).block - end - - def self.unblock(user, by_user=nil, opts={}) - UserBlocker.new(user, by_user, opts).unblock - end - - def block - hide_posts unless @opts[:keep_posts] - unless @user.blocked? - @user.blocked = true - if @user.save - message_type = @opts[:message] || :blocked_by_staff - post = SystemMessage.create(@user, message_type) - if post && @by_user - StaffActionLogger.new(@by_user).log_block_user(@user, {context: "#{message_type}: '#{post.topic&.title rescue ''}' #{@opts[:reason]}"}) - end - end - else - false - end - end - - def hide_posts - return unless @user.trust_level == TrustLevel[0] - - Post.where(user_id: @user.id).where("created_at > ?", 24.hours.ago).update_all(["hidden = true, hidden_reason_id = COALESCE(hidden_reason_id, ?)", Post.hidden_reasons[:new_user_spam_threshold_reached]]) - topic_ids = Post.where(user_id: @user.id, post_number: 1).where("created_at > ?", 24.hours.ago).pluck(:topic_id) - Topic.where(id: topic_ids).update_all(visible: false) unless topic_ids.empty? - end - - def unblock - @user.blocked = false - if @user.save - SystemMessage.create(@user, :unblocked) - StaffActionLogger.new(@by_user).log_unblock_user(@user) if @by_user - end - end - -end diff --git a/app/services/user_destroyer.rb b/app/services/user_destroyer.rb index 9af5731fc9..b245bd32d8 100644 --- a/app/services/user_destroyer.rb +++ b/app/services/user_destroyer.rb @@ -7,14 +7,14 @@ class UserDestroyer def initialize(actor) @actor = actor - raise Discourse::InvalidParameters.new('acting user is nil') unless @actor and @actor.is_a?(User) + raise Discourse::InvalidParameters.new('acting user is nil') unless @actor && @actor.is_a?(User) @guardian = Guardian.new(actor) end # Returns false if the user failed to be deleted. # Returns a frozen instance of the User if the delete succeeded. - def destroy(user, opts={}) - raise Discourse::InvalidParameters.new('user is nil') unless user and user.is_a?(User) + def destroy(user, opts = {}) + raise Discourse::InvalidParameters.new('user is nil') unless user && user.is_a?(User) raise PostsExistError if !opts[:delete_posts] && user.posts.count != 0 @guardian.ensure_can_delete_user!(user) @@ -31,18 +31,16 @@ class UserDestroyer # block all external urls if opts[:block_urls] post.topic_links.each do |link| - unless link.internal || - (Oneboxer.engine(link.url) != Onebox::Engine::WhitelistedGenericOnebox) - - ScreenedUrl.watch(link.url, link.domain, ip_address: user.ip_address)&.record_match! - end + next if link.internal + next if Oneboxer.engine(link.url) != Onebox::Engine::WhitelistedGenericOnebox + ScreenedUrl.watch(link.url, link.domain, ip_address: user.ip_address)&.record_match! end end PostDestroyer.new(@actor.staff? ? @actor : Discourse.system_user, post).destroy if post.topic && post.is_first_post? - Topic.unscoped.where(id: post.topic.id).update_all(user_id: nil) + Topic.unscoped.where(id: post.topic_id).update_all(user_id: nil) end end end @@ -51,39 +49,42 @@ class UserDestroyer post_action.remove_act!(Discourse.system_user) end + # keep track of emails used + user_emails = user.user_emails.pluck(:email) + user.destroy.tap do |u| if u - if opts[:block_email] - b = ScreenedEmail.block(u.email, ip_address: u.ip_address) - b.record_match! if b - end - - if opts[:block_ip] && u.ip_address - b = ScreenedIpAddress.watch(u.ip_address) - b.record_match! if b - if u.registration_ip_address && u.ip_address != u.registration_ip_address - b = ScreenedIpAddress.watch(u.registration_ip_address) - b.record_match! if b + user_emails.each do |email| + ScreenedEmail.block(email, ip_address: u.ip_address)&.record_match! end end - Post.with_deleted.where(user_id: user.id).update_all("user_id = NULL") + if opts[:block_ip] && u.ip_address + ScreenedIpAddress.watch(u.ip_address)&.record_match! + if u.registration_ip_address && u.ip_address != u.registration_ip_address + ScreenedIpAddress.watch(u.registration_ip_address)&.record_match! + end + end + + Post.unscoped.where(user_id: u.id).update_all(user_id: nil) # If this user created categories, fix those up: - categories = Category.where(user_id: user.id) - categories.each do |c| - c.user_id = Discourse.system_user.id + Category.where(user_id: u.id).each do |c| + c.user_id = Discourse::SYSTEM_USER_ID c.save! - if topic = Topic.with_deleted.find_by(id: c.topic_id) - topic.try(:recover!) - topic.user_id = Discourse.system_user.id + if topic = Topic.unscoped.find_by(id: c.topic_id) + topic.recover! + topic.user_id = Discourse::SYSTEM_USER_ID topic.save! end end - StaffActionLogger.new(@actor == user ? Discourse.system_user : @actor).log_user_deletion(user, opts.slice(:context)) - MessageBus.publish "/file-change", ["refresh"], user_ids: [user.id] + unless opts[:quiet] + StaffActionLogger.new(@actor == user ? Discourse.system_user : @actor).log_user_deletion(user, opts.slice(:context)) + end + + MessageBus.publish "/file-change", ["refresh"], user_ids: [u.id] end end end diff --git a/app/services/user_silencer.rb b/app/services/user_silencer.rb new file mode 100644 index 0000000000..9858c75f9d --- /dev/null +++ b/app/services/user_silencer.rb @@ -0,0 +1,60 @@ +class UserSilencer + + attr_reader :user_history + + def initialize(user, by_user = nil, opts = {}) + @user, @by_user, @opts = user, by_user, opts + end + + def self.silence(user, by_user = nil, opts = {}) + UserSilencer.new(user, by_user, opts).silence + end + + def self.unsilence(user, by_user = nil, opts = {}) + UserSilencer.new(user, by_user, opts).unsilence + end + + def silence + hide_posts unless @opts[:keep_posts] + unless @user.silenced_till.present? + @user.silenced_till = @opts[:silenced_till] || 1000.years.from_now + if @user.save + message_type = @opts[:message] || :silenced_by_staff + + details = (@opts[:reason] || '').dup + details << "\n\n#{@opts[:message_body]}" if @opts[:message_body].present? + + context = "#{message_type}: '#{post.topic&.title rescue ''}' #{@opts[:reason]}" + SystemMessage.create(@user, message_type) + + if @by_user + @user_history = StaffActionLogger.new(@by_user).log_silence_user( + @user, + context: context, + details: details + ) + end + return true + end + else + false + end + end + + def hide_posts + return unless @user.trust_level == TrustLevel[0] + + Post.where(user_id: @user.id).where("created_at > ?", 24.hours.ago).update_all(["hidden = true, hidden_reason_id = COALESCE(hidden_reason_id, ?)", Post.hidden_reasons[:new_user_spam_threshold_reached]]) + topic_ids = Post.where(user_id: @user.id, post_number: 1).where("created_at > ?", 24.hours.ago).pluck(:topic_id) + Topic.where(id: topic_ids).update_all(visible: false) unless topic_ids.empty? + end + + def unsilence + @user.silenced_till = nil + if @user.save + SystemMessage.create(@user, :unsilenced) + StaffActionLogger.new(@by_user).log_unsilence_user(@user) if @by_user + end + end + +end diff --git a/app/services/user_updater.rb b/app/services/user_updater.rb index 758c019c2a..305f96fddc 100644 --- a/app/services/user_updater.rb +++ b/app/services/user_updater.rb @@ -34,7 +34,9 @@ class UserUpdater :email_in_reply_to, :like_notification_frequency, :include_tl0_in_digests, - :theme_key + :theme_key, + :allow_private_messages, + :homepage_id, ] def initialize(actor, user) @@ -109,15 +111,15 @@ class UserUpdater update_muted_users(attributes[:muted_usernames]) end - saved = (!save_options || user.user_option.save) && user_profile.save && user.save + if (saved = (!save_options || user.user_option.save) && user_profile.save && user.save) && + (attributes[:name].present? && old_user_name.casecmp(attributes.fetch(:name)) != 0) || + (attributes[:name].blank? && old_user_name.present?) - if saved - # log name changes - if attributes[:name].present? && old_user_name.downcase != attributes.fetch(:name).downcase - StaffActionLogger.new(@actor).log_name_change(user.id, old_user_name, attributes.fetch(:name)) - elsif attributes[:name].blank? && old_user_name.present? - StaffActionLogger.new(@actor).log_name_change(user.id, old_user_name, "") - end + StaffActionLogger.new(@actor).log_name_change( + user.id, + old_user_name, + attributes.fetch(:name) { '' } + ) end end diff --git a/app/services/username_changer.rb b/app/services/username_changer.rb index 990534e282..a81f089415 100644 --- a/app/services/username_changer.rb +++ b/app/services/username_changer.rb @@ -1,12 +1,12 @@ class UsernameChanger - def initialize(user, new_username, actor=nil) + def initialize(user, new_username, actor = nil) @user = user @new_username = new_username @actor = actor end - def self.change(user, new_username, actor=nil) + def self.change(user, new_username, actor = nil) self.new(user, new_username, actor).change end diff --git a/app/services/username_checker_service.rb b/app/services/username_checker_service.rb index 91bfe42de9..539e07e39c 100644 --- a/app/services/username_checker_service.rb +++ b/app/services/username_checker_service.rb @@ -4,7 +4,7 @@ class UsernameCheckerService if username && username.length > 0 validator = UsernameValidator.new(username) if !validator.valid_format? - {errors: validator.errors} + { errors: validator.errors } else check_username_availability(username, email) end @@ -12,7 +12,7 @@ class UsernameCheckerService end def check_username_availability(username, email) - if User.username_available?(username) + if User.username_available?(username, email) { available: true, is_developer: is_developer?(email) } else { available: false, suggestion: UserNameSuggester.suggest(username) } @@ -23,7 +23,6 @@ class UsernameCheckerService Rails.configuration.respond_to?(:developer_emails) && Rails.configuration.developer_emails.include?(value) end - def self.is_developer?(email) UsernameCheckerService.new.is_developer?(email) end diff --git a/app/services/word_watcher.rb b/app/services/word_watcher.rb new file mode 100644 index 0000000000..ff82f36bd5 --- /dev/null +++ b/app/services/word_watcher.rb @@ -0,0 +1,64 @@ +class WordWatcher + + def initialize(raw) + @raw = raw + end + + def self.words_for_action(action) + WatchedWord.where(action: WatchedWord.actions[action.to_sym]).limit(1000).pluck(:word) + end + + def self.words_for_action_exists?(action) + 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 : "\\b(#{regexp})\\b" + end + end + s.present? ? Regexp.new(s, Regexp::IGNORECASE) : nil + end + + def self.word_to_regexp(word) + if SiteSetting.watched_words_regular_expressions? + # Strip ruby regexp format if present, we're going to make the whole thing + # case insensitive anyway + return word.start_with?("(?-mix:") ? word[7..-2] : word + end + Regexp.escape(word).gsub("\\*", '\S*') + end + + def self.word_matcher_regexp_key(action) + "watched-words-regexp:#{action}" + end + + def self.clear_cache! + WatchedWord.actions.sum do |a, i| + Discourse.cache.delete word_matcher_regexp_key(a) + end + end + + def requires_approval? + word_matches_for_action?(:require_approval) + end + + def should_flag? + word_matches_for_action?(:flag) + end + + def should_block? + word_matches_for_action?(:block) + end + + def word_matches_for_action?(action) + r = self.class.word_matcher_regexp(action) + r ? r.match(@raw) : false + end + +end diff --git a/app/views/about/index.html.erb b/app/views/about/index.html.erb index 67b9d3240f..563a9600c8 100644 --- a/app/views/about/index.html.erb +++ b/app/views/about/index.html.erb @@ -1,6 +1,6 @@ <% if crawler_layout? %> -<% content_for :title do %><%=t "about" %><% end %> +<% content_for :title do %><%=t "about" %> - <%= SiteSetting.title %><% end %>

    diff --git a/app/views/application/_header.html.erb b/app/views/application/_header.html.erb index a179dbc0c8..82305d1eba 100644 --- a/app/views/application/_header.html.erb +++ b/app/views/application/_header.html.erb @@ -1,22 +1,24 @@ -
    -
    + +<% end %> diff --git a/app/views/badges/show.html.erb b/app/views/badges/show.html.erb index 6fc281b9dd..1022d59a0d 100644 --- a/app/views/badges/show.html.erb +++ b/app/views/badges/show.html.erb @@ -1,3 +1,4 @@ <% content_for :head do %> <%= raw crawlable_meta_data(title: I18n.t('badges.badge_title_metadata', display_name: @badge.display_name, site_title: SiteSetting.title), description: @badge.long_description) %> + <%= auto_discovery_link_tag(:rss, "#{@rss_link}.rss", title: @rss_title) %> <% end %> diff --git a/app/views/badges/show.rss.erb b/app/views/badges/show.rss.erb new file mode 100644 index 0000000000..ca74f8091a --- /dev/null +++ b/app/views/badges/show.rss.erb @@ -0,0 +1,21 @@ + + + + <% lang = SiteSetting.find_by_name('default_locale').try(:value) %> + <% site_email = SiteSetting.find_by_name('contact_email').try(:value) %> + <%= @rss_title %> + <%= @rss_link %> + ]]> + <% if @badge.user_badges.first.present? %> + " rel="self" type="application/rss+xml" /> + <% @badge.user_badges.last(50).reverse_each do |user_badge| %> + + <![CDATA[<%= "@#{user_badge.user.username}#{" #{user_badge.user.name}" if (user_badge.user.name.present? && SiteSetting.enable_names?)}" -%>]]> + <%= user_badge.granted_at.rfc2822 %> + ]]> + <%= "#{@rss_link}?username=#{user_badge.user.username}" %> + + <% end %> + <% end %> + + diff --git a/app/views/categories/index.html.erb b/app/views/categories/index.html.erb index f30438dd2f..2733cc8167 100644 --- a/app/views/categories/index.html.erb +++ b/app/views/categories/index.html.erb @@ -6,7 +6,7 @@

    <%= c.name %>

    - <%= c.description %> + <%= c.description&.html_safe %>
    <% end %>
    diff --git a/app/views/common/_discourse_javascript.html.erb b/app/views/common/_discourse_javascript.html.erb index 05e0a95f94..3ee86fa4d4 100644 --- a/app/views/common/_discourse_javascript.html.erb +++ b/app/views/common/_discourse_javascript.html.erb @@ -17,16 +17,11 @@ <% end %> window.onerror(e && e.message, null,null,null,e); }); - -<% if Rails.env.development? || Rails.env.test? %> - //Ember.ENV.RAISE_ON_DEPRECATION = true - //Ember.LOG_STACKTRACE_ON_DEPRECATION = true -<% end %> - diff --git a/app/views/common/_google_analytics.html.erb b/app/views/common/_google_analytics.html.erb deleted file mode 100644 index c8a7f41740..0000000000 --- a/app/views/common/_google_analytics.html.erb +++ /dev/null @@ -1,18 +0,0 @@ - diff --git a/app/views/common/_google_universal_analytics.html.erb b/app/views/common/_google_universal_analytics.html.erb index 9067c94cb3..7c03711df4 100644 --- a/app/views/common/_google_universal_analytics.html.erb +++ b/app/views/common/_google_universal_analytics.html.erb @@ -5,4 +5,10 @@ })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); ga('create', '<%= SiteSetting.ga_universal_tracking_code %>', <%= ga_universal_json %>); + + <% if SiteSetting.ga_universal_auto_link_domains.present? %> + ga('require', 'linker'); + ga('linker:autoLink', <%= raw SiteSetting.ga_universal_auto_link_domains.split('|').to_json %>); + <% end %> + diff --git a/app/views/email/unsubscribe.html.erb b/app/views/email/unsubscribe.html.erb index b4d543a16b..762cd8afae 100644 --- a/app/views/email/unsubscribe.html.erb +++ b/app/views/email/unsubscribe.html.erb @@ -12,7 +12,7 @@ <%- end %> <%- else %>
    -

    <%= t 'unsubscribe.title'%>

    +

    <%= t 'unsubscribe.title'%> <%= @user.email %>


    <%= form_tag(email_perform_unsubscribe_path(key: params[:key])) do %> <%if @topic %> diff --git a/app/views/email/unsubscribed.html.erb b/app/views/email/unsubscribed.html.erb index cfa7ed31ab..a593bfed31 100644 --- a/app/views/email/unsubscribed.html.erb +++ b/app/views/email/unsubscribed.html.erb @@ -3,7 +3,7 @@

    <%=t "unsubscribed.title"%>


    - <%=t("unsubscribed.description", url: path("/my/preferences")).html_safe %> + <%=t("unsubscribed.description", email: @email, url: path("/my/preferences")).html_safe %>

    <% if @topic %> diff --git a/app/views/exceptions/not_found.html.erb b/app/views/exceptions/not_found.html.erb index 64ffd293f9..899c231514 100644 --- a/app/views/exceptions/not_found.html.erb +++ b/app/views/exceptions/not_found.html.erb @@ -1,40 +1,40 @@ -<% local_domain = "#{request.protocol}#{request.host_with_port}" %> -

    <%= t 'page_not_found.title' %>

    +<%= build_plugin_html 'server:not-found-before-topics' %> + <% unless SiteSetting.login_required? && current_user.nil? %>
    -