diff --git a/.babelrc b/.babelrc index 139b5b71155aa97d139010df87478d8b1079a74e..081c4f963c24a4fd6a688af21b7396a4dd8695f2 100644 --- a/.babelrc +++ b/.babelrc @@ -14,7 +14,7 @@ ], "plugins": [ "syntax-dynamic-import", - "transform-object-rest-spread", + ["transform-object-rest-spread", { "useBuiltIns": true }], "transform-class-properties", [ "react-intl", diff --git a/.dockerignore b/.dockerignore index 62f56dfb0bbdd2f56456700818f17924df399087..2ddfa9b953696b564b8374b38c44d180d3566f03 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ .env.* public/system public/assets +public/packs node_modules storybook neo4j diff --git a/.env.nanobox b/.env.nanobox index 4a89e0e41fc9a3940f98006d0cd6b9978e8a3ba5..73abefdc657537d627e93f5506aec37004a1faa1 100644 --- a/.env.nanobox +++ b/.env.nanobox @@ -11,6 +11,8 @@ DB_NAME=gonano DB_PASS=$DATA_DB_PASS DB_PORT=5432 +DATABASE_URL=postgresql://$DATA_DB_USER:$DATA_DB_PASS@$DATA_DB_HOST/gonano + # Federation # Note: Changing LOCAL_DOMAIN or LOCAL_HTTPS at a later time will cause unwanted side effects. # LOCAL_DOMAIN should *NOT* contain the protocol part of the domain e.g https://example.com. diff --git a/.env.production.sample b/.env.production.sample index 23cf18c8ea244db05a065b9144ac854c75678000..394cdedfef89f0e749fbdcf14b5fa9d136496c6a 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -65,7 +65,7 @@ SMTP_FROM_ADDRESS=notifications@example.com # PAPERCLIP_ROOT_URL=/system # Optional asset host for multi-server setups -# CDN_HOST=assets.example.com +# CDN_HOST=https://assets.example.com # S3 (optional) # S3_ENABLED=true diff --git a/.eslintrc.yml b/.eslintrc.yml index 59218cb24d337bbbcf058a818a1061785af23278..2fb54ae66912146333500ed063922d7b13c0a2ff 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -21,22 +21,10 @@ parserOptions: rules: - no-cond-assign: error - no-console: warn - no-irregular-whitespace: error - no-unreachable: error - valid-typeof: error - consistent-return: error - dot-notation: error - eqeqeq: error - no-fallthrough: error - no-unused-expressions: error - strict: off - no-catch-shadow: error - indent: - - warn - - 2 brace-style: warn + comma-dangle: + - error + - always-multiline comma-spacing: - warn - before: false @@ -44,22 +32,61 @@ rules: comma-style: - warn - last + consistent-return: error + dot-notation: error + eqeqeq: error + indent: + - warn + - 2 + jsx-quotes: + - error + - prefer-single + no-catch-shadow: error + no-cond-assign: error + no-console: + - warn + - allow: + - error + no-fallthrough: error + no-irregular-whitespace: error no-mixed-spaces-and-tabs: warn no-nested-ternary: warn no-trailing-spaces: warn - semi: error + no-unreachable: error + no-unused-expressions: error + object-curly-spacing: + - error + - always padded-blocks: - error - classes: always - comma-dangle: + quotes: - error - - always-multiline + - single + semi: error + strict: off + valid-typeof: error - react/jsx-wrap-multilines: error + react/jsx-boolean-value: error + react/jsx-closing-bracket-location: + - error + - line-aligned + react/jsx-curly-spacing: error + react/jsx-equals-spacing: error + react/jsx-first-prop-new-line: + - error + - multiline-multiprop + react/jsx-indent: + - error + - 2 react/jsx-no-bind: error - react/self-closing-comp: error - react/prop-types: error + react/jsx-no-duplicate-props: error + react/jsx-tag-spacing: error + react/jsx-wrap-multilines: error react/no-multi-comp: off + react/no-string-refs: error + react/prop-types: error + react/self-closing-comp: error jsx-a11y/accessible-emoji: warn jsx-a11y/anchor-has-content: warn diff --git a/.gitignore b/.gitignore index 91be96eee974b4f6c950346987667d3c293d433e..38ebc934f28eceec022667a1c5aa6fc0b126d858 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ coverage public/system public/assets public/packs +public/packs-test .env .env.production node_modules/ @@ -33,6 +34,7 @@ config/deploy/* # Ignore IDE files .vscode/ +.idea/ # Ignore postgres + redis volume optionally created by docker-compose postgres diff --git a/.rubocop.yml b/.rubocop.yml index 295787e267fb28de88e2f40380b8180f73ef1ff2..1cbdadd49fe5a3119c36a810f13b10b4354abeff 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,26 +1,35 @@ -Rails: - Enabled: true - -Style/PerlBackrefs: - AutoCorrect: false +AllCops: + TargetRubyVersion: 2.3 + Exclude: + - 'spec/**/*' + - 'db/**/*' + - 'app/views/**/*' + - 'config/**/*' + - 'bin/*' + - 'Rakefile' + - 'node_modules/**/*' + - 'Vagrantfile' + - 'vendor/**/*' -Style/ClassAndModuleChildren: +Bundler/OrderedGems: Enabled: false -Metrics/BlockNesting: - Max: 2 +Layout/AccessModifierIndentation: + EnforcedStyle: indent -Metrics/LineLength: - AllowURI: true +Layout/EmptyLineAfterMagicComment: Enabled: false -Metrics/MethodLength: - CountComments: false - Max: 10 +Layout/SpaceInsideHashLiteralBraces: + EnforcedStyle: space Metrics/AbcSize: Max: 100 +Metrics/BlockLength: + Exclude: + - 'lib/tasks/**/*' + Metrics/BlockNesting: Max: 3 @@ -31,22 +40,36 @@ Metrics/ClassLength: Metrics/CyclomaticComplexity: Max: 15 +Metrics/LineLength: + AllowURI: true + Enabled: false + Metrics/MethodLength: + CountComments: false Max: 55 Metrics/ModuleLength: CountComments: false Max: 200 -Metrics/PerceivedComplexity: - Max: 10 - Metrics/ParameterLists: Max: 4 CountKeywordArgs: true -Style/AccessModifierIndentation: - EnforcedStyle: indent +Metrics/PerceivedComplexity: + Max: 10 + +Rails: + Enabled: true + +Rails/HasAndBelongsToMany: + Enabled: false + +Rails/SkipsModelValidations: + Enabled: false + +Style/ClassAndModuleChildren: + Enabled: false Style/CollectionMethods: Enabled: true @@ -62,36 +85,25 @@ Style/DoubleNegation: Style/FrozenStringLiteralComment: Enabled: true -Style/SpaceInsideHashLiteralBraces: - EnforcedStyle: space - -Style/TrailingCommaInLiteral: - EnforcedStyleForMultiline: 'comma' - -Style/RegexpLiteral: +Style/GuardClause: Enabled: false Style/Lambda: Enabled: false -Style/GuardClause: - Enabled: false +Style/PercentLiteralDelimiters: + PreferredDelimiters: + '%i': '()' + '%w': '()' -Rails/HasAndBelongsToMany: +Style/PerlBackrefs: + AutoCorrect: false + +Style/RegexpLiteral: Enabled: false -Bundler/OrderedGems: +Style/SymbolArray: Enabled: false -AllCops: - TargetRubyVersion: 2.3 - Exclude: - - 'spec/**/*' - - 'db/**/*' - - 'app/views/**/*' - - 'config/**/*' - - 'bin/*' - - 'Rakefile' - - 'node_modules/**/*' - - 'Vagrantfile' - - 'vendor/**/*' +Style/TrailingCommaInLiteral: + EnforcedStyleForMultiline: 'comma' diff --git a/.travis.yml b/.travis.yml index 660a7ebf7558f3fe6ca066f861a55f5dbf2f0673..8bef9c4aef68d8f7f5f1798519d454ca661e3d21 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,8 @@ cache: yarn: true directories: - node_modules + - public/assets + - public/packs-test dist: trusty sudo: false @@ -50,6 +52,6 @@ before_script: - ln -s /usr/bin/x86_64-linux-gnu-g++-6 "$HOME/g++" script: - - bundle exec parallel_test spec/ --group-by filesize --type rspec + - travis_retry bundle exec parallel_test spec/ --group-by filesize --type rspec - npm test - bundle exec i18n-tasks unused diff --git a/Gemfile b/Gemfile index 225e7cf48660ff3d453568cbc99a7ed318c3e372..77fffe7a66d8eeab10d36d91bd553dd7ea6aaebb 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,7 @@ ruby '>= 2.3.0', '< 2.5.0' gem 'pkg-config', '~> 1.2' gem 'puma', '~> 3.8' -gem 'rails', '~> 5.0' +gem 'rails', '~> 5.1.0' gem 'uglifier', '~> 3.2' gem 'hamlit-rails', '~> 0.2' @@ -19,7 +19,7 @@ gem 'paperclip', '~> 5.1' gem 'paperclip-av-transcoder', '~> 0.6' gem 'addressable', '~> 2.5' -gem 'bootsnap', '~> 0.3' +gem 'bootsnap' gem 'cld3', '~> 3.1' gem 'devise', '~> 4.2' gem 'devise-two-factor', '~> 3.0' @@ -38,6 +38,7 @@ gem 'nokogiri', '~> 1.7' gem 'oj', '~> 3.0' gem 'ostatus2', '~> 2.0' gem 'ox', '~> 2.5' +gem 'pundit', '~> 1.1' gem 'rabl', '~> 0.13' gem 'rack-attack', '~> 5.0' gem 'rack-cors', '~> 0.4', require: 'rack/cors' @@ -51,13 +52,14 @@ gem 'sanitize', '~> 4.4' gem 'sidekiq', '~> 5.0' gem 'sidekiq-scheduler', '~> 2.1' gem 'sidekiq-unique-jobs', '~> 5.0' +gem 'sidekiq-bulk', '~>0.1.1' gem 'simple-navigation', '~> 4.0' gem 'simple_form', '~> 3.4' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'statsd-instrument', '~> 2.1' gem 'twitter-text', '~> 1.14' gem 'tzinfo-data', '~> 1.2017' -gem 'webpacker', '~> 1.2' +gem 'webpacker', '~> 2.0' group :development, :test do gem 'fabrication', '~> 2.16' @@ -69,6 +71,7 @@ end group :test do gem 'capybara', '~> 2.14' + gem 'climate_control', '~> 0.2' gem 'faker', '~> 1.7' gem 'microformats2', '~> 3.0' gem 'rails-controller-testing', '~> 1.0' @@ -86,7 +89,7 @@ group :development do gem 'bullet', '~> 5.5' gem 'letter_opener', '~> 1.4' gem 'letter_opener_web', '~> 1.3' - gem 'rubocop', '~> 0.48', require: false + gem 'rubocop', require: false gem 'brakeman', '~> 3.6', require: false gem 'bundler-audit', '~> 0.5', require: false gem 'scss_lint', '~> 0.53', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 4d27dc9be91b004dac4780c8982feaecec083dd5..00ce84556fad25f67475bb5339eff644c3a29bb7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,40 +1,40 @@ GEM remote: https://rubygems.org/ specs: - actioncable (5.0.3) - actionpack (= 5.0.3) - nio4r (>= 1.2, < 3.0) + actioncable (5.1.1) + actionpack (= 5.1.1) + nio4r (~> 2.0) websocket-driver (~> 0.6.1) - actionmailer (5.0.3) - actionpack (= 5.0.3) - actionview (= 5.0.3) - activejob (= 5.0.3) + actionmailer (5.1.1) + actionpack (= 5.1.1) + actionview (= 5.1.1) + activejob (= 5.1.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.0.3) - actionview (= 5.0.3) - activesupport (= 5.0.3) + actionpack (5.1.1) + actionview (= 5.1.1) + activesupport (= 5.1.1) rack (~> 2.0) rack-test (~> 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.0.3) - activesupport (= 5.0.3) + actionview (5.1.1) + activesupport (= 5.1.1) builder (~> 3.1) - erubis (~> 2.7.0) + erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) active_record_query_trace (1.5.4) - activejob (5.0.3) - activesupport (= 5.0.3) + activejob (5.1.1) + activesupport (= 5.1.1) globalid (>= 0.3.6) - activemodel (5.0.3) - activesupport (= 5.0.3) - activerecord (5.0.3) - activemodel (= 5.0.3) - activesupport (= 5.0.3) - arel (~> 7.0) - activesupport (5.0.3) + activemodel (5.1.1) + activesupport (= 5.1.1) + activerecord (5.1.1) + activemodel (= 5.1.1) + activesupport (= 5.1.1) + arel (~> 8.0) + activesupport (5.1.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (~> 0.7) minitest (~> 5.1) @@ -43,22 +43,22 @@ GEM public_suffix (~> 2.0, >= 2.0.2) airbrussh (1.2.0) sshkit (>= 1.6.1, != 1.7.0) - annotate (2.7.1) + annotate (2.7.2) activerecord (>= 3.2, < 6.0) - rake (>= 10.4, < 12.0) - arel (7.1.4) + rake (>= 10.4, < 13.0) + arel (8.0.0) ast (2.3.0) attr_encrypted (3.0.3) encryptor (~> 3.0.0) av (0.9.0) cocaine (~> 0.5.3) - aws-sdk (2.9.21) - aws-sdk-resources (= 2.9.21) - aws-sdk-core (2.9.21) + aws-sdk (2.9.37) + aws-sdk-resources (= 2.9.37) + aws-sdk-core (2.9.37) aws-sigv4 (~> 1.0) jmespath (~> 1.0) - aws-sdk-resources (2.9.21) - aws-sdk-core (= 2.9.21) + aws-sdk-resources (2.9.37) + aws-sdk-core (= 2.9.37) aws-sigv4 (1.0.0) bcrypt (3.1.11) better_errors (2.1.1) @@ -67,9 +67,9 @@ 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) - brakeman (3.6.1) + brakeman (3.6.2) builder (3.2.3) bullet (5.5.1) activesupport (>= 3.0.0) @@ -85,7 +85,7 @@ GEM capistrano-bundler (1.2.0) capistrano (~> 3.1) sshkit (~> 1.2) - capistrano-rails (1.2.3) + capistrano-rails (1.3.0) capistrano (~> 3.1) capistrano-bundler (~> 1.1) capistrano-rbenv (2.1.1) @@ -93,7 +93,7 @@ GEM sshkit (~> 1.3) capistrano-yarn (2.0.2) capistrano (~> 3.0) - capybara (2.14.0) + capybara (2.14.2) addressable mime-types (>= 1.16) nokogiri (>= 1.3.3) @@ -130,7 +130,7 @@ GEM docile (1.1.5) domain_name (0.5.20170404) unf (>= 0.0.5, < 1.0.0) - doorkeeper (4.2.5) + doorkeeper (4.2.6) railties (>= 4.2) dotenv (2.2.1) dotenv-rails (2.2.1) @@ -141,6 +141,7 @@ GEM thread thread_safe encryptor (3.0.0) + erubi (1.6.0) erubis (2.7.0) et-orbi (1.0.4) tzinfo @@ -185,7 +186,7 @@ GEM httplog (0.99.3) colorize rack - i18n (0.8.1) + i18n (0.8.4) i18n-tasks (0.9.15) activesupport (>= 4.0.2) ast (>= 2.1.0) @@ -225,7 +226,7 @@ GEM railties (>= 4, < 5.2) loofah (2.0.3) nokogiri (>= 1.5.9) - mail (2.6.5) + mail (2.6.6) mime-types (>= 1.16, < 4) method_source (0.8.2) microformats2 (3.1.0) @@ -235,22 +236,22 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) mimemagic (0.3.2) - mini_portile2 (2.1.0) + mini_portile2 (2.2.0) minitest (5.10.2) msgpack (1.1.0) multi_json (1.12.1) net-scp (1.2.1) net-ssh (>= 2.6.5) net-ssh (4.1.0) - nio4r (2.0.0) - nokogiri (1.7.2) - mini_portile2 (~> 2.1.0) - nokogumbo (1.4.11) + nio4r (2.1.0) + nokogiri (1.8.0) + mini_portile2 (~> 2.2.0) + nokogumbo (1.4.13) nokogiri - oj (3.0.9) + oj (3.1.0) openssl (2.0.3) orm_adapter (0.5.0) - ostatus2 (2.0.0) + ostatus2 (2.0.1) addressable (~> 2.4) http (~> 2.0) nokogiri (~> 1.6) @@ -273,7 +274,7 @@ GEM pg (0.20.0) pghero (1.7.0) activerecord - pkg-config (1.2.0) + pkg-config (1.2.3) powerpack (0.1.1) pry (0.10.4) coderay (~> 1.1.0) @@ -282,7 +283,9 @@ GEM pry-rails (0.3.6) pry (>= 0.10.4) public_suffix (2.0.5) - puma (3.8.2) + puma (3.9.1) + pundit (1.1.0) + activesupport (>= 3.0.0) rabl (0.13.1) activesupport (>= 2.3.14) rack (2.0.3) @@ -294,17 +297,17 @@ GEM rack-test (0.6.3) rack (>= 1.0) rack-timeout (0.4.2) - rails (5.0.3) - actioncable (= 5.0.3) - actionmailer (= 5.0.3) - actionpack (= 5.0.3) - actionview (= 5.0.3) - activejob (= 5.0.3) - activemodel (= 5.0.3) - activerecord (= 5.0.3) - activesupport (= 5.0.3) + rails (5.1.1) + actioncable (= 5.1.1) + actionmailer (= 5.1.1) + actionpack (= 5.1.1) + actionview (= 5.1.1) + activejob (= 5.1.1) + activemodel (= 5.1.1) + activerecord (= 5.1.1) + activesupport (= 5.1.1) bundler (>= 1.3.0, < 2.0) - railties (= 5.0.3) + railties (= 5.1.1) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.2) actionpack (~> 5.x, >= 5.0.1) @@ -320,15 +323,15 @@ GEM railties (~> 5.0) rails-settings-cached (0.6.5) rails (>= 4.2.0) - railties (5.0.3) - actionpack (= 5.0.3) - activesupport (= 5.0.3) + railties (5.1.1) + actionpack (= 5.1.1) + activesupport (= 5.1.1) method_source rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) rainbow (2.2.2) rake - rake (11.3.0) + rake (12.0.0) redis (3.3.3) redis-actionpack (5.0.1) actionpack (>= 4.0, < 6) @@ -374,7 +377,8 @@ GEM rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) rspec-support (3.6.0) - rubocop (0.48.1) + rubocop (0.49.1) + parallel (~> 1.10) parser (>= 2.3.3.1, < 3.0) powerpack (~> 0.1) rainbow (>= 1.99.1, < 3.0) @@ -382,10 +386,10 @@ GEM unicode-display_width (~> 1.0, >= 1.0.1) ruby-oembed (0.12.0) ruby-progressbar (1.8.1) - rufus-scheduler (3.4.0) + rufus-scheduler (3.4.2) et-orbi (~> 1.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) @@ -393,12 +397,15 @@ GEM scss_lint (0.53.0) rake (>= 0.9, < 13) sass (~> 3.4.20) - sidekiq (5.0.0) + sidekiq (5.0.2) concurrent-ruby (~> 1.0) connection_pool (~> 2.2, >= 2.2.0) rack-protection (>= 1.5.0) redis (~> 3.3, >= 3.3.3) - sidekiq-scheduler (2.1.4) + sidekiq-bulk (0.1.1) + activesupport + sidekiq + sidekiq-scheduler (2.1.5) redis (~> 3) rufus-scheduler (~> 3.2) sidekiq (>= 3) @@ -454,14 +461,14 @@ GEM addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff - webpacker (1.2) + webpacker (2.0) activesupport (>= 4.2) multi_json (~> 1.2) railties (>= 4.2) websocket-driver (0.6.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.2) - xpath (2.0.0) + xpath (2.1.0) nokogiri (~> 1.3) PLATFORMS @@ -474,7 +481,7 @@ DEPENDENCIES aws-sdk (~> 2.9) better_errors (~> 2.1) binding_of_caller (~> 0.7) - bootsnap (~> 0.3) + bootsnap brakeman (~> 3.6) bullet (~> 5.5) bundler-audit (~> 0.5) @@ -484,6 +491,7 @@ DEPENDENCIES capistrano-yarn (~> 2.0) capybara (~> 2.14) cld3 (~> 3.1) + climate_control (~> 0.2) devise (~> 4.2) devise-two-factor (~> 3.0) doorkeeper (~> 4.2) @@ -518,11 +526,12 @@ DEPENDENCIES pkg-config (~> 1.2) pry-rails (~> 0.3) puma (~> 3.8) + pundit (~> 1.1) rabl (~> 0.13) rack-attack (~> 5.0) rack-cors (~> 0.4) rack-timeout (~> 0.4) - rails (~> 5.0) + rails (~> 5.1.0) rails-controller-testing (~> 1.0) rails-i18n (~> 5.0) rails-settings-cached (~> 0.6) @@ -532,11 +541,12 @@ DEPENDENCIES rqrcode (~> 0.10) rspec-rails (~> 3.6) rspec-sidekiq (~> 3.0) - rubocop (~> 0.48) + rubocop ruby-oembed (~> 0.12) sanitize (~> 4.4) scss_lint (~> 0.53) sidekiq (~> 5.0) + sidekiq-bulk (~> 0.1.1) sidekiq-scheduler (~> 2.1) sidekiq-unique-jobs (~> 5.0) simple-navigation (~> 4.0) @@ -548,10 +558,10 @@ DEPENDENCIES tzinfo-data (~> 1.2017) uglifier (~> 3.2) webmock (~> 3.0) - webpacker (~> 1.2) + webpacker (~> 2.0) RUBY VERSION ruby 2.4.1p111 BUNDLED WITH - 1.14.6 + 1.15.1 diff --git a/README.md b/README.md index 32bec0be9ad203032ebed9f5da625743e2c80f54..8b902b7b4e29600976b0761cb944f06c9f9ccb46 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ An alternative implementation of the GNU social project. Based on [ActivityStrea Click on the screenshot to watch a demo of the UI: -[![Screenshot](http://puu.sh/vMcvm/290f459dd4.jpg)][youtube_demo] +[![Screenshot](https://i.imgur.com/pG3Nnz3.jpg)][youtube_demo] [youtube_demo]: https://www.youtube.com/watch?v=YO1jQ8_rAMU diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 4c41c42159cff9569a643ea14e4b730ed7e8ea70..ef2f8c4c2eaf36f128935db8105edd047b187a67 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -2,16 +2,43 @@ module Admin class AccountsController < BaseController + before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload] + before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload] + def index @accounts = filtered_accounts.page(params[:page]) end - def show - @account = Account.find(params[:id]) + def show; end + + def subscribe + Pubsubhubbub::SubscribeWorker.perform_async(@account.id) + redirect_to admin_account_path(@account.id) + end + + def unsubscribe + UnsubscribeService.new.call(@account) + redirect_to admin_account_path(@account.id) + end + + def redownload + @account.avatar = @account.avatar_remote_url + @account.header = @account.header_remote_url + @account.save! + + redirect_to admin_account_path(@account.id) end private + def set_account + @account = Account.find(params[:id]) + end + + def require_remote_account! + redirect_to admin_account_path(@account.id) if @account.local? + end + def filtered_accounts AccountFilter.new(filter_params).results end diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index b8f170ec28555ea9f50d623d5d315898b67d686f..ac93248a8563f1f6810b88ba31396dd727a73a0a 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -3,13 +3,18 @@ module Admin class InstancesController < BaseController def index - @instances = ordered_instances.page(params[:page]) + @instances = ordered_instances end private + def paginated_instances + Account.remote.by_domain_accounts.page(params[:page]) + end + helper_method :paginated_instances + def ordered_instances - Account.remote.by_domain_accounts + paginated_instances.map { |account| Instance.new(account) } end end end diff --git a/app/controllers/admin/pubsubhubbub_controller.rb b/app/controllers/admin/pubsubhubbub_controller.rb deleted file mode 100644 index 2677a59e46bb8d40ce70acdf9fb48692f989ce34..0000000000000000000000000000000000000000 --- a/app/controllers/admin/pubsubhubbub_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module Admin - class PubsubhubbubController < BaseController - def index - @subscriptions = Subscription.order(id: :desc).includes(:account).page(params[:page]) - end - end -end diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb index 0e7a8943709560edcea632eecb99d525efc91907..32434d30f707dde787a5159e1934a91eadbce235 100644 --- a/app/controllers/admin/reported_statuses_controller.rb +++ b/app/controllers/admin/reported_statuses_controller.rb @@ -2,6 +2,8 @@ module Admin class ReportedStatusesController < BaseController + include Authorization + before_action :set_report before_action :set_status @@ -11,6 +13,7 @@ module Admin end def destroy + authorize @status, :destroy? RemovalWorker.perform_async(@status.id) redirect_to admin_report_path(@report) end diff --git a/app/controllers/admin/subscriptions_controller.rb b/app/controllers/admin/subscriptions_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..624a475a3d2f4e1e29e9da1820f8c953385811e7 --- /dev/null +++ b/app/controllers/admin/subscriptions_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Admin + class SubscriptionsController < BaseController + def index + @subscriptions = ordered_subscriptions.page(requested_page) + end + + private + + def ordered_subscriptions + Subscription.order(id: :desc).includes(:account) + end + + def requested_page + params[:page].to_i + end + end +end diff --git a/app/controllers/api/activitypub/activities_controller.rb b/app/controllers/api/activitypub/activities_controller.rb index ba03738fc9265d2cd67eccdbff6f05b2017c4346..a880ee92f27803fc9c468d0e65005ef60a7e3545 100644 --- a/app/controllers/api/activitypub/activities_controller.rb +++ b/app/controllers/api/activitypub/activities_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true -class Api::Activitypub::ActivitiesController < ApiController +class Api::ActivityPub::ActivitiesController < Api::BaseController + include Authorization + # before_action :set_follow, only: [:show_follow] before_action :set_status, only: [:show_status] @@ -8,7 +10,7 @@ class Api::Activitypub::ActivitiesController < ApiController # Show a status in AS2 format, as either an Announce (reblog) or a Create (post) activity. def show_status - return forbidden unless @status.permitted? + authorize @status, :show? if @status.reblog? render :show_status_announce diff --git a/app/controllers/api/activitypub/notes_controller.rb b/app/controllers/api/activitypub/notes_controller.rb index 6489243dc06eba3b0cfd4acea5c793c824cc50d3..96652b8791cf7fd6bfb582ce54a92e7446779470 100644 --- a/app/controllers/api/activitypub/notes_controller.rb +++ b/app/controllers/api/activitypub/notes_controller.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true -class Api::Activitypub::NotesController < ApiController +class Api::ActivityPub::NotesController < Api::BaseController + include Authorization + before_action :set_status respond_to :activitystreams2 def show - forbidden unless @status.permitted? + authorize @status, :show? end private diff --git a/app/controllers/api/activitypub/outbox_controller.rb b/app/controllers/api/activitypub/outbox_controller.rb index 7b6cbdd38be723482efa313868e6fc18a2c53a77..1af04cb54fe6534bd7fd78d5ba59da4be616210b 100644 --- a/app/controllers/api/activitypub/outbox_controller.rb +++ b/app/controllers/api/activitypub/outbox_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::Activitypub::OutboxController < ApiController +class Api::ActivityPub::OutboxController < Api::BaseController before_action :set_account respond_to :activitystreams2 diff --git a/app/controllers/api_controller.rb b/app/controllers/api/base_controller.rb similarity index 81% rename from app/controllers/api_controller.rb rename to app/controllers/api/base_controller.rb index 1c67b6fdc61d71663c4d302eb91c09391b45119f..c1b2ec3cf58bf7941bc48d1b7682e22b6da65bc5 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -1,16 +1,14 @@ # frozen_string_literal: true -class ApiController < ApplicationController +class Api::BaseController < ApplicationController DEFAULT_STATUSES_LIMIT = 20 DEFAULT_ACCOUNTS_LIMIT = 40 - protect_from_forgery with: :null_session + include RateLimitHeaders skip_before_action :verify_authenticity_token skip_before_action :store_current_location - before_action :set_rate_limit_headers - rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| render json: { error: e.to_s }, status: 422 end @@ -45,17 +43,6 @@ class ApiController < ApplicationController protected - def set_rate_limit_headers - return if request.env['rack.attack.throttle_data'].nil? - - now = Time.now.utc - match_data = request.env['rack.attack.throttle_data']['api'] - - response.headers['X-RateLimit-Limit'] = match_data[:limit].to_s - response.headers['X-RateLimit-Remaining'] = (match_data[:limit] - match_data[:count]).to_s - response.headers['X-RateLimit-Reset'] = (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6) - end - def set_pagination_headers(next_path = nil, prev_path = nil) links = [] links << [next_path, [%w(rel next)]] if next_path diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb index 656851f43b9cafc652db62e59765725b18bb6ef6..6e3e34d964d85c60787ca2bb4b967f328fa98599 100644 --- a/app/controllers/api/oembed_controller.rb +++ b/app/controllers/api/oembed_controller.rb @@ -1,33 +1,25 @@ # frozen_string_literal: true -class Api::OEmbedController < ApiController +class Api::OEmbedController < Api::BaseController respond_to :json def show - @stream_entry = stream_entry_from_url(params[:url]) - @width = params[:maxwidth].present? ? params[:maxwidth].to_i : 400 - @height = params[:maxheight].present? ? params[:maxheight].to_i : nil + @stream_entry = find_stream_entry.stream_entry + @width = maxwidth_or_default + @height = maxheight_or_default end private - def stream_entry_from_url(url) - params = Rails.application.routes.recognize_path(url) - - raise ActiveRecord::RecordNotFound unless recognized_stream_entry_url?(params) - - stream_entry(params) + def find_stream_entry + StreamEntryFinder.new(params[:url]) end - def recognized_stream_entry_url?(params) - %w(stream_entries statuses).include?(params[:controller]) && params[:action] == 'show' + def maxwidth_or_default + (params[:maxwidth].presence || 400).to_i end - def stream_entry(params) - if params[:controller] == 'stream_entries' - StreamEntry.find(params[:id]) - else - Status.find(params[:id]).stream_entry - end + def maxheight_or_default + params[:maxheight].present? ? params[:maxheight].to_i : nil end end diff --git a/app/controllers/api/push_controller.rb b/app/controllers/api/push_controller.rb index 75a1f757b44fe7709e5f119435e234889bc8c253..951867140ca13f0c91859a353bc9c59f23208d3c 100644 --- a/app/controllers/api/push_controller.rb +++ b/app/controllers/api/push_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::PushController < ApiController +class Api::PushController < Api::BaseController def update response, status = process_push_request render plain: response, status: status diff --git a/app/controllers/api/salmon_controller.rb b/app/controllers/api/salmon_controller.rb index 7fc5e548d6a0016de71f9742214155a2fe2eab44..e9e700b18de5c731bbdc77594393493322becf28 100644 --- a/app/controllers/api/salmon_controller.rb +++ b/app/controllers/api/salmon_controller.rb @@ -1,14 +1,12 @@ # frozen_string_literal: true -class Api::SalmonController < ApiController +class Api::SalmonController < Api::BaseController before_action :set_account respond_to :txt def update - payload = request.body.read - - if !payload.nil? && verify?(payload) - SalmonWorker.perform_async(@account.id, payload.force_encoding('UTF-8')) + if verify_payload? + process_salmon head 201 else head 202 @@ -21,7 +19,15 @@ class Api::SalmonController < ApiController @account = Account.find(params[:id]) end - def verify?(payload) - VerifySalmonService.new.call(payload) + def payload + @_payload ||= request.body.read + end + + def verify_payload? + payload.present? && VerifySalmonService.new.call(payload) + end + + def process_salmon + SalmonWorker.perform_async(@account.id, payload.force_encoding('UTF-8')) end end diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb index 135a5632e6d0b15df76154611f20cb3a29c00c92..d3ea986761d94c1bc73b75bda27e5d87b70f6110 100644 --- a/app/controllers/api/subscriptions_controller.rb +++ b/app/controllers/api/subscriptions_controller.rb @@ -1,22 +1,19 @@ # frozen_string_literal: true -class Api::SubscriptionsController < ApiController +class Api::SubscriptionsController < Api::BaseController before_action :set_account respond_to :txt def show - if @account.subscription(api_subscription_url(@account.id)).valid?(params['hub.topic']) - @account.update(subscription_expires_at: Time.now.utc + (params['hub.lease_seconds'] || 86_400).to_i.seconds) - render plain: HTMLEntities.new.encode(params['hub.challenge']), status: 200 + if subscription.valid?(params['hub.topic']) + @account.update(subscription_expires_at: future_expires) + render plain: encoded_challenge, status: 200 else head 404 end end def update - body = request.body.read - subscription = @account.subscription(api_subscription_url(@account.id)) - if subscription.verify(body, request.headers['HTTP_X_HUB_SIGNATURE']) ProcessingWorker.perform_async(@account.id, body.force_encoding('UTF-8')) end @@ -26,6 +23,28 @@ class Api::SubscriptionsController < ApiController private + def subscription + @_subscription ||= @account.subscription( + api_subscription_url(@account.id) + ) + end + + def body + @_body ||= request.body.read + end + + def encoded_challenge + HTMLEntities.new.encode(params['hub.challenge']) + end + + def future_expires + Time.now.utc + lease_seconds_or_default + end + + def lease_seconds_or_default + (params['hub.lease_seconds'] || 86_400).to_i.seconds + end + def set_account @account = Account.find(params[:id]) end diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..1cf52ff10c5ed7cf53b2eb1a206e6e5f470d5631 --- /dev/null +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::CredentialsController < Api::BaseController + before_action -> { doorkeeper_authorize! :write }, only: [:update] + before_action :require_user! + + def show + @account = current_account + render 'api/v1/accounts/show' + end + + def update + current_account.update!(account_params) + @account = current_account + render 'api/v1/accounts/show' + end + + private + + def account_params + params.permit(:display_name, :note, :avatar, :header) + end +end diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..81aae56d3f67ae6cefde04b4b0f7fa5806921405 --- /dev/null +++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::FollowerAccountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read } + before_action :set_account + after_action :insert_pagination_headers + + respond_to :json + + def index + @accounts = load_accounts + render 'api/v1/accounts/index' + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def load_accounts + default_accounts.merge(paginated_follows).to_a + end + + def default_accounts + Account.includes(:active_relationships).references(:active_relationships) + end + + def paginated_follows + Follow.where(target_account: @account).paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + if records_continue? + api_v1_account_followers_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless @accounts.empty? + api_v1_account_followers_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @accounts.last.active_relationships.first.id + end + + def pagination_since_id + @accounts.first.active_relationships.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def pagination_params(core_params) + params.permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..63c6d54b29c12e074893717af277a54baa4c9b40 --- /dev/null +++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::FollowingAccountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read } + before_action :set_account + after_action :insert_pagination_headers + + respond_to :json + + def index + @accounts = load_accounts + render 'api/v1/accounts/index' + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def load_accounts + default_accounts.merge(paginated_follows).to_a + end + + def default_accounts + Account.includes(:passive_relationships).references(:passive_relationships) + end + + def paginated_follows + Follow.where(account: @account).paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + if records_continue? + api_v1_account_following_index_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless @accounts.empty? + api_v1_account_following_index_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @accounts.last.passive_relationships.first.id + end + + def pagination_since_id + @accounts.first.passive_relationships.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def pagination_params(core_params) + params.permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..cb923ab91777e70b71790a27ef7eac58151a4661 --- /dev/null +++ b/app/controllers/api/v1/accounts/relationships_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::RelationshipsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read } + before_action :require_user! + + respond_to :json + + def index + @accounts = Account.where(id: account_ids).select('id') + @following = Account.following_map(account_ids, current_user.account_id) + @followed_by = Account.followed_by_map(account_ids, current_user.account_id) + @blocking = Account.blocking_map(account_ids, current_user.account_id) + @muting = Account.muting_map(account_ids, current_user.account_id) + @requested = Account.requested_map(account_ids, current_user.account_id) + @domain_blocking = Account.domain_blocking_map(account_ids, current_user.account_id) + end + + private + + def account_ids + @_account_ids ||= Array(params[:id]).map(&:to_i) + end +end diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..c4a8f97f248d8224d76a7b921dac0beec04255a4 --- /dev/null +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::SearchController < Api::BaseController + before_action -> { doorkeeper_authorize! :read } + before_action :require_user! + + respond_to :json + + def show + @accounts = account_search + + render 'api/v1/accounts/index' + end + + private + + def account_search + AccountSearchService.new.call( + params[:q], + limit_param(DEFAULT_ACCOUNTS_LIMIT), + resolving_search?, + current_account + ) + end + + def resolving_search? + params[:resolve] == 'true' + end +end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..504ed8c07d37eb947f5cf493e05e978f96a96368 --- /dev/null +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::StatusesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read } + before_action :set_account + after_action :insert_pagination_headers + + respond_to :json + + def index + @statuses = load_statuses + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def load_statuses + cached_account_statuses.tap do |statuses| + set_maps(statuses) + end + end + + def cached_account_statuses + cache_collection account_statuses, Status + end + + def account_statuses + default_statuses.tap do |statuses| + statuses.merge!(only_media_scope) if params[:only_media] + statuses.merge!(no_replies_scope) if params[:exclude_replies] + end + end + + def default_statuses + permitted_account_statuses.paginate_by_max_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def permitted_account_statuses + @account.statuses.permitted_for(@account, current_account) + end + + def only_media_scope + Status.where(id: account_media_status_ids) + end + + def account_media_status_ids + @account.media_attachments.attached.reorder(nil).select(:status_id).distinct + end + + def no_replies_scope + Status.without_replies + end + + def pagination_params(core_params) + params.permit(:limit, :only_media, :exclude_replies).merge(core_params) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + if records_continue? + api_v1_account_statuses_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless @statuses.empty? + api_v1_account_statuses_url pagination_params(since_id: pagination_since_id) + end + end + + def records_continue? + @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id + end +end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index bd6fb6a2a85b0154afe76dc2f3f9fd1436b2e1f3..8fc0dd36f59b346f813566d826a031f34a83f470 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -1,73 +1,15 @@ # frozen_string_literal: true -class Api::V1::AccountsController < ApiController - before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock, :mute, :unmute, :update_credentials] +class Api::V1::AccountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock, :mute, :unmute] before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock, :mute, :unmute] - before_action -> { doorkeeper_authorize! :write }, only: [:update_credentials] - before_action :require_user!, except: [:show, :following, :followers, :statuses] - before_action :set_account, except: [:verify_credentials, :update_credentials, :suggestions, :search] + before_action :require_user!, except: [:show] + before_action :set_account respond_to :json def show; end - def verify_credentials - @account = current_user.account - render :show - end - - def update_credentials - current_account.update!(account_params) - @account = current_account - render :show - end - - def following - @accounts = Account.includes(:passive_relationships) - .references(:passive_relationships) - .merge(Follow.where(account: @account) - .paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])) - .to_a - - next_path = following_api_v1_account_url(pagination_params(max_id: @accounts.last.passive_relationships.first.id)) if @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) - prev_path = following_api_v1_account_url(pagination_params(since_id: @accounts.first.passive_relationships.first.id)) unless @accounts.empty? - - set_pagination_headers(next_path, prev_path) - - render :index - end - - def followers - @accounts = Account.includes(:active_relationships) - .references(:active_relationships) - .merge(Follow.where(target_account: @account) - .paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), - params[:max_id], - params[:since_id])) - .to_a - - next_path = followers_api_v1_account_url(pagination_params(max_id: @accounts.last.active_relationships.first.id)) if @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) - prev_path = followers_api_v1_account_url(pagination_params(since_id: @accounts.first.active_relationships.first.id)) unless @accounts.empty? - - set_pagination_headers(next_path, prev_path) - - render :index - end - - def statuses - @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) - @statuses = @statuses.where(id: MediaAttachment.where(account: @account).where.not(status_id: nil).reorder('').select('distinct status_id')) if params[:only_media] - @statuses = @statuses.without_replies if params[:exclude_replies] - @statuses = cache_collection(@statuses, Status) - - set_maps(@statuses) - - next_path = statuses_api_v1_account_url(statuses_pagination_params(max_id: @statuses.last.id)) if @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) - prev_path = statuses_api_v1_account_url(statuses_pagination_params(since_id: @statuses.first.id)) unless @statuses.empty? - - set_pagination_headers(next_path, prev_path) - end - def follow FollowService.new.call(current_user.account, @account.acct) set_relationship @@ -111,24 +53,6 @@ class Api::V1::AccountsController < ApiController render :relationship end - def relationships - ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i] - - @accounts = Account.where(id: ids).select('id') - @following = Account.following_map(ids, current_user.account_id) - @followed_by = Account.followed_by_map(ids, current_user.account_id) - @blocking = Account.blocking_map(ids, current_user.account_id) - @muting = Account.muting_map(ids, current_user.account_id) - @requested = Account.requested_map(ids, current_user.account_id) - @domain_blocking = Account.domain_blocking_map(ids, current_user.account_id) - end - - def search - @accounts = AccountSearchService.new.call(params[:q], limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:resolve] == 'true', current_account) - - render :index - end - private def set_account @@ -143,16 +67,4 @@ class Api::V1::AccountsController < ApiController @requested = Account.requested_map([@account.id], current_user.account_id) @domain_blocking = Account.domain_blocking_map([@account.id], current_user.account_id) end - - def pagination_params(core_params) - params.permit(:limit).merge(core_params) - end - - def statuses_pagination_params(core_params) - params.permit(:limit, :only_media, :exclude_replies).merge(core_params) - end - - def account_params - params.permit(:display_name, :note, :avatar, :header) - end end diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb index 2ec7280af05d00c87fca8943ca62ab279f9931d0..98e90894896e33dc1632e5f1f8c6ce93a3652d60 100644 --- a/app/controllers/api/v1/apps_controller.rb +++ b/app/controllers/api/v1/apps_controller.rb @@ -1,14 +1,27 @@ # frozen_string_literal: true -class Api::V1::AppsController < ApiController +class Api::V1::AppsController < Api::BaseController respond_to :json def create - @app = Doorkeeper::Application.create!(name: app_params[:client_name], redirect_uri: app_params[:redirect_uris], scopes: (app_params[:scopes] || Doorkeeper.configuration.default_scopes), website: app_params[:website]) + @app = Doorkeeper::Application.create!(application_options) end private + def application_options + { + name: app_params[:client_name], + redirect_uri: app_params[:redirect_uris], + scopes: app_scopes_or_default, + website: app_params[:website], + } + end + + def app_scopes_or_default + app_params[:scopes] || Doorkeeper.configuration.default_scopes + end + def app_params params.permit(:client_name, :redirect_uris, :scopes, :website) end diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index b2f3ae51254c902f46d445e30b3f744b390599c9..1702953cf73a0ad03766eb737c6ddf02902af33c 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -1,25 +1,61 @@ # frozen_string_literal: true -class Api::V1::BlocksController < ApiController +class Api::V1::BlocksController < Api::BaseController before_action -> { doorkeeper_authorize! :follow } before_action :require_user! + after_action :insert_pagination_headers respond_to :json def index - @accounts = Account.includes(:blocked_by) - .references(:blocked_by) - .merge(Block.where(account: current_account) - .paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])) - .to_a + @accounts = load_accounts + end - next_path = api_v1_blocks_url(pagination_params(max_id: @accounts.last.blocked_by_ids.last)) if @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) - prev_path = api_v1_blocks_url(pagination_params(since_id: @accounts.first.blocked_by_ids.first)) unless @accounts.empty? + private + def load_accounts + default_accounts.merge(paginated_blocks).to_a + end + + def default_accounts + Account.includes(:blocked_by).references(:blocked_by) + end + + def paginated_blocks + Block.where(account: current_account).paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers set_pagination_headers(next_path, prev_path) end - private + def next_path + if records_continue? + api_v1_blocks_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless @accounts.empty? + api_v1_blocks_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @accounts.last.blocked_by_ids.last + end + + def pagination_since_id + @accounts.first.blocked_by_ids.first + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end def pagination_params(core_params) params.permit(:limit).merge(core_params) diff --git a/app/controllers/api/v1/domain_blocks_controller.rb b/app/controllers/api/v1/domain_blocks_controller.rb index f223dd16ee50ecd183a321c6b00ee1db520d0e7e..e93dc603bb2fbcf2015006b4be6a310b8fecca82 100644 --- a/app/controllers/api/v1/domain_blocks_controller.rb +++ b/app/controllers/api/v1/domain_blocks_controller.rb @@ -1,18 +1,16 @@ # frozen_string_literal: true -class Api::V1::DomainBlocksController < ApiController +class Api::V1::DomainBlocksController < Api::BaseController + BLOCK_LIMIT = 100 + before_action -> { doorkeeper_authorize! :follow } before_action :require_user! + after_action :insert_pagination_headers, only: :show respond_to :json def show - @blocks = AccountDomainBlock.where(account: current_account).paginate_by_max_id(limit_param(100), params[:max_id], params[:since_id]) - - next_path = api_v1_domain_blocks_url(pagination_params(max_id: @blocks.last.id)) if @blocks.size == limit_param(100) - prev_path = api_v1_domain_blocks_url(pagination_params(since_id: @blocks.first.id)) unless @blocks.empty? - - set_pagination_headers(next_path, prev_path) + @blocks = load_domain_blocks render json: @blocks.map(&:domain) end @@ -28,6 +26,46 @@ class Api::V1::DomainBlocksController < ApiController private + def load_domain_blocks + account_domain_blocks.paginate_by_max_id( + limit_param(BLOCK_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def account_domain_blocks + current_account.domain_blocks + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + if records_continue? + api_v1_domain_blocks_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless @blocks.empty? + api_v1_domain_blocks_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @blocks.last.id + end + + def pagination_since_id + @blocks.first.id + end + + def records_continue? + @blocks.size == limit_param(BLOCK_LIMIT) + end + def pagination_params(core_params) params.permit(:limit).merge(core_params) end diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index 22b93fe79a2e7a488b1a32e36d2c112887379faa..fe0819a3f5913cb768814c73944907bc1259dc7d 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -1,24 +1,72 @@ # frozen_string_literal: true -class Api::V1::FavouritesController < ApiController +class Api::V1::FavouritesController < Api::BaseController before_action -> { doorkeeper_authorize! :read } before_action :require_user! + after_action :insert_pagination_headers respond_to :json def index - results = Favourite.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) - @statuses = cache_collection(Status.where(id: results.map(&:status_id)), Status) + @statuses = load_statuses + end + + private + + def load_statuses + cached_favourites.tap do |statuses| + set_maps(statuses) + end + end - set_maps(@statuses) + def cached_favourites + cache_collection( + Status.where( + id: results.map(&:status_id) + ), + Status + ) + end - next_path = api_v1_favourites_url(pagination_params(max_id: results.last.id)) if results.size == limit_param(DEFAULT_STATUSES_LIMIT) - prev_path = api_v1_favourites_url(pagination_params(since_id: results.first.id)) unless results.empty? + def results + @_results ||= account_favourites.paginate_by_max_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) + end + def account_favourites + current_account.favourites + end + + def insert_pagination_headers set_pagination_headers(next_path, prev_path) end - private + def next_path + if records_continue? + api_v1_favourites_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless results.empty? + api_v1_favourites_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + results.last.id + end + + def pagination_since_id + results.first.id + end + + def records_continue? + results.size == limit_param(DEFAULT_STATUSES_LIMIT) + end def pagination_params(core_params) params.permit(:limit).merge(core_params) diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb index fbb2733d822017afb02091f04e6e1bb180d040dc..eed22ef4fbae183eafb49c198ad06ce1cf556e33 100644 --- a/app/controllers/api/v1/follow_requests_controller.rb +++ b/app/controllers/api/v1/follow_requests_controller.rb @@ -1,34 +1,74 @@ # frozen_string_literal: true -class Api::V1::FollowRequestsController < ApiController +class Api::V1::FollowRequestsController < Api::BaseController before_action -> { doorkeeper_authorize! :follow } before_action :require_user! + after_action :insert_pagination_headers, only: :index def index - @accounts = Account.includes(:follow_requests) - .references(:follow_requests) - .merge(FollowRequest.where(target_account: current_account) - .paginate_by_max_id(DEFAULT_ACCOUNTS_LIMIT, params[:max_id], params[:since_id])) - .to_a - - next_path = api_v1_follow_requests_url(pagination_params(max_id: @accounts.last.follow_requests.last.id)) if @accounts.size == DEFAULT_ACCOUNTS_LIMIT - prev_path = api_v1_follow_requests_url(pagination_params(since_id: @accounts.first.follow_requests.first.id)) unless @accounts.empty? - - set_pagination_headers(next_path, prev_path) + @accounts = load_accounts end def authorize - AuthorizeFollowService.new.call(Account.find(params[:id]), current_account) + AuthorizeFollowService.new.call(account, current_account) render_empty end def reject - RejectFollowService.new.call(Account.find(params[:id]), current_account) + RejectFollowService.new.call(account, current_account) render_empty end private + def account + Account.find(params[:id]) + end + + def load_accounts + default_accounts.merge(paginated_follow_requests).to_a + end + + def default_accounts + Account.includes(:follow_requests).references(:follow_requests) + end + + def paginated_follow_requests + FollowRequest.where(target_account: current_account).paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + if records_continue? + api_v1_follow_requests_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless @accounts.empty? + api_v1_follow_requests_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @accounts.last.follow_requests.last.id + end + + def pagination_since_id + @accounts.first.follow_requests.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + def pagination_params(core_params) params.permit(:limit).merge(core_params) end diff --git a/app/controllers/api/v1/follows_controller.rb b/app/controllers/api/v1/follows_controller.rb index 67d823398a686b7bf406975858c7bd64a64ea3ef..bcdb4e177a752c9e1416c2d888d94af5cadda2d2 100644 --- a/app/controllers/api/v1/follows_controller.rb +++ b/app/controllers/api/v1/follows_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::FollowsController < ApiController +class Api::V1::FollowsController < Api::BaseController before_action -> { doorkeeper_authorize! :follow } before_action :require_user! diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb index 51d92838acf179ca47dff1d4e948808deba05ad6..ce2181879be0f64e1cdd06f13b1078e1fb96f9d5 100644 --- a/app/controllers/api/v1/instances_controller.rb +++ b/app/controllers/api/v1/instances_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::InstancesController < ApiController +class Api::V1::InstancesController < Api::BaseController respond_to :json def show; end diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index aed3578d7e827d7f327ed2598c7e5d13fecd6346..25a3313195cbdc62513a93aa1d4624d76d8d7635 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::MediaController < ApiController +class Api::V1::MediaController < Api::BaseController before_action -> { doorkeeper_authorize! :write } before_action :require_user! @@ -10,11 +10,11 @@ class Api::V1::MediaController < ApiController respond_to :json def create - @media = MediaAttachment.create!(account: current_user.account, file: media_params[:file]) + @media = current_account.media_attachments.create!(file: media_params[:file]) rescue Paperclip::Errors::NotIdentifiedByImageMagickError - render json: { error: 'File type of uploaded media could not be verified' }, status: 422 + render json: file_type_error, status: 422 rescue Paperclip::Error - render json: { error: 'Error processing thumbnail for uploaded media' }, status: 500 + render json: processing_error, status: 500 end private @@ -22,4 +22,12 @@ class Api::V1::MediaController < ApiController def media_params params.permit(:file) end + + def file_type_error + { error: 'File type of uploaded media could not be verified' } + end + + def processing_error + { error: 'Error processing thumbnail for uploaded media' } + end end diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index 53eb2554b60249c773c64b4a220b1ac069f7642a..2a353df039a622a93433600306ea8cbe564a54cb 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -1,25 +1,61 @@ # frozen_string_literal: true -class Api::V1::MutesController < ApiController +class Api::V1::MutesController < Api::BaseController before_action -> { doorkeeper_authorize! :follow } before_action :require_user! + after_action :insert_pagination_headers respond_to :json def index - @accounts = Account.includes(:muted_by) - .references(:muted_by) - .merge(Mute.where(account: current_account) - .paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])) - .to_a + @accounts = load_accounts + end - next_path = api_v1_mutes_url(pagination_params(max_id: @accounts.last.muted_by_ids.last)) if @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) - prev_path = api_v1_mutes_url(pagination_params(since_id: @accounts.first.muted_by_ids.first)) unless @accounts.empty? + private + def load_accounts + default_accounts.merge(paginated_mutes).to_a + end + + def default_accounts + Account.includes(:muted_by).references(:muted_by) + end + + def paginated_mutes + Mute.where(account: current_account).paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers set_pagination_headers(next_path, prev_path) end - private + def next_path + if records_continue? + api_v1_mutes_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless @accounts.empty? + api_v1_mutes_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @accounts.last.muted_by_ids.last + end + + def pagination_since_id + @accounts.first.muted_by_ids.first + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end def pagination_params(core_params) params.permit(:limit).merge(core_params) diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index e79434f1da98faad974d40ef0cdbe0005a6cf7b0..a28e99f2fb0c433f03dd19cbb77f999761886098 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -1,42 +1,83 @@ # frozen_string_literal: true -class Api::V1::NotificationsController < ApiController +class Api::V1::NotificationsController < Api::BaseController before_action -> { doorkeeper_authorize! :read } before_action :require_user! + after_action :insert_pagination_headers, only: :index respond_to :json DEFAULT_NOTIFICATIONS_LIMIT = 15 def index - @notifications = Notification.where(account: current_account).browserable(exclude_types).paginate_by_max_id(limit_param(DEFAULT_NOTIFICATIONS_LIMIT), params[:max_id], params[:since_id]) - @notifications = cache_collection(@notifications, Notification) - statuses = @notifications.select { |n| !n.target_status.nil? }.map(&:target_status) - - set_maps(statuses) - - next_path = api_v1_notifications_url(pagination_params(max_id: @notifications.last.id)) unless @notifications.empty? - prev_path = api_v1_notifications_url(pagination_params(since_id: @notifications.first.id)) unless @notifications.empty? - - set_pagination_headers(next_path, prev_path) + @notifications = load_notifications + set_maps_for_notification_target_statuses end def show - @notification = Notification.where(account: current_account).find(params[:id]) + @notification = current_account.notifications.find(params[:id]) end def clear - Notification.where(account: current_account).delete_all + current_account.notifications.delete_all render_empty end def dismiss - Notification.find_by!(account: current_account, id: params[:id]).destroy! + current_account.notifications.find_by!(id: params[:id]).destroy! render_empty end private + def load_notifications + cache_collection paginated_notifications, Notification + end + + def paginated_notifications + browserable_account_notifications.paginate_by_max_id( + limit_param(DEFAULT_NOTIFICATIONS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def browserable_account_notifications + current_account.notifications.browserable(exclude_types) + end + + def set_maps_for_notification_target_statuses + set_maps target_statuses_from_notifications + end + + def target_statuses_from_notifications + @notifications.reject { |notification| notification.target_status.nil? }.map(&:target_status) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + unless @notifications.empty? + api_v1_notifications_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless @notifications.empty? + api_v1_notifications_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @notifications.last.id + end + + def pagination_since_id + @notifications.first.id + end + def exclude_types val = params.permit(exclude_types: [])[:exclude_types] || [] val = [val] unless val.is_a?(Enumerable) diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index f83c573cb5fbaeaa798c02581cbe48b338125f1a..71df76e922e0b285a2908451e5e452a8a4a3efb7 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::ReportsController < ApiController +class Api::V1::ReportsController < Api::BaseController before_action -> { doorkeeper_authorize! :read }, except: [:create] before_action -> { doorkeeper_authorize! :write }, only: [:create] before_action :require_user! @@ -8,22 +8,32 @@ class Api::V1::ReportsController < ApiController respond_to :json def index - @reports = Report.where(account: current_account) + @reports = current_account.reports end def create - status_ids = report_params[:status_ids].is_a?(Enumerable) ? report_params[:status_ids] : [report_params[:status_ids]] - - @report = Report.create!(account: current_account, - target_account: Account.find(report_params[:account_id]), - status_ids: Status.find(status_ids).pluck(:id), - comment: report_params[:comment]) - + @report = current_account.reports.create!( + target_account: reported_account, + status_ids: reported_status_ids, + comment: report_params[:comment] + ) render :show end private + def reported_status_ids + Status.find(status_ids).pluck(:id) + end + + def status_ids + Array(report_params[:status_ids]) + end + + def reported_account + Account.find(report_params[:account_id]) + end + def report_params params.permit(:account_id, :comment, status_ids: []) end diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb index 6b12924581dbcc0eec2fa8a966f55414faf768cd..8b832148c303dd896c69db8de21517b6c128b3c0 100644 --- a/app/controllers/api/v1/search_controller.rb +++ b/app/controllers/api/v1/search_controller.rb @@ -1,9 +1,26 @@ # frozen_string_literal: true -class Api::V1::SearchController < ApiController +class Api::V1::SearchController < Api::BaseController + RESULTS_LIMIT = 5 + respond_to :json def index - @search = OpenStruct.new(SearchService.new.call(params[:q], 5, params[:resolve] == 'true', current_account)) + @search = OpenStruct.new(search_results) + end + + private + + def search_results + SearchService.new.call( + params[:q], + RESULTS_LIMIT, + resolving_search?, + current_account + ) + end + + def resolving_search? + params[:resolve] == 'true' end end diff --git a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..e58184939529d955d12158ed2a5ca195b18f70ec --- /dev/null +++ b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController + include Authorization + + before_action :authorize_if_got_token + before_action :set_status + after_action :insert_pagination_headers + + respond_to :json + + def index + @accounts = load_accounts + render 'api/v1/statuses/accounts' + end + + private + + def load_accounts + default_accounts.merge(paginated_favourites).to_a + end + + def default_accounts + Account + .includes(:favourites) + .references(:favourites) + .where(favourites: { status_id: @status.id }) + end + + def paginated_favourites + Favourite.paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + if records_continue? + api_v1_status_favourited_by_index_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless @accounts.empty? + api_v1_status_favourited_by_index_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @accounts.last.favourites.last.id + end + + def pagination_since_id + @accounts.first.favourites.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + # Reraise in order to get a 404 instead of a 403 error code + raise ActiveRecord::RecordNotFound + end + + def authorize_if_got_token + request_token = Doorkeeper::OAuth::Token.from_request(request, *Doorkeeper.configuration.access_token_methods) + doorkeeper_authorize! :read if request_token + end + + def pagination_params(core_params) + params.permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..b6fb13cc073570447cec0e4d962c5ad06fc6c79a --- /dev/null +++ b/app/controllers/api/v1/statuses/favourites_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::FavouritesController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write } + before_action :require_user! + + respond_to :json + + def create + @status = favourited_status + render 'api/v1/statuses/show' + end + + def destroy + @status = requested_status + @favourites_map = { @status.id => false } + + UnfavouriteWorker.perform_async(current_user.account_id, @status.id) + + render 'api/v1/statuses/show' + end + + private + + def favourited_status + service_result.status.reload + end + + def service_result + FavouriteService.new.call(current_user.account, requested_status) + end + + def requested_status + Status.find(params[:status_id]) + end +end diff --git a/app/controllers/api/v1/statuses/mutes_controller.rb b/app/controllers/api/v1/statuses/mutes_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..eab88f2efdb9ceaf0c889bf24643bd27cb64a3c1 --- /dev/null +++ b/app/controllers/api/v1/statuses/mutes_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::MutesController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write } + before_action :require_user! + before_action :set_status + before_action :set_conversation + + respond_to :json + + def create + current_account.mute_conversation!(@conversation) + @mutes_map = { @conversation.id => true } + + render 'api/v1/statuses/show' + end + + def destroy + current_account.unmute_conversation!(@conversation) + @mutes_map = { @conversation.id => false } + + render 'api/v1/statuses/show' + end + + private + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + # Reraise in order to get a 404 instead of a 403 error code + raise ActiveRecord::RecordNotFound + end + + def set_conversation + @conversation = @status.conversation + raise Mastodon::ValidationError if @conversation.nil? + end +end diff --git a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..43593d3c5a78ce8aaa1223b049bb8e30783abe79 --- /dev/null +++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController + include Authorization + + before_action :authorize_if_got_token + before_action :set_status + after_action :insert_pagination_headers + + respond_to :json + + def index + @accounts = load_accounts + render 'api/v1/statuses/accounts' + end + + private + + def load_accounts + default_accounts.merge(paginated_statuses).to_a + end + + def default_accounts + Account.includes(:statuses).references(:statuses) + end + + def paginated_statuses + Status.where(reblog_of_id: @status.id).paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + if records_continue? + api_v1_status_reblogged_by_index_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless @accounts.empty? + api_v1_status_reblogged_by_index_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @accounts.last.statuses.last.id + end + + def pagination_since_id + @accounts.first.statuses.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + # Reraise in order to get a 404 instead of a 403 error code + raise ActiveRecord::RecordNotFound + end + + def authorize_if_got_token + request_token = Doorkeeper::OAuth::Token.from_request(request, *Doorkeeper.configuration.access_token_methods) + doorkeeper_authorize! :read if request_token + end + + def pagination_params(core_params) + params.permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..ee9c5b3a624f419885c3ccbde1aecc1b8581d53c --- /dev/null +++ b/app/controllers/api/v1/statuses/reblogs_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::ReblogsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write } + before_action :require_user! + + respond_to :json + + def create + @status = ReblogService.new.call(current_user.account, status_for_reblog) + render 'api/v1/statuses/show' + end + + def destroy + @status = status_for_destroy.reblog + @reblogs_map = { @status.id => false } + + authorize status_for_destroy, :unreblog? + RemovalWorker.perform_async(status_for_destroy.id) + + render 'api/v1/statuses/show' + end + + private + + def status_for_reblog + Status.find params[:status_id] + end + + def status_for_destroy + current_user.account.statuses.where(reblog_of_id: params[:status_id]).first! + end +end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 852ffc3ab1ea65c6ad0b187b42af4484da33ac5c..9aa1cbc4d178131146d01f4764e3f1a1c23932fd 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true -class Api::V1::StatusesController < ApiController - before_action :authorize_if_got_token, except: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite, :mute, :unmute] - before_action -> { doorkeeper_authorize! :write }, only: [:create, :destroy, :reblog, :unreblog, :favourite, :unfavourite, :mute, :unmute] - before_action :require_user!, except: [:show, :context, :card, :reblogged_by, :favourited_by] - before_action :set_status, only: [:show, :context, :card, :reblogged_by, :favourited_by, :mute, :unmute] - before_action :set_conversation, only: [:mute, :unmute] +class Api::V1::StatusesController < Api::BaseController + include Authorization + + before_action :authorize_if_got_token, except: [:create, :destroy] + before_action -> { doorkeeper_authorize! :write }, only: [:create, :destroy] + before_action :require_user!, except: [:show, :context, :card] + before_action :set_status, only: [:show, :context, :card] respond_to :json @@ -31,36 +32,6 @@ class Api::V1::StatusesController < ApiController render_empty if @card.nil? end - def reblogged_by - @accounts = Account.includes(:statuses) - .references(:statuses) - .merge(Status.where(reblog_of_id: @status.id) - .paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])) - .to_a - - next_path = reblogged_by_api_v1_status_url(pagination_params(max_id: @accounts.last.statuses.last.id)) if @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) - prev_path = reblogged_by_api_v1_status_url(pagination_params(since_id: @accounts.first.statuses.first.id)) unless @accounts.empty? - - set_pagination_headers(next_path, prev_path) - - render :accounts - end - - def favourited_by - @accounts = Account.includes(:favourites) - .references(:favourites) - .where(favourites: { status_id: @status.id }) - .merge(Favourite.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])) - .to_a - - next_path = favourited_by_api_v1_status_url(pagination_params(max_id: @accounts.last.favourites.last.id)) if @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) - prev_path = favourited_by_api_v1_status_url(pagination_params(since_id: @accounts.first.favourites.first.id)) unless @accounts.empty? - - set_pagination_headers(next_path, prev_path) - - render :accounts - end - def create @status = PostStatusService.new.call(current_user.account, status_params[:status], @@ -77,65 +48,21 @@ class Api::V1::StatusesController < ApiController def destroy @status = Status.where(account_id: current_user.account).find(params[:id]) - RemovalWorker.perform_async(@status.id) - render_empty - end - - def reblog - @status = ReblogService.new.call(current_user.account, Status.find(params[:id])) - render :show - end - - def unreblog - reblog = Status.where(account_id: current_user.account, reblog_of_id: params[:id]).first! - @status = reblog.reblog - @reblogs_map = { @status.id => false } - - RemovalWorker.perform_async(reblog.id) + authorize @status, :destroy? - render :show - end - - def favourite - @status = FavouriteService.new.call(current_user.account, Status.find(params[:id])).status.reload - render :show - end - - def unfavourite - @status = Status.find(params[:id]) - @favourites_map = { @status.id => false } - - UnfavouriteWorker.perform_async(current_user.account_id, @status.id) - - render :show - end - - def mute - current_account.mute_conversation!(@conversation) - - @mutes_map = { @conversation.id => true } - - render :show - end - - def unmute - current_account.unmute_conversation!(@conversation) - - @mutes_map = { @conversation.id => false } + RemovalWorker.perform_async(@status.id) - render :show + render_empty end private def set_status @status = Status.find(params[:id]) - raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account) - end - - def set_conversation - @conversation = @status.conversation - raise Mastodon::ValidationError if @conversation.nil? + authorize @status, :show? + rescue Mastodon::NotPermittedError + # Reraise in order to get a 404 instead of a 403 error code + raise ActiveRecord::RecordNotFound end def status_params diff --git a/app/controllers/api/v1/streaming_controller.rb b/app/controllers/api/v1/streaming_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..66b812e761f641f39301843a824b303ecf3268e3 --- /dev/null +++ b/app/controllers/api/v1/streaming_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Api::V1::StreamingController < Api::BaseController + respond_to :json + + def index + if Rails.configuration.x.streaming_api_base_url != request.host + uri = URI.parse(request.url) + uri.host = URI.parse(Rails.configuration.x.streaming_api_base_url).host + redirect_to uri.to_s, status: 301 + else + raise ActiveRecord::RecordNotFound + end + end +end diff --git a/app/controllers/api/v1/timelines/base_controller.rb b/app/controllers/api/v1/timelines/base_controller.rb deleted file mode 100644 index 4eb29e74a21a4c54150f33f1d31c3ceae27485c6..0000000000000000000000000000000000000000 --- a/app/controllers/api/v1/timelines/base_controller.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module Api::V1::Timelines - class BaseController < ApiController - respond_to :json - after_action :insert_pagination_headers, unless: -> { @statuses.empty? } - - private - - def cache_collection(raw) - super(raw, Status) - end - - def pagination_params(core_params) - params.permit(:local, :limit).merge(core_params) - end - - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - - def next_path - raise 'Override in child controllers' - end - - def prev_path - raise 'Override in child controllers' - end - end -end diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index 33ff48b398b2b07427e9e91792149b210209ceaa..511d2f65da2d6d13b511b32e5f923ddbf400f55c 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -1,44 +1,62 @@ # frozen_string_literal: true -module Api::V1::Timelines - class HomeController < BaseController - before_action -> { doorkeeper_authorize! :read }, only: [:show] - before_action :require_user!, only: [:show] +class Api::V1::Timelines::HomeController < Api::BaseController + before_action -> { doorkeeper_authorize! :read }, only: [:show] + before_action :require_user!, only: [:show] + after_action :insert_pagination_headers, unless: -> { @statuses.empty? } - def show - @statuses = load_statuses - end + respond_to :json - private + def show + @statuses = load_statuses + render 'api/v1/timelines/show' + end - def load_statuses - cached_home_statuses.tap do |statuses| - set_maps(statuses) - end - end + private - def cached_home_statuses - cache_collection home_statuses + def load_statuses + cached_home_statuses.tap do |statuses| + set_maps(statuses) end + end - def home_statuses - account_home_feed.get( - limit_param(DEFAULT_STATUSES_LIMIT), - params[:max_id], - params[:since_id] - ) - end + def cached_home_statuses + cache_collection home_statuses, Status + end - def account_home_feed - Feed.new(:home, current_account) - end + def home_statuses + account_home_feed.get( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) + end - def next_path - api_v1_timelines_home_url pagination_params(max_id: @statuses.last.id) - end + def account_home_feed + Feed.new(:home, current_account) + end - def prev_path - api_v1_timelines_home_url pagination_params(since_id: @statuses.first.id) - end + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_params(core_params) + params.permit(:local, :limit).merge(core_params) + end + + def next_path + api_v1_timelines_home_url pagination_params(max_id: pagination_max_id) + end + + def prev_path + api_v1_timelines_home_url pagination_params(since_id: pagination_since_id) + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id end end diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index 644c7a6f173bd6e4151b4c60d61428c9bdd25e69..305451cc7b76d6eaedc45e7137fbb48dc91cbea8 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -1,41 +1,60 @@ # frozen_string_literal: true -module Api::V1::Timelines - class PublicController < BaseController - def show - @statuses = load_statuses - end +class Api::V1::Timelines::PublicController < Api::BaseController + after_action :insert_pagination_headers, unless: -> { @statuses.empty? } - private + respond_to :json - def load_statuses - cached_public_statuses.tap do |statuses| - set_maps(statuses) - end - end + def show + @statuses = load_statuses + render 'api/v1/timelines/show' + end - def cached_public_statuses - cache_collection public_statuses - end + private - def public_statuses - public_timeline_statuses.paginate_by_max_id( - limit_param(DEFAULT_STATUSES_LIMIT), - params[:max_id], - params[:since_id] - ) + def load_statuses + cached_public_statuses.tap do |statuses| + set_maps(statuses) end + end - def public_timeline_statuses - Status.as_public_timeline(current_account, params[:local]) - end + def cached_public_statuses + cache_collection public_statuses, Status + end - def next_path - api_v1_timelines_public_url pagination_params(max_id: @statuses.last.id) - end + def public_statuses + public_timeline_statuses.paginate_by_max_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) + end - def prev_path - api_v1_timelines_public_url pagination_params(since_id: @statuses.first.id) - end + def public_timeline_statuses + Status.as_public_timeline(current_account, params[:local]) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_params(core_params) + params.permit(:local, :limit).merge(core_params) + end + + def next_path + api_v1_timelines_public_url pagination_params(max_id: pagination_max_id) + end + + def prev_path + api_v1_timelines_public_url pagination_params(since_id: pagination_since_id) + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id end end diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 818f49d3deca13f5ba896fa5deb6d133f3c4f214..50afca7c72352d35759121dc7bd5c42e902aaef9 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -1,51 +1,69 @@ # frozen_string_literal: true -module Api::V1::Timelines - class TagController < BaseController - before_action :load_tag +class Api::V1::Timelines::TagController < Api::BaseController + before_action :load_tag + after_action :insert_pagination_headers, unless: -> { @statuses.empty? } - def show - @statuses = load_statuses - end + respond_to :json - private + def show + @statuses = load_statuses + render 'api/v1/timelines/show' + end - def load_tag - @tag = Tag.find_by(name: params[:id].downcase) - end + private - def load_statuses - cached_tagged_statuses.tap do |statuses| - set_maps(statuses) - end - end + def load_tag + @tag = Tag.find_by(name: params[:id].downcase) + end - def cached_tagged_statuses - cache_collection tagged_statuses + def load_statuses + cached_tagged_statuses.tap do |statuses| + set_maps(statuses) end + end - def tagged_statuses - if @tag.nil? - [] - else - tag_timeline_statuses.paginate_by_max_id( - limit_param(DEFAULT_STATUSES_LIMIT), - params[:max_id], - params[:since_id] - ) - end - end + def cached_tagged_statuses + cache_collection tagged_statuses, Status + end - def tag_timeline_statuses - Status.as_tag_timeline(@tag, current_account, params[:local]) + def tagged_statuses + if @tag.nil? + [] + else + tag_timeline_statuses.paginate_by_max_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) end + end - def next_path - api_v1_timelines_tag_url params[:id], pagination_params(max_id: @statuses.last.id) - end + def tag_timeline_statuses + Status.as_tag_timeline(@tag, current_account, params[:local]) + end - def prev_path - api_v1_timelines_tag_url params[:id], pagination_params(since_id: @statuses.first.id) - end + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_params(core_params) + params.permit(:local, :limit).merge(core_params) + end + + def next_path + api_v1_timelines_tag_url params[:id], pagination_params(max_id: pagination_max_id) + end + + def prev_path + api_v1_timelines_tag_url params[:id], pagination_params(since_id: pagination_since_id) + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id end end diff --git a/app/controllers/api/web/settings_controller.rb b/app/controllers/api/web/settings_controller.rb index 7cceb0dfc794fefb75de6e6f1301d773cc12b593..f6739d506207c3bfb06bbb505a1d568ee588172b 100644 --- a/app/controllers/api/web/settings_controller.rb +++ b/app/controllers/api/web/settings_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::Web::SettingsController < ApiController +class Api::Web::SettingsController < Api::BaseController respond_to :json before_action :require_user! diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 79e3da5f9f3d78eea8e678b4784d70529d5b72d9..bc3bd2f4bcd15699a253eafa20470cf445975d00 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -27,7 +27,7 @@ class Auth::SessionsController < Devise::SessionsController if session[:otp_user_id] User.find(session[:otp_user_id]) elsif user_params[:email] - User.find_by(email: user_params[:email]) + User.find_for_authentication(email: user_params[:email]) end end diff --git a/app/controllers/authorize_follows_controller.rb b/app/controllers/authorize_follows_controller.rb index b15883d58351d8936368288e938d14cae7846445..da4ef022a0bbd6ee34494761d871f5096fae080a 100644 --- a/app/controllers/authorize_follows_controller.rb +++ b/app/controllers/authorize_follows_controller.rb @@ -40,7 +40,7 @@ class AuthorizeFollowsController < ApplicationController end def account_from_remote_follow - FollowRemoteAccountService.new.call(acct_without_prefix) + ResolveRemoteAccountService.new.call(acct_without_prefix) end def acct_param_is_url? diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb new file mode 100644 index 0000000000000000000000000000000000000000..7828fe48de080ffea7a42e424e4fe2b354f5dfd3 --- /dev/null +++ b/app/controllers/concerns/authorization.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Authorization + extend ActiveSupport::Concern + include Pundit + + def pundit_user + current_account + end + + def authorize(*) + super + rescue Pundit::NotAuthorizedError + raise Mastodon::NotPermittedError + end + + def authorize_with(user, record, query) + Pundit.authorize(user, record, query) + rescue Pundit::NotAuthorizedError + raise Mastodon::NotPermittedError + end +end diff --git a/app/controllers/concerns/export_controller_concern.rb b/app/controllers/concerns/export_controller_concern.rb new file mode 100644 index 0000000000000000000000000000000000000000..e20b71a30394a540332fd6f3d4a9c1ec710d215f --- /dev/null +++ b/app/controllers/concerns/export_controller_concern.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module ExportControllerConcern + extend ActiveSupport::Concern + + included do + before_action :authenticate_user! + before_action :load_export + end + + private + + def load_export + @export = Export.new(current_account) + end + + def send_export_file + respond_to do |format| + format.csv { send_data export_data, filename: export_filename } + end + end + + def export_data + raise 'Override in controller' + end + + def export_filename + "#{controller_name}.csv" + end +end diff --git a/app/controllers/concerns/localized.rb b/app/controllers/concerns/localized.rb index 5501b6793be79a0704aeb9d12830f2f3667f2197..a9ea60f7dc65e5f410f5bf4a6f30e1a897363eb2 100644 --- a/app/controllers/concerns/localized.rb +++ b/app/controllers/concerns/localized.rb @@ -17,12 +17,24 @@ module Localized end def default_locale - ENV.fetch('DEFAULT_LOCALE') do - user_supplied_locale || I18n.default_locale - end + request_locale || env_locale || I18n.default_locale end - def user_supplied_locale - http_accept_language.language_region_compatible_from(I18n.available_locales) + def env_locale + ENV['DEFAULT_LOCALE'] + end + + def request_locale + preferred_locale || compatible_locale + end + + def preferred_locale + http_accept_language.preferred_language_from([env_locale]) || + http_accept_language.preferred_language_from(I18n.available_locales) + end + + def compatible_locale + http_accept_language.compatible_language_from([env_locale]) || + http_accept_language.compatible_language_from(I18n.available_locales) end end diff --git a/app/controllers/concerns/obfuscate_filename.rb b/app/controllers/concerns/obfuscate_filename.rb index 9c896fb0988ba6c5fc78a6b127b66304feb822f3..22736ec3abf968bbb07c5cb19b1e346a51e44981 100644 --- a/app/controllers/concerns/obfuscate_filename.rb +++ b/app/controllers/concerns/obfuscate_filename.rb @@ -4,19 +4,13 @@ module ObfuscateFilename extend ActiveSupport::Concern class_methods do - def obfuscate_filename(*args) - before_action { obfuscate_filename(*args) } - end - end - - def obfuscate_filename(path) - file = params.dig(*path) - return if file.nil? + def obfuscate_filename(path) + before_action do + file = params.dig(*path) + next if file.nil? - file.original_filename = secure_token + File.extname(file.original_filename) - end - - def secure_token(length = 16) - SecureRandom.hex(length / 2) + file.original_filename = SecureRandom.hex(8) + File.extname(file.original_filename) + end + end end end diff --git a/app/controllers/concerns/rate_limit_headers.rb b/app/controllers/concerns/rate_limit_headers.rb new file mode 100644 index 0000000000000000000000000000000000000000..36cb91075343c7a6480dd36ed0c770660424ec3c --- /dev/null +++ b/app/controllers/concerns/rate_limit_headers.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module RateLimitHeaders + extend ActiveSupport::Concern + + included do + before_action :set_rate_limit_headers, if: :rate_limited_request? + end + + private + + def set_rate_limit_headers + apply_header_limit + apply_header_remaining + apply_header_reset + end + + def rate_limited_request? + !request.env['rack.attack.throttle_data'].nil? + end + + def apply_header_limit + response.headers['X-RateLimit-Limit'] = rate_limit_limit + end + + def rate_limit_limit + api_throttle_data[:limit].to_s + end + + def apply_header_remaining + response.headers['X-RateLimit-Remaining'] = rate_limit_remaining + end + + def rate_limit_remaining + (api_throttle_data[:limit] - api_throttle_data[:count]).to_s + end + + def apply_header_reset + response.headers['X-RateLimit-Reset'] = rate_limit_reset + end + + def rate_limit_reset + (request_time + reset_period_offset).iso8601(6) + end + + def api_throttle_data + request.env['rack.attack.throttle_data']['api'] + end + + def request_time + @_request_time ||= Time.now.utc + end + + def reset_period_offset + api_throttle_data[:period] - request_time.to_i % api_throttle_data[:period] + end +end diff --git a/app/controllers/concerns/user_tracking_concern.rb b/app/controllers/concerns/user_tracking_concern.rb index 2a3449617c77d9f27cc84e70078b8a4166638e03..8a63af95d0aa3d36666ff8c0ea8807e92d71fa0c 100644 --- a/app/controllers/concerns/user_tracking_concern.rb +++ b/app/controllers/concerns/user_tracking_concern.rb @@ -17,7 +17,7 @@ module UserTrackingConcern current_user.update_tracked_fields!(request) # Regenerate feed if needed - RegenerationWorker.perform_async(current_user.account_id) if user_needs_feed_update? + regenerate_feed! if user_needs_feed_update? end def user_needs_sign_in_update? @@ -27,4 +27,9 @@ module UserTrackingConcern def user_needs_feed_update? current_user.last_sign_in_at < REGENERATE_FEED_DAYS.days.ago end + + def regenerate_feed! + Redis.current.setnx("account:#{current_user.account_id}:regeneration", true) == 1 && Redis.current.expire("account:#{current_user.account_id}:regeneration", 3_600 * 24) + RegenerationWorker.perform_async(current_user.account_id) + end end diff --git a/app/controllers/manifests_controller.rb b/app/controllers/manifests_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..832e1eb6f67e792026d17f6e4cb55ec06e5551cf --- /dev/null +++ b/app/controllers/manifests_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ManifestsController < ApplicationController + before_action :set_instance_presenter + + def show; end + + def set_instance_presenter + @instance_presenter = InstancePresenter.new + end +end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index fa1daf012a6891d16d8d60a93e30cbce3f1442a0..f652f5acef9964878a7d8250b57fa688b9b01aa8 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class MediaController < ApplicationController + include Authorization + before_action :verify_permitted_status def show @@ -14,6 +16,9 @@ class MediaController < ApplicationController end def verify_permitted_status - raise ActiveRecord::RecordNotFound unless media_attachment.status.permitted?(current_account) + authorize media_attachment.status, :show? + rescue Mastodon::NotPermittedError + # Reraise in order to get a 404 instead of a 403 error code + raise ActiveRecord::RecordNotFound end end diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..80002b995e58a20eff26ee9257b3fc32957aa406 --- /dev/null +++ b/app/controllers/settings/deletes_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Settings::DeletesController < ApplicationController + layout 'admin' + + before_action :check_enabled_deletion + before_action :authenticate_user! + + def show + @confirmation = Form::DeleteConfirmation.new + end + + def destroy + if current_user.valid_password?(delete_params[:password]) + Admin::SuspensionWorker.perform_async(current_user.account_id, true) + sign_out + redirect_to new_user_session_path, notice: I18n.t('deletes.success_msg') + else + redirect_to settings_delete_path, alert: I18n.t('deletes.bad_password_msg') + end + end + + private + + def check_enabled_deletion + redirect_to root_path unless Setting.open_deletion + end + + def delete_params + params.require(:form_delete_confirmation).permit(:password) + end +end diff --git a/app/controllers/settings/exports/base_controller.rb b/app/controllers/settings/exports/base_controller.rb deleted file mode 100644 index c082ed806c44018348c73f99841a452fe9bcb2f6..0000000000000000000000000000000000000000 --- a/app/controllers/settings/exports/base_controller.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Settings - module Exports - class BaseController < ApplicationController - before_action :authenticate_user! - - def index - @export = Export.new(current_account) - - respond_to do |format| - format.csv { send_data export_data, filename: export_filename } - end - end - - private - - def export_filename - "#{controller_name}.csv" - end - end - end -end diff --git a/app/controllers/settings/exports/blocked_accounts_controller.rb b/app/controllers/settings/exports/blocked_accounts_controller.rb index f1115b21e31acf45caabf402387956f437d94ce1..2092104e01d1d147103d52de5f2b03b7213e042f 100644 --- a/app/controllers/settings/exports/blocked_accounts_controller.rb +++ b/app/controllers/settings/exports/blocked_accounts_controller.rb @@ -2,7 +2,13 @@ module Settings module Exports - class BlockedAccountsController < BaseController + class BlockedAccountsController < ApplicationController + include ExportControllerConcern + + def index + send_export_file + end + private def export_data diff --git a/app/controllers/settings/exports/following_accounts_controller.rb b/app/controllers/settings/exports/following_accounts_controller.rb index 0011d2463a183ec10b1b75f1c56504f320a6ff3b..74281ddca267ac298be567d1f25d2c85ec07b870 100644 --- a/app/controllers/settings/exports/following_accounts_controller.rb +++ b/app/controllers/settings/exports/following_accounts_controller.rb @@ -2,7 +2,13 @@ module Settings module Exports - class FollowingAccountsController < BaseController + class FollowingAccountsController < ApplicationController + include ExportControllerConcern + + def index + send_export_file + end + private def export_data diff --git a/app/controllers/settings/exports/muted_accounts_controller.rb b/app/controllers/settings/exports/muted_accounts_controller.rb index dfe72cfcb9e613ef035d2a7de9e79be0cf46f754..e511619ca6ab7aeb8fa01ebbd204f173a50c9028 100644 --- a/app/controllers/settings/exports/muted_accounts_controller.rb +++ b/app/controllers/settings/exports/muted_accounts_controller.rb @@ -2,7 +2,13 @@ module Settings module Exports - class MutedAccountsController < BaseController + class MutedAccountsController < ApplicationController + include ExportControllerConcern + + def index + send_export_file + end + private def export_data diff --git a/app/controllers/settings/follower_domains_controller.rb b/app/controllers/settings/follower_domains_controller.rb index 13722345fdca1e768384ec40b8b2659cd8abc0a9..90b48887facd74522c916f2fafa0e7492467a1ca 100644 --- a/app/controllers/settings/follower_domains_controller.rb +++ b/app/controllers/settings/follower_domains_controller.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'sidekiq-bulk' + class Settings::FollowerDomainsController < ApplicationController layout 'admin' @@ -13,8 +15,8 @@ class Settings::FollowerDomainsController < ApplicationController def update domains = bulk_params[:select] || [] - domains.each do |domain| - SoftBlockDomainFollowersWorker.perform_async(current_account.id, domain) + SoftBlockDomainFollowersWorker.push_bulk(domains) do |domain| + [current_account.id, domain] end redirect_to settings_follower_domains_path, notice: I18n.t('followers.success', count: domains.size) diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 3dc3013c352bc5f1bd90c127a597c0f6efd73763..71f5a7c04be2bbb6ec1ed702673edb82aec746d7 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -35,6 +35,7 @@ class Settings::PreferencesController < ApplicationController params.require(:user).permit( :setting_default_privacy, :setting_boost_modal, + :setting_delete_modal, :setting_auto_play_gif, notification_emails: %i(follow follow_request reblog favourite mention digest), interactions: %i(must_be_follower must_be_following) diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 696bb4f521c14a90ec5e4e3d6919065c7c28b3a2..59c9d0a87faa83340fc5db1c8370f2bfed12e224 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class StatusesController < ApplicationController + include Authorization + layout 'public' before_action :set_account @@ -30,7 +32,10 @@ class StatusesController < ApplicationController @stream_entry = @status.stream_entry @type = @stream_entry.activity_type.downcase - raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account) + authorize @status, :show? + rescue Mastodon::NotPermittedError + # Reraise in order to get a 404 + raise ActiveRecord::RecordNotFound end def check_account_suspension diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index baff4317a3cd99ca4b83a4e1c016ecc33fa6b881..314d5961909e58a32d3b03b31f98c4432f42f060 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class StreamEntriesController < ApplicationController + include Authorization + layout 'public' before_action :set_account @@ -42,7 +44,11 @@ class StreamEntriesController < ApplicationController @stream_entry = @account.stream_entries.where(activity_type: 'Status').find(params[:id]) @type = @stream_entry.activity_type.downcase - raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil? || (@stream_entry.hidden? && !@stream_entry.activity.permitted?(current_account)) + raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil? + authorize @stream_entry.activity, :show? if @stream_entry.hidden? + rescue Mastodon::NotPermittedError + # Reraise in order to get a 404 + raise ActiveRecord::RecordNotFound end def check_account_suspension diff --git a/app/controllers/well_known/host_meta_controller.rb b/app/controllers/well_known/host_meta_controller.rb index 82bfd3575a4cd952082a9ef469f180cee89f8913..40f96eaa25877518473c8fc002394466c910301d 100644 --- a/app/controllers/well_known/host_meta_controller.rb +++ b/app/controllers/well_known/host_meta_controller.rb @@ -2,6 +2,8 @@ module WellKnown class HostMetaController < ApplicationController + include RoutingHelper + def show @webfinger_template = "#{webfinger_url}?resource={uri}" diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 84f3015e87d4d1ddee98ea2ed6105a1537c018c4..1c27b2b184982d43ef2fe288fb4af59bad4632de 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -2,6 +2,8 @@ module WellKnown class WebfingerController < ApplicationController + include RoutingHelper + def show @account = Account.find_local!(username_from_resource) @canonical_account_uri = @account.to_webfinger_s diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 42f6ab3db474a0435fb16783703f92ef1d533dc5..36c37fae08ffbdd5f98877c1184d70f2bae50b46 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -13,6 +13,10 @@ module ApplicationHelper Setting.open_registrations end + def open_deletion? + Setting.open_deletion + end + def add_rtl_body_class(other_classes) other_classes = "#{other_classes} rtl" if [:ar, :fa, :he].include?(I18n.locale) other_classes diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index ba989a84d7142d8714113e06447914451a20300d..4ef7cffb07f40e897fdac6099b84c97425066710 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true module StreamEntriesHelper - EMBEDDED_CONTROLLER = 'stream_entries'.freeze - EMBEDDED_ACTION = 'embed'.freeze + EMBEDDED_CONTROLLER = 'stream_entries' + EMBEDDED_ACTION = 'embed' def display_name(account) account.display_name.presence || account.username @@ -47,12 +47,17 @@ module StreamEntriesHelper end end + def rtl_status?(status) + status.local? ? rtl?(status.text) : rtl?(strip_tags(status.text)) + end + def rtl?(text) - rtl_characters = /[\p{Hebrew}|\p{Arabic}|\p{Syriac}|\p{Thaana}|\p{Nko}]+/m.match(text) + text = simplified_text(text) + rtl_words = text.scan(/[\p{Hebrew}\p{Arabic}\p{Syriac}\p{Thaana}\p{Nko}]+/m) - if rtl_characters.present? - total_size = text.strip.size.to_f - rtl_size(rtl_characters.to_a) / total_size > 0.3 + if rtl_words.present? + total_size = text.size.to_f + rtl_size(rtl_words) / total_size > 0.3 else false end @@ -60,8 +65,20 @@ module StreamEntriesHelper private - def rtl_size(characters) - characters.reduce(0) { |acc, elem| acc + elem.size }.to_f + def simplified_text(text) + text.dup.tap do |new_text| + URI.extract(new_text).each do |url| + new_text.gsub!(url, '') + end + + new_text.gsub!(Account::MENTION_RE, '') + new_text.gsub!(Tag::HASHTAG_RE, '') + new_text.gsub!(/\s+/, '') + end + end + + def rtl_size(words) + words.reduce(0) { |acc, elem| acc + elem.size }.to_f end def embedded_view? diff --git a/app/helpers/style_helper.rb b/app/helpers/style_helper.rb deleted file mode 100644 index b695f8f3eafcbc2c5181b0064afc87ad009e4d73..0000000000000000000000000000000000000000 --- a/app/helpers/style_helper.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module StyleHelper - def stylesheet_for_layout - if asset_exist? 'custom.css' - 'custom' - else - 'application' - end - end - - def asset_exist?(path) - true if Webpacker::Manifest.lookup(path) - rescue Webpacker::FileLoader::NotFoundError - false - end -end diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index 48ab7b6ede8023aa8e42c1159e6a02fb9b678c7b..a862798f9067990513b6111d19a3677336474fd7 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -29,22 +29,6 @@ export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST'; export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS'; export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL'; -export const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST'; -export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS'; -export const ACCOUNT_TIMELINE_FETCH_FAIL = 'ACCOUNT_TIMELINE_FETCH_FAIL'; - -export const ACCOUNT_TIMELINE_EXPAND_REQUEST = 'ACCOUNT_TIMELINE_EXPAND_REQUEST'; -export const ACCOUNT_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_TIMELINE_EXPAND_SUCCESS'; -export const ACCOUNT_TIMELINE_EXPAND_FAIL = 'ACCOUNT_TIMELINE_EXPAND_FAIL'; - -export const ACCOUNT_MEDIA_TIMELINE_FETCH_REQUEST = 'ACCOUNT_MEDIA_TIMELINE_FETCH_REQUEST'; -export const ACCOUNT_MEDIA_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_MEDIA_TIMELINE_FETCH_SUCCESS'; -export const ACCOUNT_MEDIA_TIMELINE_FETCH_FAIL = 'ACCOUNT_MEDIA_TIMELINE_FETCH_FAIL'; - -export const ACCOUNT_MEDIA_TIMELINE_EXPAND_REQUEST = 'ACCOUNT_MEDIA_TIMELINE_EXPAND_REQUEST'; -export const ACCOUNT_MEDIA_TIMELINE_EXPAND_SUCCESS = 'ACCOUNT_MEDIA_TIMELINE_EXPAND_SUCCESS'; -export const ACCOUNT_MEDIA_TIMELINE_EXPAND_FAIL = 'ACCOUNT_MEDIA_TIMELINE_EXPAND_FAIL'; - export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST'; export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS'; export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL'; @@ -99,95 +83,6 @@ export function fetchAccount(id) { }; }; -export function fetchAccountTimeline(id, replace = false) { - return (dispatch, getState) => { - const ids = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()); - const newestId = ids.size > 0 ? ids.first() : null; - - let params = {}; - let skipLoading = false; - - if (newestId !== null && !replace) { - params.since_id = newestId; - skipLoading = true; - } - - dispatch(fetchAccountTimelineRequest(id, skipLoading)); - - api(getState).get(`/api/v1/accounts/${id}/statuses`, { params }).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(fetchAccountTimelineSuccess(id, response.data, replace, skipLoading, next)); - }).catch(error => { - dispatch(fetchAccountTimelineFail(id, error, skipLoading)); - }); - }; -}; - -export function fetchAccountMediaTimeline(id, replace = false) { - return (dispatch, getState) => { - const ids = getState().getIn(['timelines', 'accounts_media_timelines', id, 'items'], Immutable.List()); - const newestId = ids.size > 0 ? ids.first() : null; - - let params = { only_media: 'true', limit: 12 }; - let skipLoading = false; - - if (newestId !== null && !replace) { - params.since_id = newestId; - skipLoading = true; - } - - dispatch(fetchAccountMediaTimelineRequest(id, skipLoading)); - - api(getState).get(`/api/v1/accounts/${id}/statuses`, { params }).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(fetchAccountMediaTimelineSuccess(id, response.data, replace, skipLoading, next)); - }).catch(error => { - dispatch(fetchAccountMediaTimelineFail(id, error, skipLoading)); - }); - }; -}; - -export function expandAccountTimeline(id) { - return (dispatch, getState) => { - const lastId = getState().getIn(['timelines', 'accounts_timelines', id, 'items'], Immutable.List()).last(); - - dispatch(expandAccountTimelineRequest(id)); - - api(getState).get(`/api/v1/accounts/${id}/statuses`, { - params: { - limit: 10, - max_id: lastId, - }, - }).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(expandAccountTimelineSuccess(id, response.data, next)); - }).catch(error => { - dispatch(expandAccountTimelineFail(id, error)); - }); - }; -}; - -export function expandAccountMediaTimeline(id) { - return (dispatch, getState) => { - const lastId = getState().getIn(['timelines', 'accounts_media_timelines', id, 'items'], Immutable.List()).last(); - - dispatch(expandAccountMediaTimelineRequest(id)); - - api(getState).get(`/api/v1/accounts/${id}/statuses`, { - params: { - limit: 12, - only_media: 'true', - max_id: lastId, - }, - }).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(expandAccountMediaTimelineSuccess(id, response.data, next)); - }).catch(error => { - dispatch(expandAccountMediaTimelineFail(id, error)); - }); - }; -}; - export function fetchAccountRequest(id) { return { type: ACCOUNT_FETCH_REQUEST, @@ -277,112 +172,6 @@ export function unfollowAccountFail(error) { }; }; -export function fetchAccountTimelineRequest(id, skipLoading) { - return { - type: ACCOUNT_TIMELINE_FETCH_REQUEST, - id, - skipLoading, - }; -}; - -export function fetchAccountTimelineSuccess(id, statuses, replace, skipLoading, next) { - return { - type: ACCOUNT_TIMELINE_FETCH_SUCCESS, - id, - statuses, - replace, - skipLoading, - next, - }; -}; - -export function fetchAccountTimelineFail(id, error, skipLoading) { - return { - type: ACCOUNT_TIMELINE_FETCH_FAIL, - id, - error, - skipLoading, - skipAlert: error.response.status === 404, - }; -}; - -export function fetchAccountMediaTimelineRequest(id, skipLoading) { - return { - type: ACCOUNT_MEDIA_TIMELINE_FETCH_REQUEST, - id, - skipLoading, - }; -}; - -export function fetchAccountMediaTimelineSuccess(id, statuses, replace, skipLoading, next) { - return { - type: ACCOUNT_MEDIA_TIMELINE_FETCH_SUCCESS, - id, - statuses, - replace, - skipLoading, - next, - }; -}; - -export function fetchAccountMediaTimelineFail(id, error, skipLoading) { - return { - type: ACCOUNT_MEDIA_TIMELINE_FETCH_FAIL, - id, - error, - skipLoading, - skipAlert: error.response.status === 404, - }; -}; - -export function expandAccountTimelineRequest(id) { - return { - type: ACCOUNT_TIMELINE_EXPAND_REQUEST, - id, - }; -}; - -export function expandAccountTimelineSuccess(id, statuses, next) { - return { - type: ACCOUNT_TIMELINE_EXPAND_SUCCESS, - id, - statuses, - next, - }; -}; - -export function expandAccountTimelineFail(id, error) { - return { - type: ACCOUNT_TIMELINE_EXPAND_FAIL, - id, - error, - }; -}; - -export function expandAccountMediaTimelineRequest(id) { - return { - type: ACCOUNT_MEDIA_TIMELINE_EXPAND_REQUEST, - id, - }; -}; - -export function expandAccountMediaTimelineSuccess(id, statuses, next) { - return { - type: ACCOUNT_MEDIA_TIMELINE_EXPAND_SUCCESS, - id, - statuses, - next, - }; -}; - -export function expandAccountMediaTimelineFail(id, error) { - return { - type: ACCOUNT_MEDIA_TIMELINE_EXPAND_FAIL, - id, - error, - }; -}; - export function blockAccount(id) { return (dispatch, getState) => { dispatch(blockAccountRequest(id)); diff --git a/app/javascript/mastodon/actions/columns.js b/app/javascript/mastodon/actions/columns.js new file mode 100644 index 0000000000000000000000000000000000000000..bcb0cdf983784b773da9751d2cc8b52f3acf43b6 --- /dev/null +++ b/app/javascript/mastodon/actions/columns.js @@ -0,0 +1,40 @@ +import { saveSettings } from './settings'; + +export const COLUMN_ADD = 'COLUMN_ADD'; +export const COLUMN_REMOVE = 'COLUMN_REMOVE'; +export const COLUMN_MOVE = 'COLUMN_MOVE'; + +export function addColumn(id, params) { + return dispatch => { + dispatch({ + type: COLUMN_ADD, + id, + params, + }); + + dispatch(saveSettings()); + }; +}; + +export function removeColumn(uuid) { + return dispatch => { + dispatch({ + type: COLUMN_REMOVE, + uuid, + }); + + dispatch(saveSettings()); + }; +}; + +export function moveColumn(uuid, direction) { + return dispatch => { + dispatch({ + type: COLUMN_MOVE, + uuid, + direction, + }); + + dispatch(saveSettings()); + }; +}; diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 51b06ed3248e4c961da3e8558f102e798327f2e7..d3de2d871f2303b71c4115e39e40fde31980c9c6 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -124,25 +124,22 @@ export function refreshNotificationsFail(error, skipLoading) { export function expandNotifications() { return (dispatch, getState) => { - const url = getState().getIn(['notifications', 'next'], null); - const lastId = getState().getIn(['notifications', 'items']).last(); + const items = getState().getIn(['notifications', 'items'], Immutable.List()); - if (url === null || getState().getIn(['notifications', 'isLoading'])) { + if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) { return; } - dispatch(expandNotificationsRequest()); - const params = { - max_id: lastId, + max_id: items.last().get('id'), limit: 20, + exclude_types: excludeTypesFromSettings(getState()), }; - params.exclude_types = excludeTypesFromSettings(getState()); + dispatch(expandNotificationsRequest()); - api(getState).get(url, params).then(response => { + api(getState).get('/api/v1/notifications', { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null)); fetchRelatedRelationships(dispatch, response.data); }).catch(error => { diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js index 2d260b84c5afcd5fe0113bb891629a125a7bb4ef..f9d304c96a37bd3a72bd1dcf700f4b4ae4e5ff9c 100644 --- a/app/javascript/mastodon/actions/settings.js +++ b/app/javascript/mastodon/actions/settings.js @@ -3,10 +3,14 @@ import axios from 'axios'; export const SETTING_CHANGE = 'SETTING_CHANGE'; export function changeSetting(key, value) { - return { - type: SETTING_CHANGE, - key, - value, + return dispatch => { + dispatch({ + type: SETTING_CHANGE, + key, + value, + }); + + dispatch(saveSettings()); }; }; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 98e441cd16ccb4d11572e39bdfb8ee63168a7817..cb4410eba808c1c030b45594dbf8276229349b01 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -56,91 +56,89 @@ export function deleteFromTimelines(id) { }; }; -export function refreshTimelineRequest(timeline, id, skipLoading) { +export function refreshTimelineRequest(timeline, skipLoading) { return { type: TIMELINE_REFRESH_REQUEST, timeline, - id, skipLoading, }; }; -export function refreshTimeline(timeline, id = null) { +export function refreshTimeline(timelineId, path, params = {}) { return function (dispatch, getState) { - if (getState().getIn(['timelines', timeline, 'isLoading'])) { + const timeline = getState().getIn(['timelines', timelineId], Immutable.Map()); + + if (timeline.get('isLoading') || timeline.get('online')) { return; } - const ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List()); + const ids = timeline.get('items', Immutable.List()); const newestId = ids.size > 0 ? ids.first() : null; - let params = getState().getIn(['timelines', timeline, 'params'], {}); - const path = getState().getIn(['timelines', timeline, 'path'])(id); - - let skipLoading = false; - if (newestId !== null && getState().getIn(['timelines', timeline, 'loaded']) && (id === null || getState().getIn(['timelines', timeline, 'id']) === id)) { - if (id === null && getState().getIn(['timelines', timeline, 'online'])) { - // Skip refreshing when timeline is live anyway - return; - } + let skipLoading = timeline.get('loaded'); - params = { ...params, since_id: newestId }; - skipLoading = true; - } else if (getState().getIn(['timelines', timeline, 'loaded'])) { - skipLoading = true; + if (newestId !== null) { + params.since_id = newestId; } - dispatch(refreshTimelineRequest(timeline, id, skipLoading)); + dispatch(refreshTimelineRequest(timelineId, skipLoading)); api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(refreshTimelineSuccess(timeline, response.data, skipLoading, next ? next.uri : null)); + dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null)); }).catch(error => { - dispatch(refreshTimelineFail(timeline, error, skipLoading)); + dispatch(refreshTimelineFail(timelineId, error, skipLoading)); }); }; }; +export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home'); +export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public'); +export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true }); +export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); +export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); +export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); + export function refreshTimelineFail(timeline, error, skipLoading) { return { type: TIMELINE_REFRESH_FAIL, timeline, error, skipLoading, + skipAlert: error.response.status === 404, }; }; -export function expandTimeline(timeline) { +export function expandTimeline(timelineId, path, params = {}) { return (dispatch, getState) => { - if (getState().getIn(['timelines', timeline, 'isLoading'])) { - return; - } + const timeline = getState().getIn(['timelines', timelineId], Immutable.Map()); + const ids = timeline.get('items', Immutable.List()); - if (getState().getIn(['timelines', timeline, 'items']).size === 0) { + if (timeline.get('isLoading') || ids.size === 0) { return; } - const path = getState().getIn(['timelines', timeline, 'path'])(getState().getIn(['timelines', timeline, 'id'])); - const params = getState().getIn(['timelines', timeline, 'params'], {}); - const lastId = getState().getIn(['timelines', timeline, 'items']).last(); + params.max_id = ids.last(); + params.limit = 10; - dispatch(expandTimelineRequest(timeline)); + dispatch(expandTimelineRequest(timelineId)); - api(getState).get(path, { - params: { - ...params, - max_id: lastId, - limit: 10, - }, - }).then(response => { + api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(expandTimelineSuccess(timeline, response.data, next ? next.uri : null)); + dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null)); }).catch(error => { - dispatch(expandTimelineFail(timeline, error)); + dispatch(expandTimelineFail(timelineId, error)); }); }; }; +export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home'); +export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public'); +export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true }); +export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); +export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); +export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); + export function expandTimelineRequest(timeline) { return { type: TIMELINE_EXPAND_REQUEST, diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 9d65af46cc2b614211a0b342c1e61a52bbddf25a..960d136d3347ceb1c72bf061b6039b958a0692d9 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -55,11 +55,11 @@ class Account extends ImmutablePureComponent { const muting = account.getIn(['relationship', 'muting']); if (requested) { - buttons = ; + buttons = ; } else if (blocking) { - buttons = ; + buttons = ; } else if (muting) { - buttons = ; + buttons = ; } else { buttons = ; } diff --git a/app/javascript/mastodon/components/attachment_list.js b/app/javascript/mastodon/components/attachment_list.js index 54d3643bd98db3a8c8259e4a93b39095af8f03a5..a57c25ad0be5d39e59629ada91ba503eb194b156 100644 --- a/app/javascript/mastodon/components/attachment_list.js +++ b/app/javascript/mastodon/components/attachment_list.js @@ -1,9 +1,10 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; -class AttachmentList extends React.PureComponent { +class AttachmentList extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.list.isRequired, diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index 10efd17fb6c4910dc05aba4121eedb8657f1b982..9a5760a2cfb44f6680dd118375c8605e7a676be2 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -124,13 +124,7 @@ class AutosuggestTextarea extends ImmutablePureComponent { } onBlur = () => { - // If we hide the suggestions immediately, then this will prevent the - // onClick for the suggestions themselves from firing. - // Setting a short window for that to take place before hiding the - // suggestions ensures that can't happen. - setTimeout(() => { - this.setState({ suggestionsHidden: true }); - }, 100); + this.setState({ suggestionsHidden: true }); } onSuggestionClick = (e) => { @@ -191,7 +185,8 @@ class AutosuggestTextarea extends ImmutablePureComponent { key={suggestion} data-index={suggestion} className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`} - onClick={this.onSuggestionClick}> + onMouseDown={this.onSuggestionClick} + > ))} diff --git a/app/javascript/mastodon/components/avatar_overlay.js b/app/javascript/mastodon/components/avatar_overlay.js index a9643499d3a4cfeda597fccaf0c635568e773400..c82c8963720feb54b2a898f0afe91ce193ed3f13 100644 --- a/app/javascript/mastodon/components/avatar_overlay.js +++ b/app/javascript/mastodon/components/avatar_overlay.js @@ -9,7 +9,7 @@ class AvatarOverlay extends React.PureComponent { }; render() { - const {staticSrc, overlaySrc} = this.props; + const { staticSrc, overlaySrc } = this.props; const baseStyle = { backgroundImage: `url(${staticSrc})`, @@ -21,8 +21,8 @@ class AvatarOverlay extends React.PureComponent { return (
-
-
+
+
); } diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js new file mode 100644 index 0000000000000000000000000000000000000000..157a89c0e261377ef932edcdbb3aa545f8a7049c --- /dev/null +++ b/app/javascript/mastodon/components/column.js @@ -0,0 +1,45 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import scrollTop from '../scroll'; + +class Column extends React.PureComponent { + + static propTypes = { + children: PropTypes.node, + }; + + scrollTop () { + const scrollable = this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + handleWheel = () => { + if (typeof this._interruptScrollAnimation !== 'function') { + return; + } + + this._interruptScrollAnimation(); + } + + setRef = c => { + this.node = c; + } + + render () { + const { children } = this.props; + + return ( +
+ {children} +
+ ); + } + +} + +export default Column; diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js index 9d2de40f593d1744d80568f21fd2359d182db7a4..191c0f597ef425a35136903a68bdd7dee95cb5ce 100644 --- a/app/javascript/mastodon/components/column_back_button.js +++ b/app/javascript/mastodon/components/column_back_button.js @@ -9,14 +9,14 @@ class ColumnBackButton extends React.PureComponent { }; handleClick = () => { - if (window.history && window.history.length === 1) this.context.router.push("/"); - else this.context.router.goBack(); + if (window.history && window.history.length === 1) this.context.router.history.push('/'); + else this.context.router.history.goBack(); } render () { return (
- +
); diff --git a/app/javascript/mastodon/components/column_back_button_slim.js b/app/javascript/mastodon/components/column_back_button_slim.js index 6f6bbc0b8e4c59427c1fbe2af351ad82d6cc1789..ffb05fa027d64070baac4b32aa87a2eb48ee676d 100644 --- a/app/javascript/mastodon/components/column_back_button_slim.js +++ b/app/javascript/mastodon/components/column_back_button_slim.js @@ -9,7 +9,8 @@ class ColumnBackButtonSlim extends React.PureComponent { }; handleClick = () => { - this.context.router.push('/'); + if (window.history && window.history.length === 1) this.context.router.history.push('/'); + else this.context.router.history.goBack(); } render () { diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js new file mode 100644 index 0000000000000000000000000000000000000000..076f1cdaddc57a4482c6238a4769676d552700d7 --- /dev/null +++ b/app/javascript/mastodon/components/column_header.js @@ -0,0 +1,145 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { FormattedMessage } from 'react-intl'; + +class ColumnHeader extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + title: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + active: PropTypes.bool, + multiColumn: PropTypes.bool, + showBackButton: PropTypes.bool, + children: PropTypes.node, + pinned: PropTypes.bool, + onPin: PropTypes.func, + onMove: PropTypes.func, + onClick: PropTypes.func, + }; + + state = { + collapsed: true, + animating: false, + }; + + handleToggleClick = (e) => { + e.stopPropagation(); + this.setState({ collapsed: !this.state.collapsed, animating: true }); + } + + handleTitleClick = () => { + this.props.onClick(); + } + + handleMoveLeft = () => { + this.props.onMove(-1); + } + + handleMoveRight = () => { + this.props.onMove(1); + } + + handleBackClick = () => { + if (window.history && window.history.length === 1) this.context.router.history.push('/'); + else this.context.router.history.goBack(); + } + + handleTransitionEnd = () => { + this.setState({ animating: false }); + } + + render () { + const { title, icon, active, children, pinned, onPin, multiColumn, showBackButton } = this.props; + const { collapsed, animating } = this.state; + + const wrapperClassName = classNames('column-header__wrapper', { + 'active': active, + }); + + const buttonClassName = classNames('column-header', { + 'active': active, + }); + + const collapsibleClassName = classNames('column-header__collapsible', { + 'collapsed': collapsed, + 'animating': animating, + }); + + const collapsibleButtonClassName = classNames('column-header__button', { + 'active': !collapsed, + }); + + let extraContent, pinButton, moveButtons, backButton, collapseButton; + + if (children) { + extraContent = ( +
+ {children} +
+ ); + } + + if (multiColumn && pinned) { + pinButton = ; + + moveButtons = ( +
+ + +
+ ); + } else if (multiColumn) { + pinButton = ; + } + + if (!pinned && (multiColumn || showBackButton)) { + backButton = ( + + ); + } + + const collapsedContent = [ + extraContent, + ]; + + if (multiColumn) { + collapsedContent.push(moveButtons); + collapsedContent.push(pinButton); + } + + if (children || multiColumn) { + collapseButton = ; + } + + return ( +
+
+ + {title} + +
+ {backButton} + {collapseButton} +
+
+ +
+
+ {(!collapsed || animating) && collapsedContent} +
+
+
+ ); + } + +} + +export default ColumnHeader; diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index 4fe4db313cc81a8191c991f3933dac57d7439a6a..6e394a95d9e9bbc503f2693c27c78d1c457bfd9e 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -17,7 +17,7 @@ class DropdownMenu extends React.PureComponent { }; static defaultProps = { - ariaLabel: "Menu", + ariaLabel: 'Menu', }; state = { @@ -41,7 +41,7 @@ class DropdownMenu extends React.PureComponent { action(); } else if (to) { e.preventDefault(); - this.context.router.push(to); + this.context.router.history.push(to); } this.dropdown.hide(); @@ -53,13 +53,13 @@ class DropdownMenu extends React.PureComponent { renderItem = (item, i) => { if (item === null) { - return
  • ; + return
  • ; } const { text, action, href = '#' } = item; return ( -
  • +
  • {text} @@ -70,7 +70,7 @@ class DropdownMenu extends React.PureComponent { render () { const { icon, items, size, direction, ariaLabel } = this.props; const { expanded } = this.state; - const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right"; + const directionClass = (direction === 'left') ? 'dropdown__left' : 'dropdown__right'; const dropdownItems = expanded && (