diff --git a/.buildpacks b/.buildpacks index 3450683ce8..5e73304a5d 100644 --- a/.buildpacks +++ b/.buildpacks @@ -1,4 +1,3 @@ https://github.com/heroku/heroku-buildpack-apt https://github.com/Scalingo/ffmpeg-buildpack -https://github.com/Scalingo/nodejs-buildpack https://github.com/Scalingo/ruby-buildpack diff --git a/.circleci/config.yml b/.circleci/config.yml index 9f43a05735..862fa126b7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -72,11 +72,12 @@ aliases: - run: name: Set bundler settings command: | - bundle config clean 'true' - bundle config deployment 'true' - bundle config with 'pam_authentication' - bundle config without 'development production' - bundle config frozen 'true' + bundle config --local clean 'true' + bundle config --local deployment 'true' + bundle config --local with 'pam_authentication' + bundle config --local without 'development production' + bundle config --local frozen 'true' + bundle config --local path $BUNDLE_PATH - run: name: Install bundler dependencies command: bundle check || (bundle install && bundle clean) diff --git a/.codeclimate.yml b/.codeclimate.yml index d8d5c0ac79..8701e5f3d2 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -27,10 +27,10 @@ plugins: enabled: true eslint: enabled: true - channel: eslint-6 + channel: eslint-7 rubocop: enabled: true - channel: rubocop-0-82 + channel: rubocop-1-9-1 sass-lint: enabled: true exclude_patterns: diff --git a/.deepsource.toml b/.deepsource.toml new file mode 100644 index 0000000000..bcd3104124 --- /dev/null +++ b/.deepsource.toml @@ -0,0 +1,23 @@ +version = 1 + +test_patterns = ["app/javascript/mastodon/**/__tests__/**"] + +exclude_patterns = [ + "db/migrate/**", + "db/post_migrate/**" +] + +[[analyzers]] +name = "ruby" +enabled = true + +[[analyzers]] +name = "javascript" +enabled = true + + [analyzers.meta] + environment = [ + "browser", + "jest", + "nodejs" + ] diff --git a/.dockerignore b/.dockerignore index bf918029b6..9bc23d8132 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,3 +13,4 @@ vendor/bundle postgres redis elasticsearch +chart diff --git a/.env.production.sample b/.env.production.sample index 1292b752e2..12ca64a066 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -1,27 +1,15 @@ -# Service dependencies -# You may set REDIS_URL instead for more advanced options -# You may also set REDIS_NAMESPACE to share Redis between multiple Mastodon servers -REDIS_HOST=redis -REDIS_PORT=6379 -# You may set DATABASE_URL instead for more advanced options -DB_HOST=db -DB_USER=postgres -DB_NAME=postgres -DB_PASS= -DB_PORT=5432 -# Optional ElasticSearch configuration -# You may also set ES_PREFIX to share the same cluster between multiple Mastodon servers (falls back to REDIS_NAMESPACE if not set) -# ES_ENABLED=true -# ES_HOST=es -# ES_PORT=9200 +# This is a sample configuration file. You can generate your configuration +# with the `rake mastodon:setup` interactive setup wizard, but to customize +# your setup even further, you'll need to edit it manually. This sample does +# not demonstrate all available configuration options. Please look at +# https://docs.joinmastodon.org/admin/config/ for the full documentation. # Federation -# Note: Changing LOCAL_DOMAIN at a later time will cause unwanted side effects, including breaking all existing federation. -# LOCAL_DOMAIN should *NOT* contain the protocol part of the domain e.g https://example.com. +# ---------- +# This identifies your server and cannot be changed safely later +# ---------- LOCAL_DOMAIN=example.com -# Changing LOCAL_HTTPS in production is no longer supported. (Mastodon will always serve https:// links) - # Use this only if you need to run mastodon on a different domain than the one used for federation. # You can read more about this option on https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Serving_a_different_domain.md # DO *NOT* USE THIS UNLESS YOU KNOW *EXACTLY* WHAT YOU ARE DOING. @@ -32,107 +20,99 @@ LOCAL_DOMAIN=example.com # be added. Comma separated values # ALTERNATE_DOMAINS=example1.com,example2.com -# Application secrets +# Use HTTP proxy for outgoing request (optional) +# http_proxy=http://gateway.local:8118 +# Access control for hidden service. +# ALLOW_ACCESS_TO_HIDDEN_SERVICE=true + +# Authorized fetch mode (optional) +# Require remote servers to authentify when fetching toots, see +# https://docs.joinmastodon.org/admin/config/#authorized_fetch +# AUTHORIZED_FETCH=true + +# Limited federation mode (optional) +# Only allow federation with specific domains, see +# https://docs.joinmastodon.org/admin/config/#whitelist_mode +# LIMITED_FEDERATION_MODE=true + +# Redis +# ----- +REDIS_HOST=localhost +REDIS_PORT=6379 + + +# PostgreSQL +# ---------- +DB_HOST=/var/run/postgresql +DB_USER=mastodon +DB_NAME=mastodon_production +DB_PASS= +DB_PORT=5432 + + +# ElasticSearch (optional) +# ------------------------ +#ES_ENABLED=true +#ES_HOST=localhost +#ES_PORT=9200 + + +# Secrets +# ------- # Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web bundle exec rake secret` if you use docker compose) +# ------- SECRET_KEY_BASE= 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) + +# Web Push +# -------- +# Generate with `rake mastodon:webpush:generate_vapid_key` (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 `RAILS_ENV=production bundle exec rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web bundle exec rake mastodon:webpush:generate_vapid_key` if you use docker compose) -# -# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html +# -------- VAPID_PRIVATE_KEY= VAPID_PUBLIC_KEY= + # Registrations +# ------------- + # Single user mode will disable registrations and redirect frontpage to the first profile # SINGLE_USER_MODE=true -# Prevent registrations with following e-mail domains -# EMAIL_DOMAIN_BLACKLIST=example1.com|example2.de|etc -# Only allow registrations with the following e-mail domains -# EMAIL_DOMAIN_WHITELIST=example1.com|example2.de|etc +# Prevent registrations with following e-mail domains +# EMAIL_DOMAIN_DENYLIST=example1.com|example2.de|etc + +# Only allow registrations with the following e-mail domains +# EMAIL_DOMAIN_ALLOWLIST=example1.com|example2.de|etc + +#TODO move this # Optionally change default language # DEFAULT_LOCALE=de -# E-mail configuration -# Note: Mailgun and SparkPost (https://sparkpo.st/smtp) each have good free tiers -# If you want to use an SMTP server without authentication (e.g local Postfix relay) -# then set SMTP_AUTH_METHOD and SMTP_OPENSSL_VERIFY_MODE to 'none' and -# *comment* SMTP_LOGIN and SMTP_PASSWORD (leaving them blank is not enough). + +# Sending mail +# ------------ SMTP_SERVER=smtp.mailgun.org SMTP_PORT=587 SMTP_LOGIN= SMTP_PASSWORD= -SMTP_FROM_ADDRESS=notifications@example.com -#SMTP_REPLY_TO= -#SMTP_DOMAIN= # defaults to LOCAL_DOMAIN -#SMTP_DELIVERY_METHOD=smtp # delivery method can also be sendmail -#SMTP_AUTH_METHOD=plain -#SMTP_CA_FILE=/etc/ssl/certs/ca-certificates.crt -#SMTP_OPENSSL_VERIFY_MODE=peer -#SMTP_ENABLE_STARTTLS_AUTO=true -#SMTP_TLS=true +SMTP_FROM_ADDRESS=notificatons@example.com -# 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 -# PAPERCLIP_ROOT_URL=/system -# Optional asset host for multi-server setups -# The asset host must allow cross origin request from WEB_DOMAIN or LOCAL_DOMAIN -# if WEB_DOMAIN is not set. For example, the server may have the -# following header field: -# Access-Control-Allow-Origin: https://example.com/ -# CDN_HOST=https://assets.example.com - -# Optional list of hosts that are allowed to serve media for your instance -# This is useful if you include external media in your custom CSS or about page, -# or if your data storage provider makes use of redirects to other domains. -# EXTRA_DATA_HOSTS=https://data.example1.com|https://data.example2.com - -# S3 (optional) +# File storage (optional) +# ----------------------- # The attachment host must allow cross origin request from WEB_DOMAIN or # LOCAL_DOMAIN if WEB_DOMAIN is not set. For example, the server may have the # following header field: # Access-Control-Allow-Origin: https://192.168.1.123:9000/ -# S3_ENABLED=true -# S3_BUCKET= -# AWS_ACCESS_KEY_ID= -# AWS_SECRET_ACCESS_KEY= -# S3_REGION= -# S3_PROTOCOL=http -# S3_HOSTNAME=192.168.1.123:9000 - -# S3 (Minio Config (optional) Please check Minio instance for details) -# The attachment host must allow cross origin request - see the description -# above. -# S3_ENABLED=true -# S3_BUCKET= -# AWS_ACCESS_KEY_ID= -# AWS_SECRET_ACCESS_KEY= -# S3_REGION= -# S3_PROTOCOL=https -# S3_HOSTNAME= -# S3_ENDPOINT= -# S3_SIGNATURE_VERSION= - -# Google Cloud Storage (optional) -# Use S3 compatible API. Since GCS does not support Multipart Upload, -# increase the value of S3_MULTIPART_THRESHOLD to disable Multipart Upload. -# The attachment host must allow cross origin request - see the description -# above. -# S3_ENABLED=true -# AWS_ACCESS_KEY_ID= -# AWS_SECRET_ACCESS_KEY= -# S3_REGION= -# S3_PROTOCOL=https -# S3_HOSTNAME=storage.googleapis.com -# S3_ENDPOINT=https://storage.googleapis.com -# S3_MULTIPART_THRESHOLD=52428801 # 50.megabytes +# ----------------------- +#S3_ENABLED=true +#S3_BUCKET=files.example.com +#AWS_ACCESS_KEY_ID= +#AWS_SECRET_ACCESS_KEY= +#S3_ALIAS_HOST=files.example.com # Swift (optional) # The attachment host must allow cross origin request - see the description @@ -155,50 +135,27 @@ SMTP_FROM_ADDRESS=notifications@example.com # Defaults to 60 seconds. Set to 0 to disable # SWIFT_CACHE_TTL= +# Optional asset host for multi-server setups +# The asset host must allow cross origin request from WEB_DOMAIN or LOCAL_DOMAIN +# if WEB_DOMAIN is not set. For example, the server may have the +# following header field: +# Access-Control-Allow-Origin: https://example.com/ +# CDN_HOST=https://assets.example.com + +# Optional list of hosts that are allowed to serve media for your instance +# This is useful if you include external media in your custom CSS or about page, +# or if your data storage provider makes use of redirects to other domains. +# EXTRA_DATA_HOSTS=https://data.example1.com|https://data.example2.com + # Optional alias for S3 (e.g. to serve files on a custom domain, possibly using Cloudfront or Cloudflare) # S3_ALIAS_HOST= # Streaming API integration # STREAMING_API_BASE_URL= -# Advanced settings -# If you need to use pgBouncer, you need to disable prepared statements: -# PREPARED_STATEMENTS=false - -# Cluster number setting for streaming API server. -# If you comment out following line, cluster number will be `numOfCpuCores - 1`. -STREAMING_CLUSTER_NUM=1 - -# Docker mastodon user -# If you use Docker, you may want to assign UID/GID manually. -# UID=1000 -# GID=1000 - -# Maximum allowed character count -# MAX_TOOT_CHARS=500 - -# Maximum number of pinned posts -# MAX_PINNED_TOOTS=5 - -# Maximum allowed bio characters -# MAX_BIO_CHARS=500 - -# Maximim number of profile fields allowed -# MAX_PROFILE_FIELDS=4 - -# Maximum allowed display name characters -# MAX_DISPLAY_NAME_CHARS=30 - -# Maximum image and video/audio upload sizes -# Units are in bytes -# 1048576 bytes equals 1 megabyte -# MAX_IMAGE_SIZE=8388608 -# MAX_VIDEO_SIZE=41943040 - -# Maximum search results to display -# Only relevant when elasticsearch is installed -# MAX_SEARCH_RESULTS=20 +# External authentication (optional) +# ---------------------------------- # LDAP authentication (optional) # LDAP_ENABLED=true # LDAP_HOST=localhost @@ -276,17 +233,39 @@ STREAMING_CLUSTER_NUM=1 # SAML_ATTRIBUTES_STATEMENTS_VERIFIED= # SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL= -# Use HTTP proxy for outgoing request (optional) -# http_proxy=http://gateway.local:8118 -# Access control for hidden service. -# ALLOW_ACCESS_TO_HIDDEN_SERVICE=true -# Authorized fetch mode (optional) -# Require remote servers to authentify when fetching toots, see -# https://docs.joinmastodon.org/admin/config/#authorized_fetch -# AUTHORIZED_FETCH=true +# Custom settings +# --------------- +# Various ways to customize Mastodon's behavior +# --------------- + +# Maximum allowed character count +MAX_TOOT_CHARS=500 -# Whitelist mode (optional) -# Only allow federation with whitelisted domains, see -# https://docs.joinmastodon.org/admin/config/#whitelist_mode -# WHITELIST_MODE=true +# Maximum number of pinned posts +MAX_PINNED_TOOTS=5 + +# Maximum allowed bio characters +MAX_BIO_CHARS=500 + +# Maximim number of profile fields allowed +MAX_PROFILE_FIELDS=4 + +# Maximum allowed display name characters +MAX_DISPLAY_NAME_CHARS=30 + +# Maximum allowed poll options +MAX_POLL_OPTIONS=5 + +# Maximum allowed poll option characters +MAX_POLL_OPTION_CHARS=100 + +# Maximum image and video/audio upload sizes +# Units are in bytes +# 1048576 bytes equals 1 megabyte +# MAX_IMAGE_SIZE=8388608 +# MAX_VIDEO_SIZE=41943040 + +# Maximum search results to display +# Only relevant when elasticsearch is installed +# MAX_SEARCH_RESULTS=20 diff --git a/.eslintrc.js b/.eslintrc.js index 177496d3a3..7dda011082 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -199,6 +199,11 @@ module.exports = { 'import/no-unresolved': 'error', 'import/no-webpack-loader-syntax': 'error', - 'promise/catch-or-return': 'error', + 'promise/catch-or-return': [ + 'error', + { + allowFinally: true, + }, + ], }, }; diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 91ee92a2e5..9526e17db7 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,3 @@ patreon: mastodon open_collective: mastodon +github: [Gargron] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ceb737075b..870394a915 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,7 +1,7 @@ --- name: Bug Report -about: Create a report to help us improve - +about: If something isn't working as expected +labels: bug --- [Issue text goes here]. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 3890729e22..ff92c0316e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,7 +1,6 @@ --- name: Feature Request about: I have a suggestion - --- diff --git a/.gitignore b/.gitignore index ea61b2724c..b4d2712ffc 100644 --- a/.gitignore +++ b/.gitignore @@ -17,31 +17,34 @@ /log/* !/log/.keep /tmp -coverage -public/system -public/assets -public/packs -public/packs-test +/coverage +/public/system +/public/assets +/public/packs +/public/packs-test .env .env.production .env.development -node_modules/ -build/ +/node_modules/ +/build/ # Ignore Vagrant files .vagrant/ # Ignore Capistrano customizations -config/deploy/* +/config/deploy/* # Ignore IDE files .vscode/ .idea/ # Ignore postgres + redis + elasticsearch volume optionally created by docker-compose -postgres -redis -elasticsearch +/postgres +/redis +/elasticsearch + +# ignore Helm dependency charts +/chart/charts/*.tgz # Ignore Apple files .DS_Store diff --git a/.rubocop.yml b/.rubocop.yml index 3a11f70009..2af0f59bbe 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,7 +2,8 @@ require: - rubocop-rails AllCops: - TargetRubyVersion: 2.4 + TargetRubyVersion: 2.5 + NewCops: disable Exclude: - 'spec/**/*' - 'db/**/*' @@ -25,26 +26,68 @@ Layout/AccessModifierIndentation: Layout/EmptyLineAfterMagicComment: Enabled: false +Layout/EmptyLineAfterGuardClause: + Enabled: false + +Layout/EmptyLinesAroundAttributeAccessor: + Enabled: true + +Layout/HashAlignment: + Enabled: false + # EnforcedHashRocketStyle: table + # EnforcedColonStyle: table + +Layout/SpaceAroundMethodCallOperator: + Enabled: true + Layout/SpaceInsideHashLiteralBraces: EnforcedStyle: space +Lint/DeprecatedOpenSSLConstant: + Enabled: true + +Lint/DuplicateElsifCondition: + Enabled: true + +Lint/MixedRegexpCaptureTypes: + Enabled: true + +Lint/RaiseException: + Enabled: true + +Lint/StructNewOverride: + Enabled: true + +Lint/UselessAccessModifier: + ContextCreatingMethods: + - class_methods + Metrics/AbcSize: Max: 100 + Exclude: + - 'lib/mastodon/*_cli.rb' Metrics/BlockLength: - Max: 35 + Max: 55 Exclude: - 'lib/tasks/**/*' + - 'lib/mastodon/*_cli.rb' Metrics/BlockNesting: Max: 3 + Exclude: + - 'lib/mastodon/*_cli.rb' Metrics/ClassLength: CountComments: false - Max: 300 + Max: 400 + Exclude: + - 'lib/mastodon/*_cli.rb' Metrics/CyclomaticComplexity: Max: 25 + Exclude: + - 'lib/mastodon/*_cli.rb' Layout/LineLength: AllowURI: true @@ -52,7 +95,9 @@ Layout/LineLength: Metrics/MethodLength: CountComments: false - Max: 55 + Max: 65 + Exclude: + - 'lib/mastodon/*_cli.rb' Metrics/ModuleLength: CountComments: false @@ -63,34 +108,90 @@ Metrics/ParameterLists: CountKeywordArgs: true Metrics/PerceivedComplexity: - Max: 20 + Max: 25 Naming/MemoizedInstanceVariableName: Enabled: false +Naming/MethodParameterName: + Enabled: true + Rails: Enabled: true +Rails/ApplicationController: + Enabled: false + Exclude: + - 'app/controllers/well_known/**/*.rb' + +Rails/BelongsTo: + Enabled: false + +Rails/ContentTag: + Enabled: false + Rails/EnumHash: Enabled: false -Rails/HasAndBelongsToMany: - Enabled: false - -Rails/SkipsModelValidations: - Enabled: false - -Rails/HttpStatus: - Enabled: false - Rails/Exit: Exclude: - 'lib/mastodon/*' - 'lib/cli.rb' +Rails/FilePath: + Enabled: false + +Rails/HasAndBelongsToMany: + Enabled: false + +Rails/HasManyOrHasOneDependent: + Enabled: false + Rails/HelperInstanceVariable: Enabled: false +Rails/HttpStatus: + Enabled: false + +Rails/IndexBy: + Enabled: false + +Rails/InverseOf: + Enabled: false + +Rails/LexicallyScopedActionFilter: + Enabled: false + +Rails/OutputSafety: + Enabled: true + +Rails/RakeEnvironment: + Enabled: false + +Rails/RedundantForeignKey: + Enabled: false + +Rails/SkipsModelValidations: + Enabled: false + +Rails/UniqueValidationWithoutIndex: + Enabled: false + +Style/AccessorGrouping: + Enabled: true + +Style/AccessModifierDeclarations: + Enabled: false + +Style/ArrayCoercion: + Enabled: true + +Style/BisectedAttrAccessor: + Enabled: true + +Style/CaseLikeIf: + Enabled: false + Style/ClassAndModuleChildren: Enabled: false @@ -105,6 +206,15 @@ Style/Documentation: Style/DoubleNegation: Enabled: true +Style/ExpandPathArguments: + Enabled: false + +Style/ExponentialNotation: + Enabled: true + +Style/FormatString: + Enabled: false + Style/FormatStringToken: Enabled: false @@ -114,9 +224,33 @@ Style/FrozenStringLiteralComment: Style/GuardClause: Enabled: false +Style/HashAsLastArrayItem: + Enabled: false + +Style/HashEachMethods: + Enabled: true + +Style/HashLikeCase: + Enabled: true + +Style/HashTransformKeys: + Enabled: true + +Style/HashTransformValues: + Enabled: false + +Style/IfUnlessModifier: + Enabled: false + +Style/InverseMethods: + Enabled: false + Style/Lambda: Enabled: false +Style/MutableConstant: + Enabled: false + Style/PercentLiteralDelimiters: PreferredDelimiters: '%i': '()' @@ -125,9 +259,36 @@ Style/PercentLiteralDelimiters: Style/PerlBackrefs: AutoCorrect: false +Style/RedundantAssignment: + Enabled: false + +Style/RedundantFetchBlock: + Enabled: true + +Style/RedundantFileExtensionInRequire: + Enabled: true + +Style/RedundantRegexpCharacterClass: + Enabled: false + +Style/RedundantRegexpEscape: + Enabled: false + +Style/RedundantReturn: + Enabled: true + Style/RegexpLiteral: Enabled: false +Style/RescueStandardError: + Enabled: false + +Style/SignalException: + Enabled: false + +Style/SlicingWithRange: + Enabled: true + Style/SymbolArray: Enabled: false @@ -136,3 +297,6 @@ Style/TrailingCommaInArrayLiteral: Style/TrailingCommaInHashLiteral: EnforcedStyleForMultiline: 'comma' + +Style/UnpackFirst: + Enabled: false diff --git a/.ruby-version b/.ruby-version index 338a5b5d8f..2c9b4ef42e 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.6.6 +2.7.3 diff --git a/AUTHORS.md b/AUTHORS.md index 5f5985fba8..43adc3bb1a 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -6,19 +6,20 @@ and provided thanks to the work of the following contributors: * [Gargron](https://github.com/Gargron) * [ThibG](https://github.com/ThibG) -* [ykzts](https://github.com/ykzts) -* [dependabot[bot]](https://github.com/apps/dependabot) -* [akihikodaki](https://github.com/akihikodaki) * [dependabot-preview[bot]](https://github.com/apps/dependabot-preview) +* [dependabot[bot]](https://github.com/apps/dependabot) +* [ykzts](https://github.com/ykzts) +* [akihikodaki](https://github.com/akihikodaki) * [mjankowski](https://github.com/mjankowski) * [unarist](https://github.com/unarist) * [yiskah](https://github.com/yiskah) * [nolanlawson](https://github.com/nolanlawson) -* [ysksn](https://github.com/ysksn) * [abcang](https://github.com/abcang) -* [sorin-davidoi](https://github.com/sorin-davidoi) -* [lynlynlynx](https://github.com/lynlynlynx) * [mayaeh](https://github.com/mayaeh) +* [ysksn](https://github.com/ysksn) +* [sorin-davidoi](https://github.com/sorin-davidoi) +* [noellabo](https://github.com/noellabo) +* [lynlynlynx](https://github.com/lynlynlynx) * [m4sk1n](mailto:me@m4sk.in) * [Marcin Mikołajczak](mailto:me@m4sk.in) * [Kjwon15](https://github.com/Kjwon15) @@ -27,40 +28,44 @@ and provided thanks to the work of the following contributors: * [jeroenpraat](https://github.com/jeroenpraat) * [nclm](https://github.com/nclm) * [ineffyble](https://github.com/ineffyble) -* [mabkenar](https://github.com/mabkenar) +* [zunda](https://github.com/zunda) +* [shleeable](https://github.com/shleeable) +* [Masoud Abkenar](mailto:ampbox@gmail.com) * [blackle](https://github.com/blackle) * [Quent-in](https://github.com/Quent-in) * [JantsoP](https://github.com/JantsoP) -* [zunda](https://github.com/zunda) * [nullkal](https://github.com/nullkal) * [yookoala](https://github.com/yookoala) +* [Brawaru](https://github.com/Brawaru) +* [ariasuni](https://github.com/ariasuni) * [Aditoo17](https://github.com/Aditoo17) * [Quenty31](https://github.com/Quenty31) * [marek-lach](https://github.com/marek-lach) * [shuheiktgw](https://github.com/shuheiktgw) * [ashfurrow](https://github.com/ashfurrow) -* [eramdam](https://github.com/eramdam) -* [noellabo](https://github.com/noellabo) -* [takayamaki](https://github.com/takayamaki) * [danhunsaker](https://github.com/danhunsaker) +* [eramdam](https://github.com/eramdam) +* [takayamaki](https://github.com/takayamaki) * [masarakki](https://github.com/masarakki) * [ticky](https://github.com/ticky) +* [trwnh](https://github.com/trwnh) * [ThisIsMissEm](https://github.com/ThisIsMissEm) +* [hinaloe](https://github.com/hinaloe) * [hcmiya](https://github.com/hcmiya) * [stephenburgess8](https://github.com/stephenburgess8) -* [Wonderfall](https://github.com/Wonderfall) +* [Wonderfall](mailto:wonderfall@targaryen.house) * [matteoaquila](https://github.com/matteoaquila) * [yukimochi](https://github.com/yukimochi) * [palindromordnilap](https://github.com/palindromordnilap) * [rkarabut](https://github.com/rkarabut) -* [Artoria2e5](https://github.com/Artoria2e5) * [nightpool](https://github.com/nightpool) +* [Artoria2e5](https://github.com/Artoria2e5) * [marrus-sh](https://github.com/marrus-sh) -* [hinaloe](https://github.com/hinaloe) +* [dunn](https://github.com/dunn) * [krainboltgreene](https://github.com/krainboltgreene) * [pfigel](https://github.com/pfigel) -* [Aldarone](https://github.com/Aldarone) * [BoFFire](https://github.com/BoFFire) +* [Aldarone](https://github.com/Aldarone) * [clworld](https://github.com/clworld) * [MasterGroosha](https://github.com/MasterGroosha) * [dracos](https://github.com/dracos) @@ -68,61 +73,61 @@ and provided thanks to the work of the following contributors: * [SerCom_KC](mailto:sercom-kc@users.noreply.github.com) * [Sylvhem](https://github.com/Sylvhem) * [MitarashiDango](https://github.com/MitarashiDango) +* [angristan](https://github.com/angristan) * [JeanGauthier](https://github.com/JeanGauthier) * [kschaper](https://github.com/kschaper) * [beatrix-bitrot](https://github.com/beatrix-bitrot) -* [angristan](https://github.com/angristan) +* [koyuawsmbrtn](https://github.com/koyuawsmbrtn) +* [BenLubar](https://github.com/BenLubar) * [adbelle](https://github.com/adbelle) * [evanminto](https://github.com/evanminto) * [MightyPork](https://github.com/MightyPork) -* [ashleyhull-versent](mailto:ashley.hull@versent.com.au) +* [ashleyhull-versent](https://github.com/ashleyhull-versent) * [yhirano55](https://github.com/yhirano55) * [rinsuki](https://github.com/rinsuki) +* [devkral](https://github.com/devkral) * [camponez](https://github.com/camponez) +* [hugogameiro](https://github.com/hugogameiro) * [SerCom_KC](mailto:szescxz@gmail.com) * [aschmitz](https://github.com/aschmitz) -* [trwnh](https://github.com/trwnh) -* [devkral](https://github.com/devkral) +* [mfmfuyu](https://github.com/mfmfuyu) +* [kedamaDQ](https://github.com/kedamaDQ) * [fpiesche](https://github.com/fpiesche) -* [hugogameiro](https://github.com/hugogameiro) * [gandaro](https://github.com/gandaro) * [johnsudaar](https://github.com/johnsudaar) -* [ariasuni](https://github.com/ariasuni) * [trebmuh](https://github.com/trebmuh) * [rmhasan](https://github.com/rmhasan) -* [kedamaDQ](https://github.com/kedamaDQ) * [lindwurm](https://github.com/lindwurm) * [victorhck](mailto:victorhck@geeko.site) * [voidsatisfaction](https://github.com/voidsatisfaction) -* [BenLubar](https://github.com/BenLubar) +* [mkljczk](https://github.com/mkljczk) * [hikari-no-yume](https://github.com/hikari-no-yume) * [seefood](https://github.com/seefood) * [jackjennings](https://github.com/jackjennings) -* [koyuawsmbrtn](https://github.com/koyuawsmbrtn) +* [puckipedia](https://github.com/puckipedia) * [spla](mailto:spla@mastodont.cat) -* [expenses](https://github.com/expenses) * [walf443](https://github.com/walf443) * [JoelQ](https://github.com/JoelQ) * [mistydemeo](https://github.com/mistydemeo) -* [dunn](https://github.com/dunn) +* [Ashley](mailto:expenses@airmail.cc) * [xqus](https://github.com/xqus) * [pfm-eyesightjp](https://github.com/pfm-eyesightjp) * [fakenine](https://github.com/fakenine) -* [Shleeble](https://github.com/Shleeble) * [tsuwatch](https://github.com/tsuwatch) * [victorhck](https://github.com/victorhck) -* [mkljczk](https://github.com/mkljczk) * [manuelviens](https://github.com/manuelviens) -* [puckipedia](https://github.com/puckipedia) +* [tateisu](https://github.com/tateisu) * [fvh-P](https://github.com/fvh-P) * [rtucker](https://github.com/rtucker) * [Anna e só](mailto:contraexemplos@gmail.com) +* [dariusk](https://github.com/dariusk) * [kazu9su](https://github.com/kazu9su) * [Komic](https://github.com/Komic) * [lmorchard](https://github.com/lmorchard) * [diomed](https://github.com/diomed) * [Neetshin](mailto:neetshin@neetsh.in) * [rainyday](https://github.com/rainyday) +* [tcitworld](https://github.com/tcitworld) * [ProgVal](https://github.com/ProgVal) * [valentin2105](https://github.com/valentin2105) * [yuntan](https://github.com/yuntan) @@ -136,44 +141,52 @@ and provided thanks to the work of the following contributors: * [TheKinrar](https://github.com/TheKinrar) * [AA4ch1](https://github.com/AA4ch1) * [alexgleason](https://github.com/alexgleason) +* [Bèr Kessels](mailto:ber@berk.es) * [cpytel](https://github.com/cpytel) * [northerner](https://github.com/northerner) * [fhemberger](https://github.com/fhemberger) +* [Gomasy](https://github.com/Gomasy) * [greysteil](https://github.com/greysteil) -* [hencatsmith](https://github.com/hencatsmith) +* [hendotcat](https://github.com/hendotcat) * [d6rkaiz](https://github.com/d6rkaiz) -* [Reverite](https://github.com/Reverite) +* [ladyisatis](https://github.com/ladyisatis) * [JohnD28](https://github.com/JohnD28) * [znz](https://github.com/znz) +* [saper](https://github.com/saper) * [Naouak](https://github.com/Naouak) * [pawelngei](https://github.com/pawelngei) * [reneklacan](https://github.com/reneklacan) * [ekiru](https://github.com/ekiru) -* [tcitworld](https://github.com/tcitworld) * [geta6](https://github.com/geta6) * [happycoloredbanana](https://github.com/happycoloredbanana) * [leopku](https://github.com/leopku) * [SansPseudoFix](https://github.com/SansPseudoFix) -* [salvadorpla](https://github.com/salvadorpla) +* [spla](mailto:sp@mastodont.cat) * [tomfhowe](https://github.com/tomfhowe) * [noraworld](https://github.com/noraworld) -* [theboss](https://github.com/theboss) +* [lfuelling](https://github.com/lfuelling) +* [aji-su](https://github.com/aji-su) * [nzws](https://github.com/nzws) +* [duxovni](https://github.com/duxovni) +* [smorimoto](https://github.com/smorimoto) +* [mashirozx](https://github.com/mashirozx) * [178inaba](https://github.com/178inaba) +* [acid-chicken](https://github.com/acid-chicken) * [xgess](https://github.com/xgess) * [alyssais](https://github.com/alyssais) * [aablinov](https://github.com/aablinov) * [stalker314314](https://github.com/stalker314314) * [cutls](https://github.com/cutls) * [huertanix](https://github.com/huertanix) -* [genesixx](https://github.com/genesixx) +* [eleboucher](https://github.com/eleboucher) * [halkeye](https://github.com/halkeye) +* [Hanage999](https://github.com/Hanage999) * [treby](https://github.com/treby) * [jpdevries](https://github.com/jpdevries) * [gdpelican](https://github.com/gdpelican) -* [kmichl](https://github.com/kmichl) +* [Korbinian](mailto:kontakt@korbinian-michl.de) * [Kurtis Rainbolt-Greene](mailto:me@kurtisrainboltgreene.name) -* [saper](https://github.com/saper) +* [panarom](https://github.com/panarom) * [Dar13](https://github.com/Dar13) * [nevillepark](https://github.com/nevillepark) * [ornithocoder](https://github.com/ornithocoder) @@ -181,7 +194,7 @@ and provided thanks to the work of the following contributors: * [pierreozoux](https://github.com/pierreozoux) * [qguv](https://github.com/qguv) * [Ram Lmn](mailto:ramlmn@users.noreply.github.com) -* [aurelia-sl](https://github.com/aurelia-sl) +* [Sascha](mailto:sascha@serenitylabs.cloud) * [harukasan](https://github.com/harukasan) * [stamak](https://github.com/stamak) * [Technowix](https://github.com/Technowix) @@ -196,9 +209,9 @@ and provided thanks to the work of the following contributors: * [chr-1x](https://github.com/chr-1x) * [esetomo](https://github.com/esetomo) * [foxiehkins](https://github.com/foxiehkins) +* [highemerly](https://github.com/highemerly) * [hoodie](mailto:hoodiekitten@outlook.com) * [luzi82](https://github.com/luzi82) -* [duxovni](https://github.com/duxovni) * [slice](https://github.com/slice) * [tmm576](https://github.com/tmm576) * [unsmell](mailto:unsmell@users.noreply.github.com) @@ -209,13 +222,13 @@ and provided thanks to the work of the following contributors: * [AndreLewin](https://github.com/AndreLewin) * [0xflotus](https://github.com/0xflotus) * [redtachyons](https://github.com/redtachyons) -* [acid-chicken](https://github.com/acid-chicken) * [thurloat](https://github.com/thurloat) * [aaribaud](https://github.com/aaribaud) * [pointlessone](https://github.com/pointlessone) * [Andrew](mailto:andrewlchronister@gmail.com) +* [arielrodrigues](https://github.com/arielrodrigues) * [aurelien-reeves](https://github.com/aurelien-reeves) -* [AnaGelez](https://github.com/AnaGelez) +* [elegaanz](https://github.com/elegaanz) * [estuans](https://github.com/estuans) * [dissolve](https://github.com/dissolve) * [PurpleBooth](https://github.com/PurpleBooth) @@ -227,16 +240,15 @@ and provided thanks to the work of the following contributors: * [muffinista](https://github.com/muffinista) * [cdutson](https://github.com/cdutson) * [farlistener](https://github.com/farlistener) -* [dariusk](https://github.com/dariusk) +* [divergentdave](https://github.com/divergentdave) * [DavidLibeau](https://github.com/DavidLibeau) +* [dmerejkowsky](https://github.com/dmerejkowsky) * [ddevault](https://github.com/ddevault) * [Fjoerfoks](https://github.com/Fjoerfoks) * [fmauNeko](https://github.com/fmauNeko) * [gloaec](https://github.com/gloaec) -* [Gomasy](https://github.com/Gomasy) * [unstabler](https://github.com/unstabler) * [potato4d](https://github.com/potato4d) -* [Hanage999](https://github.com/Hanage999) * [h-izumi](https://github.com/h-izumi) * [ErikXXon](https://github.com/ErikXXon) * [ian-kelling](https://github.com/ian-kelling) @@ -251,19 +263,23 @@ and provided thanks to the work of the following contributors: * [tkbky](https://github.com/tkbky) * [Kaylee](mailto:kaylee@codethat.sucks) * [Kazhnuz](https://github.com/Kazhnuz) +* [mkody](https://github.com/mkody) * [connyduck](https://github.com/connyduck) * [LindseyB](https://github.com/LindseyB) * [Lorenz Diener](mailto:halcyon@icosahedron.website) -* [alimony](https://github.com/alimony) +* [Markus Amalthea Magnuson](mailto:markus.magnuson@gmail.com) +* [madmath03](https://github.com/madmath03) * [mig5](https://github.com/mig5) * [moritzheiber](https://github.com/moritzheiber) +* [Nathaniel Suchy](mailto:me@lunorian.is) * [ndarville](https://github.com/ndarville) +* [NimaBoscarino](https://github.com/NimaBoscarino) * [Abzol](https://github.com/Abzol) * [PatOnTheBack](https://github.com/PatOnTheBack) * [xPaw](https://github.com/xPaw) * [petzah](https://github.com/petzah) * [ignisf](https://github.com/ignisf) -* [raymestalez](https://github.com/raymestalez) +* [lumenwrites](https://github.com/lumenwrites) * [remram44](https://github.com/remram44) * [sts10](https://github.com/sts10) * [SuperSandro2000](https://github.com/SuperSandro2000) @@ -273,8 +289,9 @@ and provided thanks to the work of the following contributors: * [Sir-Boops](https://github.com/Sir-Boops) * [stemid](https://github.com/stemid) * [sumdog](https://github.com/sumdog) +* [OmmyZhang](https://github.com/OmmyZhang) * [ThomasLeister](https://github.com/ThomasLeister) -* [mcat-ee](https://github.com/mcat-ee) +* [Tom McAtee](mailto:a1608768@student.adelaide.edu.au) * [tototoshi](https://github.com/tototoshi) * [TrashMacNugget](https://github.com/TrashMacNugget) * [VirtuBox](https://github.com/VirtuBox) @@ -287,35 +304,39 @@ and provided thanks to the work of the following contributors: * [amazedkoumei](https://github.com/amazedkoumei) * [anon5r](https://github.com/anon5r) * [aus-social](https://github.com/aus-social) -* [imbsky](https://github.com/imbsky) * [bsky](mailto:me@imbsky.net) * [codl](https://github.com/codl) * [cpsdqs](https://github.com/cpsdqs) * [barzamin](https://github.com/barzamin) * [fhalna](https://github.com/fhalna) -* [highemerly](https://github.com/highemerly) * [haoyayoi](https://github.com/haoyayoi) * [ik11235](https://github.com/ik11235) * [kawax](https://github.com/kawax) +* [shrft](https://github.com/shrft) * [007lva](https://github.com/007lva) * [mbajur](https://github.com/mbajur) * [matsurai25](https://github.com/matsurai25) * [mecab](https://github.com/mecab) * [nicobz25](https://github.com/nicobz25) +* [niwatori24](https://github.com/niwatori24) * [oliverkeeble](https://github.com/oliverkeeble) * [partev](https://github.com/partev) * [pinfort](https://github.com/pinfort) * [rbaumert](https://github.com/rbaumert) * [rhoio](https://github.com/rhoio) +* [santiagorodriguez96](https://github.com/santiagorodriguez96) +* [sclaire-1](https://github.com/sclaire-1) +* [umonaca](https://github.com/umonaca) * [usagi-f](https://github.com/usagi-f) * [vidarlee](https://github.com/vidarlee) * [vjackson725](https://github.com/vjackson725) * [wxcafe](https://github.com/wxcafe) +* [Grawl](https://github.com/Grawl) * [新都心(Neet Shin)](mailto:nucx@dio-vox.com) -* [clarfon](https://github.com/clarfon) +* [clarfonthey](https://github.com/clarfonthey) * [cygnan](https://github.com/cygnan) * [Awea](https://github.com/Awea) -* [halcy](https://github.com/halcy) +* [eai04191](https://github.com/eai04191) * [8398a7](https://github.com/8398a7) * [857b](https://github.com/857b) * [insom](https://github.com/insom) @@ -332,6 +353,7 @@ and provided thanks to the work of the following contributors: * [a2](https://github.com/a2) * [alfiedotwtf](https://github.com/alfiedotwtf) * [0xa](https://github.com/0xa) +* [ArisuOngaku](https://github.com/ArisuOngaku) * [virtualpain](https://github.com/virtualpain) * [sapphirus](https://github.com/sapphirus) * [amandavisconti](https://github.com/amandavisconti) @@ -342,15 +364,22 @@ and provided thanks to the work of the following contributors: * [schas002](https://github.com/schas002) * [contraexemplo](https://github.com/contraexemplo) * [abackstrom](https://github.com/abackstrom) +* [orlea](https://github.com/orlea) * [armandfardeau](https://github.com/armandfardeau) * [raboof](https://github.com/raboof) * [jumbosushi](https://github.com/jumbosushi) +* [acuteaura](https://github.com/acuteaura) * [ayumin](https://github.com/ayumin) * [bzg](https://github.com/bzg) -* [benediktg](https://github.com/benediktg) +* [BastienDurel](https://github.com/BastienDurel) +* [li-bei](https://github.com/li-bei) +* [Benedikt Geißler](mailto:benedikt@g5r.eu) +* [BenisonSebastian](https://github.com/BenisonSebastian) * [blakebarnett](https://github.com/blakebarnett) -* [bradj](https://github.com/bradj) +* [Brad Janke](mailto:brad.janke@gmail.com) +* [bclindner](https://github.com/bclindner) * [brycied00d](https://github.com/brycied00d) +* [berkes](https://github.com/berkes) * [carlosjs23](https://github.com/carlosjs23) * [cgxxx](https://github.com/cgxxx) * [kibitan](https://github.com/kibitan) @@ -358,41 +387,47 @@ and provided thanks to the work of the following contributors: * [chris-martin](https://github.com/chris-martin) * [DoubleMalt](https://github.com/DoubleMalt) * [Moosh-be](https://github.com/Moosh-be) +* [cchoi12](https://github.com/cchoi12) * [Motoma](https://github.com/Motoma) * [Christopher Kolstad](mailto:christopher.kolstad@finn.no) * [csu](https://github.com/csu) * [kklleemm](https://github.com/kklleemm) * [colindean](https://github.com/colindean) +* [DeeUnderscore](https://github.com/DeeUnderscore) * [dachinat](https://github.com/dachinat) -* [multiple-creatures](https://github.com/multiple-creatures) +* [monsterpit-firedemon](https://github.com/monsterpit-firedemon) * [watilde](https://github.com/watilde) * [daprice](https://github.com/daprice) * [da2x](https://github.com/da2x) +* [codesections](https://github.com/codesections) * [dar5hak](https://github.com/dar5hak) * [kant](https://github.com/kant) * [maxolasersquad](https://github.com/maxolasersquad) * [singingwolfboy](https://github.com/singingwolfboy) +* [caldwell](https://github.com/caldwell) * [davidcelis](https://github.com/davidcelis) * [davefp](https://github.com/davefp) * [yipdw](https://github.com/yipdw) * [debanshuk](https://github.com/debanshuk) +* [mascali33](https://github.com/mascali33) * [DerekNonGeneric](https://github.com/DerekNonGeneric) * [dblandin](https://github.com/dblandin) -* [Drew Gates](mailto:aranaur@users.noreply.github.com) +* [Aranaur](https://github.com/Aranaur) * [dtschust](https://github.com/dtschust) * [Dryusdan](https://github.com/Dryusdan) -* [eai04191](https://github.com/eai04191) * [d3vgru](https://github.com/d3vgru) * [Elizafox](https://github.com/Elizafox) * [enewhuis](https://github.com/enewhuis) * [ericblade](https://github.com/ericblade) * [mikoim](https://github.com/mikoim) * [espenronnevik](https://github.com/espenronnevik) +* [Expenses](mailto:expenses@airmail.cc) * [fabianonline](https://github.com/fabianonline) * [Finariel](https://github.com/Finariel) * [siuying](https://github.com/siuying) * [zoc](https://github.com/zoc) * [fwenzel](https://github.com/fwenzel) +* [gabrielrumiranda](https://github.com/gabrielrumiranda) * [GenbuHase](https://github.com/GenbuHase) * [nilsding](https://github.com/nilsding) * [hattori6789](https://github.com/hattori6789) @@ -401,6 +436,7 @@ and provided thanks to the work of the following contributors: * [myfreeweb](https://github.com/myfreeweb) * [gfaivre](https://github.com/gfaivre) * [Fiaxhs](https://github.com/Fiaxhs) +* [rasjonell](https://github.com/rasjonell) * [reedcourty](https://github.com/reedcourty) * [anneau](https://github.com/anneau) * [lanodan](https://github.com/lanodan) @@ -420,47 +456,52 @@ and provided thanks to the work of the following contributors: * [J Yeary](mailto:usbsnowcrash@users.noreply.github.com) * [jack-michaud](https://github.com/jack-michaud) * [Floppy](https://github.com/Floppy) -* [loomchild](https://github.com/loomchild) -* [jenkr55](https://github.com/jenkr55) -* [hyenagirl64](https://github.com/hyenagirl64) -* [press5](https://github.com/press5) -* [TrollDecker](https://github.com/TrollDecker) -* [jmontane](https://github.com/jmontane) -* [jonathanklee](https://github.com/jonathanklee) -* [jguerder](https://github.com/jguerder) -* [Jehops](https://github.com/Jehops) -* [joshuap](https://github.com/joshuap) -* [Tiwy57](https://github.com/Tiwy57) -* [xuv](https://github.com/xuv) -* [Jnsll](https://github.com/Jnsll) -* [j0k3r](https://github.com/j0k3r) -* [KEINOS](https://github.com/KEINOS) -* [futoase](https://github.com/futoase) -* [pot8to](https://github.com/pot8to) +* [Jarek Lipski](mailto:pub@loomchild.net) +* [Jennifer Glauche](mailto:=^.^=@github19.jglauche.de) +* [Jennifer Kruse](mailto:jenkr55@gmail.com) +* [Jeremy Rose](mailto:nornagon@nornagon.net) +* [Jessica](mailto:46502909+hyenagirl64@users.noreply.github.com) +* [Jessica K. Litwin](mailto:jessica@litw.in) +* [Jo Decker](mailto:trolldecker@users.noreply.github.com) +* [Joan Montané](mailto:jmontane@users.noreply.github.com) +* [Jonathan Klee](mailto:klee.jonathan@gmail.com) +* [Jordan Guerder](mailto:jguerder@fr.pulseheberg.net) +* [Joseph Mingrone](mailto:jehops@users.noreply.github.com) +* [Josh Leeb-du Toit](mailto:mail@joshleeb.com) +* [Joshua Wood](mailto:josh@joshuawood.net) +* [Julien](mailto:tiwy57@users.noreply.github.com) +* [Julien Deswaef](mailto:juego@requiem4tv.com) +* [June Sallou](mailto:jnsll@users.noreply.github.com) +* [Jérémy Benoist](mailto:j0k3r@users.noreply.github.com) +* [KEINOS](mailto:github@keinos.com) +* [Kairui Song | 宋恺睿](mailto:ryncsn@gmail.com) +* [Keiji Matsuzaki](mailto:futoase@gmail.com) +* [Kevin Liu](mailto:kevin@potatofrom.space) * [Kit Redgrave](mailto:qwertyitis@gmail.com) * [Knut Erik](mailto:abjectio@users.noreply.github.com) -* [mkody](https://github.com/mkody) -* [k0ta0uchi](https://github.com/k0ta0uchi) -* [KrzysiekJ](https://github.com/KrzysiekJ) +* [Kota Ouchi](mailto:k0ta0uchi@gmail.com) +* [Krzysztof Jurewicz](mailto:krzysztof.jurewicz@gmail.com) * [Leo Wzukw](mailto:leowzukw@users.noreply.github.com) -* [Tak](https://github.com/Tak) -* [cacheflow](https://github.com/cacheflow) -* [ldidry](https://github.com/ldidry) -* [jemus42](https://github.com/jemus42) -* [lfuelling](https://github.com/lfuelling) -* [Grabacr07](https://github.com/Grabacr07) -* [mistermantas](https://github.com/mistermantas) -* [MareenaKunjachan](https://github.com/MareenaKunjachan) -* [mareklach](https://github.com/mareklach) -* [wirehack7](https://github.com/wirehack7) -* [martymcguire](https://github.com/martymcguire) -* [marvinkopf](https://github.com/marvinkopf) -* [otsune](https://github.com/otsune) -* [mbugowski](https://github.com/mbugowski) +* [Leonie](mailto:62470640+bubblineyuri@users.noreply.github.com) +* [Levi Bard](mailto:taktaktaktaktaktaktaktaktaktak@gmail.com) +* [Lex Alexander](mailto:l.alexander10@gmail.com) +* [Lorenz Diener](mailto:lorenzd@gmail.com) +* [Luc Didry](mailto:ldidry@users.noreply.github.com) +* [Lukas Burk](mailto:jemus42@users.noreply.github.com) +* [Manato Kameya](mailto:grabacr07+github@gmail.com) +* [Mantas](mailto:mistermantas@users.noreply.github.com) +* [Mareena Kunjachan](mailto:mareenakunjachan@gmail.com) +* [Marek Lach](mailto:marek.brohatwack.lach@gmail.com) +* [Markus R](mailto:wirehack7@users.noreply.github.com) +* [Marty McGuire](mailto:schmartissimo@gmail.com) +* [Marvin Kopf](mailto:marvinkopf@posteo.de) +* [Masafumi Otsune](mailto:info@otsune.com) +* [Matej Ľach](mailto:matejlach@users.noreply.github.com) +* [Mateusz Bugowski](mailto:23140767+mbugowski@users.noreply.github.com) * [Mathias B](mailto:10813340+mathias-b@users.noreply.github.com) -* [madmath03](https://github.com/madmath03) -* [matt-auckland](https://github.com/matt-auckland) -* [webroo](https://github.com/webroo) +* [Mathieu Brunot](mailto:mb.mathieu.brunot@gmail.com) +* [Matt](mailto:matt-auckland@users.noreply.github.com) +* [Matt Sweetman](mailto:webroo@gmail.com) * [Matthias Beyer](mailto:mail@beyermatthias.de) * [Matthias Jouan](mailto:matthias.jouan@gmail.com) * [Matthieu Paret](mailto:matthieuparet69@gmail.com) @@ -495,10 +536,12 @@ and provided thanks to the work of the following contributors: * [Norayr Chilingarian](mailto:norayr@arnet.am) * [Noëlle Anthony](mailto:noelle.d.anthony@gmail.com) * [N氏](mailto:uenok.htc@gmail.com) +* [OSAMU SATO](mailto:satosamu@gmail.com) * [Olivier Nicole](mailto:olivierthnicole@gmail.com) * [Oskari Noppa](mailto:noppa@users.noreply.github.com) * [Otakan](mailto:otakan951@gmail.com) * [Padraig Fahy](mailto:tech@padraigfahy.com) +* [Patrice Ferlet](mailto:metal3d@gmail.com) * [PatrickRWells](mailto:32802366+patrickrwells@users.noreply.github.com) * [Paul](mailto:naydex.mc+github@gmail.com) * [Pete Keen](mailto:pete@petekeen.net) @@ -512,10 +555,11 @@ and provided thanks to the work of the following contributors: * [S.H](mailto:gamelinks007@gmail.com) * [Sadiq Saif](mailto:staticsafe@users.noreply.github.com) * [Sam Hewitt](mailto:hewittsamuel@gmail.com) -* [Sasha Sorokin](mailto:dafri.nochiterov8@gmail.com) +* [Sara Aimée Smiseth](mailto:51710585+sarasmiseth@users.noreply.github.com) * [Satoshi KOJIMA](mailto:skoji@mac.com) * [ScienJus](mailto:i@scienjus.com) * [Scott Larkin](mailto:scott@codeclimate.com) +* [Scott Sweeny](mailto:scott@ssweeny.net) * [Sebastian Hübner](mailto:imolein@users.noreply.github.com) * [Sebastian Morr](mailto:sebastian@morr.cc) * [Sergei Č](mailto:noiwex1911@gmail.com) @@ -525,10 +569,12 @@ and provided thanks to the work of the following contributors: * [Shin Kojima](mailto:shin@kojima.org) * [Shouko Yu](mailto:imshouko@gmail.com) * [Sina Mashek](mailto:sina@mashek.xyz) +* [Soft. Dev](mailto:24978+nileshkumar@users.noreply.github.com) * [Soshi Kato](mailto:mail@sossii.com) * [Spanky](mailto:2788886+spankyworks@users.noreply.github.com) * [StefOfficiel](mailto:pichard.stephane@free.fr) * [Steven Tappert](mailto:admin@dark-it.net) +* [Stéphane Guillou](mailto:stephane.guillou@member.fsf.org) * [Svetlozar Todorov](mailto:svetlik@users.noreply.github.com) * [Sébastien Santoro](mailto:dereckson@espace-win.org) * [Tad Thorley](mailto:phaedryx@users.noreply.github.com) @@ -536,7 +582,9 @@ and provided thanks to the work of the following contributors: * [Takayuki KUSANO](mailto:github@tkusano.jp) * [TakesxiSximada](mailto:takesxi.sximada@gmail.com) * [Tao Bror Bojlén](mailto:brortao@users.noreply.github.com) +* [Taras Gogol](mailto:taras2358@gmail.com) * [TheInventrix](mailto:theinventrix@users.noreply.github.com) +* [TheMainOne](mailto:50847364+theevilskeleton@users.noreply.github.com) * [Thomas Alberola](mailto:thomas@needacoffee.fr) * [Toby Deshane](mailto:fortyseven@users.noreply.github.com) * [Toby Pinder](mailto:gigitrix@gmail.com) @@ -554,6 +602,7 @@ and provided thanks to the work of the following contributors: * [Wesley Ellis](mailto:tahnok@gmail.com) * [Wiktor](mailto:wiktor@metacode.biz) * [Wonderfall](mailto:wonderfall@schrodinger.io) +* [Y.Yamashiro](mailto:shukukei@mojizuri.jp) * [YDrogen](mailto:ydrogen45@gmail.com) * [YMHuang](mailto:ymhuang@fmbase.tw) * [YOSHIOKA Eiichiro](mailto:yoshioka.eiichiro@gmail.com) @@ -563,6 +612,7 @@ and provided thanks to the work of the following contributors: * [Yann Klis](mailto:yann.klis@gmail.com) * [Yağızhan](mailto:35808275+yagizhan49@users.noreply.github.com) * [Yeechan Lu](mailto:wz.bluesnow@gmail.com) +* [Your Name](mailto:lorenzd@gmail.com) * [Yusuke Abe](mailto:moonset20@gmail.com) * [Zachary Spector](mailto:logicaldash@gmail.com) * [ZiiX](mailto:ziix@users.noreply.github.com) @@ -572,6 +622,7 @@ and provided thanks to the work of the following contributors: * [bsky](mailto:git@imbsky.net) * [caesarologia](mailto:lopesgemelli.1@gmail.com) * [cbayerlein](mailto:c.bayerlein@gmail.com) +* [chr v1.x](mailto:chr@cybre.space) * [chrolis](mailto:chrolis@users.noreply.github.com) * [cormo](mailto:cormorant2+github@gmail.com) * [d0p1](mailto:dopi-sama@hush.com) @@ -582,6 +633,7 @@ and provided thanks to the work of the following contributors: * [fusshi-](mailto:dikky1218@users.noreply.github.com) * [gentaro](mailto:gentaroooo@gmail.com) * [gol-cha](mailto:info@mevo.xyz) +* [guigeekz](mailto:pattusg@gmail.com) * [hakoai](mailto:hk--76@qa2.so-net.ne.jp) * [haosbvnker](mailto:github@chaosbunker.com) * [ichi_i](mailto:51489410+ichi-i@users.noreply.github.com) @@ -593,10 +645,12 @@ and provided thanks to the work of the following contributors: * [jooops](mailto:joops@autistici.org) * [jukper](mailto:jukkaperanto@gmail.com) * [jumoru](mailto:jumoru@mailbox.org) +* [kaiyou](mailto:pierre@jaury.eu) * [karlyeurl](mailto:karl.yeurl@gmail.com) +* [kawaguchi](mailto:jiikko@users.noreply.github.com) * [kedama](mailto:32974885+kedamadq@users.noreply.github.com) -* [kodai](mailto:shirafuta.kodai@gmail.com) * [kuro5hin](mailto:rusty@kuro5hin.org) +* [leo60228](mailto:leo@60228.dev) * [luzpaz](mailto:luzpaz@users.noreply.github.com) * [maxypy](mailto:maxime@mpigou.fr) * [mhe](mailto:mail@marcus-herrmann.com) @@ -607,21 +661,26 @@ and provided thanks to the work of the following contributors: * [muan](mailto:muan@github.com) * [namelessGonbai](mailto:43787036+namelessgonbai@users.noreply.github.com) * [neetshin](mailto:neetshin@neetsh.in) +* [noiob](mailto:8197071+noiob@users.noreply.github.com) +* [notozeki](mailto:notozeki@users.noreply.github.com) +* [ntl-purism](mailto:57806346+ntl-purism@users.noreply.github.com) * [nzws](mailto:git-yuzu@svk.jp) +* [proxy](mailto:51172302+3n-k1@users.noreply.github.com) * [rch850](mailto:rich850@gmail.com) * [roikale](mailto:roikale@users.noreply.github.com) * [rysiekpl](mailto:rysiek@hackerspace.pl) * [saturday06](mailto:dyob@lunaport.net) +* [scd31](mailto:57571338+scd31@users.noreply.github.com) * [scriptjunkie](mailto:scriptjunkie@scriptjunkie.us) * [seekr](mailto:mario.drs@gmail.com) +* [sternenseemann](mailto:git@lukasepple.de) * [sundevour](mailto:31990469+sundevour@users.noreply.github.com) * [syui](mailto:syui@users.noreply.github.com) * [tackeyy](mailto:mailto.takita.yusuke@gmail.com) -* [tateisu](mailto:tateisu@gmail.com) +* [taicv](mailto:chuvantai@gmail.com) * [tmyt](mailto:shigure@refy.net) * [trevDev()](mailto:trev@trevdev.ca) * [tsia](mailto:github@tsia.de) -* [umonaca](mailto:53662960+umonaca@users.noreply.github.com) * [utam0k](mailto:k0ma@utam0k.jp) * [vpzomtrrfrt](mailto:vpzomtrrfrt@gmail.com) * [walfie](mailto:walfington@gmail.com) @@ -634,6 +693,7 @@ and provided thanks to the work of the following contributors: * [りんすき](mailto:6533808+rinsuki@users.noreply.github.com) * [ヨイツの賢狼ホロ | 3rd style](mailto:horo@yoitsu.moe) * [唐宗勛](mailto:tangzongxun@hotmail.com) +* [夕日](mailto:xirikm@gmail.com) * [猫吸血鬼ディフリス / 猫ロキP](mailto:deflis@gmail.com) * [艮 鮟鱇](mailto:ushitora_anqou@yahoo.co.jp) * [西小倉宏信](mailto:nishiko@mindia.jp) @@ -645,122 +705,414 @@ This document is provided for informational purposes only. Since it is only upda Following people have contributed to translation of Mastodon: -- Zoltán Gera (*Hungarian*) -- Kristijan Tkalec (*Slovenian*) -- Evert Prants (*Estonian*) +- ᏦᏁᎢᎵᏫ 😷 (KNTRO) (*Spanish, Argentina*) +- Sveinn í Felli (sveinki) (*Icelandic*) +- qezwan (*Persian, Sorani (Kurdish)*) +- Hồ Nhất Duy (kantcer) (*Vietnamese*) +- taicv (*Vietnamese*) +- Zoltán Gera (gerazo) (*Hungarian*) +- ButterflyOfFire (BoFFire) (*French, Arabic, Kabyle*) +- adrmzz (*Sardinian*) +- Ramdziana F Y (rafeyu) (*Indonesian*) +- Evert Prants (IcyDiamond) (*Estonian*) +- Daniele Lira Mereb (danilmereb) (*Portuguese, Brazilian*) +- Xosé M. (XoseM) (*Spanish, Galician*) +- Kristijan Tkalec (lapor) (*Slovenian*) +- stan ionut (stanionut12) (*Romanian*) +- Besnik_b (*Albanian*) +- Emanuel Pina (emanuelpina) (*Portuguese*) +- Thai Localization (thl10n) (*Thai*) +- 奈卜拉 (nebula_moe) (*Chinese Simplified*) +- Jeong Arm (Kjwon15) (*Japanese, Korean, Esperanto*) +- Michal Stanke (mstanke) (*Czech*) +- Alix Rossi (palindromordnilap) (*French, Corsican*) +- spla (*Spanish, Catalan*) +- Imre Kristoffer Eilertsen (DandelionSprout) (*Norwegian*) +- Jeroen (jeroenpraat) (*Dutch*) - borys_sh (*Ukrainian*) -- ButterflyOfFire (*Arabic; French*) -- Osoitz (*Basque*) -- oɹʇuʞ (*Spanish, Argentina*) +- Miguel Mayol (mitcoes) (*Spanish, Catalan*) +- Danial Behzadi (danialbehzadi) (*Persian*) +- yeft (*Chinese Traditional, Chinese Traditional, Hong Kong*) - koyu (*German*) -- Jeroen (*Dutch*) -- Muha Aliss (*Turkish*) -- 唐宗勛 (*Chinese Simplified*) -- Jeong Arm (*Korean; Esperanto; Japanese*) -- Oguz Ersen (*Turkish*) -- spla (*Catalan*) -- Ramdziana F Y (*Indonesian*) -- Aditoo17 (*Czech*) -- Xosé M. (*Galician*) -- Roboron (*Spanish*) -- Alix Rossi (*Corsican; French*) -- Maya Minatsuki (*Japanese*) -- Masoud Abkenar (*Persian*) -- Thai Localization (*Thai*) -- Marek Ľach (*Slovak; Polish*) -- d5Ziif3K (*Ukrainian*) +- Koala Yeung (yookoala) (*Chinese Traditional, Hong Kong*) +- Osoitz (*Basque*) +- Peterandre (*Norwegian, Norwegian Nynorsk*) +- tzium (*Sardinian*) +- Iváns (Ivans_translator) (*Galician*) +- Sasha Sorokin (Sasha-Sorokin) (*French, Catalan, Danish, German, Greek, Hungarian, Armenian, Korean, Russian, Albanian, Swedish, Ukrainian, Vietnamese, Galician*) +- kamee (*Armenian*) +- tolstoevsky (*Russian*) +- enolp (*Asturian*) +- FédiQuébec (manuelviens) (*French*) - lamnatos (*Greek*) -- Emyn Nant Nefydd (*Welsh*) +- Maya Minatsuki (mayaeh) (*Japanese*) +- Masoud Abkenar (mabkenar) (*Persian*) +- Alessandro Levati (Oct326) (*Italian*) +- arshat (*Kazakh*) +- Roboron (*Spanish*) +- ariasuni (*French, Arabic, Czech, German, Greek, Hungarian, Slovenian, Ukrainian, Chinese Simplified, Portuguese, Brazilian, Persian, Norwegian Nynorsk, Esperanto, Breton, Corsican, Sardinian, Kabyle*) +- Ali Demirtaş (alidemirtas) (*Turkish*) +- Em St Cenydd (cancennau) (*Welsh*) +- Marek Ľach (mareklach) (*Polish, Slovak*) +- Muha Aliss (muhaaliss) (*Turkish*) +- Jurica (ahjk) (*Croatian*) +- Aditoo17 (*Czech*) - Diluns (*Occitan*) -- atarashiako (*Chinese Simplified*) -- 101010 (*Polish*) -- Yi-Jyun Pan (*Chinese Traditional*) -- silkevicious (*Italian*) -- FédiQuébec (*French*) -- Jaz-Michael King (*Welsh*) -- christalleras (*Norwegian Nynorsk*) -- tykayn (*French*) -- Alessandro Levati (*Italian*) -- carolinagiorno (*Portuguese, Brazilian*) -- taoxvx (*Danish*) -- sabri (*Spanish*) -- Sasha Sorokin (*Russian*) -- shioko (*Chinese Simplified*) -- Evgeny Petrov (*Russian*) -- ariasuni (*French; Esperanto*) -- Tiago Epifânio (*Portuguese*) -- dxwc (*Bengali*) -- liffon (*Swedish*) -- Vanege (*Esperanto*) -- Johan Schiff (*Swedish*) -- kat (*Ukrainian; Russian*) -- oti4500 (*Hungarian; Ukrainian*) -- Juan José Salvador Piedra (*Spanish*) -- diazepan (*Spanish*) -- SHeija (*Finnish*) -- Jack R (*Spanish*) +- gagik_ (*Armenian*) +- vishnuvaratharajan (*Tamil*) +- Marcin Mikołajczak (mkljczkk) (*Czech, Polish, Russian*) +- regulartranslator (*Portuguese, Brazilian*) +- Akarshan Biswas (biswasab) (*Bengali, Sanskrit*) +- Yi-Jyun Pan (pan93412) (*Chinese Traditional*) +- d5Ziif3K (*Ukrainian*) +- GiorgioHerbie (*Italian*) +- Rafael H L Moretti (Moretti) (*Portuguese, Brazilian*) - Saederup92 (*Danish*) -- Stasiek Michalski (*Polish*) -- Dewi (*Breton; French*) -- cybergene (*Japanese*) -- AW Unad (*Indonesian*) -- Andrea Lo Iacono (*Italian*) -- Ray (*Spanish*) -- Unmual (*Spanish*) -- Ryo (*Korean*) -- juanda097 (*Spanish*) -- Anunnakey (*Macedonian*) -- Cutls (*Japanese*) -- erikstl (*Esperanto*) -- ruine (*Japanese*) -- MadeInSteak (*Finnish*) -- Sokratis Alichanidis (*Greek*) -- dragnucs2 (*Arabic*) -- frumble (*German*) -- Rikard Linde (*Swedish*) +- christalleras (*Norwegian Nynorsk*) +- cybergene (cyber-gene) (*Japanese*) +- Taloran (*Norwegian Nynorsk*) +- ThibG (*French, Icelandic*) +- xatier (*Chinese Traditional*) +- otrapersona (*Spanish, Spanish, Mexico*) +- atarashiako (*Chinese Simplified*) +- 101010 (101010pl) (*Polish*) +- silkevicious (*Italian*) +- Floxu (fredrikdim1) (*Norwegian Nynorsk*) +- Bertil Hedkvist (Berrahed) (*Swedish*) +- William(ѕ)ⁿ (wmlgr) (*Spanish*) +- norayr (*Armenian*) +- Tiago Epifânio (tfve) (*Portuguese*) +- Ryo (DrRyo) (*Korean*) +- Mentor Gashi (mentorgashi.com) (*Albanian*) +- Jaz-Michael King (jazmichaelking) (*Welsh*) +- carolinagiorno (*Portuguese, Brazilian*) +- Roby Thomas (roby.thomas) (*Malayalam*) +- Bharat Kumar (Marwari) (*Hindi*) +- ThonyVezbe (*Breton*) +- dkdarshan760 (*Sanskrit*) +- Tagomago (tagomago) (*French, Spanish*) +- tykayn (*French*) +- axi (*Finnish*) +- Selyan Slimane AMIRI (slimane_AMIRI) (*Kabyle*) +- Balázs Meskó (mesko.balazs) (*Hungarian*) +- taoxvx (*Danish*) +- Hrach Mkrtchyan (mhrach87) (*Armenian*) +- sabri (thetomatoisavegetable) (*Spanish, Spanish, Argentina*) +- Dewi (Unkorneg) (*French, Breton*) +- Coelacanthus (*Chinese Simplified*) +- syncopams (*Chinese Simplified, Chinese Traditional, Chinese Traditional, Hong Kong*) +- SteinarK (*Norwegian Nynorsk*) +- Sokratis Alichanidis (alichani) (*Greek*) +- Mathias B. Vagnes (vagnes) (*Norwegian*) +- dashersyed (*Urdu (Pakistan)*) +- Acolyte (666noob404) (*Ukrainian*) +- Conight Wang (xfddwhh) (*Chinese Simplified*) +- liffon (*Swedish*) +- Damjan Dimitrioski (gnud) (*Macedonian*) - PPNplus (*Thai*) +- shioko (*Chinese Simplified*) +- v4vachan (*Malayalam*) +- Hakim Oubouali (zenata1) (*Standard Moroccan Tamazight*) +- Evgeny Petrov (kondra007) (*Russian*) +- Gwenn (Belvar) (*Breton*) +- StanleyFrew (*French*) +- Hayk Khachatryan (brutusromanus123) (*Armenian*) +- jaranta (*Finnish*) +- Felicia (midsommar) (*Swedish*) +- Denys (dector) (*Ukrainian*) +- Pukima (pukimaaa) (*German*) +- Vanege (*Esperanto*) +- Jess Rafn (therealyez) (*Danish*) +- strubbl (*German*) +- Stasiek Michalski (hellcp) (*Polish*) +- dxwc (*Bengali*) +- jmontane (*Catalan*) +- Liboide (*Spanish*) +- Johan Schiff (schyffel) (*Swedish*) +- Arunmozhi (tecoholic) (*Tamil*) +- kat (katktv) (*Russian, Ukrainian*) +- Rikard Linde (rikardlinde) (*Swedish*) +- oti4500 (*Hungarian, Ukrainian*) +- Laura (selfisekai) (*Polish*) +- Rachida S. (ZiriSut) (*Kabyle*) +- diazepan (*Spanish, Spanish, Argentina*) +- marzuquccen (*Kabyle*) +- Juan José Salvador Piedra (JuanjoSalvador) (*Spanish*) +- Tigran (tigransimonyan) (*Armenian*) +- BurekzFinezt (*Serbian (Cyrillic)*) +- SHeija (*Finnish*) +- atriix (*Swedish*) +- Jack R (isaac.97_WT) (*Spanish*) +- antonyho (*Chinese Traditional, Hong Kong*) +- andruhov (*Russian, Ukrainian*) +- Aryamik Sharma (Aryamik) (*Swedish, Hindi*) +- phena109 (*Chinese Traditional, Hong Kong*) +- 森の子リスのミーコの大冒険 (Phroneris) (*Japanese*) +- るいーね (ruine) (*Japanese*) +- ahangarha (*Persian*) +- Sam Tux (imahbub) (*Bengali*) +- igordrozniak (*Polish*) +- Unmual (*Spanish*) +- Isaac Huang (caasih) (*Chinese Traditional*) +- AW Unad (awcodify) (*Indonesian*) +- Allen Zhong (AstroProfundis) (*Chinese Simplified*) +- Cutls (cutls) (*Japanese*) +- Ray (Ipsumry) (*Spanish*) +- Falling Snowdin (tghgg) (*Vietnamese*) +- coxde (*Chinese Simplified*) +- Rasmus Lindroth (RasmusLindroth) (*Swedish*) +- Andrea Lo Iacono (niels0n) (*Italian*) +- Kinshuk Sunil (kinshuksunil) (*Hindi*) +- Ullas Joseph (ullasjoseph) (*Malayalam*) +- Goudarz Jafari (Goudarz) (*Persian*) +- Yu-Pai Liu (tedliou) (*Chinese Traditional*) +- Amarin Cemthong (acitmaster) (*Thai*) +- juanda097 (juanda-097) (*Spanish*) +- Anunnakey (*Macedonian*) +- fragola (*Italian*) +- erikstl (*Esperanto*) +- twpenguin (*Chinese Traditional*) +- bobchao (*Chinese Traditional*) +- Esther (esthermations) (*Portuguese*) +- MadeInSteak (*Finnish*) +- Heimen Stoffels (vistausss) (*Dutch*) +- Rajarshi Guha (rajarshiguha) (*Bengali*) +- Andrew (iAndrew3) (*Romanian*) +- Gopal Sharma (gopalvirat) (*Hindi*) - arethsu (*Swedish*) -- EPEMA YT (*German*) -- Rhys Harrison (*Esperanto*) -- KEINOS (*Japanese*) +- Tofiq Abdula (Xwla) (*Sorani (Kurdish)*) +- Carlos Solís (csolisr) (*Esperanto*) +- Parthan S Ramanujam (parthan) (*Tamil*) +- Kasper Nymand (KasperNymand) (*Danish*) +- TS (morte) (*Finnish*) +- subram (*Turkish*) +- SensDeViata (*Ukrainian*) +- Ptrcmd (ptrcmd) (*Chinese Traditional*) +- SergioFMiranda (*Portuguese, Brazilian*) +- Scvoet (scvoet) (*Chinese Simplified*) +- hiroTS (*Chinese Traditional*) +- johne32rus23 (*Russian*) +- AzureNya (*Chinese Simplified*) +- OctolinGamer (octolingamer) (*Portuguese, Brazilian*) +- Ram varma (ram4varma) (*Tamil*) +- Hexandcube (hexandcube) (*Polish*) +- 北䑓如法 (Nyoho) (*Japanese*) +- frumble (*German*) +- kekkepikkuni (*Tamil*) +- Neo_Chen (NeoChen1024) (*Chinese Traditional*) +- oorsutri (*Tamil*) +- Rhys Harrison (rhedders) (*Esperanto*) +- Nithin V (Nithin896) (*Tamil*) +- Miro Rauhala (mirorauhala) (*Finnish*) +- diorama (*Italian*) +- AlexKoala (alexkoala) (*Korean*) +- Aswin C (officialcjunior) (*Malayalam*) +- Guillaume Turchini (orion78fr) (*French*) +- Ganesh D (auntgd) (*Marathi*) +- dragnucs2 (*Arabic*) +- Ryan Ho (koungho) (*Chinese Traditional*) +- Pedro Henrique (exploronauta) (*Portuguese, Brazilian*) +- Tejas Harad (h_tejas) (*Marathi*) +- Vasanthan (vasanthan) (*Tamil*) +- 硫酸鶏 (acid_chicken) (*Japanese*) +- clarmin b8 (clarminb8) (*Sorani (Kurdish)*) +- manukp (*Malayalam*) +- psymyn (*Hebrew*) +- earth dweller (sanethoughtyt) (*Marathi*) +- meijerivoi (toilet) (*Finnish*) +- essaar (*Tamil*) +- serubeena (*Swedish*) +- Karol Kosek (krkkPL) (*Polish*) +- Rintan (*Japanese*) +- valarivan (*Tamil*) +- Hernik (hernik27) (*Czech*) +- Sebastián Andil (Selrond) (*Slovak*) +- Hinaloe (hinaloe) (*Japanese*) - filippodb (*Italian*) +- KEINOS (*Japanese*) +- Balázs Meskó (meskobalazs) (*Hungarian*) +- Bottle (suryasalem2010) (*Tamil*) - JzshAC (*Chinese Simplified*) -- Rintan1 (*Japanese*) -- Antillion (*Spanish*) +- Wrya ali (John12) (*Sorani (Kurdish)*) +- Khóo (khootiatling) (*Chinese Traditional*) +- Steven Tappert (sammy8806) (*German*) +- Antillion (antillion99) (*Spanish*) +- Pukima (Pukimaa) (*German*) +- Reg3xp (*Persian*) - hiphipvargas (*Portuguese*) -- Ch. (*Korean*) +- gowthamanb (*Tamil*) +- Ch. (sftblw) (*Korean*) +- Jeff Huang (s8321414) (*Chinese Traditional*) +- Arttu Ylhävuori (arttu.ylhavuori) (*Finnish*) - tctovsli (*Norwegian Nynorsk*) +- Timo Tijhof (Krinkle) (*Dutch*) +- Yamagishi Kazutoshi (ykzts) (*Japanese, Icelandic, Sorani (Kurdish)*) - vjasiegd (*Polish*) -- SamitiMed (*Thai*) +- SamitiMed (samiti3d) (*Thai*) +- Rekan Adl (rekan-adl1) (*Sorani (Kurdish)*) - umelard (*Hebrew*) -- 硫酸鶏 (*Japanese*) -- Adrián Lattes (*Spanish*) -- Hinaloe (*Japanese*) -- Renato "Lond" Cerqueira (*Portuguese, Brazilian*) +- Antara2Cinta (Se7enTime) (*Indonesian*) +- VSx86 (*Russian*) +- Daniel Dimitrov (danny-dimitrov) (*Bulgarian*) - parnikkapore (*Thai*) -- Marcin Mikołajczak (*Polish*) -- 森の子リスのミーコの大冒険 (*Japanese*) -- Marcepanek_ (*Polish*) -- Sahak Petrosyan (*Armenian*) -- Daniel Dimitrov (*Bulgarian*) -- Hugh Liu (*Chinese Simplified*) -- Rakino (*Chinese Simplified*) +- mynameismonkey (*Welsh*) +- Sherwan Othman (sherwanothman11) (*Sorani (Kurdish)*) +- Yassine Aït-El-Mouden (yaitelmouden) (*Standard Moroccan Tamazight*) +- SKELET (*Danish*) +- Mo_der Steven (SakuraPuare) (*Chinese Simplified*) +- Fei Yang (Fei1Yang) (*Chinese Traditional*) +- ALEM FARID (faridatcemlulaqbayli) (*Kabyle*) +- enipra (*Armenian*) +- musix (*Persian*) +- Renato "Lond" Cerqueira (renatolond) (*Portuguese, Brazilian*) +- ギャラ (gyara) (*Japanese, Chinese Simplified*) +- Hougo (hougo) (*French*) +- ybardapurkar (*Marathi*) +- Adrián Lattes (haztecaso) (*Spanish*) +- TracyJacks (*Chinese Simplified*) +- rasheedgm (*Kannada*) +- GatoOscuro (*Spanish*) +- mecqor labi (mecqorlabi) (*Persian*) +- Belkacem Mohammed (belkacem77) (*Kabyle*) +- Navjot Singh (nspeaks) (*Hindi*) +- omquylzu (*Latvian*) +- Ozai (*German*) +- Sahak Petrosyan (petrosyan) (*Armenian*) +- siamano (*Thai, Esperanto*) +- Viorel-Cătălin Răpițeanu (rapiteanu) (*Romanian*) +- Siddhartha Sarathi Basu (quinoa_biryani) (*Bengali*) +- Pachara Chantawong (pachara2202) (*Thai*) +- mkljczk (*Polish*) +- Skew (noan.perrot) (*French*) +- Zijian Zhao (jobs2512821228) (*Chinese Simplified*) +- turtle836 (*German*) +- Guru Prasath Anandapadmanaban (guruprasath) (*Tamil*) +- Lamin (laminne) (*Japanese*) +- Marcepanek_ (thekingmarcepan) (*Polish*) +- Feruz Oripov (FeruzOripov) (*Russian*) +- Yann Aguettaz (yann-a) (*French*) +- Mick Onio (xgc.redes) (*Asturian*) +- Tianqi Zhang (tina.zhang040609) (*Chinese Simplified*) +- Malik Mann (dermalikmann) (*German*) +- dadosch (*German*) +- r3dsp1 (*Chinese Traditional, Hong Kong*) +- padulafacundo (*Spanish*) +- hg6 (*Hindi*) +- Orlando Murcio (Atos20) (*Spanish, Mexico*) +- piupiupiudiu (*Chinese Simplified*) +- shdy (*German*) +- Padraic Calpin (padraic-padraic) (*Slovenian*) +- Ильзира Рахматуллина (rahmatullinailzira53) (*Tatar*) +- cenegd (*Chinese Simplified*) +- Hugh Liu (youloveonlymeh) (*Chinese Simplified*) +- Pixelcode (realpixelcode) (*German*) +- Yogesh K S (yogi) (*Kannada*) +- Rakino (rakino) (*Chinese Simplified*) +- Miquel Sabaté Solà (mssola) (*Catalan*) +- AmazighNM (*Kabyle*) +- Jothipazhani Nagarajan (jothipazhani.n) (*Tamil*) +- Clash Clans (KURD12345) (*Sorani (Kurdish)*) +- hallomaurits (*Dutch*) +- alnd hezh (alndhezh) (*Sorani (Kurdish)*) +- Solid Rhino (SolidRhino) (*Dutch*) +- k_taka (peaceroad) (*Japanese*) +- Hallo Abdullah (hallo_hamza12) (*Sorani (Kurdish)*) - hussama (*Portuguese, Brazilian*) -- ThibG (*French*) +- Sébastien Feugère (smonff) (*French*) +- 林水溶 (shuiRong) (*Chinese Simplified*) +- eichkat3r (*German*) +- OminousCry (*Russian*) - SnDer (*Dutch*) - PifyZ (*French*) -- eichkat3r (*German*) -- Karol Kosek (*Polish*) -- Akarshan Biswas (*Bengali*) -- Tradjincal (*French*) -- Steven Tappert (*German*) -- sergioaraujo1 (*Portuguese, Brazilian*) +- Tom_ (*Czech*) +- Tagada (Tagadda) (*French*) +- shafouz (*Portuguese, Brazilian*) +- Kahina Mess (K_hina) (*Kabyle*) +- Nathaël Noguès (NatNgs) (*French*) +- Kk (kishorkumara3) (*Kannada*) +- Swati Sani (swatisani) (*Urdu (Pakistan)*) +- Shrinivasan T (tshrinivasan) (*Tamil*) +- さっかりんにーさん (saccharin23) (*Japanese*) +- 夜楓Yoka (Yoka2627) (*Chinese Simplified*) +- Daniel M. (daniconil) (*Catalan*) +- Vikatakavi (*Kannada*) +- SusVersiva (*Catalan*) +- Tradjincal (tradjincal) (*French*) +- pullopen (*Chinese Simplified*) +- Robin van der Vliet (RobinvanderVliet) (*Esperanto*) +- Zinkokooo (*Basque*) - mmokhi (*Persian*) -- fedot (*Russian*) +- Livingston Samuel (livingston) (*Tamil*) +- prabhjot (*Hindi*) +- sergioaraujo1 (*Portuguese, Brazilian*) +- CyberAmoeba (pseudoobscura) (*Chinese Simplified*) +- tsundoker (*Malayalam*) - skaaarrr (*German*) -- JackXu (*Chinese Simplified*) -- Lukas Fülling (*German*) -- Zoé Bőle (*German*) +- Ricardo Colin (rysard) (*Spanish*) +- mkljczk (mykylyjczyk) (*Polish*) +- Philipp Fischbeck (PFischbeck) (*German*) +- fedot (*Russian*) +- Paz Galindo (paz.almendra.g) (*Spanish*) +- GaggiX (*Italian*) +- ralozkolya (*Georgian*) +- Zoé Bőle (zoe1337) (*German*) +- Lukas Fülling (lfuelling) (*German*) +- JackXu (Merman-Jack) (*Chinese Simplified*) +- Aymeric (AymBroussier) (*French*) +- Anoop (anoopp) (*Malayalam*) +- pezcurrel (*Italian*) - Dremski (*Bulgarian*) -- tamaina (*Japanese*) +- Xurxo Guerra (xguerrap) (*Galician*) +- mashirozx (*Chinese Simplified*) +- Albatroz Jeremias (albjeremias) (*Portuguese*) +- Samir Tighzert (samir_t7) (*Kabyle*) +- Apple (blackteaovo) (*Chinese Simplified*) +- Nocta (*French*) - OpenAlgeria (*Arabic*) +- tamaina (*Japanese*) +- abidin toumi (Zet24) (*Arabic*) +- xpac1985 (xpac) (*German*) +- Kaede (kaedech) (*Japanese*) +- ÀŘǾŚ PÀŚĦÀÍ (arospashai) (*Sorani (Kurdish)*) +- Matias Lavik (matiaslavik) (*Norwegian Nynorsk*) +- smedvedev (*Russian*) +- mikel (mikelalas) (*Spanish*) +- Doug (douglasalvespe) (*Portuguese, Brazilian*) +- Trond Boksasp (boksasp) (*Norwegian*) +- Fleva (*Sardinian*) +- Mohammad Adnan Mahmood (adnanmig) (*Arabic*) +- Sais Lakshmanan (Saislakshmanan) (*Tamil*) +- Amith Raj Shetty (amithraj1989) (*Kannada*) +- random_person (*Spanish*) +- djoerd (*Dutch*) +- Baban Abdulrahman (baban.abdulrehman) (*Sorani (Kurdish)*) +- ebrezhoneg (*Breton*) +- dashty (*Sorani (Kurdish)*) +- Salh_haji6 (*Sorani (Kurdish)*) +- Amir Kurdo (kuraking202) (*Sorani (Kurdish)*) +- おさ (osapon) (*Japanese*) +- Ranj A Abdulqadir (RanjAhmed) (*Sorani (Kurdish)*) +- umonaca (*Chinese Simplified*) +- Bartek Fijałkowski (brateq) (*Polish*) +- tateisu (*Japanese*) +- centumix (*Japanese*) +- Jari Ronkainen (ronchaine) (*Finnish*) +- Savarín Electrográfico Marmota Intergalactica (herrero.maty) (*Spanish*) +- Torsten Högel (torstenhoegel) (*German*) +- Abijeet Patro (Abijeet) (*Basque*) +- Ács Zoltán (acszoltan111) (*Hungarian*) +- Benjamin Cobb (benjamincobb) (*German*) +- waweic (*German*) +- Aries (orlea) (*Japanese*) +- silverscat_3 (SilversCat) (*Japanese*) +- kavitha129 (*Tamil*) +- dcapillae (*Spanish*) +- SamOak (*Portuguese, Brazilian*) +- capiscuas (*Spanish*) +- NeverMine17 (*Russian*) +- Nithya Mary (nithyamary25) (*Tamil*) +- t_aus_m (*German*) +- dobrado (*Portuguese, Brazilian*) +- Hannah (Aniqueper1) (*Chinese Simplified*) +- Jiniux (*Italian*) +- 于晚霞 (xissshawww) (*Chinese Simplified*) diff --git a/Aptfile b/Aptfile index 0a01fa24bd..b2cbad714d 100644 --- a/Aptfile +++ b/Aptfile @@ -5,7 +5,6 @@ libidn11 libidn11-dev libpq-dev libprotobuf-dev -libssl-dev libxdamage1 libxfixes3 protobuf-compiler @@ -23,7 +22,7 @@ libpixman-1-0 librsvg2-2 libthai-data libthai0 -libvpx5 +libvpx[5-9] libxcb-render0 libxcb-shm0 libxrender1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d0110936e..8d749c255c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,398 @@ Changelog All notable changes to this project will be documented in this file. -## [v3.1.4] - 2020-05-14 +## [3.3.0] - 2020-12-27 +### Added + +- **Add hotkeys for audio/video control in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/15158), [Gargron](https://github.com/tootsuite/mastodon/pull/15198)) + - `Space` and `k` to toggle playback + - `m` to toggle mute + - `f` to toggle fullscreen + - `j` and `l` to go back and forward by 10 seconds + - `.` and `,` to go back and forward by a frame (video only) +- Add expand/compress button on media modal in web UI ([mashirozx](https://github.com/tootsuite/mastodon/pull/15068), [mashirozx](https://github.com/tootsuite/mastodon/pull/15088), [mashirozx](https://github.com/tootsuite/mastodon/pull/15094)) +- Add border around 🕺 emoji in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14769)) +- Add border around 🐞 emoji in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14712)) +- Add home link to the getting started column when home isn't mounted ([ThibG](https://github.com/tootsuite/mastodon/pull/14707)) +- Add option to disable swiping motions across the web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13885)) +- **Add pop-out player for audio/video in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/14870), [Gargron](https://github.com/tootsuite/mastodon/pull/15157), [Gargron](https://github.com/tootsuite/mastodon/pull/14915), [noellabo](https://github.com/tootsuite/mastodon/pull/15309)) + - Continue watching/listening when you scroll away + - Action bar to interact with/open toot from the pop-out player +- Add unread notification markers in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14818), [ThibG](https://github.com/tootsuite/mastodon/pull/14960), [ThibG](https://github.com/tootsuite/mastodon/pull/14954), [noellabo](https://github.com/tootsuite/mastodon/pull/14897), [noellabo](https://github.com/tootsuite/mastodon/pull/14907)) +- Add paragraph about browser add-ons when encountering errors in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14801)) +- Add import and export for bookmarks ([ThibG](https://github.com/tootsuite/mastodon/pull/14956)) +- Add cache buster feature for media files ([Gargron](https://github.com/tootsuite/mastodon/pull/15155)) + - If you have a proxy cache in front of object storage, deleted files will persist until the cache expires + - If enabled, cache buster will make a special request to the proxy to signal a cache reset +- Add duration option to the mute function ([aquarla](https://github.com/tootsuite/mastodon/pull/13831)) +- Add replies policy option to the list function ([ThibG](https://github.com/tootsuite/mastodon/pull/9205), [trwnh](https://github.com/tootsuite/mastodon/pull/15304)) +- Add `og:published_time` OpenGraph tags on toots ([nornagon](https://github.com/tootsuite/mastodon/pull/14865)) +- **Add option to be notified when a followed user posts** ([Gargron](https://github.com/tootsuite/mastodon/pull/13546), [ThibG](https://github.com/tootsuite/mastodon/pull/14896), [Gargron](https://github.com/tootsuite/mastodon/pull/14822)) + - If you don't want to miss a toot, click the bell button! +- Add client-side validation in password change forms ([ThibG](https://github.com/tootsuite/mastodon/pull/14564)) +- Add client-side validation in the registration form ([ThibG](https://github.com/tootsuite/mastodon/pull/14560), [ThibG](https://github.com/tootsuite/mastodon/pull/14599)) +- Add support for Gemini URLs ([joshleeb](https://github.com/tootsuite/mastodon/pull/15013)) +- Add app shortcuts to web app manifest ([mkljczk](https://github.com/tootsuite/mastodon/pull/15234)) +- Add WebAuthn as an alternative 2FA method ([santiagorodriguez96](https://github.com/tootsuite/mastodon/pull/14466), [jiikko](https://github.com/tootsuite/mastodon/pull/14806)) +- Add honeypot fields and minimum fill-out time for sign-up form ([ThibG](https://github.com/tootsuite/mastodon/pull/15276)) +- Add icon for mutual relationships in relationship manager ([noellabo](https://github.com/tootsuite/mastodon/pull/15149)) +- Add follow selected followers button in relationship manager ([noellabo](https://github.com/tootsuite/mastodon/pull/15148)) +- **Add subresource integrity for JS and CSS assets** ([Gargron](https://github.com/tootsuite/mastodon/pull/15096)) + - If you use a CDN for static assets (JavaScript, CSS, and so on), you have to trust that the CDN does not modify the assets maliciously + - Subresource integrity compares server-generated asset digests with what's actually served from the CDN and prevents such attacks +- Add `ku`, `sa`, `sc`, `zgh` to available locales ([ykzts](https://github.com/tootsuite/mastodon/pull/15138)) +- Add ability to force an account to mark media as sensitive ([noellabo](https://github.com/tootsuite/mastodon/pull/14361)) +- **Add ability to block access or limit sign-ups from chosen IPs** ([Gargron](https://github.com/tootsuite/mastodon/pull/14963), [ThibG](https://github.com/tootsuite/mastodon/pull/15263)) + - Add rules for IPs or CIDR ranges that automatically expire after a configurable amount of time + - Choose the severity of the rule, either blocking all access or merely limiting sign-ups +- **Add support for reversible suspensions through ActivityPub** ([Gargron](https://github.com/tootsuite/mastodon/pull/14989)) + - Servers can signal that one of their accounts has been suspended + - During suspension, the account can only delete its own content + - A reversal of the suspension can be signalled the same way + - A local suspension always overrides a remote one +- Add indication to admin UI of whether a report has been forwarded ([ThibG](https://github.com/tootsuite/mastodon/pull/13237)) +- Add display of reasons for joining of an account in admin UI ([mashirozx](https://github.com/tootsuite/mastodon/pull/15265)) +- Add option to obfuscate domain name in public list of domain blocks ([Gargron](https://github.com/tootsuite/mastodon/pull/15355)) +- Add option to make reasons for joining required on sign-up ([ThibG](https://github.com/tootsuite/mastodon/pull/15326), [ThibG](https://github.com/tootsuite/mastodon/pull/15358), [ThibG](https://github.com/tootsuite/mastodon/pull/15385), [ThibG](https://github.com/tootsuite/mastodon/pull/15405)) +- Add ActivityPub follower synchronization mechanism ([ThibG](https://github.com/tootsuite/mastodon/pull/14510), [ThibG](https://github.com/tootsuite/mastodon/pull/15026)) +- Add outbox attribute to instance actor ([ThibG](https://github.com/tootsuite/mastodon/pull/14721)) +- Add featured hashtags as an ActivityPub collection ([Gargron](https://github.com/tootsuite/mastodon/pull/11595), [noellabo](https://github.com/tootsuite/mastodon/pull/15277)) +- Add support for dereferencing objects through bearcaps ([Gargron](https://github.com/tootsuite/mastodon/pull/14683), [noellabo](https://github.com/tootsuite/mastodon/pull/14981)) +- Add `S3_READ_TIMEOUT` environment variable ([tateisu](https://github.com/tootsuite/mastodon/pull/14952)) +- Add `ALLOWED_PRIVATE_ADDRESSES` environment variable ([ThibG](https://github.com/tootsuite/mastodon/pull/14722)) +- Add `--fix-permissions` option to `tootctl media remove-orphans` ([Gargron](https://github.com/tootsuite/mastodon/pull/14383), [uist1idrju3i](https://github.com/tootsuite/mastodon/pull/14715)) +- Add `tootctl accounts merge` ([Gargron](https://github.com/tootsuite/mastodon/pull/15201), [ThibG](https://github.com/tootsuite/mastodon/pull/15264), [ThibG](https://github.com/tootsuite/mastodon/pull/15256)) + - Has someone changed their domain or subdomain thereby creating two accounts where there should be one? + - This command will fix it on your end +- Add `tootctl maintenance fix-duplicates` ([ThibG](https://github.com/tootsuite/mastodon/pull/14860), [Gargron](https://github.com/tootsuite/mastodon/pull/15223), [ThibG](https://github.com/tootsuite/mastodon/pull/15373)) + - Index corruption in the database? + - This command is for you +- **Add support for managing multiple stream subscriptions in a single connection** ([Gargron](https://github.com/tootsuite/mastodon/pull/14524), [Gargron](https://github.com/tootsuite/mastodon/pull/14566), [mfmfuyu](https://github.com/tootsuite/mastodon/pull/14859), [zunda](https://github.com/tootsuite/mastodon/pull/14608)) + - Previously, getting live updates for multiple timelines required opening a HTTP or WebSocket connection for each + - More connections means more resource consumption on both ends, not to mention the (ever so slight) delay when establishing a new connection + - Now, with just a single WebSocket connection you can subscribe and unsubscribe to and from multiple streams +- Add support for limiting results by both `min_id` and `max_id` at the same time in REST API ([tateisu](https://github.com/tootsuite/mastodon/pull/14776)) +- Add `GET /api/v1/accounts/:id/featured_tags` to REST API ([noellabo](https://github.com/tootsuite/mastodon/pull/11817), [noellabo](https://github.com/tootsuite/mastodon/pull/15270)) +- Add stoplight for object storage failures, return HTTP 503 in REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/13043)) +- Add optional `tootctl remove media` cronjob in Helm chart ([dunn](https://github.com/tootsuite/mastodon/pull/14396)) +- Add clean error message when `RAILS_ENV` is unset ([ThibG](https://github.com/tootsuite/mastodon/pull/15381)) + +### Changed + +- **Change media modals look in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/15217), [Gargron](https://github.com/tootsuite/mastodon/pull/15221), [Gargron](https://github.com/tootsuite/mastodon/pull/15284), [Gargron](https://github.com/tootsuite/mastodon/pull/15283), [Kjwon15](https://github.com/tootsuite/mastodon/pull/15308), [noellabo](https://github.com/tootsuite/mastodon/pull/15305), [ThibG](https://github.com/tootsuite/mastodon/pull/15417)) + - Background of the overlay matches the color of the image + - Action bar to interact with or open the toot from the modal +- Change order of announcements in admin UI to be newest-first ([ThibG](https://github.com/tootsuite/mastodon/pull/15091)) +- **Change account suspensions to be reversible by default** ([Gargron](https://github.com/tootsuite/mastodon/pull/14726), [ThibG](https://github.com/tootsuite/mastodon/pull/15152), [ThibG](https://github.com/tootsuite/mastodon/pull/15106), [ThibG](https://github.com/tootsuite/mastodon/pull/15100), [ThibG](https://github.com/tootsuite/mastodon/pull/15099), [noellabo](https://github.com/tootsuite/mastodon/pull/14855), [ThibG](https://github.com/tootsuite/mastodon/pull/15380), [Gargron](https://github.com/tootsuite/mastodon/pull/15420), [Gargron](https://github.com/tootsuite/mastodon/pull/15414)) + - Suspensions no longer equal deletions + - A suspended account can be unsuspended with minimal consequences for 30 days + - Immediate deletion of data is still available as an explicit option + - Suspended accounts can request an archive of their data through the UI +- Change REST API to return empty data for suspended accounts (14765) +- Change web UI to show empty profile for suspended accounts ([Gargron](https://github.com/tootsuite/mastodon/pull/14766), [Gargron](https://github.com/tootsuite/mastodon/pull/15345)) +- Change featured hashtag suggestions to be recently used instead of most used ([abcang](https://github.com/tootsuite/mastodon/pull/14760)) +- Change direct toots to appear in the home feed again ([Gargron](https://github.com/tootsuite/mastodon/pull/14711), [ThibG](https://github.com/tootsuite/mastodon/pull/15182), [noellabo](https://github.com/tootsuite/mastodon/pull/14727)) + - Return to treating all toots the same instead of trying to retrofit direct visibility into an instant messaging model +- Change email address validation to return more specific errors ([ThibG](https://github.com/tootsuite/mastodon/pull/14565)) +- Change HTTP signature requirements to include `Digest` header on `POST` requests ([ThibG](https://github.com/tootsuite/mastodon/pull/15069)) +- Change click area of video/audio player buttons to be bigger in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/15049)) +- Change order of filters by alphabetic by "keyword or phrase" ([ariasuni](https://github.com/tootsuite/mastodon/pull/15050)) +- Change suspension of remote accounts to also undo outgoing follows ([ThibG](https://github.com/tootsuite/mastodon/pull/15188)) +- Change string "Home" to "Home and lists" in the filter creation screen ([ariasuni](https://github.com/tootsuite/mastodon/pull/15139)) +- Change string "Boost to original audience" to "Boost with original visibility" in web UI ([3n-k1](https://github.com/tootsuite/mastodon/pull/14598)) +- Change string "Show more" to "Show newer" and "Show older" on public pages ([ariasuni](https://github.com/tootsuite/mastodon/pull/15052)) +- Change order of announcements to be reverse chronological in web UI ([dariusk](https://github.com/tootsuite/mastodon/pull/15065), [dariusk](https://github.com/tootsuite/mastodon/pull/15070)) +- Change RTL detection to rely on unicode-bidi paragraph by paragraph in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14573)) +- Change visibility icon next to timestamp to be clickable in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/15053), [mayaeh](https://github.com/tootsuite/mastodon/pull/15055)) +- Change public thread view to hide "Show thread" link ([ThibG](https://github.com/tootsuite/mastodon/pull/15266)) +- Change number format on about page from full to shortened ([Gargron](https://github.com/tootsuite/mastodon/pull/15327)) +- Change how scheduled tasks run in multi-process environments ([noellabo](https://github.com/tootsuite/mastodon/pull/15314)) + - New dedicated queue `scheduler` + - Runs by default when Sidekiq is executed with no options + - Has to be added manually in a multi-process environment + +### Removed + +- Remove fade-in animation from modals in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/15199)) +- Remove auto-redirect to direct messages in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/15142)) +- Remove obsolete IndexedDB operations from web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14730)) +- Remove dependency on unused and unmaintained http_parser.rb gem ([ThibG](https://github.com/tootsuite/mastodon/pull/14574)) + +### Fixed + +- Fix layout on about page when contact account has a long username ([ThibG](https://github.com/tootsuite/mastodon/pull/15357)) +- Fix follow limit preventing re-following of a moved account ([Gargron](https://github.com/tootsuite/mastodon/pull/14207), [ThibG](https://github.com/tootsuite/mastodon/pull/15384)) +- **Fix deletes not reaching every server that interacted with toot** ([Gargron](https://github.com/tootsuite/mastodon/pull/15200)) + - Previously, delete of a toot would be primarily sent to the followers of its author, people mentioned in the toot, and people who reblogged the toot + - Now, additionally, it is ensured that it is sent to people who replied to it, favourited it, and to the person it replies to even if that person is not mentioned +- Fix resolving an account through its non-canonical form (i.e. alternate domain) ([ThibG](https://github.com/tootsuite/mastodon/pull/15187)) +- Fix sending redundant ActivityPub events when processing remote account deletion ([ThibG](https://github.com/tootsuite/mastodon/pull/15104)) +- Fix Move handler not being triggered when failing to fetch target account ([ThibG](https://github.com/tootsuite/mastodon/pull/15107)) +- Fix downloading remote media files when server returns empty filename ([ThibG](https://github.com/tootsuite/mastodon/pull/14867)) +- Fix account processing failing because of large collections ([ThibG](https://github.com/tootsuite/mastodon/pull/15027)) +- Fix not being able to unfavorite toots one has lost access to ([ThibG](https://github.com/tootsuite/mastodon/pull/15192)) +- Fix not being able to unbookmark toots one has lost access to ([ThibG](https://github.com/tootsuite/mastodon/pull/14604)) +- Fix possible casing inconsistencies in hashtag search ([ThibG](https://github.com/tootsuite/mastodon/pull/14906)) +- Fix updating account counters when association is not yet created ([Gargron](https://github.com/tootsuite/mastodon/pull/15108)) +- Fix cookies not having a SameSite attribute ([Gargron](https://github.com/tootsuite/mastodon/pull/15098)) +- Fix poll ending notifications being created for each vote ([ThibG](https://github.com/tootsuite/mastodon/pull/15071)) +- Fix multiple boosts of a same toot erroneously appearing in TL ([ThibG](https://github.com/tootsuite/mastodon/pull/14759)) +- Fix asset builds not picking up `CDN_HOST` change ([ThibG](https://github.com/tootsuite/mastodon/pull/14381)) +- Fix desktop notifications permission prompt in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14985), [Gargron](https://github.com/tootsuite/mastodon/pull/15141), [ThibG](https://github.com/tootsuite/mastodon/pull/13543), [ThibG](https://github.com/tootsuite/mastodon/pull/15176)) + - Some time ago, browsers added a requirement that desktop notification prompts could only be displayed in response to a user-generated event (such as a click) + - This means that for some time, users who haven't already given the permission before were not getting a prompt and as such were not receiving desktop notifications +- Fix "Mark media as sensitive" string not supporting pluralizations in other languages in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/15051)) +- Fix glitched image uploads when canvas read access is blocked in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/15180)) +- Fix some account gallery items having empty labels in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/15073)) +- Fix alt-key hotkeys activating while typing in a text field in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14942)) +- Fix wrong seek bar width on media player in web UI ([mfmfuyu](https://github.com/tootsuite/mastodon/pull/15060)) +- Fix logging out on mobile in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14901)) +- Fix wrong click area for GIFVs in media modal in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/14615)) +- Fix unreadable placeholder text color in high contrast theme in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14803)) +- Fix scrolling issues when closing some dropdown menus in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14606)) +- Fix notification filter bar incorrectly filtering gaps in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14808)) +- Fix disabled boost icon being replaced by private boost icon on hover in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14456)) +- Fix hashtag detection in compose form being different to server-side in web UI ([kedamaDQ](https://github.com/tootsuite/mastodon/pull/14484), [ThibG](https://github.com/tootsuite/mastodon/pull/14513)) +- Fix home last read marker mishandling gaps in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14809)) +- Fix unnecessary re-rendering of various components when typing in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/15286)) +- Fix notifications being unnecessarily re-rendered in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/15312)) +- Fix column swiping animation logic in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/15301)) +- Fix inefficiency when fetching hashtag timeline ([noellabo](https://github.com/tootsuite/mastodon/pull/14861), [akihikodaki](https://github.com/tootsuite/mastodon/pull/14662)) +- Fix inefficiency when fetching bookmarks ([akihikodaki](https://github.com/tootsuite/mastodon/pull/14674)) +- Fix inefficiency when fetching favourites ([akihikodaki](https://github.com/tootsuite/mastodon/pull/14673)) +- Fix inefficiency when fetching media-only account timeline ([akihikodaki](https://github.com/tootsuite/mastodon/pull/14675)) +- Fix inefficieny when deleting accounts ([Gargron](https://github.com/tootsuite/mastodon/pull/15387), [ThibG](https://github.com/tootsuite/mastodon/pull/15409), [ThibG](https://github.com/tootsuite/mastodon/pull/15407), [ThibG](https://github.com/tootsuite/mastodon/pull/15408), [ThibG](https://github.com/tootsuite/mastodon/pull/15402), [ThibG](https://github.com/tootsuite/mastodon/pull/15416), [Gargron](https://github.com/tootsuite/mastodon/pull/15421)) +- Fix redundant query when processing batch actions on custom emojis ([niwatori24](https://github.com/tootsuite/mastodon/pull/14534)) +- Fix slow distinct queries where grouped queries are faster ([Gargron](https://github.com/tootsuite/mastodon/pull/15287)) +- Fix performance on instances list in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/15282)) +- Fix server actor appearing in list of accounts in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14567)) +- Fix "bootstrap timeline accounts" toggle in site settings in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/15325)) +- Fix PostgreSQL secret name for cronjob in Helm chart ([metal3d](https://github.com/tootsuite/mastodon/pull/15072)) +- Fix Procfile not being compatible with herokuish ([acuteaura](https://github.com/tootsuite/mastodon/pull/12685)) +- Fix installation of tini being split into multiple steps in Dockerfile ([ryncsn](https://github.com/tootsuite/mastodon/pull/14686)) + +### Security + +- Fix streaming API allowing connections to persist after access token invalidation ([Gargron](https://github.com/tootsuite/mastodon/pull/15111)) +- Fix 2FA/sign-in token sessions being valid after password change ([Gargron](https://github.com/tootsuite/mastodon/pull/14802)) +- Fix resolving accounts sometimes creating duplicate records for a given ActivityPub identifier ([ThibG](https://github.com/tootsuite/mastodon/pull/15364)) + +## [3.2.2] - 2020-12-19 +### Added + +- Add `tootctl maintenance fix-duplicates` ([ThibG](https://github.com/tootsuite/mastodon/pull/14860), [Gargron](https://github.com/tootsuite/mastodon/pull/15223)) + - Index corruption in the database? + - This command is for you + +### Removed + +- Remove dependency on unused and unmaintained http_parser.rb gem ([ThibG](https://github.com/tootsuite/mastodon/pull/14574)) + +### Fixed + +- Fix Move handler not being triggered when failing to fetch target account ([ThibG](https://github.com/tootsuite/mastodon/pull/15107)) +- Fix downloading remote media files when server returns empty filename ([ThibG](https://github.com/tootsuite/mastodon/pull/14867)) +- Fix possible casing inconsistencies in hashtag search ([ThibG](https://github.com/tootsuite/mastodon/pull/14906)) +- Fix updating account counters when association is not yet created ([Gargron](https://github.com/tootsuite/mastodon/pull/15108)) +- Fix account processing failing because of large collections ([ThibG](https://github.com/tootsuite/mastodon/pull/15027)) +- Fix resolving an account through its non-canonical form (i.e. alternate domain) ([ThibG](https://github.com/tootsuite/mastodon/pull/15187)) +- Fix slow distinct queries where grouped queries are faster ([Gargron](https://github.com/tootsuite/mastodon/pull/15287)) + +### Security + +- Fix 2FA/sign-in token sessions being valid after password change ([Gargron](https://github.com/tootsuite/mastodon/pull/14802)) +- Fix resolving accounts sometimes creating duplicate records for a given ActivityPub identifier ([ThibG](https://github.com/tootsuite/mastodon/pull/15364)) + +## [3.2.1] - 2020-10-19 +### Added + +- Add support for latest HTTP Signatures spec draft ([ThibG](https://github.com/tootsuite/mastodon/pull/14556)) +- Add support for inlined objects in ActivityPub `to`/`cc` ([ThibG](https://github.com/tootsuite/mastodon/pull/14514)) + +### Changed + +- Change actors to not be served at all without authentication in limited federation mode ([ThibG](https://github.com/tootsuite/mastodon/pull/14800)) + - Previously, a bare version of an actor was served when not authenticated, i.e. username and public key + - Because all actor fetch requests are signed using a separate system actor, that is no longer required + +### Fixed + +- Fix `tootctl media` commands not recognizing very large IDs ([ThibG](https://github.com/tootsuite/mastodon/pull/14536)) +- Fix crash when failing to load emoji picker in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14525)) +- Fix contrast requirements in thumbnail color extraction ([ThibG](https://github.com/tootsuite/mastodon/pull/14464)) +- Fix audio/video player not using `CDN_HOST` on public pages ([ThibG](https://github.com/tootsuite/mastodon/pull/14486)) +- Fix private boost icon not being used on public pages ([OmmyZhang](https://github.com/tootsuite/mastodon/pull/14471)) +- Fix audio player on Safari in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14485), [ThibG](https://github.com/tootsuite/mastodon/pull/14465)) +- Fix dereferencing remote statuses not using the correct account for signature when receiving a targeted inbox delivery ([ThibG](https://github.com/tootsuite/mastodon/pull/14656)) +- Fix nil error in `tootctl media remove` ([noellabo](https://github.com/tootsuite/mastodon/pull/14657)) +- Fix videos with near-60 fps being rejected ([Gargron](https://github.com/tootsuite/mastodon/pull/14684)) +- Fix reported statuses not being included in warning e-mail ([Gargron](https://github.com/tootsuite/mastodon/pull/14778)) +- Fix `Reject` activities of `Follow` objects not correctly destroying a follow relationship ([ThibG](https://github.com/tootsuite/mastodon/pull/14479)) +- Fix inefficiencies in fan-out-on-write service ([Gargron](https://github.com/tootsuite/mastodon/pull/14682), [noellabo](https://github.com/tootsuite/mastodon/pull/14709)) +- Fix timeout errors when trying to webfinger some IPv6 configurations ([Gargron](https://github.com/tootsuite/mastodon/pull/14919)) +- Fix files served as `application/octet-stream` being rejected without attempting mime type detection ([ThibG](https://github.com/tootsuite/mastodon/pull/14452)) + +## [3.2.0] - 2020-07-27 +### Added + +- Add `SMTP_SSL` environment variable ([OmmyZhang](https://github.com/tootsuite/mastodon/pull/14309)) +- Add hotkey for toggling content warning input in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13987)) +- **Add e-mail-based sign in challenge for users with disabled 2FA** ([Gargron](https://github.com/tootsuite/mastodon/pull/14013)) + - If user tries signing in after: + - Being inactive for a while + - With a previously unknown IP + - Without 2FA being enabled + - Require to enter a token sent via e-mail before sigining in +- Add `limit` param to RSS feeds ([noellabo](https://github.com/tootsuite/mastodon/pull/13743)) +- Add `visibility` param to share page ([noellabo](https://github.com/tootsuite/mastodon/pull/13023)) +- Add blurhash to link previews ([ThibG](https://github.com/tootsuite/mastodon/pull/13984), [ThibG](https://github.com/tootsuite/mastodon/pull/14143), [ThibG](https://github.com/tootsuite/mastodon/pull/13985), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/14267), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/14278), [ThibG](https://github.com/tootsuite/mastodon/pull/14126), [ThibG](https://github.com/tootsuite/mastodon/pull/14261), [ThibG](https://github.com/tootsuite/mastodon/pull/14260)) + - In web UI, toots cannot be marked as sensitive unless there is media attached + - However, it's possible to do via API or ActivityPub + - Thumnails of link previews of such posts now use blurhash in web UI + - The Card entity in REST API has a new `blurhash` attribute +- Add support for `summary` field for media description in ActivityPub ([ThibG](https://github.com/tootsuite/mastodon/pull/13763)) +- Add hints about incomplete remote content to web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14031), [noellabo](https://github.com/tootsuite/mastodon/pull/14195)) +- **Add personal notes for accounts** ([ThibG](https://github.com/tootsuite/mastodon/pull/14148), [Gargron](https://github.com/tootsuite/mastodon/pull/14208), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/14251)) + - To clarify, these are notes only you can see, to help you remember details + - Notes can be viewed and edited from profiles in web UI + - New REST API: `POST /api/v1/accounts/:id/note` with `comment` param + - The Relationship entity in REST API has a new `note` attribute +- Add Helm chart ([dunn](https://github.com/tootsuite/mastodon/pull/14090), [dunn](https://github.com/tootsuite/mastodon/pull/14256), [dunn](https://github.com/tootsuite/mastodon/pull/14245)) +- **Add customizable thumbnails for audio and video attachments** ([Gargron](https://github.com/tootsuite/mastodon/pull/14145), [Gargron](https://github.com/tootsuite/mastodon/pull/14244), [Gargron](https://github.com/tootsuite/mastodon/pull/14273), [Gargron](https://github.com/tootsuite/mastodon/pull/14203), [ThibG](https://github.com/tootsuite/mastodon/pull/14255), [ThibG](https://github.com/tootsuite/mastodon/pull/14306), [noellabo](https://github.com/tootsuite/mastodon/pull/14358), [noellabo](https://github.com/tootsuite/mastodon/pull/14357)) + - Metadata (album, artist, etc) is no longer stripped from audio files + - Album art is automatically extracted from audio files + - Thumbnail can be manually uploaded for both audio and video attachments + - Media upload APIs now support `thumbnail` param + - On `POST /api/v1/media` and `POST /api/v2/media` + - And on `PUT /api/v1/media/:id` + - ActivityPub representation of media attachments represents custom thumbnails with an `icon` attribute + - The Media Attachment entity in REST API now has a `preview_remote_url` to its `preview_url`, equivalent to `remote_url` to its `url` +- **Add color extraction for thumbnails** ([Gargron](https://github.com/tootsuite/mastodon/pull/14209), [ThibG](https://github.com/tootsuite/mastodon/pull/14264)) + - The `meta` attribute on the Media Attachment entity in REST API can now have a `colors` attribute which in turn contains three hex colors: `background`, `foreground`, and `accent` + - The background color is chosen from the most dominant color around the edges of the thumbnail + - The foreground and accent colors are chosen from the colors that are the most different from the background color using the CIEDE2000 algorithm + - The most satured color of the two is designated as the accent color + - The one with the highest W3C contrast is designated as the foreground color + - If there are not enough colors in the thumbnail, new ones are generated using a monochrome pattern +- Add a visibility indicator to toots in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/14123), [highemerly](https://github.com/tootsuite/mastodon/pull/14292)) +- Add `tootctl email_domain_blocks` ([tateisu](https://github.com/tootsuite/mastodon/pull/13589), [Gargron](https://github.com/tootsuite/mastodon/pull/14147)) +- Add "Add new domain block" to header of federation page in admin UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/13934)) +- Add ability to keep emoji picker open with ctrl+click in web UI ([bclindner](https://github.com/tootsuite/mastodon/pull/13896), [noellabo](https://github.com/tootsuite/mastodon/pull/14096)) +- Add custom icon for private boosts in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14380)) +- Add support for Create and Update activities that don't inline objects in ActivityPub ([ThibG](https://github.com/tootsuite/mastodon/pull/14359)) +- Add support for Undo activities that don't inline activities in ActivityPub ([ThibG](https://github.com/tootsuite/mastodon/pull/14346)) + +### Changed + +- Change `.env.production.sample` to be leaner and cleaner ([Gargron](https://github.com/tootsuite/mastodon/pull/14206)) + - It was overloaded as de-facto documentation and getting quite crowded + - Defer to the actual documentation while still giving a minimal example +- Change `tootctl search deploy` to work faster and display progress ([Gargron](https://github.com/tootsuite/mastodon/pull/14300)) +- Change User-Agent of link preview fetching service to include "Bot" ([Gargron](https://github.com/tootsuite/mastodon/pull/14248)) + - Some websites may not render OpenGraph tags into HTML if that's not the case +- Change behaviour to carry blocks over when someone migrates their followers ([ThibG](https://github.com/tootsuite/mastodon/pull/14144)) +- Change volume control and download buttons in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14122)) +- **Change design of audio players in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/14095), [ThibG](https://github.com/tootsuite/mastodon/pull/14281), [Gargron](https://github.com/tootsuite/mastodon/pull/14282), [ThibG](https://github.com/tootsuite/mastodon/pull/14118), [Gargron](https://github.com/tootsuite/mastodon/pull/14199), [ThibG](https://github.com/tootsuite/mastodon/pull/14338)) +- Change reply filter to never filter own toots in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14128)) +- Change boost button to no longer serve as visibility indicator in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/14132), [ThibG](https://github.com/tootsuite/mastodon/pull/14373)) +- Change contrast of flash messages ([cchoi12](https://github.com/tootsuite/mastodon/pull/13892)) +- Change wording from "Hide media" to "Hide image/images" in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/13834)) +- Change appearence of settings pages to be more consistent ([ariasuni](https://github.com/tootsuite/mastodon/pull/13938)) +- Change "Add media" tooltip to not include long list of formats in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/13954)) +- Change how badly contrasting emoji are rendered in web UI ([leo60228](https://github.com/tootsuite/mastodon/pull/13773), [ThibG](https://github.com/tootsuite/mastodon/pull/13772), [mfmfuyu](https://github.com/tootsuite/mastodon/pull/14020), [ThibG](https://github.com/tootsuite/mastodon/pull/14015)) +- Change structure of unavailable content section on about page ([ariasuni](https://github.com/tootsuite/mastodon/pull/13930)) +- Change behaviour to accept ActivityPub activities relayed through group actor ([noellabo](https://github.com/tootsuite/mastodon/pull/14279)) +- Change amount of processing retries for ActivityPub activities ([noellabo](https://github.com/tootsuite/mastodon/pull/14355)) + +### Removed + +- Remove the terms "blacklist" and "whitelist" from UX ([Gargron](https://github.com/tootsuite/mastodon/pull/14149), [mayaeh](https://github.com/tootsuite/mastodon/pull/14192)) + - Environment variables changed (old versions continue to work): + - `WHITELIST_MODE` → `LIMITED_FEDERATION_MODE` + - `EMAIL_DOMAIN_BLACKLIST` → `EMAIL_DOMAIN_DENYLIST` + - `EMAIL_DOMAIN_WHITELIST` → `EMAIL_DOMAIN_ALLOWLIST` + - CLI option changed: + - `tootctl domains purge --whitelist-mode` → `tootctl domains purge --limited-federation-mode` +- Remove some unnecessary database indices ([lfuelling](https://github.com/tootsuite/mastodon/pull/13695), [noellabo](https://github.com/tootsuite/mastodon/pull/14259)) +- Remove unnecessary Node.js version upper bound ([ykzts](https://github.com/tootsuite/mastodon/pull/14139)) + +### Fixed + +- Fix `following` param not working when exact match is found in account search ([noellabo](https://github.com/tootsuite/mastodon/pull/14394)) +- Fix sometimes occuring duplicate mention notifications ([noellabo](https://github.com/tootsuite/mastodon/pull/14378)) +- Fix RSS feeds not being cachable ([ThibG](https://github.com/tootsuite/mastodon/pull/14368)) +- Fix lack of locking around processing of Announce activities in ActivityPub ([noellabo](https://github.com/tootsuite/mastodon/pull/14365)) +- Fix boosted toots from blocked account not being retroactively removed from TL ([ThibG](https://github.com/tootsuite/mastodon/pull/14339)) +- Fix large shortened numbers (like 1.2K) using incorrect pluralization ([Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/14061)) +- Fix streaming server trying to use empty password to connect to Redis when `REDIS_PASSWORD` is given but blank ([ThibG](https://github.com/tootsuite/mastodon/pull/14135)) +- Fix being unable to unboost posts when blocked by their author ([ThibG](https://github.com/tootsuite/mastodon/pull/14308)) +- Fix account domain block not properly unfollowing accounts from domain ([Gargron](https://github.com/tootsuite/mastodon/pull/14304)) +- Fix removing a domain allow wiping known accounts in open federation mode ([ThibG](https://github.com/tootsuite/mastodon/pull/14298)) +- Fix blocks and mutes pagination in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14275)) +- Fix new posts pushing down origin of opened dropdown in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14271), [ThibG](https://github.com/tootsuite/mastodon/pull/14348)) +- Fix timeline markers not being saved sometimes ([ThibG](https://github.com/tootsuite/mastodon/pull/13887), [ThibG](https://github.com/tootsuite/mastodon/pull/13889), [ThibG](https://github.com/tootsuite/mastodon/pull/14155)) +- Fix CSV uploads being rejected ([noellabo](https://github.com/tootsuite/mastodon/pull/13835)) +- Fix incompatibility with ElasticSearch 7.x ([noellabo](https://github.com/tootsuite/mastodon/pull/13828)) +- Fix being able to search posts where you're in the target audience but not actively mentioned ([noellabo](https://github.com/tootsuite/mastodon/pull/13829)) +- Fix non-local posts appearing on local-only hashtag timelines in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/13827)) +- Fix `tootctl media remove-orphans` choking on unknown files in storage ([Gargron](https://github.com/tootsuite/mastodon/pull/13765)) +- Fix `tootctl upgrade storage-schema` misbehaving ([Gargron](https://github.com/tootsuite/mastodon/pull/13761), [angristan](https://github.com/tootsuite/mastodon/pull/13768)) + - Fix it marking records as upgraded even though no files were moved + - Fix it not working with S3 storage + - Fix it not working with custom emojis +- Fix GIF reader raising incorrect exceptions ([ThibG](https://github.com/tootsuite/mastodon/pull/13760)) +- Fix hashtag search performing account search as well ([ThibG](https://github.com/tootsuite/mastodon/pull/13758)) +- Fix Webfinger returning wrong status code on malformed or missing param ([ThibG](https://github.com/tootsuite/mastodon/pull/13759)) +- Fix `rake mastodon:setup` error when some environment variables are set ([ThibG](https://github.com/tootsuite/mastodon/pull/13928)) +- Fix admin page crashing when trying to block an invalid domain name in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13884)) +- Fix unsent toot confirmation dialog not popping up in single column mode in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13888)) +- Fix performance of follow import ([noellabo](https://github.com/tootsuite/mastodon/pull/13836)) + - Reduce timeout of Webfinger requests to that of other requests + - Use circuit breakers to stop hitting unresponsive servers + - Avoid hitting servers that are already known to be generally unavailable +- Fix filters ignoring media descriptions ([BenLubar](https://github.com/tootsuite/mastodon/pull/13837)) +- Fix some actions on custom emojis leading to cryptic errors in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13951)) +- Fix ActivityPub serialization of replies when some of them are URIs ([ThibG](https://github.com/tootsuite/mastodon/pull/13957)) +- Fix `rake mastodon:setup` choking on environment variables containing `%` ([ThibG](https://github.com/tootsuite/mastodon/pull/13940)) +- Fix account redirect confirmation message talking about moved followers ([ThibG](https://github.com/tootsuite/mastodon/pull/13950)) +- Fix avatars having the wrong size on public detailed status pages ([ThibG](https://github.com/tootsuite/mastodon/pull/14140)) +- Fix various issues around OpenGraph representation of media ([Gargron](https://github.com/tootsuite/mastodon/pull/14133)) + - Pages containing audio no longer say "Attached: 1 image" in description + - Audio attachments now represented as OpenGraph `og:audio` + - The `twitter:player` page now uses Mastodon's proper audio/video player + - Audio/video buffered bars now display correctly in audio/video player + - Volume and progress bars now respond to movement/move smoother +- Fix audio/video/images/cards not reacting to window resizes in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14130)) +- Fix very wide media attachments resulting in too thin a thumbnail in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14127)) +- Fix crash when merging posts into home feed after following someone ([ThibG](https://github.com/tootsuite/mastodon/pull/14129)) +- Fix unique username constraint for local users not being enforced in database ([ThibG](https://github.com/tootsuite/mastodon/pull/14099)) +- Fix unnecessary gap under video modal in web UI ([mfmfuyu](https://github.com/tootsuite/mastodon/pull/14098)) +- Fix 2FA and sign in token pages not respecting user locale ([mfmfuyu](https://github.com/tootsuite/mastodon/pull/14087)) +- Fix unapproved users being able to view profiles when in limited-federation mode *and* requiring approval for sign-ups ([ThibG](https://github.com/tootsuite/mastodon/pull/14093)) +- Fix initial audio volume not corresponding to what's displayed in audio player in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14057)) +- Fix timelines sometimes jumping when closing modals in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14019)) +- Fix memory usage of downloading remote files ([Gargron](https://github.com/tootsuite/mastodon/pull/14184), [Gargron](https://github.com/tootsuite/mastodon/pull/14181), [noellabo](https://github.com/tootsuite/mastodon/pull/14356)) + - Don't read entire file (up to 40 MB) into memory + - Read and write it to temp file in small chunks +- Fix inconsistent account header padding in web UI ([trwnh](https://github.com/tootsuite/mastodon/pull/14179)) +- Fix Thai being skipped from language detection ([Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/13989)) + - Since Thai has its own alphabet, it can be detected more reliably +- Fix broken hashtag column options styling in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14247)) +- Fix pointer cursor being shown on toots that are not clickable in web UI ([arielrodrigues](https://github.com/tootsuite/mastodon/pull/14185)) +- Fix lock icon not being shown when locking account in profile settings ([ThibG](https://github.com/tootsuite/mastodon/pull/14190)) +- Fix domain blocks doing work the wrong way around ([ThibG](https://github.com/tootsuite/mastodon/pull/13424)) + - Instead of suspending accounts one by one, mark all as suspended first (quick) + - Only then proceed to start removing their data (slow) + - Clear out media attachments in a separate worker (slow) + +## [3.1.5] - 2020-07-07 +### Security + +- Fix media attachment enumeration ([ThibG](https://github.com/tootsuite/mastodon/pull/14254)) +- Change rate limits for various paths ([Gargron](https://github.com/tootsuite/mastodon/pull/14253)) +- Fix other sessions not being logged out on password change ([Gargron](https://github.com/tootsuite/mastodon/pull/14252)) + +## [3.1.4] - 2020-05-14 ### Added - Add `vi` to available locales ([taicv](https://github.com/tootsuite/mastodon/pull/13542)) @@ -70,7 +461,7 @@ All notable changes to this project will be documented in this file. - For apps that self-register on behalf of every individual user (such as most mobile apps), this is a non-issue - The issue only affects developers of apps who are shared between multiple users, such as server-side apps like cross-posters -## [v3.1.3] - 2020-04-05 +## [3.1.3] - 2020-04-05 ### Added - Add ability to filter audit log in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/13381)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5b80c7e29..4b2d1f3e84 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,6 @@ The smaller the set of changes in the pull request is, the quicker it can be rev ## Documentation -The [Mastodon documentation](https://docs.joinmastodon.org) is a statically generated site. You can [submit merge requests to mastodon/docs](https://source.joinmastodon.org/mastodon/docs). +The [Mastodon documentation](https://docs.joinmastodon.org) is a statically generated site. You can [submit merge requests to tootsuite/documentation](https://github.com/tootsuite/documentation). diff --git a/Dockerfile b/Dockerfile index fa6abad5a1..ee0fc6e691 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ FROM ubuntu:20.04 as build-dep # Use bash for the shell -SHELL ["bash", "-c"] +SHELL ["/bin/bash", "-c"] # Install Node v12 (LTS) -ENV NODE_VER="12.16.3" +ENV NODE_VER="12.21.0" RUN ARCH= && \ dpkgArch="$(dpkg --print-architecture)" && \ case "${dpkgArch##*-}" in \ @@ -17,34 +17,19 @@ RUN ARCH= && \ *) echo "unsupported architecture"; exit 1 ;; \ esac && \ echo "Etc/UTC" > /etc/localtime && \ - apt update && \ - apt -y install wget python && \ + apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates wget python && \ cd ~ && \ - wget https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER-linux-$ARCH.tar.gz && \ + wget -q https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER-linux-$ARCH.tar.gz && \ tar xf node-v$NODE_VER-linux-$ARCH.tar.gz && \ rm node-v$NODE_VER-linux-$ARCH.tar.gz && \ mv node-v$NODE_VER-linux-$ARCH /opt/node -# Install jemalloc -ENV JE_VER="5.2.1" -RUN apt update && \ - apt -y install make autoconf gcc g++ && \ - cd ~ && \ - wget https://github.com/jemalloc/jemalloc/archive/$JE_VER.tar.gz && \ - tar xf $JE_VER.tar.gz && \ - cd jemalloc-$JE_VER && \ - ./autogen.sh && \ - ./configure --prefix=/opt/jemalloc && \ - make -j$(nproc) > /dev/null && \ - make install_bin install_include install_lib - # Install Ruby -ENV RUBY_VER="2.6.6" -ENV CPPFLAGS="-I/opt/jemalloc/include" -ENV LDFLAGS="-L/opt/jemalloc/lib/" -RUN apt update && \ - apt -y install build-essential \ - bison libyaml-dev libgdbm-dev libreadline-dev \ +ENV RUBY_VER="2.7.3" +RUN apt-get update && \ + apt-get install -y --no-install-recommends build-essential \ + bison libyaml-dev libgdbm-dev libreadline-dev libjemalloc-dev \ libncurses5-dev libffi-dev zlib1g-dev libssl-dev && \ cd ~ && \ wget https://cache.ruby-lang.org/pub/ruby/${RUBY_VER%.*}/ruby-$RUBY_VER.tar.gz && \ @@ -54,24 +39,24 @@ RUN apt update && \ --with-jemalloc \ --with-shared \ --disable-install-doc && \ - ln -s /opt/jemalloc/lib/* /usr/lib/ && \ - make -j$(nproc) > /dev/null && \ - make install + make -j"$(nproc)" > /dev/null && \ + make install && \ + rm -rf ../ruby-$RUBY_VER.tar.gz ../ruby-$RUBY_VER ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin" RUN npm install -g yarn && \ gem install bundler && \ - apt update && \ - apt -y install git libicu-dev libidn11-dev \ - libpq-dev libprotobuf-dev protobuf-compiler + apt-get update && \ + apt-get install -y --no-install-recommends git libicu-dev libidn11-dev \ + libpq-dev libprotobuf-dev protobuf-compiler shared-mime-info COPY Gemfile* package.json yarn.lock /opt/mastodon/ RUN cd /opt/mastodon && \ bundle config set deployment 'true' && \ bundle config set without 'development test' && \ - bundle install -j$(nproc) && \ + bundle install -j"$(nproc)" && \ yarn install --pure-lockfile FROM ubuntu:20.04 @@ -79,7 +64,6 @@ FROM ubuntu:20.04 # Copy over all the langs needed for runtime COPY --from=build-dep /opt/node /opt/node COPY --from=build-dep /opt/ruby /opt/ruby -COPY --from=build-dep /opt/jemalloc /opt/jemalloc # Add more PATHs to the PATH ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin:/opt/mastodon/bin" @@ -87,32 +71,26 @@ ENV PATH="${PATH}:/opt/ruby/bin:/opt/node/bin:/opt/mastodon/bin" # Create the mastodon user ARG UID=991 ARG GID=991 -RUN apt update && \ +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +RUN apt-get update && \ echo "Etc/UTC" > /etc/localtime && \ - ln -s /opt/jemalloc/lib/* /usr/lib/ && \ - apt install -y whois wget && \ + apt-get install -y --no-install-recommends whois wget && \ addgroup --gid $GID mastodon && \ useradd -m -u $UID -g $GID -d /opt/mastodon mastodon && \ - echo "mastodon:`head /dev/urandom | tr -dc A-Za-z0-9 | head -c 24 | mkpasswd -s -m sha-256`" | chpasswd + echo "mastodon:$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 24 | mkpasswd -s -m sha-256)" | chpasswd && \ + rm -rf /var/lib/apt/lists/* # Install mastodon runtime deps -RUN apt -y --no-install-recommends install \ - libssl1.1 libpq5 imagemagick ffmpeg \ +RUN apt-get update && \ + apt-get -y --no-install-recommends install \ + libssl1.1 libpq5 imagemagick ffmpeg libjemalloc2 \ libicu66 libprotobuf17 libidn11 libyaml-0-2 \ - file ca-certificates tzdata libreadline8 && \ - apt -y install gcc && \ + file ca-certificates tzdata libreadline8 gcc tini && \ ln -s /opt/mastodon /mastodon && \ gem install bundler && \ rm -rf /var/cache && \ rm -rf /var/lib/apt/lists/* -# Add tini -ENV TINI_VERSION="0.18.0" -ENV TINI_SUM="12d20136605531b09a2c2dac02ccee85e1b874eb322ef6baf7561cd93f93c855" -ADD https://github.com/krallin/tini/releases/download/v${TINI_VERSION}/tini /tini -RUN echo "$TINI_SUM tini" | sha256sum -c - -RUN chmod +x /tini - # Copy over mastodon source, and dependencies from building, and set permissions COPY --chown=mastodon:mastodon . /opt/mastodon COPY --from=build-dep --chown=mastodon:mastodon /opt/mastodon /opt/mastodon @@ -135,5 +113,5 @@ RUN cd ~ && \ # Set the work dir and the container entry point WORKDIR /opt/mastodon -ENTRYPOINT ["/tini", "--"] +ENTRYPOINT ["/usr/bin/tini", "--"] EXPOSE 3000 4000 diff --git a/Gemfile b/Gemfile index 6e0ece4065..83e3aa5645 100644 --- a/Gemfile +++ b/Gemfile @@ -5,10 +5,10 @@ ruby '>= 2.5.0', '< 3.0.0' gem 'pkg-config', '~> 1.4' -gem 'puma', '~> 4.3' -gem 'rails', '~> 5.2.4.3' +gem 'puma', '~> 5.2' +gem 'rails', '~> 6.1.3' gem 'sprockets', '~> 3.7.2' -gem 'thor', '~> 0.20' +gem 'thor', '~> 1.1' gem 'rack', '~> 2.2.3' gem 'thwait', '~> 0.2.0' @@ -30,31 +30,31 @@ gem 'blurhash', '~> 0.1' gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.7' -gem 'bootsnap', '~> 1.4', require: false +gem 'bootsnap', '~> 1.6.0', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'iso-639' -gem 'chewy', '~> 5.1' -gem 'cld3', '~> 3.3.0' +gem 'chewy', '~> 5.2' +gem 'cld3', '~> 3.4.2' gem 'devise', '~> 4.7' -gem 'devise-two-factor', '~> 3.1' +gem 'devise-two-factor', '~> 4.0' group :pam_authentication, optional: true do gem 'devise_pam_authenticatable2', '~> 9.2' end -gem 'net-ldap', '~> 0.16' -gem 'omniauth-cas', '~> 1.1' +gem 'net-ldap', '~> 0.17' +gem 'omniauth-cas', '~> 2.0' gem 'omniauth-saml', '~> 1.10' gem 'omniauth', '~> 1.9' +gem 'omniauth-rails_csrf_protection', '~> 0.1' gem 'color_diff', '~> 0.1' gem 'discard', '~> 1.2' -gem 'doorkeeper', '~> 5.4' +gem 'doorkeeper', '~> 5.5' gem 'ed25519', '~> 1.2' gem 'fast_blank', '~> 1.0' gem 'fastimage' -gem 'goldfinger', '~> 2.1' gem 'hiredis', '~> 0.6' gem 'redis-namespace', '~> 1.8' gem 'health_check', git: 'https://github.com/ianheggie/health_check', ref: '0b799ead604f900ed50685e9b2d469cd2befba5b' @@ -66,31 +66,30 @@ gem 'idn-ruby', require: 'idn' gem 'kaminari', '~> 1.2' gem 'link_header', '~> 0.0' gem 'mime-types', '~> 3.3.1', require: 'mime/types/columnar' -gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532' -gem 'nokogiri', '~> 1.10' +gem 'nokogiri', '~> 1.11' gem 'nsa', '~> 0.2' -gem 'oj', '~> 3.10' -gem 'ox', '~> 2.13' +gem 'oj', '~> 3.11' +gem 'ox', '~> 2.14' gem 'parslet' gem 'parallel', '~> 1.19' gem 'posix-spawn' gem 'pundit', '~> 2.1' gem 'premailer-rails' -gem 'rack-attack', '~> 6.3' +gem 'rack-attack', '~> 6.5' gem 'rack-cors', '~> 1.1', require: 'rack/cors' -gem 'rails-i18n', '~> 5.1' +gem 'rails-i18n', '~> 6.0' gem 'rails-settings-cached', '~> 0.6' gem 'redis', '~> 4.2', require: ['redis', 'redis/connection/hiredis'] gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' -gem 'rqrcode', '~> 1.1' -gem 'ruby-progressbar', '~> 1.10' +gem 'rqrcode', '~> 1.2' +gem 'ruby-progressbar', '~> 1.11' gem 'sanitize', '~> 5.2' gem 'sidekiq', '~> 6.1' gem 'sidekiq-scheduler', '~> 3.0' -gem 'sidekiq-unique-jobs', '~> 6.0' +gem 'sidekiq-unique-jobs', '~> 7.0' gem 'sidekiq-bulk', '~>0.2.0' gem 'simple-navigation', '~> 4.1' -gem 'simple_form', '~> 5.0' +gem 'simple_form', '~> 5.1' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'stoplight', '~> 2.2.1' gem 'strong_migrations', '~> 0.7' @@ -105,15 +104,15 @@ gem 'json-ld' gem 'json-ld-preloaded', '~> 3.1' gem 'rdf-normalize', '~> 0.4' -gem 'redcarpet', '~> 3.4' +gem 'redcarpet', '~> 3.5' group :development, :test do - gem 'fabrication', '~> 2.21' + gem 'fabrication', '~> 2.22' gem 'fuubar', '~> 2.5' gem 'i18n-tasks', '~> 0.9', require: false gem 'pry-byebug', '~> 3.9' gem 'pry-rails', '~> 0.3' - gem 'rspec-rails', '~> 4.0' + gem 'rspec-rails', '~> 5.0' end group :production, :test do @@ -121,7 +120,7 @@ group :production, :test do end group :test do - gem 'capybara', '~> 3.33' + gem 'capybara', '~> 3.35' gem 'climate_control', '~> 0.2' gem 'faker', '~> 2.13' gem 'microformats', '~> 4.2' @@ -134,10 +133,10 @@ group :test do end group :development do - gem 'active_record_query_trace', '~> 1.7' + gem 'active_record_query_trace', '~> 1.8' gem 'annotate', '~> 3.1' - gem 'better_errors', '~> 2.7' - gem 'binding_of_caller', '~> 0.7' + gem 'better_errors', '~> 2.9' + gem 'binding_of_caller', '~> 1.0' gem 'bullet', '~> 6.1' gem 'letter_opener', '~> 1.7' gem 'letter_opener_web', '~> 1.4' @@ -157,8 +156,9 @@ end group :production do gem 'lograge', '~> 0.11' - gem 'redis-rails', '~> 5.0' end gem 'concurrent-ruby', require: false gem 'connection_pool', require: false + +gem 'xorcist', '~> 1.1' diff --git a/Gemfile.lock b/Gemfile.lock index e72a3be842..48cd5dcd67 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -16,53 +16,71 @@ GIT GEM remote: https://rubygems.org/ specs: - actioncable (5.2.4.3) - actionpack (= 5.2.4.3) + actioncable (6.1.3.1) + actionpack (= 6.1.3.1) + activesupport (= 6.1.3.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailer (5.2.4.3) - actionpack (= 5.2.4.3) - actionview (= 5.2.4.3) - activejob (= 5.2.4.3) + actionmailbox (6.1.3.1) + actionpack (= 6.1.3.1) + activejob (= 6.1.3.1) + activerecord (= 6.1.3.1) + activestorage (= 6.1.3.1) + activesupport (= 6.1.3.1) + mail (>= 2.7.1) + actionmailer (6.1.3.1) + actionpack (= 6.1.3.1) + actionview (= 6.1.3.1) + activejob (= 6.1.3.1) + activesupport (= 6.1.3.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.2.4.3) - actionview (= 5.2.4.3) - activesupport (= 5.2.4.3) - rack (~> 2.0, >= 2.0.8) + actionpack (6.1.3.1) + actionview (= 6.1.3.1) + activesupport (= 6.1.3.1) + rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.2.4.3) - activesupport (= 5.2.4.3) + rails-html-sanitizer (~> 1.0, >= 1.2.0) + actiontext (6.1.3.1) + actionpack (= 6.1.3.1) + activerecord (= 6.1.3.1) + activestorage (= 6.1.3.1) + activesupport (= 6.1.3.1) + nokogiri (>= 1.8.5) + actionview (6.1.3.1) + activesupport (= 6.1.3.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.0.3) - active_model_serializers (0.10.10) - actionpack (>= 4.1, < 6.1) - activemodel (>= 4.1, < 6.1) + rails-html-sanitizer (~> 1.1, >= 1.2.0) + active_model_serializers (0.10.12) + actionpack (>= 4.1, < 6.2) + activemodel (>= 4.1, < 6.2) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) - active_record_query_trace (1.7) - activejob (5.2.4.3) - activesupport (= 5.2.4.3) + active_record_query_trace (1.8) + activejob (6.1.3.1) + activesupport (= 6.1.3.1) globalid (>= 0.3.6) - activemodel (5.2.4.3) - activesupport (= 5.2.4.3) - activerecord (5.2.4.3) - activemodel (= 5.2.4.3) - activesupport (= 5.2.4.3) - arel (>= 9.0) - activestorage (5.2.4.3) - actionpack (= 5.2.4.3) - activerecord (= 5.2.4.3) - marcel (~> 0.3.1) - activesupport (5.2.4.3) + activemodel (6.1.3.1) + activesupport (= 6.1.3.1) + activerecord (6.1.3.1) + activemodel (= 6.1.3.1) + activesupport (= 6.1.3.1) + activestorage (6.1.3.1) + actionpack (= 6.1.3.1) + activejob (= 6.1.3.1) + activerecord (= 6.1.3.1) + activesupport (= 6.1.3.1) + marcel (~> 1.0.0) + mini_mime (~> 1.0.2) + activesupport (6.1.3.1) concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 0.7, < 2) - minitest (~> 5.1) - tzinfo (~> 1.1) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) airbrussh (1.4.0) @@ -71,8 +89,7 @@ GEM annotate (3.1.1) activerecord (>= 3.2, < 7.0) rake (>= 10.4, < 14.0) - arel (9.0.0) - ast (2.4.1) + ast (2.4.2) attr_encrypted (3.1.0) encryptor (~> 3.0.0) av (0.9.0) @@ -108,15 +125,18 @@ GEM msgpack (~> 1.0) brakeman (4.9.0) browser (4.2.0) + brpoplpush-redis_script (0.1.2) + concurrent-ruby (~> 1.0, >= 1.0.5) + redis (>= 1.0, <= 5.0) builder (3.2.4) - bullet (6.1.0) + bullet (6.1.4) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) - bundler-audit (0.7.0.1) + bundler-audit (0.8.0) bundler (>= 1.2.0, < 3) - thor (>= 0.18, < 2) + thor (~> 1.0) byebug (11.1.3) - capistrano (3.14.1) + capistrano (3.16.0) airbrussh (>= 1.0.0) i18n rake (>= 10.0.0) @@ -131,20 +151,20 @@ GEM sshkit (~> 1.3) capistrano-yarn (2.0.2) capistrano (~> 3.0) - capybara (3.33.0) + capybara (3.35.3) addressable mini_mime (>= 0.1.3) nokogiri (~> 1.8) rack (>= 1.6.0) rack-test (>= 0.6.3) - regexp_parser (~> 1.5) + regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) case_transform (0.2) activesupport cbor (0.5.9.6) charlock_holmes (0.7.7) - chewy (5.1.0) - activesupport (>= 4.0) + chewy (5.2.0) + activesupport (>= 5.2) elasticsearch (>= 2.0.0) elasticsearch-dsl chunky_png (1.3.12) @@ -165,29 +185,29 @@ GEM crass (1.0.6) css_parser (1.7.1) addressable - debug_inspector (0.0.3) - devise (4.7.2) + debug_inspector (1.0.0) + devise (4.7.3) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-two-factor (3.1.0) - activesupport (< 6.1) + devise-two-factor (4.0.0) + activesupport (< 6.2) attr_encrypted (>= 1.3, < 4, != 2) devise (~> 4.0) - railties (< 6.1) - rotp (~> 2.0) + railties (< 6.2) + rotp (~> 6.0) devise_pam_authenticatable2 (9.2.0) devise (>= 4.0.0) rpam2 (~> 4.0) diff-lcs (1.4.4) discard (1.2.0) activerecord (>= 4.2, < 7) - docile (1.3.2) + docile (1.3.4) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) - doorkeeper (5.4.0) + doorkeeper (5.5.1) railties (>= 5) dotenv (2.7.6) dotenv-rails (2.7.6) @@ -212,8 +232,11 @@ GEM fabrication (2.21.1) faker (2.13.0) i18n (>= 1.6, < 2) - faraday (1.0.1) + faraday (1.3.0) + faraday-net_http (~> 1.0) multipart-post (>= 1.2, < 3) + ruby2_keywords + faraday-net_http (1.0.1) fast_blank (1.0.0) fastimage (2.2.0) ffi (1.10.0) @@ -236,7 +259,7 @@ GEM fugit (1.3.8) et-orbi (~> 1.1, >= 1.1.8) raabro (~> 1.3) - fuubar (2.5.0) + fuubar (2.5.1) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) globalid (0.4.2) @@ -279,7 +302,7 @@ GEM rainbow (>= 2.0.0) i18n (1.8.5) concurrent-ruby (~> 1.0) - i18n-tasks (0.9.31) + i18n-tasks (0.9.34) activesupport (>= 4.0.2) ast (>= 2.1.0) erubi @@ -302,7 +325,7 @@ GEM multi_json (~> 1.14) rack (~> 2.0) rdf (~> 3.1) - json-ld-preloaded (3.1.3) + json-ld-preloaded (3.1.5) json-ld (~> 3.1) rdf (~> 3.1) jsonapi-renderer (0.2.2) @@ -338,13 +361,12 @@ GEM nokogiri (>= 1.5.9) mail (2.7.1) mini_mime (>= 0.1.1) - makara (0.4.1) + makara (0.5.0) activerecord (>= 3.0.0) - marcel (0.3.3) - mimemagic (~> 0.3.2) + marcel (1.0.0) mario-redis-lock (1.2.1) redis (>= 3.0.5) - memory_profiler (0.9.14) + memory_profiler (1.0.0) method_source (1.0.0) microformats (4.2.1) json (~> 2.2) @@ -368,8 +390,8 @@ GEM mini_portile2 (~> 2.4.0) nokogumbo (2.0.2) nokogiri (~> 1.8, >= 1.8.4) - nsa (0.2.7) - activesupport (>= 4.2, < 6) + nsa (0.2.8) + activesupport (>= 4.2, < 7) concurrent-ruby (~> 1.0, >= 1.0.2) sidekiq (>= 3.5) statsd-ruby (~> 1.4, >= 1.4.0) @@ -377,7 +399,7 @@ GEM omniauth (1.9.1) hashie (>= 3.4.6) rack (>= 1.6.2, < 3) - omniauth-cas (1.1.1) + omniauth-cas (2.0.0) addressable (~> 2.3) nokogiri (~> 1.5) omniauth (~> 1.2) @@ -387,7 +409,7 @@ GEM openssl (2.2.0) openssl-signature_algorithm (0.4.0) orm_adapter (0.5.0) - ox (2.13.2) + ox (2.14.4) paperclip (6.0.0) activemodel (>= 4.2.0) activesupport (>= 4.2.0) @@ -400,7 +422,7 @@ GEM parallel (1.19.2) parallel_tests (3.2.0) parallel - parser (2.7.1.4) + parser (3.0.1.0) ast (~> 2.4.1) parslet (2.0.0) pastel (0.8.0) @@ -426,14 +448,15 @@ GEM pry (~> 0.13.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (4.0.5) - puma (4.3.5) + public_suffix (4.0.6) + puma (5.2.2) nio4r (~> 2.0) pundit (2.1.0) activesupport (>= 3.0.0) - raabro (1.3.1) + raabro (1.3.3) + racc (1.5.2) rack (2.2.3) - rack-attack (6.3.1) + rack-attack (6.5.0) rack (>= 1.0, < 3) rack-cors (1.1.1) rack (>= 2.0.0) @@ -441,18 +464,20 @@ GEM rack rack-test (1.1.0) rack (>= 1.0, < 3) - rails (5.2.4.3) - actioncable (= 5.2.4.3) - actionmailer (= 5.2.4.3) - actionpack (= 5.2.4.3) - actionview (= 5.2.4.3) - activejob (= 5.2.4.3) - activemodel (= 5.2.4.3) - activerecord (= 5.2.4.3) - activestorage (= 5.2.4.3) - activesupport (= 5.2.4.3) - bundler (>= 1.3.0) - railties (= 5.2.4.3) + rails (6.1.3.1) + actioncable (= 6.1.3.1) + actionmailbox (= 6.1.3.1) + actionmailer (= 6.1.3.1) + actionpack (= 6.1.3.1) + actiontext (= 6.1.3.1) + actionview (= 6.1.3.1) + activejob (= 6.1.3.1) + activemodel (= 6.1.3.1) + activerecord (= 6.1.3.1) + activestorage (= 6.1.3.1) + activesupport (= 6.1.3.1) + bundler (>= 1.15.0) + railties (= 6.1.3.1) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) @@ -463,17 +488,17 @@ GEM nokogiri (>= 1.6) rails-html-sanitizer (1.3.0) loofah (~> 2.3) - rails-i18n (5.1.3) + rails-i18n (6.0.0) i18n (>= 0.7, < 2) - railties (>= 5.0, < 6) + railties (>= 6.0.0, < 7) rails-settings-cached (0.6.6) rails (>= 4.2.0) - railties (5.2.4.3) - actionpack (= 5.2.4.3) - activesupport (= 5.2.4.3) + railties (6.1.3.1) + actionpack (= 6.1.3.1) + activesupport (= 6.1.3.1) method_source rake (>= 0.8.7) - thor (>= 0.19.0, < 2.0) + thor (~> 1.0) rainbow (3.0.0) rake (13.0.1) rdf (3.1.5) @@ -507,40 +532,40 @@ GEM responders (3.0.1) actionpack (>= 5.0) railties (>= 5.0) - rexml (3.2.4) - rotp (2.1.2) + rexml (3.2.5) + rotp (6.2.0) rpam2 (4.0.2) - rqrcode (1.1.2) + rqrcode (1.2.0) chunky_png (~> 1.0) - rqrcode_core (~> 0.1) - rqrcode_core (0.1.2) - rspec-core (3.9.2) - rspec-support (~> 3.9.3) - rspec-expectations (3.9.2) + rqrcode_core (~> 0.2) + rqrcode_core (0.2.0) + rspec-core (3.10.1) + rspec-support (~> 3.10.0) + rspec-expectations (3.10.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.9.0) - rspec-mocks (3.9.1) + rspec-support (~> 3.10.0) + rspec-mocks (3.10.2) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.9.0) - rspec-rails (4.0.1) - actionpack (>= 4.2) - activesupport (>= 4.2) - railties (>= 4.2) - rspec-core (~> 3.9) - rspec-expectations (~> 3.9) - rspec-mocks (~> 3.9) - rspec-support (~> 3.9) + rspec-support (~> 3.10.0) + rspec-rails (5.0.1) + actionpack (>= 5.2) + activesupport (>= 5.2) + railties (>= 5.2) + rspec-core (~> 3.10) + rspec-expectations (~> 3.10) + rspec-mocks (~> 3.10) + rspec-support (~> 3.10) rspec-sidekiq (3.1.0) rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) - rspec-support (3.9.3) + rspec-support (3.10.2) rspec_junit_formatter (0.4.1) rspec-core (>= 2, < 4, != 2.12.0) rubocop (0.88.0) parallel (~> 1.10) parser (>= 2.7.1.1) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.7) + regexp_parser (>= 1.8, < 3.0) rexml rubocop-ast (>= 0.1.0, < 1.0) ruby-progressbar (~> 1.7) @@ -550,10 +575,11 @@ GEM rubocop-rails (2.6.0) activesupport (>= 4.2.0) rack (>= 1.1) - rubocop (>= 0.82.0) - ruby-progressbar (1.10.1) + rubocop (>= 0.90.0, < 2.0) + ruby-progressbar (1.11.0) ruby-saml (1.11.0) nokogiri (>= 1.5.10) + ruby2_keywords (0.0.4) rufus-scheduler (3.6.0) fugit (~> 1.1, >= 1.1.6) safe_yaml (1.0.5) @@ -578,10 +604,11 @@ GEM sidekiq (>= 3) thwait tilt (>= 1.4.0) - sidekiq-unique-jobs (6.0.22) + sidekiq-unique-jobs (7.0.8) + brpoplpush-redis_script (> 0.1.1, <= 2.0.0) concurrent-ruby (~> 1.0, >= 1.0.5) - sidekiq (>= 4.0, < 7.0) - thor (~> 0) + sidekiq (>= 5.0, < 7.0) + thor (>= 0.20, < 2.0) simple-navigation (4.1.0) activesupport (>= 2.3.2) simple_form (5.0.2) @@ -590,15 +617,17 @@ GEM simplecov (0.19.0) docile (~> 1.1) simplecov-html (~> 0.11) - simplecov-html (0.12.2) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.12.3) + simplecov_json_formatter (0.1.2) sprockets (3.7.2) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (3.2.1) + sprockets-rails (3.2.2) actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) - sshkit (1.21.0) + sshkit (1.21.2) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) stackprof (0.2.15) @@ -609,7 +638,7 @@ GEM strong_migrations (0.7.1) activerecord (>= 5) temple (0.8.2) - terminal-table (1.8.0) + terminal-table (3.0.0) unicode-display_width (~> 1.1, >= 1.1.1) terrapin (0.6.0) climate_control (>= 0.0.3, < 1.0) @@ -633,9 +662,9 @@ GEM tty-screen (0.8.1) twitter-text (1.14.7) unf (~> 0.1.0) - tzinfo (1.2.7) - thread_safe (~> 0.1) - tzinfo-data (1.2020.1) + tzinfo (2.0.4) + concurrent-ruby (~> 1.0) + tzinfo-data (1.2021.1) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext @@ -670,15 +699,17 @@ GEM websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) wisper (2.0.1) + xorcist (1.1.2) xpath (3.2.0) nokogiri (~> 1.8) + zeitwerk (2.4.2) PLATFORMS ruby DEPENDENCIES active_model_serializers (~> 0.10) - active_record_query_trace (~> 1.7) + active_record_query_trace (~> 1.8) addressable (~> 2.7) annotate (~> 3.1) aws-sdk-s3 (~> 1.79) @@ -694,21 +725,20 @@ DEPENDENCIES capistrano-rails (~> 1.6) capistrano-rbenv (~> 2.2) capistrano-yarn (~> 2.0) - capybara (~> 3.33) + capybara (~> 3.35) charlock_holmes (~> 0.7.7) - chewy (~> 5.1) - cld3 (~> 3.3.0) + chewy (~> 5.2) + cld3 (~> 3.4.2) climate_control (~> 0.2) color_diff (~> 0.1) concurrent-ruby connection_pool devise (~> 4.7) - devise-two-factor (~> 3.1) + devise-two-factor (~> 4.0) devise_pam_authenticatable2 (~> 9.2) discard (~> 1.2) - doorkeeper (~> 5.4) + doorkeeper (~> 5.5) dotenv-rails (~> 2.7) - e2mmap (~> 0.1.0) ed25519 (~> 1.2) fabrication (~> 2.21) faker (~> 2.13) @@ -717,9 +747,7 @@ DEPENDENCIES fog-core (<= 2.1.0) fog-openstack (~> 0.3) fuubar (~> 2.5) - goldfinger (~> 2.1) hamlit-rails (~> 0.2) - health_check! hiredis (~> 0.6) htmlentities (~> 4.3) http (~> 4.4) @@ -735,20 +763,20 @@ DEPENDENCIES letter_opener_web (~> 1.4) link_header (~> 0.0) lograge (~> 0.11) - makara (~> 0.4) + makara (~> 0.5) mario-redis-lock (~> 1.2) memory_profiler microformats (~> 4.2) mime-types (~> 3.3.1) - net-ldap (~> 0.16) - nilsimsa! - nokogiri (~> 1.10) + net-ldap (~> 0.17) + nokogiri (~> 1.11) nsa (~> 0.2) - oj (~> 3.10) + oj (~> 3.11) omniauth (~> 1.9) - omniauth-cas (~> 1.1) + omniauth-cas (~> 2.0) + omniauth-rails_csrf_protection (~> 0.1) omniauth-saml (~> 1.10) - ox (~> 2.13) + ox (~> 2.14) paperclip (~> 6.0) paperclip-av-transcoder (~> 0.6) parallel (~> 1.19) @@ -762,17 +790,17 @@ DEPENDENCIES private_address_check (~> 0.5) pry-byebug (~> 3.9) pry-rails (~> 0.3) - puma (~> 4.3) + puma (~> 5.2) pundit (~> 2.1) rack (~> 2.2.3) - rack-attack (~> 6.3) + rack-attack (~> 6.5) rack-cors (~> 1.1) - rails (~> 5.2.4.3) + rails (~> 6.1.3) rails-controller-testing (~> 1.0) - rails-i18n (~> 5.1) + rails-i18n (~> 6.0) rails-settings-cached (~> 0.6) rdf-normalize (~> 0.4) - redcarpet (~> 3.4) + redcarpet (~> 3.5) redis (~> 4.2) redis-namespace (~> 1.8) redis-rails (~> 5.0) @@ -787,7 +815,7 @@ DEPENDENCIES sidekiq (~> 6.1) sidekiq-bulk (~> 0.2.0) sidekiq-scheduler (~> 3.0) - sidekiq-unique-jobs (~> 6.0) + sidekiq-unique-jobs (~> 7.0) simple-navigation (~> 4.1) simple_form (~> 5.0) simplecov (~> 0.19) diff --git a/Procfile.dev b/Procfile.dev index e589bbf630..ba04fb661b 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,4 +1,4 @@ -web: env PORT=3000 bundle exec puma -C config/puma.rb -sidekiq: env PORT=3000 bundle exec sidekiq +web: env PORT=3000 RAILS_ENV=development bundle exec puma -C config/puma.rb +sidekiq: env PORT=3000 RAILS_ENV=development bundle exec sidekiq stream: env PORT=4000 yarn run start webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0 diff --git a/Vagrantfile b/Vagrantfile index 143fa27fdb..addc6b0f4a 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -72,10 +72,12 @@ bundle install yarn install # Build Mastodon +export RAILS_ENV=development export $(cat ".env.vagrant" | xargs) bundle exec rails db:setup # Configure automatic loading of environment variable +echo 'export RAILS_ENV=development' >> ~/.bash_profile echo 'export $(cat "/vagrant/.env.vagrant" | xargs)' >> ~/.bash_profile SCRIPT diff --git a/app.json b/app.json index 211f17d812..e4f7cf403e 100644 --- a/app.json +++ b/app.json @@ -88,9 +88,6 @@ { "url": "https://github.com/heroku/heroku-buildpack-apt" }, - { - "url": "heroku/nodejs" - }, { "url": "heroku/ruby" } diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb index d4b05fca99..47cb856ea9 100644 --- a/app/chewy/statuses_index.rb +++ b/app/chewy/statuses_index.rb @@ -31,7 +31,7 @@ class StatusesIndex < Chewy::Index }, } - define_type ::Status.unscoped.kept.without_reblogs.includes(:media_attachments), delete_if: ->(status) { status.searchable_by.empty? } do + define_type ::Status.unscoped.kept.without_reblogs.includes(:media_attachments, :preloadable_poll) do crutch :mentions do |collection| data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local, silent: false).pluck(:status_id, :account_id) data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 5d5db937c2..620c0ff78f 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true class AboutController < ApplicationController + include RegistrationSpamConcern + before_action :set_pack + layout 'public' before_action :require_open_federation!, only: [:show, :more] before_action :set_body_classes, only: :show before_action :set_instance_presenter - before_action :set_expires_in, only: [:show, :more, :terms] + before_action :set_expires_in, only: [:more, :terms] + before_action :set_registration_form_time, only: :show skip_before_action :require_functional!, only: [:more, :terms] @@ -18,6 +22,7 @@ class AboutController < ApplicationController toc_generator = TOCGenerator.new(@instance_presenter.site_extended_description) + @rules = Rule.ordered @contents = toc_generator.html @table_of_contents = toc_generator.toc @blocks = DomainBlock.with_user_facing_limitations.by_severity if display_blocks? diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 5c8cdd1745..ab7e1f0777 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -7,6 +7,7 @@ class AccountsController < ApplicationController include AccountControllerConcern include SignatureAuthentication + before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_cache_headers before_action :set_body_classes @@ -29,8 +30,7 @@ class AccountsController < ApplicationController end @pinned_statuses = cache_collection(@account.pinned_statuses.not_local_only, Status) if show_pinned_statuses? - @statuses = filtered_status_page - @statuses = cache_collection(@statuses, Status) + @statuses = cached_filtered_status_page @rss_url = rss_url unless @statuses.empty? @@ -50,7 +50,7 @@ class AccountsController < ApplicationController format.json do expires_in 3.minutes, public: !(authorized_fetch_mode? && signed_request_account.present?) - render_with_cache json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, fields: restrict_fields_to + render_with_cache json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter end end end @@ -82,7 +82,7 @@ class AccountsController < ApplicationController end def account_media_status_ids - @account.media_attachments.attached.reorder(nil).select(:status_id).distinct + @account.media_attachments.attached.reorder(nil).select(:status_id).group(:status_id) end def no_replies_scope @@ -103,6 +103,10 @@ class AccountsController < ApplicationController params[:username] end + def skip_temporary_suspension_response? + request.format == :json + end + def rss_url if tag_requested? short_account_tag_url(@account, params[:tag], format: 'rss') @@ -132,30 +136,27 @@ class AccountsController < ApplicationController end def media_requested? - request.path.split('.').first.ends_with?('/media') && !tag_requested? + request.path.split('.').first.end_with?('/media') && !tag_requested? end def replies_requested? - request.path.split('.').first.ends_with?('/with_replies') && !tag_requested? + request.path.split('.').first.end_with?('/with_replies') && !tag_requested? end def tag_requested? - request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize) + request.path.split('.').first.end_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize) end - def filtered_status_page - filtered_statuses.paginate_by_id(PAGE_SIZE, params_slice(:max_id, :min_id, :since_id)) + def cached_filtered_status_page + cache_collection_paginated_by_id( + filtered_statuses, + Status, + PAGE_SIZE, + params_slice(:max_id, :min_id, :since_id) + ) end def params_slice(*keys) params.slice(*keys).permit(*keys) end - - def restrict_fields_to - if signed_request_account.present? || public_fetch_mode? - # Return all fields - else - %i(id type preferred_username inbox public_key endpoints) - end - end end diff --git a/app/controllers/activitypub/base_controller.rb b/app/controllers/activitypub/base_controller.rb index 0c2591e974..4cbc3ab8f2 100644 --- a/app/controllers/activitypub/base_controller.rb +++ b/app/controllers/activitypub/base_controller.rb @@ -8,4 +8,8 @@ class ActivityPub::BaseController < Api::BaseController def set_cache_headers response.headers['Vary'] = 'Signature' if authorized_fetch_mode? end + + def skip_temporary_suspension_response? + false + end end diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index e62fba7487..00f3d3cba8 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -12,7 +12,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController def show expires_in 3.minutes, public: public_fetch_mode? - render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, skip_activities: true + render_with_cache json: collection_presenter, content_type: 'application/activity+json', serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter end private @@ -20,17 +20,9 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController def set_items case params[:id] when 'featured' - @items = begin - # Because in public fetch mode we cache the response, there would be no - # benefit from performing the check below, since a blocked account or domain - # would likely be served the cache from the reverse proxy anyway - - if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain))) - [] - else - cache_collection(@account.pinned_statuses.not_local_only, Status) - end - end + @items = for_signed_account { cache_collection(@account.pinned_statuses.not_local_only, Status) } + when 'tags' + @items = for_signed_account { @account.featured_tags } when 'devices' @items = @account.devices else @@ -40,7 +32,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController def set_size case params[:id] - when 'featured', 'devices' + when 'featured', 'devices', 'tags' @size = @items.size else not_found @@ -51,7 +43,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController case params[:id] when 'featured' @type = :ordered - when 'devices' + when 'devices', 'tags' @type = :unordered else not_found @@ -66,4 +58,16 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController items: @items ) end + + def for_signed_account + # Because in public fetch mode we cache the response, there would be no + # benefit from performing the check below, since a blocked account or domain + # would likely be served the cache from the reverse proxy anyway + + if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain))) + [] + else + yield + end + end end diff --git a/app/controllers/activitypub/followers_synchronizations_controller.rb b/app/controllers/activitypub/followers_synchronizations_controller.rb new file mode 100644 index 0000000000..5250311058 --- /dev/null +++ b/app/controllers/activitypub/followers_synchronizations_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseController + include SignatureVerification + include AccountOwnedConcern + + before_action :require_signature! + before_action :set_items + before_action :set_cache_headers + + def show + expires_in 0, public: false + render json: collection_presenter, + serializer: ActivityPub::CollectionSerializer, + adapter: ActivityPub::Adapter, + content_type: 'application/activity+json' + end + + private + + def uri_prefix + signed_request_account.uri[/http(s?):\/\/[^\/]+\//] + end + + def set_items + @items = @account.followers.where(Account.arel_table[:uri].matches(uri_prefix + '%', false, true)).pluck(:uri) + end + + def collection_presenter + ActivityPub::CollectionPresenter.new( + id: account_followers_synchronization_url(@account), + type: :ordered, + items: @items + ) + end +end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 0a561e7f0f..92dcb5ac77 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -5,25 +5,26 @@ class ActivityPub::InboxesController < ActivityPub::BaseController include JsonLdHelper include AccountOwnedConcern - before_action :skip_unknown_actor_delete + before_action :skip_unknown_actor_activity before_action :require_signature! skip_before_action :authenticate_user! def create upgrade_account + process_collection_synchronization process_payload head 202 end private - def skip_unknown_actor_delete - head 202 if unknown_deleted_account? + def skip_unknown_actor_activity + head 202 if unknown_affected_account? end - def unknown_deleted_account? + def unknown_affected_account? json = Oj.load(body, mode: :strict) - json.is_a?(Hash) && json['type'] == 'Delete' && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists? + json.is_a?(Hash) && %w(Delete Update).include?(json['type']) && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists? rescue Oj::ParseError false end @@ -32,6 +33,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController params[:account_username].present? end + def skip_temporary_suspension_response? + true + end + def body return @body if defined?(@body) @@ -52,6 +57,19 @@ class ActivityPub::InboxesController < ActivityPub::BaseController DeliveryFailureTracker.reset!(signed_request_account.inbox_url) end + def process_collection_synchronization + raw_params = request.headers['Collection-Synchronization'] + return if raw_params.blank? || ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] == 'true' + + # Re-using the syntax for signature parameters + tree = SignatureParamsParser.new.parse(raw_params) + params = SignatureParamsTransformer.new.apply(tree) + + ActivityPub::PrepareFollowersSynchronizationService.new.call(signed_request_account, params) + rescue Parslet::ParseFailed + Rails.logger.warn 'Error parsing Collection-Synchronization header' + end + def process_payload ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body, @account&.id) end diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index e25a4bc079..5fd735ad6a 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -20,9 +20,9 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController def outbox_presenter if page_requested? ActivityPub::CollectionPresenter.new( - id: account_outbox_url(@account, page_params), + id: outbox_url(page_params), type: :ordered, - part_of: account_outbox_url(@account), + part_of: outbox_url, prev: prev_page, next: next_page, items: @statuses @@ -32,12 +32,20 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController id: account_outbox_url(@account), type: :ordered, size: @account.statuses_count, - first: account_outbox_url(@account, page: true), - last: account_outbox_url(@account, page: true, min_id: 0) + first: outbox_url(page: true), + last: outbox_url(page: true, min_id: 0) ) end end + def outbox_url(**kwargs) + if params[:account_username].present? + account_outbox_url(@account, **kwargs) + else + instance_actor_outbox_url(**kwargs) + end + end + def next_page account_outbox_url(@account, page: true, max_id: @statuses.last.id) if @statuses.size == LIMIT end @@ -49,9 +57,12 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController def set_statuses return unless page_requested? - @statuses = @account.statuses.permitted_for(@account, signed_request_account) - @statuses = @statuses.paginate_by_id(LIMIT, params_slice(:max_id, :min_id, :since_id)) - @statuses = cache_collection(@statuses, Status) + @statuses = cache_collection_paginated_by_id( + @account.statuses.permitted_for(@account, signed_request_account), + Status, + LIMIT, + params_slice(:max_id, :min_id, :since_id) + ) end def page_requested? @@ -61,4 +72,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController def page_params { page: true, max_id: params[:max_id], min_id: params[:min_id] }.compact end + + def set_account + @account = params[:account_username].present? ? Account.find_local!(username_param) : Account.representative + end end diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb index 43bf4e657d..fde6c861f2 100644 --- a/app/controllers/activitypub/replies_controller.rb +++ b/app/controllers/activitypub/replies_controller.rb @@ -31,7 +31,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController end def set_replies - @replies = only_other_accounts? ? Status.where.not(account_id: @account.id) : @account.statuses + @replies = only_other_accounts? ? Status.where.not(account_id: @account.id).joins(:account).merge(Account.without_suspended) : @account.statuses @replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted]) @replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id]) end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 7b17835429..1dd7430e09 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -2,7 +2,7 @@ module Admin class AccountsController < BaseController - before_action :set_account, only: [:show, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject] + before_action :set_account, except: [:index] before_action :require_remote_account!, only: [:redownload] before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject] @@ -14,49 +14,65 @@ module Admin def show authorize @account, :show? + @deletion_request = @account.deletion_request @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) @moderation_notes = @account.targeted_moderation_notes.latest @warnings = @account.targeted_account_warnings.latest.custom + @domain_block = DomainBlock.rule_for(@account.domain) end def memorialize authorize @account, :memorialize? @account.memorialize! log_action :memorialize, @account - redirect_to admin_account_path(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.memorialized_msg', username: @account.acct) end def enable authorize @account.user, :enable? @account.user.enable! log_action :enable, @account.user - redirect_to admin_account_path(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.enabled_msg', username: @account.acct) end def approve authorize @account.user, :approve? @account.user.approve! - redirect_to admin_pending_accounts_path + redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.approved_msg', username: @account.acct) end def reject authorize @account.user, :reject? - SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false) - redirect_to admin_pending_accounts_path + DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false) + redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct) + end + + def destroy + authorize @account, :destroy? + Admin::AccountDeletionWorker.perform_async(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.destroyed_msg', username: @account.acct) + end + + def unsensitive + authorize @account, :unsensitive? + @account.unsensitize! + log_action :unsensitive, @account + redirect_to admin_account_path(@account.id) end def unsilence authorize @account, :unsilence? @account.unsilence! log_action :unsilence, @account - redirect_to admin_account_path(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unsilenced_msg', username: @account.acct) end def unsuspend authorize @account, :unsuspend? @account.unsuspend! + Admin::UnsuspensionWorker.perform_async(@account.id) log_action :unsuspend, @account - redirect_to admin_account_path(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unsuspended_msg', username: @account.acct) end def redownload @@ -65,7 +81,7 @@ module Admin @account.update!(last_webfingered_at: nil) ResolveAccountService.new.call(@account) - redirect_to admin_account_path(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.redownloaded_msg', username: @account.acct) end def remove_avatar @@ -76,7 +92,7 @@ module Admin log_action :remove_avatar, @account.user - redirect_to admin_account_path(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_avatar_msg', username: @account.acct) end def remove_header @@ -87,7 +103,7 @@ module Admin log_action :remove_header, @account.user - redirect_to admin_account_path(@account.id) + redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_header_msg', username: @account.acct) end private diff --git a/app/controllers/admin/announcements_controller.rb b/app/controllers/admin/announcements_controller.rb index 494fd13d0f..351b9a9910 100644 --- a/app/controllers/admin/announcements_controller.rb +++ b/app/controllers/admin/announcements_controller.rb @@ -71,7 +71,7 @@ class Admin::AnnouncementsController < Admin::BaseController private def set_announcements - @announcements = AnnouncementFilter.new(filter_params).results.page(params[:page]) + @announcements = AnnouncementFilter.new(filter_params).results.reverse_chronological.page(params[:page]) end def set_announcement diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 4116f99f45..a00d7ed96d 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -4,6 +4,7 @@ require 'sidekiq/api' module Admin class DashboardController < BaseController def index + @system_checks = Admin::SystemCheck.perform @users_count = User.count @pending_users_count = User.pending.count @registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0 @@ -35,7 +36,6 @@ module Admin @profile_directory = Setting.profile_directory @timeline_preview = Setting.timeline_preview @keybase_integration = Setting.enable_keybase - @spam_check_enabled = Setting.spam_check_enabled @trends_enabled = Setting.trends end diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 74a36b79ca..b140c454c1 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -22,13 +22,14 @@ module Admin if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block) @domain_block.save flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety - @domain_block.errors[:domain].clear + @domain_block.errors.delete(:domain) render :new else if existing_domain_block.present? @domain_block = existing_domain_block @domain_block.update(resource_params) end + if @domain_block.save DomainBlockWorker.perform_async(@domain_block.id) log_action :create, @domain_block @@ -40,7 +41,7 @@ module Admin end def update - authorize :domain_block, :create? + authorize :domain_block, :update? @domain_block.update(update_params) @@ -48,7 +49,7 @@ module Admin if @domain_block.save DomainBlockWorker.perform_async(@domain_block.id, severity_changed) - log_action :create, @domain_block + log_action :update, @domain_block redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg') else render :edit @@ -73,11 +74,11 @@ module Admin end def update_params - params.require(:domain_block).permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment) + params.require(:domain_block).permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate) end def resource_params - params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment) + params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate) end end end diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb index c259197262..f7bdfb0c5f 100644 --- a/app/controllers/admin/email_domain_blocks_controller.rb +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -27,7 +27,7 @@ module Admin ips = [] Resolv::DNS.open do |dns| - dns.timeouts = 1 + dns.timeouts = 5 hostnames = dns.getresources(@email_domain_block.domain, Resolv::DNS::Resource::IN::MX).to_a.map { |e| e.exchange.to_s } diff --git a/app/controllers/admin/follow_recommendations_controller.rb b/app/controllers/admin/follow_recommendations_controller.rb new file mode 100644 index 0000000000..e3eac62b36 --- /dev/null +++ b/app/controllers/admin/follow_recommendations_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Admin + class FollowRecommendationsController < BaseController + before_action :set_language + + def show + authorize :follow_recommendation, :show? + + @form = Form::AccountBatch.new + @accounts = filtered_follow_recommendations + end + + def update + @form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button)) + @form.save + rescue ActionController::ParameterMissing + # Do nothing + ensure + redirect_to admin_follow_recommendations_path(filter_params) + end + + private + + def set_language + @language = follow_recommendation_filter.language + end + + def filtered_follow_recommendations + follow_recommendation_filter.results + end + + def follow_recommendation_filter + @follow_recommendation_filter ||= FollowRecommendationFilter.new(filter_params) + end + + def form_account_batch_params + params.require(:form_account_batch).permit(:action, account_ids: []) + end + + def filter_params + params.slice(*FollowRecommendationFilter::KEYS).permit(*FollowRecommendationFilter::KEYS) + end + + def action_from_button + if params[:suppress] + 'suppress_follow_recommendation' + elsif params[:unsuppress] + 'unsuppress_follow_recommendation' + end + end + end +end diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index 1790becbf2..b5918d231c 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -2,65 +2,31 @@ module Admin class InstancesController < BaseController - before_action :set_domain_block, only: :show - before_action :set_domain_allow, only: :show + before_action :set_instances, only: :index before_action :set_instance, only: :show def index authorize :instance, :index? - - @instances = ordered_instances end def show authorize :instance, :show? - - @following_count = Follow.where(account: Account.where(domain: params[:id])).count - @followers_count = Follow.where(target_account: Account.where(domain: params[:id])).count - @reports_count = Report.where(target_account: Account.where(domain: params[:id])).count - @blocks_count = Block.where(target_account: Account.where(domain: params[:id])).count - @available = DeliveryFailureTracker.available?(params[:id]) - @media_storage = MediaAttachment.where(account: Account.where(domain: params[:id])).sum(:file_file_size) - @private_comment = @domain_block&.private_comment - @public_comment = @domain_block&.public_comment end private - def set_domain_block - @domain_block = DomainBlock.rule_for(params[:id]) - end - - def set_domain_allow - @domain_allow = DomainAllow.rule_for(params[:id]) - end - def set_instance - resource = Account.by_domain_accounts.find_by(domain: params[:id]) - resource ||= @domain_block - resource ||= @domain_allow + @instance = Instance.find(params[:id]) + end - if resource - @instance = Instance.new(resource) - else - not_found - end + def set_instances + @instances = filtered_instances.page(params[:page]) end def filtered_instances InstanceFilter.new(whitelist_mode? ? { allowed: true } : filter_params).results end - def paginated_instances - filtered_instances.page(params[:page]) - end - - helper_method :paginated_instances - - def ordered_instances - paginated_instances.map { |resource| Instance.new(resource) } - end - def filter_params params.slice(*InstanceFilter::KEYS).permit(*InstanceFilter::KEYS) end diff --git a/app/controllers/admin/ip_blocks_controller.rb b/app/controllers/admin/ip_blocks_controller.rb new file mode 100644 index 0000000000..92b8b0d2b8 --- /dev/null +++ b/app/controllers/admin/ip_blocks_controller.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Admin + class IpBlocksController < BaseController + def index + authorize :ip_block, :index? + + @ip_blocks = IpBlock.page(params[:page]) + @form = Form::IpBlockBatch.new + end + + def new + authorize :ip_block, :create? + + @ip_block = IpBlock.new(ip: '', severity: :no_access, expires_in: 1.year) + end + + def create + authorize :ip_block, :create? + + @ip_block = IpBlock.new(resource_params) + + if @ip_block.save + log_action :create, @ip_block + redirect_to admin_ip_blocks_path, notice: I18n.t('admin.ip_blocks.created_msg') + else + render :new + end + end + + def batch + @form = Form::IpBlockBatch.new(form_ip_block_batch_params.merge(current_account: current_account, action: action_from_button)) + @form.save + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.ip_blocks.no_ip_block_selected') + rescue Mastodon::NotPermittedError + flash[:alert] = I18n.t('admin.custom_emojis.not_permitted') + ensure + redirect_to admin_ip_blocks_path + end + + private + + def resource_params + params.require(:ip_block).permit(:ip, :severity, :comment, :expires_in) + end + + def action_from_button + 'delete' if params[:delete] + end + + def form_ip_block_batch_params + params.require(:form_ip_block_batch).permit(ip_block_ids: []) + end + end +end diff --git a/app/controllers/admin/rules_controller.rb b/app/controllers/admin/rules_controller.rb new file mode 100644 index 0000000000..f3bed3ad8e --- /dev/null +++ b/app/controllers/admin/rules_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Admin + class RulesController < BaseController + before_action :set_rule, except: [:index, :create] + + def index + authorize :rule, :index? + + @rules = Rule.ordered + @rule = Rule.new + end + + def create + authorize :rule, :create? + + @rule = Rule.new(resource_params) + + if @rule.save + redirect_to admin_rules_path + else + @rules = Rule.ordered + render :index + end + end + + def edit + authorize @rule, :update? + end + + def update + authorize @rule, :update? + + if @rule.update(resource_params) + redirect_to admin_rules_path + else + render :edit + end + end + + def destroy + authorize @rule, :destroy? + + @rule.discard + + redirect_to admin_rules_path + end + + private + + def set_rule + @rule = Rule.find(params[:id]) + end + + def resource_params + params.require(:rule).permit(:text, :priority) + end + end +end diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index 6501950346..d7c192f0d6 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -14,7 +14,7 @@ module Admin @statuses = @account.statuses.where(visibility: [:public, :unlisted]) if params[:media] - account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct + account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).group(:status_id) @statuses.merge!(Status.where(id: account_media_status_ids)) end diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index 59df4470e6..eed4feea2f 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -59,8 +59,8 @@ module Admin .where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day))) .joins(:account) .group('accounts.domain') - .reorder('statuses_count desc') - .pluck('accounts.domain, count(*) AS statuses_count') + .reorder(statuses_count: :desc) + .pluck(Arel.sql('accounts.domain, count(*) AS statuses_count')) end def set_counters diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 045e7dd266..85f4cc7681 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -40,7 +40,7 @@ class Api::BaseController < ApplicationController render json: { error: 'This action is not allowed' }, status: 403 end - rescue_from Mastodon::RaceConditionError do + rescue_from Mastodon::RaceConditionError, Seahorse::Client::NetworkingError, Stoplight::Error::RedLight do render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 end @@ -71,6 +71,7 @@ class Api::BaseController < ApplicationController def limit_param(default_limit) return default_limit unless params[:limit] + [params[:limit].to_i.abs, default_limit * 2].min end @@ -95,14 +96,14 @@ class Api::BaseController < ApplicationController def require_user! if !current_user render json: { error: 'This method requires an authenticated user' }, status: 422 - elsif current_user.disabled? - render json: { error: 'Your login is currently disabled' }, status: 403 elsif !current_user.confirmed? render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403 elsif !current_user.approved? render json: { error: 'Your login is currently pending approval' }, status: 403 + elsif !current_user.functional? + render json: { error: 'Your login is currently disabled' }, status: 403 else - set_user_activity + update_user_sign_in end end diff --git a/app/controllers/api/v1/accounts/featured_tags_controller.rb b/app/controllers/api/v1/accounts/featured_tags_controller.rb new file mode 100644 index 0000000000..0101fb469b --- /dev/null +++ b/app/controllers/api/v1/accounts/featured_tags_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::FeaturedTagsController < Api::BaseController + before_action :set_account + before_action :set_featured_tags + + respond_to :json + + def index + render json: @featured_tags, each_serializer: REST::FeaturedTagSerializer + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def set_featured_tags + @featured_tags = @account.suspended? ? [] : @account.featured_tags + end +end diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb index 2277067c9f..a665863ebf 100644 --- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb @@ -25,7 +25,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController end def hide_results? - (@account.hides_followers? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account)) + @account.suspended? || (@account.hides_followers? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account)) end def default_accounts diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb index 93d4bd3a4a..7d885a212f 100644 --- a/app/controllers/api/v1/accounts/following_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb @@ -25,7 +25,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController end def hide_results? - (@account.hides_following? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account)) + @account.suspended? || (@account.hides_following? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account)) end def default_accounts diff --git a/app/controllers/api/v1/accounts/identity_proofs_controller.rb b/app/controllers/api/v1/accounts/identity_proofs_controller.rb index 8dad6fee96..4b5f6902c7 100644 --- a/app/controllers/api/v1/accounts/identity_proofs_controller.rb +++ b/app/controllers/api/v1/accounts/identity_proofs_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Accounts::IdentityProofsController < Api::BaseController before_action :set_account def index - @proofs = @account.identity_proofs.active + @proofs = @account.suspended? ? [] : @account.identity_proofs.active render json: @proofs, each_serializer: REST::IdentityProofSerializer end diff --git a/app/controllers/api/v1/accounts/lists_controller.rb b/app/controllers/api/v1/accounts/lists_controller.rb index ccb751f8f7..c92f1f8a08 100644 --- a/app/controllers/api/v1/accounts/lists_controller.rb +++ b/app/controllers/api/v1/accounts/lists_controller.rb @@ -6,7 +6,7 @@ class Api::V1::Accounts::ListsController < Api::BaseController before_action :set_account def index - @lists = @account.lists.where(account: current_account) + @lists = @account.suspended? ? [] : @account.lists.where(account: current_account) render json: @lists, each_serializer: REST::ListSerializer end diff --git a/app/controllers/api/v1/accounts/lookup_controller.rb b/app/controllers/api/v1/accounts/lookup_controller.rb new file mode 100644 index 0000000000..aee6be18a9 --- /dev/null +++ b/app/controllers/api/v1/accounts/lookup_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::LookupController < Api::BaseController + before_action -> { authorize_if_got_token! :read, :'read:accounts' } + before_action :set_account + + def show + render json: @account, serializer: REST::AccountSerializer + end + + private + + def set_account + @account = ResolveAccountService.new.call(params[:acct], skip_webfinger: true) || raise(ActiveRecord::RecordNotFound) + end +end diff --git a/app/controllers/api/v1/accounts/notes_controller.rb b/app/controllers/api/v1/accounts/notes_controller.rb new file mode 100644 index 0000000000..032e807d11 --- /dev/null +++ b/app/controllers/api/v1/accounts/notes_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::NotesController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write, :'write:accounts' } + before_action :require_user! + before_action :set_account + + def create + if params[:comment].blank? + AccountNote.find_by(account: current_account, target_account: @account)&.destroy + else + @note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account) + @note.comment = params[:comment] + @note.save! if @note.changed? + end + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end + + def relationships_presenter + AccountRelationshipsPresenter.new([@account.id], current_user.account_id) + end +end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 114ee0a824..92ccb80615 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -18,14 +18,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def load_statuses - cached_account_statuses + @account.suspended? ? [] : cached_account_statuses end def cached_account_statuses - cache_collection account_statuses, Status - end - - def account_statuses statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses statuses.merge!(only_media_scope) if truthy_param?(:only_media) @@ -33,7 +29,12 @@ class Api::V1::Accounts::StatusesController < Api::BaseController statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs) statuses.merge!(hashtag_scope) if params[:tagged].present? - statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id)) + cache_collection_paginated_by_id( + statuses, + Status, + limit_param(DEFAULT_STATUSES_LIMIT), + params_slice(:max_id, :since_id, :min_id) + ) end def permitted_account_statuses @@ -41,17 +42,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def only_media_scope - Status.where(id: account_media_status_ids) - end - - def account_media_status_ids - # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids. - # Also, Avoid getting slow by not narrowing down by `statuses.account_id`. - # When narrowing down by `statuses.account_id`, `index_statuses_20180106` will be used - # and the table will be joined by `Merge Semi Join`, so the query will be slow. - @account.statuses.joins(:media_attachments).merge(@account.media_attachments).permitted_for(@account, current_account) - .paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) - .reorder(id: :desc).distinct(:id).pluck(:id) + Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id) end def pinned_scope diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 0080faf330..996f1b79bf 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -9,7 +9,6 @@ class Api::V1::AccountsController < Api::BaseController before_action :require_user!, except: [:show, :create] before_action :set_account, except: [:create] - before_action :check_account_suspension, only: [:show] before_action :check_enabled_registrations, only: [:create] skip_before_action :require_authenticated_user!, only: :create @@ -21,19 +20,20 @@ class Api::V1::AccountsController < Api::BaseController end def create - token = AppSignUpService.new.call(doorkeeper_token.application, account_params) + token = AppSignUpService.new.call(doorkeeper_token.application, request.remote_ip, account_params) response = Doorkeeper::OAuth::TokenResponse.new(token) headers.merge!(response.headers) self.response_body = Oj.dump(response.body) self.status = response.status + rescue ActiveRecord::RecordInvalid => e + render json: ValidationErrorFormatter.new(e, :'account.username' => :username, :'invite_request.text' => :reason).as_json, status: :unprocessable_entity end def follow - FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs), with_rate_limit: true) - - options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } } + follow = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, with_rate_limit: true) + options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify? } }, requested_map: { @account.id => false } } render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) end @@ -44,7 +44,7 @@ class Api::V1::AccountsController < Api::BaseController end def mute - MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications)) + MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), duration: (params[:duration]&.to_i || 0)) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end @@ -73,10 +73,6 @@ class Api::V1::AccountsController < Api::BaseController AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options) end - def check_account_suspension - gone if @account.suspended? - end - def account_params params.permit(:username, :email, :password, :agreement, :locale, :reason) end diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb index c35ea5ab25..63cc521ed0 100644 --- a/app/controllers/api/v1/admin/accounts_controller.rb +++ b/app/controllers/api/v1/admin/accounts_controller.rb @@ -22,6 +22,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController active pending disabled + sensitized silenced suspended username @@ -58,7 +59,20 @@ class Api::V1::Admin::AccountsController < Api::BaseController def reject authorize @account.user, :reject? - SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false) + DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false) + render json: @account, serializer: REST::Admin::AccountSerializer + end + + def destroy + authorize @account, :destroy? + Admin::AccountDeletionWorker.perform_async(@account.id) + render json: @account, serializer: REST::Admin::AccountSerializer + end + + def unsensitive + authorize @account, :unsensitive? + @account.unsensitize! + log_action :unsensitive, @account render json: @account, serializer: REST::Admin::AccountSerializer end @@ -72,6 +86,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController def unsuspend authorize @account, :unsuspend? @account.unsuspend! + Admin::UnsuspensionWorker.perform_async(@account.id) log_action :unsuspend, @account render json: @account, serializer: REST::Admin::AccountSerializer end @@ -79,7 +94,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController private def set_accounts - @accounts = filtered_accounts.order(id: :desc).includes(user: [:invite_request, :invite]).paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + @accounts = filtered_accounts.order(id: :desc).includes(user: [:invite_request, :invite]).to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end def set_account diff --git a/app/controllers/api/v1/admin/reports_controller.rb b/app/controllers/api/v1/admin/reports_controller.rb index 1d48d3160f..c8f4cd8d80 100644 --- a/app/controllers/api/v1/admin/reports_controller.rb +++ b/app/controllers/api/v1/admin/reports_controller.rb @@ -63,7 +63,7 @@ class Api::V1::Admin::ReportsController < Api::BaseController private def set_reports - @reports = filtered_reports.order(id: :desc).with_accounts.paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + @reports = filtered_reports.order(id: :desc).with_accounts.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end def set_report diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index a2baeef900..586cdfca9d 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -18,6 +18,8 @@ class Api::V1::BlocksController < Api::BaseController def paginated_blocks @paginated_blocks ||= Block.eager_load(target_account: :account_stat) + .joins(:target_account) + .merge(Account.without_suspended) .where(account: current_account) .paginate_by_max_id( limit_param(DEFAULT_ACCOUNTS_LIMIT), diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb index c15212f0a9..aa3fb88f08 100644 --- a/app/controllers/api/v1/bookmarks_controller.rb +++ b/app/controllers/api/v1/bookmarks_controller.rb @@ -17,14 +17,11 @@ class Api::V1::BookmarksController < Api::BaseController end def cached_bookmarks - cache_collection( - Status.reorder(nil).joins(:bookmarks).merge(results), - Status - ) + cache_collection(results.map(&:status), Status) end def results - @_results ||= account_bookmarks.paginate_by_id( + @_results ||= account_bookmarks.eager_load(:status).to_a_paginated_by_id( limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id) ) diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb index bc80133794..6c75834037 100644 --- a/app/controllers/api/v1/conversations_controller.rb +++ b/app/controllers/api/v1/conversations_controller.rb @@ -32,7 +32,7 @@ class Api::V1::ConversationsController < Api::BaseController def paginated_conversations AccountConversation.where(account: current_account) - .paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + .to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end def insert_pagination_headers diff --git a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb index c764915e57..68cf4384f7 100644 --- a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb +++ b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb @@ -26,7 +26,7 @@ class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController end def set_encrypted_messages - @encrypted_messages = @current_device.encrypted_messages.paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) + @encrypted_messages = @current_device.encrypted_messages.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) end def insert_pagination_headers diff --git a/app/controllers/api/v1/crypto/keys/claims_controller.rb b/app/controllers/api/v1/crypto/keys/claims_controller.rb index 34b21a3809..f9d202d67b 100644 --- a/app/controllers/api/v1/crypto/keys/claims_controller.rb +++ b/app/controllers/api/v1/crypto/keys/claims_controller.rb @@ -12,7 +12,7 @@ class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController private def set_claim_results - @claim_results = devices.map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) }.compact + @claim_results = devices.filter_map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) } end def resource_params diff --git a/app/controllers/api/v1/crypto/keys/queries_controller.rb b/app/controllers/api/v1/crypto/keys/queries_controller.rb index 0851d797d3..e6ce9f9192 100644 --- a/app/controllers/api/v1/crypto/keys/queries_controller.rb +++ b/app/controllers/api/v1/crypto/keys/queries_controller.rb @@ -17,7 +17,7 @@ class Api::V1::Crypto::Keys::QueriesController < Api::BaseController end def set_query_results - @query_results = @accounts.map { |account| ::Keys::QueryService.new.call(account) }.compact + @query_results = @accounts.filter_map { |account| ::Keys::QueryService.new.call(account) } end def account_ids diff --git a/app/controllers/api/v1/emails/confirmations_controller.rb b/app/controllers/api/v1/emails/confirmations_controller.rb new file mode 100644 index 0000000000..4a7aa9c32a --- /dev/null +++ b/app/controllers/api/v1/emails/confirmations_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class Api::V1::Emails::ConfirmationsController < Api::BaseController + before_action :doorkeeper_authorize! + before_action :require_user_owned_by_application! + + def create + if !current_user.confirmed? && current_user.unconfirmed_email.present? + current_user.update!(email: params[:email]) if params.key?(:email) + current_user.resend_confirmation_instructions + end + + render_empty + end + + private + + def require_user_owned_by_application! + render json: { error: 'This method is only available to the application the user originally signed-up with' }, status: :forbidden unless current_user && current_user.created_by_application_id == doorkeeper_token.application_id + end +end diff --git a/app/controllers/api/v1/endorsements_controller.rb b/app/controllers/api/v1/endorsements_controller.rb index c87dbc4ce8..9e80f468a7 100644 --- a/app/controllers/api/v1/endorsements_controller.rb +++ b/app/controllers/api/v1/endorsements_controller.rb @@ -25,7 +25,7 @@ class Api::V1::EndorsementsController < Api::BaseController end def endorsed_accounts - current_account.endorsed_accounts.includes(:account_stat) + current_account.endorsed_accounts.includes(:account_stat).without_suspended end def insert_pagination_headers diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index 3e242905da..21836bc170 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -17,14 +17,11 @@ class Api::V1::FavouritesController < Api::BaseController end def cached_favourites - cache_collection( - Status.reorder(nil).joins(:favourites).merge(results), - Status - ) + cache_collection(results.map(&:status), Status) end def results - @_results ||= account_favourites.paginate_by_id( + @_results ||= account_favourites.eager_load(:status).to_a_paginated_by_id( limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id) ) diff --git a/app/controllers/api/v1/featured_tags/suggestions_controller.rb b/app/controllers/api/v1/featured_tags/suggestions_controller.rb index 8c1b81a0f0..75545d3c7f 100644 --- a/app/controllers/api/v1/featured_tags/suggestions_controller.rb +++ b/app/controllers/api/v1/featured_tags/suggestions_controller.rb @@ -3,15 +3,15 @@ class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index before_action :require_user! - before_action :set_most_used_tags, only: :index + before_action :set_recently_used_tags, only: :index def index - render json: @most_used_tags, each_serializer: REST::TagSerializer + render json: @recently_used_tags, each_serializer: REST::TagSerializer end private - def set_most_used_tags - @most_used_tags = Tag.most_used(current_account).where.not(id: current_account.featured_tags).limit(10) + def set_recently_used_tags + @recently_used_tags = Tag.recently_used(current_account).where.not(id: current_account.featured_tags).limit(10) end end diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb index 0ee6e531f0..b34c76f29e 100644 --- a/app/controllers/api/v1/follow_requests_controller.rb +++ b/app/controllers/api/v1/follow_requests_controller.rb @@ -13,7 +13,7 @@ class Api::V1::FollowRequestsController < Api::BaseController def authorize AuthorizeFollowService.new.call(account, current_account) - NotifyService.new.call(current_account, Follow.find_by(account: account, target_account: current_account)) + NotifyService.new.call(current_account, :follow, Follow.find_by(account: account, target_account: current_account)) render json: account, serializer: REST::RelationshipSerializer, relationships: relationships end @@ -37,7 +37,7 @@ class Api::V1::FollowRequestsController < Api::BaseController end def default_accounts - Account.includes(:follow_requests, :account_stat).references(:follow_requests) + Account.without_suspended.includes(:follow_requests, :account_stat).references(:follow_requests) end def paginated_follow_requests diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb index 9fa4409357..2877fec52d 100644 --- a/app/controllers/api/v1/instances/peers_controller.rb +++ b/app/controllers/api/v1/instances/peers_controller.rb @@ -8,7 +8,7 @@ class Api::V1::Instances::PeersController < Api::BaseController def index expires_in 1.day, public: true - render_with_cache(expires_in: 1.day) { Account.remote.domains } + render_with_cache(expires_in: 1.day) { Instance.where.not(domain: DomainBlock.select(:domain)).pluck(:domain) } end private diff --git a/app/controllers/api/v1/instances/rules_controller.rb b/app/controllers/api/v1/instances/rules_controller.rb new file mode 100644 index 0000000000..93cf3c7594 --- /dev/null +++ b/app/controllers/api/v1/instances/rules_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Api::V1::Instances::RulesController < Api::BaseController + skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + + before_action :set_rules + + def index + render json: @rules, each_serializer: REST::RuleSerializer + end + + private + + def set_rules + @rules = Rule.ordered + end +end diff --git a/app/controllers/api/v1/lists/accounts_controller.rb b/app/controllers/api/v1/lists/accounts_controller.rb index 23078263e7..b66ea9bfe6 100644 --- a/app/controllers/api/v1/lists/accounts_controller.rb +++ b/app/controllers/api/v1/lists/accounts_controller.rb @@ -37,9 +37,9 @@ class Api::V1::Lists::AccountsController < Api::BaseController def load_accounts if unlimited? - @list.accounts.includes(:account_stat).all + @list.accounts.without_suspended.includes(:account_stat).all else - @list.accounts.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + @list.accounts.without_suspended.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) end end diff --git a/app/controllers/api/v1/markers_controller.rb b/app/controllers/api/v1/markers_controller.rb index 28c2ec7916..867e6facf4 100644 --- a/app/controllers/api/v1/markers_controller.rb +++ b/app/controllers/api/v1/markers_controller.rb @@ -7,7 +7,7 @@ class Api::V1::MarkersController < Api::BaseController before_action :require_user! def index - @markers = current_user.markers.where(timeline: Array(params[:timeline])).each_with_object({}) { |marker, h| h[marker.timeline] = marker } + @markers = current_user.markers.where(timeline: Array(params[:timeline])).index_by(&:timeline) render json: serialize_map(@markers) end diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index 0bb3d0d27b..a2a919a3e6 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -39,7 +39,7 @@ class Api::V1::MediaController < Api::BaseController end def media_attachment_params - params.permit(:file, :description, :focus) + params.permit(:file, :thumbnail, :description, :focus) end def file_type_error diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index 5dc047b43d..fd52511d7e 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -6,27 +6,20 @@ class Api::V1::MutesController < Api::BaseController after_action :insert_pagination_headers def index - @data = @accounts = load_accounts - render json: @accounts, each_serializer: REST::AccountSerializer + @accounts = load_accounts + render json: @accounts, each_serializer: REST::MutedAccountSerializer end - def details - @data = @mutes = load_mutes - render json: @mutes, each_serializer: REST::MuteSerializer - end - private def load_accounts paginated_mutes.map(&:target_account) end - def load_mutes - paginated_mutes.includes(:account, :target_account).to_a - end - def paginated_mutes @paginated_mutes ||= Mute.eager_load(:target_account) + .joins(:target_account) + .merge(Account.without_suspended) .where(account: current_account) .paginate_by_max_id( limit_param(DEFAULT_ACCOUNTS_LIMIT), @@ -41,34 +34,26 @@ class Api::V1::MutesController < Api::BaseController def next_path if records_continue? - url_for pagination_params(max_id: pagination_max_id) + api_v1_mutes_url pagination_params(max_id: pagination_max_id) end end def prev_path - unless @data.empty? - url_for pagination_params(since_id: pagination_since_id) + unless paginated_mutes.empty? + api_v1_mutes_url pagination_params(since_id: pagination_since_id) end end def pagination_max_id - if params[:action] == "details" - @mutes.last.id - else - paginated_mutes.last.id - end + paginated_mutes.last.id end def pagination_since_id - if params[:action] == "details" - @mutes.first.id - else - paginated_mutes.first.id - end + paginated_mutes.first.id end def records_continue? - @data.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + paginated_mutes.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) end def pagination_params(core_params) diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 9dce9b8074..eefd28d455 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -14,7 +14,7 @@ class Api::V1::NotificationsController < Api::BaseController end def show - @notification = current_account.notifications.find(params[:id]) + @notification = current_account.notifications.without_suspended.find(params[:id]) render json: @notification, serializer: REST::NotificationSerializer end @@ -40,18 +40,17 @@ class Api::V1::NotificationsController < Api::BaseController private def load_notifications - cache_collection paginated_notifications, Notification - end - - def paginated_notifications - browserable_account_notifications.paginate_by_id( + notifications = browserable_account_notifications.includes(from_account: :account_stat).to_a_paginated_by_id( limit_param(DEFAULT_NOTIFICATIONS_LIMIT), params_slice(:max_id, :since_id, :min_id) ) + Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses| + cache_collection(target_statuses, Status) + end end def browserable_account_notifications - current_account.notifications.browserable(exclude_types, from_account) + current_account.notifications.without_suspended.browserable(exclude_types, from_account) end def target_statuses_from_notifications diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb index d34b333eb3..47f2e6440c 100644 --- a/app/controllers/api/v1/push/subscriptions_controller.rb +++ b/app/controllers/api/v1/push/subscriptions_controller.rb @@ -3,13 +3,13 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController before_action -> { doorkeeper_authorize! :push } before_action :require_user! - before_action :set_web_push_subscription - before_action :check_web_push_subscription, only: [:show, :update] + before_action :set_push_subscription + before_action :check_push_subscription, only: [:show, :update] def create - @web_subscription&.destroy! + @push_subscription&.destroy! - @web_subscription = ::Web::PushSubscription.create!( + @push_subscription = Web::PushSubscription.create!( endpoint: subscription_params[:endpoint], key_p256dh: subscription_params[:keys][:p256dh], key_auth: subscription_params[:keys][:auth], @@ -18,31 +18,31 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController access_token_id: doorkeeper_token.id ) - render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer + render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer end def show - render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer + render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer end def update - @web_subscription.update!(data: data_params) - render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer + @push_subscription.update!(data: data_params) + render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer end def destroy - @web_subscription&.destroy! + @push_subscription&.destroy! render_empty end private - def set_web_push_subscription - @web_subscription = ::Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id) + def set_push_subscription + @push_subscription = Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id) end - def check_web_push_subscription - not_found if @web_subscription.nil? + def check_push_subscription + not_found if @push_subscription.nil? end def subscription_params @@ -52,6 +52,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController def data_params return {} if params[:data].blank? - params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll]) + params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status]) end end diff --git a/app/controllers/api/v1/scheduled_statuses_controller.rb b/app/controllers/api/v1/scheduled_statuses_controller.rb index 9950296f3b..f90642a738 100644 --- a/app/controllers/api/v1/scheduled_statuses_controller.rb +++ b/app/controllers/api/v1/scheduled_statuses_controller.rb @@ -32,7 +32,7 @@ class Api::V1::ScheduledStatusesController < Api::BaseController private def set_statuses - @statuses = current_account.scheduled_statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id)) + @statuses = current_account.scheduled_statuses.to_a_paginated_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id)) end def set_status diff --git a/app/controllers/api/v1/statuses/bookmarks_controller.rb b/app/controllers/api/v1/statuses/bookmarks_controller.rb index 3954af3c9b..19963c002a 100644 --- a/app/controllers/api/v1/statuses/bookmarks_controller.rb +++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Statuses::BookmarksController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:bookmarks' } before_action :require_user! - before_action :set_status + before_action :set_status, only: [:create] def create current_account.bookmarks.find_or_create_by!(account: current_account, status: @status) @@ -13,10 +13,20 @@ class Api::V1::Statuses::BookmarksController < Api::BaseController end def destroy - bookmark = current_account.bookmarks.find_by(status: @status) + bookmark = current_account.bookmarks.find_by(status_id: params[:status_id]) + + if bookmark + @status = bookmark.status + else + @status = Status.find(params[:status_id]) + authorize @status, :show? + end + bookmark&.destroy! render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, bookmarks_map: { @status.id => false }) + rescue Mastodon::NotPermittedError + not_found end private diff --git a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb index 8229786d6c..2b614a8375 100644 --- a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb @@ -22,6 +22,7 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController def default_accounts Account + .without_suspended .includes(:favourites, :account_stat) .references(:favourites) .where(favourites: { status_id: @status.id }) diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb index 7afa822ed8..2e21ce6a06 100644 --- a/app/controllers/api/v1/statuses/favourites_controller.rb +++ b/app/controllers/api/v1/statuses/favourites_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:favourites' } before_action :require_user! - before_action :set_status + before_action :set_status, only: [:create] def create FavouriteService.new.call(current_account, @status) @@ -13,8 +13,19 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController end def destroy - UnfavouriteWorker.perform_async(current_account.id, @status.id) + fav = current_account.favourites.find_by(status_id: params[:status_id]) + + if fav + @status = fav.status + UnfavouriteWorker.perform_async(current_account.id, @status.id) + else + @status = Status.find(params[:status_id]) + authorize @status, :show? + end + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }) + rescue Mastodon::NotPermittedError + not_found end private diff --git a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb index 6c9e49d903..24db30fcc0 100644 --- a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb @@ -21,7 +21,7 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController end def default_accounts - Account.includes(:statuses, :account_stat).references(:statuses) + Account.without_suspended.includes(:statuses, :account_stat).references(:statuses) end def paginated_statuses diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb index 7fa774a4d7..1be15a5a43 100644 --- a/app/controllers/api/v1/statuses/reblogs_controller.rb +++ b/app/controllers/api/v1/statuses/reblogs_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:statuses' } before_action :require_user! - before_action :set_reblog + before_action :set_reblog, only: [:create] override_rate_limit_headers :create, family: :statuses @@ -16,15 +16,21 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController end def destroy - @status = current_account.statuses.find_by(reblog_of_id: @reblog.id) + @status = current_account.statuses.find_by(reblog_of_id: params[:status_id]) if @status authorize @status, :unreblog? @status.discard RemovalWorker.perform_async(@status.id) + @reblog = @status.reblog + else + @reblog = Status.find(params[:status_id]) + authorize @reblog, :show? end render json: @reblog, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, reblogs_map: { @reblog.id => false }) + rescue Mastodon::NotPermittedError + not_found end private diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index b3edce6760..c8529318ff 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -58,6 +58,7 @@ class Api::V1::StatusesController < Api::BaseController @status.discard RemovalWorker.perform_async(@status.id, redraft: true) + @status.account.statuses_count = @status.account.statuses_count - 1 render json: @status, serializer: REST::StatusSerializer, source_requested: true end diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb index 52054160df..b2788cc76c 100644 --- a/app/controllers/api/v1/suggestions_controller.rb +++ b/app/controllers/api/v1/suggestions_controller.rb @@ -19,6 +19,6 @@ class Api::V1::SuggestionsController < Api::BaseController private def set_accounts - @accounts = PotentialFriendshipTracker.get(current_account.id, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT)) + @accounts = PotentialFriendshipTracker.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT)) end end diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index c6e7854d93..493fe4776a 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -16,30 +16,32 @@ class Api::V1::Timelines::PublicController < Api::BaseController end def load_statuses - cached_public_statuses + cached_public_statuses_page end - def cached_public_statuses - cache_collection public_statuses, Status + def cached_public_statuses_page + cache_collection(public_statuses, Status) end def public_statuses - statuses = public_timeline_statuses.paginate_by_id( + public_feed.get( limit_param(DEFAULT_STATUSES_LIMIT), - params_slice(:max_id, :since_id, :min_id) + params[:max_id], + params[:since_id], + params[:min_id] ) - - if truthy_param?(:only_media) - # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids. - status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id) - statuses.where(id: status_ids) - else - statuses - end end - def public_timeline_statuses - Status.as_public_timeline(current_account, truthy_param?(:remote) ? :remote : truthy_param?(:local)) + def public_feed + PublicFeed.new( + current_account, + local: truthy_param?(:local), + remote: truthy_param?(:remote), + only_media: truthy_param?(:only_media), + allow_local_only: truthy_param?(:allow_local_only), + with_replies: Setting.show_replies_in_public_timelines, + with_reblogs: Setting.show_reblogs_in_public_timelines, + ) end def insert_pagination_headers @@ -47,7 +49,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController end def pagination_params(core_params) - params.slice(:local, :remote, :limit, :only_media).permit(:local, :remote, :limit, :only_media).merge(core_params) + params.slice(:local, :remote, :limit, :only_media, :allow_local_only).permit(:local, :remote, :limit, :only_media, :allow_local_only).merge(core_params) end def next_path diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 2d6ad5a80c..64a1db58df 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -20,30 +20,29 @@ class Api::V1::Timelines::TagController < Api::BaseController end def cached_tagged_statuses - cache_collection tagged_statuses, Status - end - - def tagged_statuses - if @tag.nil? - [] - else - statuses = tag_timeline_statuses.paginate_by_id( - limit_param(DEFAULT_STATUSES_LIMIT), - params_slice(:max_id, :since_id, :min_id) - ) - - if truthy_param?(:only_media) - # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids. - status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id) - statuses.where(id: status_ids) - else - statuses - end - end + @tag.nil? ? [] : cache_collection(tag_timeline_statuses, Status) end def tag_timeline_statuses - HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, truthy_param?(:local)) + tag_feed.get( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id], + params[:min_id] + ) + end + + def tag_feed + TagFeed.new( + @tag, + current_account, + any: params[:any], + all: params[:all], + none: params[:none], + local: truthy_param?(:local), + remote: truthy_param?(:remote), + only_media: truthy_param?(:only_media) + ) end def insert_pagination_headers diff --git a/app/controllers/api/v2/suggestions_controller.rb b/app/controllers/api/v2/suggestions_controller.rb new file mode 100644 index 0000000000..35eb276c01 --- /dev/null +++ b/app/controllers/api/v2/suggestions_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Api::V2::SuggestionsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :read } + before_action :require_user! + before_action :set_suggestions + + def index + render json: @suggestions, each_serializer: REST::SuggestionSerializer + end + + private + + def set_suggestions + @suggestions = AccountSuggestions.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT)) + end +end diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index 7916b82fa0..bed57fc54c 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -2,6 +2,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController before_action :require_user! + before_action :set_push_subscription, only: :update def create active_session = current_session @@ -15,19 +16,22 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController alerts_enabled = active_session.detection.device.mobile? || active_session.detection.device.tablet? data = { + policy: 'all', + alerts: { follow: alerts_enabled, - follow_request: false, + follow_request: alerts_enabled, favourite: alerts_enabled, reblog: alerts_enabled, mention: alerts_enabled, poll: alerts_enabled, + status: alerts_enabled, }, } data.deep_merge!(data_params) if params[:data] - web_subscription = ::Web::PushSubscription.create!( + push_subscription = ::Web::PushSubscription.create!( endpoint: subscription_params[:endpoint], key_p256dh: subscription_params[:keys][:p256dh], key_auth: subscription_params[:keys][:auth], @@ -36,27 +40,27 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController access_token_id: active_session.access_token_id ) - active_session.update!(web_push_subscription: web_subscription) + active_session.update!(web_push_subscription: push_subscription) - render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer + render json: push_subscription, serializer: REST::WebPushSubscriptionSerializer end def update - params.require([:id]) - - web_subscription = ::Web::PushSubscription.find(params[:id]) - web_subscription.update!(data: data_params) - - render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer + @push_subscription.update!(data: data_params) + render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer end private + def set_push_subscription + @push_subscription = ::Web::PushSubscription.find(params[:id]) + end + def subscription_params @subscription_params ||= params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh]) end def data_params - @data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll]) + @data_params ||= params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status]) end end diff --git a/app/controllers/api/web/settings_controller.rb b/app/controllers/api/web/settings_controller.rb index 3d65e46ed9..601e25e6ee 100644 --- a/app/controllers/api/web/settings_controller.rb +++ b/app/controllers/api/web/settings_controller.rb @@ -2,17 +2,16 @@ class Api::Web::SettingsController < Api::Web::BaseController before_action :require_user! + before_action :set_setting def update - setting.data = params[:data] - setting.save! - + @setting.update!(data: params[:data]) render_empty end private - def setting - @_setting ||= ::Web::Setting.where(user: current_user).first_or_initialize(user: current_user) + def set_setting + @setting = ::Web::Setting.where(user: current_user).first_or_initialize(user: current_user) end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 63d9f91fb5..7435d78bff 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,8 +5,6 @@ class ApplicationController < ActionController::Base # For APIs, you may want to use :null_session instead. protect_from_forgery with: :exception - force_ssl if: :https_enabled? - include Localized include UserTrackingConcern include SessionTrackingConcern @@ -29,7 +27,7 @@ class ApplicationController < ActionController::Base rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from Mastodon::NotPermittedError, with: :forbidden rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error - rescue_from Mastodon::RaceConditionError, with: :service_unavailable + rescue_from Mastodon::RaceConditionError, Seahorse::Client::NetworkingError, Stoplight::Error::RedLight, with: :service_unavailable rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? @@ -43,10 +41,6 @@ class ApplicationController < ActionController::Base private - def https_enabled? - Rails.env.production? && !request.path.start_with?('/health') - end - def authorized_fetch_mode? ENV['AUTHORIZED_FETCH'] == 'true' || Rails.configuration.x.whitelist_mode end @@ -56,7 +50,7 @@ class ApplicationController < ActionController::Base end def store_current_location - store_location_for(:user, request.url) unless request.format == :json + store_location_for(:user, request.url) unless [:json, :rss].include?(request.format&.to_sym) end def require_admin! diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index c224e1a03b..42534f8ce6 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -9,7 +9,10 @@ class Auth::PasswordsController < Devise::PasswordsController def update super do |resource| - resource.session_activations.destroy_all if resource.errors.empty? + if resource.errors.empty? + resource.session_activations.destroy_all + resource.forget_me! + end end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index f6a85d87e1..6429bd9690 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class Auth::RegistrationsController < Devise::RegistrationsController + include Devise::Controllers::Rememberable + include RegistrationSpamConcern + layout :determine_layout before_action :set_invite, only: [:new, :create] @@ -12,6 +15,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :set_body_classes, only: [:new, :create, :edit, :update] before_action :require_not_suspended!, only: [:update] before_action :set_cache_headers, only: [:edit, :update] + before_action :set_registration_form_time, only: :new skip_before_action :require_functional!, only: [:edit, :update] @@ -25,7 +29,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController def update super do |resource| - resource.clear_other_sessions(current_session.session_id) if resource.saved_change_to_encrypted_password? + if resource.saved_change_to_encrypted_password? + resource.clear_other_sessions(current_session.session_id) + resource.forget_me! + remember_me(resource) + end end end @@ -40,16 +48,17 @@ class Auth::RegistrationsController < Devise::RegistrationsController def build_resource(hash = nil) super(hash) - resource.locale = I18n.locale - resource.invite_code = params[:invite_code] if resource.invite_code.blank? - resource.current_sign_in_ip = request.remote_ip + resource.locale = I18n.locale + resource.invite_code = params[:invite_code] if resource.invite_code.blank? + resource.registration_form_time = session[:registration_form_time] + resource.sign_up_ip = request.remote_ip 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], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement) + u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password) end end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index c54f6643ad..548832b21b 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -7,6 +7,7 @@ class Auth::SessionsController < Devise::SessionsController skip_before_action :require_no_authentication, only: [:create] skip_before_action :require_functional! + skip_before_action :update_user_sign_in prepend_before_action :set_pack @@ -26,6 +27,7 @@ class Auth::SessionsController < Devise::SessionsController def create super do |resource| + resource.update_sign_in!(request, new_sign_in: true) remember_me(resource) flash.delete(:notice) end @@ -39,20 +41,37 @@ class Auth::SessionsController < Devise::SessionsController store_location_for(:user, tmp_stored_location) if continue_after? end + def webauthn_options + user = find_user + + if user.webauthn_enabled? + options_for_get = WebAuthn::Credential.options_for_get( + allow: user.webauthn_credentials.pluck(:external_id) + ) + + session[:webauthn_challenge] = options_for_get.challenge + + render json: options_for_get, status: :ok + else + render json: { error: t('webauthn_credentials.not_enabled') }, status: :unauthorized + end + end + protected def find_user if session[:attempt_user_id] - User.find(session[:attempt_user_id]) + User.find_by(id: session[:attempt_user_id]) else user = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication user ||= User.find_for_authentication(email: user_params[:email]) + user end end def user_params - params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt) + params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt, credential: {}) end def after_sign_in_path_for(resource) @@ -75,6 +94,7 @@ class Auth::SessionsController < Devise::SessionsController def require_no_authentication super + # Delete flash message that isn't entirely useful and may be confusing in # most cases because /web doesn't display/clear flash messages. flash.delete(:alert) if flash[:alert] == I18n.t('devise.failure.already_authenticated') @@ -96,13 +116,30 @@ class Auth::SessionsController < Devise::SessionsController def home_paths(resource) paths = [about_path] + if single_user_mode? && resource.is_a?(User) paths << short_account_path(username: resource.account) end + paths end def continue_after? truthy_param?(:continue) end + + def restart_session + clear_attempt_from_session + redirect_to new_user_session_path, alert: I18n.t('devise.failure.timeout') + end + + def set_attempt_session(user) + session[:attempt_user_id] = user.id + session[:attempt_user_updated_at] = user.updated_at.to_s + end + + def clear_attempt_from_session + session.delete(:attempt_user_id) + session.delete(:attempt_user_updated_at) + end end diff --git a/app/controllers/concerns/account_owned_concern.rb b/app/controllers/concerns/account_owned_concern.rb index 460f71f65f..62e379846a 100644 --- a/app/controllers/concerns/account_owned_concern.rb +++ b/app/controllers/concerns/account_owned_concern.rb @@ -29,6 +29,24 @@ module AccountOwnedConcern end def check_account_suspension - expires_in(3.minutes, public: true) && gone if @account.suspended? + if @account.suspended_permanently? + permanent_suspension_response + elsif @account.suspended? && !skip_temporary_suspension_response? + temporary_suspension_response + end + end + + def skip_temporary_suspension_response? + false + end + + def permanent_suspension_response + expires_in(3.minutes, public: true) + gone + end + + def temporary_suspension_response + expires_in(3.minutes, public: true) + forbidden end end diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb index c7d25ae00c..05e431b19a 100644 --- a/app/controllers/concerns/cache_concern.rb +++ b/app/controllers/concerns/cache_concern.rb @@ -31,20 +31,26 @@ module CacheConcern def cache_collection(raw, klass) return raw unless klass.respond_to?(:with_includes) - raw = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation) + raw = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation) + return [] if raw.empty? + cached_keys_with_value = Rails.cache.read_multi(*raw).transform_keys(&:id) uncached_ids = raw.map(&:id) - cached_keys_with_value.keys klass.reload_stale_associations!(cached_keys_with_value.values) if klass.respond_to?(:reload_stale_associations!) unless uncached_ids.empty? - uncached = klass.where(id: uncached_ids).with_includes.each_with_object({}) { |item, h| h[item.id] = item } + uncached = klass.where(id: uncached_ids).with_includes.index_by(&:id) uncached.each_value do |item| Rails.cache.write(item, item) end end - raw.map { |item| cached_keys_with_value[item.id] || uncached[item.id] }.compact + raw.filter_map { |item| cached_keys_with_value[item.id] || uncached[item.id] } + end + + def cache_collection_paginated_by_id(raw, klass, limit, options) + cache_collection raw.cache_ids.to_a_paginated_by_id(limit, options), klass end end diff --git a/app/controllers/concerns/challengable_concern.rb b/app/controllers/concerns/challengable_concern.rb index b29d90b3cc..2995a25e09 100644 --- a/app/controllers/concerns/challengable_concern.rb +++ b/app/controllers/concerns/challengable_concern.rb @@ -32,7 +32,6 @@ module ChallengableConcern if params.key?(:form_challenge) if challenge_passed? session[:challenge_passed_at] = Time.now.utc - return else flash.now[:alert] = I18n.t('challenge.invalid_password') render_challenge diff --git a/app/controllers/concerns/export_controller_concern.rb b/app/controllers/concerns/export_controller_concern.rb index bfe990c827..24cfc7a012 100644 --- a/app/controllers/concerns/export_controller_concern.rb +++ b/app/controllers/concerns/export_controller_concern.rb @@ -5,7 +5,6 @@ module ExportControllerConcern included do before_action :authenticate_user! - before_action :require_not_suspended! before_action :load_export skip_before_action :require_functional! @@ -30,8 +29,4 @@ module ExportControllerConcern def export_filename "#{controller_name}.csv" end - - def require_not_suspended! - forbidden if current_account.suspended? - end end diff --git a/app/controllers/concerns/registration_spam_concern.rb b/app/controllers/concerns/registration_spam_concern.rb new file mode 100644 index 0000000000..af434c985a --- /dev/null +++ b/app/controllers/concerns/registration_spam_concern.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module RegistrationSpamConcern + extend ActiveSupport::Concern + + def set_registration_form_time + session[:registration_form_time] = Time.now.utc + end +end diff --git a/app/controllers/concerns/sign_in_token_authentication_concern.rb b/app/controllers/concerns/sign_in_token_authentication_concern.rb index f5178930b6..51ebcb1152 100644 --- a/app/controllers/concerns/sign_in_token_authentication_concern.rb +++ b/app/controllers/concerns/sign_in_token_authentication_concern.rb @@ -18,7 +18,9 @@ module SignInTokenAuthenticationConcern def authenticate_with_sign_in_token user = self.resource = find_user - if user_params[:sign_in_token_attempt].present? && session[:attempt_user_id] + if user.present? && session[:attempt_user_id].present? && session[:attempt_user_updated_at] != user.updated_at.to_s + restart_session + elsif user_params.key?(:sign_in_token_attempt) && session[:attempt_user_id] authenticate_with_sign_in_token_attempt(user) elsif user.present? && user.external_or_valid_password?(user_params[:password]) prompt_for_sign_in_token(user) @@ -27,7 +29,7 @@ module SignInTokenAuthenticationConcern def authenticate_with_sign_in_token_attempt(user) if valid_sign_in_token_attempt?(user) - session.delete(:attempt_user_id) + clear_attempt_from_session remember_me(user) sign_in(user) else @@ -42,11 +44,11 @@ module SignInTokenAuthenticationConcern UserMailer.sign_in_token(user, request.remote_ip, request.user_agent, Time.now.utc.to_s).deliver_later! end - set_locale do - session[:attempt_user_id] = user.id - use_pack 'auth' - @body_classes = 'lighter' - render :sign_in_token - end + set_attempt_session(user) + use_pack 'auth' + + @body_classes = 'lighter' + + set_locale { render :sign_in_token } end end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 10efbf2e0b..4dd0cac55d 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -7,6 +7,44 @@ module SignatureVerification include DomainControlHelper + EXPIRATION_WINDOW_LIMIT = 12.hours + CLOCK_SKEW_MARGIN = 1.hour + + class SignatureVerificationError < StandardError; end + + class SignatureParamsParser < Parslet::Parser + rule(:token) { match("[0-9a-zA-Z!#$%&'*+.^_`|~-]").repeat(1).as(:token) } + rule(:quoted_string) { str('"') >> (qdtext | quoted_pair).repeat.as(:quoted_string) >> str('"') } + # qdtext and quoted_pair are not exactly according to spec but meh + rule(:qdtext) { match('[^\\\\"]') } + rule(:quoted_pair) { str('\\') >> any } + rule(:bws) { match('\s').repeat } + rule(:param) { (token.as(:key) >> bws >> str('=') >> bws >> (token | quoted_string).as(:value)).as(:param) } + rule(:comma) { bws >> str(',') >> bws } + # Old versions of node-http-signature add an incorrect "Signature " prefix to the header + rule(:buggy_prefix) { str('Signature ') } + rule(:params) { buggy_prefix.maybe >> (param >> (comma >> param).repeat).as(:params) } + root(:params) + end + + class SignatureParamsTransformer < Parslet::Transform + rule(params: subtree(:p)) do + (p.is_a?(Array) ? p : [p]).each_with_object({}) { |(key, val), h| h[key] = val } + end + + rule(param: { key: simple(:key), value: simple(:val) }) do + [key, val] + end + + rule(quoted_string: simple(:string)) do + string.to_s + end + + rule(token: simple(:string)) do + string.to_s + end + end + def require_signature! render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account end @@ -24,72 +62,41 @@ module SignatureVerification end def signature_key_id - raw_signature = request.headers['Signature'] - signature_params = {} - - raw_signature.split(',').each do |part| - parsed_parts = part.match(/([a-z]+)="([^"]+)"/i) - next if parsed_parts.nil? || parsed_parts.size != 3 - signature_params[parsed_parts[1]] = parsed_parts[2] - end - signature_params['keyId'] + rescue SignatureVerificationError + nil end def signed_request_account return @signed_request_account if defined?(@signed_request_account) - unless signed_request? - @signature_verification_failure_reason = 'Request not signed' - @signed_request_account = nil - return - end + raise SignatureVerificationError, 'Request not signed' unless signed_request? + raise SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters? + raise SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm) + raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window? - if request.headers['Date'].present? && !matches_time_window? - @signature_verification_failure_reason = 'Signed request date outside acceptable time window' - @signed_request_account = nil - return - end - - raw_signature = request.headers['Signature'] - signature_params = {} - - raw_signature.split(',').each do |part| - parsed_parts = part.match(/([a-z]+)="([^"]+)"/i) - next if parsed_parts.nil? || parsed_parts.size != 3 - signature_params[parsed_parts[1]] = parsed_parts[2] - end - - if incompatible_signature?(signature_params) - @signature_verification_failure_reason = 'Incompatible request signature' - @signed_request_account = nil - return - end + verify_signature_strength! + verify_body_digest! account = account_from_key_id(signature_params['keyId']) - if account.nil? - @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}" - @signed_request_account = nil - return - end + raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if account.nil? signature = Base64.decode64(signature_params['signature']) - compare_signed_string = build_signed_string(signature_params['headers']) + compare_signed_string = build_signed_string return account unless verify_signature(account, signature, compare_signed_string).nil? account = stoplight_wrap_request { account.possibly_stale? ? account.refresh! : account_refresh_key(account) } - if account.nil? - @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}" - @signed_request_account = nil - return - end + raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if account.nil? return account unless verify_signature(account, signature, compare_signed_string).nil? - @signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}" + @signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)" + @signed_request_account = nil + rescue SignatureVerificationError => e + @signature_verification_failure_reason = e.message @signed_request_account = nil end @@ -99,8 +106,43 @@ module SignatureVerification private + def signature_params + @signature_params ||= begin + raw_signature = request.headers['Signature'] + tree = SignatureParamsParser.new.parse(raw_signature) + SignatureParamsTransformer.new.apply(tree) + end + rescue Parslet::ParseFailed + raise SignatureVerificationError, 'Error parsing signature parameters' + end + + def signature_algorithm + signature_params.fetch('algorithm', 'hs2019') + end + + def signed_headers + signature_params.fetch('headers', signature_algorithm == 'hs2019' ? '(created)' : 'date').downcase.split(' ') + end + + def verify_signature_strength! + raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)') + raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest') + raise SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if request.get? && !signed_headers.include?('host') + raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest') + end + + def verify_body_digest! + return unless signed_headers.include?('digest') + raise SignatureVerificationError, 'Digest header missing' unless request.headers.key?('Digest') + + digests = request.headers['Digest'].split(',').map { |digest| digest.split('=', 2) }.map { |key, value| [key.downcase, value] } + sha256 = digests.assoc('sha-256') + raise SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil? + raise SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}" if body_digest != sha256[1] + end + def verify_signature(account, signature, compare_signed_string) - if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) + if account.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), signature, compare_signed_string) @signed_request_account = account @signed_request_account end @@ -108,14 +150,20 @@ module SignatureVerification nil end - def build_signed_string(signed_headers) - signed_headers = 'date' if signed_headers.blank? - - signed_headers.downcase.split(' ').map do |signed_header| + def build_signed_string + signed_headers.map do |signed_header| if signed_header == Request::REQUEST_TARGET "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" - elsif signed_header == 'digest' - "digest: #{body_digest}" + elsif signed_header == '(created)' + raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019' + raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank? + + "(created): #{signature_params['created']}" + elsif signed_header == '(expires)' + raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019' + raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank? + + "(expires): #{signature_params['expires']}" else "#{signed_header}: #{request.headers[to_header_name(signed_header)]}" end @@ -123,26 +171,40 @@ module SignatureVerification end def matches_time_window? + created_time = nil + expires_time = nil + begin - time_sent = Time.httpdate(request.headers['Date']) + if signature_algorithm == 'hs2019' && signature_params['created'].present? + created_time = Time.at(signature_params['created'].to_i).utc + elsif request.headers['Date'].present? + created_time = Time.httpdate(request.headers['Date']).utc + end + + expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present? rescue ArgumentError return false end - (Time.now.utc - time_sent).abs <= 12.hours + expires_time ||= created_time + 5.minutes unless created_time.nil? + expires_time = [expires_time, created_time + EXPIRATION_WINDOW_LIMIT].min unless created_time.nil? + + return false if created_time.present? && created_time > Time.now.utc + CLOCK_SKEW_MARGIN + return false if expires_time.present? && Time.now.utc > expires_time + CLOCK_SKEW_MARGIN + + true end def body_digest - "SHA-256=#{Digest::SHA256.base64digest(request_body)}" + @body_digest ||= Digest::SHA256.base64digest(request_body) end def to_header_name(name) name.split(/-/).map(&:capitalize).join('-') end - def incompatible_signature?(signature_params) - signature_params['keyId'].blank? || - signature_params['signature'].blank? + def missing_required_signature_parameters? + signature_params['keyId'].blank? || signature_params['signature'].blank? end def account_from_key_id(key_id) diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb index 35c0c27cfc..4800db3480 100644 --- a/app/controllers/concerns/two_factor_authentication_concern.rb +++ b/app/controllers/concerns/two_factor_authentication_concern.rb @@ -8,7 +8,23 @@ module TwoFactorAuthenticationConcern end def two_factor_enabled? - find_user&.otp_required_for_login? + find_user&.two_factor_enabled? + end + + def valid_webauthn_credential?(user, webauthn_credential) + user_credential = user.webauthn_credentials.find_by!(external_id: webauthn_credential.id) + + begin + webauthn_credential.verify( + session[:webauthn_challenge], + public_key: user_credential.public_key, + sign_count: user_credential.sign_count + ) + + user_credential.update!(sign_count: webauthn_credential.sign_count) + rescue WebAuthn::Error + false + end end def valid_otp_attempt?(user) @@ -21,16 +37,33 @@ module TwoFactorAuthenticationConcern def authenticate_with_two_factor user = self.resource = find_user - if user_params[:otp_attempt].present? && session[:attempt_user_id] - authenticate_with_two_factor_attempt(user) + if user.present? && session[:attempt_user_id].present? && session[:attempt_user_updated_at] != user.updated_at.to_s + restart_session + elsif user.webauthn_enabled? && user_params.key?(:credential) && session[:attempt_user_id] + authenticate_with_two_factor_via_webauthn(user) + elsif user_params.key?(:otp_attempt) && session[:attempt_user_id] + authenticate_with_two_factor_via_otp(user) elsif user.present? && user.external_or_valid_password?(user_params[:password]) prompt_for_two_factor(user) end end - def authenticate_with_two_factor_attempt(user) + def authenticate_with_two_factor_via_webauthn(user) + webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential]) + + if valid_webauthn_credential?(user, webauthn_credential) + clear_attempt_from_session + remember_me(user) + sign_in(user) + render json: { redirect_path: root_path }, status: :ok + else + render json: { error: t('webauthn_credentials.invalid_credential') }, status: :unprocessable_entity + end + end + + def authenticate_with_two_factor_via_otp(user) if valid_otp_attempt?(user) - session.delete(:attempt_user_id) + clear_attempt_from_session remember_me(user) sign_in(user) else @@ -40,11 +73,20 @@ module TwoFactorAuthenticationConcern end def prompt_for_two_factor(user) - set_locale do - session[:attempt_user_id] = user.id - use_pack 'auth' - @body_classes = 'lighter' - render :two_factor + set_attempt_session(user) + + use_pack 'auth' + + @body_classes = 'lighter' + @webauthn_enabled = user.webauthn_enabled? + @scheme_type = begin + if user.webauthn_enabled? && user_params[:otp_attempt].blank? + 'webauthn' + else + 'totp' + end end + + set_locale { render :two_factor } end end diff --git a/app/controllers/concerns/user_tracking_concern.rb b/app/controllers/concerns/user_tracking_concern.rb index be10705fcc..efda37fae7 100644 --- a/app/controllers/concerns/user_tracking_concern.rb +++ b/app/controllers/concerns/user_tracking_concern.rb @@ -6,14 +6,13 @@ module UserTrackingConcern UPDATE_SIGN_IN_HOURS = 24 included do - before_action :set_user_activity + before_action :update_user_sign_in end private - def set_user_activity - return unless user_needs_sign_in_update? - current_user.update_tracked_fields!(request) + def update_user_sign_in + current_user.update_sign_in!(request) if user_needs_sign_in_update? end def user_needs_sign_in_update? diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index 76be03e537..0d4c1b97c0 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -10,7 +10,7 @@ class FiltersController < ApplicationController before_action :set_body_classes def index - @filters = current_account.custom_filters + @filters = current_account.custom_filters.order(:phrase) end def new diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 5ffbdae79d..18b281325b 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -53,6 +53,14 @@ class FollowerAccountsController < ApplicationController account_followers_url(@account, page: page) unless page.nil? end + def next_page_url + page_url(follows.next_page) if follows.respond_to?(:next_page) + end + + def prev_page_url + page_url(follows.prev_page) if follows.respond_to?(:prev_page) + end + def collection_presenter options = { type: :ordered } options[:size] = @account.followers_count unless Setting.hide_followers_count || @account.user&.setting_hide_followers_count @@ -61,8 +69,8 @@ class FollowerAccountsController < ApplicationController id: account_followers_url(@account, page: params.fetch(:page, 1)), items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }, part_of: account_followers_url(@account), - next: page_url(follows.next_page), - prev: page_url(follows.prev_page), + next: next_page_url, + prev: prev_page_url, **options ) else diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 69820ebb7d..eba44d34be 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -53,6 +53,14 @@ class FollowingAccountsController < ApplicationController account_following_index_url(@account, page: page) unless page.nil? end + def next_page_url + page_url(follows.next_page) if follows.respond_to?(:next_page) + end + + def prev_page_url + page_url(follows.prev_page) if follows.respond_to?(:prev_page) + end + def collection_presenter if page_requested? ActivityPub::CollectionPresenter.new( @@ -61,8 +69,8 @@ class FollowingAccountsController < ApplicationController size: @account.following_count, items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }, part_of: account_following_index_url(@account), - next: page_url(follows.next_page), - prev: page_url(follows.prev_page) + next: next_page_url, + prev: prev_page_url ) else ActivityPub::CollectionPresenter.new( diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb new file mode 100644 index 0000000000..2a22a05570 --- /dev/null +++ b/app/controllers/health_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class HealthController < ActionController::Base + def show + render plain: 'OK' + end +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index efdb1d2268..c9b8408817 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class HomeController < ApplicationController + before_action :redirect_unauthenticated_to_permalinks! before_action :authenticate_user! before_action :set_pack @@ -12,7 +13,7 @@ class HomeController < ApplicationController private - def authenticate_user! + def redirect_unauthenticated_to_permalinks! return if user_signed_in? matches = request.path.match(/\A\/web\/(statuses|accounts)\/([\d]+)\z/) @@ -37,6 +38,7 @@ class HomeController < ApplicationController end matches = request.path.match(%r{\A/web/timelines/tag/(?.+)\z}) + redirect_to(matches ? tag_path(CGI.unescape(matches[:tag])) : default_redirect_path) end diff --git a/app/controllers/instance_actors_controller.rb b/app/controllers/instance_actors_controller.rb index 6f02d6a358..b3b5476e2f 100644 --- a/app/controllers/instance_actors_controller.rb +++ b/app/controllers/instance_actors_controller.rb @@ -13,10 +13,10 @@ class InstanceActorsController < ApplicationController private def set_account - @account = Account.find(-99) + @account = Account.representative end def restrict_fields_to - %i(id type preferred_username inbox public_key endpoints url manually_approves_followers) + %i(id type preferred_username inbox outbox public_key endpoints url manually_approves_followers) end end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index ce015dd1b2..772fc42cb4 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -11,6 +11,7 @@ class MediaController < ApplicationController before_action :verify_permitted_status! before_action :check_playable, only: :player before_action :allow_iframing, only: :player + before_action :set_pack, only: :player content_security_policy only: :player do |p| p.frame_ancestors(false) @@ -43,4 +44,8 @@ class MediaController < ApplicationController def allow_iframing response.headers['X-Frame-Options'] = 'ALLOWALL' end + + def set_pack + use_pack 'public' + end end diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index 014b89de10..1b610318d3 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -2,6 +2,7 @@ class MediaProxyController < ApplicationController include RoutingHelper + include Authorization skip_before_action :store_current_location skip_before_action :require_functional! @@ -10,12 +11,14 @@ class MediaProxyController < ApplicationController rescue_from ActiveRecord::RecordInvalid, with: :not_found rescue_from Mastodon::UnexpectedResponseError, with: :not_found + rescue_from Mastodon::NotPermittedError, with: :not_found rescue_from HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, with: :internal_server_error def show RedisLock.acquire(lock_options) do |lock| if lock.acquired? - @media_attachment = MediaAttachment.remote.find(params[:id]) + @media_attachment = MediaAttachment.remote.attached.find(params[:id]) + authorize @media_attachment.status, :show? redownload! if @media_attachment.needs_redownload? && !reject_media? else raise Mastodon::RaceConditionError @@ -28,13 +31,13 @@ class MediaProxyController < ApplicationController private def redownload! - @media_attachment.file_remote_url = @media_attachment.remote_url - @media_attachment.created_at = Time.now.utc + @media_attachment.download_file! + @media_attachment.created_at = Time.now.utc @media_attachment.save! end def version - if request.path.ends_with?('/small') + if request.path.end_with?('/small') :small else :original diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index c5ccece134..b2564a7915 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -6,6 +6,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio before_action :store_current_location before_action :authenticate_resource_owner! before_action :set_pack + before_action :require_not_suspended!, only: :destroy before_action :set_body_classes skip_before_action :require_functional! @@ -30,4 +31,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio def set_pack use_pack 'settings' end + + def require_not_suspended! + forbidden if current_account.suspended? + end end diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb index f1ab980c88..d40770726c 100644 --- a/app/controllers/relationships_controller.rb +++ b/app/controllers/relationships_controller.rb @@ -6,6 +6,7 @@ class RelationshipsController < ApplicationController before_action :authenticate_user! before_action :set_accounts, only: :show before_action :set_pack + before_action :set_relationships, only: :show before_action :set_body_classes helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship? @@ -29,6 +30,10 @@ class RelationshipsController < ApplicationController @accounts = RelationshipFilter.new(current_account, filter_params).results.page(params[:page]).per(40) end + def set_relationships + @relationships = AccountRelationshipsPresenter.new(@accounts.pluck(:id), current_user.account_id) + end + def form_account_batch_params params.require(:form_account_batch).permit(:action, account_ids: []) end @@ -50,7 +55,9 @@ class RelationshipsController < ApplicationController end def action_from_button - if params[:unfollow] + if params[:follow] + 'follow' + elsif params[:unfollow] 'unfollow' elsif params[:remove_from_followers] 'remove_from_followers' diff --git a/app/controllers/settings/aliases_controller.rb b/app/controllers/settings/aliases_controller.rb index b7c9a409d1..a421b8ede3 100644 --- a/app/controllers/settings/aliases_controller.rb +++ b/app/controllers/settings/aliases_controller.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true class Settings::AliasesController < Settings::BaseController - layout 'admin' + skip_before_action :require_functional! - before_action :authenticate_user! + before_action :require_not_suspended! before_action :set_aliases, except: :destroy before_action :set_alias, only: :destroy diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb index ed3f82a8e0..d3ac268d86 100644 --- a/app/controllers/settings/applications_controller.rb +++ b/app/controllers/settings/applications_controller.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true class Settings::ApplicationsController < Settings::BaseController - layout 'admin' - - before_action :authenticate_user! before_action :set_application, only: [:show, :update, :destroy, :regenerate] before_action :prepare_scopes, only: [:create, :update] diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index b97603af6f..dee3922d80 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -2,6 +2,9 @@ class Settings::BaseController < ApplicationController before_action :set_pack + layout 'admin' + + before_action :authenticate_user! before_action :set_body_classes before_action :set_cache_headers @@ -18,4 +21,8 @@ class Settings::BaseController < ApplicationController def set_cache_headers response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' end + + def require_not_suspended! + forbidden if current_account.suspended? + end end diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb index 15a59c999d..7b8f8d2078 100644 --- a/app/controllers/settings/deletes_controller.rb +++ b/app/controllers/settings/deletes_controller.rb @@ -1,14 +1,11 @@ # frozen_string_literal: true class Settings::DeletesController < Settings::BaseController - layout 'admin' - - before_action :check_enabled_deletion - before_action :authenticate_user! - before_action :require_not_suspended! - skip_before_action :require_functional! + before_action :require_not_suspended! + before_action :check_enabled_deletion + def show @confirmation = Form::DeleteConfirmation.new end @@ -45,8 +42,8 @@ class Settings::DeletesController < Settings::BaseController end def destroy_account! - current_account.suspend! - Admin::SuspensionWorker.perform_async(current_user.account_id, true) + current_account.suspend!(origin: :local) + AccountDeletionWorker.perform_async(current_user.account_id) sign_out end end diff --git a/app/controllers/settings/exports/blocked_accounts_controller.rb b/app/controllers/settings/exports/blocked_accounts_controller.rb index 2092104e01..2190caa361 100644 --- a/app/controllers/settings/exports/blocked_accounts_controller.rb +++ b/app/controllers/settings/exports/blocked_accounts_controller.rb @@ -2,7 +2,7 @@ module Settings module Exports - class BlockedAccountsController < ApplicationController + class BlockedAccountsController < BaseController include ExportControllerConcern def index diff --git a/app/controllers/settings/exports/blocked_domains_controller.rb b/app/controllers/settings/exports/blocked_domains_controller.rb index 6676ce3401..bee4b2431e 100644 --- a/app/controllers/settings/exports/blocked_domains_controller.rb +++ b/app/controllers/settings/exports/blocked_domains_controller.rb @@ -2,7 +2,7 @@ module Settings module Exports - class BlockedDomainsController < ApplicationController + class BlockedDomainsController < BaseController include ExportControllerConcern def index diff --git a/app/controllers/settings/exports/bookmarks_controller.rb b/app/controllers/settings/exports/bookmarks_controller.rb new file mode 100644 index 0000000000..c12e2f147a --- /dev/null +++ b/app/controllers/settings/exports/bookmarks_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Settings + module Exports + class BookmarksController < BaseController + include ExportControllerConcern + + def index + send_export_file + end + + private + + def export_data + @export.to_bookmarks_csv + end + end + end +end diff --git a/app/controllers/settings/exports/following_accounts_controller.rb b/app/controllers/settings/exports/following_accounts_controller.rb index 74281ddca2..acefcb15da 100644 --- a/app/controllers/settings/exports/following_accounts_controller.rb +++ b/app/controllers/settings/exports/following_accounts_controller.rb @@ -2,7 +2,7 @@ module Settings module Exports - class FollowingAccountsController < ApplicationController + class FollowingAccountsController < BaseController include ExportControllerConcern def index diff --git a/app/controllers/settings/exports/lists_controller.rb b/app/controllers/settings/exports/lists_controller.rb index cf5a9de44b..bc65f56a0e 100644 --- a/app/controllers/settings/exports/lists_controller.rb +++ b/app/controllers/settings/exports/lists_controller.rb @@ -2,7 +2,7 @@ module Settings module Exports - class ListsController < ApplicationController + class ListsController < BaseController include ExportControllerConcern def index diff --git a/app/controllers/settings/exports/muted_accounts_controller.rb b/app/controllers/settings/exports/muted_accounts_controller.rb index e511619ca6..50b7bf1f79 100644 --- a/app/controllers/settings/exports/muted_accounts_controller.rb +++ b/app/controllers/settings/exports/muted_accounts_controller.rb @@ -2,7 +2,7 @@ module Settings module Exports - class MutedAccountsController < ApplicationController + class MutedAccountsController < BaseController include ExportControllerConcern def index diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb index 0e93d07a9b..30138d29ed 100644 --- a/app/controllers/settings/exports_controller.rb +++ b/app/controllers/settings/exports_controller.rb @@ -3,11 +3,6 @@ class Settings::ExportsController < Settings::BaseController include Authorization - layout 'admin' - - before_action :authenticate_user! - before_action :require_not_suspended! - skip_before_action :require_functional! def show @@ -16,8 +11,6 @@ class Settings::ExportsController < Settings::BaseController end def create - raise Mastodon::NotPermittedError unless user_signed_in? - backup = nil RedisLock.acquire(lock_options) do |lock| @@ -37,8 +30,4 @@ class Settings::ExportsController < Settings::BaseController def lock_options { redis: Redis.current, key: "backup:#{current_user.id}" } end - - def require_not_suspended! - forbidden if current_account.suspended? - end end diff --git a/app/controllers/settings/featured_tags_controller.rb b/app/controllers/settings/featured_tags_controller.rb index 3a3241425d..e805527d07 100644 --- a/app/controllers/settings/featured_tags_controller.rb +++ b/app/controllers/settings/featured_tags_controller.rb @@ -1,12 +1,9 @@ # frozen_string_literal: true class Settings::FeaturedTagsController < Settings::BaseController - layout 'admin' - - before_action :authenticate_user! before_action :set_featured_tags, only: :index before_action :set_featured_tag, except: [:index, :create] - before_action :set_most_used_tags, only: :index + before_action :set_recently_used_tags, only: :index def index @featured_tag = FeaturedTag.new @@ -20,7 +17,7 @@ class Settings::FeaturedTagsController < Settings::BaseController redirect_to settings_featured_tags_path else set_featured_tags - set_most_used_tags + set_recently_used_tags render :index end @@ -41,8 +38,8 @@ class Settings::FeaturedTagsController < Settings::BaseController @featured_tags = current_account.featured_tags.order(statuses_count: :desc).reject(&:new_record?) end - def set_most_used_tags - @most_used_tags = Tag.most_used(current_account).where.not(id: @featured_tags.map(&:id)).limit(10) + def set_recently_used_tags + @recently_used_tags = Tag.recently_used(current_account).where.not(id: @featured_tags.map(&:id)).limit(10) end def featured_tag_params diff --git a/app/controllers/settings/identity_proofs_controller.rb b/app/controllers/settings/identity_proofs_controller.rb index b217b3c3be..4618c78836 100644 --- a/app/controllers/settings/identity_proofs_controller.rb +++ b/app/controllers/settings/identity_proofs_controller.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true class Settings::IdentityProofsController < Settings::BaseController - layout 'admin' - - before_action :authenticate_user! before_action :check_required_params, only: :new before_action :check_enabled, only: :new diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb index 7b8c4ae235..d4516526ee 100644 --- a/app/controllers/settings/imports_controller.rb +++ b/app/controllers/settings/imports_controller.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true class Settings::ImportsController < Settings::BaseController - layout 'admin' - - before_action :authenticate_user! before_action :set_account def show diff --git a/app/controllers/settings/migration/redirects_controller.rb b/app/controllers/settings/migration/redirects_controller.rb index 97193ade02..6d469f3842 100644 --- a/app/controllers/settings/migration/redirects_controller.rb +++ b/app/controllers/settings/migration/redirects_controller.rb @@ -1,13 +1,10 @@ # frozen_string_literal: true class Settings::Migration::RedirectsController < Settings::BaseController - layout 'admin' - - before_action :authenticate_user! - before_action :require_not_suspended! - skip_before_action :require_functional! + before_action :require_not_suspended! + def new @redirect = Form::Redirect.new end @@ -38,8 +35,4 @@ class Settings::Migration::RedirectsController < Settings::BaseController def resource_params params.require(:form_redirect).permit(:acct, :current_password, :current_username) end - - def require_not_suspended! - forbidden if current_account.suspended? - end end diff --git a/app/controllers/settings/migrations_controller.rb b/app/controllers/settings/migrations_controller.rb index 68304bb513..62603aba81 100644 --- a/app/controllers/settings/migrations_controller.rb +++ b/app/controllers/settings/migrations_controller.rb @@ -1,15 +1,12 @@ # frozen_string_literal: true class Settings::MigrationsController < Settings::BaseController - layout 'admin' + skip_before_action :require_functional! - before_action :authenticate_user! before_action :require_not_suspended! before_action :set_migrations before_action :set_cooldown - skip_before_action :require_functional! - def show @migration = current_account.migrations.build end @@ -44,8 +41,4 @@ class Settings::MigrationsController < Settings::BaseController def on_cooldown? @cooldown.present? end - - def require_not_suspended! - forbidden if current_account.suspended? - end end diff --git a/app/controllers/settings/pictures_controller.rb b/app/controllers/settings/pictures_controller.rb index 73926707bd..58a4325307 100644 --- a/app/controllers/settings/pictures_controller.rb +++ b/app/controllers/settings/pictures_controller.rb @@ -2,19 +2,17 @@ module Settings class PicturesController < BaseController - before_action :authenticate_user! before_action :set_account before_action :set_picture def destroy - if valid_picture - account_params = { - @picture => nil, - (@picture + '_remote_url') => nil, - } - - msg = UpdateAccountService.new.call(@account, account_params) ? I18n.t('generic.changes_saved_msg') : nil - redirect_to settings_profile_path, notice: msg, status: 303 + if valid_picture? + if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' }) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) + redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg'), status: 303 + else + redirect_to settings_profile_path + end else bad_request end @@ -30,8 +28,8 @@ module Settings @picture = params[:id] end - def valid_picture - @picture == 'avatar' || @picture == 'header' + def valid_picture? + %w(avatar header).include?(@picture) end end end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 75c3e2495d..d05ceb53f6 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true class Settings::PreferencesController < Settings::BaseController - layout 'admin' - - before_action :authenticate_user! - def show; end def update @@ -48,6 +44,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_display_media, :setting_expand_spoilers, :setting_reduce_motion, + :setting_disable_swiping, :setting_system_font_ui, :setting_system_emoji_font, :setting_noindex, diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 19a7ce157f..0c15447a6c 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true class Settings::ProfilesController < Settings::BaseController - layout 'admin' - - before_action :authenticate_user! before_action :set_account def show diff --git a/app/controllers/settings/sessions_controller.rb b/app/controllers/settings/sessions_controller.rb index f8fb4036ef..ee2fc5dc80 100644 --- a/app/controllers/settings/sessions_controller.rb +++ b/app/controllers/settings/sessions_controller.rb @@ -1,12 +1,11 @@ # frozen_string_literal: true -# Intentionally does not inherit from BaseController -class Settings::SessionsController < ApplicationController - before_action :authenticate_user! - before_action :set_session, only: :destroy - +class Settings::SessionsController < Settings::BaseController skip_before_action :require_functional! + before_action :require_not_suspended! + before_action :set_session, only: :destroy + def destroy @session.destroy! flash[:notice] = I18n.t('sessions.revoke_success') diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb index ef4df33390..1a0afe58b0 100644 --- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb +++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb @@ -5,31 +5,31 @@ module Settings class ConfirmationsController < BaseController include ChallengableConcern - layout 'admin' + skip_before_action :require_functional! - before_action :authenticate_user! before_action :require_challenge! before_action :ensure_otp_secret - skip_before_action :require_functional! - def new prepare_two_factor_form end def create - if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) + if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt], otp_secret: session[:new_otp_secret]) flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success') current_user.otp_required_for_login = true + current_user.otp_secret = session[:new_otp_secret] @recovery_codes = current_user.generate_otp_backup_codes! current_user.save! UserMailer.two_factor_enabled(current_user).deliver_later! + session.delete(:new_otp_secret) + render 'settings/two_factor_authentication/recovery_codes/index' else - flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code') + flash.now[:alert] = I18n.t('otp_authentication.wrong_code') prepare_two_factor_form render :new end @@ -43,12 +43,15 @@ module Settings def prepare_two_factor_form @confirmation = Form::TwoFactorConfirmation.new - @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Rails.configuration.x.local_domain) + @new_otp_secret = session[:new_otp_secret] + @provision_url = current_user.otp_provisioning_uri(current_user.email, + otp_secret: @new_otp_secret, + issuer: Rails.configuration.x.local_domain) @qrcode = RQRCode::QRCode.new(@provision_url) end def ensure_otp_secret - redirect_to settings_two_factor_authentication_path unless current_user.otp_secret + redirect_to settings_otp_authentication_path if session[:new_otp_secret].blank? end end end diff --git a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb new file mode 100644 index 0000000000..cbba842a98 --- /dev/null +++ b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Settings + module TwoFactorAuthentication + class OtpAuthenticationController < BaseController + include ChallengableConcern + + skip_before_action :require_functional! + + before_action :verify_otp_not_enabled, only: [:show] + before_action :require_challenge!, only: [:create] + + def show + @confirmation = Form::TwoFactorConfirmation.new + end + + def create + session[:new_otp_secret] = User.generate_otp_secret(32) + + redirect_to new_settings_two_factor_authentication_confirmation_path + end + + private + + def confirmation_params + params.require(:form_two_factor_confirmation).permit(:otp_attempt) + end + + def verify_otp_not_enabled + redirect_to settings_two_factor_authentication_methods_path if current_user.otp_enabled? + end + + def acceptable_code? + current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) || + current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt]) + end + end + end +end diff --git a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb index 0c4f5bff76..6ec53224d3 100644 --- a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb +++ b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb @@ -5,13 +5,10 @@ module Settings class RecoveryCodesController < BaseController include ChallengableConcern - layout 'admin' - - before_action :authenticate_user! - before_action :require_challenge!, on: :create - skip_before_action :require_functional! + before_action :require_challenge!, on: :create + def create @recovery_codes = current_user.generate_otp_backup_codes! current_user.save! diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb new file mode 100644 index 0000000000..bd6f83134c --- /dev/null +++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Settings + module TwoFactorAuthentication + class WebauthnCredentialsController < BaseController + skip_before_action :require_functional! + + before_action :require_otp_enabled + before_action :require_webauthn_enabled, only: [:index, :destroy] + + def new; end + + def index; end + + def options + current_user.update(webauthn_id: WebAuthn.generate_user_id) unless current_user.webauthn_id + + options_for_create = WebAuthn::Credential.options_for_create( + user: { + name: current_user.account.username, + display_name: current_user.account.username, + id: current_user.webauthn_id, + }, + exclude: current_user.webauthn_credentials.pluck(:external_id) + ) + + session[:webauthn_challenge] = options_for_create.challenge + + render json: options_for_create, status: :ok + end + + def create + webauthn_credential = WebAuthn::Credential.from_create(params[:credential]) + + if webauthn_credential.verify(session[:webauthn_challenge]) + user_credential = current_user.webauthn_credentials.build( + external_id: webauthn_credential.id, + public_key: webauthn_credential.public_key, + nickname: params[:nickname], + sign_count: webauthn_credential.sign_count + ) + + if user_credential.save + flash[:success] = I18n.t('webauthn_credentials.create.success') + status = :ok + + if current_user.webauthn_credentials.size == 1 + UserMailer.webauthn_enabled(current_user).deliver_later! + else + UserMailer.webauthn_credential_added(current_user, user_credential).deliver_later! + end + else + flash[:error] = I18n.t('webauthn_credentials.create.error') + status = :internal_server_error + end + else + flash[:error] = t('webauthn_credentials.create.error') + status = :unauthorized + end + + render json: { redirect_path: settings_two_factor_authentication_methods_path }, status: status + end + + def destroy + credential = current_user.webauthn_credentials.find_by(id: params[:id]) + if credential + credential.destroy + if credential.destroyed? + flash[:success] = I18n.t('webauthn_credentials.destroy.success') + + if current_user.webauthn_credentials.empty? + UserMailer.webauthn_disabled(current_user).deliver_later! + else + UserMailer.webauthn_credential_deleted(current_user, credential).deliver_later! + end + else + flash[:error] = I18n.t('webauthn_credentials.destroy.error') + end + else + flash[:error] = I18n.t('webauthn_credentials.destroy.error') + end + redirect_to settings_two_factor_authentication_methods_path + end + + private + + def set_pack + use_pack 'auth' + end + + def require_otp_enabled + unless current_user.otp_enabled? + flash[:error] = t('webauthn_credentials.otp_required') + redirect_to settings_two_factor_authentication_methods_path + end + end + + def require_webauthn_enabled + unless current_user.webauthn_enabled? + flash[:error] = t('webauthn_credentials.not_enabled') + redirect_to settings_two_factor_authentication_methods_path + end + end + end + end +end diff --git a/app/controllers/settings/two_factor_authentication_methods_controller.rb b/app/controllers/settings/two_factor_authentication_methods_controller.rb new file mode 100644 index 0000000000..205933ea81 --- /dev/null +++ b/app/controllers/settings/two_factor_authentication_methods_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Settings + class TwoFactorAuthenticationMethodsController < BaseController + include ChallengableConcern + + skip_before_action :require_functional! + + before_action :require_challenge!, only: :disable + before_action :require_otp_enabled + + def index; end + + def disable + current_user.disable_two_factor! + UserMailer.two_factor_disabled(current_user).deliver_later! + + redirect_to settings_otp_authentication_path, flash: { notice: I18n.t('two_factor_authentication.disabled_success') } + end + + private + + def require_otp_enabled + redirect_to settings_otp_authentication_path unless current_user.otp_enabled? + end + end +end diff --git a/app/controllers/settings/two_factor_authentications_controller.rb b/app/controllers/settings/two_factor_authentications_controller.rb deleted file mode 100644 index 9118a79332..0000000000 --- a/app/controllers/settings/two_factor_authentications_controller.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module Settings - class TwoFactorAuthenticationsController < BaseController - include ChallengableConcern - - layout 'admin' - - before_action :authenticate_user! - before_action :verify_otp_required, only: [:create] - before_action :require_challenge!, only: [:create] - - skip_before_action :require_functional! - - def show - @confirmation = Form::TwoFactorConfirmation.new - end - - def create - current_user.otp_secret = User.generate_otp_secret(32) - current_user.save! - redirect_to new_settings_two_factor_authentication_confirmation_path - end - - def destroy - if acceptable_code? - current_user.otp_required_for_login = false - current_user.save! - UserMailer.two_factor_disabled(current_user).deliver_later! - redirect_to settings_two_factor_authentication_path - else - flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code') - @confirmation = Form::TwoFactorConfirmation.new - render :show - end - end - - private - - def confirmation_params - params.require(:form_two_factor_confirmation).permit(:otp_attempt) - end - - def verify_otp_required - redirect_to settings_two_factor_authentication_path if current_user.otp_required_for_login? - end - - def acceptable_code? - current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) || - current_user.invalidate_otp_backup_code!(confirmation_params[:otp_attempt]) - end - end -end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index a6ab8828f2..3290224b4e 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -8,7 +8,7 @@ class StatusesController < ApplicationController layout 'public' - before_action :require_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? } + before_action :require_signature!, only: [:show, :activity], if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_status before_action :set_instance_presenter before_action :set_link_headers diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index e46c0532cb..64736e77fc 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -10,8 +10,9 @@ class TagsController < ApplicationController before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :authenticate_user!, if: :whitelist_mode? - before_action :set_tag before_action :set_local + before_action :set_tag + before_action :set_statuses before_action :set_body_classes before_action :set_instance_presenter @@ -26,20 +27,11 @@ class TagsController < ApplicationController format.rss do expires_in 0, public: true - - limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE - @statuses = HashtagQueryService.new.call(@tag, filter_params, nil, @local).limit(PAGE_SIZE) - @statuses = cache_collection(@statuses, Status) - render xml: RSS::TagSerializer.render(@tag, @statuses) end format.json do expires_in 3.minutes, public: public_fetch_mode? - - @statuses = HashtagQueryService.new.call(@tag, filter_params, current_account, @local).paginate_by_max_id(PAGE_SIZE, params[:max_id]) - @statuses = cache_collection(@statuses, Status) - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end @@ -55,6 +47,15 @@ class TagsController < ApplicationController @local = truthy_param?(:local) end + def set_statuses + case request.format&.to_sym + when :json + @statuses = cache_collection(TagFeed.new(@tag, current_account, local: @local).get(PAGE_SIZE, params[:max_id], params[:since_id], params[:min_id]), Status) + when :rss + @statuses = cache_collection(TagFeed.new(@tag, nil, local: @local).get(limit_param), Status) + end + end + def set_body_classes @body_classes = 'with-modals' end @@ -63,16 +64,16 @@ class TagsController < ApplicationController @instance_presenter = InstancePresenter.new end + def limit_param + params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE + end + def collection_presenter ActivityPub::CollectionPresenter.new( - id: tag_url(@tag, filter_params), + id: tag_url(@tag), type: :ordered, size: @tag.statuses.count, items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } ) end - - def filter_params - params.slice(:any, :all, :none).permit(:any, :all, :none) - end end diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 9de9db6ba8..0227f722a7 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -35,7 +35,7 @@ module WellKnown end def check_account_suspension - expires_in(3.minutes, public: true) && gone if @account.suspended? + expires_in(3.minutes, public: true) && gone if @account.suspended_permanently? end def bad_request diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 8e398c3b26..0f3ca36e2d 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -29,6 +29,8 @@ module Admin::ActionLogsHelper link_to record.target_account.acct, admin_account_path(record.target_account_id) when 'Announcement' link_to truncate(record.text), edit_admin_announcement_path(record.id) + when 'IpBlock' + "#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})" end end @@ -48,6 +50,8 @@ module Admin::ActionLogsHelper end when 'Announcement' truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text']) + when 'IpBlock' + "#{attributes['ip']}/#{attributes['ip'].prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{attributes['severity']}")})" end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9ca11d5737..9be3419b0c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -7,6 +7,13 @@ module ApplicationHelper follow ).freeze + RTL_LOCALES = %i( + ar + fa + he + ku + ).freeze + def active_nav_class(*paths) paths.any? { |path| current_page?(path) } ? 'active' : '' end @@ -44,7 +51,7 @@ module ApplicationHelper end def locale_direction - if [:ar, :fa, :he].include?(I18n.locale) + if RTL_LOCALES.include?(I18n.locale) 'rtl' else 'ltr' @@ -84,8 +91,16 @@ module ApplicationHelper fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted')) elsif status.private_visibility? || status.limited_visibility? fa_icon('lock', title: I18n.t('statuses.visibilities.private')) - elsif status.direct_visibility? - fa_icon('envelope', title: I18n.t('statuses.visibilities.direct')) + end + end + + def interrelationships_icon(relationships, account_id) + if relationships.following[account_id] && relationships.followed_by[account_id] + fa_icon('exchange', title: I18n.t('relationships.mutual'), class: 'fa-fw active passive') + elsif relationships.following[account_id] + fa_icon(locale_direction == 'ltr' ? 'arrow-right' : 'arrow-left', title: I18n.t('relationships.following'), class: 'fa-fw active') + elsif relationships.followed_by[account_id] + fa_icon(locale_direction == 'ltr' ? 'arrow-left' : 'arrow-right', title: I18n.t('relationships.followers'), class: 'fa-fw passive') end end @@ -163,6 +178,8 @@ module ApplicationHelper end json = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(state_params), serializer: InitialStateSerializer).to_json + # rubocop:disable Rails/OutputSafety content_tag(:script, json_escape(json).html_safe, id: 'initial-state', type: 'application/json') + # rubocop:enable Rails/OutputSafety end end diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb new file mode 100644 index 0000000000..360783c628 --- /dev/null +++ b/app/helpers/email_helper.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module EmailHelper + def self.included(base) + base.extend(self) + end + + def email_to_canonical_email(str) + username, domain = str.downcase.split('@', 2) + username, = username.gsub('.', '').split('+', 2) + + "#{username}@#{domain}" + end + + def email_to_canonical_email_hash(str) + Digest::SHA2.new(256).hexdigest(email_to_canonical_email(str)) + end +end diff --git a/app/helpers/mascot_helper.rb b/app/helpers/mascot_helper.rb new file mode 100644 index 0000000000..0124c74f19 --- /dev/null +++ b/app/helpers/mascot_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module MascotHelper + def mascot_url + full_asset_url(instance_presenter.mascot&.file&.url || asset_pack_path('media/images/elephant_ui_plane.svg')) + end + + private + + def instance_presenter + @instance_presenter ||= InstancePresenter.new + end +end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 87718dc058..5b39497b6b 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -40,6 +40,7 @@ module SettingsHelper kk: 'Қазақша', kn: 'ಕನ್ನಡ', ko: '한국어', + ku: 'سۆرانی', lt: 'Lietuvių', lv: 'Latviešu', mk: 'Македонски', @@ -56,6 +57,8 @@ module SettingsHelper pt: 'Português', ro: 'Română', ru: 'Русский', + sa: 'संस्कृतम्', + sc: 'Sardu', sk: 'Slovenčina', sl: 'Slovenščina', sq: 'Shqip', @@ -69,6 +72,7 @@ module SettingsHelper uk: 'Українська', ur: 'اُردُو', vi: 'Tiếng Việt', + zgh: 'ⵜⴰⵎⴰⵣⵉⵖⵜ', 'zh-CN': '简体中文', 'zh-HK': '繁體中文(香港)', 'zh-TW': '繁體中文(臺灣)', diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index a51597cf35..1f654f34fc 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -4,8 +4,12 @@ module StatusesHelper EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_ACTION = 'embed' - def link_to_more(url) - link_to t('statuses.show_more'), url, class: 'load-more load-gap' + def link_to_newer(url) + link_to t('statuses.show_newer'), url, class: 'load-more load-gap' + end + + def link_to_older(url) + link_to t('statuses.show_older'), url, class: 'load-more load-gap' end def nothing_here(extra_classes = '') @@ -88,22 +92,6 @@ module StatusesHelper end end - def rtl_status?(status) - status.local? ? rtl?(status.text) : rtl?(strip_tags(status.text)) - end - - def rtl?(text) - text = simplified_text(text) - rtl_words = text.scan(/[\p{Hebrew}\p{Arabic}\p{Syriac}\p{Thaana}\p{Nko}]+/m) - - if rtl_words.present? - total_size = text.size.to_f - rtl_size(rtl_words) / total_size > 0.3 - else - false - end - end - def fa_visibility_icon(status) case status.visibility when 'public' @@ -117,6 +105,14 @@ module StatusesHelper end end + def sensitized?(status, account) + if !account.nil? && account.id == status.account_id + status.sensitive + else + status.account.sensitized? || status.sensitive + end + end + private def simplified_text(text) @@ -131,10 +127,6 @@ module StatusesHelper end end - def rtl_size(words) - words.reduce(0) { |acc, elem| acc + elem.size }.to_f - end - def embedded_view? params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION end diff --git a/app/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb index ab7ca46981..482f4e19ea 100644 --- a/app/helpers/webfinger_helper.rb +++ b/app/helpers/webfinger_helper.rb @@ -1,38 +1,7 @@ # frozen_string_literal: true -# Monkey-patch on monkey-patch. -# Because it conflicts with the request.rb patch. -class HTTP::Timeout::PerOperationOriginal < HTTP::Timeout::PerOperation - def connect(socket_class, host, port, nodelay = false) - ::Timeout.timeout(@connect_timeout, HTTP::TimeoutError) do - @socket = socket_class.open(host, port) - @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay - end - end -end - module WebfingerHelper def webfinger!(uri) - hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri) - - raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if !Rails.configuration.x.access_to_hidden_service && hidden_service_uri - - opts = { - ssl: !hidden_service_uri, - - headers: { - 'User-Agent': Mastodon::Version.user_agent, - }, - - timeout_class: HTTP::Timeout::PerOperationOriginal, - - timeout_options: { - write_timeout: 10, - connect_timeout: 5, - read_timeout: 10, - }, - } - - Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger + Webfinger.new(uri).perform end end diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js index f2334c254b..d2db89ca77 100644 --- a/app/javascript/core/admin.js +++ b/app/javascript/core/admin.js @@ -1,5 +1,6 @@ // This file will be loaded on admin pages, regardless of theme. +import 'packs/public-path'; import { delegate } from '@rails/ujs'; import ready from '../mastodon/ready'; @@ -58,18 +59,46 @@ const onEnableBootstrapTimelineAccountsChange = (target) => { bootstrapTimelineAccountsField.disabled = !target.checked; if (target.checked) { bootstrapTimelineAccountsField.parentElement.classList.remove('disabled'); + bootstrapTimelineAccountsField.parentElement.parentElement.classList.remove('disabled'); } else { bootstrapTimelineAccountsField.parentElement.classList.add('disabled'); + bootstrapTimelineAccountsField.parentElement.parentElement.classList.add('disabled'); } } }; delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'change', ({ target }) => onEnableBootstrapTimelineAccountsChange(target)); +const onChangeRegistrationMode = (target) => { + const enabled = target.value === 'approved'; + + [].forEach.call(document.querySelectorAll('#form_admin_settings_require_invite_text'), (input) => { + input.disabled = !enabled; + if (enabled) { + let element = input; + do { + element.classList.remove('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } else { + let element = input; + do { + element.classList.add('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } + }); +}; + +delegate(document, '#form_admin_settings_registrations_mode', 'change', ({ target }) => onChangeRegistrationMode(target)); + ready(() => { const domainBlockSeverityInput = document.getElementById('domain_block_severity'); if (domainBlockSeverityInput) onDomainBlockSeverityChange(domainBlockSeverityInput); const enableBootstrapTimelineAccounts = document.getElementById('form_admin_settings_enable_bootstrap_timeline_accounts'); if (enableBootstrapTimelineAccounts) onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts); + + const registrationMode = document.getElementById('form_admin_settings_registrations_mode'); + if (registrationMode) onChangeRegistrationMode(registrationMode); }); diff --git a/app/javascript/core/auth.js b/app/javascript/core/auth.js new file mode 100644 index 0000000000..d1d14d99e8 --- /dev/null +++ b/app/javascript/core/auth.js @@ -0,0 +1,3 @@ +import 'packs/public-path'; +import './settings'; +import './two_factor_authentication'; diff --git a/app/javascript/core/common.js b/app/javascript/core/common.js index 3b038ca40c..1cee2f6036 100644 --- a/app/javascript/core/common.js +++ b/app/javascript/core/common.js @@ -1,5 +1,6 @@ // This file will be loaded on all pages, regardless of theme. +import 'packs/public-path'; import 'font-awesome/css/font-awesome.css'; require.context('../images/', true); diff --git a/app/javascript/core/embed.js b/app/javascript/core/embed.js index 6146e65929..9083eb7a3e 100644 --- a/app/javascript/core/embed.js +++ b/app/javascript/core/embed.js @@ -1,5 +1,7 @@ // This file will be loaded on embed pages, regardless of theme. +import 'packs/public-path'; + window.addEventListener('message', e => { const data = e.data || {}; diff --git a/app/javascript/core/public.js b/app/javascript/core/public.js index 8c26f14045..b67fb13e54 100644 --- a/app/javascript/core/public.js +++ b/app/javascript/core/public.js @@ -1,5 +1,6 @@ // This file will be loaded on public pages, regardless of theme. +import 'packs/public-path'; import ready from '../mastodon/ready'; const { delegate } = require('@rails/ujs'); diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js index 9fe03f90c2..d5bb9532c4 100644 --- a/app/javascript/core/settings.js +++ b/app/javascript/core/settings.js @@ -1,5 +1,6 @@ // This file will be loaded on settings pages, regardless of theme. +import 'packs/public-path'; import escapeTextContentForBrowser from 'escape-html'; const { delegate } = require('@rails/ujs'); import emojify from '../mastodon/features/emoji/emoji'; @@ -34,10 +35,12 @@ delegate(document, '#account_header', 'change', ({ target }) => { delegate(document, '#account_locked', 'change', ({ target }) => { const lock = document.querySelector('.card .display-name i'); - if (target.checked) { - lock.style.display = 'inline'; - } else { - lock.style.display = 'none'; + if (lock) { + if (target.checked) { + delete lock.dataset.hidden; + } else { + lock.dataset.hidden = 'true'; + } } }); diff --git a/app/javascript/core/theme.yml b/app/javascript/core/theme.yml index dc641772c5..b9144e43aa 100644 --- a/app/javascript/core/theme.yml +++ b/app/javascript/core/theme.yml @@ -3,7 +3,7 @@ pack: about: admin: admin.js - auth: settings.js + auth: auth.js common: filename: common.js stylesheet: true diff --git a/app/javascript/core/two_factor_authentication.js b/app/javascript/core/two_factor_authentication.js new file mode 100644 index 0000000000..f076cdf30a --- /dev/null +++ b/app/javascript/core/two_factor_authentication.js @@ -0,0 +1,119 @@ +import 'packs/public-path'; +import axios from 'axios'; +import * as WebAuthnJSON from '@github/webauthn-json'; +import ready from '../mastodon/ready'; +import 'regenerator-runtime/runtime'; + +function getCSRFToken() { + var CSRFSelector = document.querySelector('meta[name="csrf-token"]'); + if (CSRFSelector) { + return CSRFSelector.getAttribute('content'); + } else { + return null; + } +} + +function hideFlashMessages() { + Array.from(document.getElementsByClassName('flash-message')).forEach(function(flashMessage) { + flashMessage.classList.add('hidden'); + }); +} + +function callback(url, body) { + axios.post(url, JSON.stringify(body), { + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-Token': getCSRFToken(), + }, + credentials: 'same-origin', + }).then(function(response) { + window.location.replace(response.data.redirect_path); + }).catch(function(error) { + if (error.response.status === 422) { + const errorMessage = document.getElementById('security-key-error-message'); + errorMessage.classList.remove('hidden'); + console.error(error.response.data.error); + } else { + console.error(error); + } + }); +} + +ready(() => { + if (!WebAuthnJSON.supported()) { + const unsupported_browser_message = document.getElementById('unsupported-browser-message'); + if (unsupported_browser_message) { + unsupported_browser_message.classList.remove('hidden'); + document.querySelector('.btn.js-webauthn').disabled = true; + } + } + + + const webAuthnCredentialRegistrationForm = document.getElementById('new_webauthn_credential'); + if (webAuthnCredentialRegistrationForm) { + webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => { + event.preventDefault(); + + var nickname = event.target.querySelector('input[name="new_webauthn_credential[nickname]"]'); + if (nickname.value) { + axios.get('/settings/security_keys/options') + .then((response) => { + const credentialOptions = response.data; + + WebAuthnJSON.create({ 'publicKey': credentialOptions }).then((credential) => { + var params = { 'credential': credential, 'nickname': nickname.value }; + callback('/settings/security_keys', params); + }).catch((error) => { + const errorMessage = document.getElementById('security-key-error-message'); + errorMessage.classList.remove('hidden'); + console.error(error); + }); + }).catch((error) => { + console.error(error.response.data.error); + }); + } else { + nickname.focus(); + } + }); + } + + const webAuthnCredentialAuthenticationForm = document.getElementById('webauthn-form'); + if (webAuthnCredentialAuthenticationForm) { + webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => { + event.preventDefault(); + + axios.get('sessions/security_key_options') + .then((response) => { + const credentialOptions = response.data; + + WebAuthnJSON.get({ 'publicKey': credentialOptions }).then((credential) => { + var params = { 'user': { 'credential': credential } }; + callback('sign_in', params); + }).catch((error) => { + const errorMessage = document.getElementById('security-key-error-message'); + errorMessage.classList.remove('hidden'); + console.error(error); + }); + }).catch((error) => { + console.error(error.response.data.error); + }); + }); + + const otpAuthenticationForm = document.getElementById('otp-authentication-form'); + + const linkToOtp = document.getElementById('link-to-otp'); + linkToOtp.addEventListener('click', () => { + webAuthnCredentialAuthenticationForm.classList.add('hidden'); + otpAuthenticationForm.classList.remove('hidden'); + hideFlashMessages(); + }); + + const linkToWebAuthn = document.getElementById('link-to-webauthn'); + linkToWebAuthn.addEventListener('click', () => { + otpAuthenticationForm.classList.add('hidden'); + webAuthnCredentialAuthenticationForm.classList.remove('hidden'); + hideFlashMessages(); + }); + } +}); diff --git a/app/javascript/flavours/glitch/actions/account_notes.js b/app/javascript/flavours/glitch/actions/account_notes.js new file mode 100644 index 0000000000..c1cce31939 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/account_notes.js @@ -0,0 +1,69 @@ +import api from 'flavours/glitch/util/api'; + +export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST'; +export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS'; +export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL'; + +export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT'; +export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL'; + +export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT'; + +export function submitAccountNote() { + return (dispatch, getState) => { + dispatch(submitAccountNoteRequest()); + + const id = getState().getIn(['account_notes', 'edit', 'account_id']); + + api(getState).post(`/api/v1/accounts/${id}/note`, { + comment: getState().getIn(['account_notes', 'edit', 'comment']), + }).then(response => { + dispatch(submitAccountNoteSuccess(response.data)); + }).catch(error => dispatch(submitAccountNoteFail(error))); + }; +}; + +export function submitAccountNoteRequest() { + return { + type: ACCOUNT_NOTE_SUBMIT_REQUEST, + }; +}; + +export function submitAccountNoteSuccess(relationship) { + return { + type: ACCOUNT_NOTE_SUBMIT_SUCCESS, + relationship, + }; +}; + +export function submitAccountNoteFail(error) { + return { + type: ACCOUNT_NOTE_SUBMIT_FAIL, + error, + }; +}; + +export function initEditAccountNote(account) { + return (dispatch, getState) => { + const comment = getState().getIn(['relationships', account.get('id'), 'note']); + + dispatch({ + type: ACCOUNT_NOTE_INIT_EDIT, + account, + comment, + }); + }; +}; + +export function cancelAccountNote() { + return { + type: ACCOUNT_NOTE_CANCEL, + }; +}; + +export function changeAccountNoteComment(comment) { + return { + type: ACCOUNT_NOTE_CHANGE_COMMENT, + comment, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js index e1012a80bc..912a3d179f 100644 --- a/app/javascript/flavours/glitch/actions/accounts.js +++ b/app/javascript/flavours/glitch/actions/accounts.js @@ -126,15 +126,17 @@ export function fetchAccountFail(id, error) { }; }; -export function followAccount(id, reblogs = true) { +export function followAccount(id, options = { reblogs: true }) { return (dispatch, getState) => { const alreadyFollowing = getState().getIn(['relationships', id, 'following']); - dispatch(followAccountRequest(id)); + const locked = getState().getIn(['accounts', id, 'locked'], false); - api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => { + dispatch(followAccountRequest(id, locked)); + + api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => { dispatch(followAccountSuccess(response.data, alreadyFollowing)); }).catch(error => { - dispatch(followAccountFail(error)); + dispatch(followAccountFail(error, locked)); }); }; }; @@ -151,10 +153,12 @@ export function unfollowAccount(id) { }; }; -export function followAccountRequest(id) { +export function followAccountRequest(id, locked) { return { type: ACCOUNT_FOLLOW_REQUEST, id, + locked, + skipLoading: true, }; }; @@ -163,13 +167,16 @@ export function followAccountSuccess(relationship, alreadyFollowing) { type: ACCOUNT_FOLLOW_SUCCESS, relationship, alreadyFollowing, + skipLoading: true, }; }; -export function followAccountFail(error) { +export function followAccountFail(error, locked) { return { type: ACCOUNT_FOLLOW_FAIL, error, + locked, + skipLoading: true, }; }; @@ -177,6 +184,7 @@ export function unfollowAccountRequest(id) { return { type: ACCOUNT_UNFOLLOW_REQUEST, id, + skipLoading: true, }; }; @@ -185,6 +193,7 @@ export function unfollowAccountSuccess(relationship, statuses) { type: ACCOUNT_UNFOLLOW_SUCCESS, relationship, statuses, + skipLoading: true, }; }; @@ -192,6 +201,7 @@ export function unfollowAccountFail(error) { return { type: ACCOUNT_UNFOLLOW_FAIL, error, + skipLoading: true, }; }; @@ -264,11 +274,11 @@ export function unblockAccountFail(error) { }; -export function muteAccount(id, notifications) { +export function muteAccount(id, notifications, duration=0) { return (dispatch, getState) => { dispatch(muteAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).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/flavours/glitch/actions/boosts.js b/app/javascript/flavours/glitch/actions/boosts.js new file mode 100644 index 0000000000..6e14065d6f --- /dev/null +++ b/app/javascript/flavours/glitch/actions/boosts.js @@ -0,0 +1,29 @@ +import { openModal } from './modal'; + +export const BOOSTS_INIT_MODAL = 'BOOSTS_INIT_MODAL'; +export const BOOSTS_CHANGE_PRIVACY = 'BOOSTS_CHANGE_PRIVACY'; + +export function initBoostModal(props) { + return (dispatch, getState) => { + const default_privacy = getState().getIn(['compose', 'default_privacy']); + + const privacy = props.status.get('visibility') === 'private' ? 'private' : default_privacy; + + dispatch({ + type: BOOSTS_INIT_MODAL, + privacy + }); + + dispatch(openModal('BOOST', props)); + }; +} + + +export function changeBoostPrivacy(privacy) { + return dispatch => { + dispatch({ + type: BOOSTS_CHANGE_PRIVACY, + privacy, + }); + }; +} diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index f98cb7bf86..f837380936 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -30,6 +30,11 @@ export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS'; export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; +export const THUMBNAIL_UPLOAD_REQUEST = 'THUMBNAIL_UPLOAD_REQUEST'; +export const THUMBNAIL_UPLOAD_SUCCESS = 'THUMBNAIL_UPLOAD_SUCCESS'; +export const THUMBNAIL_UPLOAD_FAIL = 'THUMBNAIL_UPLOAD_FAIL'; +export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS'; + export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; @@ -193,7 +198,9 @@ export function submitCompose(routerHistory) { if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { insertIfOnline('community'); - insertIfOnline('public'); + if (!response.data.local_only) { + insertIfOnline('public'); + } } else if (response.data.visibility === 'direct') { insertIfOnline('direct'); } @@ -289,6 +296,49 @@ export function uploadCompose(files) { }; }; +export const uploadThumbnail = (id, file) => (dispatch, getState) => { + dispatch(uploadThumbnailRequest()); + + const total = file.size; + const data = new FormData(); + + data.append('thumbnail', file); + + api(getState).put(`/api/v1/media/${id}`, data, { + onUploadProgress: ({ loaded }) => { + dispatch(uploadThumbnailProgress(loaded, total)); + }, + }).then(({ data }) => { + dispatch(uploadThumbnailSuccess(data)); + }).catch(error => { + dispatch(uploadThumbnailFail(id, error)); + }); +}; + +export const uploadThumbnailRequest = () => ({ + type: THUMBNAIL_UPLOAD_REQUEST, + skipLoading: true, +}); + +export const uploadThumbnailProgress = (loaded, total) => ({ + type: THUMBNAIL_UPLOAD_PROGRESS, + loaded, + total, + skipLoading: true, +}); + +export const uploadThumbnailSuccess = media => ({ + type: THUMBNAIL_UPLOAD_SUCCESS, + media, + skipLoading: true, +}); + +export const uploadThumbnailFail = error => ({ + type: THUMBNAIL_UPLOAD_FAIL, + error, + skipLoading: true, +}); + export function changeUploadCompose(id, params) { return (dispatch, getState) => { dispatch(changeUploadComposeRequest()); @@ -307,6 +357,7 @@ export function changeUploadComposeRequest() { skipLoading: true, }; }; + export function changeUploadComposeSuccess(media) { return { type: COMPOSE_UPLOAD_CHANGE_SUCCESS, diff --git a/app/javascript/flavours/glitch/actions/dropdown_menu.js b/app/javascript/flavours/glitch/actions/dropdown_menu.js index 14f2939c78..fb6e55612e 100644 --- a/app/javascript/flavours/glitch/actions/dropdown_menu.js +++ b/app/javascript/flavours/glitch/actions/dropdown_menu.js @@ -1,8 +1,8 @@ export const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; export const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; -export function openDropdownMenu(id, placement, keyboard) { - return { type: DROPDOWN_MENU_OPEN, id, placement, keyboard }; +export function openDropdownMenu(id, placement, keyboard, scroll_key) { + return { type: DROPDOWN_MENU_OPEN, id, placement, keyboard, scroll_key }; } export function closeDropdownMenu(id) { diff --git a/app/javascript/flavours/glitch/actions/interactions.js b/app/javascript/flavours/glitch/actions/interactions.js index 4407f8b6ea..336c8fa907 100644 --- a/app/javascript/flavours/glitch/actions/interactions.js +++ b/app/javascript/flavours/glitch/actions/interactions.js @@ -41,11 +41,11 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; -export function reblog(status) { +export function reblog(status, visibility) { return function (dispatch, getState) { dispatch(reblogRequest(status)); - api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) { + api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`, { visibility }).then(function (response) { // The reblog API method returns a new status wrapped around the original. In this case we are only // interested in how the original is modified, hence passing it skipping the wrapper dispatch(importFetchedStatus(response.data.reblog)); diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js index 96e29accf4..a086def979 100644 --- a/app/javascript/flavours/glitch/actions/markers.js +++ b/app/javascript/flavours/glitch/actions/markers.js @@ -1,7 +1,6 @@ import api from 'flavours/glitch/util/api'; import { debounce } from 'lodash'; import compareId from 'flavours/glitch/util/compare_id'; -import { showAlertForError } from './alerts'; export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST'; export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS'; @@ -29,15 +28,19 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => { }, body: JSON.stringify(params), }); + return; } else if (navigator && navigator.sendBeacon) { // Failing that, we can use sendBeacon, but we have to encode the data as // FormData for DoorKeeper to recognize the token. const formData = new FormData(); + formData.append('bearer_token', accessToken); + for (const [id, value] of Object.entries(params)) { formData.append(`${id}[last_read_id]`, value.last_read_id); } + if (navigator.sendBeacon('/api/v1/markers', formData)) { return; } @@ -60,7 +63,7 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => { const _buildParams = (state) => { const params = {}; - const lastHomeId = state.getIn(['timelines', 'home', 'items', 0]); + const lastHomeId = state.getIn(['timelines', 'home', 'items']).find(item => item !== null); const lastNotificationId = state.getIn(['notifications', 'lastReadId']); if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) { @@ -85,11 +88,9 @@ const debouncedSubmitMarkers = debounce((dispatch, getState) => { return; } - api().post('/api/v1/markers', params).then(() => { + api(getState).post('/api/v1/markers', params).then(() => { dispatch(submitMarkersSuccess(params)); - }).catch(error => { - dispatch(showAlertForError(error)); - }); + }).catch(() => {}); }, 300000, { leading: true, trailing: true }); export function submitMarkersSuccess({ home, notifications }) { @@ -100,20 +101,26 @@ export function submitMarkersSuccess({ home, notifications }) { }; }; -export function submitMarkers() { - return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); +export function submitMarkers(params = {}) { + const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); + + if (params.immediate === true) { + debouncedSubmitMarkers.flush(); + } + + return result; }; export const fetchMarkers = () => (dispatch, getState) => { - const params = { timeline: ['notifications'] }; + const params = { timeline: ['notifications'] }; - dispatch(fetchMarkersRequest()); + dispatch(fetchMarkersRequest()); - api(getState).get('/api/v1/markers', { params }).then(response => { - dispatch(fetchMarkersSuccess(response.data)); - }).catch(error => { - dispatch(fetchMarkersFail(error)); - }); + api(getState).get('/api/v1/markers', { params }).then(response => { + dispatch(fetchMarkersSuccess(response.data)); + }).catch(error => { + dispatch(fetchMarkersFail(error)); + }); }; export function fetchMarkersRequest() { diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js index 927fc7415e..2bacfadb70 100644 --- a/app/javascript/flavours/glitch/actions/mutes.js +++ b/app/javascript/flavours/glitch/actions/mutes.js @@ -13,6 +13,7 @@ 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 const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; export function fetchMutes() { return (dispatch, getState) => { @@ -104,3 +105,12 @@ export function toggleHideNotifications() { dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); }; } + +export function changeMuteDuration(duration) { + return dispatch => { + dispatch({ + type: MUTES_CHANGE_DURATION, + duration, + }); + }; +} diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index ceb1e6df6e..bd3a34e5d9 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -16,8 +16,10 @@ import { getFiltersRegex } from 'flavours/glitch/selectors'; import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state'; import compareId from 'flavours/glitch/util/compare_id'; import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer'; +import { requestNotificationPermission } from 'flavours/glitch/util/notifications'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; +export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; // tracking the notif cleaning request export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST'; @@ -45,6 +47,12 @@ export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY'; + +export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; + +export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; + defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, }); @@ -70,7 +78,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { let filtered = false; - if (notification.type === 'mention') { + if (['mention', 'status'].includes(notification.type)) { const dropRegex = filters[0]; const regex = filters[1]; const searchIndex = searchTextFromRawStatus(notification.status); @@ -318,3 +326,48 @@ export function setFilter (filterType) { dispatch(saveSettings()); }; }; + +export function markNotificationsAsRead() { + return { + type: NOTIFICATIONS_MARK_AS_READ, + }; +}; + +// Browser support +export function setupBrowserNotifications() { + return dispatch => { + dispatch(setBrowserSupport('Notification' in window)); + if ('Notification' in window) { + dispatch(setBrowserPermission(Notification.permission)); + } + + if ('Notification' in window && 'permissions' in navigator) { + navigator.permissions.query({ name: 'notifications' }).then((status) => { + status.onchange = () => dispatch(setBrowserPermission(Notification.permission)); + }).catch(console.warn); + } + }; +} + +export function requestBrowserPermission(callback = noOp) { + return dispatch => { + requestNotificationPermission((permission) => { + dispatch(setBrowserPermission(permission)); + callback(permission); + }); + }; +}; + +export function setBrowserSupport (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_SUPPORT, + value, + }; +} + +export function setBrowserPermission (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_PERMISSION, + value, + }; +} diff --git a/app/javascript/flavours/glitch/actions/picture_in_picture.js b/app/javascript/flavours/glitch/actions/picture_in_picture.js new file mode 100644 index 0000000000..4085cb59e0 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/picture_in_picture.js @@ -0,0 +1,38 @@ +// @ts-check + +export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY'; +export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE'; + +/** + * @typedef MediaProps + * @property {string} src + * @property {boolean} muted + * @property {number} volume + * @property {number} currentTime + * @property {string} poster + * @property {string} backgroundColor + * @property {string} foregroundColor + * @property {string} accentColor + */ + +/** + * @param {string} statusId + * @param {string} accountId + * @param {string} playerType + * @param {MediaProps} props + * @return {object} + */ +export const deployPictureInPicture = (statusId, accountId, playerType, props) => ({ + type: PICTURE_IN_PICTURE_DEPLOY, + statusId, + accountId, + playerType, + props, +}); + +/* + * @return {object} + */ +export const removePictureInPicture = () => ({ + type: PICTURE_IN_PICTURE_REMOVE, +}); diff --git a/app/javascript/flavours/glitch/actions/store.js b/app/javascript/flavours/glitch/actions/store.js index 34dcafc510..9dbc0b2148 100644 --- a/app/javascript/flavours/glitch/actions/store.js +++ b/app/javascript/flavours/glitch/actions/store.js @@ -1,6 +1,7 @@ import { Iterable, fromJS } from 'immutable'; import { hydrateCompose } from './compose'; import { importFetchedAccounts } from './importer'; +import { saveSettings } from './settings'; export const STORE_HYDRATE = 'STORE_HYDRATE'; export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; @@ -9,9 +10,22 @@ const convertState = rawState => fromJS(rawState, (k, v) => Iterable.isIndexed(v) ? v.toList() : v.toMap()); +const applyMigrations = (state) => { + return state.withMutations(state => { + // Migrate glitch-soc local-only “Show unread marker” setting to Mastodon's setting + if (state.getIn(['local_settings', 'notifications', 'show_unread']) !== undefined) { + // Only change if the Mastodon setting does not deviate from default + if (state.getIn(['settings', 'notifications', 'showUnread']) !== false) { + state.setIn(['settings', 'notifications', 'showUnread'], state.getIn(['local_settings', 'notifications', 'show_unread'])); + } + state.removeIn(['local_settings', 'notifications', 'show_unread']) + } + }); +}; + export function hydrateStore(rawState) { return dispatch => { - const state = convertState(rawState); + const state = applyMigrations(convertState(rawState)); dispatch({ type: STORE_HYDRATE, @@ -20,5 +34,6 @@ export function hydrateStore(rawState) { dispatch(hydrateCompose()); dispatch(importFetchedAccounts(Object.values(rawState.accounts))); + dispatch(saveSettings()); }; }; diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index e7e57f5f59..35db5dcc92 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -1,3 +1,5 @@ +// @ts-check + import { connectStream } from 'flavours/glitch/util/stream'; import { updateTimeline, @@ -19,24 +21,59 @@ import { getLocale } from 'mastodon/locales'; const { messages } = getLocale(); -export function connectTimelineStream (timelineId, path, pollingRefresh = null, accept = null) { +/** + * @param {number} max + * @return {number} + */ +const randomUpTo = max => + Math.floor(Math.random() * Math.floor(max)); - return connectStream (path, pollingRefresh, (dispatch, getState) => { +/** + * @param {string} timelineId + * @param {string} channelName + * @param {Object.} params + * @param {Object} options + * @param {function(Function, Function): void} [options.fallback] + * @param {function(object): boolean} [options.accept] + * @return {function(): void} + */ +export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => + connectStream(channelName, params, (dispatch, getState) => { const locale = getState().getIn(['meta', 'locale']); + let pollingId; + + /** + * @param {function(Function, Function): void} fallback + */ + const useFallback = fallback => { + fallback(dispatch, () => { + pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000)); + }); + }; + return { onConnect() { dispatch(connectTimeline(timelineId)); + + if (pollingId) { + clearTimeout(pollingId); + pollingId = null; + } }, onDisconnect() { dispatch(disconnectTimeline(timelineId)); + + if (options.fallback) { + pollingId = setTimeout(() => useFallback(options.fallback), randomUpTo(40000)); + } }, onReceive (data) { switch(data.event) { case 'update': - dispatch(updateTimeline(timelineId, JSON.parse(data.payload), accept)); + dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept)); break; case 'delete': dispatch(deleteFromTimelines(data.payload)); @@ -63,17 +100,60 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null, }, }; }); -} +/** + * @param {Function} dispatch + * @param {function(): void} done + */ const refreshHomeTimelineAndNotification = (dispatch, done) => { dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, () => dispatch(fetchAnnouncements(done)))))); }; -export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); -export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); -export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) => connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`); -export const connectHashtagStream = (id, tag, local, accept) => connectTimelineStream(`hashtag:${id}${local ? ':local' : ''}`, `hashtag${local ? ':local' : ''}&tag=${tag}`, null, accept); -export const connectDirectStream = () => connectTimelineStream('direct', 'direct'); -export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`); +/** + * @return {function(): void} + */ +export const connectUserStream = () => + connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification }); + +/** + * @param {Object} options + * @param {boolean} [options.onlyMedia] + * @return {function(): void} + */ +export const connectCommunityStream = ({ onlyMedia } = {}) => + connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); + +/** + * @param {Object} options + * @param {boolean} [options.onlyMedia] + * @param {boolean} [options.onlyRemote] + * @param {boolean} [options.allowLocalOnly] + * @return {function(): void} + */ +export const connectPublicStream = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}) => + connectTimelineStream(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`); + +/** + * @param {string} columnId + * @param {string} tagName + * @param {boolean} onlyLocal + * @param {function(object): boolean} accept + * @return {function(): void} + */ +export const connectHashtagStream = (columnId, tagName, onlyLocal, accept) => + connectTimelineStream(`hashtag:${columnId}${onlyLocal ? ':local' : ''}`, `hashtag${onlyLocal ? ':local' : ''}`, { tag: tagName }, { accept }); + +/** + * @return {function(): void} + */ +export const connectDirectStream = () => + connectTimelineStream('direct', 'direct'); + +/** + * @param {string} listId + * @return {function(): void} + */ +export const connectListStream = listId => + connectTimelineStream(`list:${listId}`, 'list', { list: listId }); diff --git a/app/javascript/flavours/glitch/actions/suggestions.js b/app/javascript/flavours/glitch/actions/suggestions.js index 3687136ff4..e867bccf73 100644 --- a/app/javascript/flavours/glitch/actions/suggestions.js +++ b/app/javascript/flavours/glitch/actions/suggestions.js @@ -11,8 +11,8 @@ export function fetchSuggestions() { return (dispatch, getState) => { dispatch(fetchSuggestionsRequest()); - api(getState).get('/api/v1/suggestions').then(response => { - dispatch(importFetchedAccounts(response.data)); + api(getState).get('/api/v2/suggestions').then(response => { + dispatch(importFetchedAccounts(response.data.map(x => x.account))); dispatch(fetchSuggestionsSuccess(response.data)); }).catch(error => dispatch(fetchSuggestionsFail(error))); }; @@ -25,10 +25,10 @@ export function fetchSuggestionsRequest() { }; }; -export function fetchSuggestionsSuccess(accounts) { +export function fetchSuggestionsSuccess(suggestions) { return { type: SUGGESTIONS_FETCH_SUCCESS, - accounts, + suggestions, skipLoading: true, }; }; @@ -48,5 +48,12 @@ export const dismissSuggestion = accountId => (dispatch, getState) => { id: accountId, }); - api(getState).delete(`/api/v1/suggestions/${accountId}`); + api(getState).delete(`/api/v1/suggestions/${accountId}`).then(() => { + dispatch(fetchSuggestionsRequest()); + + api(getState).get('/api/v2/suggestions').then(response => { + dispatch(importFetchedAccounts(response.data.map(x => x.account))); + dispatch(fetchSuggestionsSuccess(response.data)); + }).catch(error => dispatch(fetchSuggestionsFail(error))); + }).catch(() => {}); }; diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index 9a7f62a085..b19666e62c 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -130,7 +130,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { }; export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); -export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia }, done); +export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, allow_local_only: !!allowLocalOnly, max_id: maxId, only_media: !!onlyMedia }, done); export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js index f3e58dfe32..20313535bf 100644 --- a/app/javascript/flavours/glitch/components/account.js +++ b/app/javascript/flavours/glitch/components/account.js @@ -8,6 +8,7 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { me } from 'flavours/glitch/util/initial_state'; +import RelativeTimestamp from './relative_timestamp'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -86,8 +87,10 @@ class Account extends ImmutablePureComponent { let buttons; - if (onActionClick && actionIcon) { - buttons = ; + if (onActionClick) { + if (actionIcon) { + buttons = ; + } } else if (account.get('id') !== me && !small && account.get('relationship', null) !== null) { const following = account.getIn(['relationship', 'following']); const requested = account.getIn(['relationship', 'requested']); @@ -116,6 +119,11 @@ class Account extends ImmutablePureComponent { } } + let mute_expires_at; + if (account.get('mute_expires_at')) { + mute_expires_at =
; + } + return small ? (
+ {mute_expires_at}
{buttons ? diff --git a/app/javascript/flavours/glitch/components/animated_number.js b/app/javascript/flavours/glitch/components/animated_number.js index e3235e368e..3cc5173dd4 100644 --- a/app/javascript/flavours/glitch/components/animated_number.js +++ b/app/javascript/flavours/glitch/components/animated_number.js @@ -5,10 +5,21 @@ import TransitionMotion from 'react-motion/lib/TransitionMotion'; import spring from 'react-motion/lib/spring'; import { reduceMotion } from 'flavours/glitch/util/initial_state'; +const obfuscatedCount = count => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + export default class AnimatedNumber extends React.PureComponent { static propTypes = { value: PropTypes.number.isRequired, + obfuscate: PropTypes.bool, }; state = { @@ -36,11 +47,11 @@ export default class AnimatedNumber extends React.PureComponent { } render () { - const { value } = this.props; + const { value, obfuscate } = this.props; const { direction } = this.state; if (reduceMotion) { - return ; + return obfuscate ? obfuscatedCount(value) : ; } const styles = [{ @@ -54,7 +65,7 @@ export default class AnimatedNumber extends React.PureComponent { {items => ( {items.map(({ key, data, style }) => ( - 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}> + 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : } ))} )} diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js index c8609e48f6..d04c1eb687 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_emoji.js +++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; -const assetHost = process.env.CDN_HOST || ''; +import { assetHost } from 'flavours/glitch/util/config'; export default class AutosuggestEmoji extends React.PureComponent { diff --git a/app/javascript/flavours/glitch/components/autosuggest_hashtag.js b/app/javascript/flavours/glitch/components/autosuggest_hashtag.js index 648987dfd4..d787ed07ad 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_hashtag.js +++ b/app/javascript/flavours/glitch/components/autosuggest_hashtag.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { shortNumberFormat } from 'flavours/glitch/util/numbers'; +import ShortNumber from 'flavours/glitch/components/short_number'; import { FormattedMessage } from 'react-intl'; export default class AutosuggestHashtag extends React.PureComponent { @@ -13,14 +13,28 @@ export default class AutosuggestHashtag extends React.PureComponent { }).isRequired, }; - render () { + render() { const { tag } = this.props; - const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0)); + const weeklyUses = tag.history && ( + total + day.uses * 1, 0)} + /> + ); return (
-
#{tag.name}
- {tag.history !== undefined &&
} +
+ #{tag.name} +
+ {tag.history !== undefined && ( +
+ +
+ )}
); } diff --git a/app/javascript/flavours/glitch/components/autosuggest_input.js b/app/javascript/flavours/glitch/components/autosuggest_input.js index 1ef7ee2168..b40a2ff350 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_input.js +++ b/app/javascript/flavours/glitch/components/autosuggest_input.js @@ -4,10 +4,8 @@ import AutosuggestEmoji from './autosuggest_emoji'; import AutosuggestHashtag from './autosuggest_hashtag'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import { isRtl } from 'flavours/glitch/util/rtl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import classNames from 'classnames'; -import { List as ImmutableList } from 'immutable'; const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { let word; @@ -56,7 +54,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { static defaultProps = { autoFocus: true, - searchTokens: ImmutableList(['@', ':', '#']), + searchTokens: ['@', ':', '#'], }; state = { @@ -189,11 +187,6 @@ export default class AutosuggestInput extends ImmutablePureComponent { render () { const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props; const { suggestionsHidden } = this.state; - const style = { direction: 'ltr' }; - - if (isRtl(value)) { - style.direction = 'rtl'; - } return (
@@ -212,7 +205,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { onKeyUp={onKeyUp} onFocus={this.onFocus} onBlur={this.onBlur} - style={style} + dir='auto' aria-autocomplete='list' id={id} className={className} diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js index 1ce2f42b41..967c593af2 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_textarea.js +++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.js @@ -4,7 +4,6 @@ import AutosuggestEmoji from './autosuggest_emoji'; import AutosuggestHashtag from './autosuggest_hashtag'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import { isRtl } from 'flavours/glitch/util/rtl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Textarea from 'react-textarea-autosize'; import classNames from 'classnames'; @@ -195,11 +194,6 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { render () { const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props; const { suggestionsHidden } = this.state; - const style = { direction: 'ltr' }; - - if (isRtl(value)) { - style.direction = 'rtl'; - } return [
@@ -220,7 +214,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { onFocus={this.onFocus} onBlur={this.onBlur} onPaste={this.onPaste} - style={style} + dir='auto' aria-autocomplete='list' /> diff --git a/app/javascript/flavours/glitch/components/blurhash.js b/app/javascript/flavours/glitch/components/blurhash.js new file mode 100644 index 0000000000..2af5cfc568 --- /dev/null +++ b/app/javascript/flavours/glitch/components/blurhash.js @@ -0,0 +1,65 @@ +// @ts-check + +import { decode } from 'blurhash'; +import React, { useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; + +/** + * @typedef BlurhashPropsBase + * @property {string?} hash Hash to render + * @property {number} width + * Width of the blurred region in pixels. Defaults to 32 + * @property {number} [height] + * Height of the blurred region in pixels. Defaults to width + * @property {boolean} [dummy] + * Whether dummy mode is enabled. If enabled, nothing is rendered + * and canvas left untouched + */ + +/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */ + +/** + * Component that is used to render blurred of blurhash string + * + * @param {BlurhashProps} param1 Props of the component + * @returns Canvas which will render blurred region element to embed + */ +function Blurhash({ + hash, + width = 32, + height = width, + dummy = false, + ...canvasProps +}) { + const canvasRef = /** @type {import('react').MutableRefObject} */ (useRef()); + + useEffect(() => { + const { current: canvas } = canvasRef; + canvas.width = canvas.width; // resets canvas + + if (dummy || !hash) return; + + try { + const pixels = decode(hash, width, height); + const ctx = canvas.getContext('2d'); + const imageData = new ImageData(pixels, width, height); + + ctx.putImageData(imageData, 0, 0); + } catch (err) { + console.error('Blurhash decoding failure', { err, hash }); + } + }, [dummy, hash, width, height]); + + return ( + + ); +} + +Blurhash.propTypes = { + hash: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + dummy: PropTypes.bool, +}; + +export default React.memo(Blurhash); diff --git a/app/javascript/flavours/glitch/components/button.js b/app/javascript/flavours/glitch/components/button.js index cd6528f583..b1815c3e19 100644 --- a/app/javascript/flavours/glitch/components/button.js +++ b/app/javascript/flavours/glitch/components/button.js @@ -10,17 +10,11 @@ export default class Button extends React.PureComponent { disabled: PropTypes.bool, block: PropTypes.bool, secondary: PropTypes.bool, - size: PropTypes.number, className: PropTypes.string, title: PropTypes.string, - style: PropTypes.object, children: PropTypes.node, }; - static defaultProps = { - size: 36, - }; - handleClick = (e) => { if (!this.props.disabled) { this.props.onClick(e); @@ -44,12 +38,6 @@ export default class Button extends React.PureComponent { disabled: this.props.disabled, onClick: this.handleClick, ref: this.setRef, - style: { - padding: `0 ${this.props.size / 2.25}px`, - height: `${this.props.size}px`, - lineHeight: `${this.props.size}px`, - ...this.props.style, - }, }; if (this.props.title) attrs.title = this.props.title; diff --git a/app/javascript/flavours/glitch/components/column.js b/app/javascript/flavours/glitch/components/column.js index 5819d5362c..c9da7d329e 100644 --- a/app/javascript/flavours/glitch/components/column.js +++ b/app/javascript/flavours/glitch/components/column.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import detectPassiveEvents from 'detect-passive-events'; +import { supportsPassiveEvents } from 'detect-passive-events'; import { scrollTop } from 'flavours/glitch/util/scroll'; export default class Column extends React.PureComponent { @@ -37,9 +37,9 @@ export default class Column extends React.PureComponent { componentDidMount () { if (this.props.bindToDocument) { - document.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); } else { - this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); } } diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js index 01bd4a2469..ccd0714f1b 100644 --- a/app/javascript/flavours/glitch/components/column_header.js +++ b/app/javascript/flavours/glitch/components/column_header.js @@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent { onMove: PropTypes.func, onClick: PropTypes.func, appendContent: PropTypes.node, + collapseIssues: PropTypes.bool, }; state = { @@ -88,7 +89,7 @@ class ColumnHeader extends React.PureComponent { } render () { - const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props; + const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props; const { collapsed, animating } = this.state; const wrapperClassName = classNames('column-header__wrapper', { @@ -150,7 +151,20 @@ class ColumnHeader extends React.PureComponent { } if (children || (multiColumn && this.props.onPin)) { - collapseButton = ; + collapseButton = ( + + ); } const hasTitle = icon && title; diff --git a/app/javascript/flavours/glitch/components/common_counter.js b/app/javascript/flavours/glitch/components/common_counter.js new file mode 100644 index 0000000000..e10cd9b765 --- /dev/null +++ b/app/javascript/flavours/glitch/components/common_counter.js @@ -0,0 +1,62 @@ +// @ts-check +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +/** + * Returns custom renderer for one of the common counter types + * + * @param {"statuses" | "following" | "followers"} counterType + * Type of the counter + * @param {boolean} isBold Whether display number must be displayed in bold + * @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element} + * Renderer function + * @throws If counterType is not covered by this function + */ +export function counterRenderer(counterType, isBold = true) { + /** + * @type {(displayNumber: JSX.Element) => JSX.Element} + */ + const renderCounter = isBold + ? (displayNumber) => {displayNumber} + : (displayNumber) => displayNumber; + + switch (counterType) { + case 'statuses': { + return (displayNumber, pluralReady) => ( + + ); + } + case 'following': { + return (displayNumber, pluralReady) => ( + + ); + } + case 'followers': { + return (displayNumber, pluralReady) => ( + + ); + } + default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`); + } +} diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js index 44662a8b87..ad978a2c61 100644 --- a/app/javascript/flavours/glitch/components/display_name.js +++ b/app/javascript/flavours/glitch/components/display_name.js @@ -15,45 +15,30 @@ export default class DisplayName extends React.PureComponent { handleClick: PropTypes.func, }; - _updateEmojis () { - const node = this.node; - - if (!node || autoPlayGif) { + handleMouseEnter = ({ currentTarget }) => { + if (autoPlayGif) { return; } - const emojis = node.querySelectorAll('.custom-emoji'); + const emojis = currentTarget.querySelectorAll('.custom-emoji'); for (var i = 0; i < emojis.length; i++) { let emoji = emojis[i]; - if (emoji.classList.contains('status-emoji')) { - continue; - } - emoji.classList.add('status-emoji'); - - emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false); - emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false); + emoji.src = emoji.getAttribute('data-original'); } } - componentDidMount () { - this._updateEmojis(); - } + handleMouseLeave = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } - componentDidUpdate () { - this._updateEmojis(); - } + const emojis = currentTarget.querySelectorAll('.custom-emoji'); - handleEmojiMouseEnter = ({ target }) => { - target.src = target.getAttribute('data-original'); - } - - handleEmojiMouseLeave = ({ target }) => { - target.src = target.getAttribute('data-static'); - } - - setRef = (c) => { - this.node = c; + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-static'); + } } render() { @@ -101,7 +86,7 @@ export default class DisplayName extends React.PureComponent { } return ( - + {displayName} {inline ? ' ' : null} {suffix} diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js index 60ed859a3f..023fecb9ab 100644 --- a/app/javascript/flavours/glitch/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/components/dropdown_menu.js @@ -5,9 +5,9 @@ import IconButton from './icon_button'; import Overlay from 'react-overlays/lib/Overlay'; import Motion from 'flavours/glitch/util/optional_motion'; import spring from 'react-motion/lib/spring'; -import detectPassiveEvents from 'detect-passive-events'; +import { supportsPassiveEvents } from 'detect-passive-events'; -const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; let id = 0; class DropdownMenu extends React.PureComponent { @@ -177,7 +177,6 @@ export default class Dropdown extends React.PureComponent { disabled: PropTypes.bool, status: ImmutablePropTypes.map, isUserTouching: PropTypes.func, - isModalOpen: PropTypes.bool.isRequired, onOpen: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, dropdownPlacement: PropTypes.string, @@ -205,7 +204,7 @@ export default class Dropdown extends React.PureComponent { handleClose = () => { if (this.activeElement) { - this.activeElement.focus(); + this.activeElement.focus({ preventScroll: true }); this.activeElement = null; } this.props.onClose(this.state.id); diff --git a/app/javascript/flavours/glitch/components/error_boundary.js b/app/javascript/flavours/glitch/components/error_boundary.js index 8998802b1b..8e6cd14617 100644 --- a/app/javascript/flavours/glitch/components/error_boundary.js +++ b/app/javascript/flavours/glitch/components/error_boundary.js @@ -48,6 +48,8 @@ export default class ErrorBoundary extends React.PureComponent { if (!hasError) return this.props.children; + const likelyBrowserAddonIssue = errorMessage && errorMessage.includes('NotFoundError'); + let debugInfo = ''; if (stackTrace) { debugInfo += 'Stack trace\n-----------\n\n```\n' + errorMessage + '\n' + stackTrace.toString() + '\n```'; @@ -70,6 +72,14 @@ export default class ErrorBoundary extends React.PureComponent {

    + { likelyBrowserAddonIssue && ( +
  • + +
  • + ) }
  • JSX.Element} + */ +const accountsCountRenderer = (displayNumber, pluralReady) => ( + {displayNumber}, + }} + /> +); const Hashtag = ({ hashtag }) => (
    - + #{hashtag.get('name')} - {shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)} }} /> +
    - {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)} +
    - day.get('uses')).toArray()}> - - + + day.get('uses')) + .toArray()} + > + + +
    ); diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js index e134d0a39c..58d3568dd2 100644 --- a/app/javascript/flavours/glitch/components/icon_button.js +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -4,6 +4,7 @@ import spring from 'react-motion/lib/spring'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import Icon from 'flavours/glitch/components/icon'; +import AnimatedNumber from 'flavours/glitch/components/animated_number'; export default class IconButton extends React.PureComponent { @@ -27,6 +28,8 @@ export default class IconButton extends React.PureComponent { overlay: PropTypes.bool, tabIndex: PropTypes.string, label: PropTypes.string, + counter: PropTypes.number, + obfuscateCount: PropTypes.bool, }; static defaultProps = { @@ -104,6 +107,8 @@ export default class IconButton extends React.PureComponent { pressed, tabIndex, title, + counter, + obfuscateCount, } = this.props; const { @@ -118,8 +123,13 @@ export default class IconButton extends React.PureComponent { activate, deactivate, overlayed: overlay, + 'icon-button--with-counter': typeof counter !== 'undefined', }); + if (typeof counter !== 'undefined') { + style.width = 'auto'; + } + return ( ); diff --git a/app/javascript/flavours/glitch/components/icon_with_badge.js b/app/javascript/flavours/glitch/components/icon_with_badge.js index 219efc28cd..a42ba45892 100644 --- a/app/javascript/flavours/glitch/components/icon_with_badge.js +++ b/app/javascript/flavours/glitch/components/icon_with_badge.js @@ -4,16 +4,18 @@ import Icon from 'flavours/glitch/components/icon'; const formatNumber = num => num > 40 ? '40+' : num; -const IconWithBadge = ({ id, count, className }) => ( +const IconWithBadge = ({ id, count, issueBadge, className }) => ( {count > 0 && {formatNumber(count)}} + {issueBadge && } ); IconWithBadge.propTypes = { id: PropTypes.string.isRequired, count: PropTypes.number.isRequired, + issueBadge: PropTypes.bool, className: PropTypes.string, }; diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js index 973406ad22..890a422d3c 100644 --- a/app/javascript/flavours/glitch/components/media_gallery.js +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -7,7 +7,8 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from 'flavours/glitch/util/is_mobile'; import classNames from 'classnames'; import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/util/initial_state'; -import { decode } from 'blurhash'; +import { debounce } from 'lodash'; +import Blurhash from 'flavours/glitch/components/blurhash'; const messages = defineMessages({ hidden: { @@ -93,36 +94,6 @@ class Item extends React.PureComponent { e.stopPropagation(); } - componentDidMount () { - if (this.props.attachment.get('blurhash')) { - this._decode(); - } - } - - componentDidUpdate (prevProps) { - if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) { - this._decode(); - } - } - - _decode () { - if (!useBlurhash) return; - - const hash = this.props.attachment.get('blurhash'); - const pixels = decode(hash, 32, 32); - - if (pixels) { - const ctx = this.canvas.getContext('2d'); - const imageData = new ImageData(pixels, 32, 32); - - ctx.putImageData(imageData, 0, 0); - } - } - - setCanvasRef = c => { - this.canvas = c; - } - handleImageLoad = () => { this.setState({ loaded: true }); } @@ -185,7 +156,11 @@ class Item extends React.PureComponent { return ( ); @@ -252,7 +227,13 @@ class Item extends React.PureComponent { return (
    - + {visible && thumbnail}
    ); @@ -289,6 +270,14 @@ class MediaGallery extends React.PureComponent { width: this.props.defaultWidth, }; + componentDidMount () { + window.addEventListener('resize', this.handleResize, { passive: true }); + } + + componentWillUnmount () { + window.removeEventListener('resize', this.handleResize); + } + componentWillReceiveProps (nextProps) { if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) { this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' }); @@ -298,13 +287,20 @@ class MediaGallery extends React.PureComponent { } componentDidUpdate (prevProps) { - if (this.node && this.node.offsetWidth && this.node.offsetWidth != this.state.width) { - this.setState({ - width: this.node.offsetWidth, - }); + if (this.node) { + this.handleResize(); } } + handleResize = debounce(() => { + if (this.node) { + this._setDimensions(); + } + }, 250, { + leading: true, + trailing: true, + }); + handleOpen = () => { if (this.props.onToggleVisibility) { this.props.onToggleVisibility(); @@ -319,11 +315,23 @@ class MediaGallery extends React.PureComponent { handleRef = (node) => { this.node = node; - if (node && node.offsetWidth && node.offsetWidth != this.state.width) { - if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth); + + if (this.node) { + this._setDimensions(); + } + } + + _setDimensions () { + const width = this.node.offsetWidth; + + if (width && width != this.state.width) { + // offsetWidth triggers a layout, so only calculate when we need to + if (this.props.cacheWidth) { + this.props.cacheWidth(width); + } this.setState({ - width: node.offsetWidth, + width: width, }); } } diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js index 8d73a1e407..13a8e8702d 100644 --- a/app/javascript/flavours/glitch/components/modal_root.js +++ b/app/javascript/flavours/glitch/components/modal_root.js @@ -14,11 +14,7 @@ export default class ModalRoot extends React.PureComponent { noEsc: PropTypes.bool, }; - state = { - revealed: !!this.props.children, - }; - - activeElement = this.state.revealed ? document.activeElement : null; + activeElement = this.props.children ? document.activeElement : null; handleKeyUp = (e) => { if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) @@ -59,8 +55,6 @@ export default class ModalRoot extends React.PureComponent { this.activeElement = document.activeElement; this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); - } else if (!nextProps.children) { - this.setState({ revealed: false }); } } @@ -80,11 +74,8 @@ export default class ModalRoot extends React.PureComponent { this.handleModalClose(); } - if (this.props.children) { - requestAnimationFrame(() => { - this.setState({ revealed: true }); - }); - if (!prevProps.children) this.handleModalOpen(); + if (this.props.children && !prevProps.children) { + this.handleModalOpen(); } } @@ -121,7 +112,6 @@ export default class ModalRoot extends React.PureComponent { render () { const { children, onClose } = this.props; - const { revealed } = this.state; const visible = !!children; if (!visible) { @@ -131,7 +121,7 @@ export default class ModalRoot extends React.PureComponent { } return ( -
    +
    {children}
    diff --git a/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js new file mode 100644 index 0000000000..01dce0a383 --- /dev/null +++ b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; +import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; +import { connect } from 'react-redux'; +import { debounce } from 'lodash'; +import { FormattedMessage } from 'react-intl'; + +export default @connect() +class PictureInPicturePlaceholder extends React.PureComponent { + + static propTypes = { + width: PropTypes.number, + dispatch: PropTypes.func.isRequired, + }; + + state = { + width: this.props.width, + height: this.props.width && (this.props.width / (16/9)), + }; + + handleClick = () => { + const { dispatch } = this.props; + dispatch(removePictureInPicture()); + } + + setRef = c => { + this.node = c; + + if (this.node) { + this._setDimensions(); + } + } + + _setDimensions () { + const width = this.node.offsetWidth; + const height = width / (16/9); + + this.setState({ width, height }); + } + + componentDidMount () { + window.addEventListener('resize', this.handleResize, { passive: true }); + } + + componentWillUnmount () { + window.removeEventListener('resize', this.handleResize); + } + + handleResize = debounce(() => { + if (this.node) { + this._setDimensions(); + } + }, 250, { + trailing: true, + }); + + render () { + const { height } = this.state; + + return ( +
    + + +
    + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js index 6f57c19509..f230823cc3 100644 --- a/app/javascript/flavours/glitch/components/poll.js +++ b/app/javascript/flavours/glitch/components/poll.js @@ -153,7 +153,7 @@ class Poll extends ImmutablePureComponent { } diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js index fae0a73934..cc8d9f1f31 100644 --- a/app/javascript/flavours/glitch/components/scrollable_list.js +++ b/app/javascript/flavours/glitch/components/scrollable_list.js @@ -10,10 +10,18 @@ import { List as ImmutableList } from 'immutable'; import classNames from 'classnames'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'flavours/glitch/util/fullscreen'; import LoadingIndicator from './loading_indicator'; +import { connect } from 'react-redux'; const MOUSE_IDLE_DELAY = 300; -export default class ScrollableList extends PureComponent { +const mapStateToProps = (state, { scrollKey }) => { + return { + preventScroll: scrollKey === state.getIn(['dropdown_menu', 'scroll_key']), + }; +}; + +export default @connect(mapStateToProps, null, null, { forwardRef: true }) +class ScrollableList extends PureComponent { static contextTypes = { router: PropTypes.object, @@ -37,6 +45,7 @@ export default class ScrollableList extends PureComponent { emptyMessage: PropTypes.node, children: PropTypes.node, bindToDocument: PropTypes.bool, + preventScroll: PropTypes.bool, }; static defaultProps = { @@ -124,7 +133,7 @@ export default class ScrollableList extends PureComponent { }); handleMouseIdle = () => { - if (this.scrollToTopOnMouseIdle) { + if (this.scrollToTopOnMouseIdle && !this.props.preventScroll) { this.setScrollTop(0); } this.mouseMovedRecently = false; @@ -176,7 +185,7 @@ export default class ScrollableList extends PureComponent { this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props); const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0); - if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently)) { + if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently || this.props.preventScroll)) { return this.getScrollHeight() - this.getScrollTop(); } else { return null; diff --git a/app/javascript/flavours/glitch/components/short_number.js b/app/javascript/flavours/glitch/components/short_number.js new file mode 100644 index 0000000000..e4ba09634c --- /dev/null +++ b/app/javascript/flavours/glitch/components/short_number.js @@ -0,0 +1,117 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../util/numbers'; +import { FormattedMessage, FormattedNumber } from 'react-intl'; +// @ts-check + +/** + * @callback ShortNumberRenderer + * @param {JSX.Element} displayNumber Number to display + * @param {number} pluralReady Number used for pluralization + * @returns {JSX.Element} Final render of number + */ + +/** + * @typedef {object} ShortNumberProps + * @property {number} value Number to display in short variant + * @property {ShortNumberRenderer} [renderer] + * Custom renderer for numbers, provided as a prop. If another renderer + * passed as a child of this component, this prop won't be used. + * @property {ShortNumberRenderer} [children] + * Custom renderer for numbers, provided as a child. If another renderer + * passed as a prop of this component, this one will be used instead. + */ + +/** + * Component that renders short big number to a shorter version + * + * @param {ShortNumberProps} param0 Props for the component + * @returns {JSX.Element} Rendered number + */ +function ShortNumber({ value, renderer, children }) { + const shortNumber = toShortNumber(value); + const [, division] = shortNumber; + + // eslint-disable-next-line eqeqeq + if (children != null && renderer != null) { + console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.'); + } + + // eslint-disable-next-line eqeqeq + const customRenderer = children != null ? children : renderer; + + const displayNumber = ; + + // eslint-disable-next-line eqeqeq + return customRenderer != null + ? customRenderer(displayNumber, pluralReady(value, division)) + : displayNumber; +} + +ShortNumber.propTypes = { + value: PropTypes.number.isRequired, + renderer: PropTypes.func, + children: PropTypes.func, +}; + +/** + * @typedef {object} ShortNumberCounterProps + * @property {import('../util/number').ShortNumber} value Short number + */ + +/** + * Renders short number into corresponding localizable react fragment + * + * @param {ShortNumberCounterProps} param0 Props for the component + * @returns {JSX.Element} FormattedMessage ready to be embedded in code + */ +function ShortNumberCounter({ value }) { + const [rawNumber, unit, maxFractionDigits = 0] = value; + + const count = ( + + ); + + let values = { count, rawNumber }; + + switch (unit) { + case DECIMAL_UNITS.THOUSAND: { + return ( + + ); + } + case DECIMAL_UNITS.MILLION: { + return ( + + ); + } + case DECIMAL_UNITS.BILLION: { + return ( + + ); + } + // Not sure if we should go farther - @Sasha-Sorokin + default: return count; + } +} + +ShortNumberCounter.propTypes = { + value: PropTypes.arrayOf(PropTypes.number), +}; + +export default React.memo(ShortNumber); diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index e036c0da7f..fcbf4be8c3 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -17,6 +17,7 @@ import classNames from 'classnames'; import { autoUnfoldCW } from 'flavours/glitch/util/content_warning'; import PollContainer from 'flavours/glitch/containers/poll_container'; import { displayMedia } from 'flavours/glitch/util/initial_state'; +import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress @@ -96,6 +97,9 @@ class Status extends ImmutablePureComponent { cacheMediaWidth: PropTypes.func, cachedMediaWidth: PropTypes.number, onClick: PropTypes.func, + scrollKey: PropTypes.string, + deployPictureInPicture: PropTypes.func, + usingPiP: PropTypes.bool, }; state = { @@ -121,6 +125,8 @@ class Status extends ImmutablePureComponent { 'notification', 'hidden', 'expanded', + 'unread', + 'usingPiP', ] updateOnStates = [ @@ -392,6 +398,12 @@ class Status extends ImmutablePureComponent { } } + handleDeployPictureInPicture = (type, mediaProps) => { + const { deployPictureInPicture, status } = this.props; + + deployPictureInPicture(status, type, mediaProps); + } + handleHotkeyReply = e => { e.preventDefault(); this.props.onReply(this.props.status, this.context.router.history); @@ -494,6 +506,7 @@ class Status extends ImmutablePureComponent { hidden, unread, featured, + usingPiP, ...other } = this.props; const { isExpanded, isCollapsed, forceFilter } = this.state; @@ -574,6 +587,9 @@ class Status extends ImmutablePureComponent { if (status.get('poll')) { media = ; mediaIcon = 'tasks'; + } else if (usingPiP) { + media = ; + mediaIcon = 'video-camera'; } else if (attachments.size > 0) { if (muted || attachments.some(item => item.get('type') === 'unknown')) { media = ( @@ -591,9 +607,15 @@ class Status extends ImmutablePureComponent { )} @@ -606,6 +628,7 @@ class Status extends ImmutablePureComponent { {Component => ()} @@ -673,6 +697,7 @@ class Status extends ImmutablePureComponent { favourite: 'favourited', reblog: 'boosted', reblogged_by: 'boosted', + status: 'posted', }[prepend]; selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`; @@ -688,7 +713,7 @@ class Status extends ImmutablePureComponent { collapsed: isCollapsed, 'has-background': isCollapsed && background, 'status__wrapper-reply': !!status.get('in_reply_to_id'), - read: unread === false, + unread, muted, }, 'focusable'); diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index 0a481c816e..74bfd948ed 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.js +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -8,6 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { me, isStaff } from 'flavours/glitch/util/initial_state'; import RelativeTimestamp from './relative_timestamp'; import { accountAdminLink, statusAdminLink } from 'flavours/glitch/util/backend_links'; +import classNames from 'classnames'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -21,7 +22,8 @@ const messages = defineMessages({ more: { id: 'status.more', defaultMessage: 'More' }, replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, - reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' }, + reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, + cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, @@ -38,16 +40,6 @@ const messages = defineMessages({ hide: { id: 'status.hide', defaultMessage: 'Hide toot' }, }); -const obfuscatedCount = count => { - if (count < 0) { - return 0; - } else if (count <= 1) { - return count; - } else { - return '1+'; - } -}; - export default @injectIntl class StatusActionBar extends ImmutablePureComponent { @@ -74,6 +66,7 @@ class StatusActionBar extends ImmutablePureComponent { withDismiss: PropTypes.bool, showReplyCount: PropTypes.bool, directMessage: PropTypes.bool, + scrollKey: PropTypes.string, intl: PropTypes.object.isRequired, }; @@ -198,13 +191,12 @@ class StatusActionBar extends ImmutablePureComponent { } render () { - const { status, intl, withDismiss, showReplyCount, directMessage } = this.props; + const { status, intl, withDismiss, showReplyCount, directMessage, scrollKey } = this.props; - const mutingConversation = status.get('muted'); const anonymousAccess = !me; + const mutingConversation = status.get('muted'); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); - const reblogDisabled = status.get('visibility') === 'direct' || (status.get('visibility') === 'private' && me !== status.getIn(['account', 'id'])); - const reblogMessage = status.get('visibility') === 'private' ? messages.reblog_private : messages.reblog; + const writtenByMe = status.getIn(['account', 'id']) === me; let menu = []; let reblogIcon = 'retweet'; @@ -220,16 +212,17 @@ class StatusActionBar extends ImmutablePureComponent { menu.push(null); - if (status.getIn(['account', 'id']) === me || withDismiss) { + if (writtenByMe && publicStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + menu.push(null); + } + + if (writtenByMe || withDismiss) { menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push(null); } - if (status.getIn(['account', 'id']) === me) { - if (publicStatus) { - menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); - } - + if (writtenByMe) { menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); } else { @@ -283,24 +276,50 @@ class StatusActionBar extends ImmutablePureComponent { ); if (showReplyCount) { replyButton = ( -
    - {replyButton} - {obfuscatedCount(status.get('replies_count'))} -
    + ); } + const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; + + let reblogTitle = ''; + if (status.get('reblogged')) { + reblogTitle = intl.formatMessage(messages.cancel_reblog_private); + } else if (publicStatus) { + reblogTitle = intl.formatMessage(messages.reblog); + } else if (reblogPrivate) { + reblogTitle = intl.formatMessage(messages.reblog_private); + } else { + reblogTitle = intl.formatMessage(messages.cannot_reblog); + } + return (
    {replyButton} {!directMessage && [ - , + , , shareButton, , filterButton,
    - +
    , ]} diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index a5822866ab..34ff973059 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -1,7 +1,6 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import { isRtl } from 'flavours/glitch/util/rtl'; import { FormattedMessage } from 'react-intl'; import Permalink from './permalink'; import classnames from 'classnames'; @@ -155,35 +154,38 @@ export default class StatusContent extends React.PureComponent { } } - _updateStatusEmojis () { - const node = this.node; - - if (!node || autoPlayGif) { + handleMouseEnter = ({ currentTarget }) => { + if (autoPlayGif) { return; } - const emojis = node.querySelectorAll('.custom-emoji'); + const emojis = currentTarget.querySelectorAll('.custom-emoji'); for (var i = 0; i < emojis.length; i++) { let emoji = emojis[i]; - if (emoji.classList.contains('status-emoji')) { - continue; - } - emoji.classList.add('status-emoji'); + emoji.src = emoji.getAttribute('data-original'); + } + } - emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false); - emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false); + handleMouseLeave = ({ currentTarget }) => { + if (autoPlayGif) { + return; + } + + const emojis = currentTarget.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + emoji.src = emoji.getAttribute('data-static'); } } componentDidMount () { this._updateStatusLinks(); - this._updateStatusEmojis(); } componentDidUpdate () { this._updateStatusLinks(); - this._updateStatusEmojis(); if (this.props.onUpdate) this.props.onUpdate(); } @@ -207,14 +209,6 @@ export default class StatusContent extends React.PureComponent { } } - handleEmojiMouseEnter = ({ target }) => { - target.src = target.getAttribute('data-original'); - } - - handleEmojiMouseLeave = ({ target }) => { - target.src = target.getAttribute('data-static'); - } - handleMouseDown = (e) => { this.startXY = [e.clientX, e.clientY]; } @@ -231,7 +225,7 @@ export default class StatusContent extends React.PureComponent { let element = e.target; while (element) { - if (['button', 'video', 'a', 'label', 'wave'].includes(element.localName)) { + if (['button', 'video', 'a', 'label', 'canvas'].includes(element.localName)) { return; } element = element.parentNode; @@ -254,10 +248,6 @@ export default class StatusContent extends React.PureComponent { } } - setRef = (c) => { - this.node = c; - } - setContentsRef = (c) => { this.contentsNode = c; } @@ -277,16 +267,11 @@ export default class StatusContent extends React.PureComponent { const content = { __html: status.get('contentHtml') }; const spoilerContent = { __html: status.get('spoilerHtml') }; - const directionStyle = { direction: 'ltr' }; const classNames = classnames('status__content', { 'status__content--with-action': parseClick && !disabled, 'status__content--with-spoiler': status.get('spoiler_text').length > 0, }); - if (isRtl(status.get('search_index'))) { - directionStyle.direction = 'rtl'; - } - if (status.get('spoiler_text').length > 0) { let mentionsPlaceholder = ''; @@ -329,11 +314,11 @@ export default class StatusContent extends React.PureComponent { } return ( -
    +
    + +
    + ); + } else { + action_buttons = ( +
    + +
    + ); + } + + let note_container = null; + if (isEditing) { + note_container = ( +