diff --git a/.codeclimate.yml b/.codeclimate.yml index 29701a777..47e3e6ab9 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -1,21 +1,36 @@ -engines: +version: "2" +checks: + argument-count: + enabled: false + complex-logic: + enabled: false + file-lines: + enabled: false + method-complexity: + enabled: false + method-count: + enabled: false + method-lines: + enabled: false + nested-control-flow: + enabled: false + return-statements: + enabled: false + similar-code: + enabled: false + identical-code: + enabled: false +plugins: brakeman: enabled: true bundler-audit: enabled: true - duplication: - enabled: false eslint: enabled: true rubocop: enabled: true scss-lint: enabled: true -ratings: - paths: - - "**.rb" - - "**.js" - - "**.scss" -exclude_paths: +exclude_patterns: - spec/ - vendor/asset diff --git a/.env.nanobox b/.env.nanobox index 7920c47b9..48204a6bf 100644 --- a/.env.nanobox +++ b/.env.nanobox @@ -35,6 +35,17 @@ PAPERCLIP_SECRET=$PAPERCLIP_SECRET SECRET_KEY_BASE=$SECRET_KEY_BASE OTP_SECRET=$OTP_SECRET +# VAPID keys (used for push notifications) +# You can generate the keys using the following command (first is the private key, second is the public one) +# You should only generate this once per instance. If you later decide to change it, all push subscription will +# be invalidated, requiring the users to access the website again to resubscribe. +# +# Generate with `rake mastodon:webpush:generate_vapid_key` task (`nanobox run bundle exec rake mastodon:webpush:generate_vapid_key`) +# +# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html +VAPID_PRIVATE_KEY=$VAPID_PRIVATE_KEY +VAPID_PUBLIC_KEY=$VAPID_PUBLIC_KEY + # Registrations # Single user mode will disable registrations and redirect frontpage to the first profile # SINGLE_USER_MODE=true @@ -62,7 +73,7 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io #SMTP_CA_FILE=/etc/ssl/certs/ca-certificates.crt #SMTP_OPENSSL_VERIFY_MODE=peer #SMTP_ENABLE_STARTTLS_AUTO=true - +#SMTP_TLS=true # Optional user upload path and URL (images, avatars). Default is :rails_root/public/system. If you set this variable, you are responsible for making your HTTP server (eg. nginx) serve these files. # PAPERCLIP_ROOT_PATH=/var/lib/mastodon/public-system @@ -91,6 +102,23 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io # S3_ENDPOINT= # S3_SIGNATURE_VERSION= +# Swift (optional) +# SWIFT_ENABLED=true +# SWIFT_USERNAME= +# For Keystone V3, the value for SWIFT_TENANT should be the project name +# SWIFT_TENANT= +# SWIFT_PASSWORD= +# Keystone V2 and V3 URLs are supported. Use a V3 URL if possible to avoid +# issues with token rate-limiting during high load. +# SWIFT_AUTH_URL= +# SWIFT_CONTAINER= +# SWIFT_OBJECT_URL= +# SWIFT_REGION= +# Defaults to 'default' +# SWIFT_DOMAIN_NAME= +# Defaults to 60 seconds. Set to 0 to disable +# SWIFT_CACHE_TTL= + # Optional alias for S3 if you want to use Cloudfront or Cloudflare in front # S3_CLOUDFRONT_HOST= diff --git a/CODEOWNERS b/.github/CODEOWNERS similarity index 100% rename from CODEOWNERS rename to .github/CODEOWNERS diff --git a/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md similarity index 100% rename from ISSUE_TEMPLATE.md rename to .github/ISSUE_TEMPLATE.md diff --git a/.travis.yml b/.travis.yml index 5c2c2c889..777ca581c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -27,11 +27,14 @@ addons: apt: sources: - trusty-media + - sourceline: deb https://dl.yarnpkg.com/debian/ stable main + key_url: https://dl.yarnpkg.com/debian/pubkey.gpg packages: - ffmpeg + - libicu-dev - libprotobuf-dev - protobuf-compiler - - libicu-dev + - yarn rvm: - 2.3.4 @@ -42,7 +45,6 @@ services: install: - nvm install - - npm install -g yarn - bundle install --path=vendor/bundle --without development production --retry=3 --jobs=16 - yarn install diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..7cec57180 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at eugen@zeonfederated.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/Dockerfile b/Dockerfile index c3b38fa8b..7cca02ecf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,8 @@ ENV UID=991 GID=991 \ RAILS_SERVE_STATIC_FILES=true \ RAILS_ENV=production NODE_ENV=production -ARG YARN_VERSION=1.1.0 -ARG YARN_DOWNLOAD_SHA256=171c1f9ee93c488c0d774ac6e9c72649047c3f896277d88d0f805266519430f3 +ARG YARN_VERSION=1.3.2 +ARG YARN_DOWNLOAD_SHA256=6cfe82e530ef0837212f13e45c1565ba53f5199eec2527b85ecbcd88bf26821d ARG LIBICONV_VERSION=1.15 ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178 @@ -48,7 +48,7 @@ RUN apk -U upgrade \ && rm yarn.tar.gz \ && mv /tmp/src/yarn-v$YARN_VERSION /opt/yarn \ && ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \ - && wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \ + && wget -O libiconv.tar.gz "https://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \ && echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \ && tar -xzf libiconv.tar.gz -C /tmp/src \ && rm libiconv.tar.gz \ diff --git a/Gemfile b/Gemfile index 7b359af1d..f6acb431a 100644 --- a/Gemfile +++ b/Gemfile @@ -14,8 +14,10 @@ gem 'pg', '~> 0.20' gem 'pghero', '~> 1.7' gem 'dotenv-rails', '~> 2.2' -gem 'aws-sdk', '~> 2.9' -gem 'fog-openstack', '~> 0.1' +gem 'aws-sdk', '~> 2.10', require: false +gem 'fog-core', '~> 1.45' +gem 'fog-local', '~> 0.4', require: false +gem 'fog-openstack', '~> 0.1', require: false gem 'paperclip', '~> 5.1' gem 'paperclip-av-transcoder', '~> 0.6' @@ -38,16 +40,15 @@ gem 'http', '~> 2.2' gem 'http_accept_language', '~> 2.1' gem 'httplog', '~> 0.99' gem 'idn-ruby', require: 'idn' -gem 'kaminari', '~> 1.0' +gem 'kaminari', '~> 1.1' gem 'link_header', '~> 0.0' gem 'mime-types', '~> 3.1' -gem 'nokogiri', '~> 1.7' +gem 'nokogiri', '~> 1.8' gem 'nsa', '~> 0.2' -gem 'oj', '~> 3.0' +gem 'oj', '~> 3.3' gem 'ostatus2', '~> 2.0' -gem 'ox', '~> 2.5' +gem 'ox', '~> 2.8' gem 'pundit', '~> 1.1' -gem 'rabl', '~> 0.13' gem 'rack-attack', '~> 5.0' gem 'rack-cors', '~> 0.4', require: 'rack/cors' gem 'rack-timeout', '~> 0.4' @@ -75,15 +76,15 @@ gem 'json-ld-preloaded', '~> 2.2.1' gem 'rdf-normalize', '~> 0.3.1' group :development, :test do - gem 'fabrication', '~> 2.16' + gem 'fabrication', '~> 2.18' gem 'fuubar', '~> 2.2' gem 'i18n-tasks', '~> 0.9', require: false gem 'pry-rails', '~> 0.3' - gem 'rspec-rails', '~> 3.6' + gem 'rspec-rails', '~> 3.7' end group :test do - gem 'capybara', '~> 2.14' + gem 'capybara', '~> 2.15' gem 'climate_control', '~> 0.2' gem 'faker', '~> 1.7' gem 'microformats', '~> 4.0' @@ -91,13 +92,13 @@ group :test do gem 'rspec-sidekiq', '~> 3.0' gem 'simplecov', '~> 0.14', require: false gem 'webmock', '~> 3.0' - gem 'parallel_tests', '~> 2.14' + gem 'parallel_tests', '~> 2.17' end group :development do gem 'active_record_query_trace', '~> 1.5' gem 'annotate', '~> 2.7' - gem 'better_errors', '~> 2.1' + gem 'better_errors', '~> 2.4' gem 'binding_of_caller', '~> 0.7' gem 'bullet', '~> 5.5' gem 'letter_opener', '~> 1.4' @@ -105,15 +106,15 @@ group :development do gem 'rubocop', require: false gem 'brakeman', '~> 4.0', require: false gem 'bundler-audit', '~> 0.6', require: false - gem 'scss_lint', '~> 0.53', require: false + gem 'scss_lint', '~> 0.55', require: false - gem 'capistrano', '~> 3.8' - gem 'capistrano-rails', '~> 1.2' + gem 'capistrano', '~> 3.10' + gem 'capistrano-rails', '~> 1.3' gem 'capistrano-rbenv', '~> 2.1' gem 'capistrano-yarn', '~> 2.0' end group :production do - gem 'lograge', '~> 0.5' + gem 'lograge', '~> 0.7' gem 'redis-rails', '~> 5.0' end diff --git a/Gemfile.lock b/Gemfile.lock index 14ed0d309..febfb8561 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -24,11 +24,11 @@ GEM erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) - active_model_serializers (0.10.6) + active_model_serializers (0.10.7) actionpack (>= 4.1, < 6) activemodel (>= 4.1, < 6) case_transform (>= 0.2) - jsonapi-renderer (>= 0.1.1.beta1, < 0.2) + jsonapi-renderer (>= 0.1.1.beta1, < 0.3) active_record_query_trace (1.5.4) activejob (5.1.4) activesupport (= 5.1.4) @@ -57,25 +57,25 @@ GEM encryptor (~> 3.0.0) av (0.9.0) cocaine (~> 0.5.3) - aws-sdk (2.10.46) - aws-sdk-resources (= 2.10.46) - aws-sdk-core (2.10.46) + aws-sdk (2.10.100) + aws-sdk-resources (= 2.10.100) + aws-sdk-core (2.10.100) aws-sigv4 (~> 1.0) jmespath (~> 1.0) - aws-sdk-resources (2.10.46) - aws-sdk-core (= 2.10.46) + aws-sdk-resources (2.10.100) + aws-sdk-core (= 2.10.100) aws-sigv4 (1.0.2) bcrypt (3.1.11) - better_errors (2.3.0) + better_errors (2.4.0) coderay (>= 1.0.0) erubi (>= 1.0.0) rack (>= 0.9.0) - binding_of_caller (0.7.2) + binding_of_caller (0.7.3) debug_inspector (>= 0.0.1) - bootsnap (1.1.3) + bootsnap (1.1.5) msgpack (~> 1.0) brakeman (4.0.1) - browser (2.5.1) + browser (2.5.2) builder (3.2.3) bullet (5.6.1) activesupport (>= 3.0.0) @@ -83,23 +83,23 @@ GEM bundler-audit (0.6.0) bundler (~> 1.2) thor (~> 0.18) - capistrano (3.9.1) + capistrano (3.10.0) airbrussh (>= 1.0.0) i18n rake (>= 10.0.0) sshkit (>= 1.9.0) - capistrano-bundler (1.2.0) + capistrano-bundler (1.3.0) capistrano (~> 3.1) sshkit (~> 1.2) - capistrano-rails (1.3.0) + capistrano-rails (1.3.1) capistrano (~> 3.1) capistrano-bundler (~> 1.1) - capistrano-rbenv (2.1.1) + capistrano-rbenv (2.1.3) capistrano (~> 3.1) sshkit (~> 1.3) capistrano-yarn (2.0.2) capistrano (~> 3.0) - capybara (2.15.1) + capybara (2.16.1) addressable mini_mime (>= 0.1.3) nokogiri (>= 1.3.3) @@ -110,7 +110,7 @@ GEM activesupport charlock_holmes (0.7.5) chunky_png (1.3.8) - cld3 (3.2.0) + cld3 (3.2.1) ffi (>= 1.1.0, < 1.10.0) climate_control (0.2.0) cocaine (0.5.8) @@ -121,7 +121,7 @@ GEM connection_pool (2.2.1) crack (0.4.3) safe_yaml (~> 1.0.0) - crass (1.0.2) + crass (1.0.3) debug_inspector (0.0.3) devise (4.3.0) bcrypt (~> 3.0) @@ -129,11 +129,11 @@ GEM railties (>= 4.1.0, < 5.2) responders warden (~> 1.2.3) - devise-two-factor (3.0.0) - activesupport + devise-two-factor (3.0.2) + activesupport (< 5.2) attr_encrypted (>= 1.3, < 4, != 2) devise (~> 4.0) - railties + railties (< 5.2) rotp (~> 2.0) diff-lcs (1.3) docile (1.1.5) @@ -150,12 +150,12 @@ GEM thread thread_safe encryptor (3.0.0) - erubi (1.6.1) - et-orbi (1.0.5) + erubi (1.7.0) + et-orbi (1.0.8) tzinfo excon (0.59.0) execjs (2.7.0) - fabrication (2.16.3) + fabrication (2.18.0) faker (1.8.4) i18n (~> 0.5) fast_blank (1.0.0) @@ -167,7 +167,9 @@ GEM fog-json (1.0.2) fog-core (~> 1.0) multi_json (~> 1.10) - fog-openstack (0.1.21) + fog-local (0.4.0) + fog-core (~> 1.27) + fog-openstack (0.1.22) fog-core (>= 1.40) fog-json (>= 1.0) ipaddress (>= 0.8) @@ -175,14 +177,14 @@ GEM fuubar (2.2.0) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) - globalid (0.4.0) + globalid (0.4.1) activesupport (>= 4.2.0) goldfinger (2.0.1) addressable (~> 2.5) http (~> 2.2) nokogiri (~> 1.8) oj (~> 3.0) - hamlit (2.8.4) + hamlit (2.8.5) temple (>= 0.8.0) thor tilt @@ -194,7 +196,7 @@ GEM hamster (3.0.0) concurrent-ruby (~> 1.0) hashdiff (0.3.7) - highline (1.7.8) + highline (1.7.10) hiredis (0.6.1) hkdf (0.3.0) htmlentities (4.3.4) @@ -211,8 +213,9 @@ GEM httplog (0.99.7) colorize rack - i18n (0.8.6) - i18n-tasks (0.9.18) + i18n (0.9.1) + concurrent-ruby (~> 1.0) + i18n-tasks (0.9.19) activesupport (>= 4.0.2) ast (>= 2.1.0) easy_translate (>= 0.5.0) @@ -227,27 +230,27 @@ GEM iso-639 (0.2.8) jmespath (1.3.1) json (2.1.0) - json-ld (2.1.5) + json-ld (2.1.7) multi_json (~> 1.12) - rdf (~> 2.2) + rdf (~> 2.2, >= 2.2.8) json-ld-preloaded (2.2.2) json-ld (~> 2.1, >= 2.1.5) multi_json (~> 1.11) rdf (~> 2.2) - jsonapi-renderer (0.1.3) - jwt (1.5.6) - kaminari (1.0.1) + jsonapi-renderer (0.2.0) + jwt (2.1.0) + kaminari (1.1.1) activesupport (>= 4.1.0) - kaminari-actionview (= 1.0.1) - kaminari-activerecord (= 1.0.1) - kaminari-core (= 1.0.1) - kaminari-actionview (1.0.1) + kaminari-actionview (= 1.1.1) + kaminari-activerecord (= 1.1.1) + kaminari-core (= 1.1.1) + kaminari-actionview (1.1.1) actionview - kaminari-core (= 1.0.1) - kaminari-activerecord (1.0.1) + kaminari-core (= 1.1.1) + kaminari-activerecord (1.1.1) activerecord - kaminari-core (= 1.0.1) - kaminari-core (1.0.1) + kaminari-core (= 1.1.1) + kaminari-core (1.1.1) launchy (2.4.3) addressable (~> 2.3) letter_opener (1.4.1) @@ -257,18 +260,19 @@ GEM letter_opener (~> 1.0) railties (>= 3.2) link_header (0.0.8) - lograge (0.6.0) + lograge (0.7.1) actionpack (>= 4, < 5.2) activesupport (>= 4, < 5.2) railties (>= 4, < 5.2) request_store (~> 1.0) - loofah (2.0.3) + loofah (2.1.1) + crass (~> 1.0.2) nokogiri (>= 1.5.9) - mail (2.6.6) - mime-types (>= 1.16, < 4) + mail (2.7.0) + mini_mime (>= 0.1.1) mario-redis-lock (1.2.0) redis (~> 3, >= 3.0.5) - method_source (0.8.2) + method_source (0.9.0) microformats (4.0.7) json nokogiri @@ -276,8 +280,8 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) mimemagic (0.3.2) - mini_mime (0.1.4) - mini_portile2 (2.2.0) + mini_mime (1.0.0) + mini_portile2 (2.3.0) minitest (5.10.3) msgpack (1.1.0) multi_json (1.12.2) @@ -285,8 +289,8 @@ GEM net-ssh (>= 2.6.5) net-ssh (4.2.0) nio4r (2.1.0) - nokogiri (1.8.0) - mini_portile2 (~> 2.2.0) + nokogiri (1.8.1) + mini_portile2 (~> 2.3.0) nokogumbo (1.4.13) nokogiri nsa (0.2.4) @@ -294,15 +298,15 @@ GEM concurrent-ruby (~> 1.0.0) sidekiq (>= 3.5.0) statsd-ruby (~> 1.2.0) - oj (3.3.5) - openssl (2.0.5) + oj (3.3.9) + openssl (2.0.6) orm_adapter (0.5.0) ostatus2 (2.0.1) addressable (~> 2.4) http (~> 2.0) nokogiri (~> 1.6) openssl (~> 2.0) - ox (2.6.0) + ox (2.8.2) paperclip (5.1.0) activemodel (>= 4.2.0) activesupport (>= 4.2.0) @@ -313,27 +317,24 @@ GEM av (~> 0.9.0) paperclip (>= 2.5.2) parallel (1.12.0) - parallel_tests (2.15.0) + parallel_tests (2.19.0) parallel - parser (2.4.0.0) - ast (~> 2.2) + parser (2.4.0.2) + ast (~> 2.3) pg (0.21.0) pghero (1.7.0) activerecord - pkg-config (1.2.7) + pkg-config (1.2.8) powerpack (0.1.1) - pry (0.10.4) + pry (0.11.3) coderay (~> 1.1.0) - method_source (~> 0.8.1) - slop (~> 3.4) + method_source (~> 0.9.0) pry-rails (0.3.6) pry (>= 0.10.4) - public_suffix (3.0.0) - puma (3.10.0) + public_suffix (3.0.1) + puma (3.11.0) pundit (1.1.0) activesupport (>= 3.0.0) - rabl (0.13.1) - activesupport (>= 2.3.14) rack (2.0.3) rack-attack (5.0.1) rack @@ -342,7 +343,7 @@ GEM rack rack-proxy (0.6.2) rack - rack-test (0.7.0) + rack-test (0.8.2) rack (>= 1.0, < 3) rack-timeout (0.4.2) rails (5.1.4) @@ -379,31 +380,34 @@ GEM thor (>= 0.18.1, < 2.0) rainbow (2.2.2) rake - rake (12.1.0) - rdf (2.2.9) + rake (12.3.0) + rb-fsevent (0.10.2) + rb-inotify (0.9.10) + ffi (>= 0.5.0, < 2) + rdf (2.2.12) hamster (~> 3.0) link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.3.2) rdf (~> 2.0) - redis (3.3.3) - redis-actionpack (5.0.1) + redis (3.3.5) + redis-actionpack (5.0.2) actionpack (>= 4.0, < 6) redis-rack (>= 1, < 3) - redis-store (>= 1.1.0, < 1.4.0) - redis-activesupport (5.0.3) + redis-store (>= 1.1.0, < 2) + redis-activesupport (5.0.4) activesupport (>= 3, < 6) - redis-store (~> 1.3.0) - redis-namespace (1.5.3) - redis (~> 3.0, >= 3.0.4) - redis-rack (2.0.2) + redis-store (>= 1.3, < 2) + redis-namespace (1.6.0) + redis (>= 3.0.4) + redis-rack (2.0.3) rack (>= 1.5, < 3) - redis-store (>= 1.2, < 1.4) + redis-store (>= 1.2, < 2) redis-rails (5.0.2) redis-actionpack (>= 5.0, < 6) redis-activesupport (>= 5.0, < 6) redis-store (>= 1.2, < 2) - redis-store (1.3.0) - redis (>= 2.2) + redis-store (1.4.1) + redis (>= 2.2, < 5) request_store (1.3.2) responders (2.4.0) actionpack (>= 4.2.0, < 5.3) @@ -411,27 +415,27 @@ GEM rotp (2.1.2) rqrcode (0.10.1) chunky_png (~> 1.0) - rspec-core (3.6.0) - rspec-support (~> 3.6.0) - rspec-expectations (3.6.0) + rspec-core (3.7.0) + rspec-support (~> 3.7.0) + rspec-expectations (3.7.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.6.0) - rspec-mocks (3.6.0) + rspec-support (~> 3.7.0) + rspec-mocks (3.7.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.6.0) - rspec-rails (3.6.1) + rspec-support (~> 3.7.0) + rspec-rails (3.7.2) actionpack (>= 3.0) activesupport (>= 3.0) railties (>= 3.0) - rspec-core (~> 3.6.0) - rspec-expectations (~> 3.6.0) - rspec-mocks (~> 3.6.0) - rspec-support (~> 3.6.0) + rspec-core (~> 3.7.0) + rspec-expectations (~> 3.7.0) + rspec-mocks (~> 3.7.0) + rspec-support (~> 3.7.0) rspec-sidekiq (3.0.3) rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) - rspec-support (3.6.0) - rubocop (0.50.0) + rspec-support (3.7.0) + rubocop (0.51.0) parallel (~> 1.10) parser (>= 2.3.3.1, < 3.0) powerpack (~> 0.1) @@ -439,7 +443,7 @@ GEM ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) ruby-oembed (0.12.0) - ruby-progressbar (1.8.3) + ruby-progressbar (1.9.0) rufus-scheduler (3.4.2) et-orbi (~> 1.0) safe_yaml (1.0.4) @@ -447,20 +451,24 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.4.4) nokogumbo (~> 1.4.1) - sass (3.4.25) - scss_lint (0.54.0) + sass (3.5.3) + sass-listen (~> 4.0.0) + sass-listen (4.0.0) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + scss_lint (0.56.0) rake (>= 0.9, < 13) - sass (~> 3.4.20) - sidekiq (5.0.4) + sass (~> 3.5.3) + sidekiq (5.0.5) concurrent-ruby (~> 1.0) connection_pool (~> 2.2, >= 2.2.0) rack-protection (>= 1.5.0) - redis (~> 3.3, >= 3.3.3) + redis (>= 3.3.4, < 5) sidekiq-bulk (0.1.1) activesupport sidekiq - sidekiq-scheduler (2.1.9) - redis (~> 3) + sidekiq-scheduler (2.1.10) + redis (>= 3, < 5) rufus-scheduler (~> 3.2) sidekiq (>= 3) tilt (>= 1.4.0) @@ -477,7 +485,6 @@ GEM json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) - slop (3.6.0) sprockets (3.7.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) @@ -485,7 +492,7 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) - sshkit (1.14.0) + sshkit (1.15.1) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) statsd-ruby (1.2.1) @@ -500,9 +507,9 @@ GEM tilt (2.0.8) twitter-text (1.14.7) unf (~> 0.1.0) - tzinfo (1.2.3) + tzinfo (1.2.4) thread_safe (~> 0.1) - tzinfo-data (1.2017.2) + tzinfo-data (1.2017.3) tzinfo (>= 1.0.0) uglifier (3.2.0) execjs (>= 0.3.0, < 3) @@ -513,20 +520,20 @@ GEM uniform_notifier (1.10.0) warden (1.2.7) rack (>= 1.0) - webmock (3.1.0) + webmock (3.1.1) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff - webpacker (3.0.1) + webpacker (3.0.2) activesupport (>= 4.2) rack-proxy (>= 0.6.1) railties (>= 4.2) - webpush (0.3.2) + webpush (0.3.3) hkdf (~> 0.2) - jwt + jwt (~> 2.0) websocket-driver (0.6.5) websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.2) + websocket-extensions (0.1.3) xpath (2.1.0) nokogiri (~> 1.3) @@ -538,19 +545,19 @@ DEPENDENCIES active_record_query_trace (~> 1.5) addressable (~> 2.5) annotate (~> 2.7) - aws-sdk (~> 2.9) - better_errors (~> 2.1) + aws-sdk (~> 2.10) + better_errors (~> 2.4) binding_of_caller (~> 0.7) bootsnap brakeman (~> 4.0) browser bullet (~> 5.5) bundler-audit (~> 0.6) - capistrano (~> 3.8) - capistrano-rails (~> 1.2) + capistrano (~> 3.10) + capistrano-rails (~> 1.3) capistrano-rbenv (~> 2.1) capistrano-yarn (~> 2.0) - capybara (~> 2.14) + capybara (~> 2.15) charlock_holmes (~> 0.7.5) cld3 (~> 3.2.0) climate_control (~> 0.2) @@ -558,9 +565,11 @@ DEPENDENCIES devise-two-factor (~> 3.0) doorkeeper (~> 4.2) dotenv-rails (~> 2.2) - fabrication (~> 2.16) + fabrication (~> 2.18) faker (~> 1.7) fast_blank (~> 1.0) + fog-core (~> 1.45) + fog-local (~> 0.4) fog-openstack (~> 0.1) fuubar (~> 2.2) goldfinger (~> 2.0) @@ -574,29 +583,28 @@ DEPENDENCIES idn-ruby iso-639 json-ld-preloaded (~> 2.2.1) - kaminari (~> 1.0) + kaminari (~> 1.1) letter_opener (~> 1.4) letter_opener_web (~> 1.3) link_header (~> 0.0) - lograge (~> 0.5) + lograge (~> 0.7) mario-redis-lock (~> 1.2) microformats (~> 4.0) mime-types (~> 3.1) - nokogiri (~> 1.7) + nokogiri (~> 1.8) nsa (~> 0.2) - oj (~> 3.0) + oj (~> 3.3) ostatus2 (~> 2.0) - ox (~> 2.5) + ox (~> 2.8) paperclip (~> 5.1) paperclip-av-transcoder (~> 0.6) - parallel_tests (~> 2.14) + parallel_tests (~> 2.17) pg (~> 0.20) pghero (~> 1.7) pkg-config (~> 1.2) pry-rails (~> 0.3) puma (~> 3.10) pundit (~> 1.1) - rabl (~> 0.13) rack-attack (~> 5.0) rack-cors (~> 0.4) rack-timeout (~> 0.4) @@ -609,12 +617,12 @@ DEPENDENCIES redis-namespace (~> 1.5) redis-rails (~> 5.0) rqrcode (~> 0.10) - rspec-rails (~> 3.6) + rspec-rails (~> 3.7) rspec-sidekiq (~> 3.0) rubocop ruby-oembed (~> 0.12) sanitize (~> 4.4) - scss_lint (~> 0.53) + scss_lint (~> 0.55) sidekiq (~> 5.0) sidekiq-bulk (~> 0.1.1) sidekiq-scheduler (~> 2.1) @@ -635,4 +643,4 @@ RUBY VERSION ruby 2.4.2p198 BUNDLED WITH - 1.15.4 + 1.16.0 diff --git a/README.md b/README.md index fc8296813..5cf91d52c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ ![Mastodon](https://i.imgur.com/NhZc40l.png) ======== -[![Build Status](http://img.shields.io/travis/tootsuite/mastodon.svg)][travis] -[![Code Climate](https://img.shields.io/codeclimate/github/tootsuite/mastodon.svg)][code_climate] +[![Build Status](https://img.shields.io/travis/tootsuite/mastodon.svg)][travis] +[![Code Climate](https://img.shields.io/codeclimate/maintainability/tootsuite/mastodon.svg)][code_climate] [travis]: https://travis-ci.org/tootsuite/mastodon [code_climate]: https://codeclimate.com/github/tootsuite/mastodon diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb index 414a875d0..7d5b9bf52 100644 --- a/app/controllers/admin/account_moderation_notes_controller.rb +++ b/app/controllers/admin/account_moderation_notes_controller.rb @@ -1,31 +1,41 @@ # frozen_string_literal: true -class Admin::AccountModerationNotesController < Admin::BaseController - def create - @account_moderation_note = current_account.account_moderation_notes.new(resource_params) - if @account_moderation_note.save - @target_account = @account_moderation_note.target_account - redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.created_msg') - else - @account = @account_moderation_note.target_account - @moderation_notes = @account.targeted_moderation_notes.latest - render template: 'admin/accounts/show' +module Admin + class AccountModerationNotesController < BaseController + before_action :set_account_moderation_note, only: [:destroy] + + def create + authorize AccountModerationNote, :create? + + @account_moderation_note = current_account.account_moderation_notes.new(resource_params) + + if @account_moderation_note.save + redirect_to admin_account_path(@account_moderation_note.target_account_id), notice: I18n.t('admin.account_moderation_notes.created_msg') + else + @account = @account_moderation_note.target_account + @moderation_notes = @account.targeted_moderation_notes.latest + + render template: 'admin/accounts/show' + end + end + + def destroy + authorize @account_moderation_note, :destroy? + @account_moderation_note.destroy! + redirect_to admin_account_path(@account_moderation_note.target_account_id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg') + end + + private + + def resource_params + params.require(:account_moderation_note).permit( + :content, + :target_account_id + ) + end + + def set_account_moderation_note + @account_moderation_note = AccountModerationNote.find(params[:id]) end end - - def destroy - @account_moderation_note = AccountModerationNote.find(params[:id]) - @target_account = @account_moderation_note.target_account - @account_moderation_note.destroy - redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg') - end - - private - - def resource_params - params.require(:account_moderation_note).permit( - :content, - :target_account_id - ) - end end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index ffa4dc850..7428c3f22 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -2,29 +2,57 @@ module Admin class AccountsController < BaseController - before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload] + before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :enable, :disable, :memorialize] before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload] + before_action :require_local_account!, only: [:enable, :disable, :memorialize] def index + authorize :account, :index? @accounts = filtered_accounts.page(params[:page]) end def show + authorize @account, :show? @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) @moderation_notes = @account.targeted_moderation_notes.latest end def subscribe + authorize @account, :subscribe? Pubsubhubbub::SubscribeWorker.perform_async(@account.id) redirect_to admin_account_path(@account.id) end def unsubscribe + authorize @account, :unsubscribe? Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id) redirect_to admin_account_path(@account.id) end + def memorialize + authorize @account, :memorialize? + @account.memorialize! + log_action :memorialize, @account + redirect_to admin_account_path(@account.id) + end + + def enable + authorize @account.user, :enable? + @account.user.enable! + log_action :enable, @account.user + redirect_to admin_account_path(@account.id) + end + + def disable + authorize @account.user, :disable? + @account.user.disable! + log_action :disable, @account.user + redirect_to admin_account_path(@account.id) + end + def redownload + authorize @account, :redownload? + @account.reset_avatar! @account.reset_header! @account.save! @@ -42,6 +70,10 @@ module Admin redirect_to admin_account_path(@account.id) if @account.local? end + def require_local_account! + redirect_to admin_account_path(@account.id) unless @account.local? && @account.user.present? + end + def filtered_accounts AccountFilter.new(filter_params).results end @@ -57,7 +89,8 @@ module Admin :username, :display_name, :email, - :ip + :ip, + :staff ) end end diff --git a/app/controllers/admin/action_logs_controller.rb b/app/controllers/admin/action_logs_controller.rb new file mode 100644 index 000000000..e273dfeae --- /dev/null +++ b/app/controllers/admin/action_logs_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Admin + class ActionLogsController < BaseController + def index + @action_logs = Admin::ActionLog.page(params[:page]) + end + end +end diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index 11fe326bc..7fb69d578 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -2,7 +2,10 @@ module Admin class BaseController < ApplicationController - before_action :require_admin! + include Authorization + include AccountableConcern + + before_action :require_staff! layout 'admin' end diff --git a/app/controllers/admin/confirmations_controller.rb b/app/controllers/admin/confirmations_controller.rb index 2542e21ee..34dfb458e 100644 --- a/app/controllers/admin/confirmations_controller.rb +++ b/app/controllers/admin/confirmations_controller.rb @@ -2,15 +2,19 @@ module Admin class ConfirmationsController < BaseController + before_action :set_user + def create - account_user.confirm + authorize @user, :confirm? + @user.confirm! + log_action :confirm, @user redirect_to admin_accounts_path end private - def account_user - Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) + def set_user + @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) end end end diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index 5cce5bce4..ccab03de4 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -5,47 +5,73 @@ module Admin before_action :set_custom_emoji, except: [:index, :new, :create] def index - @custom_emojis = filtered_custom_emojis.page(params[:page]) + authorize :custom_emoji, :index? + @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]) end def new + authorize :custom_emoji, :create? @custom_emoji = CustomEmoji.new end def create + authorize :custom_emoji, :create? + @custom_emoji = CustomEmoji.new(resource_params) if @custom_emoji.save + log_action :create, @custom_emoji redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg') else render :new end end + def update + authorize @custom_emoji, :update? + + if @custom_emoji.update(resource_params) + log_action :update, @custom_emoji + redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.updated_msg') + else + redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.update_failed_msg') + end + end + def destroy - @custom_emoji.destroy + authorize @custom_emoji, :destroy? + @custom_emoji.destroy! + log_action :destroy, @custom_emoji redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg') end def copy - emoji = CustomEmoji.new(domain: nil, shortcode: @custom_emoji.shortcode, image: @custom_emoji.image) + authorize @custom_emoji, :copy? + + emoji = CustomEmoji.find_or_initialize_by(domain: nil, shortcode: @custom_emoji.shortcode) + emoji.image = @custom_emoji.image if emoji.save + log_action :create, emoji flash[:notice] = I18n.t('admin.custom_emojis.copied_msg') else flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg') end - redirect_to admin_custom_emojis_path(params[:page]) + redirect_to admin_custom_emojis_path(page: params[:page]) end def enable + authorize @custom_emoji, :enable? @custom_emoji.update!(disabled: false) + log_action :enable, @custom_emoji redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg') end def disable + authorize @custom_emoji, :disable? @custom_emoji.update!(disabled: true) + log_action :disable, @custom_emoji redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg') end @@ -56,7 +82,7 @@ module Admin end def resource_params - params.require(:custom_emoji).permit(:shortcode, :image) + params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker) end def filtered_custom_emojis @@ -66,7 +92,9 @@ module Admin def filter_params params.permit( :local, - :remote + :remote, + :by_domain, + :shortcode ) end end diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 1ab620e03..64de2cbf0 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -5,28 +5,37 @@ module Admin before_action :set_domain_block, only: [:show, :destroy] def index + authorize :domain_block, :index? @domain_blocks = DomainBlock.page(params[:page]) end def new + authorize :domain_block, :create? @domain_block = DomainBlock.new end def create + authorize :domain_block, :create? + @domain_block = DomainBlock.new(resource_params) if @domain_block.save DomainBlockWorker.perform_async(@domain_block.id) + log_action :create, @domain_block redirect_to admin_domain_blocks_path, notice: I18n.t('admin.domain_blocks.created_msg') else render :new end end - def show; end + def show + authorize @domain_block, :show? + end def destroy + authorize @domain_block, :destroy? UnblockDomainService.new.call(@domain_block, retroactive_unblock?) + log_action :destroy, @domain_block redirect_to admin_domain_blocks_path, notice: I18n.t('admin.domain_blocks.destroyed_msg') end diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb index 09275d5dc..9fe85064e 100644 --- a/app/controllers/admin/email_domain_blocks_controller.rb +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -5,17 +5,22 @@ module Admin before_action :set_email_domain_block, only: [:show, :destroy] def index + authorize :email_domain_block, :index? @email_domain_blocks = EmailDomainBlock.page(params[:page]) end def new + authorize :email_domain_block, :create? @email_domain_block = EmailDomainBlock.new end def create + authorize :email_domain_block, :create? + @email_domain_block = EmailDomainBlock.new(resource_params) if @email_domain_block.save + log_action :create, @email_domain_block redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg') else render :new @@ -23,7 +28,9 @@ module Admin end def destroy - @email_domain_block.destroy + authorize @email_domain_block, :destroy? + @email_domain_block.destroy! + log_action :destroy, @email_domain_block redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg') end diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index 22f02e5d0..8ed0ea421 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -3,10 +3,12 @@ module Admin class InstancesController < BaseController def index + authorize :instance, :index? @instances = ordered_instances end def resubscribe + authorize :instance, :resubscribe? params.require(:by_domain) Pubsubhubbub::SubscribeWorker.push_bulk(subscribeable_accounts.pluck(:id)) redirect_to admin_instances_path diff --git a/app/controllers/admin/invites_controller.rb b/app/controllers/admin/invites_controller.rb new file mode 100644 index 000000000..faccaa7c8 --- /dev/null +++ b/app/controllers/admin/invites_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Admin + class InvitesController < BaseController + def index + authorize :invite, :index? + + @invites = filtered_invites.includes(user: :account).page(params[:page]) + @invite = Invite.new + end + + def create + authorize :invite, :create? + + @invite = Invite.new(resource_params) + @invite.user = current_user + + if @invite.save + redirect_to admin_invites_path + else + @invites = Invite.page(params[:page]) + render :index + end + end + + def destroy + @invite = Invite.find(params[:id]) + authorize @invite, :destroy? + @invite.expire! + redirect_to admin_invites_path + end + + private + + def resource_params + params.require(:invite).permit(:max_uses, :expires_in) + end + + def filtered_invites + InviteFilter.new(filter_params).results + end + + def filter_params + params.permit(:available, :expired) + end + end +end diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb index 5a31adecf..535bd11d4 100644 --- a/app/controllers/admin/reported_statuses_controller.rb +++ b/app/controllers/admin/reported_statuses_controller.rb @@ -2,26 +2,29 @@ module Admin class ReportedStatusesController < BaseController - include Authorization - before_action :set_report before_action :set_status, only: [:update, :destroy] def create - @form = Form::StatusBatch.new(form_status_batch_params) - flash[:alert] = t('admin.statuses.failed_to_execute') unless @form.save + authorize :status, :update? + + @form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account)) + flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save redirect_to admin_report_path(@report) end def update - @status.update(status_params) + authorize @status, :update? + @status.update!(status_params) + log_action :update, @status redirect_to admin_report_path(@report) end def destroy authorize @status, :destroy? RemovalWorker.perform_async(@status.id) + log_action :destroy, @status render json: @status end diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index 226467739..75db6b78a 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -5,14 +5,17 @@ module Admin before_action :set_report, except: [:index] def index + authorize :report, :index? @reports = filtered_reports.page(params[:page]) end def show + authorize @report, :show? @form = Form::StatusBatch.new end def update + authorize @report, :update? process_report redirect_to admin_report_path(@report) end @@ -22,12 +25,17 @@ module Admin def process_report case params[:outcome].to_s when 'resolve' - @report.update(action_taken_by_current_attributes) + @report.update!(action_taken_by_current_attributes) + log_action :resolve, @report when 'suspend' Admin::SuspensionWorker.perform_async(@report.target_account.id) + log_action :resolve, @report + log_action :suspend, @report.target_account resolve_all_target_account_reports when 'silence' - @report.target_account.update(silenced: true) + @report.target_account.update!(silenced: true) + log_action :resolve, @report + log_action :silence, @report.target_account resolve_all_target_account_reports else raise ActiveRecord::RecordNotFound diff --git a/app/controllers/admin/resets_controller.rb b/app/controllers/admin/resets_controller.rb index 6db648403..3e27d01ac 100644 --- a/app/controllers/admin/resets_controller.rb +++ b/app/controllers/admin/resets_controller.rb @@ -2,17 +2,19 @@ module Admin class ResetsController < BaseController - before_action :set_account + before_action :set_user def create - @account.user.send_reset_password_instructions + authorize @user, :reset_password? + @user.send_reset_password_instructions + log_action :reset_password, @user redirect_to admin_accounts_path end private - def set_account - @account = Account.find(params[:account_id]) + def set_user + @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) end end end diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb new file mode 100644 index 000000000..af7ec0740 --- /dev/null +++ b/app/controllers/admin/roles_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Admin + class RolesController < BaseController + before_action :set_user + + def promote + authorize @user, :promote? + @user.promote! + log_action :promote, @user + redirect_to admin_account_path(@user.account_id) + end + + def demote + authorize @user, :demote? + @user.demote! + log_action :demote, @user + redirect_to admin_account_path(@user.account_id) + end + + private + + def set_user + @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound) + end + end +end diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index a2f86b8a9..eed5fb6b5 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -13,14 +13,17 @@ module Admin closed_registrations_message open_deletion timeline_preview + show_staff_badge bootstrap_timeline_accounts thumbnail + min_invite_role ).freeze BOOLEAN_SETTINGS = %w( open_registrations open_deletion timeline_preview + show_staff_badge ).freeze UPLOAD_SETTINGS = %w( @@ -28,10 +31,13 @@ module Admin ).freeze def edit + authorize :settings, :show? @admin_settings = Form::AdminSettings.new end def update + authorize :settings, :update? + settings_params.each do |key, value| if UPLOAD_SETTINGS.include?(key) upload = SiteUpload.where(var: key).first_or_initialize(var: key) diff --git a/app/controllers/admin/silences_controller.rb b/app/controllers/admin/silences_controller.rb index 81a3008b9..4c06a9c0c 100644 --- a/app/controllers/admin/silences_controller.rb +++ b/app/controllers/admin/silences_controller.rb @@ -5,12 +5,16 @@ module Admin before_action :set_account def create - @account.update(silenced: true) + authorize @account, :silence? + @account.update!(silenced: true) + log_action :silence, @account redirect_to admin_accounts_path end def destroy - @account.update(silenced: false) + authorize @account, :unsilence? + @account.update!(silenced: false) + log_action :unsilence, @account redirect_to admin_accounts_path end diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index b05000b16..5d4325f57 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -2,8 +2,6 @@ module Admin class StatusesController < BaseController - include Authorization - helper_method :current_params before_action :set_account @@ -12,31 +10,39 @@ module Admin PER_PAGE = 20 def index + authorize :status, :index? + @statuses = @account.statuses + if params[:media] account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct @statuses.merge!(Status.where(id: account_media_status_ids)) end - @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE) - @form = Form::StatusBatch.new + @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE) + @form = Form::StatusBatch.new end def create - @form = Form::StatusBatch.new(form_status_batch_params) - flash[:alert] = t('admin.statuses.failed_to_execute') unless @form.save + authorize :status, :update? + + @form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account)) + flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save redirect_to admin_account_statuses_path(@account.id, current_params) end def update - @status.update(status_params) + authorize @status, :update? + @status.update!(status_params) + log_action :update, @status redirect_to admin_account_statuses_path(@account.id, current_params) end def destroy authorize @status, :destroy? RemovalWorker.perform_async(@status.id) + log_action :destroy, @status render json: @status end @@ -60,6 +66,7 @@ module Admin def current_params page = (params[:page] || 1).to_i + { media: params[:media], page: page > 1 && page, diff --git a/app/controllers/admin/subscriptions_controller.rb b/app/controllers/admin/subscriptions_controller.rb index 624a475a3..40500ef43 100644 --- a/app/controllers/admin/subscriptions_controller.rb +++ b/app/controllers/admin/subscriptions_controller.rb @@ -3,6 +3,7 @@ module Admin class SubscriptionsController < BaseController def index + authorize :subscription, :index? @subscriptions = ordered_subscriptions.page(requested_page) end diff --git a/app/controllers/admin/suspensions_controller.rb b/app/controllers/admin/suspensions_controller.rb index 5d9048d94..5f222e125 100644 --- a/app/controllers/admin/suspensions_controller.rb +++ b/app/controllers/admin/suspensions_controller.rb @@ -5,12 +5,16 @@ module Admin before_action :set_account def create + authorize @account, :suspend? Admin::SuspensionWorker.perform_async(@account.id) + log_action :suspend, @account redirect_to admin_accounts_path end def destroy - @account.update(suspended: false) + authorize @account, :unsuspend? + @account.unsuspend! + log_action :unsuspend, @account redirect_to admin_accounts_path end diff --git a/app/controllers/admin/two_factor_authentications_controller.rb b/app/controllers/admin/two_factor_authentications_controller.rb index 69c08f605..022107203 100644 --- a/app/controllers/admin/two_factor_authentications_controller.rb +++ b/app/controllers/admin/two_factor_authentications_controller.rb @@ -5,7 +5,9 @@ module Admin before_action :set_user def destroy + authorize @user, :disable_2fa? @user.disable_two_factor! + log_action :disable_2fa, @user redirect_to admin_accounts_path end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 7cfe8fe71..5983c0fbe 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -72,19 +72,4 @@ class Api::BaseController < ApplicationController def render_empty render json: {}, status: 200 end - - def set_maps(statuses) # rubocop:disable Style/AccessorMethodName - if current_account.nil? - @reblogs_map = {} - @favourites_map = {} - @mutes_map = {} - return - end - - status_ids = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq - conversation_ids = statuses.compact.map(&:conversation_id).compact.uniq - @reblogs_map = Status.reblogs_map(status_ids, current_account) - @favourites_map = Status.favourites_map(status_ids, current_account) - @mutes_map = Status.mutes_map(conversation_ids, current_account) - end end diff --git a/app/controllers/api/v1/accounts/lists_controller.rb b/app/controllers/api/v1/accounts/lists_controller.rb new file mode 100644 index 000000000..a7ba89ce2 --- /dev/null +++ b/app/controllers/api/v1/accounts/lists_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::ListsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read } + before_action :require_user! + before_action :set_account + + respond_to :json + + def index + @lists = @account.lists.where(account: current_account) + render json: @lists, each_serializer: REST::ListSerializer + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end +end diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index 2a5cac547..11e647c3c 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -17,12 +17,13 @@ class Api::V1::Accounts::SearchController < Api::BaseController AccountSearchService.new.call( params[:q], limit_param(DEFAULT_ACCOUNTS_LIMIT), - resolving_search?, - current_account + current_account, + resolve: truthy_param?(:resolve), + following: truthy_param?(:following) ) end - def resolving_search? - params[:resolve] == 'true' + def truthy_param?(key) + params[key] == 'true' end end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index b3fc4e561..4e73e9e8b 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -13,9 +13,9 @@ class Api::V1::AccountsController < Api::BaseController end def follow - FollowService.new.call(current_user.account, @account.acct) + FollowService.new.call(current_user.account, @account.acct, reblogs: params[:reblogs]) - options = @account.locked? ? {} : { following_map: { @account.id => true }, requested_map: { @account.id => false } } + options = @account.locked? ? {} : { following_map: { @account.id => { reblogs: params[:reblogs] } }, requested_map: { @account.id => false } } render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) end @@ -26,7 +26,7 @@ class Api::V1::AccountsController < Api::BaseController end def mute - MuteService.new.call(current_user.account, @account) + MuteService.new.call(current_user.account, @account, notifications: params[:notifications]) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end @@ -51,7 +51,7 @@ class Api::V1::AccountsController < Api::BaseController @account = Account.find(params[:id]) end - def relationships(options = {}) + def relationships(**options) AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options) end end diff --git a/app/controllers/api/v1/lists/accounts_controller.rb b/app/controllers/api/v1/lists/accounts_controller.rb new file mode 100644 index 000000000..c29c73b3e --- /dev/null +++ b/app/controllers/api/v1/lists/accounts_controller.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +class Api::V1::Lists::AccountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read }, only: [:show] + before_action -> { doorkeeper_authorize! :write }, except: [:show] + + before_action :require_user! + before_action :set_list + + after_action :insert_pagination_headers, only: :show + + def show + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + def create + ApplicationRecord.transaction do + list_accounts.each do |account| + @list.accounts << account + end + end + + render_empty + end + + def destroy + ListAccount.where(list: @list, account_id: account_ids).destroy_all + render_empty + end + + private + + def set_list + @list = List.where(account: current_account).find(params[:list_id]) + end + + def load_accounts + if unlimited? + @list.accounts.all + else + @list.accounts.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + end + end + + def list_accounts + Account.find(account_ids) + end + + def account_ids + Array(resource_params[:account_ids]) + end + + def resource_params + params.permit(account_ids: []) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + if records_continue? + api_v1_list_accounts_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + return if unlimited? + + unless @accounts.empty? + api_v1_list_accounts_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @accounts.last.id + end + + def pagination_since_id + @accounts.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 + + def unlimited? + params[:limit] == '0' + end +end diff --git a/app/controllers/api/v1/lists_controller.rb b/app/controllers/api/v1/lists_controller.rb new file mode 100644 index 000000000..180a91d81 --- /dev/null +++ b/app/controllers/api/v1/lists_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class Api::V1::ListsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write }, except: [:index, :show] + + before_action :require_user! + before_action :set_list, except: [:index, :create] + + def index + @lists = List.where(account: current_account).all + render json: @lists, each_serializer: REST::ListSerializer + end + + def show + render json: @list, serializer: REST::ListSerializer + end + + def create + @list = List.create!(list_params.merge(account: current_account)) + render json: @list, serializer: REST::ListSerializer + end + + def update + @list.update!(list_params) + render json: @list, serializer: REST::ListSerializer + end + + def destroy + @list.destroy! + render_empty + end + + private + + def set_list + @list = List.where(account: current_account).find(params[:id]) + end + + def list_params + params.permit(:title) + end +end diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 9592cd4bd..22828217d 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -19,7 +19,7 @@ class Api::V1::ReportsController < Api::BaseController comment: report_params[:comment] ) - User.admins.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later } + User.staff.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later } render json: @report, serializer: REST::ReportSerializer end diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb index bc5b8e5d4..997eed6e2 100644 --- a/app/controllers/api/v1/search_controller.rb +++ b/app/controllers/api/v1/search_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::V1::SearchController < Api::BaseController + include Authorization + RESULTS_LIMIT = 5 before_action -> { doorkeeper_authorize! :read } @@ -9,12 +11,24 @@ class Api::V1::SearchController < Api::BaseController respond_to :json def index - @search = Search.new(search_results) + @search = Search.new(search) render json: @search, serializer: REST::SearchSerializer end private + def search + search_results.tap do |search| + search[:statuses].keep_if do |status| + begin + authorize status, :show? + rescue Mastodon::NotPermittedError + false + end + end + end + end + def search_results SearchService.new.call( params[:q], diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index 3dd27710c..db6cd8568 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -31,7 +31,7 @@ class Api::V1::Timelines::HomeController < Api::BaseController end def account_home_feed - Feed.new(:home, current_account) + HomeFeed.new(current_account) end def insert_pagination_headers diff --git a/app/controllers/api/v1/timelines/list_controller.rb b/app/controllers/api/v1/timelines/list_controller.rb new file mode 100644 index 000000000..f5db71e46 --- /dev/null +++ b/app/controllers/api/v1/timelines/list_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::ListController < Api::BaseController + before_action -> { doorkeeper_authorize! :read } + before_action :require_user! + before_action :set_list + before_action :set_statuses + + after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + + def show + render json: @statuses, + each_serializer: REST::StatusSerializer, + relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id) + end + + private + + def set_list + @list = List.where(account: current_account).find(params[:id]) + end + + def set_statuses + @statuses = cached_list_statuses + end + + def cached_list_statuses + cache_collection list_statuses, Status + end + + def list_statuses + list_feed.get( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def list_feed + ListFeed.new(@list) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_params(core_params) + params.permit(:limit).merge(core_params) + end + + def next_path + api_v1_timelines_list_url params[:id], pagination_params(max_id: pagination_max_id) + end + + def prev_path + api_v1_timelines_list_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/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index d66237feb..52e250d02 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -28,6 +28,8 @@ class Api::Web::PushSubscriptionsController < Api::BaseController }, } + data.deep_merge!(params[:data]) if params[:data] + web_subscription = ::Web::PushSubscription.create!( endpoint: params[:subscription][:endpoint], key_p256dh: params[:subscription][:keys][:p256dh], diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d5eca6ffb..a213302cb 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -18,6 +18,7 @@ class ApplicationController < ActionController::Base rescue_from ActionController::RoutingError, with: :not_found rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity + rescue_from Mastodon::NotPermittedError, with: :forbidden before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? before_action :check_suspension, if: :user_signed_in? @@ -40,6 +41,10 @@ class ApplicationController < ActionController::Base redirect_to root_path unless current_user&.admin? end + def require_staff! + redirect_to root_path unless current_user&.staff? + end + def check_suspension forbidden if current_user.account.suspended? end @@ -99,7 +104,7 @@ class ApplicationController < ActionController::Base unless uncached_ids.empty? uncached = klass.where(id: uncached_ids).with_includes.map { |item| [item.id, item] }.to_h - uncached.values.each do |item| + uncached.each_value do |item| Rails.cache.write(item.cache_key, item) end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 223db96ff..da0b6512f 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -16,13 +16,16 @@ class Auth::RegistrationsController < Devise::RegistrationsController def build_resource(hash = nil) super(hash) - resource.locale = I18n.locale + + resource.locale = I18n.locale + resource.invite_code = params[:invite_code] if resource.invite_code.blank? + resource.build_account if resource.account.nil? end def configure_sign_up_params devise_parameter_sanitizer.permit(:sign_up) do |u| - u.permit({ account_attributes: [:username] }, :email, :password, :password_confirmation) + u.permit({ account_attributes: [:username] }, :email, :password, :password_confirmation, :invite_code) end end @@ -35,7 +38,19 @@ class Auth::RegistrationsController < Devise::RegistrationsController end def check_enabled_registrations - redirect_to root_path if single_user_mode? || !Setting.open_registrations + redirect_to root_path if single_user_mode? || !allowed_registrations? + end + + def allowed_registrations? + Setting.open_registrations || (invite_code.present? && Invite.find_by(code: invite_code)&.valid_for_use?) + end + + def invite_code + if params[:user] + params[:user][:invite_code] + else + params[:invite_code] + end end private diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index 463a183e4..a5acb6c36 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -62,7 +62,7 @@ class Auth::SessionsController < Devise::SessionsController if user_params[:otp_attempt].present? && session[:otp_user_id] authenticate_with_two_factor_via_otp(user) - elsif user && user.valid_password?(user_params[:password]) + elsif user&.valid_password?(user_params[:password]) prompt_for_two_factor(user) end end diff --git a/app/controllers/concerns/accountable_concern.rb b/app/controllers/concerns/accountable_concern.rb new file mode 100644 index 000000000..3cdcffc51 --- /dev/null +++ b/app/controllers/concerns/accountable_concern.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module AccountableConcern + extend ActiveSupport::Concern + + def log_action(action, target) + Admin::ActionLog.create(account: current_account, action: action, target: target) + end +end diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 7828fe48d..95a37e379 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -2,6 +2,7 @@ module Authorization extend ActiveSupport::Concern + include Pundit def pundit_user diff --git a/app/controllers/concerns/rate_limit_headers.rb b/app/controllers/concerns/rate_limit_headers.rb index 36cb91075..b79c558d8 100644 --- a/app/controllers/concerns/rate_limit_headers.rb +++ b/app/controllers/concerns/rate_limit_headers.rb @@ -44,7 +44,8 @@ module RateLimitHeaders end def api_throttle_data - request.env['rack.attack.throttle_data']['api'] + most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_, v| v[:limit] } + request.env['rack.attack.throttle_data'][most_limited_type] end def request_time diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb new file mode 100644 index 000000000..38d6c8d73 --- /dev/null +++ b/app/controllers/invites_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class InvitesController < ApplicationController + include Authorization + + layout 'admin' + + before_action :authenticate_user! + + def index + authorize :invite, :create? + + @invites = Invite.where(user: current_user) + @invite = Invite.new(expires_in: 1.day.to_i) + end + + def create + authorize :invite, :create? + + @invite = Invite.new(resource_params) + @invite.user = current_user + + if @invite.save + redirect_to invites_path + else + @invites = Invite.where(user: current_user) + render :index + end + end + + def destroy + @invite = Invite.where(user: current_user).find(params[:id]) + authorize @invite, :destroy? + @invite.expire! + redirect_to invites_path + end + + private + + def resource_params + params.require(:invite).permit(:max_uses, :expires_in) + end +end diff --git a/app/controllers/settings/migrations_controller.rb b/app/controllers/settings/migrations_controller.rb new file mode 100644 index 000000000..bc6436b87 --- /dev/null +++ b/app/controllers/settings/migrations_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Settings::MigrationsController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + + def show + @migration = Form::Migration.new(account: current_account.moved_to_account) + end + + def update + @migration = Form::Migration.new(resource_params) + + if @migration.valid? && migration_account_changed? + current_account.update!(moved_to_account: @migration.account) + ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) + redirect_to settings_migration_path, notice: I18n.t('migrations.updated_msg') + else + render :show + end + end + + private + + def resource_params + params.require(:migration).permit(:acct) + end + + def migration_account_changed? + current_account.moved_to_account_id != @migration.account&.id && + current_account.id != @migration.account&.id + end +end diff --git a/app/controllers/settings/notifications_controller.rb b/app/controllers/settings/notifications_controller.rb index 09839f16e..ce2530c54 100644 --- a/app/controllers/settings/notifications_controller.rb +++ b/app/controllers/settings/notifications_controller.rb @@ -26,7 +26,7 @@ class Settings::NotificationsController < ApplicationController def user_settings_params params.require(:user).permit( notification_emails: %i(follow follow_request reblog favourite mention digest), - interactions: %i(must_be_follower must_be_following) + interactions: %i(must_be_follower must_be_following must_be_following_dm) ) end end diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 1c27b2b18..5cc606808 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -6,12 +6,10 @@ module WellKnown def show @account = Account.find_local!(username_from_resource) - @canonical_account_uri = @account.to_webfinger_s - @magic_key = pem_to_magic_key(@account.keypair.public_key) respond_to do |format| format.any(:json, :html) do - render formats: :json, content_type: 'application/jrd+json' + render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json' end format.xml do @@ -35,21 +33,6 @@ module WellKnown WebfingerResource.new(resource_user).username end - def pem_to_magic_key(public_key) - modulus, exponent = [public_key.n, public_key.e].map do |component| - result = [] - - until component.zero? - result << [component % 256].pack('C') - component >>= 8 - end - - result.reverse.join - end - - (['RSA'] + [modulus, exponent].map { |n| Base64.urlsafe_encode64(n) }).join('.') - end - def resource_param params.require(:resource) end diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb new file mode 100644 index 000000000..e85243e57 --- /dev/null +++ b/app/helpers/admin/action_logs_helper.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Admin::ActionLogsHelper + def log_target(log) + if log.target + linkable_log_target(log.target) + else + log_target_from_history(log.target_type, log.recorded_changes) + end + end + + def linkable_log_target(record) + case record.class.name + when 'Account' + link_to record.acct, admin_account_path(record.id) + when 'User' + link_to record.account.acct, admin_account_path(record.account_id) + when 'CustomEmoji' + record.shortcode + when 'Report' + link_to "##{record.id}", admin_report_path(record) + when 'DomainBlock', 'EmailDomainBlock' + link_to record.domain, "https://#{record.domain}" + when 'Status' + link_to record.account.acct, TagManager.instance.url_for(record) + end + end + + def log_target_from_history(type, attributes) + case type + when 'CustomEmoji' + attributes['shortcode'] + when 'DomainBlock', 'EmailDomainBlock' + link_to attributes['domain'], "https://#{attributes['domain']}" + when 'Status' + tmp_status = Status.new(attributes) + link_to tmp_status.account.acct, TagManager.instance.url_for(tmp_status) + end + end + + def relevant_log_changes(log) + if log.target_type == 'CustomEmoji' && [:enable, :disable, :destroy].include?(log.action) + log.recorded_changes.slice('domain') + elsif log.target_type == 'CustomEmoji' && log.action == :update + log.recorded_changes.slice('domain', 'visible_in_picker') + elsif log.target_type == 'User' && [:promote, :demote].include?(log.action) + log.recorded_changes.slice('moderator', 'admin') + elsif log.target_type == 'DomainBlock' + log.recorded_changes.slice('severity', 'reject_media') + elsif log.target_type == 'Status' && log.action == :update + log.recorded_changes.slice('sensitive') + end + end + + def log_extra_attributes(hash) + safe_join(hash.to_a.map { |key, value| safe_join([content_tag(:span, key, class: 'diff-key'), '=', log_change(value)]) }, ' ') + end + + def log_change(val) + return content_tag(:span, val, class: 'diff-neutral') unless val.is_a?(Array) + safe_join([content_tag(:span, val.first, class: 'diff-old'), content_tag(:span, val.last, class: 'diff-new')], '→') + end + + def icon_for_log(log) + case log.target_type + when 'Account', 'User' + 'user' + when 'CustomEmoji' + 'file' + when 'Report' + 'flag' + when 'DomainBlock' + 'lock' + when 'EmailDomainBlock' + 'envelope' + when 'Status' + 'pencil' + end + end + + def class_for_log_icon(log) + case log.action + when :enable, :unsuspend, :unsilence, :confirm, :promote, :resolve + 'positive' + when :create + opposite_verbs?(log) ? 'negative' : 'positive' + when :update, :reset_password, :disable_2fa, :memorialize + 'neutral' + when :demote, :silence, :disable, :suspend + 'negative' + when :destroy + opposite_verbs?(log) ? 'positive' : 'negative' + else + '' + end + end + + private + + def opposite_verbs?(log) + %w(DomainBlock EmailDomainBlock).include?(log.target_type) + end +end diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 6a57b3d63..359c43d0e 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true module Admin::FilterHelper - ACCOUNT_FILTERS = %i(local remote by_domain silenced suspended recent username display_name email ip).freeze - REPORT_FILTERS = %i(resolved account_id target_account_id).freeze + ACCOUNT_FILTERS = %i(local remote by_domain silenced suspended recent username display_name email ip staff).freeze + REPORT_FILTERS = %i(resolved account_id target_account_id).freeze + INVITE_FILTER = %i(available expired).freeze + CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze - FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS def filter_link_to(text, link_to_params, link_class_params = link_to_params) new_url = filtered_url_for(link_to_params) @@ -12,13 +14,13 @@ module Admin::FilterHelper link_to text, new_url, class: filter_link_class(new_class) end - def table_link_to(icon, text, path, options = {}) + def table_link_to(icon, text, path, **options) link_to safe_join([fa_icon(icon), text]), path, options.merge(class: 'table-action-link') end def selected?(more_params) new_url = filtered_url_for(more_params) - filter_link_class(new_url) == 'selected' ? true : false + filter_link_class(new_url) == 'selected' end private diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 6d625e7db..8ed5c8bda 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -5,7 +5,7 @@ module ApplicationHelper current_page?(path) ? 'active' : '' end - def active_link_to(label, path, options = {}) + def active_link_to(label, path, **options) link_to label, path, options.merge(class: active_nav_class(path)) end @@ -35,6 +35,11 @@ module ApplicationHelper Rails.env.production? ? site_title : "#{site_title} (Dev)" end + def can?(action, record) + return false if record.nil? + policy(record).public_send("#{action}?") + end + def fa_icon(icon, attributes = {}) class_names = attributes[:class]&.split(' ') || [] class_names << 'fa' @@ -43,6 +48,10 @@ module ApplicationHelper content_tag(:i, nil, attributes.merge(class: class_names.join(' '))) end + def custom_emoji_tag(custom_emoji) + image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:") + end + def opengraph(property, content) tag(:meta, content: content, property: property) end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index c23a2e095..6c7c38070 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -9,6 +9,28 @@ module JsonLdHelper value.is_a?(Array) ? value.first : value end + # The url attribute can be a string, an array of strings, or an array of objects. + # The objects could include a mimeType. Not-included mimeType means it's text/html. + def url_to_href(value, preferred_type = nil) + single_value = if value.is_a?(Array) && !value.first.is_a?(String) + value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) } + elsif value.is_a?(Array) + value.first + else + value + end + + if single_value.nil? || single_value.is_a?(String) + single_value + else + single_value['href'] + end + end + + def as_array(value) + value.is_a?(Array) ? value : [value] + end + def value_or_id(value) value.is_a?(String) || value.nil? ? value : value['id'] end diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb index f4693358c..11894a895 100644 --- a/app/helpers/routing_helper.rb +++ b/app/helpers/routing_helper.rb @@ -11,7 +11,7 @@ module RoutingHelper end end - def full_asset_url(source, options = {}) + def full_asset_url(source, **options) source = ActionController::Base.helpers.asset_url(source, options) unless use_storage? URI.join(root_url, source).to_s diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index abce85812..1d4cb8a57 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -10,6 +10,7 @@ module SettingsHelper eo: 'Esperanto', es: 'Español', fa: 'فارسی', + gl: 'Galego', fi: 'Suomi', fr: 'Français', he: 'עברית', diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index 73d6baace..f63325658 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -105,12 +105,13 @@ export function fetchAccountFail(id, error) { }; }; -export function followAccount(id) { +export function followAccount(id, reblogs = true) { return (dispatch, getState) => { + const alreadyFollowing = getState().getIn(['relationships', id, 'following']); dispatch(followAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => { - dispatch(followAccountSuccess(response.data)); + api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => { + dispatch(followAccountSuccess(response.data, alreadyFollowing)); }).catch(error => { dispatch(followAccountFail(error)); }); @@ -136,10 +137,11 @@ export function followAccountRequest(id) { }; }; -export function followAccountSuccess(relationship) { +export function followAccountSuccess(relationship, alreadyFollowing) { return { type: ACCOUNT_FOLLOW_SUCCESS, relationship, + alreadyFollowing, }; }; @@ -241,11 +243,11 @@ export function unblockAccountFail(error) { }; -export function muteAccount(id) { +export function muteAccount(id, notifications) { return (dispatch, getState) => { dispatch(muteAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => { + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); }).catch(error => { diff --git a/app/javascript/mastodon/actions/favourites.js b/app/javascript/mastodon/actions/favourites.js index 09ce51fce..93094c526 100644 --- a/app/javascript/mastodon/actions/favourites.js +++ b/app/javascript/mastodon/actions/favourites.js @@ -10,6 +10,10 @@ export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FA export function fetchFavouritedStatuses() { return (dispatch, getState) => { + if (getState().getIn(['status_lists', 'favourites', 'isLoading'])) { + return; + } + dispatch(fetchFavouritedStatusesRequest()); api(getState).get('/api/v1/favourites').then(response => { @@ -46,7 +50,7 @@ export function expandFavouritedStatuses() { return (dispatch, getState) => { const url = getState().getIn(['status_lists', 'favourites', 'next'], null); - if (url === null) { + if (url === null || getState().getIn(['status_lists', 'favourites', 'isLoading'])) { return; } diff --git a/app/javascript/mastodon/actions/lists.js b/app/javascript/mastodon/actions/lists.js new file mode 100644 index 000000000..4c8f9b186 --- /dev/null +++ b/app/javascript/mastodon/actions/lists.js @@ -0,0 +1,313 @@ +import api from '../api'; + +export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; +export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; +export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL'; + +export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST'; +export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS'; +export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL'; + +export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE'; +export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET'; +export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP'; + +export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST'; +export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS'; +export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL'; + +export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST'; +export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS'; +export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL'; + +export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST'; +export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS'; +export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL'; + +export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST'; +export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS'; +export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL'; + +export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE'; +export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY'; +export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR'; + +export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST'; +export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS'; +export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL'; + +export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST'; +export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS'; +export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL'; + +export const fetchList = id => (dispatch, getState) => { + if (getState().getIn(['lists', id])) { + return; + } + + dispatch(fetchListRequest(id)); + + api(getState).get(`/api/v1/lists/${id}`) + .then(({ data }) => dispatch(fetchListSuccess(data))) + .catch(err => dispatch(fetchListFail(id, err))); +}; + +export const fetchListRequest = id => ({ + type: LIST_FETCH_REQUEST, + id, +}); + +export const fetchListSuccess = list => ({ + type: LIST_FETCH_SUCCESS, + list, +}); + +export const fetchListFail = (id, error) => ({ + type: LIST_FETCH_FAIL, + id, + error, +}); + +export const fetchLists = () => (dispatch, getState) => { + dispatch(fetchListsRequest()); + + api(getState).get('/api/v1/lists') + .then(({ data }) => dispatch(fetchListsSuccess(data))) + .catch(err => dispatch(fetchListsFail(err))); +}; + +export const fetchListsRequest = () => ({ + type: LISTS_FETCH_REQUEST, +}); + +export const fetchListsSuccess = lists => ({ + type: LISTS_FETCH_SUCCESS, + lists, +}); + +export const fetchListsFail = error => ({ + type: LISTS_FETCH_FAIL, + error, +}); + +export const submitListEditor = shouldReset => (dispatch, getState) => { + const listId = getState().getIn(['listEditor', 'listId']); + const title = getState().getIn(['listEditor', 'title']); + + if (listId === null) { + dispatch(createList(title, shouldReset)); + } else { + dispatch(updateList(listId, title, shouldReset)); + } +}; + +export const setupListEditor = listId => (dispatch, getState) => { + dispatch({ + type: LIST_EDITOR_SETUP, + list: getState().getIn(['lists', listId]), + }); + + dispatch(fetchListAccounts(listId)); +}; + +export const changeListEditorTitle = value => ({ + type: LIST_EDITOR_TITLE_CHANGE, + value, +}); + +export const createList = (title, shouldReset) => (dispatch, getState) => { + dispatch(createListRequest()); + + api(getState).post('/api/v1/lists', { title }).then(({ data }) => { + dispatch(createListSuccess(data)); + + if (shouldReset) { + dispatch(resetListEditor()); + } + }).catch(err => dispatch(createListFail(err))); +}; + +export const createListRequest = () => ({ + type: LIST_CREATE_REQUEST, +}); + +export const createListSuccess = list => ({ + type: LIST_CREATE_SUCCESS, + list, +}); + +export const createListFail = error => ({ + type: LIST_CREATE_FAIL, + error, +}); + +export const updateList = (id, title, shouldReset) => (dispatch, getState) => { + dispatch(updateListRequest(id)); + + api(getState).put(`/api/v1/lists/${id}`, { title }).then(({ data }) => { + dispatch(updateListSuccess(data)); + + if (shouldReset) { + dispatch(resetListEditor()); + } + }).catch(err => dispatch(updateListFail(id, err))); +}; + +export const updateListRequest = id => ({ + type: LIST_UPDATE_REQUEST, + id, +}); + +export const updateListSuccess = list => ({ + type: LIST_UPDATE_SUCCESS, + list, +}); + +export const updateListFail = (id, error) => ({ + type: LIST_UPDATE_FAIL, + id, + error, +}); + +export const resetListEditor = () => ({ + type: LIST_EDITOR_RESET, +}); + +export const deleteList = id => (dispatch, getState) => { + dispatch(deleteListRequest(id)); + + api(getState).delete(`/api/v1/lists/${id}`) + .then(() => dispatch(deleteListSuccess(id))) + .catch(err => dispatch(deleteListFail(id, err))); +}; + +export const deleteListRequest = id => ({ + type: LIST_DELETE_REQUEST, + id, +}); + +export const deleteListSuccess = id => ({ + type: LIST_DELETE_SUCCESS, + id, +}); + +export const deleteListFail = (id, error) => ({ + type: LIST_DELETE_FAIL, + id, + error, +}); + +export const fetchListAccounts = listId => (dispatch, getState) => { + dispatch(fetchListAccountsRequest(listId)); + + api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }) + .then(({ data }) => dispatch(fetchListAccountsSuccess(listId, data))) + .catch(err => dispatch(fetchListAccountsFail(listId, err))); +}; + +export const fetchListAccountsRequest = id => ({ + type: LIST_ACCOUNTS_FETCH_REQUEST, + id, +}); + +export const fetchListAccountsSuccess = (id, accounts, next) => ({ + type: LIST_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +export const fetchListAccountsFail = (id, error) => ({ + type: LIST_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +export const fetchListSuggestions = q => (dispatch, getState) => { + const params = { + q, + resolve: false, + limit: 4, + following: true, + }; + + api(getState).get('/api/v1/accounts/search', { params }) + .then(({ data }) => dispatch(fetchListSuggestionsReady(q, data))); +}; + +export const fetchListSuggestionsReady = (query, accounts) => ({ + type: LIST_EDITOR_SUGGESTIONS_READY, + query, + accounts, +}); + +export const clearListSuggestions = () => ({ + type: LIST_EDITOR_SUGGESTIONS_CLEAR, +}); + +export const changeListSuggestions = value => ({ + type: LIST_EDITOR_SUGGESTIONS_CHANGE, + value, +}); + +export const addToListEditor = accountId => (dispatch, getState) => { + dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId)); +}; + +export const addToList = (listId, accountId) => (dispatch, getState) => { + dispatch(addToListRequest(listId, accountId)); + + api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] }) + .then(() => dispatch(addToListSuccess(listId, accountId))) + .catch(err => dispatch(addToListFail(listId, accountId, err))); +}; + +export const addToListRequest = (listId, accountId) => ({ + type: LIST_EDITOR_ADD_REQUEST, + listId, + accountId, +}); + +export const addToListSuccess = (listId, accountId) => ({ + type: LIST_EDITOR_ADD_SUCCESS, + listId, + accountId, +}); + +export const addToListFail = (listId, accountId, error) => ({ + type: LIST_EDITOR_ADD_FAIL, + listId, + accountId, + error, +}); + +export const removeFromListEditor = accountId => (dispatch, getState) => { + dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId)); +}; + +export const removeFromList = (listId, accountId) => (dispatch, getState) => { + dispatch(removeFromListRequest(listId, accountId)); + + api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } }) + .then(() => dispatch(removeFromListSuccess(listId, accountId))) + .catch(err => dispatch(removeFromListFail(listId, accountId, err))); +}; + +export const removeFromListRequest = (listId, accountId) => ({ + type: LIST_EDITOR_REMOVE_REQUEST, + listId, + accountId, +}); + +export const removeFromListSuccess = (listId, accountId) => ({ + type: LIST_EDITOR_REMOVE_SUCCESS, + listId, + accountId, +}); + +export const removeFromListFail = (listId, accountId, error) => ({ + type: LIST_EDITOR_REMOVE_FAIL, + listId, + accountId, + error, +}); diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js index febda7219..daa76a8f7 100644 --- a/app/javascript/mastodon/actions/mutes.js +++ b/app/javascript/mastodon/actions/mutes.js @@ -1,5 +1,6 @@ import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; +import { openModal } from './modal'; export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS'; @@ -9,6 +10,9 @@ export const MUTES_EXPAND_REQUEST = 'MUTES_EXPAND_REQUEST'; export const MUTES_EXPAND_SUCCESS = 'MUTES_EXPAND_SUCCESS'; export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; +export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; +export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; + export function fetchMutes() { return (dispatch, getState) => { dispatch(fetchMutesRequest()); @@ -80,3 +84,20 @@ export function expandMutesFail(error) { error, }; }; + +export function initMuteModal(account) { + return dispatch => { + dispatch({ + type: MUTES_INIT_MODAL, + account, + }); + + dispatch(openModal('MUTE')); + }; +} + +export function toggleHideNotifications() { + return dispatch => { + dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); + }; +} diff --git a/app/javascript/mastodon/actions/pin_statuses.js b/app/javascript/mastodon/actions/pin_statuses.js index 01bf8930b..3f40f6c2d 100644 --- a/app/javascript/mastodon/actions/pin_statuses.js +++ b/app/javascript/mastodon/actions/pin_statuses.js @@ -4,12 +4,13 @@ export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; +import { me } from '../initial_state'; + export function fetchPinnedStatuses() { return (dispatch, getState) => { dispatch(fetchPinnedStatusesRequest()); - const accountId = getState().getIn(['meta', 'me']); - api(getState).get(`/api/v1/accounts/${accountId}/statuses`, { params: { pinned: true } }).then(response => { + api(getState).get(`/api/v1/accounts/${me}/statuses`, { params: { pinned: true } }).then(response => { dispatch(fetchPinnedStatusesSuccess(response.data, null)); }).catch(error => { dispatch(fetchPinnedStatusesFail(error)); diff --git a/app/javascript/mastodon/actions/push_notifications.js b/app/javascript/mastodon/actions/push_notifications.js index 55661d2b0..de06385f9 100644 --- a/app/javascript/mastodon/actions/push_notifications.js +++ b/app/javascript/mastodon/actions/push_notifications.js @@ -1,4 +1,5 @@ import axios from 'axios'; +import { pushNotificationsSetting } from '../settings'; export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT'; export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION'; @@ -42,11 +43,15 @@ export function saveSettings() { const state = getState().get('push_notifications'); const subscription = state.get('subscription'); const alerts = state.get('alerts'); + const data = { alerts }; axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, { - data: { - alerts, - }, + data, + }).then(() => { + const me = getState().getIn(['meta', 'me']); + if (me) { + pushNotificationsSetting.set(me, data); + } }); }; } diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 7802694a3..c22152edd 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -1,4 +1,4 @@ -import createStream from '../stream'; +import { connectStream } from '../stream'; import { updateTimeline, deleteFromTimelines, @@ -12,42 +12,19 @@ import { getLocale } from '../locales'; const { messages } = getLocale(); export function connectTimelineStream (timelineId, path, pollingRefresh = null) { - return (dispatch, getState) => { - const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); - const accessToken = getState().getIn(['meta', 'access_token']); + + return connectStream (path, pollingRefresh, (dispatch, getState) => { const locale = getState().getIn(['meta', 'locale']); - let polling = null; - - const setupPolling = () => { - polling = setInterval(() => { - pollingRefresh(dispatch); - }, 20000); - }; - - const clearPolling = () => { - if (polling) { - clearInterval(polling); - polling = null; - } - }; - - const subscription = createStream(streamingAPIBaseURL, accessToken, path, { - - connected () { - if (pollingRefresh) { - clearPolling(); - } + return { + onConnect() { dispatch(connectTimeline(timelineId)); }, - disconnected () { - if (pollingRefresh) { - setupPolling(); - } + onDisconnect() { dispatch(disconnectTimeline(timelineId)); }, - received (data) { + onReceive (data) { switch(data.event) { case 'update': dispatch(updateTimeline(timelineId, JSON.parse(data.payload))); @@ -60,26 +37,8 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) break; } }, - - reconnected () { - if (pollingRefresh) { - clearPolling(); - pollingRefresh(dispatch); - } - dispatch(connectTimeline(timelineId)); - }, - - }); - - const disconnect = () => { - if (subscription) { - subscription.close(); - } - clearPolling(); }; - - return disconnect; - }; + }); } function refreshHomeTimelineAndNotification (dispatch) { @@ -92,3 +51,4 @@ export const connectCommunityStream = () => connectTimelineStream('community', ' export const connectMediaStream = () => connectTimelineStream('community', 'public:local'); export const connectPublicStream = () => connectTimelineStream('public', 'public'); export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`); +export const connectListStream = (id) => connectTimelineStream(`list:${id}`, `list&list=${id}`); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 09abe2702..f8843d1d9 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -118,6 +118,7 @@ export const refreshCommunityTimeline = () => refreshTimeline('community', '/ 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 const refreshListTimeline = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); export function refreshTimelineFail(timeline, error, skipLoading) { return { @@ -158,6 +159,7 @@ export const expandCommunityTimeline = () => expandTimeline('community', '/ap 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 const expandListTimeline = id => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); export function expandTimelineRequest(timeline) { return { diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index d614a52c9..b0479db4f 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { Fragment } from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import Avatar from './avatar'; @@ -7,6 +7,7 @@ import Permalink from './permalink'; import IconButton from './icon_button'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { me } from '../initial_state'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -14,6 +15,8 @@ const messages = defineMessages({ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' }, + unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' }, }); @injectIntl @@ -21,7 +24,6 @@ export default class Account extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, - me: PropTypes.string.isRequired, onFollow: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, @@ -41,8 +43,16 @@ export default class Account extends ImmutablePureComponent { this.props.onMute(this.props.account); } + handleMuteNotifications = () => { + this.props.onMuteNotifications(this.props.account, true); + } + + handleUnmuteNotifications = () => { + this.props.onMuteNotifications(this.props.account, false); + } + render () { - const { account, me, intl, hidden } = this.props; + const { account, intl, hidden } = this.props; if (!account) { return
; @@ -70,8 +80,19 @@ export default class Account extends ImmutablePureComponent { } else if (blocking) { buttons = ; } else if (muting) { - buttons = ; - } else { + let hidingNotificationsButton; + if (account.getIn(['relationship', 'muting_notifications'])) { + hidingNotificationsButton = ; + } else { + hidingNotificationsButton = ; + } + buttons = ( + + + {hidingNotificationsButton} + + ); + } else if (!account.get('moved')) { buttons = ; } } diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index 14a8d4c38..6a16e2fc7 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -209,6 +209,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { onBlur={this.onBlur} onPaste={this.onPaste} style={style} + aria-autocomplete='list' /> diff --git a/app/javascript/mastodon/components/avatar.js b/app/javascript/mastodon/components/avatar.js index f7c484ee3..570505833 100644 --- a/app/javascript/mastodon/components/avatar.js +++ b/app/javascript/mastodon/components/avatar.js @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { autoPlayGif } from '../initial_state'; export default class Avatar extends React.PureComponent { @@ -8,12 +9,12 @@ export default class Avatar extends React.PureComponent { account: ImmutablePropTypes.map.isRequired, size: PropTypes.number.isRequired, style: PropTypes.object, - animate: PropTypes.bool, inline: PropTypes.bool, + animate: PropTypes.bool, }; static defaultProps = { - animate: false, + animate: autoPlayGif, size: 20, inline: false, }; diff --git a/app/javascript/mastodon/components/avatar_overlay.js b/app/javascript/mastodon/components/avatar_overlay.js index f5d67b34e..3ec1d7730 100644 --- a/app/javascript/mastodon/components/avatar_overlay.js +++ b/app/javascript/mastodon/components/avatar_overlay.js @@ -1,22 +1,29 @@ import React from 'react'; +import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { autoPlayGif } from '../initial_state'; export default class AvatarOverlay extends React.PureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, friend: ImmutablePropTypes.map.isRequired, + animate: PropTypes.bool, + }; + + static defaultProps = { + animate: autoPlayGif, }; render() { - const { account, friend } = this.props; + const { account, friend, animate } = this.props; const baseStyle = { - backgroundImage: `url(${account.get('avatar_static')})`, + backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`, }; const overlayStyle = { - backgroundImage: `url(${friend.get('avatar_static')})`, + backgroundImage: `url(${friend.get(animate ? 'avatar' : 'avatar_static')})`, }; return ( diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index e4fa8fa7a..80a8fbdb3 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -137,7 +137,9 @@ export default class ColumnHeader extends React.PureComponent {

- {title} + + {title} +
{backButton} diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index 3a3ebf487..43dc0d6e3 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -110,7 +110,7 @@ export default class Dropdown extends React.PureComponent { icon: PropTypes.string.isRequired, items: PropTypes.array.isRequired, size: PropTypes.number.isRequired, - ariaLabel: PropTypes.string, + title: PropTypes.string, disabled: PropTypes.bool, status: ImmutablePropTypes.map, isUserTouching: PropTypes.func, @@ -120,7 +120,7 @@ export default class Dropdown extends React.PureComponent { }; static defaultProps = { - ariaLabel: 'Menu', + title: 'Menu', }; state = { @@ -186,14 +186,14 @@ export default class Dropdown extends React.PureComponent { } render () { - const { icon, items, size, ariaLabel, disabled } = this.props; + const { icon, items, size, title, disabled } = this.props; const { expanded } = this.state; return (
components unless + // we actually need to animate. + return ( +