diff --git a/.circleci/config.yml b/.circleci/config.yml index 2355d9d7d..ff8eb4859 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ version: 2 aliases: - &defaults docker: - - image: circleci/ruby:2.6.0-stretch-node + - image: circleci/ruby:2.6-stretch-node environment: &ruby_environment BUNDLE_APP_CONFIG: ./.bundle/ DB_HOST: localhost @@ -105,14 +105,14 @@ jobs: install-ruby2.5: <<: *defaults docker: - - image: circleci/ruby:2.5.3-stretch-node + - image: circleci/ruby:2.5-stretch-node environment: *ruby_environment <<: *install_ruby_dependencies install-ruby2.4: <<: *defaults docker: - - image: circleci/ruby:2.4.5-stretch-node + - image: circleci/ruby:2.4-stretch-node environment: *ruby_environment <<: *install_ruby_dependencies @@ -131,40 +131,40 @@ jobs: test-ruby2.6: <<: *defaults docker: - - image: circleci/ruby:2.6.0-stretch-node + - image: circleci/ruby:2.6-stretch-node environment: *ruby_environment - image: circleci/postgres:10.6-alpine environment: POSTGRES_USER: root - - image: circleci/redis:5.0.3-alpine3.8 + - image: circleci/redis:5-alpine <<: *test_steps test-ruby2.5: <<: *defaults docker: - - image: circleci/ruby:2.5.3-stretch-node + - image: circleci/ruby:2.5-stretch-node environment: *ruby_environment - image: circleci/postgres:10.6-alpine environment: POSTGRES_USER: root - - image: circleci/redis:4.0.12-alpine + - image: circleci/redis:5-alpine <<: *test_steps test-ruby2.4: <<: *defaults docker: - - image: circleci/ruby:2.4.5-stretch-node + - image: circleci/ruby:2.4-stretch-node environment: *ruby_environment - image: circleci/postgres:10.6-alpine environment: POSTGRES_USER: root - - image: circleci/redis:4.0.12-alpine + - image: circleci/redis:5-alpine <<: *test_steps test-webui: <<: *defaults docker: - - image: circleci/node:8.15.0-stretch + - image: circleci/node:12.9-stretch steps: - *attach_workspace - run: ./bin/retry yarn test:jest @@ -173,9 +173,11 @@ jobs: <<: *defaults steps: - *attach_workspace + - *install_system_dependencies - run: bundle exec i18n-tasks check-normalized - run: bundle exec i18n-tasks unused -l en - run: bundle exec i18n-tasks check-consistent-interpolations + - run: bundle exec rake repo:check_locales_files workflows: version: 2 diff --git a/.env.nanobox b/.env.nanobox index b60b6ee68..cfbe487fb 100644 --- a/.env.nanobox +++ b/.env.nanobox @@ -11,24 +11,14 @@ DB_NAME=gonano DB_PASS=$DATA_DB_PASS DB_PORT=5432 -DATABASE_URL=postgresql://$DATA_DB_USER:$DATA_DB_PASS@$DATA_DB_HOST/gonano +# DATABASE_URL=postgresql://$DATA_DB_USER:$DATA_DB_PASS@$DATA_DB_HOST/gonano # Optional ElasticSearch configuration ES_ENABLED=true ES_HOST=$DATA_ELASTIC_HOST ES_PORT=9200 -# Optimizations -LD_PRELOAD=/data/lib/libjemalloc.so - -# ImageMagick optimizations -MAGICK_TEMPORARY_PATH=/app/tmp -MAGICK_MEMORY_LIMIT=128MiB -MAGICK_MAP_LIMIT=64MiB -MAGICK_TIME_LIMIT=15 -MAGICK_AREA_LIMIT=16MP -MAGICK_WIDTH_LIMIT=8KP -MAGICK_HEIGHT_LIMIT=8KP +BIND=0.0.0.0 # Federation # Note: Changing LOCAL_DOMAIN at a later time will cause unwanted side effects, including breaking all existing federation. @@ -84,6 +74,7 @@ SMTP_PORT=587 SMTP_LOGIN=$SMTP_LOGIN SMTP_PASSWORD=$SMTP_PASSWORD SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io +#SMTP_REPLY_TO= #SMTP_DOMAIN= # defaults to LOCAL_DOMAIN #SMTP_DELIVERY_METHOD=smtp # delivery method can also be sendmail #SMTP_AUTH_METHOD=plain @@ -97,9 +88,17 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io # 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 # S3 (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= @@ -109,6 +108,8 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io # 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= @@ -119,12 +120,30 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io # 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 + # Swift (optional) +# The attachment host must allow cross origin request - see the description +# above. # SWIFT_ENABLED=true # SWIFT_USERNAME= # For Keystone V3, the value for SWIFT_TENANT should be the project name # SWIFT_TENANT= # SWIFT_PASSWORD= +# Some OpenStack V3 providers require PROJECT_ID (optional) +# SWIFT_PROJECT_ID= # Keystone V2 and V3 URLs are supported. Use a V3 URL if possible to avoid # issues with token rate-limiting during high load. # SWIFT_AUTH_URL= @@ -171,8 +190,8 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io # The pam environment variable "email" is provided by: # https://github.com/devkral/pam_email_extractor # PAM_ENABLED=true -# Fallback Suffix for email address generation (nil by default) -# PAM_DEFAULT_SUFFIX=pam +# Fallback email domain for email address generation (LOCAL_DOMAIN by default) +# PAM_EMAIL_DOMAIN=example.com # Name of the pam service (pam "auth" section is evaluated) # PAM_DEFAULT_SERVICE=rpam # Name of the pam service used for checking if an user can register (pam "account" section is evaluated) (nil (disabled) by default) @@ -220,7 +239,14 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io # SAML_SECURITY_ASSUME_EMAIL_IS_VERIFIED=true # SAML_ATTRIBUTES_STATEMENTS_UID="urn:oid:0.9.2342.19200300.100.1.1" # SAML_ATTRIBUTES_STATEMENTS_EMAIL="urn:oid:1.3.6.1.4.1.5923.1.1.1.6" -# SAML_ATTRIBUTES_STATEMENTS_FULL_NAME="urn:oid:2.5.4.42" +# SAML_ATTRIBUTES_STATEMENTS_FULL_NAME="urn:oid:2.16.840.1.113730.3.1.241" +# SAML_ATTRIBUTES_STATEMENTS_FIRST_NAME="urn:oid:2.5.4.42" +# SAML_ATTRIBUTES_STATEMENTS_LAST_NAME="urn:oid:2.5.4.4" # SAML_UID_ATTRIBUTE="urn:oid:0.9.2342.19200300.100.1.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 diff --git a/.env.production.sample b/.env.production.sample index d66b05050..f9a8bb7c1 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -69,6 +69,7 @@ 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 @@ -114,6 +115,20 @@ SMTP_FROM_ADDRESS=notifications@example.com # 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 + # Swift (optional) # The attachment host must allow cross origin request - see the description # above. @@ -163,7 +178,7 @@ STREAMING_CLUSTER_NUM=1 # LDAP_BIND_DN= # LDAP_PASSWORD= # LDAP_UID=cn -# LDAP_SEARCH_FILTER="%{uid}=%{email}" +# LDAP_SEARCH_FILTER=%{uid}=%{email} # PAM authentication (optional) # PAM authentication uses for the email generation the "email" pam variable diff --git a/.ruby-version b/.ruby-version index 6a6a3d8e3..57cf282eb 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.6.1 +2.6.5 diff --git a/AUTHORS.md b/AUTHORS.md index 167fccb08..5f5985fba 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -5,92 +5,100 @@ Mastodon is available on [GitHub](https://github.com/tootsuite/mastodon) and provided thanks to the work of the following contributors: * [Gargron](https://github.com/Gargron) -* [ykzts](https://github.com/ykzts) * [ThibG](https://github.com/ThibG) -* [akihikodaki](https://github.com/akihikodaki) -* [mjankowski](https://github.com/mjankowski) +* [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) +* [mjankowski](https://github.com/mjankowski) * [unarist](https://github.com/unarist) -* [m4sk1n](https://github.com/m4sk1n) * [yiskah](https://github.com/yiskah) * [nolanlawson](https://github.com/nolanlawson) * [ysksn](https://github.com/ysksn) -* [sorin-davidoi](https://github.com/sorin-davidoi) * [abcang](https://github.com/abcang) +* [sorin-davidoi](https://github.com/sorin-davidoi) * [lynlynlynx](https://github.com/lynlynlynx) * [mayaeh](https://github.com/mayaeh) +* [m4sk1n](mailto:me@m4sk.in) +* [Marcin Mikołajczak](mailto:me@m4sk.in) +* [Kjwon15](https://github.com/Kjwon15) * [renatolond](https://github.com/renatolond) * [alpaca-tc](https://github.com/alpaca-tc) +* [jeroenpraat](https://github.com/jeroenpraat) * [nclm](https://github.com/nclm) * [ineffyble](https://github.com/ineffyble) -* [jeroenpraat](https://github.com/jeroenpraat) +* [mabkenar](https://github.com/mabkenar) * [blackle](https://github.com/blackle) * [Quent-in](https://github.com/Quent-in) * [JantsoP](https://github.com/JantsoP) -* [Kjwon15](https://github.com/Kjwon15) -* [mabkenar](https://github.com/mabkenar) -* [Kjwon15](https://github.com/Kjwon15) +* [zunda](https://github.com/zunda) * [nullkal](https://github.com/nullkal) * [yookoala](https://github.com/yookoala) +* [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) -* [zunda](https://github.com/zunda) -* [Quenty31](https://github.com/Quenty31) * [eramdam](https://github.com/eramdam) +* [noellabo](https://github.com/noellabo) * [takayamaki](https://github.com/takayamaki) +* [danhunsaker](https://github.com/danhunsaker) * [masarakki](https://github.com/masarakki) * [ticky](https://github.com/ticky) -* [danhunsaker](https://github.com/danhunsaker) * [ThisIsMissEm](https://github.com/ThisIsMissEm) * [hcmiya](https://github.com/hcmiya) * [stephenburgess8](https://github.com/stephenburgess8) * [Wonderfall](https://github.com/Wonderfall) * [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) * [marrus-sh](https://github.com/marrus-sh) +* [hinaloe](https://github.com/hinaloe) * [krainboltgreene](https://github.com/krainboltgreene) * [pfigel](https://github.com/pfigel) * [Aldarone](https://github.com/Aldarone) * [BoFFire](https://github.com/BoFFire) * [clworld](https://github.com/clworld) +* [MasterGroosha](https://github.com/MasterGroosha) * [dracos](https://github.com/dracos) +* [MaciekBaron](https://github.com/MaciekBaron) * [SerCom_KC](mailto:sercom-kc@users.noreply.github.com) * [Sylvhem](https://github.com/Sylvhem) -* [MasterGroosha](https://github.com/MasterGroosha) +* [MitarashiDango](https://github.com/MitarashiDango) * [JeanGauthier](https://github.com/JeanGauthier) * [kschaper](https://github.com/kschaper) -* [MaciekBaron](https://github.com/MaciekBaron) -* [MitarashiDango](mailto:mitarashidango@users.noreply.github.com) * [beatrix-bitrot](https://github.com/beatrix-bitrot) -* [Aditoo17](https://github.com/Aditoo17) +* [angristan](https://github.com/angristan) * [adbelle](https://github.com/adbelle) * [evanminto](https://github.com/evanminto) * [MightyPork](https://github.com/MightyPork) +* [ashleyhull-versent](mailto:ashley.hull@versent.com.au) * [yhirano55](https://github.com/yhirano55) * [rinsuki](https://github.com/rinsuki) * [camponez](https://github.com/camponez) -* [hinaloe](https://github.com/hinaloe) -* [SerCom-KC](https://github.com/SerCom-KC) +* [SerCom_KC](mailto:szescxz@gmail.com) * [aschmitz](https://github.com/aschmitz) +* [trwnh](https://github.com/trwnh) * [devkral](https://github.com/devkral) * [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) -* [Rakib Hasan](mailto:rmhasan@gmail.com) -* [ashleyhull-versent](https://github.com/ashleyhull-versent) +* [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) -* [rinsuki](https://github.com/rinsuki) +* [BenLubar](https://github.com/BenLubar) * [hikari-no-yume](https://github.com/hikari-no-yume) -* [angristan](https://github.com/angristan) -* [hinaloe](https://github.com/hinaloe) * [seefood](https://github.com/seefood) * [jackjennings](https://github.com/jackjennings) +* [koyuawsmbrtn](https://github.com/koyuawsmbrtn) * [spla](mailto:spla@mastodont.cat) * [expenses](https://github.com/expenses) * [walf443](https://github.com/walf443) @@ -98,18 +106,17 @@ and provided thanks to the work of the following contributors: * [mistydemeo](https://github.com/mistydemeo) * [dunn](https://github.com/dunn) * [xqus](https://github.com/xqus) -* [hugogameiro](https://github.com/hugogameiro) -* [ariasuni](https://github.com/ariasuni) * [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) -* [kedamaDQ](https://github.com/kedamaDQ) +* [mkljczk](https://github.com/mkljczk) +* [manuelviens](https://github.com/manuelviens) * [puckipedia](https://github.com/puckipedia) -* [trwnh](https://github.com/trwnh) * [fvh-P](https://github.com/fvh-P) +* [rtucker](https://github.com/rtucker) * [Anna e só](mailto:contraexemplos@gmail.com) -* [BenLubar](https://github.com/BenLubar) * [kazu9su](https://github.com/kazu9su) * [Komic](https://github.com/Komic) * [lmorchard](https://github.com/lmorchard) @@ -122,6 +129,7 @@ and provided thanks to the work of the following contributors: * [goofy-bz](mailto:goofy@babelzilla.org) * [kadiix](https://github.com/kadiix) * [kodacs](https://github.com/kodacs) +* [marcin mikołajczak](mailto:me@m4sk.in) * [JMendyk](https://github.com/JMendyk) * [KScl](https://github.com/KScl) * [sterdev](https://github.com/sterdev) @@ -132,30 +140,31 @@ and provided thanks to the work of the following contributors: * [northerner](https://github.com/northerner) * [fhemberger](https://github.com/fhemberger) * [greysteil](https://github.com/greysteil) -* [hensmith](https://github.com/hensmith) +* [hencatsmith](https://github.com/hencatsmith) * [d6rkaiz](https://github.com/d6rkaiz) * [Reverite](https://github.com/Reverite) * [JohnD28](https://github.com/JohnD28) * [znz](https://github.com/znz) -* [marek-lach](https://github.com/marek-lach) * [Naouak](https://github.com/Naouak) * [pawelngei](https://github.com/pawelngei) -* [rtucker](https://github.com/rtucker) * [reneklacan](https://github.com/reneklacan) * [ekiru](https://github.com/ekiru) -* [noellabo](https://github.com/noellabo) * [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) * [tomfhowe](https://github.com/tomfhowe) * [noraworld](https://github.com/noraworld) * [theboss](https://github.com/theboss) +* [nzws](https://github.com/nzws) * [178inaba](https://github.com/178inaba) +* [xgess](https://github.com/xgess) * [alyssais](https://github.com/alyssais) -* [hiphref](https://github.com/hiphref) +* [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) * [halkeye](https://github.com/halkeye) @@ -165,22 +174,24 @@ and provided thanks to the work of the following contributors: * [kmichl](https://github.com/kmichl) * [Kurtis Rainbolt-Greene](mailto:me@kurtisrainboltgreene.name) * [saper](https://github.com/saper) -* [marek-lach](https://github.com/marek-lach) +* [Dar13](https://github.com/Dar13) * [nevillepark](https://github.com/nevillepark) * [ornithocoder](https://github.com/ornithocoder) +* [pwoolcoc](https://github.com/pwoolcoc) * [pierreozoux](https://github.com/pierreozoux) * [qguv](https://github.com/qguv) * [Ram Lmn](mailto:ramlmn@users.noreply.github.com) -* [sascha-sl](https://github.com/sascha-sl) +* [aurelia-sl](https://github.com/aurelia-sl) * [harukasan](https://github.com/harukasan) * [stamak](https://github.com/stamak) -* [Technowix](mailto:technowix@users.noreply.github.com) +* [Technowix](https://github.com/Technowix) * [Zoeille](https://github.com/Zoeille) * [Thor Harald Johansen](mailto:thj@thj.no) * [0x70b1a5](https://github.com/0x70b1a5) * [gled-rs](https://github.com/gled-rs) * [Valentin_NC](mailto:valentin.ouvrard@nautile.sarl) * [R0ckweb](https://github.com/R0ckweb) +* [unasuke](https://github.com/unasuke) * [caasi](https://github.com/caasi) * [chr-1x](https://github.com/chr-1x) * [esetomo](https://github.com/esetomo) @@ -188,8 +199,9 @@ and provided thanks to the work of the following contributors: * [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](https://github.com/unsmell) +* [unsmell](mailto:unsmell@users.noreply.github.com) * [valerauko](https://github.com/valerauko) * [chriswmartin](https://github.com/chriswmartin) * [vahnj](https://github.com/vahnj) @@ -197,21 +209,25 @@ 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) +* [aurelien-reeves](https://github.com/aurelien-reeves) +* [AnaGelez](https://github.com/AnaGelez) * [estuans](https://github.com/estuans) * [dissolve](https://github.com/dissolve) * [PurpleBooth](https://github.com/PurpleBooth) * [bradurani](https://github.com/bradurani) * [wavebeem](https://github.com/wavebeem) * [bruwalfas](https://github.com/bruwalfas) -* [foxsan48](https://github.com/foxsan48) +* [LottieVixen](https://github.com/LottieVixen) * [wchristian](https://github.com/wchristian) * [muffinista](https://github.com/muffinista) * [cdutson](https://github.com/cdutson) * [farlistener](https://github.com/farlistener) +* [dariusk](https://github.com/dariusk) * [DavidLibeau](https://github.com/DavidLibeau) * [ddevault](https://github.com/ddevault) * [Fjoerfoks](https://github.com/Fjoerfoks) @@ -220,6 +236,7 @@ and provided thanks to the work of the following contributors: * [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) @@ -235,21 +252,23 @@ and provided thanks to the work of the following contributors: * [Kaylee](mailto:kaylee@codethat.sucks) * [Kazhnuz](https://github.com/Kazhnuz) * [connyduck](https://github.com/connyduck) -* [Lindsey Bieda](mailto:lindseyb@users.noreply.github.com) +* [LindseyB](https://github.com/LindseyB) * [Lorenz Diener](mailto:halcyon@icosahedron.website) * [alimony](https://github.com/alimony) * [mig5](https://github.com/mig5) * [moritzheiber](https://github.com/moritzheiber) * [ndarville](https://github.com/ndarville) * [Abzol](https://github.com/Abzol) -* [pwoolcoc](https://github.com/pwoolcoc) +* [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) * [remram44](https://github.com/remram44) * [sts10](https://github.com/sts10) +* [SuperSandro2000](https://github.com/SuperSandro2000) * [u1-liquid](https://github.com/u1-liquid) +* [rosylilly](https://github.com/rosylilly) * [sim6](https://github.com/sim6) * [Sir-Boops](https://github.com/Sir-Boops) * [stemid](https://github.com/stemid) @@ -274,6 +293,7 @@ and provided thanks to the work of the following contributors: * [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) @@ -283,6 +303,7 @@ and provided thanks to the work of the following contributors: * [mecab](https://github.com/mecab) * [nicobz25](https://github.com/nicobz25) * [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) @@ -291,19 +312,17 @@ and provided thanks to the work of the following contributors: * [vjackson725](https://github.com/vjackson725) * [wxcafe](https://github.com/wxcafe) * [新都心(Neet Shin)](mailto:nucx@dio-vox.com) +* [clarfon](https://github.com/clarfon) * [cygnan](https://github.com/cygnan) * [Awea](https://github.com/Awea) * [halcy](https://github.com/halcy) -* [naaaaaaaaaaaf](https://github.com/naaaaaaaaaaaf) * [8398a7](https://github.com/8398a7) * [857b](https://github.com/857b) * [insom](https://github.com/insom) * [tachyons](https://github.com/tachyons) -* [acid-chicken](https://github.com/acid-chicken) * [Esteth](https://github.com/Esteth) * [unascribed](https://github.com/unascribed) * [Aguay-val](https://github.com/Aguay-val) -* [Akihiko Odaki](mailto:nekomanma@pixiv.co.jp) * [knu](https://github.com/knu) * [h3poteto](https://github.com/h3poteto) * [unleashed](https://github.com/unleashed) @@ -311,8 +330,8 @@ and provided thanks to the work of the following contributors: * [console-cowboy](https://github.com/console-cowboy) * [Alkarex](https://github.com/Alkarex) * [a2](https://github.com/a2) +* [alfiedotwtf](https://github.com/alfiedotwtf) * [0xa](https://github.com/0xa) -* [palindromordnilap](https://github.com/palindromordnilap) * [virtualpain](https://github.com/virtualpain) * [sapphirus](https://github.com/sapphirus) * [amandavisconti](https://github.com/amandavisconti) @@ -324,10 +343,9 @@ and provided thanks to the work of the following contributors: * [contraexemplo](https://github.com/contraexemplo) * [abackstrom](https://github.com/abackstrom) * [armandfardeau](https://github.com/armandfardeau) +* [raboof](https://github.com/raboof) * [jumbosushi](https://github.com/jumbosushi) -* [aurelien-reeves](https://github.com/aurelien-reeves) * [ayumin](https://github.com/ayumin) -* [BaptisteGelez](https://github.com/BaptisteGelez) * [bzg](https://github.com/bzg) * [benediktg](https://github.com/benediktg) * [blakebarnett](https://github.com/blakebarnett) @@ -341,15 +359,15 @@ and provided thanks to the work of the following contributors: * [DoubleMalt](https://github.com/DoubleMalt) * [Moosh-be](https://github.com/Moosh-be) * [Motoma](https://github.com/Motoma) -* [chriswk](https://github.com/chriswk) +* [Christopher Kolstad](mailto:christopher.kolstad@finn.no) * [csu](https://github.com/csu) -* [clarfon](https://github.com/clarfon) * [kklleemm](https://github.com/kklleemm) * [colindean](https://github.com/colindean) * [dachinat](https://github.com/dachinat) * [multiple-creatures](https://github.com/multiple-creatures) * [watilde](https://github.com/watilde) * [daprice](https://github.com/daprice) +* [da2x](https://github.com/da2x) * [dar5hak](https://github.com/dar5hak) * [kant](https://github.com/kant) * [maxolasersquad](https://github.com/maxolasersquad) @@ -358,7 +376,7 @@ and provided thanks to the work of the following contributors: * [davefp](https://github.com/davefp) * [yipdw](https://github.com/yipdw) * [debanshuk](https://github.com/debanshuk) -* [Derek Lewis](mailto:derekcecillewis@gmail.com) +* [DerekNonGeneric](https://github.com/DerekNonGeneric) * [dblandin](https://github.com/dblandin) * [Drew Gates](mailto:aranaur@users.noreply.github.com) * [dtschust](https://github.com/dtschust) @@ -370,11 +388,13 @@ and provided thanks to the work of the following contributors: * [ericblade](https://github.com/ericblade) * [mikoim](https://github.com/mikoim) * [espenronnevik](https://github.com/espenronnevik) +* [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) * [GenbuHase](https://github.com/GenbuHase) +* [nilsding](https://github.com/nilsding) * [hattori6789](https://github.com/hattori6789) * [algernon](https://github.com/algernon) * [Fastbyte01](https://github.com/Fastbyte01) @@ -390,17 +410,19 @@ and provided thanks to the work of the following contributors: * [suzukaze](https://github.com/suzukaze) * [Hiromi-Kai](https://github.com/Hiromi-Kai) * [hishamhm](https://github.com/hishamhm) +* [Slaynash](https://github.com/Slaynash) * [musashino205](https://github.com/musashino205) * [iwaim](https://github.com/iwaim) * [valrus](https://github.com/valrus) * [IMcD23](https://github.com/IMcD23) * [yi0713](https://github.com/yi0713) * [iblech](https://github.com/iblech) -* [usbsnowcrash](https://github.com/usbsnowcrash) +* [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) @@ -410,17 +432,17 @@ and provided thanks to the work of the following contributors: * [joshuap](https://github.com/joshuap) * [Tiwy57](https://github.com/Tiwy57) * [xuv](https://github.com/xuv) -* [June Sallou](mailto:jnsll@users.noreply.github.com) +* [Jnsll](https://github.com/Jnsll) * [j0k3r](https://github.com/j0k3r) * [KEINOS](https://github.com/KEINOS) * [futoase](https://github.com/futoase) -* [Pneumaticat](https://github.com/Pneumaticat) +* [pot8to](https://github.com/pot8to) * [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) -* [leowzukw](https://github.com/leowzukw) +* [Leo Wzukw](mailto:leowzukw@users.noreply.github.com) * [Tak](https://github.com/Tak) * [cacheflow](https://github.com/cacheflow) * [ldidry](https://github.com/ldidry) @@ -428,6 +450,7 @@ and provided thanks to the work of the following contributors: * [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) @@ -435,50 +458,53 @@ and provided thanks to the work of the following contributors: * [otsune](https://github.com/otsune) * [mbugowski](https://github.com/mbugowski) * [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) -* [matthiasbeyer](https://github.com/matthiasbeyer) -* [mattjmattj](https://github.com/mattjmattj) -* [mtparet](https://github.com/mtparet) -* [maximeborges](https://github.com/maximeborges) -* [minacle](https://github.com/minacle) -* [michaeljdeeb](https://github.com/michaeljdeeb) -* [Themimitoof](https://github.com/Themimitoof) -* [cyweo](https://github.com/cyweo) +* [Matthias Beyer](mailto:mail@beyermatthias.de) +* [Matthias Jouan](mailto:matthias.jouan@gmail.com) +* [Matthieu Paret](mailto:matthieuparet69@gmail.com) +* [Maxime BORGES](mailto:maxime.borges@gmail.com) +* [Mayu Laierlence](mailto:minacle@live.com) +* [Michael Deeb](mailto:michaeldeeb@me.com) +* [Michael Vieira](mailto:dtox94@gmail.com) +* [Michel](mailto:michel@cyweo.com) * [Midgard](mailto:m1dgard@users.noreply.github.com) -* [mike-burns](https://github.com/mike-burns) -* [verymilan](https://github.com/verymilan) -* [milmazz](https://github.com/milmazz) -* [premist](https://github.com/premist) -* [Mnkai](https://github.com/Mnkai) -* [mitchhentges](https://github.com/mitchhentges) -* [mouse-reeve](https://github.com/mouse-reeve) -* [Mozinet-fr](https://github.com/Mozinet-fr) -* [lae](https://github.com/lae) -* [nosada](https://github.com/nosada) -* [Nanamachi](https://github.com/Nanamachi) -* [orinthe](https://github.com/orinthe) -* [NecroTechno](https://github.com/NecroTechno) -* [Dar13](https://github.com/Dar13) -* [ngerakines](https://github.com/ngerakines) -* [vonneudeck](https://github.com/vonneudeck) -* [Ninetailed](https://github.com/Ninetailed) -* [k24](https://github.com/k24) -* [noiob](https://github.com/noiob) -* [kwaio](https://github.com/kwaio) -* [norayr](https://github.com/norayr) -* [joyeusenoelle](https://github.com/joyeusenoelle) -* [OlivierNicole](https://github.com/OlivierNicole) -* [noppa](https://github.com/noppa) -* [Otakan951](https://github.com/Otakan951) -* [fahy](https://github.com/fahy) +* [Mike Burns](mailto:mburns@thoughtbot.com) +* [Milan](mailto:me@petabyteboy.de) +* [Milan*](mailto:tchncs@vivaldi.net) +* [Milton Mazzarri](mailto:milmazz@gmail.com) +* [Minku Lee](mailto:premist@me.com) +* [Minori Hiraoka](mailto:mnkai@users.noreply.github.com) +* [Mitchell Hentges](mailto:mitch9654@gmail.com) +* [Mostafa Ahangarha](mailto:ahangarha@users.noreply.github.com) +* [Mouse Reeve](mailto:mousereeve@riseup.net) +* [Mozinet](mailto:mozinet-fr@users.noreply.github.com) +* [Musee U](mailto:lae@users.noreply.github.com) +* [NOGISAKA Sadata](mailto:ngsksdt@gmail.com) +* [Naf](mailto:uenok.htc@gmail.com) +* [Nanamachi](mailto:town7.haruki@gmail.com) +* [Nathaniel Ekoniak](mailto:nekoniak@ennate.tech) +* [NecroTechno](mailto:necrotechno@riseup.net) +* [Nick Gerakines](mailto:nick@gerakines.net) +* [Nicolai von Neudeck](mailto:nicolai@vonneudeck.com) +* [Ninetailed](mailto:ninetailed@gmail.com) +* [Nishi, Keisuke](mailto:k24@users.noreply.github.com) +* [Noiob](mailto:noiob@users.noreply.github.com) +* [Nope Nope](mailto:hireme@kwaio.ninja) +* [Norayr Chilingarian](mailto:norayr@arnet.am) +* [Noëlle Anthony](mailto:noelle.d.anthony@gmail.com) +* [N氏](mailto:uenok.htc@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) * [PatrickRWells](mailto:32802366+patrickrwells@users.noreply.github.com) * [Paul](mailto:naydex.mc+github@gmail.com) * [Pete Keen](mailto:pete@petekeen.net) * [Pierre-Morgan Gate](mailto:pgate@users.noreply.github.com) * [Ratmir Karabut](mailto:rkarabut@sfmodern.ru) * [Reto Kromer](mailto:retokromer@users.noreply.github.com) -* [Rey Tucker](mailto:git@reytucker.us) * [Rob Watson](mailto:rfwatson@users.noreply.github.com) * [Ryan Freebern](mailto:ryan@freebern.org) * [Ryan Wade](mailto:ryan.wade@protonmail.com) @@ -486,6 +512,7 @@ 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) * [Satoshi KOJIMA](mailto:skoji@mac.com) * [ScienJus](mailto:i@scienjus.com) * [Scott Larkin](mailto:scott@codeclimate.com) @@ -496,12 +523,10 @@ and provided thanks to the work of the following contributors: * [Shaun Gillies](mailto:me@shaungillies.net) * [Shin Adachi](mailto:shn@glucose.jp) * [Shin Kojima](mailto:shin@kojima.org) -* [Sho Kusano](mailto:rosylilly@aduca.org) * [Shouko Yu](mailto:imshouko@gmail.com) * [Sina Mashek](mailto:sina@mashek.xyz) * [Soshi Kato](mailto:mail@sossii.com) * [Spanky](mailto:2788886+spankyworks@users.noreply.github.com) -* [Stanislas](mailto:angristan@pm.me) * [StefOfficiel](mailto:pichard.stephane@free.fr) * [Steven Tappert](mailto:admin@dark-it.net) * [Svetlozar Todorov](mailto:svetlik@users.noreply.github.com) @@ -510,6 +535,7 @@ and provided thanks to the work of the following contributors: * [Takayoshi Nishida](mailto:takayoshi.nishida@gmail.com) * [Takayuki KUSANO](mailto:github@tkusano.jp) * [TakesxiSximada](mailto:takesxi.sximada@gmail.com) +* [Tao Bror Bojlén](mailto:brortao@users.noreply.github.com) * [TheInventrix](mailto:theinventrix@users.noreply.github.com) * [Thomas Alberola](mailto:thomas@needacoffee.fr) * [Toby Deshane](mailto:fortyseven@users.noreply.github.com) @@ -519,10 +545,12 @@ and provided thanks to the work of the following contributors: * [Treyssat-Vincent Nino](mailto:treyssatvincent@users.noreply.github.com) * [Udo Kramer](mailto:optik@fluffel.io) * [Una](mailto:una@unascribed.com) +* [Ushitora Anqou](mailto:ushitora@anqou.net) * [Ushitora Anqou](mailto:ushitora_anqou@yahoo.co.jp) * [Valentin Lorentz](mailto:progval+git@progval.net) * [Vladimir Mincev](mailto:vladimir@canicinteractive.com) * [Waldir Pimenta](mailto:waldyrious@gmail.com) +* [Wenceslao Páez Chávez](mailto:wcpaez@gmail.com) * [Wesley Ellis](mailto:tahnok@gmail.com) * [Wiktor](mailto:wiktor@metacode.biz) * [Wonderfall](mailto:wonderfall@schrodinger.io) @@ -533,6 +561,7 @@ and provided thanks to the work of the following contributors: * [YaQ](mailto:i_k_o_m_a_7@yahoo.co.jp) * [Yanaken](mailto:yanakend@gmail.com) * [Yann Klis](mailto:yann.klis@gmail.com) +* [Yağızhan](mailto:35808275+yagizhan49@users.noreply.github.com) * [Yeechan Lu](mailto:wz.bluesnow@gmail.com) * [Yusuke Abe](mailto:moonset20@gmail.com) * [Zachary Spector](mailto:logicaldash@gmail.com) @@ -546,6 +575,7 @@ and provided thanks to the work of the following contributors: * [chrolis](mailto:chrolis@users.noreply.github.com) * [cormo](mailto:cormorant2+github@gmail.com) * [d0p1](mailto:dopi-sama@hush.com) +* [dxwc](mailto:dxwc@users.noreply.github.com) * [evilny0](mailto:evilny0@moomoocamp.net) * [febrezo](mailto:felixbrezo@gmail.com) * [fsubal](mailto:fsubal@users.noreply.github.com) @@ -554,6 +584,7 @@ and provided thanks to the work of the following contributors: * [gol-cha](mailto:info@mevo.xyz) * [hakoai](mailto:hk--76@qa2.so-net.ne.jp) * [haosbvnker](mailto:github@chaosbunker.com) +* [ichi_i](mailto:51489410+ichi-i@users.noreply.github.com) * [isati](mailto:phil@juchnowi.cz) * [jacob](mailto:jacobherringtondeveloper@gmail.com) * [jenn kaplan](mailto:me@jkap.io) @@ -565,7 +596,6 @@ and provided thanks to the work of the following contributors: * [karlyeurl](mailto:karl.yeurl@gmail.com) * [kedama](mailto:32974885+kedamadq@users.noreply.github.com) * [kodai](mailto:shirafuta.kodai@gmail.com) -* [koyu](mailto:me@koyu.space) * [kuro5hin](mailto:rusty@kuro5hin.org) * [luzpaz](mailto:luzpaz@users.noreply.github.com) * [maxypy](mailto:maxime@mpigou.fr) @@ -577,6 +607,7 @@ 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) +* [nzws](mailto:git-yuzu@svk.jp) * [rch850](mailto:rich850@gmail.com) * [roikale](mailto:roikale@users.noreply.github.com) * [rysiekpl](mailto:rysiek@hackerspace.pl) @@ -589,6 +620,8 @@ and provided thanks to the work of the following contributors: * [tateisu](mailto:tateisu@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) @@ -597,9 +630,10 @@ and provided thanks to the work of the following contributors: * [yoshipc](mailto:yoooo@yoshipc.net) * [Özcan Zafer AYAN](mailto:ozcanzaferayan@gmail.com) * [ばん](mailto:detteiu0321@gmail.com) -* [みたらしだんご](mailto:mitarashidango@users.noreply.github.com) +* [ふるふる](mailto:frfs@users.noreply.github.com) * [りんすき](mailto:6533808+rinsuki@users.noreply.github.com) * [ヨイツの賢狼ホロ | 3rd style](mailto:horo@yoitsu.moe) +* [唐宗勛](mailto:tangzongxun@hotmail.com) * [猫吸血鬼ディフリス / 猫ロキP](mailto:deflis@gmail.com) * [艮 鮟鱇](mailto:ushitora_anqou@yahoo.co.jp) * [西小倉宏信](mailto:nishiko@mindia.jp) @@ -611,338 +645,122 @@ This document is provided for informational purposes only. Since it is only upda Following people have contributed to translation of Mastodon: -- **Albanian** - - Besnik Bleta - - Aditoo -- **Arabic** - - ButterflyOfFire - - Aditoo - - Amrz0 -- **Asturian** - - ButterflyOfFire - - Enol P. - - Aditoo -- **Basque** - - Osoitz - - Aditoo - - Aitzol - - ButterflyOfFire - - Peru Iparragirre - - Gorka Azkarate -- **Bengali** - - dxwc -- **Bulgarian** - - ButterflyOfFire - - Aditoo -- **Catalan** - - spla - - Aditoo - - ButterflyOfFire - - Joan Montané - - Jose Luis -- **Chinese (Hong Kong)** - - ButterflyOfFire - - Luzi Leung - - Aditoo -- **Chinese (Simplified)** - - Allen Zhong - - ButterflyOfFire - - SerCom_KC - - martialarts - - Kaitian Xie - - Aditoo - - pan93412 -- **Chinese (Traditional)** - - Aditoo - - ButterflyOfFire - - James58899 - - pan93412 - - S1ttidoe477 - - SHA265 - - Jeff Huang -- **Corsican** - - Alix D. R. - - Aditoo - - ButterflyOfFire -- **Croatian** - - ButterflyOfFire - - Aditoo -- **Czech** - - Aditoo - - Marek Ľach - - ButterflyOfFire -- **Danish** - - Einhjeriar - - Rasmus Sæderup - - Aditoo - - ButterflyOfFire -- **Dutch** - - Albakham - - ButterflyOfFire - - jeroenpraat - - rscmbbng - - Aditoo - - Jelv -- **English** - - ButterflyOfFire - - Renato "Lond" Cerqueira -- **English (United Kingdom)** - - Albakham -- **Esperanto** - - Aditoo - - ButterflyOfFire - - Becci Cat - - Jeong Arm - - Mélanie Chauvel - - Vanege - - Martin Bodin - - tuxayo/Victor Grousset -- **Finnish** - - ButterflyOfFire - - Mikko Poussu - - Taru Luojola - - S Heija - - Aditoo - - Jonne Arjoranta -- **French** - - Albakham - - Alix D. R. - - ButterflyOfFire - - codl - - Leia - - Alda Marteau-Hardi - - Mélanie Chauvel - - Paul Marques Mota - - azenet - - Olivier Humbert - - Aditoo - - Jonathan Chan - - Letiteuf55 - - Baptiste Jonglez - - goofy-mdn - - Jean-Baptiste Holcroft - - Technowix - - Martin Bodin - - Théodore - - Thibaut Girka - - Franck Paul - - Sylvhem -- **Galician** - - ButterflyOfFire - - Xose M. - - Aditoo - - manequim -- **Georgian** - - ButterflyOfFire - - Aditoo -- **German** - - Aditoo - - ButterflyOfFire - - Daniel - - averageunicorn - - Koyu Berteon - - larsreineke - - koyu - - Austin Jones - - lilo - - Benedikt Geißler - - ePirat - - Eugen Rochko - - Weblate Admin - - Patrick Figel -- **Greek** - - Dimitris Maroulidis - - Antonis - - Aditoo - - ButterflyOfFire - - Konstantinos Grevenitis -- **Hebrew** - - ButterflyOfFire - - Aditoo - - Ira - - Yaron Shahrabani -- **Hungarian** - - ButterflyOfFire - - Adam Paszternak - - Aditoo - - Tibike Miklós -- **Ido** - - ButterflyOfFire - - Aditoo -- **Indonesian** - - afachri - - ButterflyOfFire - - Dito Kurnia Pratama - - Eirworks - - Aditoo - - Alfiana Sibuea - - se7entime -- **Irish** - - Albakham - - Kevin Houlihan -- **Italian** - - Alessandro Levati - - Albakham - - ButterflyOfFire - - Marcin Mikołajczak - - Aditoo - - Giuseppe Pignataro - - Stefano -- **Japanese** - - Hinaloe - - 小鳥遊まりあ - - mayaeh - - osapon - - 森の子リスのミーコの大冒険 - - Kumasun Morino - - Yamagishi Kazutoshi - - Aditoo - - ButterflyOfFire - - Jeong Arm - - unarist -- **Kazakh** - - arshat - - Aditoo -- **Korean** - - Aditoo - - Jeong Arm - - ButterflyOfFire - - Minori Hiraoka - - Yamagishi Kazutoshi -- **Lithuanian** - - Sarunas Medeikis -- **Malay** - - Muhammad Nur Hidayat (MNH48) - - Aditoo - - ButterflyOfFire -- **Norwegian (old code)** - - ButterflyOfFire - - Espen Rønnevik - - Aditoo - - Tale -- **Occitan** - - Aditoo - - ButterflyOfFire - - Quenti2 - - Quentí - - Maxenç -- **Persian** - - Masoud Abkenar - - Aditoo - - ButterflyOfFire -- **Polish** - - Aditoo - - Albakham - - ButterflyOfFire - - Stasiek Michalski - - Marcin Mikołajczak - - Jakub Mendyk - - Marek Ľach - - krkk -- **Portuguese** - - Albakham - - João Pinheiro - - manequim - - Aditoo - - ButterflyOfFire - - Hugo Gameiro -- **Portuguese (Brazil)** - - Aditoo - - Albakham - - Anna e só - - Renato "Lond" Cerqueira - - André Andrade - - ButterflyOfFire -- **Romanian** - - adrianbblk - - ButterflyOfFire - - Aditoo -- **Russian** - - Albakham - - ButterflyOfFire - - Evgeny Petrov - - Aditoo - - Павел Гастелло - - Andrew Zyabin - - Yaron Shahrabani -- **Serbian** - - Branko Kokanovic - - Burekz Finezt - - Aditoo - - ButterflyOfFire -- **Serbian (latin)** - - ButterflyOfFire - - Aditoo -- **Slovak** - - Aditoo - - ButterflyOfFire - - Ivan Pleva - - Marek Ľach - - Peter -- **Slovenian** - - Kristijan Tkalec - - Aditoo - - ButterflyOfFire -- **Spanish** - - Albakham - - ButterflyOfFire - - Carlos Mondragon - - Antón López - - Max Winkler - - Pablo de la Concepción Sanz - - Sergio Soriano - - Angeles Broullón - - Lothar Wolf - - Aditoo - - David Charte - - Emmanuel -- **Swedish** - - ButterflyOfFire - - Isak Holmström - - Shellkr - - Aditoo - - Elias Mårtenson - - Stefan Midjich - - Tim Stahel - - Jonas Hultén -- **Telugu** - - avndp - - Ranjith Tellakula - - Aditoo - - ButterflyOfFire - - Joseph Nuthalapati -- **Thai** - - ButterflyOfFire - - parnikkapore - - Thai Localization - - Aditoo -- **Turkish** - - Ali Demirtas - - ButterflyOfFire - - Aditoo -- **Ukrainian** - - alexcleac - - ButterflyOfFire - - Aditoo - - Ivan Verchenko -- **Welsh** - - carl morris - - Jaz-Michael King - - Owain Rhys Lewis - - Rhoslyn Prys - - Aditoo - - ButterflyOfFire - - Renato "Lond" Cerqueira - - Albakham - - Kevin Beynon -- **Armenian** - - Aditoo - - ButterflyOfFire -- **Latvian** - - Aditoo - - ButterflyOfFire - - Maigonis -- **Tamil** - - Aditoo - - ButterflyOfFire - - Prasanna Venkadesh +- Zoltán Gera (*Hungarian*) +- Kristijan Tkalec (*Slovenian*) +- Evert Prants (*Estonian*) +- borys_sh (*Ukrainian*) +- ButterflyOfFire (*Arabic; French*) +- Osoitz (*Basque*) +- oɹʇuʞ (*Spanish, Argentina*) +- 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*) +- lamnatos (*Greek*) +- Emyn Nant Nefydd (*Welsh*) +- 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*) +- 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*) +- PPNplus (*Thai*) +- arethsu (*Swedish*) +- EPEMA YT (*German*) +- Rhys Harrison (*Esperanto*) +- KEINOS (*Japanese*) +- filippodb (*Italian*) +- JzshAC (*Chinese Simplified*) +- Rintan1 (*Japanese*) +- Antillion (*Spanish*) +- hiphipvargas (*Portuguese*) +- Ch. (*Korean*) +- tctovsli (*Norwegian Nynorsk*) +- vjasiegd (*Polish*) +- SamitiMed (*Thai*) +- umelard (*Hebrew*) +- 硫酸鶏 (*Japanese*) +- Adrián Lattes (*Spanish*) +- Hinaloe (*Japanese*) +- Renato "Lond" Cerqueira (*Portuguese, Brazilian*) +- parnikkapore (*Thai*) +- Marcin Mikołajczak (*Polish*) +- 森の子リスのミーコの大冒険 (*Japanese*) +- Marcepanek_ (*Polish*) +- Sahak Petrosyan (*Armenian*) +- Daniel Dimitrov (*Bulgarian*) +- Hugh Liu (*Chinese Simplified*) +- Rakino (*Chinese Simplified*) +- hussama (*Portuguese, Brazilian*) +- ThibG (*French*) +- SnDer (*Dutch*) +- PifyZ (*French*) +- eichkat3r (*German*) +- Karol Kosek (*Polish*) +- Akarshan Biswas (*Bengali*) +- Tradjincal (*French*) +- Steven Tappert (*German*) +- sergioaraujo1 (*Portuguese, Brazilian*) +- mmokhi (*Persian*) +- fedot (*Russian*) +- skaaarrr (*German*) +- JackXu (*Chinese Simplified*) +- Lukas Fülling (*German*) +- Zoé Bőle (*German*) +- Dremski (*Bulgarian*) +- tamaina (*Japanese*) +- OpenAlgeria (*Arabic*) diff --git a/CHANGELOG.md b/CHANGELOG.md index a17fbf8f0..b200747b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,235 @@ Changelog All notable changes to this project will be documented in this file. +## [3.0.1] - 2019-10-10 +### Added + +- Add `tootctl media usage` command ([Gargron](https://github.com/tootsuite/mastodon/pull/12115)) +- Add admin setting to auto-approve trending hashtags ([Gargron](https://github.com/tootsuite/mastodon/pull/12122), [Gargron](https://github.com/tootsuite/mastodon/pull/12130)) + +### Changed + +- Change `tootctl media refresh` to skip already downloaded attachments ([Gargron](https://github.com/tootsuite/mastodon/pull/12118)) + +### Removed + +- Remove auto-silence behaviour from spam check ([Gargron](https://github.com/tootsuite/mastodon/pull/12117)) +- Remove HTML `lang` attribute from individual statuses in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12124)) +- Remove fallback to long description on sidebar and meta description ([Gargron](https://github.com/tootsuite/mastodon/pull/12119)) + +### Fixed + +- Fix preloaded JSON-LD context for identity not being used ([Gargron](https://github.com/tootsuite/mastodon/pull/12138)) +- Fix media editing modal changing dimensions once the image loads ([Gargron](https://github.com/tootsuite/mastodon/pull/12131)) +- Fix not showing whether a custom emoji has a local counterpart in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12135)) +- Fix attachment not being re-downloaded even if file is not stored ([Gargron](https://github.com/tootsuite/mastodon/pull/12125)) +- Fix old migration trying to use new column due to default status scope ([Gargron](https://github.com/tootsuite/mastodon/pull/12095)) +- Fix column back button missing for not found accounts ([trwnh](https://github.com/tootsuite/mastodon/pull/12094)) +- Fix issues with tootctl's parallelization and progress reporting ([Gargron](https://github.com/tootsuite/mastodon/pull/12093), [Gargron](https://github.com/tootsuite/mastodon/pull/12097)) +- Fix existing user records with now-renamed `pt` locale ([Gargron](https://github.com/tootsuite/mastodon/pull/12092)) +- Fix hashtag timeline REST API accepting too many hashtags ([Gargron](https://github.com/tootsuite/mastodon/pull/12091)) +- Fix `GET /api/v1/instance` REST APIs being unavailable in secure mode ([Gargron](https://github.com/tootsuite/mastodon/pull/12089)) +- Fix performance of home feed regeneration and merging ([Gargron](https://github.com/tootsuite/mastodon/pull/12084)) +- Fix ffmpeg performance issues due to stdout buffer overflow ([hugogameiro](https://github.com/tootsuite/mastodon/pull/12088)) +- Fix S3 adapter retrying failing uploads with exponential backoff ([Gargron](https://github.com/tootsuite/mastodon/pull/12085)) +- Fix `tootctl accounts cull` advertising unused option flag ([Kjwon15](https://github.com/tootsuite/mastodon/pull/12074)) + +## [3.0.0] - 2019-10-03 +### Added + +- Add "not available" label to unloaded media attachments in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11715), [Gargron](https://github.com/tootsuite/mastodon/pull/11745)) +- **Add profile directory to web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11688), [mayaeh](https://github.com/tootsuite/mastodon/pull/11872)) + - Add profile directory opt-in federation + - Add profile directory REST API +- Add special alert for throttled requests in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11677)) +- Add confirmation modal when logging out from the web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11671)) +- **Add audio player in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11644), [Gargron](https://github.com/tootsuite/mastodon/pull/11652), [Gargron](https://github.com/tootsuite/mastodon/pull/11654), [ThibG](https://github.com/tootsuite/mastodon/pull/11629), [Gargron](https://github.com/tootsuite/mastodon/pull/12056)) +- **Add autosuggestions for hashtags in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11422), [ThibG](https://github.com/tootsuite/mastodon/pull/11632), [Gargron](https://github.com/tootsuite/mastodon/pull/11764), [Gargron](https://github.com/tootsuite/mastodon/pull/11588), [Gargron](https://github.com/tootsuite/mastodon/pull/11442)) +- **Add media editing modal with OCR tool in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11563), [Gargron](https://github.com/tootsuite/mastodon/pull/11566), [ThibG](https://github.com/tootsuite/mastodon/pull/11575), [ThibG](https://github.com/tootsuite/mastodon/pull/11576), [Gargron](https://github.com/tootsuite/mastodon/pull/11577), [Gargron](https://github.com/tootsuite/mastodon/pull/11573), [Gargron](https://github.com/tootsuite/mastodon/pull/11571)) +- Add indicator of unread notifications to window title when web UI is out of focus ([Gargron](https://github.com/tootsuite/mastodon/pull/11560), [Gargron](https://github.com/tootsuite/mastodon/pull/11572)) +- Add indicator for which options you voted for in a poll in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11195)) +- **Add search results pagination to web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11409), [ThibG](https://github.com/tootsuite/mastodon/pull/11447)) +- **Add option to disable real-time updates in web UI ("slow mode")** ([Gargron](https://github.com/tootsuite/mastodon/pull/9984), [ykzts](https://github.com/tootsuite/mastodon/pull/11880), [ThibG](https://github.com/tootsuite/mastodon/pull/11883), [Gargron](https://github.com/tootsuite/mastodon/pull/11898), [ThibG](https://github.com/tootsuite/mastodon/pull/11859)) +- Add option to disable blurhash previews in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11188)) +- Add native smooth scrolling when supported in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11207)) +- Add scrolling to the search bar on focus in web UI ([Kjwon15](https://github.com/tootsuite/mastodon/pull/12032)) +- Add refresh button to list of rebloggers/favouriters in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12031)) +- Add error description and button to copy stack trace to web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12033)) +- Add search and sort functions to hashtag admin UI ([mayaeh](https://github.com/tootsuite/mastodon/pull/11829), [Gargron](https://github.com/tootsuite/mastodon/pull/11897), [mayaeh](https://github.com/tootsuite/mastodon/pull/11875)) +- Add setting for default search engine indexing in admin UI ([brortao](https://github.com/tootsuite/mastodon/pull/11804)) +- Add account bio to account view in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11473)) +- **Add option to include reported statuses in warning e-mail from admin UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11639), [Gargron](https://github.com/tootsuite/mastodon/pull/11812), [Gargron](https://github.com/tootsuite/mastodon/pull/11741), [Gargron](https://github.com/tootsuite/mastodon/pull/11698), [mayaeh](https://github.com/tootsuite/mastodon/pull/11765)) +- Add number of pending accounts and pending hashtags to dashboard in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11514)) +- **Add account migration UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11846), [noellabo](https://github.com/tootsuite/mastodon/pull/11905), [noellabo](https://github.com/tootsuite/mastodon/pull/11907), [noellabo](https://github.com/tootsuite/mastodon/pull/11906), [noellabo](https://github.com/tootsuite/mastodon/pull/11902)) +- **Add table of contents to about page** ([Gargron](https://github.com/tootsuite/mastodon/pull/11885), [ykzts](https://github.com/tootsuite/mastodon/pull/11941), [ykzts](https://github.com/tootsuite/mastodon/pull/11895), [Kjwon15](https://github.com/tootsuite/mastodon/pull/11916)) +- **Add password challenge to 2FA settings, e-mail notifications** ([Gargron](https://github.com/tootsuite/mastodon/pull/11878)) +- **Add optional public list of domain blocks with comments** ([ThibG](https://github.com/tootsuite/mastodon/pull/11298), [ThibG](https://github.com/tootsuite/mastodon/pull/11515), [Gargron](https://github.com/tootsuite/mastodon/pull/11908)) +- Add an RSS feed for featured hashtags ([noellabo](https://github.com/tootsuite/mastodon/pull/10502)) +- Add explanations to featured hashtags UI and profile ([Gargron](https://github.com/tootsuite/mastodon/pull/11586)) +- **Add hashtag trends with admin and user settings** ([Gargron](https://github.com/tootsuite/mastodon/pull/11490), [Gargron](https://github.com/tootsuite/mastodon/pull/11502), [Gargron](https://github.com/tootsuite/mastodon/pull/11641), [Gargron](https://github.com/tootsuite/mastodon/pull/11594), [Gargron](https://github.com/tootsuite/mastodon/pull/11517), [mayaeh](https://github.com/tootsuite/mastodon/pull/11845), [Gargron](https://github.com/tootsuite/mastodon/pull/11774), [Gargron](https://github.com/tootsuite/mastodon/pull/11712), [Gargron](https://github.com/tootsuite/mastodon/pull/11791), [Gargron](https://github.com/tootsuite/mastodon/pull/11743), [Gargron](https://github.com/tootsuite/mastodon/pull/11740), [Gargron](https://github.com/tootsuite/mastodon/pull/11714), [ThibG](https://github.com/tootsuite/mastodon/pull/11631), [Sasha-Sorokin](https://github.com/tootsuite/mastodon/pull/11569), [Gargron](https://github.com/tootsuite/mastodon/pull/11524), [Gargron](https://github.com/tootsuite/mastodon/pull/11513)) + - Add hashtag usage breakdown to admin UI + - Add batch actions for hashtags to admin UI + - Add trends to web UI + - Add trends to public pages + - Add user preference to hide trends + - Add admin setting to disable trends +- **Add categories for custom emojis** ([Gargron](https://github.com/tootsuite/mastodon/pull/11196), [Gargron](https://github.com/tootsuite/mastodon/pull/11793), [Gargron](https://github.com/tootsuite/mastodon/pull/11920), [highemerly](https://github.com/tootsuite/mastodon/pull/11876)) + - Add custom emoji categories to emoji picker in web UI + - Add `category` to custom emojis in REST API + - Add batch actions for custom emojis in admin UI +- Add max image dimensions to error message ([raboof](https://github.com/tootsuite/mastodon/pull/11552)) +- Add aac, m4a, 3gp, amr, wma to allowed audio formats ([Gargron](https://github.com/tootsuite/mastodon/pull/11342), [umonaca](https://github.com/tootsuite/mastodon/pull/11687)) +- **Add search syntax for operators and phrases** ([Gargron](https://github.com/tootsuite/mastodon/pull/11411)) +- **Add REST API for managing featured hashtags** ([noellabo](https://github.com/tootsuite/mastodon/pull/11778)) +- **Add REST API for managing timeline read markers** ([Gargron](https://github.com/tootsuite/mastodon/pull/11762)) +- Add `exclude_unreviewed` param to `GET /api/v2/search` REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/11977)) +- Add `reason` param to `POST /api/v1/accounts` REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/12064)) +- **Add ActivityPub secure mode** ([Gargron](https://github.com/tootsuite/mastodon/pull/11269), [ThibG](https://github.com/tootsuite/mastodon/pull/11332), [ThibG](https://github.com/tootsuite/mastodon/pull/11295)) +- Add HTTP signatures to all outgoing ActivityPub GET requests ([Gargron](https://github.com/tootsuite/mastodon/pull/11284), [ThibG](https://github.com/tootsuite/mastodon/pull/11300)) +- Add support for ActivityPub Audio activities ([ThibG](https://github.com/tootsuite/mastodon/pull/11189)) +- Add ActivityPub actor representing the entire server ([ThibG](https://github.com/tootsuite/mastodon/pull/11321), [rtucker](https://github.com/tootsuite/mastodon/pull/11400), [ThibG](https://github.com/tootsuite/mastodon/pull/11561), [Gargron](https://github.com/tootsuite/mastodon/pull/11798)) +- **Add whitelist mode** ([Gargron](https://github.com/tootsuite/mastodon/pull/11291), [mayaeh](https://github.com/tootsuite/mastodon/pull/11634)) +- Add config of multipart threshold for S3 ([ykzts](https://github.com/tootsuite/mastodon/pull/11924), [ykzts](https://github.com/tootsuite/mastodon/pull/11944)) +- Add health check endpoint for web ([ykzts](https://github.com/tootsuite/mastodon/pull/11770), [ykzts](https://github.com/tootsuite/mastodon/pull/11947)) +- Add HTTP signature keyId to request log ([Gargron](https://github.com/tootsuite/mastodon/pull/11591)) +- Add `SMTP_REPLY_TO` environment variable ([hugogameiro](https://github.com/tootsuite/mastodon/pull/11718)) +- Add `tootctl preview_cards remove` command ([mayaeh](https://github.com/tootsuite/mastodon/pull/11320)) +- Add `tootctl media refresh` command ([Gargron](https://github.com/tootsuite/mastodon/pull/11775)) +- Add `tootctl cache recount` command ([Gargron](https://github.com/tootsuite/mastodon/pull/11597)) +- Add option to exclude suspended domains from `tootctl domains crawl` ([dariusk](https://github.com/tootsuite/mastodon/pull/11454)) +- Add parallelization to `tootctl search deploy` ([noellabo](https://github.com/tootsuite/mastodon/pull/12051)) +- Add soft delete for statuses for instant deletes through API ([Gargron](https://github.com/tootsuite/mastodon/pull/11623), [Gargron](https://github.com/tootsuite/mastodon/pull/11648)) +- Add rails-level JSON caching ([Gargron](https://github.com/tootsuite/mastodon/pull/11333), [Gargron](https://github.com/tootsuite/mastodon/pull/11271)) +- **Add request pool to improve delivery performance** ([Gargron](https://github.com/tootsuite/mastodon/pull/10353), [ykzts](https://github.com/tootsuite/mastodon/pull/11756)) +- Add concurrent connection attempts to resolved IP addresses ([ThibG](https://github.com/tootsuite/mastodon/pull/11757)) +- Add index for remember_token to improve login performance ([abcang](https://github.com/tootsuite/mastodon/pull/11881)) +- **Add more accurate hashtag search** ([Gargron](https://github.com/tootsuite/mastodon/pull/11579), [Gargron](https://github.com/tootsuite/mastodon/pull/11427), [Gargron](https://github.com/tootsuite/mastodon/pull/11448)) +- **Add more accurate account search** ([Gargron](https://github.com/tootsuite/mastodon/pull/11537), [Gargron](https://github.com/tootsuite/mastodon/pull/11580)) +- **Add a spam check** ([Gargron](https://github.com/tootsuite/mastodon/pull/11217), [Gargron](https://github.com/tootsuite/mastodon/pull/11806), [ThibG](https://github.com/tootsuite/mastodon/pull/11296)) +- Add new languages ([Gargron](https://github.com/tootsuite/mastodon/pull/12062)) + - Breton + - Spanish (Argentina) + - Estonian + - Macedonian + - New Norwegian +- Add NodeInfo endpoint ([Gargron](https://github.com/tootsuite/mastodon/pull/12002), [Gargron](https://github.com/tootsuite/mastodon/pull/12058)) + +### Changed + +- **Change conversations UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/11896)) +- Change dashboard to short number notation ([noellabo](https://github.com/tootsuite/mastodon/pull/11847), [noellabo](https://github.com/tootsuite/mastodon/pull/11911)) +- Change REST API `GET /api/v1/timelines/public` to require authentication when public preview is off ([ThibG](https://github.com/tootsuite/mastodon/pull/11802)) +- Change REST API `POST /api/v1/follow_requests/:id/(approve|reject)` to return relationship ([ThibG](https://github.com/tootsuite/mastodon/pull/11800)) +- Change rate limit for media proxy ([ykzts](https://github.com/tootsuite/mastodon/pull/11814)) +- Change unlisted custom emoji to not appear in autosuggestions ([Gargron](https://github.com/tootsuite/mastodon/pull/11818)) +- Change max length of media descriptions from 420 to 1500 characters ([Gargron](https://github.com/tootsuite/mastodon/pull/11819), [ThibG](https://github.com/tootsuite/mastodon/pull/11836)) +- **Change deletes to preserve soft-deleted statuses in unresolved reports** ([Gargron](https://github.com/tootsuite/mastodon/pull/11805)) +- **Change tootctl to use inline parallelization instead of Sidekiq** ([Gargron](https://github.com/tootsuite/mastodon/pull/11776)) +- **Change account deletion page to have better explanations** ([Gargron](https://github.com/tootsuite/mastodon/pull/11753), [Gargron](https://github.com/tootsuite/mastodon/pull/11763)) +- Change hashtag component in web UI to show numbers for 2 last days ([Gargron](https://github.com/tootsuite/mastodon/pull/11742), [Gargron](https://github.com/tootsuite/mastodon/pull/11755), [Gargron](https://github.com/tootsuite/mastodon/pull/11754)) +- Change OpenGraph description on sign-up page to reflect invite ([Gargron](https://github.com/tootsuite/mastodon/pull/11744)) +- Change layout of public profile directory to be the same as in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11705)) +- Change detailed status child ordering to sort self-replies on top ([ThibG](https://github.com/tootsuite/mastodon/pull/11686)) +- Change window resize handler to switch to/from mobile layout as soon as needed ([ThibG](https://github.com/tootsuite/mastodon/pull/11656)) +- Change icon button styles to make hover/focus states more obvious ([ThibG](https://github.com/tootsuite/mastodon/pull/11474)) +- Change contrast of status links that are not mentions or hashtags ([ThibG](https://github.com/tootsuite/mastodon/pull/11406)) +- **Change hashtags to preserve first-used casing** ([Gargron](https://github.com/tootsuite/mastodon/pull/11416), [Gargron](https://github.com/tootsuite/mastodon/pull/11508), [Gargron](https://github.com/tootsuite/mastodon/pull/11504), [Gargron](https://github.com/tootsuite/mastodon/pull/11507), [Gargron](https://github.com/tootsuite/mastodon/pull/11441)) +- **Change unconfirmed user login behaviour** ([Gargron](https://github.com/tootsuite/mastodon/pull/11375), [ThibG](https://github.com/tootsuite/mastodon/pull/11394), [Gargron](https://github.com/tootsuite/mastodon/pull/11860)) +- **Change single-column mode to scroll the whole page** ([Gargron](https://github.com/tootsuite/mastodon/pull/11359), [Gargron](https://github.com/tootsuite/mastodon/pull/11894), [Gargron](https://github.com/tootsuite/mastodon/pull/11891), [ThibG](https://github.com/tootsuite/mastodon/pull/11655), [Gargron](https://github.com/tootsuite/mastodon/pull/11463), [Gargron](https://github.com/tootsuite/mastodon/pull/11458), [ThibG](https://github.com/tootsuite/mastodon/pull/11395), [Gargron](https://github.com/tootsuite/mastodon/pull/11418)) +- Change `tootctl accounts follow` to only work with local accounts ([angristan](https://github.com/tootsuite/mastodon/pull/11592)) +- Change Dockerfile ([Shleeble](https://github.com/tootsuite/mastodon/pull/11710), [ykzts](https://github.com/tootsuite/mastodon/pull/11768), [Shleeble](https://github.com/tootsuite/mastodon/pull/11707)) +- Change supported Node versions to include v12 ([abcang](https://github.com/tootsuite/mastodon/pull/11706)) +- Change Portuguese language from `pt` to `pt-PT` ([Gargron](https://github.com/tootsuite/mastodon/pull/11820)) +- Change domain block silence to always require approval on follow ([ThibG](https://github.com/tootsuite/mastodon/pull/11975)) +- Change link preview fetcher to not perform a HEAD request first ([Gargron](https://github.com/tootsuite/mastodon/pull/12028)) +- Change `tootctl domains purge` to accept multiple domains at once ([Gargron](https://github.com/tootsuite/mastodon/pull/12046)) + +### Removed + +- **Remove OStatus support** ([Gargron](https://github.com/tootsuite/mastodon/pull/11205), [Gargron](https://github.com/tootsuite/mastodon/pull/11303), [Gargron](https://github.com/tootsuite/mastodon/pull/11460), [ThibG](https://github.com/tootsuite/mastodon/pull/11280), [ThibG](https://github.com/tootsuite/mastodon/pull/11278)) +- Remove Atom feeds and old URLs in the form of `GET /:username/updates/:id` ([Gargron](https://github.com/tootsuite/mastodon/pull/11247)) +- Remove WebP support ([angristan](https://github.com/tootsuite/mastodon/pull/11589)) +- Remove deprecated config options from Heroku and Scalingo ([ykzts](https://github.com/tootsuite/mastodon/pull/11925)) +- Remove deprecated REST API `GET /api/v1/search` API ([Gargron](https://github.com/tootsuite/mastodon/pull/11823)) +- Remove deprecated REST API `GET /api/v1/statuses/:id/card` ([Gargron](https://github.com/tootsuite/mastodon/pull/11213)) +- Remove deprecated REST API `POST /api/v1/notifications/dismiss?id=:id` ([Gargron](https://github.com/tootsuite/mastodon/pull/11214)) +- Remove deprecated REST API `GET /api/v1/timelines/direct` ([Gargron](https://github.com/tootsuite/mastodon/pull/11212)) + +### Fixed + +- Fix manifest warning ([ykzts](https://github.com/tootsuite/mastodon/pull/11767)) +- Fix admin UI for custom emoji not respecting GIF autoplay preference ([ThibG](https://github.com/tootsuite/mastodon/pull/11801)) +- Fix page body not being scrollable in admin/settings layout ([Gargron](https://github.com/tootsuite/mastodon/pull/11893)) +- Fix placeholder colors for inputs not being explicitly defined ([Gargron](https://github.com/tootsuite/mastodon/pull/11890)) +- Fix incorrect enclosure length in RSS ([tsia](https://github.com/tootsuite/mastodon/pull/11889)) +- Fix TOTP codes not being filtered from logs during enabling/disabling ([Gargron](https://github.com/tootsuite/mastodon/pull/11877)) +- Fix webfinger response not returning 410 when account is suspended ([Gargron](https://github.com/tootsuite/mastodon/pull/11869)) +- Fix ActivityPub Move handler queuing jobs that will fail if account is suspended ([Gargron](https://github.com/tootsuite/mastodon/pull/11864)) +- Fix SSO login not using existing account when e-mail is verified ([Gargron](https://github.com/tootsuite/mastodon/pull/11862)) +- Fix web UI allowing uploads past status limit via drag & drop ([Gargron](https://github.com/tootsuite/mastodon/pull/11863)) +- Fix expiring polls not being displayed as such in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11835)) +- Fix 2FA challenge and password challenge for non-database users ([Gargron](https://github.com/tootsuite/mastodon/pull/11831), [Gargron](https://github.com/tootsuite/mastodon/pull/11943)) +- Fix profile fields overflowing page width in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11828)) +- Fix web push subscriptions being deleted on rate limit or timeout ([Gargron](https://github.com/tootsuite/mastodon/pull/11826)) +- Fix display of long poll options in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11717), [ThibG](https://github.com/tootsuite/mastodon/pull/11833)) +- Fix search API not resolving URL when `type` is given ([Gargron](https://github.com/tootsuite/mastodon/pull/11822)) +- Fix hashtags being split by ZWNJ character ([Gargron](https://github.com/tootsuite/mastodon/pull/11821)) +- Fix scroll position resetting when opening media modals in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11815)) +- Fix duplicate HTML IDs on about page ([ThibG](https://github.com/tootsuite/mastodon/pull/11803)) +- Fix admin UI showing superfluous reject media/reports on suspended domain blocks ([ThibG](https://github.com/tootsuite/mastodon/pull/11749)) +- Fix ActivityPub context not being dynamically computed ([ThibG](https://github.com/tootsuite/mastodon/pull/11746)) +- Fix Mastodon logo style on hover on public pages' footer ([ThibG](https://github.com/tootsuite/mastodon/pull/11735)) +- Fix height of dashboard counters ([ThibG](https://github.com/tootsuite/mastodon/pull/11736)) +- Fix custom emoji animation on hover in web UI directory bios ([ThibG](https://github.com/tootsuite/mastodon/pull/11716)) +- Fix non-numbers being passed to Redis and causing an error ([Gargron](https://github.com/tootsuite/mastodon/pull/11697)) +- Fix error in REST API for an account's statuses ([Gargron](https://github.com/tootsuite/mastodon/pull/11700)) +- Fix uncaught error when resource param is missing in Webfinger request ([Gargron](https://github.com/tootsuite/mastodon/pull/11701)) +- Fix uncaught domain normalization error in remote follow ([Gargron](https://github.com/tootsuite/mastodon/pull/11703)) +- Fix uncaught 422 and 500 errors ([Gargron](https://github.com/tootsuite/mastodon/pull/11590), [Gargron](https://github.com/tootsuite/mastodon/pull/11811)) +- Fix uncaught parameter missing exceptions and missing error templates ([Gargron](https://github.com/tootsuite/mastodon/pull/11702)) +- Fix encoding error when checking e-mail MX records ([Gargron](https://github.com/tootsuite/mastodon/pull/11696)) +- Fix items in StatusContent render list not all having a key ([ThibG](https://github.com/tootsuite/mastodon/pull/11645)) +- Fix remote and staff-removed statuses leaving media behind for a day ([Gargron](https://github.com/tootsuite/mastodon/pull/11638)) +- Fix CSP needlessly allowing blob URLs in script-src ([ThibG](https://github.com/tootsuite/mastodon/pull/11620)) +- Fix ignoring whole status because of one invalid hashtag ([Gargron](https://github.com/tootsuite/mastodon/pull/11621)) +- Fix hidden statuses losing focus ([ThibG](https://github.com/tootsuite/mastodon/pull/11208)) +- Fix loading bar being obscured by other elements in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11598)) +- Fix multiple issues with replies collection for pages further than self-replies ([ThibG](https://github.com/tootsuite/mastodon/pull/11582)) +- Fix blurhash and autoplay not working on public pages ([Gargron](https://github.com/tootsuite/mastodon/pull/11585)) +- Fix 422 being returned instead of 404 when POSTing to unmatched routes ([Gargron](https://github.com/tootsuite/mastodon/pull/11574), [Gargron](https://github.com/tootsuite/mastodon/pull/11704)) +- Fix client-side resizing of image uploads ([ThibG](https://github.com/tootsuite/mastodon/pull/11570)) +- Fix short number formatting for numbers above million in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11559)) +- Fix ActivityPub and REST API queries setting cookies and preventing caching ([ThibG](https://github.com/tootsuite/mastodon/pull/11539), [ThibG](https://github.com/tootsuite/mastodon/pull/11557), [ThibG](https://github.com/tootsuite/mastodon/pull/11336), [ThibG](https://github.com/tootsuite/mastodon/pull/11331)) +- Fix some emojis in profile metadata labels are not emojified. ([kedamaDQ](https://github.com/tootsuite/mastodon/pull/11534)) +- Fix account search always returning exact match on paginated results ([Gargron](https://github.com/tootsuite/mastodon/pull/11525)) +- Fix acct URIs with IDN domains not being resolved ([Gargron](https://github.com/tootsuite/mastodon/pull/11520)) +- Fix admin dashboard missing latest features ([Gargron](https://github.com/tootsuite/mastodon/pull/11505)) +- Fix jumping of toot date when clicking spoiler button ([ariasuni](https://github.com/tootsuite/mastodon/pull/11449)) +- Fix boost to original audience not working on mobile in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11371)) +- Fix handling of webfinger redirects in ResolveAccountService ([ThibG](https://github.com/tootsuite/mastodon/pull/11279)) +- Fix URLs appearing twice in errors of ActivityPub::DeliveryWorker ([Gargron](https://github.com/tootsuite/mastodon/pull/11231)) +- Fix support for HTTP proxies ([ThibG](https://github.com/tootsuite/mastodon/pull/11245)) +- Fix HTTP requests to IPv6 hosts ([ThibG](https://github.com/tootsuite/mastodon/pull/11240)) +- Fix error in ElasticSearch index import ([mayaeh](https://github.com/tootsuite/mastodon/pull/11192)) +- Fix duplicate account error when seeding development database ([ysksn](https://github.com/tootsuite/mastodon/pull/11366)) +- Fix performance of session clean-up scheduler ([abcang](https://github.com/tootsuite/mastodon/pull/11871)) +- Fix older migrations not running ([zunda](https://github.com/tootsuite/mastodon/pull/11377)) +- Fix URLs counting towards RTL detection ([ahangarha](https://github.com/tootsuite/mastodon/pull/11759)) +- Fix unnecessary status re-rendering in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11211)) +- Fix http_parser.rb gem not being compiled when no network available ([petabyteboy](https://github.com/tootsuite/mastodon/pull/11444)) +- Fix muted text color not applying to all text ([trwnh](https://github.com/tootsuite/mastodon/pull/11996)) +- Fix follower/following lists resetting on back-navigation in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11986)) +- Fix n+1 query when approving multiple follow requests ([abcang](https://github.com/tootsuite/mastodon/pull/12004)) +- Fix records not being indexed into ElasticSearch sometimes ([Gargron](https://github.com/tootsuite/mastodon/pull/12024)) +- Fix needlessly indexing unsearchable statuses into ElasticSearch ([Gargron](https://github.com/tootsuite/mastodon/pull/12041)) +- Fix new user bootstrapping crashing when to-be-followed accounts are invalid ([ThibG](https://github.com/tootsuite/mastodon/pull/12037)) +- Fix featured hashtag URL being interpreted as media or replies tab ([Gargron](https://github.com/tootsuite/mastodon/pull/12048)) +- Fix account counters being overwritten by parallel writes ([Gargron](https://github.com/tootsuite/mastodon/pull/12045)) + +### Security + +- Fix performance of GIF re-encoding and always strip EXIF data from videos ([Gargron](https://github.com/tootsuite/mastodon/pull/12057)) + ## [2.9.3] - 2019-08-10 ### Added diff --git a/Dockerfile b/Dockerfile index d8c7e0f0c..e963674a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,22 +4,20 @@ FROM ubuntu:18.04 as build-dep SHELL ["bash", "-c"] # Install Node -ENV NODE_VER="8.15.0" +ENV NODE_VER="12.11.1" RUN echo "Etc/UTC" > /etc/localtime && \ apt update && \ - apt -y install wget make gcc g++ python && \ + apt -y install wget python && \ cd ~ && \ - wget https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER.tar.gz && \ - tar xf node-v$NODE_VER.tar.gz && \ - cd node-v$NODE_VER && \ - ./configure --prefix=/opt/node && \ - make -j$(nproc) > /dev/null && \ - make install + wget https://nodejs.org/download/release/v$NODE_VER/node-v$NODE_VER-linux-x64.tar.gz && \ + tar xf node-v$NODE_VER-linux-x64.tar.gz && \ + rm node-v$NODE_VER-linux-x64.tar.gz && \ + mv node-v$NODE_VER-linux-x64 /opt/node # Install jemalloc -ENV JE_VER="5.1.0" +ENV JE_VER="5.2.1" RUN apt update && \ - apt -y install autoconf && \ + 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 && \ @@ -30,7 +28,7 @@ RUN apt update && \ make install_bin install_include install_lib # Install ruby -ENV RUBY_VER="2.6.1" +ENV RUBY_VER="2.6.5" ENV CPPFLAGS="-I/opt/jemalloc/include" ENV LDFLAGS="-L/opt/jemalloc/lib/" RUN apt update && \ diff --git a/Gemfile b/Gemfile index b72f550eb..6f1fcb6f1 100644 --- a/Gemfile +++ b/Gemfile @@ -5,17 +5,17 @@ ruby '>= 2.4.0', '< 2.7.0' gem 'pkg-config', '~> 1.3' -gem 'puma', '~> 3.12' +gem 'puma', '~> 4.2' gem 'rails', '~> 5.2.3' gem 'thor', '~> 0.20' gem 'hamlit-rails', '~> 0.2' gem 'pg', '~> 1.1' gem 'makara', '~> 0.4' -gem 'pghero', '~> 2.2' +gem 'pghero', '~> 2.3' gem 'dotenv-rails', '~> 2.7' -gem 'aws-sdk-s3', '~> 1.42', require: false +gem 'aws-sdk-s3', '~> 1.48', require: false gem 'fog-core', '<= 2.1.0' gem 'fog-openstack', '~> 0.3', require: false gem 'paperclip', '~> 6.0' @@ -24,15 +24,15 @@ gem 'streamio-ffmpeg', '~> 3.0' gem 'blurhash', '~> 0.1' gem 'active_model_serializers', '~> 0.10' -gem 'addressable', '~> 2.6' +gem 'addressable', '~> 2.7' gem 'bootsnap', '~> 1.4', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.6' gem 'iso-639' -gem 'chewy', '~> 5.0' +gem 'chewy', '~> 5.1' gem 'cld3', '~> 3.2.4' -gem 'devise', '~> 4.6' -gem 'devise-two-factor', '~> 3.0' +gem 'devise', '~> 4.7' +gem 'devise-two-factor', '~> 3.1' group :pam_authentication, optional: true do gem 'devise_pam_authenticatable2', '~> 9.2' @@ -43,54 +43,60 @@ gem 'omniauth-cas', '~> 1.1' gem 'omniauth-saml', '~> 1.10' gem 'omniauth', '~> 1.9' -gem 'doorkeeper', '~> 5.1' +gem 'discard', '~> 1.1' +gem 'doorkeeper', '~> 5.2' gem 'fast_blank', '~> 1.0' gem 'fastimage' gem 'goldfinger', '~> 2.1' gem 'hiredis', '~> 0.6' gem 'redis-namespace', '~> 1.5' +gem 'health_check', git: 'https://github.com/ianheggie/health_check', ref: '0b799ead604f900ed50685e9b2d469cd2befba5b' gem 'htmlentities', '~> 4.3' gem 'http', '~> 3.3' gem 'http_accept_language', '~> 2.1' -gem 'http_parser.rb', '~> 0.6', git: 'https://github.com/tmm1/http_parser.rb', ref: '54b17ba8c7d8d20a16dfc65d1775241833219cf2' +gem 'http_parser.rb', '~> 0.6', git: 'https://github.com/tmm1/http_parser.rb', ref: '54b17ba8c7d8d20a16dfc65d1775241833219cf2', submodules: true gem 'httplog', '~> 1.3' gem 'idn-ruby', require: 'idn' gem 'kaminari', '~> 1.1' gem 'link_header', '~> 0.0' -gem 'mime-types', '~> 3.2', require: 'mime/types/columnar' +gem 'mime-types', '~> 3.3', require: 'mime/types/columnar' +gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532' gem 'nokogiri', '~> 1.10' gem 'nsa', '~> 0.2' -gem 'oj', '~> 3.7' +gem 'oj', '~> 3.9' gem 'ostatus2', '~> 2.0' gem 'ox', '~> 2.11' +gem 'parslet' +gem 'parallel', '~> 1.17' gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c' -gem 'pundit', '~> 2.0' +gem 'pundit', '~> 2.1' gem 'premailer-rails' -gem 'rack-attack', '~> 6.0' +gem 'rack-attack', '~> 6.1' gem 'rack-cors', '~> 1.0', require: 'rack/cors' gem 'rails-i18n', '~> 5.1' gem 'rails-settings-cached', '~> 0.6' gem 'redis', '~> 4.1', require: ['redis', 'redis/connection/hiredis'] gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'rqrcode', '~> 0.10' -gem 'sanitize', '~> 5.0' +gem 'ruby-progressbar', '~> 1.10' +gem 'sanitize', '~> 5.1' gem 'sidekiq', '~> 5.2' gem 'sidekiq-scheduler', '~> 3.0' gem 'sidekiq-unique-jobs', '~> 6.0' gem 'sidekiq-bulk', '~>0.2.0' -gem 'simple-navigation', '~> 4.0' +gem 'simple-navigation', '~> 4.1' gem 'simple_form', '~> 4.1' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'stoplight', '~> 2.1.3' gem 'strong_migrations', '~> 0.4' -gem 'tty-command', '~> 0.8', require: false +gem 'tty-command', '~> 0.9', require: false gem 'tty-prompt', '~> 0.19', require: false gem 'twitter-text', '~> 1.14' gem 'tzinfo-data', '~> 1.2019' gem 'webpacker', '~> 4.0' gem 'webpush' -gem 'json-ld', '~> 3.0' +gem 'json-ld', git: 'https://github.com/ruby-rdf/json-ld.git', ref: 'e742697a0906e74e8bb777ef98137bc3955d981d' gem 'json-ld-preloaded', '~> 3.0' gem 'rdf-normalize', '~> 0.3' @@ -108,14 +114,14 @@ group :production, :test do end group :test do - gem 'capybara', '~> 3.24' + gem 'capybara', '~> 3.29' gem 'climate_control', '~> 0.2' - gem 'faker', '~> 1.9' + gem 'faker', '~> 2.5' gem 'microformats', '~> 4.1' gem 'rails-controller-testing', '~> 1.0' gem 'rspec-sidekiq', '~> 3.0' - gem 'simplecov', '~> 0.16', require: false - gem 'webmock', '~> 3.6' + gem 'simplecov', '~> 0.17', require: false + gem 'webmock', '~> 3.7' gem 'parallel_tests', '~> 2.29' end @@ -128,9 +134,9 @@ group :development do gem 'letter_opener', '~> 1.7' gem 'letter_opener_web', '~> 1.3' gem 'memory_profiler' - gem 'rubocop', '~> 0.71', require: false - gem 'rubocop-rails', '~> 2.0', require: false - gem 'brakeman', '~> 4.5', require: false + gem 'rubocop', '~> 0.74', require: false + gem 'rubocop-rails', '~> 2.3', require: false + gem 'brakeman', '~> 4.6', require: false gem 'bundler-audit', '~> 0.6', require: false gem 'capistrano', '~> 3.11' @@ -148,3 +154,4 @@ group :production do end gem 'concurrent-ruby', require: false +gem 'connection_pool', require: false diff --git a/Gemfile.lock b/Gemfile.lock index bda915dfa..3c52f378f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,11 @@ +GIT + remote: https://github.com/ianheggie/health_check + revision: 0b799ead604f900ed50685e9b2d469cd2befba5b + ref: 0b799ead604f900ed50685e9b2d469cd2befba5b + specs: + health_check (4.0.0.pre) + rails (>= 4.0) + GIT remote: https://github.com/rtomayko/posix-spawn revision: 58465d2e213991f8afb13b984854a49fcdcc980c @@ -5,13 +13,34 @@ GIT specs: posix-spawn (0.3.13) +GIT + remote: https://github.com/ruby-rdf/json-ld.git + revision: e742697a0906e74e8bb777ef98137bc3955d981d + ref: e742697a0906e74e8bb777ef98137bc3955d981d + specs: + json-ld (3.0.2) + htmlentities (~> 4.3) + json-canonicalization (~> 0.1) + link_header (~> 0.0, >= 0.0.8) + multi_json (~> 1.13) + rack (>= 1.6, < 3.0) + rdf (~> 3.0, >= 3.0.8) + GIT remote: https://github.com/tmm1/http_parser.rb revision: 54b17ba8c7d8d20a16dfc65d1775241833219cf2 ref: 54b17ba8c7d8d20a16dfc65d1775241833219cf2 + submodules: true specs: http_parser.rb (0.6.1) +GIT + remote: https://github.com/witgo/nilsimsa + revision: fd184883048b922b176939f851338d0a4971a532 + ref: fd184883048b922b176939f851338d0a4971a532 + specs: + nilsimsa (1.1.2) + GEM remote: https://rubygems.org/ specs: @@ -38,9 +67,9 @@ GEM erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) - active_model_serializers (0.10.9) - actionpack (>= 4.1, < 6) - activemodel (>= 4.1, < 6) + active_model_serializers (0.10.10) + actionpack (>= 4.1, < 6.1) + activemodel (>= 4.1, < 6.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) active_record_query_trace (1.6.2) @@ -62,9 +91,9 @@ GEM i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) - addressable (2.6.0) - public_suffix (>= 2.0.2, < 4.0) - airbrussh (1.3.0) + addressable (2.7.0) + public_suffix (>= 2.0.2, < 5.0) + airbrussh (1.3.4) sshkit (>= 1.6.1, != 1.7.0) annotate (2.7.5) activerecord (>= 3.2, < 7.0) @@ -76,17 +105,17 @@ GEM av (0.9.0) cocaine (~> 0.5.3) aws-eventstream (1.0.3) - aws-partitions (1.175.0) - aws-sdk-core (3.55.0) + aws-partitions (1.207.0) + aws-sdk-core (3.65.1) aws-eventstream (~> 1.0, >= 1.0.2) aws-partitions (~> 1.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.21.0) - aws-sdk-core (~> 3, >= 3.53.0) + aws-sdk-kms (1.24.0) + aws-sdk-core (~> 3, >= 3.61.1) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.42.0) - aws-sdk-core (~> 3, >= 3.53.0) + aws-sdk-s3 (1.48.0) + aws-sdk-core (~> 3, >= 3.61.1) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) aws-sigv4 (1.1.0) @@ -101,19 +130,19 @@ GEM debug_inspector (>= 0.0.1) blurhash (0.1.3) ffi (~> 1.10.0) - bootsnap (1.4.4) + bootsnap (1.4.5) msgpack (~> 1.0) - brakeman (4.5.1) - browser (2.5.3) + brakeman (4.6.1) + browser (2.6.1) builder (3.2.3) - bullet (6.0.0) + bullet (6.0.2) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundler-audit (0.6.1) bundler (>= 1.2.0, < 3) thor (~> 0.18) byebug (11.0.0) - capistrano (3.11.0) + capistrano (3.11.2) airbrussh (>= 1.0.0) i18n rake (>= 10.0.0) @@ -129,7 +158,7 @@ GEM sshkit (~> 1.3) capistrano-yarn (2.0.2) capistrano (~> 3.0) - capybara (3.24.0) + capybara (3.29.0) addressable mini_mime (>= 0.1.3) nokogiri (~> 1.8) @@ -140,7 +169,7 @@ GEM case_transform (0.2) activesupport charlock_holmes (0.7.6) - chewy (5.0.0) + chewy (5.1.0) activesupport (>= 4.0) elasticsearch (>= 2.0.0) elasticsearch-dsl @@ -156,64 +185,67 @@ GEM crack (0.4.3) safe_yaml (~> 1.0.0) crass (1.0.4) - css_parser (1.6.0) + css_parser (1.7.0) addressable debug_inspector (0.0.3) - derailed_benchmarks (1.3.5) + derailed_benchmarks (1.4.0) benchmark-ips (~> 2) get_process_mem (~> 0) heapy (~> 0) memory_profiler (~> 0) rack (>= 1) rake (> 10, < 13) + ruby-statistics (>= 2.1) thor (~> 0.19) - devise (4.6.2) + devise (4.7.1) bcrypt (~> 3.0) orm_adapter (~> 0.1) - railties (>= 4.1.0, < 6.0) + railties (>= 4.1.0) responders warden (~> 1.2.3) - devise-two-factor (3.0.3) - activesupport (< 5.3) + devise-two-factor (3.1.0) + activesupport (< 6.1) attr_encrypted (>= 1.3, < 4, != 2) devise (~> 4.0) - railties (< 5.3) + railties (< 6.1) rotp (~> 2.0) devise_pam_authenticatable2 (9.2.0) devise (>= 4.0.0) rpam2 (~> 4.0) diff-lcs (1.3) - docile (1.3.0) + discard (1.1.0) + activerecord (>= 4.2, < 7) + docile (1.3.2) domain_name (0.5.20180417) unf (>= 0.0.5, < 1.0.0) - doorkeeper (5.1.0) + doorkeeper (5.2.1) railties (>= 5) - dotenv (2.7.2) - dotenv-rails (2.7.2) - dotenv (= 2.7.2) + dotenv (2.7.5) + dotenv-rails (2.7.5) + dotenv (= 2.7.5) railties (>= 3.2, < 6.1) - elasticsearch (6.0.2) - elasticsearch-api (= 6.0.2) - elasticsearch-transport (= 6.0.2) - elasticsearch-api (6.0.2) + elasticsearch (7.3.0) + elasticsearch-api (= 7.3.0) + elasticsearch-transport (= 7.3.0) + elasticsearch-api (7.3.0) multi_json - elasticsearch-dsl (0.1.5) - elasticsearch-transport (6.0.2) + elasticsearch-dsl (0.1.8) + elasticsearch-transport (7.3.0) faraday multi_json encryptor (3.0.0) - equatable (0.5.0) + equatable (0.6.1) erubi (1.8.0) et-orbi (1.1.6) tzinfo excon (0.62.0) fabrication (2.20.2) - faker (1.9.3) - i18n (>= 0.7) - faraday (0.15.0) + faker (2.5.0) + i18n (~> 1.6.0) + faraday (0.15.4) multipart-post (>= 1.2, < 3) fast_blank (1.0.0) - fastimage (2.1.5) + fastimage (2.1.7) ffi (1.10.0) fog-core (2.1.0) builder @@ -234,7 +266,8 @@ GEM fuubar (2.4.1) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) - get_process_mem (0.2.3) + get_process_mem (0.2.4) + ffi (~> 1.0) globalid (0.4.2) activesupport (>= 4.2.0) goldfinger (2.1.0) @@ -253,7 +286,7 @@ GEM railties (>= 4.0.1) hamster (3.0.0) concurrent-ruby (~> 1.0) - hashdiff (0.4.0) + hashdiff (1.0.0) hashie (3.6.0) heapy (0.1.4) highline (2.0.1) @@ -269,7 +302,7 @@ GEM domain_name (~> 0.5) http-form_data (2.1.1) http_accept_language (2.1.1) - httplog (1.3.1) + httplog (1.3.2) rack (>= 1.0) rainbow (>= 2.0.0) i18n (1.6.0) @@ -287,17 +320,15 @@ GEM idn-ruby (0.1.0) ipaddress (0.8.3) iso-639 (0.2.8) - jaro_winkler (1.5.2) + jaro_winkler (1.5.3) jmespath (1.4.0) - json (2.1.0) - json-ld (3.0.2) - multi_json (~> 1.12) - rdf (>= 2.2.8, < 4.0) - json-ld-preloaded (3.0.2) + json (2.2.0) + json-canonicalization (0.1.0) + json-ld-preloaded (3.0.4) json-ld (~> 3.0) multi_json (~> 1.12) rdf (~> 3.0) - jsonapi-renderer (0.2.0) + jsonapi-renderer (0.2.2) jwt (2.1.0) kaminari (1.1.1) activesupport (>= 4.1.0) @@ -336,37 +367,37 @@ GEM mimemagic (~> 0.3.2) mario-redis-lock (1.2.1) redis (>= 3.0.5) - memory_profiler (0.9.13) + memory_profiler (0.9.14) method_source (0.9.2) microformats (4.1.0) json (~> 2.1) nokogiri (~> 1.8, >= 1.8.3) - mime-types (3.2.2) + mime-types (3.3) mime-types-data (~> 3.2015) - mime-types-data (3.2018.0812) + mime-types-data (3.2019.0904) mimemagic (0.3.3) - mini_mime (1.0.1) + mini_mime (1.0.2) mini_portile2 (2.4.0) - minitest (5.11.3) - msgpack (1.2.10) + minitest (5.12.0) + msgpack (1.3.1) multi_json (1.13.1) - multipart-post (2.0.0) + multipart-post (2.1.1) necromancer (0.5.0) net-ldap (0.16.1) - net-scp (1.2.1) - net-ssh (>= 2.6.5) - net-ssh (5.0.2) - nio4r (2.3.1) - nokogiri (1.10.3) + net-scp (2.0.0) + net-ssh (>= 2.6.5, < 6.0.0) + net-ssh (5.2.0) + nio4r (2.5.1) + nokogiri (1.10.4) mini_portile2 (~> 2.4.0) - nokogumbo (2.0.0) + nokogumbo (2.0.1) nokogiri (~> 1.8, >= 1.8.4) nsa (0.2.7) activesupport (>= 4.2, < 6) concurrent-ruby (~> 1.0, >= 1.0.2) sidekiq (>= 3.5) statsd-ruby (~> 1.4, >= 1.4.0) - oj (3.7.12) + oj (3.9.1) omniauth (1.9.0) hashie (>= 3.4.6, < 3.7.0) rack (>= 1.6.2, < 3) @@ -393,23 +424,24 @@ GEM av (~> 0.9.0) paperclip (>= 2.5.2) parallel (1.17.0) - parallel_tests (2.29.0) + parallel_tests (2.29.2) parallel - parser (2.6.3.0) + parser (2.6.4.0) ast (~> 2.4.0) - pastel (0.7.2) - equatable (~> 0.5.0) - tty-color (~> 0.4.0) + parslet (1.8.2) + pastel (0.7.3) + equatable (~> 0.6) + tty-color (~> 0.5) pg (1.1.4) - pghero (2.2.1) - activerecord - pkg-config (1.3.7) + pghero (2.3.0) + activerecord (>= 5) + pkg-config (1.3.9) premailer (1.11.1) addressable css_parser (>= 1.6.0) htmlentities (>= 4.0.0) - premailer-rails (1.10.2) - actionmailer (>= 3, < 6) + premailer-rails (1.10.3) + actionmailer (>= 3) premailer (~> 1.7, >= 1.7.9) private_address_check (0.5.0) pry (0.12.2) @@ -420,13 +452,14 @@ GEM pry (~> 0.10) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (3.1.0) - puma (3.12.1) - pundit (2.0.1) + public_suffix (4.0.1) + puma (4.2.0) + nio4r (~> 2.0) + pundit (2.1.0) activesupport (>= 3.0.0) raabro (1.1.6) rack (2.0.7) - rack-attack (6.0.0) + rack-attack (6.1.0) rack (>= 1.0, < 3) rack-cors (1.0.3) rack-protection (2.0.5) @@ -455,7 +488,7 @@ GEM rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.0.4) + rails-html-sanitizer (1.2.0) loofah (~> 2.2, >= 2.2.2) rails-i18n (5.1.3) i18n (>= 0.7, < 2) @@ -469,13 +502,13 @@ GEM rake (>= 0.8.7) thor (>= 0.19.0, < 2.0) rainbow (3.0.0) - rake (12.3.2) - rdf (3.0.9) + rake (12.3.3) + rdf (3.0.12) hamster (~> 3.0) link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.3.3) rdf (>= 2.2, < 4.0) - redis (4.1.2) + redis (4.1.3) redis-actionpack (5.0.2) actionpack (>= 4.0, < 6) redis-rack (>= 1, < 3) @@ -494,12 +527,12 @@ GEM redis-store (>= 1.2, < 2) redis-store (1.5.0) redis (>= 2.2, < 5) - regexp_parser (1.5.1) + regexp_parser (1.6.0) request_store (1.4.1) rack (>= 1.4) - responders (2.4.1) - actionpack (>= 4.2.0, < 6.0) - railties (>= 4.2.0, < 6.0) + responders (3.0.0) + actionpack (>= 5.0) + railties (>= 5.0) rotp (2.1.2) rpam2 (4.0.2) rqrcode (0.10.1) @@ -524,23 +557,24 @@ GEM rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) rspec-support (3.8.0) - rubocop (0.71.0) + rubocop (0.74.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) parser (>= 2.6) rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 1.7) - rubocop-rails (2.0.1) + rubocop-rails (2.3.2) rack (>= 1.1) - rubocop (>= 0.70.0) + rubocop (>= 0.72.0) ruby-progressbar (1.10.1) ruby-saml (1.9.0) nokogiri (>= 1.5.10) + ruby-statistics (2.1.1) rufus-scheduler (3.5.2) fugit (~> 1.1, >= 1.1.5) safe_yaml (1.0.5) - sanitize (5.0.0) + sanitize (5.1.0) crass (~> 1.0.2) nokogiri (>= 1.8.0) nokogumbo (~> 2.0) @@ -560,12 +594,12 @@ GEM concurrent-ruby (~> 1.0, >= 1.0.5) sidekiq (>= 4.0, < 7.0) thor (~> 0) - simple-navigation (4.0.5) + simple-navigation (4.1.0) activesupport (>= 2.3.2) simple_form (4.1.0) actionpack (>= 5.0) activemodel (>= 5.0) - simplecov (0.16.1) + simplecov (0.17.1) docile (~> 1.1) json (>= 1.8, < 3) simplecov-html (~> 0.10.0) @@ -577,7 +611,7 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) - sshkit (1.17.0) + sshkit (1.20.0) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) stackprof (0.2.12) @@ -585,7 +619,7 @@ GEM stoplight (2.1.3) streamio-ffmpeg (3.0.2) multi_json (~> 1.8) - strong_migrations (0.4.0) + strong_migrations (0.4.1) activerecord (>= 5) temple (0.8.1) terminal-table (1.8.0) @@ -595,8 +629,8 @@ GEM thor (0.20.3) thread_safe (0.3.6) tilt (2.0.9) - tty-color (0.4.3) - tty-command (0.8.2) + tty-color (0.5.0) + tty-command (0.9.0) pastel (~> 0.7.0) tty-cursor (0.7.0) tty-prompt (0.19.0) @@ -612,7 +646,7 @@ GEM unf (~> 0.1.0) tzinfo (1.2.5) thread_safe (~> 0.1) - tzinfo-data (1.2019.1) + tzinfo-data (1.2019.3) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext @@ -621,7 +655,7 @@ GEM uniform_notifier (1.12.1) warden (1.2.8) rack (>= 2.0.6) - webmock (3.6.0) + webmock (3.7.6) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -645,14 +679,14 @@ PLATFORMS DEPENDENCIES active_model_serializers (~> 0.10) active_record_query_trace (~> 1.6) - addressable (~> 2.6) + addressable (~> 2.7) annotate (~> 2.7) - aws-sdk-s3 (~> 1.42) + aws-sdk-s3 (~> 1.48) better_errors (~> 2.5) binding_of_caller (~> 0.7) blurhash (~> 0.1) bootsnap (~> 1.4) - brakeman (~> 4.5) + brakeman (~> 4.6) browser bullet (~> 6.0) bundler-audit (~> 0.6) @@ -660,20 +694,22 @@ DEPENDENCIES capistrano-rails (~> 1.4) capistrano-rbenv (~> 2.1) capistrano-yarn (~> 2.0) - capybara (~> 3.24) + capybara (~> 3.29) charlock_holmes (~> 0.7.6) - chewy (~> 5.0) + chewy (~> 5.1) cld3 (~> 3.2.4) climate_control (~> 0.2) concurrent-ruby + connection_pool derailed_benchmarks - devise (~> 4.6) - devise-two-factor (~> 3.0) + devise (~> 4.7) + devise-two-factor (~> 3.1) devise_pam_authenticatable2 (~> 9.2) - doorkeeper (~> 5.1) + discard (~> 1.1) + doorkeeper (~> 5.2) dotenv-rails (~> 2.7) fabrication (~> 2.20) - faker (~> 1.9) + faker (~> 2.5) fast_blank (~> 1.0) fastimage fog-core (<= 2.1.0) @@ -681,6 +717,7 @@ DEPENDENCIES fuubar (~> 2.4) goldfinger (~> 2.1) hamlit-rails (~> 0.2) + health_check! hiredis (~> 0.6) htmlentities (~> 4.3) http (~> 3.3) @@ -690,7 +727,7 @@ DEPENDENCIES i18n-tasks (~> 0.9) idn-ruby iso-639 - json-ld (~> 3.0) + json-ld! json-ld-preloaded (~> 3.0) kaminari (~> 1.1) letter_opener (~> 1.7) @@ -701,11 +738,12 @@ DEPENDENCIES mario-redis-lock (~> 1.2) memory_profiler microformats (~> 4.1) - mime-types (~> 3.2) + mime-types (~> 3.3) net-ldap (~> 0.10) + nilsimsa! nokogiri (~> 1.10) nsa (~> 0.2) - oj (~> 3.7) + oj (~> 3.9) omniauth (~> 1.9) omniauth-cas (~> 1.1) omniauth-saml (~> 1.10) @@ -713,18 +751,20 @@ DEPENDENCIES ox (~> 2.11) paperclip (~> 6.0) paperclip-av-transcoder (~> 0.6) + parallel (~> 1.17) parallel_tests (~> 2.29) + parslet pg (~> 1.1) - pghero (~> 2.2) + pghero (~> 2.3) pkg-config (~> 1.3) posix-spawn! premailer-rails private_address_check (~> 0.5) pry-byebug (~> 3.7) pry-rails (~> 0.3) - puma (~> 3.12) - pundit (~> 2.0) - rack-attack (~> 6.0) + puma (~> 4.2) + pundit (~> 2.1) + rack-attack (~> 6.1) rack-cors (~> 1.0) rails (~> 5.2.3) rails-controller-testing (~> 1.0) @@ -737,32 +777,33 @@ DEPENDENCIES rqrcode (~> 0.10) rspec-rails (~> 3.8) rspec-sidekiq (~> 3.0) - rubocop (~> 0.71) - rubocop-rails (~> 2.0) - sanitize (~> 5.0) + rubocop (~> 0.74) + rubocop-rails (~> 2.3) + ruby-progressbar (~> 1.10) + sanitize (~> 5.1) sidekiq (~> 5.2) sidekiq-bulk (~> 0.2.0) sidekiq-scheduler (~> 3.0) sidekiq-unique-jobs (~> 6.0) - simple-navigation (~> 4.0) + simple-navigation (~> 4.1) simple_form (~> 4.1) - simplecov (~> 0.16) + simplecov (~> 0.17) sprockets-rails (~> 3.2) stackprof stoplight (~> 2.1.3) streamio-ffmpeg (~> 3.0) strong_migrations (~> 0.4) thor (~> 0.20) - tty-command (~> 0.8) + tty-command (~> 0.9) tty-prompt (~> 0.19) twitter-text (~> 1.14) tzinfo-data (~> 1.2019) - webmock (~> 3.6) + webmock (~> 3.7) webpacker (~> 4.0) webpush RUBY VERSION - ruby 2.6.1p33 + ruby 2.6.5p114 BUNDLED WITH 1.17.3 diff --git a/README.md b/README.md index 2d18a4ee2..d50c1b3bc 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Private posts, locked accounts, phrase filtering, muting, blocking and all sorts **OAuth2 and a straightforward REST API** -Mastodon acts as an OAuth2 provider so 3rd party apps can use the REST and Streaming APIs, resulting in a rich app ecosystem with a lot of choice! +Mastodon acts as an OAuth2 provider so 3rd party apps can use the REST and Streaming APIs, resulting in a rich app ecosystem with a lot of choices! ## Deployment diff --git a/app.json b/app.json index 09adaac2c..211f17d81 100644 --- a/app.json +++ b/app.json @@ -13,15 +13,6 @@ "description": "The domain that your Mastodon instance will run on (this can be appname.herokuapp.com or a custom domain)", "required": true }, - "LOCAL_HTTPS": { - "description": "Will your domain support HTTPS? (Automatic for herokuapp, requires manual configuration for custom domains)", - "value": "false", - "required": true - }, - "PAPERCLIP_SECRET": { - "description": "The secret key for storing media files", - "generator": "secret" - }, "SECRET_KEY_BASE": { "description": "The secret key base", "generator": "secret" diff --git a/app/chewy/accounts_index.rb b/app/chewy/accounts_index.rb new file mode 100644 index 000000000..b814e009e --- /dev/null +++ b/app/chewy/accounts_index.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class AccountsIndex < Chewy::Index + settings index: { refresh_interval: '5m' }, analysis: { + analyzer: { + content: { + tokenizer: 'whitespace', + filter: %w(lowercase asciifolding cjk_width), + }, + + edge_ngram: { + tokenizer: 'edge_ngram', + filter: %w(lowercase asciifolding cjk_width), + }, + }, + + tokenizer: { + edge_ngram: { + type: 'edge_ngram', + min_gram: 1, + max_gram: 15, + }, + }, + } + + define_type ::Account.searchable.includes(:account_stat), delete_if: ->(account) { account.destroyed? || !account.searchable? } do + root date_detection: false do + field :id, type: 'long' + + field :display_name, type: 'text', analyzer: 'content' do + field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content' + end + + field :acct, type: 'text', analyzer: 'content', value: ->(account) { [account.username, account.domain].compact.join('@') } do + field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content' + end + + field :following_count, type: 'long', value: ->(account) { account.following.local.count } + field :followers_count, type: 'long', value: ->(account) { account.followers.local.count } + field :last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at } + end + end +end diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb index f5983a5a5..f5735421c 100644 --- a/app/chewy/statuses_index.rb +++ b/app/chewy/statuses_index.rb @@ -31,19 +31,19 @@ class StatusesIndex < Chewy::Index }, } - define_type ::Status.unscoped.without_reblogs.includes(:media_attachments) do + define_type ::Status.unscoped.kept.without_reblogs.includes(:media_attachments), delete_if: ->(status) { status.searchable_by.empty? } do crutch :mentions do |collection| - data = ::Mention.where(status_id: collection.map(&:id)).pluck(:status_id, :account_id) + data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id) data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } end crutch :favourites do |collection| - data = ::Favourite.where(status_id: collection.map(&:id)).pluck(:status_id, :account_id) + data = ::Favourite.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id) data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } end crutch :reblogs do |collection| - data = ::Status.where(reblog_of_id: collection.map(&:id)).pluck(:reblog_of_id, :account_id) + data = ::Status.where(reblog_of_id: collection.map(&:id)).where(account: Account.local).pluck(:reblog_of_id, :account_id) data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } end @@ -51,7 +51,7 @@ class StatusesIndex < Chewy::Index field :id, type: 'long' field :account_id, type: 'long' - field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).concat(status.preloadable_poll ? status_preloadable_poll.options : []).join("\n\n") } do + field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).concat(status.preloadable_poll ? status.preloadable_poll.options : []).join("\n\n") } do field :stemmed, type: 'text', analyzer: 'content' end diff --git a/app/chewy/tags_index.rb b/app/chewy/tags_index.rb new file mode 100644 index 000000000..300fc128f --- /dev/null +++ b/app/chewy/tags_index.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class TagsIndex < Chewy::Index + settings index: { refresh_interval: '15m' }, analysis: { + analyzer: { + content: { + tokenizer: 'keyword', + filter: %w(lowercase asciifolding cjk_width), + }, + + edge_ngram: { + tokenizer: 'edge_ngram', + filter: %w(lowercase asciifolding cjk_width), + }, + }, + + tokenizer: { + edge_ngram: { + type: 'edge_ngram', + min_gram: 2, + max_gram: 15, + }, + }, + } + + define_type ::Tag.listable, delete_if: ->(tag) { tag.destroyed? || !tag.listable? } do + root date_detection: false do + field :name, type: 'text', analyzer: 'content' do + field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content' + end + + field :reviewed, type: 'boolean', value: ->(tag) { tag.reviewed? } + field :usage, type: 'long', value: ->(tag) { tag.history.reduce(0) { |total, day| total + day[:accounts].to_i } } + field :last_status_at, type: 'date', value: ->(tag) { tag.last_status_at || tag.created_at } + end + end +end diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 9f608a851..abd1ec0cb 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -3,20 +3,46 @@ class AboutController < ApplicationController layout 'public' - before_action :set_instance_presenter, only: [:show, :more, :terms] + 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] - skip_before_action :check_user_permissions, only: [:more, :terms] + skip_before_action :require_functional!, only: [:more, :terms] - def show - @hide_navbar = true + def show; end + + def more + flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor] + + toc_generator = TOCGenerator.new(@instance_presenter.site_extended_description) + + @contents = toc_generator.html + @table_of_contents = toc_generator.toc + @blocks = DomainBlock.with_user_facing_limitations.by_severity if display_blocks? end - def more; end - def terms; end + helper_method :display_blocks? + helper_method :display_blocks_rationale? + helper_method :public_fetch_mode? + helper_method :new_user + private + def require_open_federation! + not_found if whitelist_mode? + end + + def display_blocks? + Setting.show_domain_blocks == 'all' || (Setting.show_domain_blocks == 'users' && user_signed_in?) + end + + def display_blocks_rationale? + Setting.show_domain_blocks_rationale == 'all' || (Setting.show_domain_blocks_rationale == 'users' && user_signed_in?) + end + def new_user User.new.tap do |user| user.build_account @@ -24,9 +50,15 @@ class AboutController < ApplicationController end end - helper_method :new_user - def set_instance_presenter @instance_presenter = InstancePresenter.new end + + def set_body_classes + @hide_navbar = true + end + + def set_expires_in + expires_in 0, public: true + end end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 73a4b1859..0a8015a56 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -4,17 +4,22 @@ class AccountsController < ApplicationController PAGE_SIZE = 20 include AccountControllerConcern + include SignatureAuthentication before_action :set_cache_headers + before_action :set_body_classes + + skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format) } + skip_before_action :require_functional! def show respond_to do |format| format.html do - mark_cacheable! unless user_signed_in? + expires_in 0, public: true unless user_signed_in? - @body_classes = 'with-modals' @pinned_statuses = [] @endorsed_accounts = @account.endorsed_accounts.to_a.sample(4) + @featured_hashtags = @account.featured_tags.order(statuses_count: :desc) if current_account && @account.blocking?(current_account) @statuses = [] @@ -24,6 +29,7 @@ class AccountsController < ApplicationController @pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses? @statuses = filtered_status_page(params) @statuses = cache_collection(@statuses, Status) + @rss_url = rss_url unless @statuses.empty? @older_url = older_url if @statuses.last.id > filtered_statuses.last.id @@ -31,30 +37,27 @@ class AccountsController < ApplicationController end end - format.atom do - mark_cacheable! - - @entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id]) - render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? })) - end - format.rss do - mark_cacheable! + expires_in 1.minute, public: true - @statuses = cache_collection(default_statuses.without_reblogs.without_replies.limit(PAGE_SIZE), Status) - render xml: RSS::AccountSerializer.render(@account, @statuses) + @statuses = filtered_statuses.without_reblogs.without_replies.limit(PAGE_SIZE) + @statuses = cache_collection(@statuses, Status) + render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag]) end format.json do - render_cached_json(['activitypub', 'actor', @account], content_type: 'application/activity+json') do - ActiveModelSerializers::SerializableResource.new(@account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter) - end + 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 end end end private + def set_body_classes + @body_classes = 'with-modals' + end + def show_pinned_statuses? [replies_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none? end @@ -97,6 +100,14 @@ class AccountsController < ApplicationController params[:username] end + def rss_url + if tag_requested? + short_account_tag_url(@account, params[:tag], format: 'rss') + else + short_account_url(@account, format: 'rss') + end + end + def older_url pagination_url(max_id: @statuses.last.id) end @@ -118,15 +129,15 @@ class AccountsController < ApplicationController end def media_requested? - request.path.ends_with?('/media') + request.path.ends_with?('/media') && !tag_requested? end def replies_requested? - request.path.ends_with?('/with_replies') + request.path.ends_with?('/with_replies') && !tag_requested? end def tag_requested? - request.path.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize) + request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize) end def filtered_status_page(params) @@ -136,4 +147,12 @@ class AccountsController < ApplicationController filtered_statuses.paginate_by_max_id(PAGE_SIZE, params[:max_id], params[:since_id]).to_a end 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 new file mode 100644 index 000000000..0c2591e97 --- /dev/null +++ b/app/controllers/activitypub/base_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ActivityPub::BaseController < Api::BaseController + skip_before_action :require_authenticated_user! + + private + + def set_cache_headers + response.headers['Vary'] = 'Signature' if authorized_fetch_mode? + end +end diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb index 012c3c538..910fefb1c 100644 --- a/app/controllers/activitypub/collections_controller.rb +++ b/app/controllers/activitypub/collections_controller.rb @@ -1,30 +1,21 @@ # frozen_string_literal: true -class ActivityPub::CollectionsController < Api::BaseController +class ActivityPub::CollectionsController < ActivityPub::BaseController include SignatureVerification + include AccountOwnedConcern - before_action :set_account + before_action :require_signature!, if: :authorized_fetch_mode? before_action :set_size before_action :set_statuses before_action :set_cache_headers def show - render_cached_json(['activitypub', 'collection', @account, params[:id]], content_type: 'application/activity+json') do - ActiveModelSerializers::SerializableResource.new( - collection_presenter, - serializer: ActivityPub::CollectionSerializer, - adapter: ActivityPub::Adapter, - skip_activities: true - ) - end + 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 end private - def set_account - @account = Account.find_local!(params[:account_username]) - end - def set_statuses @statuses = scope_for_collection @statuses = cache_collection(@statuses, Status) @@ -42,9 +33,9 @@ class ActivityPub::CollectionsController < Api::BaseController def scope_for_collection case params[:id] when 'featured' - @account.statuses.permitted_for(@account, signed_request_account).tap do |scope| - scope.merge!(@account.pinned_statuses) - end + return Status.none if @account.blocking?(signed_request_account) + + @account.pinned_statuses else raise ActiveRecord::RecordNotFound end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index a0b7532c2..bcfc1e6d4 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -1,40 +1,44 @@ # frozen_string_literal: true -class ActivityPub::InboxesController < Api::BaseController +class ActivityPub::InboxesController < ActivityPub::BaseController include SignatureVerification include JsonLdHelper + include AccountOwnedConcern - before_action :set_account + before_action :skip_unknown_actor_delete + before_action :require_signature! def create - if unknown_deleted_account? - head 202 - elsif signed_request_account - upgrade_account - process_payload - head 202 - else - render plain: signature_verification_failure_reason, status: 401 - end + upgrade_account + process_payload + head 202 end private + def skip_unknown_actor_delete + head 202 if unknown_deleted_account? + end + def unknown_deleted_account? json = Oj.load(body, mode: :strict) - json['type'] == 'Delete' && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists? + json.is_a?(Hash) && json['type'] == 'Delete' && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists? rescue Oj::ParseError false end - def set_account - @account = Account.find_local!(params[:account_username]) if params[:account_username] + def account_required? + params[:account_username].present? end def body return @body if defined?(@body) - @body = request.body.read.force_encoding('UTF-8') + + @body = request.body.read + @body.force_encoding('UTF-8') if @body.present? + request.body.rewind if request.body.respond_to?(:rewind) + @body end @@ -44,7 +48,6 @@ class ActivityPub::InboxesController < Api::BaseController ResolveAccountWorker.perform_async(signed_request_account.acct) end - Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed? DeliveryFailureTracker.track_inverse_success!(signed_request_account) end diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index 5147afbf7..891756b7e 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -1,26 +1,22 @@ # frozen_string_literal: true -class ActivityPub::OutboxesController < Api::BaseController +class ActivityPub::OutboxesController < ActivityPub::BaseController LIMIT = 20 include SignatureVerification + include AccountOwnedConcern - before_action :set_account + before_action :require_signature!, if: :authorized_fetch_mode? before_action :set_statuses before_action :set_cache_headers def show - expires_in 1.minute, public: true unless page_requested? - + expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end private - def set_account - @account = Account.find_local!(params[:account_username]) - end - def outbox_presenter if page_requested? ActivityPub::CollectionPresenter.new( diff --git a/app/controllers/activitypub/replies_controller.rb b/app/controllers/activitypub/replies_controller.rb new file mode 100644 index 000000000..c62061555 --- /dev/null +++ b/app/controllers/activitypub/replies_controller.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +class ActivityPub::RepliesController < ActivityPub::BaseController + include SignatureAuthentication + include Authorization + include AccountOwnedConcern + + DESCENDANTS_LIMIT = 60 + + before_action :require_signature!, if: :authorized_fetch_mode? + before_action :set_status + before_action :set_cache_headers + before_action :set_replies + + def index + expires_in 0, public: public_fetch_mode? + render json: replies_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', skip_activities: true + end + + private + + def set_status + @status = @account.statuses.find(params[:status_id]) + authorize @status, :show? + rescue Mastodon::NotPermittedError + raise ActiveRecord::RecordNotFound + end + + def set_replies + @replies = page_params[:only_other_accounts] ? Status.where.not(account_id: @account.id) : @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 + + def replies_collection_presenter + page = ActivityPub::CollectionPresenter.new( + id: account_status_replies_url(@account, @status, page_params), + type: :unordered, + part_of: account_status_replies_url(@account, @status), + next: next_page, + items: @replies.map { |status| status.local ? status : status.uri } + ) + + return page if page_requested? + + ActivityPub::CollectionPresenter.new( + id: account_status_replies_url(@account, @status), + type: :unordered, + first: page + ) + end + + def page_requested? + params[:page] == 'true' + end + + def next_page + only_other_accounts = !(@replies&.last&.account_id == @account.id && @replies.size == DESCENDANTS_LIMIT) + account_status_replies_url( + @account, + @status, + page: true, + min_id: only_other_accounts && !page_params[:only_other_accounts] ? nil : @replies&.last&.id, + only_other_accounts: only_other_accounts + ) + end + + def page_params + params_slice(:only_other_accounts, :min_id).merge(page: true) + end +end diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb index a2cea461e..ea56fa0ac 100644 --- a/app/controllers/admin/account_actions_controller.rb +++ b/app/controllers/admin/account_actions_controller.rb @@ -5,7 +5,7 @@ module Admin before_action :set_account def new - @account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true) + @account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true, include_statuses: true) @warning_presets = AccountWarningPreset.all end @@ -30,7 +30,7 @@ module Admin end def resource_params - params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification) + params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses) end end end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 0c7760d77..68b6352f8 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -2,8 +2,8 @@ module Admin class AccountsController < BaseController - before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject] - before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload] + before_action :set_account, only: [:show, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject] + before_action :require_remote_account!, only: [:redownload] before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject] def index @@ -19,18 +19,6 @@ module Admin @warnings = @account.targeted_account_warnings.latest.custom end - def subscribe - authorize @account, :subscribe? - Pubsubhubbub::SubscribeWorker.perform_async(@account.id) - redirect_to admin_account_path(@account.id) - end - - def unsubscribe - authorize @account, :unsubscribe? - Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id) - redirect_to admin_account_path(@account.id) - end - def memorialize authorize @account, :memorialize? @account.memorialize! @@ -53,7 +41,7 @@ module Admin def reject authorize @account.user, :reject? - SuspendAccountService.new.call(@account, including_user: true, destroy: true, skip_distribution: true) + SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false) redirect_to admin_pending_accounts_path end diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index f77699166..2af90f051 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -2,19 +2,20 @@ module Admin class CustomEmojisController < BaseController - before_action :set_custom_emoji, except: [:index, :new, :create] - before_action :set_filter_params - include ObfuscateFilename + obfuscate_filename [:custom_emoji, :image] def index authorize :custom_emoji, :index? + @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page]) + @form = Form::CustomEmojiBatch.new end def new authorize :custom_emoji, :create? + @custom_emoji = CustomEmoji.new end @@ -31,69 +32,17 @@ module Admin end end - def update - authorize @custom_emoji, :update? - - if @custom_emoji.update(resource_params) - log_action :update, @custom_emoji - flash[:notice] = I18n.t('admin.custom_emojis.updated_msg') - else - flash[:alert] = I18n.t('admin.custom_emojis.update_failed_msg') - end - redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params) - end - - def destroy - authorize @custom_emoji, :destroy? - @custom_emoji.destroy! - log_action :destroy, @custom_emoji - flash[:notice] = I18n.t('admin.custom_emojis.destroyed_msg') - redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params) - end - - def copy - authorize @custom_emoji, :copy? - - emoji = CustomEmoji.find_or_initialize_by(domain: nil, - shortcode: @custom_emoji.shortcode) - emoji.image = @custom_emoji.image - - if emoji.save - log_action :create, emoji - flash[:notice] = I18n.t('admin.custom_emojis.copied_msg') - else - flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg') - end - - redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params) - end - - def enable - authorize @custom_emoji, :enable? - @custom_emoji.update!(disabled: false) - log_action :enable, @custom_emoji - flash[:notice] = I18n.t('admin.custom_emojis.enabled_msg') - redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params) - end - - def disable - authorize @custom_emoji, :disable? - @custom_emoji.update!(disabled: true) - log_action :disable, @custom_emoji - flash[:notice] = I18n.t('admin.custom_emojis.disabled_msg') - redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params) + def batch + @form = Form::CustomEmojiBatch.new(form_custom_emoji_batch_params.merge(current_account: current_account, action: action_from_button)) + @form.save + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.accounts.no_account_selected') + ensure + redirect_to admin_custom_emojis_path(filter_params) end private - def set_custom_emoji - @custom_emoji = CustomEmoji.find(params[:id]) - end - - def set_filter_params - @filter_params = filter_params.to_hash.symbolize_keys - end - def resource_params params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker) end @@ -103,12 +52,29 @@ module Admin end def filter_params - params.permit( - :local, - :remote, - :by_domain, - :shortcode - ) + params.slice(:local, :remote, :by_domain, :shortcode, :page).permit(:local, :remote, :by_domain, :shortcode, :page) + end + + def action_from_button + if params[:update] + 'update' + elsif params[:list] + 'list' + elsif params[:unlist] + 'unlist' + elsif params[:enable] + 'enable' + elsif params[:disable] + 'disable' + elsif params[:copy] + 'copy' + elsif params[:delete] + 'delete' + end + end + + def form_custom_emoji_batch_params + params.require(:form_custom_emoji_batch).permit(:action, :category_id, :category_name, custom_emoji_ids: []) end end end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index f23ed1508..7c2951acb 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -5,6 +5,7 @@ module Admin class DashboardController < BaseController def index @users_count = User.count + @pending_users_count = User.pending.count @registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0 @logins_week = Redis.current.pfcount("activity:logins:#{current_week}") @interactions_week = Redis.current.get("activity:interactions:#{current_week}") || 0 @@ -19,7 +20,7 @@ module Admin @redis_version = redis_info['redis_version'] @reports_count = Report.unresolved.count @queue_backlog = Sidekiq::Stats.new.enqueued - @recent_users = User.confirmed.recent.includes(:account).limit(4) + @recent_users = User.confirmed.recent.includes(:account).limit(8) @database_size = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size'] @redis_size = redis_info['used_memory'] @ldap_enabled = ENV['LDAP_ENABLED'] == 'true' @@ -27,9 +28,14 @@ module Admin @saml_enabled = ENV['SAML_ENABLED'] == 'true' @pam_enabled = ENV['PAM_ENABLED'] == 'true' @hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true' - @trending_hashtags = TrendingTags.get(7) + @trending_hashtags = TrendingTags.get(10, filtered: false) + @pending_tags_count = Tag.pending_review.count + @authorized_fetch = authorized_fetch_mode? + @whitelist_enabled = whitelist_mode? @profile_directory = Setting.profile_directory @timeline_preview = Setting.timeline_preview + @spam_check_enabled = Setting.spam_check_enabled + @trends_enabled = Setting.trends end private @@ -39,7 +45,13 @@ module Admin end def redis_info - @redis_info ||= Redis.current.info + @redis_info ||= begin + if Redis.current.is_a?(Redis::Namespace) + Redis.current.redis.info + else + Redis.current.info + end + end end end end diff --git a/app/controllers/admin/domain_allows_controller.rb b/app/controllers/admin/domain_allows_controller.rb new file mode 100644 index 000000000..31be1978b --- /dev/null +++ b/app/controllers/admin/domain_allows_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class Admin::DomainAllowsController < Admin::BaseController + before_action :set_domain_allow, only: [:destroy] + + def new + authorize :domain_allow, :create? + + @domain_allow = DomainAllow.new(domain: params[:_domain]) + end + + def create + authorize :domain_allow, :create? + + @domain_allow = DomainAllow.new(resource_params) + + if @domain_allow.save + log_action :create, @domain_allow + redirect_to admin_instances_path, notice: I18n.t('admin.domain_allows.created_msg') + else + render :new + end + end + + def destroy + authorize @domain_allow, :destroy? + UnallowDomainService.new.call(@domain_allow) + redirect_to admin_instances_path, notice: I18n.t('admin.domain_allows.destroyed_msg') + end + + private + + def set_domain_allow + @domain_allow = DomainAllow.find(params[:id]) + end + + def resource_params + params.require(:domain_allow).permit(:domain) + end +end diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 7129656da..74a36b79c 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -2,13 +2,17 @@ module Admin class DomainBlocksController < BaseController - before_action :set_domain_block, only: [:show, :destroy] + before_action :set_domain_block, only: [:show, :destroy, :edit, :update] def new authorize :domain_block, :create? @domain_block = DomainBlock.new(domain: params[:_domain]) end + def edit + authorize :domain_block, :create? + end + def create authorize :domain_block, :create? @@ -35,6 +39,22 @@ module Admin end end + def update + authorize :domain_block, :create? + + @domain_block.update(update_params) + + severity_changed = @domain_block.severity_changed? + + if @domain_block.save + DomainBlockWorker.perform_async(@domain_block.id, severity_changed) + log_action :create, @domain_block + redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg') + else + render :edit + end + end + def show authorize @domain_block, :show? end @@ -52,8 +72,12 @@ module Admin @domain_block = DomainBlock.find(params[:id]) end + def update_params + params.require(:domain_block).permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment) + end + def resource_params - params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports) + params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment) end end end diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index 7888e844f..b47b18f8e 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -2,6 +2,10 @@ module Admin class InstancesController < BaseController + before_action :set_domain_block, only: :show + before_action :set_domain_allow, only: :show + before_action :set_instance, only: :show + def index authorize :instance, :index? @@ -11,20 +15,40 @@ module Admin def show authorize :instance, :show? - @instance = Instance.new(Account.by_domain_accounts.find_by(domain: params[:id]) || DomainBlock.find_by!(domain: params[:id])) @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?(Account.select(:shared_inbox_url).where(domain: params[:id]).first&.shared_inbox_url) @media_storage = MediaAttachment.where(account: Account.where(domain: params[:id])).sum(:file_file_size) - @domain_block = DomainBlock.rule_for(params[:id]) + @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 + + if resource + @instance = Instance.new(resource) + else + not_found + end + end + def filtered_instances - InstanceFilter.new(filter_params).results + InstanceFilter.new(whitelist_mode? ? { allowed: true } : filter_params).results end def paginated_instances diff --git a/app/controllers/admin/relays_controller.rb b/app/controllers/admin/relays_controller.rb index 1b02d3c36..6fbb6e063 100644 --- a/app/controllers/admin/relays_controller.rb +++ b/app/controllers/admin/relays_controller.rb @@ -3,6 +3,7 @@ module Admin class RelaysController < BaseController before_action :set_relay, except: [:index, :new, :create] + before_action :require_signatures_enabled!, only: [:new, :create, :enable] def index authorize :relay, :update? @@ -11,7 +12,7 @@ module Admin def new authorize :relay, :update? - @relay = Relay.new(inbox_url: Relay::PRESET_RELAY) + @relay = Relay.new end def create @@ -54,5 +55,9 @@ module Admin def resource_params params.require(:relay).permit(:inbox_url) end + + def require_signatures_enabled! + redirect_to admin_relays_path, alert: I18n.t('admin.relays.signatures_not_enabled') if authorized_fetch_mode? + end end end diff --git a/app/controllers/admin/report_notes_controller.rb b/app/controllers/admin/report_notes_controller.rb index bcb3f2026..b816c5b5d 100644 --- a/app/controllers/admin/report_notes_controller.rb +++ b/app/controllers/admin/report_notes_controller.rb @@ -5,10 +5,10 @@ module Admin before_action :set_report_note, only: [:destroy] def create - authorize ReportNote, :create? + authorize :report_note, :create? @report_note = current_account.report_notes.new(resource_params) - @report = @report_note.report + @report = @report_note.report if @report_note.save if params[:create_and_resolve] @@ -26,9 +26,8 @@ module Admin redirect_to admin_report_path(@report), notice: I18n.t('admin.report_notes.created_msg') else - @report_notes = @report.notes.latest - @report_history = @report.history - @form = Form::StatusBatch.new + @report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at) + @form = Form::StatusBatch.new render template: 'admin/reports/show' end diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index e9f4f2cfa..65341bbfb 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -2,43 +2,102 @@ module Admin class TagsController < BaseController - before_action :set_tags, only: :index - before_action :set_tag, except: :index - before_action :set_filter_params + before_action :set_tag, except: [:index, :batch, :approve_all, :reject_all] + before_action :set_usage_by_domain, except: [:index, :batch, :approve_all, :reject_all] + before_action :set_counters, except: [:index, :batch, :approve_all, :reject_all] def index authorize :tag, :index? + + @tags = filtered_tags.page(params[:page]) + @form = Form::TagBatch.new end - def hide - authorize @tag, :hide? - @tag.account_tag_stat.update!(hidden: true) - redirect_to admin_tags_path(@filter_params) + def batch + @form = Form::TagBatch.new(form_tag_batch_params.merge(current_account: current_account, action: action_from_button)) + @form.save + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.accounts.no_account_selected') + ensure + redirect_to admin_tags_path(filter_params) end - def unhide - authorize @tag, :unhide? - @tag.account_tag_stat.update!(hidden: false) - redirect_to admin_tags_path(@filter_params) + def approve_all + Form::TagBatch.new(current_account: current_account, tag_ids: Tag.pending_review.pluck(:id), action: 'approve').save + redirect_to admin_tags_path(filter_params) + end + + def reject_all + Form::TagBatch.new(current_account: current_account, tag_ids: Tag.pending_review.pluck(:id), action: 'reject').save + redirect_to admin_tags_path(filter_params) + end + + def show + authorize @tag, :show? + end + + def update + authorize @tag, :update? + + if @tag.update(tag_params.merge(reviewed_at: Time.now.utc)) + redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg') + else + render :show + end end private - def set_tags - @tags = Tag.discoverable - @tags.merge!(Tag.hidden) if filter_params[:hidden] - end - def set_tag @tag = Tag.find(params[:id]) end - def set_filter_params - @filter_params = filter_params.to_hash.symbolize_keys + def set_usage_by_domain + @usage_by_domain = @tag.statuses + .with_public_visibility + .excluding_silenced_accounts + .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') + end + + def set_counters + @accounts_today = @tag.history.first[:accounts] + @accounts_week = Redis.current.pfcount(*current_week_days.map { |day| "activity:tags:#{@tag.id}:#{day}:accounts" }) + end + + def filtered_tags + TagFilter.new(filter_params).results end def filter_params - params.permit(:hidden) + params.slice(:directory, :reviewed, :unreviewed, :pending_review, :page, :popular, :active, :name).permit(:directory, :reviewed, :unreviewed, :pending_review, :page, :popular, :active, :name) + end + + def tag_params + params.require(:tag).permit(:name, :trendable, :usable, :listable) + end + + def current_week_days + now = Time.now.utc.beginning_of_day.to_date + + (Date.commercial(now.cwyear, now.cweek)..now).map do |date| + date.to_time(:utc).beginning_of_day.to_i + end + end + + def form_tag_batch_params + params.require(:form_tag_batch).permit(:action, tag_ids: []) + end + + def action_from_button + if params[:approve] + 'approve' + elsif params[:reject] + 'reject' + end end end end diff --git a/app/controllers/admin/two_factor_authentications_controller.rb b/app/controllers/admin/two_factor_authentications_controller.rb index 2577a4b17..0652c3a7a 100644 --- a/app/controllers/admin/two_factor_authentications_controller.rb +++ b/app/controllers/admin/two_factor_authentications_controller.rb @@ -8,6 +8,7 @@ module Admin authorize @user, :disable_2fa? @user.disable_two_factor! log_action :disable_2fa, @user + UserMailer.two_factor_disabled(@user).deliver_later! redirect_to admin_accounts_path end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index eca558f42..33df75b37 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -7,12 +7,15 @@ class Api::BaseController < ApplicationController include RateLimitHeaders skip_before_action :store_current_location - skip_before_action :check_user_permissions + skip_before_action :require_functional! + before_action :require_authenticated_user!, if: :disallow_unauthenticated_api_access? before_action :set_cache_headers protect_from_forgery with: :null_session + skip_around_action :set_locale + rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| render json: { error: e.to_s }, status: 422 end @@ -33,6 +36,14 @@ class Api::BaseController < ApplicationController render json: { error: 'This action is not allowed' }, status: 403 end + rescue_from Mastodon::RaceConditionError do + render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 + end + + rescue_from ActionController::ParameterMissing do |e| + render json: { error: e.to_s }, status: 400 + end + def doorkeeper_unauthorized_render_options(error: nil) { json: { error: (error.try(:description) || 'Not authorized') } } end @@ -69,6 +80,10 @@ class Api::BaseController < ApplicationController nil end + def require_authenticated_user! + render json: { error: 'This API requires an authenticated user' }, status: 401 unless current_user + end + def require_user! if !current_user render json: { error: 'This method requires an authenticated user' }, status: 422 @@ -94,4 +109,8 @@ class Api::BaseController < ApplicationController def set_cache_headers response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' end + + def disallow_unauthenticated_api_access? + authorized_fetch_mode? + end end diff --git a/app/controllers/api/proofs_controller.rb b/app/controllers/api/proofs_controller.rb index a84ad2014..a98599eee 100644 --- a/app/controllers/api/proofs_controller.rb +++ b/app/controllers/api/proofs_controller.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true class Api::ProofsController < Api::BaseController - before_action :set_account + include AccountOwnedConcern + before_action :set_provider - before_action :check_account_approval - before_action :check_account_suspension def index render json: @account, serializer: @provider.serializer_class @@ -16,15 +15,7 @@ class Api::ProofsController < Api::BaseController @provider = ProofProvider.find(params[:provider]) || raise(ActiveRecord::RecordNotFound) end - def set_account - @account = Account.find_local!(params[:username]) - end - - def check_account_approval - not_found if @account.user_pending? - end - - def check_account_suspension - gone if @account.suspended? + def username_param + params[:username] end end diff --git a/app/controllers/api/push_controller.rb b/app/controllers/api/push_controller.rb deleted file mode 100644 index e04d19125..000000000 --- a/app/controllers/api/push_controller.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -class Api::PushController < Api::BaseController - include SignatureVerification - - def update - response, status = process_push_request - render plain: response, status: status - end - - private - - def process_push_request - case hub_mode - when 'subscribe' - Pubsubhubbub::SubscribeService.new.call(account_from_topic, hub_callback, hub_secret, hub_lease_seconds, verified_domain) - when 'unsubscribe' - Pubsubhubbub::UnsubscribeService.new.call(account_from_topic, hub_callback) - else - ["Unknown mode: #{hub_mode}", 422] - end - end - - def hub_mode - params['hub.mode'] - end - - def hub_topic - params['hub.topic'] - end - - def hub_callback - params['hub.callback'] - end - - def hub_lease_seconds - params['hub.lease_seconds'] - end - - def hub_secret - params['hub.secret'] - end - - def account_from_topic - if hub_topic.present? && local_domain? && account_feed_path? - Account.find_local(hub_topic_params[:username]) - end - end - - def hub_topic_params - @_hub_topic_params ||= Rails.application.routes.recognize_path(hub_topic_uri.path) - end - - def hub_topic_uri - @_hub_topic_uri ||= Addressable::URI.parse(hub_topic).normalize - end - - def local_domain? - TagManager.instance.web_domain?(hub_topic_domain) - end - - def verified_domain - return signed_request_account.domain if signed_request_account - end - - def hub_topic_domain - hub_topic_uri.host + (hub_topic_uri.port ? ":#{hub_topic_uri.port}" : '') - end - - def account_feed_path? - hub_topic_params[:controller] == 'accounts' && hub_topic_params[:action] == 'show' && hub_topic_params[:format] == 'atom' - end -end diff --git a/app/controllers/api/salmon_controller.rb b/app/controllers/api/salmon_controller.rb deleted file mode 100644 index ac5f3268d..000000000 --- a/app/controllers/api/salmon_controller.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -class Api::SalmonController < Api::BaseController - include SignatureVerification - - before_action :set_account - respond_to :txt - - def update - if verify_payload? - process_salmon - head 202 - elsif payload.present? - render plain: signature_verification_failure_reason, status: 401 - else - head 400 - end - end - - private - - def set_account - @account = Account.find(params[:id]) - end - - def payload - @_payload ||= request.body.read - end - - def verify_payload? - payload.present? && VerifySalmonService.new.call(payload) - end - - def process_salmon - SalmonWorker.perform_async(@account.id, payload.force_encoding('UTF-8')) - end -end diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb deleted file mode 100644 index 89007f3d6..000000000 --- a/app/controllers/api/subscriptions_controller.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -class Api::SubscriptionsController < Api::BaseController - before_action :set_account - respond_to :txt - - def show - if subscription.valid?(params['hub.topic']) - @account.update(subscription_expires_at: future_expires) - render plain: encoded_challenge, status: 200 - else - head 404 - end - end - - def update - if subscription.verify(body, request.headers['HTTP_X_HUB_SIGNATURE']) - ProcessingWorker.perform_async(@account.id, body.force_encoding('UTF-8')) - end - - head 200 - end - - private - - def subscription - @_subscription ||= @account.subscription( - api_subscription_url(@account.id) - ) - end - - def body - @_body ||= request.body.read - end - - def encoded_challenge - HTMLEntities.new.encode(params['hub.challenge']) - end - - def future_expires - Time.now.utc + lease_seconds_or_default - end - - def lease_seconds_or_default - (params['hub.lease_seconds'] || 1.day).to_i.seconds - end - - def set_account - @account = Account.find(params[:id]) - end -end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 13cb4caf1..333db9618 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -29,14 +29,13 @@ class Api::V1::Accounts::StatusesController < Api::BaseController def account_statuses statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses - statuses = statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id)) statuses.merge!(only_media_scope) if truthy_param?(:only_media) statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies) statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs) statuses.merge!(hashtag_scope) if params[:tagged].present? - statuses + statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id)) end def permitted_account_statuses @@ -58,6 +57,8 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def pinned_scope + return Status.none if @account.blocking?(current_account) + @account.pinned_statuses end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index b0c62778e..d68d2715f 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -12,6 +12,8 @@ class Api::V1::AccountsController < Api::BaseController before_action :check_account_suspension, only: [:show] before_action :check_enabled_registrations, only: [:create] + skip_before_action :require_authenticated_user!, only: :create + respond_to :json def show @@ -31,7 +33,7 @@ class Api::V1::AccountsController < Api::BaseController def follow FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs)) - options = @account.locked? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } } + options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } } render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) end @@ -76,7 +78,7 @@ class Api::V1::AccountsController < Api::BaseController end def account_params - params.permit(:username, :email, :password, :agreement, :locale) + params.permit(:username, :email, :password, :agreement, :locale, :reason) end def check_enabled_registrations diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb index c306180ca..c35ea5ab2 100644 --- a/app/controllers/api/v1/admin/accounts_controller.rb +++ b/app/controllers/api/v1/admin/accounts_controller.rb @@ -58,7 +58,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController def reject authorize @account.user, :reject? - SuspendAccountService.new.call(@account, including_user: true, destroy: true, skip_distribution: true) + SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false) render json: @account, serializer: REST::Admin::AccountSerializer end diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb index e9f7a7291..97177547a 100644 --- a/app/controllers/api/v1/apps_controller.rb +++ b/app/controllers/api/v1/apps_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::V1::AppsController < Api::BaseController + skip_before_action :require_authenticated_user! + def create @app = Doorkeeper::Application.create!(application_options) render json: @app, serializer: REST::ApplicationSerializer diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb index 1bb19a09d..4e6d5d7c6 100644 --- a/app/controllers/api/v1/custom_emojis_controller.rb +++ b/app/controllers/api/v1/custom_emojis_controller.rb @@ -6,8 +6,7 @@ class Api::V1::CustomEmojisController < Api::BaseController skip_before_action :set_cache_headers def index - render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do - ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer) - end + expires_in 3.minutes, public: true + render_with_cache(each_serializer: REST::CustomEmojiSerializer) { CustomEmoji.listed.includes(:category) } end end diff --git a/app/controllers/api/v1/directories_controller.rb b/app/controllers/api/v1/directories_controller.rb new file mode 100644 index 000000000..c91543e3a --- /dev/null +++ b/app/controllers/api/v1/directories_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::V1::DirectoriesController < Api::BaseController + before_action :require_enabled! + before_action :set_accounts + + def show + render json: @accounts, each_serializer: REST::AccountSerializer + end + + private + + def require_enabled! + return not_found unless Setting.profile_directory + end + + def set_accounts + @accounts = accounts_scope.offset(params[:offset]).limit(limit_param(DEFAULT_ACCOUNTS_LIMIT)) + end + + def accounts_scope + Account.discoverable.tap do |scope| + scope.merge!(Account.local) if truthy_param?(:local) + scope.merge!(Account.by_recent_status) if params[:order].blank? || params[:order] == 'active' + scope.merge!(Account.order(id: :desc)) if params[:order] == 'new' + scope.merge!(Account.not_excluded_by_account(current_account)) if current_account + scope.merge!(Account.not_domain_blocked_by_account(current_account)) if current_account && !truthy_param?(:local) + end + end +end diff --git a/app/controllers/api/v1/featured_tags/suggestions_controller.rb b/app/controllers/api/v1/featured_tags/suggestions_controller.rb new file mode 100644 index 000000000..fb27ef88b --- /dev/null +++ b/app/controllers/api/v1/featured_tags/suggestions_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +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 + + respond_to :json + + def index + render json: @most_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) + end +end diff --git a/app/controllers/api/v1/featured_tags_controller.rb b/app/controllers/api/v1/featured_tags_controller.rb new file mode 100644 index 000000000..e4e836c97 --- /dev/null +++ b/app/controllers/api/v1/featured_tags_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class Api::V1::FeaturedTagsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index + before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index + + before_action :require_user! + before_action :set_featured_tags, only: :index + before_action :set_featured_tag, except: [:index, :create] + + def index + render json: @featured_tags, each_serializer: REST::FeaturedTagSerializer + end + + def create + @featured_tag = current_account.featured_tags.new(featured_tag_params) + @featured_tag.reset_data + @featured_tag.save! + render json: @featured_tag, serializer: REST::FeaturedTagSerializer + end + + def destroy + @featured_tag.destroy! + render_empty + end + + private + + def set_featured_tag + @featured_tag = current_account.featured_tags.find(params[:id]) + end + + def set_featured_tags + @featured_tags = current_account.featured_tags.order(statuses_count: :desc) + end + + def featured_tag_params + params.permit(:name) + end +end diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb index e6888154e..0ee6e531f 100644 --- a/app/controllers/api/v1/follow_requests_controller.rb +++ b/app/controllers/api/v1/follow_requests_controller.rb @@ -14,12 +14,12 @@ 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)) - render_empty + render json: account, serializer: REST::RelationshipSerializer, relationships: relationships end def reject RejectFollowService.new.call(account, current_account) - render_empty + render json: account, serializer: REST::RelationshipSerializer, relationships: relationships end private @@ -28,6 +28,10 @@ class Api::V1::FollowRequestsController < Api::BaseController Account.find(params[:id]) end + def relationships(**options) + AccountRelationshipsPresenter.new([params[:id]], current_user.account_id, options) + end + def load_accounts default_accounts.merge(paginated_follow_requests).to_a end diff --git a/app/controllers/api/v1/follows_controller.rb b/app/controllers/api/v1/follows_controller.rb deleted file mode 100644 index 5420c0533..000000000 --- a/app/controllers/api/v1/follows_controller.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::FollowsController < Api::BaseController - before_action -> { doorkeeper_authorize! :follow, :'write:follows' } - before_action :require_user! - - respond_to :json - - def create - raise ActiveRecord::RecordNotFound if follow_params[:uri].blank? - - @account = FollowService.new.call(current_user.account, target_uri).try(:target_account) - - if @account.nil? - username, domain = target_uri.split('@') - @account = Account.find_remote!(username, domain) - end - - render json: @account, serializer: REST::AccountSerializer - end - - private - - def target_uri - follow_params[:uri].strip.gsub(/\A@/, '') - end - - def follow_params - params.permit(:uri) - end -end diff --git a/app/controllers/api/v1/instances/activity_controller.rb b/app/controllers/api/v1/instances/activity_controller.rb index 09edfe365..b30e8464c 100644 --- a/app/controllers/api/v1/instances/activity_controller.rb +++ b/app/controllers/api/v1/instances/activity_controller.rb @@ -2,12 +2,15 @@ class Api::V1::Instances::ActivityController < Api::BaseController before_action :require_enabled_api! + skip_before_action :set_cache_headers + skip_before_action :require_authenticated_user!, unless: :whitelist_mode? respond_to :json def show - render_cached_json('api:v1:instances:activity:show', expires_in: 1.day) { activity } + expires_in 1.day, public: true + render_with_cache json: :activity, expires_in: 1.day end private @@ -32,6 +35,6 @@ class Api::V1::Instances::ActivityController < Api::BaseController end def require_enabled_api! - head 404 unless Setting.activity_api_enabled + head 404 unless Setting.activity_api_enabled && !whitelist_mode? end end diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb index a8891d126..cc00d8a6b 100644 --- a/app/controllers/api/v1/instances/peers_controller.rb +++ b/app/controllers/api/v1/instances/peers_controller.rb @@ -2,17 +2,20 @@ class Api::V1::Instances::PeersController < Api::BaseController before_action :require_enabled_api! + skip_before_action :set_cache_headers + skip_before_action :require_authenticated_user!, unless: :whitelist_mode? respond_to :json def index - render_cached_json('api:v1:instances:peers:index', expires_in: 1.day) { Account.remote.domains } + expires_in 1.day, public: true + render_with_cache(expires_in: 1.day) { Account.remote.domains } end private def require_enabled_api! - head 404 unless Setting.peers_api_enabled + head 404 unless Setting.peers_api_enabled && !whitelist_mode? end end diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb index 8c83a1801..c323b60b4 100644 --- a/app/controllers/api/v1/instances_controller.rb +++ b/app/controllers/api/v1/instances_controller.rb @@ -2,11 +2,12 @@ class Api::V1::InstancesController < Api::BaseController respond_to :json + skip_before_action :set_cache_headers + skip_before_action :require_authenticated_user!, unless: :whitelist_mode? def show - render_cached_json('api:v1:instances', expires_in: 5.minutes) do - ActiveModelSerializers::SerializableResource.new({}, serializer: REST::InstanceSerializer) - end + expires_in 3.minutes, public: true + render_with_cache json: {}, serializer: REST::InstanceSerializer, root: 'instance' end end diff --git a/app/controllers/api/v1/markers_controller.rb b/app/controllers/api/v1/markers_controller.rb new file mode 100644 index 000000000..28c2ec791 --- /dev/null +++ b/app/controllers/api/v1/markers_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class Api::V1::MarkersController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:index] + before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, except: [:index] + + before_action :require_user! + + def index + @markers = current_user.markers.where(timeline: Array(params[:timeline])).each_with_object({}) { |marker, h| h[marker.timeline] = marker } + render json: serialize_map(@markers) + end + + def create + Marker.transaction do + @markers = {} + + resource_params.each_pair do |timeline, timeline_params| + @markers[timeline] = current_user.markers.find_or_initialize_by(timeline: timeline) + @markers[timeline].update!(timeline_params) + end + end + + render json: serialize_map(@markers) + rescue ActiveRecord::StaleObjectError + render json: { error: 'Conflict during update, please try again' }, status: 409 + end + + private + + def serialize_map(map) + serialized = {} + + map.each_pair do |key, value| + serialized[key] = ActiveModelSerializers::SerializableResource.new(value, serializer: REST::MarkerSerializer).as_json + end + + Oj.dump(serialized) + end + + def resource_params + params.slice(*Marker::TIMELINES).permit(*Marker::TIMELINES.map { |timeline| { timeline.to_sym => [:last_read_id] } }) + end +end diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index e182a9c6c..1b0b4b05b 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -21,7 +21,7 @@ class Api::V1::ReportsController < Api::BaseController private def reported_status_ids - reported_account.statuses.find(status_ids).pluck(:id) + reported_account.statuses.with_discarded.find(status_ids).pluck(:id) end def status_ids diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb deleted file mode 100644 index 6131cbbb6..000000000 --- a/app/controllers/api/v1/search_controller.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::SearchController < Api::BaseController - include Authorization - - RESULTS_LIMIT = 20 - - before_action -> { doorkeeper_authorize! :read, :'read:search' } - before_action :require_user! - - respond_to :json - - def index - @search = Search.new(search_results) - render json: @search, serializer: REST::SearchSerializer - end - - private - - def search_results - SearchService.new.call( - params[:q], - current_account, - limit_param(RESULTS_LIMIT), - search_params.merge(resolve: truthy_param?(:resolve)) - ) - end - - def search_params - params.permit(:type, :offset, :min_id, :max_id, :account_id) - end -end diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb index ed4f55100..42381a37f 100644 --- a/app/controllers/api/v1/statuses/reblogs_controller.rb +++ b/app/controllers/api/v1/statuses/reblogs_controller.rb @@ -18,6 +18,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController @reblogs_map = { @status.id => false } authorize status_for_destroy, :unreblog? + status_for_destroy.discard RemovalWorker.perform_async(status_for_destroy.id) render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map) @@ -30,7 +31,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController end def status_for_destroy - current_user.account.statuses.where(reblog_of_id: params[:status_id]).first! + @status_for_destroy ||= current_user.account.statuses.where(reblog_of_id: params[:status_id]).first! end def reblog_params diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index b0e134554..bba3c0651 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -5,8 +5,8 @@ class Api::V1::StatusesController < Api::BaseController before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :destroy] before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :destroy] - before_action :require_user!, except: [:show, :context, :card] - before_action :set_status, only: [:show, :context, :card] + before_action :require_user!, except: [:show, :context] + before_action :set_status, only: [:show, :context] respond_to :json @@ -33,16 +33,6 @@ class Api::V1::StatusesController < Api::BaseController render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) end - def card - @card = @status.preview_cards.first - - if @card.nil? - render_empty - else - render json: @card, serializer: REST::PreviewCardSerializer - end - end - def create @status = PostStatusService.new.call(current_user.account, text: status_params[:status], @@ -63,7 +53,8 @@ class Api::V1::StatusesController < Api::BaseController @status = Status.where(account_id: current_user.account).find(params[:id]) authorize @status, :destroy? - RemovalWorker.perform_async(@status.id) + @status.discard + RemovalWorker.perform_async(@status.id, redraft: true) render json: @status, serializer: REST::StatusSerializer, source_requested: true end diff --git a/app/controllers/api/v1/streaming_controller.rb b/app/controllers/api/v1/streaming_controller.rb index 66b812e76..ebb17608c 100644 --- a/app/controllers/api/v1/streaming_controller.rb +++ b/app/controllers/api/v1/streaming_controller.rb @@ -5,11 +5,17 @@ class Api::V1::StreamingController < Api::BaseController def index if Rails.configuration.x.streaming_api_base_url != request.host - uri = URI.parse(request.url) - uri.host = URI.parse(Rails.configuration.x.streaming_api_base_url).host - redirect_to uri.to_s, status: 301 + redirect_to streaming_api_url, status: 301 else - raise ActiveRecord::RecordNotFound + not_found end end + + private + + def streaming_api_url + Addressable::URI.parse(request.url).tap do |uri| + uri.host = Addressable::URI.parse(Rails.configuration.x.streaming_api_base_url).host + end.to_s + end end diff --git a/app/controllers/api/v1/timelines/direct_controller.rb b/app/controllers/api/v1/timelines/direct_controller.rb deleted file mode 100644 index d8a76d153..000000000 --- a/app/controllers/api/v1/timelines/direct_controller.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::Timelines::DirectController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show] - before_action :require_user!, only: [:show] - after_action :insert_pagination_headers, unless: -> { @statuses.empty? } - - respond_to :json - - def show - @statuses = load_statuses - render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) - end - - private - - def load_statuses - cached_direct_statuses - end - - def cached_direct_statuses - cache_collection direct_statuses, Status - end - - def direct_statuses - direct_timeline_statuses - end - - def direct_timeline_statuses - # this query requires built in pagination. - Status.as_direct_timeline( - current_account, - limit_param(DEFAULT_STATUSES_LIMIT), - params[:max_id], - params[:since_id], - true # returns array of cache_ids object - ) - end - - def insert_pagination_headers - set_pagination_headers(next_path, prev_path) - end - - def pagination_params(core_params) - params.permit(:local, :limit).merge(core_params) - end - - def next_path - api_v1_timelines_direct_url pagination_params(max_id: pagination_max_id) - end - - def prev_path - api_v1_timelines_direct_url pagination_params(since_id: pagination_since_id) - end - - def pagination_max_id - @statuses.last.id - end - - def pagination_since_id - @statuses.first.id - end -end diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index fcd0757f1..ff5ede138 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -13,7 +13,7 @@ class Api::V1::Timelines::HomeController < Api::BaseController render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), - status: regeneration_in_progress? ? 206 : 200 + status: account_home_feed.regenerating? ? 206 : 200 end private @@ -62,8 +62,4 @@ class Api::V1::Timelines::HomeController < Api::BaseController def pagination_since_id @statuses.first.id end - - def regeneration_in_progress? - Redis.current.exists("account:#{current_account.id}:regeneration") - end end diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index aabe24324..ccc10f966 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Api::V1::Timelines::PublicController < Api::BaseController + before_action :require_user!, only: [:show], if: :require_auth? after_action :insert_pagination_headers, unless: -> { @statuses.empty? } respond_to :json @@ -12,6 +13,10 @@ class Api::V1::Timelines::PublicController < Api::BaseController private + def require_auth? + !Setting.timeline_preview + end + def load_statuses cached_public_statuses end diff --git a/app/controllers/api/v1/trends_controller.rb b/app/controllers/api/v1/trends_controller.rb new file mode 100644 index 000000000..bcea9857e --- /dev/null +++ b/app/controllers/api/v1/trends_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Api::V1::TrendsController < Api::BaseController + before_action :set_tags + + respond_to :json + + def index + render json: @tags, each_serializer: REST::TagSerializer + end + + private + + def set_tags + @tags = TrendingTags.get(limit_param(10)) + end +end diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb index 9aa6edc69..cbd9b551d 100644 --- a/app/controllers/api/v2/search_controller.rb +++ b/app/controllers/api/v2/search_controller.rb @@ -1,8 +1,32 @@ # frozen_string_literal: true -class Api::V2::SearchController < Api::V1::SearchController +class Api::V2::SearchController < Api::BaseController + include Authorization + + RESULTS_LIMIT = 20 + + before_action -> { doorkeeper_authorize! :read, :'read:search' } + before_action :require_user! + + respond_to :json + def index @search = Search.new(search_results) - render json: @search, serializer: REST::V2::SearchSerializer + render json: @search, serializer: REST::SearchSerializer + end + + private + + def search_results + SearchService.new.call( + params[:q], + current_account, + limit_param(RESULTS_LIMIT), + search_params.merge(resolve: truthy_param?(:resolve), exclude_unreviewed: truthy_param?(:exclude_unreviewed)) + ) + end + + def search_params + params.permit(:type, :offset, :min_id, :max_id, :account_id) end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6b8411402..bd3d13774 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,21 +10,29 @@ class ApplicationController < ActionController::Base include Localized include UserTrackingConcern include SessionTrackingConcern + include CacheConcern + include DomainControlHelper helper_method :current_account helper_method :current_session helper_method :current_theme helper_method :single_user_mode? helper_method :use_seamless_external_login? + helper_method :whitelist_mode? rescue_from ActionController::RoutingError, with: :not_found - rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActionController::InvalidAuthenticityToken, with: :unprocessable_entity rescue_from ActionController::UnknownFormat, with: :not_acceptable + rescue_from ActionController::ParameterMissing, with: :bad_request + 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 before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? - before_action :check_user_permissions, if: :user_signed_in? + before_action :require_functional!, if: :user_signed_in? + + skip_before_action :verify_authenticity_token, only: :raise_not_found def raise_not_found raise ActionController::RoutingError, "No route matches #{params[:unmatched_route]}" @@ -33,7 +41,15 @@ class ApplicationController < ActionController::Base private def https_enabled? - Rails.env.production? + Rails.env.production? && !request.path.start_with?('/health') + end + + def authorized_fetch_mode? + ENV['AUTHORIZED_FETCH'] == 'true' || Rails.configuration.x.whitelist_mode + end + + def public_fetch_mode? + !authorized_fetch_mode? end def store_current_location @@ -48,8 +64,8 @@ class ApplicationController < ActionController::Base forbidden unless current_user&.staff? end - def check_user_permissions - forbidden if current_user.disabled? || current_user.account.suspended? + def require_functional! + redirect_to edit_user_registration_path unless current_user.functional? end def after_sign_out_path_for(_resource_or_scope) @@ -82,8 +98,20 @@ class ApplicationController < ActionController::Base respond_with_error(406) end + def bad_request + respond_with_error(400) + end + + def internal_server_error + respond_with_error(500) + end + + def service_unavailable + respond_with_error(503) + end + def single_user_mode? - @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.exists? + @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists? end def use_seamless_external_login? @@ -107,51 +135,10 @@ class ApplicationController < ActionController::Base current_user.setting_theme end - 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) - 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.each_value do |item| - Rails.cache.write(item, item) - end - end - - raw.map { |item| cached_keys_with_value[item.id] || uncached[item.id] }.compact - end - def respond_with_error(code) respond_to do |format| format.any { head code } format.html { render "errors/#{code}", layout: 'error', status: code } end end - - def render_cached_json(cache_key, **options) - options[:expires_in] ||= 3.minutes - cache_public = options.key?(:public) ? options.delete(:public) : true - content_type = options.delete(:content_type) || 'application/json' - - data = Rails.cache.fetch(cache_key, { raw: true }.merge(options)) do - yield.to_json - end - - expires_in options[:expires_in], public: cache_public - render json: data, content_type: content_type - end - - def set_cache_headers - response.headers['Vary'] = 'Accept' - end - - def mark_cacheable! - expires_in 0, public: true - end end diff --git a/app/controllers/auth/challenges_controller.rb b/app/controllers/auth/challenges_controller.rb new file mode 100644 index 000000000..060944240 --- /dev/null +++ b/app/controllers/auth/challenges_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Auth::ChallengesController < ApplicationController + include ChallengableConcern + + layout 'auth' + + before_action :authenticate_user! + + skip_before_action :require_functional! + + def create + if challenge_passed? + session[:challenge_passed_at] = Time.now.utc + redirect_to challenge_params[:return_to] + else + @challenge = Form::Challenge.new(return_to: challenge_params[:return_to]) + flash.now[:alert] = I18n.t('challenge.invalid_password') + render_challenge + end + end +end diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb index c28c7471c..898525269 100644 --- a/app/controllers/auth/confirmations_controller.rb +++ b/app/controllers/auth/confirmations_controller.rb @@ -4,32 +4,36 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController layout 'auth' before_action :set_body_classes - before_action :set_user, only: [:finish_signup] + before_action :require_unconfirmed! - def finish_signup - return unless request.patch? && params[:user] + skip_before_action :require_functional! - if @user.update(user_params) - @user.skip_reconfirmation! - bypass_sign_in(@user) - redirect_to root_path, notice: I18n.t('devise.confirmations.send_instructions') - else - @show_errors = true - end + def new + super + + resource.email = current_user.unconfirmed_email || current_user.email if user_signed_in? end private - def set_user - @user = current_user + def require_unconfirmed! + redirect_to edit_user_registration_path if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank? end def set_body_classes @body_classes = 'lighter' end - def user_params - params.require(:user).permit(:email) + def after_resending_confirmation_instructions_path_for(_resource_name) + if user_signed_in? + if current_user.confirmed? && current_user.approved? + edit_user_registration_path + else + auth_setup_path + end + else + new_user_session_path + end end def after_confirmation_path_for(_resource_name, user) diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb index bbf63bed3..682c77016 100644 --- a/app/controllers/auth/omniauth_callbacks_controller.rb +++ b/app/controllers/auth/omniauth_callbacks_controller.rb @@ -27,7 +27,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController if resource.email_verified? root_path else - finish_signup_path + auth_setup_path(missing_email: '1') end end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index e3d079168..f35c7fad6 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -9,6 +9,9 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :set_sessions, only: [:edit, :update] before_action :set_instance_presenter, only: [:new, :create, :update] before_action :set_body_classes, only: [:new, :create, :edit, :update] + before_action :require_not_suspended!, only: [:update] + + skip_before_action :require_functional!, only: [:edit, :update] def new super(&:build_invite_request) @@ -44,7 +47,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController end def after_sign_up_path_for(_resource) - new_user_session_path + auth_setup_path end def after_sign_in_path_for(_resource) @@ -103,4 +106,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController def set_sessions @sessions = current_user.session_activations end + + def require_not_suspended! + forbidden if current_account.suspended? + end end diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb index fb8615c31..f48b17c79 100644 --- a/app/controllers/auth/sessions_controller.rb +++ b/app/controllers/auth/sessions_controller.rb @@ -6,8 +6,10 @@ class Auth::SessionsController < Devise::SessionsController layout 'auth' skip_before_action :require_no_authentication, only: [:create] - skip_before_action :check_user_permissions, only: [:destroy] + skip_before_action :require_functional! + prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create] + before_action :set_instance_presenter, only: [:new] before_action :set_body_classes @@ -29,6 +31,7 @@ class Auth::SessionsController < Devise::SessionsController def destroy tmp_stored_location = stored_location_for(:user) super + session.delete(:challenge_passed_at) flash.delete(:notice) store_location_for(:user, tmp_stored_location) if continue_after? end @@ -38,12 +41,10 @@ class Auth::SessionsController < Devise::SessionsController def find_user if session[:otp_user_id] User.find(session[:otp_user_id]) - elsif user_params[:email] - if use_seamless_external_login? && Devise.check_at_sign && user_params[:email].index('@').nil? - User.joins(:account).find_by(accounts: { username: user_params[:email] }) - else - User.find_for_authentication(email: user_params[:email]) - end + 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]) end end @@ -70,13 +71,13 @@ class Auth::SessionsController < Devise::SessionsController end def two_factor_enabled? - find_user.try(:otp_required_for_login?) + find_user&.otp_required_for_login? end def valid_otp_attempt?(user) user.validate_and_consume_otp!(user_params[:otp_attempt]) || user.invalidate_otp_backup_code!(user_params[:otp_attempt]) - rescue OpenSSL::Cipher::CipherError => _error + rescue OpenSSL::Cipher::CipherError false end @@ -85,7 +86,10 @@ class Auth::SessionsController < Devise::SessionsController if user_params[:otp_attempt].present? && session[:otp_user_id] authenticate_with_two_factor_via_otp(user) - elsif user&.valid_password?(user_params[:password]) + elsif user.present? && (user.encrypted_password.blank? || user.valid_password?(user_params[:password])) + # If encrypted_password is blank, we got the user from LDAP or PAM, + # so credentials are already valid + prompt_for_two_factor(user) end end @@ -103,6 +107,7 @@ class Auth::SessionsController < Devise::SessionsController def prompt_for_two_factor(user) session[:otp_user_id] = user.id + @body_classes = 'lighter' render :two_factor end diff --git a/app/controllers/auth/setup_controller.rb b/app/controllers/auth/setup_controller.rb new file mode 100644 index 000000000..46c5f2958 --- /dev/null +++ b/app/controllers/auth/setup_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class Auth::SetupController < ApplicationController + layout 'auth' + + before_action :authenticate_user! + before_action :require_unconfirmed_or_pending! + before_action :set_body_classes + before_action :set_user + + skip_before_action :require_functional! + + def show + flash.now[:notice] = begin + if @user.pending? + I18n.t('devise.registrations.signed_up_but_pending') + else + I18n.t('devise.registrations.signed_up_but_unconfirmed') + end + end + end + + def update + # This allows updating the e-mail without entering a password as is required + # on the account settings page; however, we only allow this for accounts + # that were not confirmed yet + + if @user.update(user_params) + redirect_to auth_setup_path, notice: I18n.t('devise.confirmations.send_instructions') + else + render :show + end + end + + helper_method :missing_email? + + private + + def require_unconfirmed_or_pending! + redirect_to root_path if current_user.confirmed? && current_user.approved? + end + + def set_user + @user = current_user + end + + def set_body_classes + @body_classes = 'lighter' + end + + def user_params + params.require(:user).permit(:email) + end + + def missing_email? + truthy_param?(:missing_email) + end +end diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb index 1c422096c..11eac0eb6 100644 --- a/app/controllers/concerns/account_controller_concern.rb +++ b/app/controllers/concerns/account_controller_concern.rb @@ -3,24 +3,19 @@ module AccountControllerConcern extend ActiveSupport::Concern + include AccountOwnedConcern + FOLLOW_PER_PAGE = 12 included do layout 'public' - before_action :set_account - before_action :check_account_approval - before_action :check_account_suspension before_action :set_instance_presenter - before_action :set_link_headers + before_action :set_link_headers, if: -> { request.format.nil? || request.format == :html } end private - def set_account - @account = Account.find_local!(username_param) - end - def set_instance_presenter @instance_presenter = InstancePresenter.new end @@ -29,27 +24,15 @@ module AccountControllerConcern response.headers['Link'] = LinkHeader.new( [ webfinger_account_link, - atom_account_url_link, actor_url_link, ] ) end - def username_param - params[:account_username] - end - def webfinger_account_link [ webfinger_account_url, - [%w(rel lrdd), %w(type application/xrd+xml)], - ] - end - - def atom_account_url_link - [ - account_url(@account, format: 'atom'), - [%w(rel alternate), %w(type application/atom+xml)], + [%w(rel lrdd), %w(type application/jrd+json)], ] end @@ -63,15 +46,4 @@ module AccountControllerConcern def webfinger_account_url webfinger_url(resource: @account.to_webfinger_s) end - - def check_account_approval - not_found if @account.user_pending? - end - - def check_account_suspension - if @account.suspended? - expires_in(3.minutes, public: true) - gone - end - end end diff --git a/app/controllers/concerns/account_owned_concern.rb b/app/controllers/concerns/account_owned_concern.rb new file mode 100644 index 000000000..460f71f65 --- /dev/null +++ b/app/controllers/concerns/account_owned_concern.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module AccountOwnedConcern + extend ActiveSupport::Concern + + included do + before_action :authenticate_user!, if: -> { whitelist_mode? && request.format != :json } + before_action :set_account, if: :account_required? + before_action :check_account_approval, if: :account_required? + before_action :check_account_suspension, if: :account_required? + end + + private + + def account_required? + true + end + + def set_account + @account = Account.find_local!(username_param) + end + + def username_param + params[:account_username] + end + + def check_account_approval + not_found if @account.local? && @account.user_pending? + end + + def check_account_suspension + expires_in(3.minutes, public: true) && gone if @account.suspended? + end +end diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb new file mode 100644 index 000000000..c7d25ae00 --- /dev/null +++ b/app/controllers/concerns/cache_concern.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module CacheConcern + extend ActiveSupport::Concern + + def render_with_cache(**options) + raise ArgumentError, 'only JSON render calls are supported' unless options.key?(:json) || block_given? + + key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields].nil? ? nil : options[:fields].join(',')].compact.join(':') + expires_in = options.delete(:expires_in) || 3.minutes + body = Rails.cache.read(key, raw: true) + + if body + render(options.except(:json, :serializer, :each_serializer, :adapter, :fields).merge(json: body)) + else + if block_given? + options[:json] = yield + elsif options[:json].is_a?(Symbol) + options[:json] = send(options[:json]) + end + + render(options) + Rails.cache.write(key, response.body, expires_in: expires_in, raw: true) + end + end + + def set_cache_headers + response.headers['Vary'] = public_fetch_mode? ? 'Accept' : 'Accept, Signature' + end + + 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) + 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.each_value do |item| + Rails.cache.write(item, item) + end + end + + raw.map { |item| cached_keys_with_value[item.id] || uncached[item.id] }.compact + end +end diff --git a/app/controllers/concerns/challengable_concern.rb b/app/controllers/concerns/challengable_concern.rb new file mode 100644 index 000000000..b29d90b3c --- /dev/null +++ b/app/controllers/concerns/challengable_concern.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# This concern is inspired by "sudo mode" on GitHub. It +# is a way to re-authenticate a user before allowing them +# to see or perform an action. +# +# Add `before_action :require_challenge!` to actions you +# want to protect. +# +# The user will be shown a page to enter the challenge (which +# is either the password, or just the username when no +# password exists). Upon passing, there is a grace period +# during which no challenge will be asked from the user. +# +# Accessing challenge-protected resources during the grace +# period will refresh the grace period. +module ChallengableConcern + extend ActiveSupport::Concern + + CHALLENGE_TIMEOUT = 1.hour.freeze + + def require_challenge! + return if skip_challenge? + + if challenge_passed_recently? + session[:challenge_passed_at] = Time.now.utc + return + end + + @challenge = Form::Challenge.new(return_to: request.url) + + 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 + end + else + render_challenge + end + end + + def render_challenge + @body_classes = 'lighter' + render template: 'auth/challenges/new', layout: 'auth' + end + + def challenge_passed? + current_user.valid_password?(challenge_params[:current_password]) + end + + def skip_challenge? + current_user.encrypted_password.blank? + end + + def challenge_passed_recently? + session[:challenge_passed_at].present? && session[:challenge_passed_at] >= CHALLENGE_TIMEOUT.ago + end + + def challenge_params + params.require(:form_challenge).permit(:current_password, :return_to) + end +end diff --git a/app/controllers/concerns/export_controller_concern.rb b/app/controllers/concerns/export_controller_concern.rb index e20b71a30..bfe990c82 100644 --- a/app/controllers/concerns/export_controller_concern.rb +++ b/app/controllers/concerns/export_controller_concern.rb @@ -5,7 +5,10 @@ module ExportControllerConcern included do before_action :authenticate_user! + before_action :require_not_suspended! before_action :load_export + + skip_before_action :require_functional! end private @@ -27,4 +30,8 @@ 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/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 90a57197c..ce353f1de 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -5,12 +5,35 @@ module SignatureVerification extend ActiveSupport::Concern + include DomainControlHelper + + def require_signature! + render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account + end + def signed_request? request.headers['Signature'].present? end def signature_verification_failure_reason - return @signature_verification_failure_reason if defined?(@signature_verification_failure_reason) + @signature_verification_failure_reason + end + + def signature_verification_failure_code + @signature_verification_failure_code || 401 + 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'] end def signed_request_account @@ -123,6 +146,13 @@ module SignatureVerification end def account_from_key_id(key_id) + domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id + + if domain_not_allowed?(domain) + @signature_verification_failure_code = 403 + return + end + if key_id.start_with?('acct:') stoplight_wrap_request { ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, '')) } elsif !ActivityPub::TagManager.instance.local_uri?(key_id) @@ -137,7 +167,7 @@ module SignatureVerification .with_fallback { nil } .with_threshold(1) .with_cool_off_time(5.minutes.seconds) - .with_error_handler { |error, handle| error.is_a?(HTTP::Error) ? handle.call(error) : raise(error) } + .with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) } .run end diff --git a/app/controllers/concerns/status_controller_concern.rb b/app/controllers/concerns/status_controller_concern.rb new file mode 100644 index 000000000..62a7cf508 --- /dev/null +++ b/app/controllers/concerns/status_controller_concern.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module StatusControllerConcern + extend ActiveSupport::Concern + + ANCESTORS_LIMIT = 40 + DESCENDANTS_LIMIT = 60 + DESCENDANTS_DEPTH_LIMIT = 20 + + def create_descendant_thread(starting_depth, statuses) + depth = starting_depth + statuses.size + + if depth < DESCENDANTS_DEPTH_LIMIT + { + statuses: statuses, + starting_depth: starting_depth, + } + else + next_status = statuses.pop + + { + statuses: statuses, + starting_depth: starting_depth, + next_status: next_status, + } + end + end + + def set_ancestors + @ancestors = @status.reply? ? cache_collection(@status.ancestors(ANCESTORS_LIMIT, current_account), Status) : [] + @next_ancestor = @ancestors.size < ANCESTORS_LIMIT ? nil : @ancestors.shift + end + + def set_descendants + @max_descendant_thread_id = params[:max_descendant_thread_id]&.to_i + @since_descendant_thread_id = params[:since_descendant_thread_id]&.to_i + + descendants = cache_collection( + @status.descendants( + DESCENDANTS_LIMIT, + current_account, + @max_descendant_thread_id, + @since_descendant_thread_id, + DESCENDANTS_DEPTH_LIMIT + ), + Status + ) + + @descendant_threads = [] + + if descendants.present? + statuses = [descendants.first] + starting_depth = 0 + + descendants.drop(1).each_with_index do |descendant, index| + if descendants[index].id == descendant.in_reply_to_id + statuses << descendant + else + @descendant_threads << create_descendant_thread(starting_depth, statuses) + + # The thread is broken, assume it's a reply to the root status + starting_depth = 0 + + # ... unless we can find its ancestor in one of the already-processed threads + @descendant_threads.reverse_each do |descendant_thread| + statuses = descendant_thread[:statuses] + + index = statuses.find_index do |thread_status| + thread_status.id == descendant.in_reply_to_id + end + + if index.present? + starting_depth = descendant_thread[:starting_depth] + index + 1 + break + end + end + + statuses = [descendant] + end + end + + @descendant_threads << create_descendant_thread(starting_depth, statuses) + end + + @max_descendant_thread_id = @descendant_threads.pop[:statuses].first.id if descendants.size >= DESCENDANTS_LIMIT + end +end diff --git a/app/controllers/custom_css_controller.rb b/app/controllers/custom_css_controller.rb index 6e80feaf8..0a667a6a6 100644 --- a/app/controllers/custom_css_controller.rb +++ b/app/controllers/custom_css_controller.rb @@ -2,10 +2,12 @@ class CustomCssController < ApplicationController skip_before_action :store_current_location + skip_before_action :require_functional! before_action :set_cache_headers def show + expires_in 3.minutes, public: true render plain: Setting.custom_css || '', content_type: 'text/css' end end diff --git a/app/controllers/directories_controller.rb b/app/controllers/directories_controller.rb index 594907674..750c835dd 100644 --- a/app/controllers/directories_controller.rb +++ b/app/controllers/directories_controller.rb @@ -3,12 +3,14 @@ class DirectoriesController < ApplicationController layout 'public' - before_action :check_enabled + before_action :authenticate_user!, if: :whitelist_mode? + before_action :require_enabled! before_action :set_instance_presenter before_action :set_tag, only: :show - before_action :set_tags before_action :set_accounts + skip_before_action :require_functional! + def index render :index end @@ -19,21 +21,18 @@ class DirectoriesController < ApplicationController private - def check_enabled + def require_enabled! return not_found unless Setting.profile_directory end def set_tag - @tag = Tag.discoverable.find_by!(name: params[:id].downcase) - end - - def set_tags - @tags = Tag.discoverable.limit(30).reject { |tag| tag.cached_sample_accounts.empty? } + @tag = Tag.discoverable.find_normalized!(params[:id]) end def set_accounts - @accounts = Account.discoverable.by_recent_status.page(params[:page]).per(40).tap do |query| + @accounts = Account.local.discoverable.by_recent_status.page(params[:page]).per(20).tap do |query| query.merge!(Account.tagged_with(@tag.id)) if @tag + query.merge!(Account.not_excluded_by_account(current_account)) if current_account end end diff --git a/app/controllers/emojis_controller.rb b/app/controllers/emojis_controller.rb index 3feb08132..41f1e1c5c 100644 --- a/app/controllers/emojis_controller.rb +++ b/app/controllers/emojis_controller.rb @@ -7,9 +7,8 @@ class EmojisController < ApplicationController def show respond_to do |format| format.json do - render_cached_json(['activitypub', 'emoji', @emoji], content_type: 'application/activity+json') do - ActiveModelSerializers::SerializableResource.new(@emoji, serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter) - end + expires_in 3.minutes, public: true + render_with_cache json: @emoji, content_type: 'application/activity+json', serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter end end end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 415abe10c..705ff4122 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -2,13 +2,18 @@ class FollowerAccountsController < ApplicationController include AccountControllerConcern + include SignatureVerification + before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_cache_headers + skip_around_action :set_locale, if: -> { request.format == :json } + skip_before_action :require_functional! + def index respond_to do |format| format.html do - mark_cacheable! unless user_signed_in? + expires_in 0, public: true unless user_signed_in? next if @account.user_hides_network? @@ -17,9 +22,9 @@ class FollowerAccountsController < ApplicationController end format.json do - raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network? + raise Mastodon::NotPermittedError if page_requested? && @account.user_hides_network? - expires_in 3.minutes, public: true if params[:page].blank? + expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, @@ -35,12 +40,16 @@ class FollowerAccountsController < ApplicationController @follows ||= Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account) end + def page_requested? + params[:page].present? + end + def page_url(page) account_followers_url(@account, page: page) unless page.nil? end def collection_presenter - if params[:page].present? + if page_requested? ActivityPub::CollectionPresenter.new( id: account_followers_url(@account, page: params.fetch(:page, 1)), type: :ordered, diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 948725664..968de980d 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -2,13 +2,18 @@ class FollowingAccountsController < ApplicationController include AccountControllerConcern + include SignatureVerification + before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_cache_headers + skip_around_action :set_locale, if: -> { request.format == :json } + skip_before_action :require_functional! + def index respond_to do |format| format.html do - mark_cacheable! unless user_signed_in? + expires_in 0, public: true unless user_signed_in? next if @account.user_hides_network? @@ -17,9 +22,9 @@ class FollowingAccountsController < ApplicationController end format.json do - raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network? + raise Mastodon::NotPermittedError if page_requested? && @account.user_hides_network? - expires_in 3.minutes, public: true if params[:page].blank? + expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, @@ -35,12 +40,16 @@ class FollowingAccountsController < ApplicationController @follows ||= Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account) end + def page_requested? + params[:page].present? + end + def page_url(page) account_following_index_url(@account, page: page) unless page.nil? end def collection_presenter - if params[:page].present? + if page_requested? ActivityPub::CollectionPresenter.new( id: account_following_index_url(@account, page: params.fetch(:page, 1)), type: :ordered, diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 85622a7b5..7c8a18d17 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -3,7 +3,6 @@ class HomeController < ApplicationController before_action :authenticate_user! before_action :set_referrer_policy_header - before_action :set_initial_state_json def index @body_classes = 'app-body' @@ -21,7 +20,7 @@ class HomeController < ApplicationController when 'statuses' status = Status.find_by(id: matches[2]) - if status && (status.public_visibility? || status.unlisted_visibility?) + if status&.distributable? redirect_to(ActivityPub::TagManager.instance.url_for(status)) return end @@ -39,26 +38,11 @@ class HomeController < ApplicationController redirect_to(matches ? tag_path(CGI.unescape(matches[:tag])) : default_redirect_path) end - def set_initial_state_json - serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) - @initial_state_json = serializable_resource.to_json - end - - def initial_state_params - { - settings: Web::Setting.find_by(user: current_user)&.data || {}, - push_subscription: current_account.user.web_push_subscription(current_session), - current_account: current_account, - token: current_session.token, - admin: Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')), - } - end - def default_redirect_path - if request.path.start_with?('/web') + if request.path.start_with?('/web') || whitelist_mode? new_user_session_path elsif single_user_mode? - short_account_path(Account.local.without_suspended.first) + short_account_path(Account.local.without_suspended.where('id > 0').first) else about_path end diff --git a/app/controllers/instance_actors_controller.rb b/app/controllers/instance_actors_controller.rb new file mode 100644 index 000000000..6f02d6a35 --- /dev/null +++ b/app/controllers/instance_actors_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class InstanceActorsController < ApplicationController + include AccountControllerConcern + + skip_around_action :set_locale + + def show + expires_in 10.minutes, public: true + render json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, fields: restrict_fields_to + end + + private + + def set_account + @account = Account.find(-99) + end + + def restrict_fields_to + %i(id type preferred_username inbox public_key endpoints url manually_approves_followers) + end +end diff --git a/app/controllers/intents_controller.rb b/app/controllers/intents_controller.rb index 9f41cf48a..ca89fc7fe 100644 --- a/app/controllers/intents_controller.rb +++ b/app/controllers/intents_controller.rb @@ -2,6 +2,7 @@ class IntentsController < ApplicationController before_action :check_uri + rescue_from Addressable::URI::InvalidURIError, with: :handle_invalid_uri def show diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index de5280305..8d92147e2 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -43,7 +43,7 @@ class InvitesController < ApplicationController end def resource_params - params.require(:invite).permit(:max_uses, :expires_in, :autofollow) + params.require(:invite).permit(:max_uses, :expires_in, :autofollow, :comment) end def set_body_classes diff --git a/app/controllers/manifests_controller.rb b/app/controllers/manifests_controller.rb index 332d845d8..960510f60 100644 --- a/app/controllers/manifests_controller.rb +++ b/app/controllers/manifests_controller.rb @@ -2,8 +2,10 @@ class ManifestsController < ApplicationController skip_before_action :store_current_location + skip_before_action :require_functional! def show - render json: InstancePresenter.new, serializer: ManifestSerializer + expires_in 3.minutes, public: true + render json: InstancePresenter.new, serializer: ManifestSerializer, root: 'instance' end end diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index d44b52d26..05cf09c28 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -4,7 +4,9 @@ class MediaController < ApplicationController include Authorization skip_before_action :store_current_location + skip_before_action :require_functional! + before_action :authenticate_user!, if: :whitelist_mode? before_action :set_media_attachment before_action :verify_permitted_status! before_action :check_playable, only: :player @@ -31,7 +33,6 @@ class MediaController < ApplicationController def verify_permitted_status! authorize @media_attachment.status, :show? rescue Mastodon::NotPermittedError - # Reraise in order to get a 404 instead of a 403 error code raise ActiveRecord::RecordNotFound end diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb index 8fc18dd06..014b89de1 100644 --- a/app/controllers/media_proxy_controller.rb +++ b/app/controllers/media_proxy_controller.rb @@ -4,6 +4,13 @@ class MediaProxyController < ApplicationController include RoutingHelper skip_before_action :store_current_location + skip_before_action :require_functional! + + before_action :authenticate_user!, if: :whitelist_mode? + + rescue_from ActiveRecord::RecordInvalid, with: :not_found + rescue_from Mastodon::UnexpectedResponseError, with: :not_found + rescue_from HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, with: :internal_server_error def show RedisLock.acquire(lock_options) do |lock| diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index f3d235366..fb8389034 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -7,6 +7,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio before_action :authenticate_resource_owner! before_action :set_body_classes + skip_before_action :require_functional! + include Localized def destroy diff --git a/app/controllers/public_timelines_controller.rb b/app/controllers/public_timelines_controller.rb index 53d4472d8..1332ba16c 100644 --- a/app/controllers/public_timelines_controller.rb +++ b/app/controllers/public_timelines_controller.rb @@ -3,25 +3,17 @@ class PublicTimelinesController < ApplicationController layout 'public' - before_action :check_enabled + before_action :authenticate_user!, if: :whitelist_mode? + before_action :require_enabled! before_action :set_body_classes before_action :set_instance_presenter - def show - respond_to do |format| - format.html do - @initial_state_json = ActiveModelSerializers::SerializableResource.new( - InitialStatePresenter.new(settings: { known_fediverse: Setting.show_known_fediverse_at_about_page }, token: current_session&.token), - serializer: InitialStateSerializer - ).to_json - end - end - end + def show; end private - def check_enabled - raise ActiveRecord::RecordNotFound unless Setting.timeline_preview + def require_enabled! + not_found unless Setting.timeline_preview end def set_body_classes diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb index 8ba331cd1..db1604644 100644 --- a/app/controllers/remote_follow_controller.rb +++ b/app/controllers/remote_follow_controller.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true class RemoteFollowController < ApplicationController + include AccountOwnedConcern + layout 'modal' - before_action :set_account - before_action :gone, if: :suspended_account? before_action :set_body_classes + skip_before_action :require_functional! + def new @remote_follow = RemoteFollow.new(session_params) end @@ -29,15 +31,7 @@ class RemoteFollowController < ApplicationController end def session_params - { acct: session[:remote_follow] } - end - - def set_account - @account = Account.find_local!(params[:account_username]) - end - - def suspended_account? - @account.suspended? + { acct: session[:remote_follow] || current_account&.username } end def set_body_classes diff --git a/app/controllers/remote_interaction_controller.rb b/app/controllers/remote_interaction_controller.rb index cc6993c52..4073e7ac3 100644 --- a/app/controllers/remote_interaction_controller.rb +++ b/app/controllers/remote_interaction_controller.rb @@ -5,10 +5,13 @@ class RemoteInteractionController < ApplicationController layout 'modal' + before_action :authenticate_user!, if: :whitelist_mode? before_action :set_interaction_type before_action :set_status before_action :set_body_classes + skip_before_action :require_functional! + def new @remote_follow = RemoteFollow.new(session_params) end @@ -31,14 +34,13 @@ class RemoteInteractionController < ApplicationController end def session_params - { acct: session[:remote_follow] } + { acct: session[:remote_follow] || current_account&.username } end def set_status @status = Status.find(params[:id]) authorize @status, :show? rescue Mastodon::NotPermittedError - # Reraise in order to get a 404 raise ActiveRecord::RecordNotFound end diff --git a/app/controllers/remote_unfollows_controller.rb b/app/controllers/remote_unfollows_controller.rb deleted file mode 100644 index af5943363..000000000 --- a/app/controllers/remote_unfollows_controller.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -class RemoteUnfollowsController < ApplicationController - layout 'modal' - - before_action :authenticate_user! - before_action :set_body_classes - - def create - @account = unfollow_attempt.try(:target_account) - - if @account.nil? - render :error - else - render :success - end - rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError - render :error - end - - private - - def unfollow_attempt - username, domain = acct_without_prefix.split('@') - UnfollowService.new.call(current_account, Account.find_remote!(username, domain)) - end - - def acct_without_prefix - acct_params.gsub(/\Aacct:/, '') - end - - def acct_params - params.fetch(:acct, '') - end - - def set_body_classes - @body_classes = 'modal-layout' - end -end diff --git a/app/controllers/settings/aliases_controller.rb b/app/controllers/settings/aliases_controller.rb new file mode 100644 index 000000000..b7c9a409d --- /dev/null +++ b/app/controllers/settings/aliases_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class Settings::AliasesController < Settings::BaseController + layout 'admin' + + before_action :authenticate_user! + before_action :set_aliases, except: :destroy + before_action :set_alias, only: :destroy + + def index + @alias = current_account.aliases.build + end + + def create + @alias = current_account.aliases.build(resource_params) + + if @alias.save + ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) + redirect_to settings_aliases_path, notice: I18n.t('aliases.created_msg') + else + render :index + end + end + + def destroy + @alias.destroy! + redirect_to settings_aliases_path, notice: I18n.t('aliases.deleted_msg') + end + + private + + def resource_params + params.require(:account_alias).permit(:acct) + end + + def set_alias + @alias = current_account.aliases.find(params[:id]) + end + + def set_aliases + @aliases = current_account.aliases.order(id: :desc).reject(&:new_record?) + end +end diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb index dd19aadf6..15a59c999 100644 --- a/app/controllers/settings/deletes_controller.rb +++ b/app/controllers/settings/deletes_controller.rb @@ -5,18 +5,20 @@ class Settings::DeletesController < Settings::BaseController before_action :check_enabled_deletion before_action :authenticate_user! + before_action :require_not_suspended! + + skip_before_action :require_functional! def show @confirmation = Form::DeleteConfirmation.new end def destroy - if current_user.valid_password?(delete_params[:password]) - Admin::SuspensionWorker.perform_async(current_user.account_id, true) - sign_out + if challenge_passed? + destroy_account! redirect_to new_user_session_path, notice: I18n.t('deletes.success_msg') else - redirect_to settings_delete_path, alert: I18n.t('deletes.bad_password_msg') + redirect_to settings_delete_path, alert: I18n.t('deletes.challenge_not_passed') end end @@ -26,7 +28,25 @@ class Settings::DeletesController < Settings::BaseController redirect_to root_path unless Setting.open_deletion end - def delete_params - params.require(:form_delete_confirmation).permit(:password) + def resource_params + params.require(:form_delete_confirmation).permit(:password, :username) + end + + def require_not_suspended! + forbidden if current_account.suspended? + end + + def challenge_passed? + if current_user.encrypted_password.blank? + current_account.username == resource_params[:username] + else + current_user.valid_password?(resource_params[:password]) + end + end + + def destroy_account! + current_account.suspend! + Admin::SuspensionWorker.perform_async(current_user.account_id, true) + sign_out end end diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb index 3012fbf77..0e93d07a9 100644 --- a/app/controllers/settings/exports_controller.rb +++ b/app/controllers/settings/exports_controller.rb @@ -6,6 +6,9 @@ class Settings::ExportsController < Settings::BaseController layout 'admin' before_action :authenticate_user! + before_action :require_not_suspended! + + skip_before_action :require_functional! def show @export = Export.new(current_account) @@ -34,4 +37,8 @@ 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/migration/redirects_controller.rb b/app/controllers/settings/migration/redirects_controller.rb new file mode 100644 index 000000000..6e5b72ffb --- /dev/null +++ b/app/controllers/settings/migration/redirects_controller.rb @@ -0,0 +1,45 @@ +# 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! + + def new + @redirect = Form::Redirect.new + end + + def create + @redirect = Form::Redirect.new(resource_params.merge(account: current_account)) + + if @redirect.valid_with_challenge?(current_user) + current_account.update!(moved_to_account: @redirect.target_account) + ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) + redirect_to settings_migration_path, notice: I18n.t('migrations.moved_msg', acct: current_account.moved_to_account.acct) + else + render :new + end + end + + def destroy + if current_account.moved_to_account_id.present? + current_account.update!(moved_to_account: nil) + ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) + end + + redirect_to settings_migration_path, notice: I18n.t('migrations.cancelled_msg') + end + + private + + 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 59eb48779..68304bb51 100644 --- a/app/controllers/settings/migrations_controller.rb +++ b/app/controllers/settings/migrations_controller.rb @@ -4,31 +4,48 @@ class Settings::MigrationsController < Settings::BaseController layout 'admin' 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 = Form::Migration.new(account: current_account.moved_to_account) + @migration = current_account.migrations.build end - def update - @migration = Form::Migration.new(resource_params) + def create + @migration = current_account.migrations.build(resource_params) - if @migration.valid? && migration_account_changed? - current_account.update!(moved_to_account: @migration.account) - ActivityPub::UpdateDistributionWorker.perform_async(current_account.id) - redirect_to settings_migration_path, notice: I18n.t('migrations.updated_msg') + if @migration.save_with_challenge(current_user) + MoveService.new.call(@migration) + redirect_to settings_migration_path, notice: I18n.t('migrations.moved_msg', acct: current_account.moved_to_account.acct) else render :show end end + helper_method :on_cooldown? + private def resource_params - params.require(:migration).permit(:acct) + params.require(:account_migration).permit(:acct, :current_password, :current_username) end - def migration_account_changed? - current_account.moved_to_account_id != @migration.account&.id && - current_account.id != @migration.account&.id + def set_migrations + @migrations = current_account.migrations.includes(:target_account).order(id: :desc).reject(&:new_record?) + end + + def set_cooldown + @cooldown = current_account.migrations.within_cooldown.first + end + + def on_cooldown? + @cooldown.present? + end + + def require_not_suspended! + forbidden if current_account.suspended? end end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 110debd6e..edf29947b 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -54,7 +54,10 @@ class Settings::PreferencesController < Settings::BaseController :setting_aggregate_reblogs, :setting_show_application, :setting_advanced_layout, - notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account), + :setting_use_blurhash, + :setting_use_pending_items, + :setting_trends, + notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag), interactions: %i(must_be_follower must_be_following must_be_following_dm) ) end diff --git a/app/controllers/settings/sessions_controller.rb b/app/controllers/settings/sessions_controller.rb index 84ebb21f2..df5ace803 100644 --- a/app/controllers/settings/sessions_controller.rb +++ b/app/controllers/settings/sessions_controller.rb @@ -4,6 +4,8 @@ class Settings::SessionsController < Settings::BaseController before_action :authenticate_user! before_action :set_session, only: :destroy + skip_before_action :require_functional! + 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 02652a36c..ef4df3339 100644 --- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb +++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb @@ -3,23 +3,30 @@ module Settings module TwoFactorAuthentication class ConfirmationsController < BaseController + include ChallengableConcern + layout 'admin' 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[:code]) + if current_user.validate_and_consume_otp!(confirmation_params[:otp_attempt]) flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success') current_user.otp_required_for_login = true @recovery_codes = current_user.generate_otp_backup_codes! current_user.save! + UserMailer.two_factor_enabled(current_user).deliver_later! + render 'settings/two_factor_authentication/recovery_codes/index' else flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code') @@ -31,7 +38,7 @@ module Settings private def confirmation_params - params.require(:form_two_factor_confirmation).permit(:code) + params.require(:form_two_factor_confirmation).permit(:otp_attempt) end def prepare_two_factor_form 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 874bf532b..0c4f5bff7 100644 --- a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb +++ b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb @@ -3,14 +3,22 @@ module Settings module TwoFactorAuthentication class RecoveryCodesController < BaseController + include ChallengableConcern + layout 'admin' before_action :authenticate_user! + before_action :require_challenge!, on: :create + + skip_before_action :require_functional! def create @recovery_codes = current_user.generate_otp_backup_codes! current_user.save! + + UserMailer.two_factor_recovery_codes_changed(current_user).deliver_later! flash.now[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated') + render :index end end diff --git a/app/controllers/settings/two_factor_authentications_controller.rb b/app/controllers/settings/two_factor_authentications_controller.rb index e12c43074..9118a7933 100644 --- a/app/controllers/settings/two_factor_authentications_controller.rb +++ b/app/controllers/settings/two_factor_authentications_controller.rb @@ -2,10 +2,15 @@ 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 @@ -21,6 +26,7 @@ module Settings 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') @@ -32,7 +38,7 @@ module Settings private def confirmation_params - params.require(:form_two_factor_confirmation).permit(:code) + params.require(:form_two_factor_confirmation).permit(:otp_attempt) end def verify_otp_required @@ -40,8 +46,8 @@ module Settings end def acceptable_code? - current_user.validate_and_consume_otp!(confirmation_params[:code]) || - current_user.invalidate_otp_backup_code!(confirmation_params[: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/shares_controller.rb b/app/controllers/shares_controller.rb index af605b98f..6546b8497 100644 --- a/app/controllers/shares_controller.rb +++ b/app/controllers/shares_controller.rb @@ -6,26 +6,10 @@ class SharesController < ApplicationController before_action :authenticate_user! before_action :set_body_classes - def show - serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) - @initial_state_json = serializable_resource.to_json - end + def show; end private - def initial_state_params - text = [params[:title], params[:text], params[:url]].compact.join(' ') - - { - settings: Web::Setting.find_by(user: current_user)&.data || {}, - push_subscription: current_account.user.web_push_subscription(current_session), - current_account: current_account, - token: current_session.token, - admin: Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')), - text: text, - } - end - def set_body_classes @body_classes = 'modal-layout compose-standalone' end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index ef26691b2..57bbeca64 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -1,24 +1,25 @@ # frozen_string_literal: true class StatusesController < ApplicationController + include StatusControllerConcern include SignatureAuthentication include Authorization - - ANCESTORS_LIMIT = 40 - DESCENDANTS_LIMIT = 60 - DESCENDANTS_DEPTH_LIMIT = 20 + include AccountOwnedConcern layout 'public' - before_action :set_account + before_action :require_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? } before_action :set_status before_action :set_instance_presenter before_action :set_link_headers - before_action :check_account_suspension - before_action :redirect_to_original, only: [:show] - before_action :set_referrer_policy_header, only: [:show] + before_action :redirect_to_original, only: :show + before_action :set_referrer_policy_header, only: :show before_action :set_cache_headers - before_action :set_replies, only: [:replies] + before_action :set_body_classes + before_action :set_autoplay, only: :embed + + skip_around_action :set_locale, if: -> { request.format == :json } + skip_before_action :require_functional!, only: [:show, :embed] content_security_policy only: :embed do |p| p.frame_ancestors(false) @@ -28,27 +29,20 @@ class StatusesController < ApplicationController respond_to do |format| format.html do expires_in 10.seconds, public: true if current_account.nil? - - @body_classes = 'with-modals' - set_ancestors set_descendants - - render 'stream_entries/show' end format.json do - render_cached_json(['activitypub', 'note', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do - ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter) - end + expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? + render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter end end end def activity - render_cached_json(['activitypub', 'activity', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do - ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter) - end + expires_in 3.minutes, public: @status.distributable? && public_fetch_mode? + render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter end def embed @@ -56,130 +50,24 @@ class StatusesController < ApplicationController expires_in 180, public: true response.headers['X-Frame-Options'] = 'ALLOWALL' - @autoplay = ActiveModel::Type::Boolean.new.cast(params[:autoplay]) - render 'stream_entries/embed', layout: 'embedded' - end - - def replies - render json: replies_collection_presenter, - serializer: ActivityPub::CollectionSerializer, - adapter: ActivityPub::Adapter, - content_type: 'application/activity+json', - skip_activities: true + render layout: 'embedded' end private - def replies_collection_presenter - page = ActivityPub::CollectionPresenter.new( - id: replies_account_status_url(@account, @status, page_params), - type: :unordered, - part_of: replies_account_status_url(@account, @status), - next: next_page, - items: @replies.map { |status| status.local ? status : status.id } - ) - if page_requested? - page - else - ActivityPub::CollectionPresenter.new( - id: replies_account_status_url(@account, @status), - type: :unordered, - first: page - ) - end - end - - def create_descendant_thread(starting_depth, statuses) - depth = starting_depth + statuses.size - if depth < DESCENDANTS_DEPTH_LIMIT - { statuses: statuses, starting_depth: starting_depth } - else - next_status = statuses.pop - { statuses: statuses, starting_depth: starting_depth, next_status: next_status } - end - end - - def set_account - @account = Account.find_local!(params[:account_username]) - end - - def set_ancestors - @ancestors = @status.reply? ? cache_collection(@status.ancestors(ANCESTORS_LIMIT, current_account), Status) : [] - @next_ancestor = @ancestors.size < ANCESTORS_LIMIT ? nil : @ancestors.shift - end - - def set_descendants - @max_descendant_thread_id = params[:max_descendant_thread_id]&.to_i - @since_descendant_thread_id = params[:since_descendant_thread_id]&.to_i - - descendants = cache_collection( - @status.descendants( - DESCENDANTS_LIMIT, - current_account, - @max_descendant_thread_id, - @since_descendant_thread_id, - DESCENDANTS_DEPTH_LIMIT - ), - Status - ) - - @descendant_threads = [] - - if descendants.present? - statuses = [descendants.first] - starting_depth = 0 - - descendants.drop(1).each_with_index do |descendant, index| - if descendants[index].id == descendant.in_reply_to_id - statuses << descendant - else - @descendant_threads << create_descendant_thread(starting_depth, statuses) - - # The thread is broken, assume it's a reply to the root status - starting_depth = 0 - - # ... unless we can find its ancestor in one of the already-processed threads - @descendant_threads.reverse_each do |descendant_thread| - statuses = descendant_thread[:statuses] - - index = statuses.find_index do |thread_status| - thread_status.id == descendant.in_reply_to_id - end - - if index.present? - starting_depth = descendant_thread[:starting_depth] + index + 1 - break - end - end - - statuses = [descendant] - end - end - - @descendant_threads << create_descendant_thread(starting_depth, statuses) - end - - @max_descendant_thread_id = @descendant_threads.pop[:statuses].first.id if descendants.size >= DESCENDANTS_LIMIT + def set_body_classes + @body_classes = 'with-modals' end def set_link_headers - response.headers['Link'] = LinkHeader.new( - [ - [account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]], - [ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]], - ] - ) + response.headers['Link'] = LinkHeader.new([[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]]]) end def set_status - @status = @account.statuses.find(params[:id]) - @stream_entry = @status.stream_entry - @type = @stream_entry.activity_type.downcase - + @status = @account.statuses.find(params[:id]) authorize @status, :show? rescue Mastodon::NotPermittedError - # Reraise in order to get a 404 raise ActiveRecord::RecordNotFound end @@ -187,39 +75,15 @@ class StatusesController < ApplicationController @instance_presenter = InstancePresenter.new end - def check_account_suspension - gone if @account.suspended? - end - def redirect_to_original - redirect_to ::TagManager.instance.url_for(@status.reblog) if @status.reblog? + redirect_to ActivityPub::TagManager.instance.url_for(@status.reblog) if @status.reblog? end def set_referrer_policy_header - return if @status.public_visibility? || @status.unlisted_visibility? - response.headers['Referrer-Policy'] = 'origin' + response.headers['Referrer-Policy'] = 'origin' unless @status.distributable? end - def page_requested? - params[:page] == 'true' - end - - def set_replies - @replies = page_params[:other_accounts] ? Status.where.not(account_id: @account.id) : @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 - - def next_page - last_reply = @replies.last - return if last_reply.nil? - same_account = last_reply.account_id == @account.id - return unless same_account || @replies.size == DESCENDANTS_LIMIT - same_account = false unless @replies.size == DESCENDANTS_LIMIT - replies_account_status_url(@account, @status, page: true, min_id: last_reply.id, other_accounts: !same_account) - end - - def page_params - { page: true, other_accounts: params[:other_accounts], min_id: params[:min_id] }.compact + def set_autoplay + @autoplay = truthy_param?(:autoplay) end end diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb deleted file mode 100644 index 0f7e9e0f5..000000000 --- a/app/controllers/stream_entries_controller.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -class StreamEntriesController < ApplicationController - include Authorization - include SignatureVerification - - layout 'public' - - before_action :set_account - before_action :set_stream_entry - before_action :set_link_headers - before_action :check_account_suspension - before_action :set_cache_headers - - def show - respond_to do |format| - format.html do - expires_in 5.minutes, public: true unless @stream_entry.hidden? - - redirect_to short_account_status_url(params[:account_username], @stream_entry.activity) - end - - format.atom do - expires_in 3.minutes, public: true unless @stream_entry.hidden? - - render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(@stream_entry, true)) - end - end - end - - def embed - redirect_to embed_short_account_status_url(@account, @stream_entry.activity), status: 301 - end - - private - - def set_account - @account = Account.find_local!(params[:account_username]) - end - - def set_link_headers - response.headers['Link'] = LinkHeader.new( - [ - [account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]], - [ActivityPub::TagManager.instance.uri_for(@stream_entry.activity), [%w(rel alternate), %w(type application/activity+json)]], - ] - ) - end - - def set_stream_entry - @stream_entry = @account.stream_entries.where(activity_type: 'Status').find(params[:id]) - @type = 'status' - - raise ActiveRecord::RecordNotFound if @stream_entry.activity.nil? - authorize @stream_entry.activity, :show? if @stream_entry.hidden? - rescue Mastodon::NotPermittedError - # Reraise in order to get a 404 - raise ActiveRecord::RecordNotFound - end - - def check_account_suspension - gone if @account.suspended? - end -end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 66b184901..77d5661b8 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -1,25 +1,29 @@ # frozen_string_literal: true class TagsController < ApplicationController + include SignatureVerification + PAGE_SIZE = 20 layout 'public' + 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_body_classes before_action :set_instance_presenter - def show - @tag = Tag.find_normalized!(params[:id]) + skip_before_action :require_functional! + def show respond_to do |format| format.html do - @initial_state_json = ActiveModelSerializers::SerializableResource.new( - InitialStatePresenter.new(settings: {}, token: current_session&.token), - serializer: InitialStateSerializer - ).to_json + expires_in 0, public: true end format.rss do + expires_in 0, public: true + @statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none)).limit(PAGE_SIZE) @statuses = cache_collection(@statuses, Status) @@ -27,19 +31,22 @@ class TagsController < ApplicationController end format.json do + expires_in 3.minutes, public: public_fetch_mode? + @statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[: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' + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end private + def set_tag + @tag = Tag.usable.find_normalized!(params[:id]) + end + def set_body_classes @body_classes = 'with-modals' end diff --git a/app/controllers/well_known/host_meta_controller.rb b/app/controllers/well_known/host_meta_controller.rb index 5fb70288a..2e9298c4a 100644 --- a/app/controllers/well_known/host_meta_controller.rb +++ b/app/controllers/well_known/host_meta_controller.rb @@ -13,7 +13,7 @@ module WellKnown format.xml { render content_type: 'application/xrd+xml' } end - expires_in(3.days, public: true) + expires_in 3.days, public: true end end end diff --git a/app/controllers/well_known/keybase_proof_config_controller.rb b/app/controllers/well_known/keybase_proof_config_controller.rb index eb41e586f..e1d43ecbe 100644 --- a/app/controllers/well_known/keybase_proof_config_controller.rb +++ b/app/controllers/well_known/keybase_proof_config_controller.rb @@ -3,7 +3,7 @@ module WellKnown class KeybaseProofConfigController < ActionController::Base def show - render json: {}, serializer: ProofProvider::Keybase::ConfigSerializer + render json: {}, serializer: ProofProvider::Keybase::ConfigSerializer, root: 'keybase_config' end end end diff --git a/app/controllers/well_known/nodeinfo_controller.rb b/app/controllers/well_known/nodeinfo_controller.rb new file mode 100644 index 000000000..11a699ebc --- /dev/null +++ b/app/controllers/well_known/nodeinfo_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module WellKnown + class NodeInfoController < ActionController::Base + include CacheConcern + + before_action { response.headers['Vary'] = 'Accept' } + + def index + expires_in 3.days, public: true + render_with_cache json: {}, serializer: NodeInfo::DiscoverySerializer, adapter: NodeInfo::Adapter, expires_in: 3.days, root: 'nodeinfo' + end + + def show + expires_in 30.minutes, public: true + render_with_cache json: {}, serializer: NodeInfo::Serializer, adapter: NodeInfo::Adapter, expires_in: 30.minutes, root: 'nodeinfo' + end + end +end diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 28654b61d..480e58f3f 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -5,34 +5,26 @@ module WellKnown include RoutingHelper before_action { response.headers['Vary'] = 'Accept' } + before_action :set_account + before_action :check_account_suspension + + rescue_from ActiveRecord::RecordNotFound, ActionController::ParameterMissing, with: :not_found def show - @account = Account.find_local!(username_from_resource) - - respond_to do |format| - format.any(:json, :html) do - render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json' - end - - format.xml do - render content_type: 'application/xrd+xml' - end - end - - expires_in(3.days, public: true) - rescue ActiveRecord::RecordNotFound - head 404 + expires_in 3.days, public: true + render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json' end private - def username_from_resource - resource_user = resource_param + def set_account + @account = Account.find_local!(username_from_resource) + end + def username_from_resource + resource_user = resource_param username, domain = resource_user.split('@') - if Rails.configuration.x.alternate_domains.include?(domain) - resource_user = "#{username}@#{Rails.configuration.x.local_domain}" - end + resource_user = "#{username}@#{Rails.configuration.x.local_domain}" if Rails.configuration.x.alternate_domains.include?(domain) WebfingerResource.new(resource_user).username end @@ -40,5 +32,17 @@ module WellKnown def resource_param params.require(:resource) end + + def check_account_suspension + expires_in(3.minutes, public: true) && gone if @account.suspended? + end + + def not_found + head 404 + end + + def gone + head 410 + end end end diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index e5fbb1500..1daa60774 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -89,7 +89,7 @@ module Admin::ActionLogsHelper when 'DomainBlock', 'EmailDomainBlock' link_to record.domain, "https://#{record.domain}" when 'Status' - link_to record.account.acct, TagManager.instance.url_for(record) + link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record) when 'AccountWarning' link_to record.target_account.acct, admin_account_path(record.target_account_id) end diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 0bda25974..8af1683e7 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -5,15 +5,16 @@ module Admin::FilterHelper REPORT_FILTERS = %i(resolved account_id target_account_id).freeze INVITE_FILTER = %i(available expired).freeze CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze - TAGS_FILTERS = %i(hidden).freeze + TAGS_FILTERS = %i(directory reviewed unreviewed pending_review popular active name).freeze INSTANCES_FILTERS = %i(limited by_domain).freeze FOLLOWERS_FILTERS = %i(relationship status by_domain activity order).freeze FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS + TAGS_FILTERS + INSTANCES_FILTERS + FOLLOWERS_FILTERS def filter_link_to(text, link_to_params, link_class_params = link_to_params) - new_url = filtered_url_for(link_to_params) + new_url = filtered_url_for(link_to_params) new_class = filtered_url_for(link_class_params) + link_to text, new_url, class: filter_link_class(new_class) end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9d113263d..defd97609 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -77,8 +77,12 @@ module ApplicationHelper content_tag(:i, nil, attributes.merge(class: class_names.join(' '))) end - def custom_emoji_tag(custom_emoji) - image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:") + def custom_emoji_tag(custom_emoji, animate = true) + if animate + image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:") + else + image_tag(custom_emoji.image.url(:static), class: 'emojione custom-emoji', alt: ":#{custom_emoji.shortcode}", 'data-original' => full_asset_url(custom_emoji.image.url), 'data-static' => full_asset_url(custom_emoji.image.url(:static))) + end end def opengraph(property, content) @@ -122,4 +126,25 @@ module ApplicationHelper text = word_wrap(text, line_width: line_width - 2, break_sequence: break_sequence) text.split("\n").map { |line| '> ' + line }.join("\n") end + + def render_initial_state + state_params = { + settings: { + known_fediverse: Setting.show_known_fediverse_at_about_page, + }, + + text: [params[:title], params[:text], params[:url]].compact.join(' '), + } + + if user_signed_in? + state_params[:settings] = state_params[:settings].merge(Web::Setting.find_by(user: current_user)&.data || {}) + state_params[:push_subscription] = current_account.user.web_push_subscription(current_session) + state_params[:current_account] = current_account + state_params[:token] = current_session.token + state_params[:admin] = Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')) + end + + json = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(state_params), serializer: InitialStateSerializer).to_json + content_tag(:script, json_escape(json).html_safe, id: 'initial-state', type: 'application/json') + end end diff --git a/app/helpers/domain_control_helper.rb b/app/helpers/domain_control_helper.rb new file mode 100644 index 000000000..067b2c2cd --- /dev/null +++ b/app/helpers/domain_control_helper.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module DomainControlHelper + def domain_not_allowed?(uri_or_domain) + return if uri_or_domain.blank? + + domain = begin + if uri_or_domain.include?('://') + Addressable::URI.parse(uri_or_domain).domain + else + uri_or_domain + end + end + + if whitelist_mode? + !DomainAllow.allowed?(domain) + else + DomainBlock.blocked?(domain) + end + end + + def whitelist_mode? + Rails.configuration.x.whitelist_mode + end +end diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb index df60b7dd7..b66e827fe 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -21,7 +21,7 @@ module HomeHelper end end else - link_to(path || TagManager.instance.url_for(account), class: 'account__display-name') do + link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do content_tag(:div, class: 'account__avatar-wrapper') do content_tag(:div, '', class: 'account__avatar', style: "width: #{size}px; height: #{size}px; background-size: #{size}px #{size}px; background-image: url(#{full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url)})") end + diff --git a/app/helpers/instance_helper.rb b/app/helpers/instance_helper.rb index dd0b25f3e..daacb535b 100644 --- a/app/helpers/instance_helper.rb +++ b/app/helpers/instance_helper.rb @@ -8,4 +8,16 @@ module InstanceHelper def site_hostname @site_hostname ||= Addressable::URI.parse("//#{Rails.configuration.x.local_domain}").display_uri.host end + + def description_for_sign_up + prefix = begin + if @invite.present? + I18n.t('auth.description.prefix_invited_by_user', name: @invite.user.account.username) + else + I18n.t('auth.description.prefix_sign_up') + end + end + + safe_join([prefix, I18n.t('auth.description.suffix')], ' ') + end end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 5b4011275..1c473efa3 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -16,13 +16,15 @@ module JsonLdHelper # The url attribute can be a string, an array of strings, or an array of objects. # The objects could include a mimeType. Not-included mimeType means it's text/html. def url_to_href(value, preferred_type = nil) - single_value = if value.is_a?(Array) && !value.first.is_a?(String) - value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) } - elsif value.is_a?(Array) - value.first - else - value - end + single_value = begin + if value.is_a?(Array) && !value.first.is_a?(String) + value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) } + elsif value.is_a?(Array) + value.first + else + value + end + end if single_value.nil? || single_value.is_a?(String) single_value @@ -64,7 +66,9 @@ module JsonLdHelper def fetch_resource(uri, id, on_behalf_of = nil) unless id json = fetch_resource_without_id_validation(uri, on_behalf_of) + return unless json + uri = json['id'] end @@ -73,25 +77,20 @@ module JsonLdHelper end def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false) + on_behalf_of ||= Account.representative + build_request(uri, on_behalf_of).perform do |response| - unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error - raise Mastodon::UnexpectedResponseError, response - end - return body_to_json(response.body_with_limit) if response.code == 200 - end - # If request failed, retry without doing it on behalf of a user - return if on_behalf_of.nil? - build_request(uri).perform do |response| - unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error - raise Mastodon::UnexpectedResponseError, response - end - response.code == 200 ? body_to_json(response.body_with_limit) : nil + raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error + + body_to_json(response.body_with_limit) if response.code == 200 end end def body_to_json(body, compare_id: nil) json = body.is_a?(String) ? Oj.load(body, mode: :strict) : body + return if compare_id.present? && json['id'] != compare_id + json rescue Oj::ParseError nil @@ -105,35 +104,34 @@ module JsonLdHelper end end - private - def response_successful?(response) (200...300).cover?(response.code) end def response_error_unsalvageable?(response) - (400...500).cover?(response.code) && response.code != 429 + response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code)) end def build_request(uri, on_behalf_of = nil) - request = Request.new(:get, uri) - request.on_behalf_of(on_behalf_of) if on_behalf_of - request.add_headers('Accept' => 'application/activity+json, application/ld+json') - request + Request.new(:get, uri).tap do |request| + request.on_behalf_of(on_behalf_of) if on_behalf_of + request.add_headers('Accept' => 'application/activity+json, application/ld+json') + end end def load_jsonld_context(url, _options = {}, &_block) json = Rails.cache.fetch("jsonld:context:#{url}", expires_in: 30.days, raw: true) do request = Request.new(:get, url) request.add_headers('Accept' => 'application/ld+json') - request.perform do |res| raise JSON::LD::JsonLdError::LoadingDocumentFailed unless res.code == 200 && res.mime_type == 'application/ld+json' + res.body_with_limit end end - doc = JSON::LD::API::RemoteDocument.new(url, json) + doc = JSON::LD::API::RemoteDocument.new(json, documentUrl: url) + block_given? ? yield(doc) : doc end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 92bc222ea..aa0a4d467 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -2,11 +2,11 @@ module SettingsHelper HUMAN_LOCALES = { - en: 'English', ar: 'العربية', ast: 'Asturianu', bg: 'Български', bn: 'বাংলা', + br: 'Breton', ca: 'Català', co: 'Corsu', cs: 'Čeština', @@ -14,8 +14,11 @@ module SettingsHelper da: 'Dansk', de: 'Deutsch', el: 'Ελληνικά', + en: 'English', eo: 'Esperanto', + 'es-AR': 'Español (Argentina)', es: 'Español', + et: 'Eesti', eu: 'Euskara', fa: 'فارسی', fi: 'Suomi', @@ -36,31 +39,34 @@ module SettingsHelper ko: '한국어', lt: 'Lietuvių', lv: 'Latviešu', + mk: 'Македонски', ml: 'മലയാളം', ms: 'Bahasa Melayu', nl: 'Nederlands', + nn: 'Nynorsk', no: 'Norsk', oc: 'Occitan', pl: 'Polski', + 'pt-BR': 'Português (Brasil)', + 'pt-PT': 'Português (Portugal)', pt: 'Português', - 'pt-BR': 'Português do Brasil', ro: 'Română', ru: 'Русский', sk: 'Slovenčina', sl: 'Slovenščina', sq: 'Shqip', - sr: 'Српски', 'sr-Latn': 'Srpski (latinica)', + sr: 'Српски', sv: 'Svenska', ta: 'தமிழ்', te: 'తెలుగు', th: 'ไทย', tr: 'Türkçe', uk: 'Українська', - zh: '中文', 'zh-CN': '简体中文', 'zh-HK': '繁體中文(香港)', 'zh-TW': '繁體中文(臺灣)', + zh: '中文', }.freeze def human_locale(locale) @@ -86,4 +92,12 @@ module SettingsHelper 'desktop' end end + + def compact_account_link_to(account) + return if account.nil? + + link_to ActivityPub::TagManager.instance.url_for(account), class: 'name-tag', title: account.acct do + safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ') + end + end end diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/statuses_helper.rb similarity index 87% rename from app/helpers/stream_entries_helper.rb rename to app/helpers/statuses_helper.rb index 02a860a74..8380b3c42 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module StreamEntriesHelper +module StatusesHelper EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_ACTION = 'embed' @@ -34,6 +34,26 @@ module StreamEntriesHelper end end + def minimal_account_action_button(account) + if user_signed_in? + return if account.id == current_user.account_id + + if current_account.following?(account) || current_account.requested?(account) + link_to account_unfollow_path(account), class: 'icon-button active', data: { method: :post }, title: t('accounts.unfollow') do + fa_icon('user-times fw') + end + elsif !(account.memorial? || account.moved?) + link_to account_follow_path(account), class: "icon-button#{account.blocking?(current_account) ? ' disabled' : ''}", data: { method: :post }, title: t('accounts.follow') do + fa_icon('user-plus fw') + end + end + elsif !(account.memorial? || account.moved?) + link_to account_remote_follow_path(account), class: 'icon-button modal-button', target: '_new', title: t('accounts.follow') do + fa_icon('user-plus fw') + end + end + end + def svg_logo content_tag(:svg, tag(:use, 'xlink:href' => '#mastodon-svg-logo'), 'viewBox' => '0 0 216.4144 232.00976') end @@ -109,11 +129,13 @@ module StreamEntriesHelper def status_text_summary(status) return if status.spoiler_text.blank? + I18n.t('statuses.content_warning', warning: status.spoiler_text) end def poll_summary(status) return unless status.preloadable_poll + status.preloadable_poll.options.map { |o| "[ ] #{o}" }.join("\n") end diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js index ef2500e7b..cd36d8007 100644 --- a/app/javascript/mastodon/actions/alerts.js +++ b/app/javascript/mastodon/actions/alerts.js @@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl'; const messages = defineMessages({ unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, + rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' }, + rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' }, }); export const ALERT_SHOW = 'ALERT_SHOW'; @@ -23,23 +25,29 @@ export function clearAlert() { }; }; -export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) { +export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) { return { type: ALERT_SHOW, title, message, + message_values, }; }; export function showAlertForError(error) { if (error.response) { - const { data, status, statusText } = error.response; + const { data, status, statusText, headers } = error.response; if (status === 404 || status === 410) { // Skip these errors as they are reflected in the UI return { type: ALERT_NOOP }; } + if (status === 429 && headers['x-ratelimit-reset']) { + const reset_date = new Date(headers['x-ratelimit-reset']); + return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date }); + } + let message = statusText; let title = `${status}`; diff --git a/app/javascript/mastodon/actions/app.js b/app/javascript/mastodon/actions/app.js new file mode 100644 index 000000000..414968f7d --- /dev/null +++ b/app/javascript/mastodon/actions/app.js @@ -0,0 +1,10 @@ +export const APP_FOCUS = 'APP_FOCUS'; +export const APP_UNFOCUS = 'APP_UNFOCUS'; + +export const focusApp = () => ({ + type: APP_FOCUS, +}); + +export const unfocusApp = () => ({ + type: APP_UNFOCUS, +}); diff --git a/app/javascript/mastodon/actions/blocks.js b/app/javascript/mastodon/actions/blocks.js index 7000f5a71..fd9881302 100644 --- a/app/javascript/mastodon/actions/blocks.js +++ b/app/javascript/mastodon/actions/blocks.js @@ -1,6 +1,7 @@ import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts } from './importer'; +import { openModal } from './modal'; export const BLOCKS_FETCH_REQUEST = 'BLOCKS_FETCH_REQUEST'; export const BLOCKS_FETCH_SUCCESS = 'BLOCKS_FETCH_SUCCESS'; @@ -10,6 +11,8 @@ export const BLOCKS_EXPAND_REQUEST = 'BLOCKS_EXPAND_REQUEST'; export const BLOCKS_EXPAND_SUCCESS = 'BLOCKS_EXPAND_SUCCESS'; export const BLOCKS_EXPAND_FAIL = 'BLOCKS_EXPAND_FAIL'; +export const BLOCKS_INIT_MODAL = 'BLOCKS_INIT_MODAL'; + export function fetchBlocks() { return (dispatch, getState) => { dispatch(fetchBlocksRequest()); @@ -83,3 +86,14 @@ export function expandBlocksFail(error) { error, }; }; + +export function initBlockModal(account) { + return dispatch => { + dispatch({ + type: BLOCKS_INIT_MODAL, + account, + }); + + dispatch(openModal('BLOCK')); + }; +} diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index fbf97d374..8e7906c73 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -11,7 +11,7 @@ import { showAlertForError } from './alerts'; import { showAlert } from './alerts'; import { defineMessages } from 'react-intl'; -let cancelFetchComposeSuggestionsAccounts; +let cancelFetchComposeSuggestionsAccounts, cancelFetchComposeSuggestionsTags; export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; @@ -234,7 +234,7 @@ export function uploadCompose(files) { progress[i] = loaded; dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); }, - }).then(({ data }) => dispatch(uploadComposeSuccess(data))); + }).then(({ data }) => dispatch(uploadComposeSuccess(data, f))); }).catch(error => dispatch(uploadComposeFail(error))); }; }; @@ -289,10 +289,11 @@ export function uploadComposeProgress(loaded, total) { }; }; -export function uploadComposeSuccess(media) { +export function uploadComposeSuccess(media, file) { return { type: COMPOSE_UPLOAD_SUCCESS, media: media, + file: file, skipLoading: true, }; }; @@ -325,10 +326,12 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => if (cancelFetchComposeSuggestionsAccounts) { cancelFetchComposeSuggestionsAccounts(); } + api(getState).get('/api/v1/accounts/search', { cancelToken: new CancelToken(cancel => { cancelFetchComposeSuggestionsAccounts = cancel; }), + params: { q: token.slice(1), resolve: false, @@ -349,9 +352,33 @@ const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { dispatch(readyComposeSuggestionsEmojis(token, results)); }; -const fetchComposeSuggestionsTags = (dispatch, getState, token) => { +const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => { + if (cancelFetchComposeSuggestionsTags) { + cancelFetchComposeSuggestionsTags(); + } + dispatch(updateSuggestionTags(token)); -}; + + api(getState).get('/api/v2/search', { + cancelToken: new CancelToken(cancel => { + cancelFetchComposeSuggestionsTags = cancel; + }), + + params: { + type: 'hashtags', + q: token.slice(1), + resolve: false, + limit: 4, + exclude_unreviewed: true, + }, + }).then(({ data }) => { + dispatch(readyComposeSuggestionsTags(token, data.hashtags)); + }).catch(error => { + if (!isCancel(error)) { + dispatch(showAlertForError(error)); + } + }); +}, 200, { leading: true, trailing: true }); export function fetchComposeSuggestions(token) { return (dispatch, getState) => { @@ -385,20 +412,26 @@ export function readyComposeSuggestionsAccounts(token, accounts) { }; }; +export const readyComposeSuggestionsTags = (token, tags) => ({ + type: COMPOSE_SUGGESTIONS_READY, + token, + tags, +}); + export function selectComposeSuggestion(position, token, suggestion, path) { return (dispatch, getState) => { let completion, startPosition; - if (typeof suggestion === 'object' && suggestion.id) { + if (suggestion.type === 'emoji') { completion = suggestion.native || suggestion.colons; startPosition = position - 1; dispatch(useEmoji(suggestion)); - } else if (suggestion[0] === '#') { - completion = suggestion; + } else if (suggestion.type === 'hashtag') { + completion = `#${suggestion.name}`; startPosition = position - 1; - } else { - completion = getState().getIn(['accounts', suggestion, 'acct']); + } else if (suggestion.type === 'account') { + completion = getState().getIn(['accounts', suggestion.id, 'acct']); startPosition = position; } diff --git a/app/javascript/mastodon/actions/conversations.js b/app/javascript/mastodon/actions/conversations.js index c6e062ef7..4ef654b1f 100644 --- a/app/javascript/mastodon/actions/conversations.js +++ b/app/javascript/mastodon/actions/conversations.js @@ -15,6 +15,10 @@ export const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE'; export const CONVERSATIONS_READ = 'CONVERSATIONS_READ'; +export const CONVERSATIONS_DELETE_REQUEST = 'CONVERSATIONS_DELETE_REQUEST'; +export const CONVERSATIONS_DELETE_SUCCESS = 'CONVERSATIONS_DELETE_SUCCESS'; +export const CONVERSATIONS_DELETE_FAIL = 'CONVERSATIONS_DELETE_FAIL'; + export const mountConversations = () => ({ type: CONVERSATIONS_MOUNT, }); @@ -82,3 +86,27 @@ export const updateConversations = conversation => dispatch => { conversation, }); }; + +export const deleteConversation = conversationId => (dispatch, getState) => { + dispatch(deleteConversationRequest(conversationId)); + + api(getState).delete(`/api/v1/conversations/${conversationId}`) + .then(() => dispatch(deleteConversationSuccess(conversationId))) + .catch(error => dispatch(deleteConversationFail(conversationId, error))); +}; + +export const deleteConversationRequest = id => ({ + type: CONVERSATIONS_DELETE_REQUEST, + id, +}); + +export const deleteConversationSuccess = id => ({ + type: CONVERSATIONS_DELETE_SUCCESS, + id, +}); + +export const deleteConversationFail = (id, error) => ({ + type: CONVERSATIONS_DELETE_FAIL, + id, + error, +}); diff --git a/app/javascript/mastodon/actions/directory.js b/app/javascript/mastodon/actions/directory.js new file mode 100644 index 000000000..4b2b6dd56 --- /dev/null +++ b/app/javascript/mastodon/actions/directory.js @@ -0,0 +1,61 @@ +import api from '../api'; +import { importFetchedAccounts } from './importer'; +import { fetchRelationships } from './accounts'; + +export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST'; +export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS'; +export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL'; + +export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST'; +export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS'; +export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL'; + +export const fetchDirectory = params => (dispatch, getState) => { + dispatch(fetchDirectoryRequest()); + + api(getState).get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(fetchDirectoryFail(error))); +}; + +export const fetchDirectoryRequest = () => ({ + type: DIRECTORY_FETCH_REQUEST, +}); + +export const fetchDirectorySuccess = accounts => ({ + type: DIRECTORY_FETCH_SUCCESS, + accounts, +}); + +export const fetchDirectoryFail = error => ({ + type: DIRECTORY_FETCH_FAIL, + error, +}); + +export const expandDirectory = params => (dispatch, getState) => { + dispatch(expandDirectoryRequest()); + + const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size; + + api(getState).get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(expandDirectorySuccess(data)); + dispatch(fetchRelationships(data.map(x => x.id))); + }).catch(error => dispatch(expandDirectoryFail(error))); +}; + +export const expandDirectoryRequest = () => ({ + type: DIRECTORY_EXPAND_REQUEST, +}); + +export const expandDirectorySuccess = accounts => ({ + type: DIRECTORY_EXPAND_SUCCESS, + accounts, +}); + +export const expandDirectoryFail = error => ({ + type: DIRECTORY_EXPAND_FAIL, + error, +}); diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 5e7e78e69..f7108fdb9 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -73,8 +73,9 @@ export function normalizePoll(poll) { const emojiMap = makeEmojiMap(normalPoll); - normalPoll.options = poll.options.map(option => ({ + normalPoll.options = poll.options.map((option, index) => ({ ...option, + voted: poll.own_votes && poll.own_votes.includes(index), title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap), })); diff --git a/app/javascript/mastodon/actions/markers.js b/app/javascript/mastodon/actions/markers.js new file mode 100644 index 000000000..c3a5fe86f --- /dev/null +++ b/app/javascript/mastodon/actions/markers.js @@ -0,0 +1,30 @@ +export const submitMarkers = () => (dispatch, getState) => { + const accessToken = getState().getIn(['meta', 'access_token'], ''); + const params = {}; + + const lastHomeId = getState().getIn(['timelines', 'home', 'items', 0]); + const lastNotificationId = getState().getIn(['notifications', 'items', 0, 'id']); + + if (lastHomeId) { + params.home = { + last_read_id: lastHomeId, + }; + } + + if (lastNotificationId) { + params.notifications = { + last_read_id: lastNotificationId, + }; + } + + if (Object.keys(params).length === 0) { + return; + } + + const client = new XMLHttpRequest(); + + client.open('POST', '/api/v1/markers', false); + client.setRequestHeader('Content-Type', 'application/json'); + client.setRequestHeader('Authorization', `Bearer ${accessToken}`); + client.send(JSON.stringify(params)); +}; diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 56c952cb0..58803d1ae 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -12,6 +12,8 @@ import { defineMessages } from 'react-intl'; import { List as ImmutableList } from 'immutable'; import { unescapeHTML } from '../utils/html'; import { getFiltersRegex } from '../selectors'; +import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; +import compareId from 'mastodon/compare_id'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; @@ -22,8 +24,12 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; -export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; -export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; +export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; +export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; +export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; + +export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT'; +export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, @@ -38,6 +44,10 @@ const fetchRelatedRelationships = (dispatch, notifications) => { } }; +export const loadPending = () => ({ + type: NOTIFICATIONS_LOAD_PENDING, +}); + export function updateNotifications(notification, intlMessages, intlLocale) { return (dispatch, getState) => { const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true); @@ -69,6 +79,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { dispatch({ type: NOTIFICATIONS_UPDATE, notification, + usePendingItems: preferPendingItems, meta: (playSound && !filtered) ? { sound: 'boop' } : undefined, }); @@ -122,10 +133,19 @@ export function expandNotifications({ maxId } = {}, done = noOp) { : excludeTypesFromFilter(activeFilter), }; - if (!maxId && notifications.get('items').size > 0) { - params.since_id = notifications.getIn(['items', 0, 'id']); + if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) { + const a = notifications.getIn(['pendingItems', 0, 'id']); + const b = notifications.getIn(['items', 0, 'id']); + + if (a && b && compareId(a, b) > 0) { + params.since_id = a; + } else { + params.since_id = b || a; + } } + const isLoadingRecent = !!params.since_id; + dispatch(expandNotificationsRequest(isLoadingMore)); api(getState).get('/api/v1/notifications', { params }).then(response => { @@ -134,7 +154,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) { dispatch(importFetchedAccounts(response.data.map(item => item.account))); dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); - dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore)); + dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems)); fetchRelatedRelationships(dispatch, response.data); done(); }).catch(error => { @@ -151,11 +171,13 @@ export function expandNotificationsRequest(isLoadingMore) { }; }; -export function expandNotificationsSuccess(notifications, next, isLoadingMore) { +export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) { return { type: NOTIFICATIONS_EXPAND_SUCCESS, notifications, next, + isLoadingRecent: isLoadingRecent, + usePendingItems, skipLoading: !isLoadingMore, }; }; @@ -196,3 +218,11 @@ export function setFilter (filterType) { dispatch(saveSettings()); }; }; + +export const mountNotifications = () => ({ + type: NOTIFICATIONS_MOUNT, +}); + +export const unmountNotifications = () => ({ + type: NOTIFICATIONS_UNMOUNT, +}); diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js index 0974fdd15..a178faead 100644 --- a/app/javascript/mastodon/actions/search.js +++ b/app/javascript/mastodon/actions/search.js @@ -10,6 +10,10 @@ export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; +export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST'; +export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; +export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL'; + export function changeSearch(value) { return { type: SEARCH_CHANGE, @@ -77,8 +81,50 @@ export function fetchSearchFail(error) { }; }; -export function showSearch() { - return { - type: SEARCH_SHOW, - }; +export const expandSearch = type => (dispatch, getState) => { + const value = getState().getIn(['search', 'value']); + const offset = getState().getIn(['search', 'results', type]).size; + + dispatch(expandSearchRequest()); + + api(getState).get('/api/v2/search', { + params: { + q: value, + type, + offset, + }, + }).then(({ data }) => { + if (data.accounts) { + dispatch(importFetchedAccounts(data.accounts)); + } + + if (data.statuses) { + dispatch(importFetchedStatuses(data.statuses)); + } + + dispatch(expandSearchSuccess(data, value, type)); + dispatch(fetchRelationships(data.accounts.map(item => item.id))); + }).catch(error => { + dispatch(expandSearchFail(error)); + }); }; + +export const expandSearchRequest = () => ({ + type: SEARCH_EXPAND_REQUEST, +}); + +export const expandSearchSuccess = (results, searchTerm, searchType) => ({ + type: SEARCH_EXPAND_SUCCESS, + results, + searchTerm, + searchType, +}); + +export const expandSearchFail = error => ({ + type: SEARCH_EXPAND_FAIL, + error, +}); + +export const showSearch = () => ({ + type: SEARCH_SHOW, +}); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 06c21b96b..bc2ac5e82 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -1,6 +1,8 @@ import { importFetchedStatus, importFetchedStatuses } from './importer'; -import api, { getLinks } from '../api'; +import api, { getLinks } from 'mastodon/api'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import compareId from 'mastodon/compare_id'; +import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; export const TIMELINE_DELETE = 'TIMELINE_DELETE'; @@ -10,10 +12,15 @@ export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; -export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; +export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; +export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING'; +export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; +export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; -export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; -export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; +export const loadPending = timeline => ({ + type: TIMELINE_LOAD_PENDING, + timeline, +}); export function updateTimeline(timeline, status, accept) { return dispatch => { @@ -27,6 +34,7 @@ export function updateTimeline(timeline, status, accept) { type: TIMELINE_UPDATE, timeline, status, + usePendingItems: preferPendingItems, }); }; }; @@ -71,8 +79,15 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { return; } - if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) { - params.since_id = timeline.getIn(['items', 0]); + if (!params.max_id && !params.pinned && (timeline.get('items', ImmutableList()).size + timeline.get('pendingItems', ImmutableList()).size) > 0) { + const a = timeline.getIn(['pendingItems', 0]); + const b = timeline.getIn(['items', 0]); + + if (a && b && compareId(a, b) > 0) { + params.since_id = a; + } else { + params.since_id = b || a; + } } const isLoadingRecent = !!params.since_id; @@ -82,7 +97,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); - dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore)); + dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); done(); }).catch(error => { dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); @@ -115,7 +130,7 @@ export function expandTimelineRequest(timeline, isLoadingMore) { }; }; -export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore) { +export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore, usePendingItems) { return { type: TIMELINE_EXPAND_SUCCESS, timeline, @@ -123,6 +138,7 @@ export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadi next, partial, isLoadingRecent, + usePendingItems, skipLoading: !isLoadingMore, }; }; @@ -151,9 +167,8 @@ export function connectTimeline(timeline) { }; }; -export function disconnectTimeline(timeline) { - return { - type: TIMELINE_DISCONNECT, - timeline, - }; -}; +export const disconnectTimeline = timeline => ({ + type: TIMELINE_DISCONNECT, + timeline, + usePendingItems: preferPendingItems, +}); diff --git a/app/javascript/mastodon/actions/trends.js b/app/javascript/mastodon/actions/trends.js new file mode 100644 index 000000000..853e4f60a --- /dev/null +++ b/app/javascript/mastodon/actions/trends.js @@ -0,0 +1,32 @@ +import api from '../api'; + +export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST'; +export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS'; +export const TRENDS_FETCH_FAIL = 'TRENDS_FETCH_FAIL'; + +export const fetchTrends = () => (dispatch, getState) => { + dispatch(fetchTrendsRequest()); + + api(getState) + .get('/api/v1/trends') + .then(({ data }) => dispatch(fetchTrendsSuccess(data))) + .catch(err => dispatch(fetchTrendsFail(err))); +}; + +export const fetchTrendsRequest = () => ({ + type: TRENDS_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchTrendsSuccess = trends => ({ + type: TRENDS_FETCH_SUCCESS, + trends, + skipLoading: true, +}); + +export const fetchTrendsFail = error => ({ + type: TRENDS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); diff --git a/app/javascript/mastodon/compare_id.js b/app/javascript/mastodon/compare_id.js index aaff66481..66cf51c4b 100644 --- a/app/javascript/mastodon/compare_id.js +++ b/app/javascript/mastodon/compare_id.js @@ -1,10 +1,11 @@ -export default function compareId(id1, id2) { +export default function compareId (id1, id2) { if (id1 === id2) { return 0; } + if (id1.length === id2.length) { return id1 > id2 ? 1 : -1; } else { return id1.length > id2.length ? 1 : -1; } -} +}; diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.js b/app/javascript/mastodon/components/autosuggest_hashtag.js new file mode 100644 index 000000000..e2f4e320d --- /dev/null +++ b/app/javascript/mastodon/components/autosuggest_hashtag.js @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { shortNumberFormat } from 'mastodon/utils/numbers'; +import { FormattedMessage } from 'react-intl'; + +export default class AutosuggestHashtag extends React.PureComponent { + + static propTypes = { + tag: PropTypes.shape({ + name: PropTypes.string.isRequired, + url: PropTypes.string, + history: PropTypes.array, + }).isRequired, + }; + + render () { + const { tag } = this.props; + const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0)); + + return ( +
+
#{tag.name}
+ {tag.history !== undefined &&
} +
+ ); + } + +} diff --git a/app/javascript/mastodon/components/autosuggest_input.js b/app/javascript/mastodon/components/autosuggest_input.js index c7d965b53..6d2035add 100644 --- a/app/javascript/mastodon/components/autosuggest_input.js +++ b/app/javascript/mastodon/components/autosuggest_input.js @@ -1,6 +1,7 @@ import React from 'react'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import AutosuggestEmoji from './autosuggest_emoji'; +import AutosuggestHashtag from './autosuggest_hashtag'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { isRtl } from '../rtl'; @@ -167,15 +168,15 @@ export default class AutosuggestInput extends ImmutablePureComponent { const { selectedSuggestion } = this.state; let inner, key; - if (typeof suggestion === 'object') { + if (suggestion.type === 'emoji') { inner = ; key = suggestion.id; - } else if (suggestion[0] === '#') { - inner = suggestion; - key = suggestion; - } else { - inner = ; - key = suggestion; + } else if (suggestion.type ==='hashtag') { + inner = ; + key = suggestion.name; + } else if (suggestion.type === 'account') { + inner = ; + key = suggestion.id; } return ( diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index b070fe3e5..ac2a6366a 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -1,6 +1,7 @@ import React from 'react'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import AutosuggestEmoji from './autosuggest_emoji'; +import AutosuggestHashtag from './autosuggest_hashtag'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { isRtl } from '../rtl'; @@ -173,15 +174,15 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { const { selectedSuggestion } = this.state; let inner, key; - if (typeof suggestion === 'object') { + if (suggestion.type === 'emoji') { inner = ; key = suggestion.id; - } else if (suggestion[0] === '#') { - inner = suggestion; - key = suggestion; - } else { - inner = ; - key = suggestion; + } else if (suggestion.type === 'hashtag') { + inner = ; + key = suggestion.name; + } else if (suggestion.type === 'account') { + inner = ; + key = suggestion.id; } return ( diff --git a/app/javascript/mastodon/components/avatar_composite.js b/app/javascript/mastodon/components/avatar_composite.js index 4a9a73c51..5d5b89749 100644 --- a/app/javascript/mastodon/components/avatar_composite.js +++ b/app/javascript/mastodon/components/avatar_composite.js @@ -35,35 +35,35 @@ export default class AvatarComposite extends React.PureComponent { if (size === 2) { if (index === 0) { - right = '2px'; + right = '1px'; } else { - left = '2px'; + left = '1px'; } } else if (size === 3) { if (index === 0) { - right = '2px'; + right = '1px'; } else if (index > 0) { - left = '2px'; + left = '1px'; } if (index === 1) { - bottom = '2px'; + bottom = '1px'; } else if (index > 1) { - top = '2px'; + top = '1px'; } } else if (size === 4) { if (index === 0 || index === 2) { - right = '2px'; + right = '1px'; } if (index === 1 || index === 3) { - left = '2px'; + left = '1px'; } if (index < 2) { - bottom = '2px'; + bottom = '1px'; } else { - top = '2px'; + top = '1px'; } } @@ -88,7 +88,13 @@ export default class AvatarComposite extends React.PureComponent { return (
- {accounts.take(4).map((account, i) => this.renderItem(account, accounts.size, i))} + {accounts.take(4).map((account, i) => this.renderItem(account, Math.min(accounts.size, 4), i))} + + {accounts.size > 4 && ( + + +{accounts.size - 4} + + )}
); } diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js index d45387463..55e3bfd5e 100644 --- a/app/javascript/mastodon/components/column.js +++ b/app/javascript/mastodon/components/column.js @@ -8,10 +8,11 @@ export default class Column extends React.PureComponent { static propTypes = { children: PropTypes.node, label: PropTypes.string, + bindToDocument: PropTypes.bool, }; scrollTop () { - const scrollable = this.node.querySelector('.scrollable'); + const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable'); if (!scrollable) { return; @@ -33,11 +34,19 @@ export default class Column extends React.PureComponent { } componentDidMount () { - this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + if (this.props.bindToDocument) { + document.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } else { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } } componentWillUnmount () { - this.node.removeEventListener('wheel', this.handleWheel); + if (this.props.bindToDocument) { + document.removeEventListener('wheel', this.handleWheel); + } else { + this.node.removeEventListener('wheel', this.handleWheel); + } } render () { diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js index f41045787..d97622705 100644 --- a/app/javascript/mastodon/components/column_back_button.js +++ b/app/javascript/mastodon/components/column_back_button.js @@ -2,6 +2,7 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; import PropTypes from 'prop-types'; import Icon from 'mastodon/components/icon'; +import { createPortal } from 'react-dom'; export default class ColumnBackButton extends React.PureComponent { @@ -9,6 +10,10 @@ export default class ColumnBackButton extends React.PureComponent { router: PropTypes.object, }; + static propTypes = { + multiColumn: PropTypes.bool, + }; + handleClick = () => { if (window.history && window.history.length === 1) { this.context.router.history.push('/'); @@ -18,12 +23,32 @@ export default class ColumnBackButton extends React.PureComponent { } render () { - return ( + const { multiColumn } = this.props; + + const component = ( ); + + if (multiColumn) { + return component; + } else { + // The portal container and the component may be rendered to the DOM in + // the same React render pass, so the container might not be available at + // the time `render()` is called. + const container = document.getElementById('tabs-bar__portal'); + if (container === null) { + // The container wasn't available, force a re-render so that the + // component can eventually be inserted in the container and not scroll + // with the rest of the area. + this.forceUpdate(); + return component; + } else { + return createPortal(component, container); + } + } } } diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index f33c689e7..0038995c8 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { createPortal } from 'react-dom'; import classNames from 'classnames'; import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import Icon from 'mastodon/components/icon'; @@ -28,6 +29,7 @@ class ColumnHeader extends React.PureComponent { showBackButton: PropTypes.bool, children: PropTypes.node, pinned: PropTypes.bool, + placeholder: PropTypes.bool, onPin: PropTypes.func, onMove: PropTypes.func, onClick: PropTypes.func, @@ -79,7 +81,7 @@ class ColumnHeader extends React.PureComponent { } render () { - const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage } } = this.props; + const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder } = this.props; const { collapsed, animating } = this.state; const wrapperClassName = classNames('column-header__wrapper', { @@ -118,7 +120,7 @@ class ColumnHeader extends React.PureComponent { ); - } else if (multiColumn) { + } else if (multiColumn && this.props.onPin) { pinButton = ; } @@ -140,13 +142,13 @@ class ColumnHeader extends React.PureComponent { collapsedContent.push(pinButton); } - if (children || multiColumn) { + if (children || (multiColumn && this.props.onPin)) { collapseButton = ; } const hasTitle = icon && title; - return ( + const component = (

{hasTitle && ( @@ -172,6 +174,24 @@ class ColumnHeader extends React.PureComponent {

); + + if (multiColumn || placeholder) { + return component; + } else { + // The portal container and the component may be rendered to the DOM in + // the same React render pass, so the container might not be available at + // the time `render()` is called. + const container = document.getElementById('tabs-bar__portal'); + if (container === null) { + // The container wasn't available, force a re-render so that the + // component can eventually be inserted in the container and not scroll + // with the rest of the area. + this.forceUpdate(); + return component; + } else { + return createPortal(component, container); + } + } } } diff --git a/app/javascript/mastodon/components/error_boundary.js b/app/javascript/mastodon/components/error_boundary.js index d1ca5bf75..82543e118 100644 --- a/app/javascript/mastodon/components/error_boundary.js +++ b/app/javascript/mastodon/components/error_boundary.js @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import illustration from '../../images/elephant_ui_disappointed.svg'; +import { FormattedMessage } from 'react-intl'; +import { version, source_url } from 'mastodon/initial_state'; export default class ErrorBoundary extends React.PureComponent { @@ -12,26 +13,53 @@ export default class ErrorBoundary extends React.PureComponent { hasError: false, stackTrace: undefined, componentStack: undefined, - } + }; - componentDidCatch(error, info) { + componentDidCatch (error, info) { this.setState({ hasError: true, stackTrace: error.stack, componentStack: info && info.componentStack, + copied: false, }); } + handleCopyStackTrace = () => { + const { stackTrace } = this.state; + const textarea = document.createElement('textarea'); + + textarea.textContent = stackTrace; + textarea.style.position = 'fixed'; + + document.body.appendChild(textarea); + + try { + textarea.select(); + document.execCommand('copy'); + } catch (e) { + + } finally { + document.body.removeChild(textarea); + } + + this.setState({ copied: true }); + setTimeout(() => this.setState({ copied: false }), 700); + } + render() { - const { hasError } = this.state; + const { hasError, copied } = this.state; if (!hasError) { return this.props.children; } return ( -
- +
+
+

+

+

Mastodon v{version} · ·

+
); } diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js deleted file mode 100644 index 009c0d559..000000000 --- a/app/javascript/mastodon/components/extended_video_player.js +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -export default class ExtendedVideoPlayer extends React.PureComponent { - - static propTypes = { - src: PropTypes.string.isRequired, - alt: PropTypes.string, - width: PropTypes.number, - height: PropTypes.number, - time: PropTypes.number, - controls: PropTypes.bool.isRequired, - muted: PropTypes.bool.isRequired, - onClick: PropTypes.func, - }; - - handleLoadedData = () => { - if (this.props.time) { - this.video.currentTime = this.props.time; - } - } - - componentDidMount () { - this.video.addEventListener('loadeddata', this.handleLoadedData); - } - - componentWillUnmount () { - this.video.removeEventListener('loadeddata', this.handleLoadedData); - } - - setRef = (c) => { - this.video = c; - } - - handleClick = e => { - e.stopPropagation(); - const handler = this.props.onClick; - if (handler) handler(); - } - - render () { - const { src, muted, controls, alt } = this.props; - - return ( -
-
- ); - } - -} diff --git a/app/javascript/mastodon/components/gifv.js b/app/javascript/mastodon/components/gifv.js new file mode 100644 index 000000000..83cfae49c --- /dev/null +++ b/app/javascript/mastodon/components/gifv.js @@ -0,0 +1,75 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class GIFV extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + }; + + state = { + loading: true, + }; + + handleLoadedData = () => { + this.setState({ loading: false }); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.src !== this.props.src) { + this.setState({ loading: true }); + } + } + + handleClick = e => { + const { onClick } = this.props; + + if (onClick) { + e.stopPropagation(); + onClick(); + } + } + + render () { + const { src, width, height, alt } = this.props; + const { loading } = this.state; + + return ( +
+ {loading && ( + + )} + +
+ ); + } + +} diff --git a/app/javascript/mastodon/components/hashtag.js b/app/javascript/mastodon/components/hashtag.js index f091d7893..62d613262 100644 --- a/app/javascript/mastodon/components/hashtag.js +++ b/app/javascript/mastodon/components/hashtag.js @@ -12,11 +12,11 @@ const Hashtag = ({ hashtag }) => ( #{hashtag.get('name')} - {shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))} }} /> + {shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)} }} />
- {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))} + {shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)}
diff --git a/app/javascript/mastodon/components/load_pending.js b/app/javascript/mastodon/components/load_pending.js new file mode 100644 index 000000000..7e2702403 --- /dev/null +++ b/app/javascript/mastodon/components/load_pending.js @@ -0,0 +1,22 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import PropTypes from 'prop-types'; + +export default class LoadPending extends React.PureComponent { + + static propTypes = { + onClick: PropTypes.func, + count: PropTypes.number, + } + + render() { + const { count } = this.props; + + return ( + + ); + } + +} diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 62032344a..1bedfff69 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -6,7 +6,7 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from '../is_mobile'; import classNames from 'classnames'; -import { autoPlayGif, displayMedia } from '../initial_state'; +import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state'; import { decode } from 'blurhash'; const messages = defineMessages({ @@ -81,6 +81,8 @@ class Item extends React.PureComponent { } _decode () { + if (!useBlurhash) return; + const hash = this.props.attachment.get('blurhash'); const pixels = decode(hash, 32, 32); @@ -157,7 +159,7 @@ class Item extends React.PureComponent { if (attachment.get('type') === 'unknown') { return ( @@ -312,15 +314,22 @@ class MediaGallery extends React.PureComponent { style.height = height; } - const size = media.take(4).size; + const size = media.take(4).size; + const uncached = media.every(attachment => attachment.get('type') === 'unknown'); if (this.isStandaloneEligible()) { children = ; } else { - children = media.take(4).map((attachment, i) => ); + children = media.take(4).map((attachment, i) => ); } - if (visible) { + if (uncached) { + spoilerButton = ( + + ); + } else if (visible) { spoilerButton = ; } else { spoilerButton = ( @@ -332,7 +341,7 @@ class MediaGallery extends React.PureComponent { return (
-
+
{spoilerButton}
diff --git a/app/javascript/mastodon/components/missing_indicator.js b/app/javascript/mastodon/components/missing_indicator.js index 70d8c3b98..7b0101bab 100644 --- a/app/javascript/mastodon/components/missing_indicator.js +++ b/app/javascript/mastodon/components/missing_indicator.js @@ -1,17 +1,24 @@ import React from 'react'; +import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; +import illustration from 'mastodon/../images/elephant_ui_disappointed.svg'; +import classNames from 'classnames'; -const MissingIndicator = () => ( -
-
-
+const MissingIndicator = ({ fullPage }) => ( +
+
+ +
-
- - -
+
+ +
); +MissingIndicator.propTypes = { + fullPage: PropTypes.bool, +}; + export default MissingIndicator; diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js index 690f9ae5a..cdbcf8f70 100644 --- a/app/javascript/mastodon/components/poll.js +++ b/app/javascript/mastodon/components/poll.js @@ -10,9 +10,11 @@ import spring from 'react-motion/lib/spring'; import escapeTextContentForBrowser from 'escape-html'; import emojify from 'mastodon/features/emoji/emoji'; import RelativeTimestamp from './relative_timestamp'; +import Icon from 'mastodon/components/icon'; const messages = defineMessages({ closed: { id: 'poll.closed', defaultMessage: 'Closed' }, + voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer', description: 'Tooltip of the "voted" checkmark in polls' }, }); const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => { @@ -32,8 +34,38 @@ class Poll extends ImmutablePureComponent { state = { selected: {}, + expired: null, }; + static getDerivedStateFromProps (props, state) { + const { poll, intl } = props; + const expired = poll.get('expired') || (new Date(poll.get('expires_at'))).getTime() < intl.now(); + return (expired === state.expired) ? null : { expired }; + } + + componentDidMount () { + this._setupTimer(); + } + + componentDidUpdate () { + this._setupTimer(); + } + + componentWillUnmount () { + clearTimeout(this._timer); + } + + _setupTimer () { + const { poll, intl } = this.props; + clearTimeout(this._timer); + if (!this.state.expired) { + const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now(); + this._timer = setTimeout(() => { + this.setState({ expired: true }); + }, delay); + } + } + handleOptionChange = e => { const { target: { value } } = e; @@ -68,12 +100,13 @@ class Poll extends ImmutablePureComponent { this.props.dispatch(fetchPoll(this.props.poll.get('id'))); }; - renderOption (option, optionIndex) { - const { poll, disabled } = this.props; - const percent = poll.get('votes_count') === 0 ? 0 : (option.get('votes_count') / poll.get('votes_count')) * 100; - const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') > other.get('votes_count')); - const active = !!this.state.selected[`${optionIndex}`]; - const showResults = poll.get('voted') || poll.get('expired'); + renderOption (option, optionIndex, showResults) { + const { poll, disabled, intl } = this.props; + const pollVotesCount = poll.get('voters_count') || poll.get('votes_count'); + const percent = pollVotesCount === 0 ? 0 : (option.get('votes_count') / pollVotesCount) * 100; + const leading = poll.get('options').filterNot(other => other.get('title') === option.get('title')).every(other => option.get('votes_count') >= other.get('votes_count')); + const active = !!this.state.selected[`${optionIndex}`]; + const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex)); let titleEmojified = option.get('title_emojified'); if (!titleEmojified) { @@ -102,7 +135,10 @@ class Poll extends ImmutablePureComponent { /> {!showResults && } - {showResults && {Math.round(percent)}%} + {showResults && + {!!voted && } + {Math.round(percent)}% + } @@ -112,25 +148,34 @@ class Poll extends ImmutablePureComponent { render () { const { poll, intl } = this.props; + const { expired } = this.state; if (!poll) { return null; } - const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : ; - const showResults = poll.get('voted') || poll.get('expired'); + const timeRemaining = expired ? intl.formatMessage(messages.closed) : ; + const showResults = poll.get('voted') || expired; const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item); + let votesCount = null; + + if (poll.get('voters_count') !== null && poll.get('voters_count') !== undefined) { + votesCount = ; + } else { + votesCount = ; + } + return (
    - {poll.get('options').map((option, i) => this.renderOption(option, i))} + {poll.get('options').map((option, i) => this.renderOption(option, i, showResults))}
{!showResults && } {showResults && !this.props.disabled && · } - + {votesCount} {poll.get('expires_at') && · {timeRemaining}}
diff --git a/app/javascript/mastodon/components/radio_button.js b/app/javascript/mastodon/components/radio_button.js new file mode 100644 index 000000000..0496fa286 --- /dev/null +++ b/app/javascript/mastodon/components/radio_button.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class RadioButton extends React.PureComponent { + + static propTypes = { + value: PropTypes.string.isRequired, + checked: PropTypes.bool, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + label: PropTypes.node.isRequired, + }; + + render () { + const { name, value, checked, onChange, label } = this.props; + + return ( + + ); + } + +} diff --git a/app/javascript/mastodon/components/regeneration_indicator.js b/app/javascript/mastodon/components/regeneration_indicator.js new file mode 100644 index 000000000..faf88c6b5 --- /dev/null +++ b/app/javascript/mastodon/components/regeneration_indicator.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import illustration from 'mastodon/../images/elephant_ui_working.svg'; + +const MissingIndicator = () => ( +
+
+ +
+ +
+ + +
+
+); + +export default MissingIndicator; diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js index 0376cf85a..421756803 100644 --- a/app/javascript/mastodon/components/scrollable_list.js +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -3,6 +3,7 @@ import { ScrollContainer } from 'react-router-scroll-4'; import PropTypes from 'prop-types'; import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; import LoadMore from './load_more'; +import LoadPending from './load_pending'; import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; import { throttle } from 'lodash'; import { List as ImmutableList } from 'immutable'; @@ -21,6 +22,7 @@ export default class ScrollableList extends PureComponent { static propTypes = { scrollKey: PropTypes.string.isRequired, onLoadMore: PropTypes.func, + onLoadPending: PropTypes.func, onScrollToTop: PropTypes.func, onScroll: PropTypes.func, trackScroll: PropTypes.bool, @@ -28,10 +30,12 @@ export default class ScrollableList extends PureComponent { isLoading: PropTypes.bool, showLoading: PropTypes.bool, hasMore: PropTypes.bool, + numPending: PropTypes.number, prepend: PropTypes.node, alwaysPrepend: PropTypes.bool, emptyMessage: PropTypes.node, children: PropTypes.node, + bindToDocument: PropTypes.bool, }; static defaultProps = { @@ -47,7 +51,9 @@ export default class ScrollableList extends PureComponent { handleScroll = throttle(() => { if (this.node) { - const { scrollTop, scrollHeight, clientHeight } = this.node; + const scrollTop = this.getScrollTop(); + const scrollHeight = this.getScrollHeight(); + const clientHeight = this.getClientHeight(); const offset = scrollHeight - scrollTop - clientHeight; if (400 > offset && this.props.onLoadMore && this.props.hasMore && !this.props.isLoading) { @@ -77,9 +83,14 @@ export default class ScrollableList extends PureComponent { scrollToTopOnMouseIdle = false; setScrollTop = newScrollTop => { - if (this.node.scrollTop !== newScrollTop) { + if (this.getScrollTop() !== newScrollTop) { this.lastScrollWasSynthetic = true; - this.node.scrollTop = newScrollTop; + + if (this.props.bindToDocument) { + document.scrollingElement.scrollTop = newScrollTop; + } else { + this.node.scrollTop = newScrollTop; + } } }; @@ -97,7 +108,7 @@ export default class ScrollableList extends PureComponent { this.clearMouseIdleTimer(); this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY); - if (!this.mouseMovedRecently && this.node.scrollTop === 0) { + if (!this.mouseMovedRecently && this.getScrollTop() === 0) { // Only set if we just started moving and are scrolled to the top. this.scrollToTopOnMouseIdle = true; } @@ -132,15 +143,27 @@ export default class ScrollableList extends PureComponent { } getScrollPosition = () => { - if (this.node && (this.node.scrollTop > 0 || this.mouseMovedRecently)) { - return { height: this.node.scrollHeight, top: this.node.scrollTop }; + if (this.node && (this.getScrollTop() > 0 || this.mouseMovedRecently)) { + return { height: this.getScrollHeight(), top: this.getScrollTop() }; } else { return null; } } + getScrollTop = () => { + return this.props.bindToDocument ? document.scrollingElement.scrollTop : this.node.scrollTop; + } + + getScrollHeight = () => { + return this.props.bindToDocument ? document.scrollingElement.scrollHeight : this.node.scrollHeight; + } + + getClientHeight = () => { + return this.props.bindToDocument ? document.scrollingElement.clientHeight : this.node.clientHeight; + } + updateScrollBottom = (snapshot) => { - const newScrollTop = this.node.scrollHeight - snapshot; + const newScrollTop = this.getScrollHeight() - snapshot; this.setScrollTop(newScrollTop); } @@ -149,9 +172,10 @@ export default class ScrollableList extends PureComponent { const someItemInserted = React.Children.count(prevProps.children) > 0 && React.Children.count(prevProps.children) < React.Children.count(this.props.children) && this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props); + const pendingChanged = (prevProps.numPending > 0) !== (this.props.numPending > 0); - if (someItemInserted && (this.node.scrollTop > 0 || this.mouseMovedRecently)) { - return this.node.scrollHeight - this.node.scrollTop; + if (pendingChanged || someItemInserted && (this.getScrollTop() > 0 || this.mouseMovedRecently)) { + return this.getScrollHeight() - this.getScrollTop(); } else { return null; } @@ -161,7 +185,7 @@ export default class ScrollableList extends PureComponent { // Reset the scroll position when a new child comes in in order not to // jerk the scrollbar around if you're already scrolled down the page. if (snapshot !== null) { - this.setScrollTop(this.node.scrollHeight - snapshot); + this.setScrollTop(this.getScrollHeight() - snapshot); } } @@ -175,6 +199,7 @@ export default class ScrollableList extends PureComponent { this.clearMouseIdleTimer(); this.detachScrollListener(); this.detachIntersectionObserver(); + detachFullscreenListener(this.onFullScreenChange); } @@ -194,13 +219,23 @@ export default class ScrollableList extends PureComponent { } attachScrollListener () { - this.node.addEventListener('scroll', this.handleScroll); - this.node.addEventListener('wheel', this.handleWheel); + if (this.props.bindToDocument) { + document.addEventListener('scroll', this.handleScroll); + document.addEventListener('wheel', this.handleWheel); + } else { + this.node.addEventListener('scroll', this.handleScroll); + this.node.addEventListener('wheel', this.handleWheel); + } } detachScrollListener () { - this.node.removeEventListener('scroll', this.handleScroll); - this.node.removeEventListener('wheel', this.handleWheel); + if (this.props.bindToDocument) { + document.removeEventListener('scroll', this.handleScroll); + document.removeEventListener('wheel', this.handleWheel); + } else { + this.node.removeEventListener('scroll', this.handleScroll); + this.node.removeEventListener('wheel', this.handleWheel); + } } getFirstChildKey (props) { @@ -225,12 +260,25 @@ export default class ScrollableList extends PureComponent { this.props.onLoadMore(); } + handleLoadPending = e => { + e.preventDefault(); + this.props.onLoadPending(); + // Prevent the weird scroll-jumping behavior, as we explicitly don't want to + // scroll to top, and we know the scroll height is going to change + this.scrollToTopOnMouseIdle = false; + this.lastScrollWasSynthetic = false; + this.clearMouseIdleTimer(); + this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY); + this.mouseMovedRecently = true; + } + render () { - const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props; + const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props; const { fullscreen } = this.state; const childrenCount = React.Children.count(children); const loadMore = (hasMore && onLoadMore) ? : null; + const loadPending = (numPending > 0) ? : null; let scrollableArea = null; if (showLoading) { @@ -251,6 +299,8 @@ export default class ScrollableList extends PureComponent {
{prepend} + {loadPending} + {React.Children.map(this.props.children, (child, index) => ( ; + return
; } renderLoadingVideoPlayer () { - return
; + return
; + } + + renderLoadingAudioPlayer () { + return
; } handleOpenVideo = (media, startTime) => { @@ -278,12 +282,27 @@ class Status extends ImmutablePureComponent { return null; } + const handlers = this.props.muted ? {} : { + reply: this.handleHotkeyReply, + favourite: this.handleHotkeyFavourite, + boost: this.handleHotkeyBoost, + mention: this.handleHotkeyMention, + open: this.handleHotkeyOpen, + openProfile: this.handleHotkeyOpenProfile, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + toggleHidden: this.handleHotkeyToggleHidden, + toggleSensitive: this.handleHotkeyToggleSensitive, + }; + if (hidden) { return ( -
- {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} - {status.get('content')} -
+ +
+ {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} + {status.get('content')} +
+
); } @@ -333,7 +352,23 @@ class Status extends ImmutablePureComponent { media={status.get('media_attachments')} /> ); - } else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) { + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + + {Component => ( + + )} + + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { const attachment = status.getIn(['media_attachments', 0]); media = ( @@ -394,19 +429,6 @@ class Status extends ImmutablePureComponent { statusAvatar = ; } - const handlers = this.props.muted ? {} : { - reply: this.handleHotkeyReply, - favourite: this.handleHotkeyFavourite, - boost: this.handleHotkeyBoost, - mention: this.handleHotkeyMention, - open: this.handleHotkeyOpen, - openProfile: this.handleHotkeyOpenProfile, - moveUp: this.handleHotkeyMoveUp, - moveDown: this.handleHotkeyMoveDown, - toggleHidden: this.handleHotkeyToggleHidden, - toggleSensitive: this.handleHotkeyToggleSensitive, - }; - return (
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index ba6d3c296..4ce9ec49f 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -55,6 +55,7 @@ export default class StatusContent extends React.PureComponent { link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); } else { link.setAttribute('title', link.href); + link.classList.add('unhandled-link'); } link.setAttribute('target', '_blank'); @@ -111,7 +112,7 @@ export default class StatusContent extends React.PureComponent { } onHashtagClick = (hashtag, e) => { - hashtag = hashtag.replace(/^#/, '').toLowerCase(); + hashtag = hashtag.replace(/^#/, ''); if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); @@ -215,22 +216,22 @@ export default class StatusContent extends React.PureComponent { return (
{mentionsPlaceholder} -
+
{!hidden && !!status.get('poll') && }
); } else if (this.props.onClick) { const output = [ -
-
+
+
{!!status.get('poll') && }
, @@ -244,7 +245,7 @@ export default class StatusContent extends React.PureComponent { } else { return (
-
+
{!!status.get('poll') && }
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 745e6422d..e1b370c91 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -1,12 +1,12 @@ import { debounce } from 'lodash'; import React from 'react'; -import { FormattedMessage } from 'react-intl'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import StatusContainer from '../containers/status_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; import LoadGap from './load_gap'; import ScrollableList from './scrollable_list'; +import RegenerationIndicator from 'mastodon/components/regeneration_indicator'; export default class StatusList extends ImmutablePureComponent { @@ -81,18 +81,7 @@ export default class StatusList extends ImmutablePureComponent { const { isLoading, isPartial } = other; if (isPartial) { - return ( -
-
-
- -
- - -
-
-
- ); + return ; } let scrollableContent = (isLoading || statusIds.size > 0) ? ( diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 542b68282..3ac58cf7c 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -12,6 +12,8 @@ import { hydrateStore } from '../actions/store'; import { connectUserStream } from '../actions/streaming'; import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from '../locales'; +import { previewState as previewMediaState } from 'mastodon/features/ui/components/media_modal'; +import { previewState as previewVideoState } from 'mastodon/features/ui/components/video_modal'; import initialState from '../initial_state'; import ErrorBoundary from '../components/error_boundary'; @@ -35,6 +37,10 @@ class MastodonMount extends React.PureComponent { showIntroduction: PropTypes.bool, }; + shouldUpdateScroll (_, { location }) { + return location.state !== previewMediaState && location.state !== previewVideoState; + } + render () { const { showIntroduction } = this.props; @@ -44,7 +50,7 @@ class MastodonMount extends React.PureComponent { return ( - + diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js index 51d4f0fed..ba55ecbc7 100644 --- a/app/javascript/mastodon/containers/media_container.js +++ b/app/javascript/mastodon/containers/media_container.js @@ -2,19 +2,22 @@ import React, { PureComponent, Fragment } from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import { IntlProvider, addLocaleData } from 'react-intl'; -import { getLocale } from '../locales'; -import MediaGallery from '../components/media_gallery'; -import Video from '../features/video'; -import Card from '../features/status/components/card'; -import Poll from 'mastodon/components/poll'; -import ModalRoot from '../components/modal_root'; -import MediaModal from '../features/ui/components/media_modal'; import { List as ImmutableList, fromJS } from 'immutable'; +import { getLocale } from 'mastodon/locales'; +import { getScrollbarWidth } from 'mastodon/utils/scrollbar'; +import MediaGallery from 'mastodon/components/media_gallery'; +import Poll from 'mastodon/components/poll'; +import Hashtag from 'mastodon/components/hashtag'; +import ModalRoot from 'mastodon/components/modal_root'; +import MediaModal from 'mastodon/features/ui/components/media_modal'; +import Video from 'mastodon/features/video'; +import Card from 'mastodon/features/status/components/card'; +import Audio from 'mastodon/features/audio'; const { localeData, messages } = getLocale(); addLocaleData(localeData); -const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll }; +const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio }; export default class MediaContainer extends PureComponent { @@ -31,6 +34,8 @@ export default class MediaContainer extends PureComponent { handleOpenMedia = (media, index) => { document.body.classList.add('with-modals--active'); + document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; + this.setState({ media, index }); } @@ -38,11 +43,15 @@ export default class MediaContainer extends PureComponent { const media = ImmutableList([video]); document.body.classList.add('with-modals--active'); + document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; + this.setState({ media, time }); } handleCloseMedia = () => { document.body.classList.remove('with-modals--active'); + document.documentElement.style.marginRight = 0; + this.setState({ media: null, index: null, time: null }); } @@ -55,12 +64,13 @@ export default class MediaContainer extends PureComponent { {[].map.call(components, (component, i) => { const componentName = component.getAttribute('data-component'); const Component = MEDIA_COMPONENTS[componentName]; - const { media, card, poll, ...props } = JSON.parse(component.getAttribute('data-props')); + const { media, card, poll, hashtag, ...props } = JSON.parse(component.getAttribute('data-props')); Object.assign(props, { - ...(media ? { media: fromJS(media) } : {}), - ...(card ? { card: fromJS(card) } : {}), - ...(poll ? { poll: fromJS(poll) } : {}), + ...(media ? { media: fromJS(media) } : {}), + ...(card ? { card: fromJS(card) } : {}), + ...(poll ? { poll: fromJS(poll) } : {}), + ...(hashtag ? { hashtag: fromJS(hashtag) } : {}), ...(componentName === 'Video' ? { onOpenVideo: this.handleOpenVideo, @@ -74,6 +84,7 @@ export default class MediaContainer extends PureComponent { component, ); })} + {this.state.media && ( { @@ -56,6 +53,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onReply (status, router) { dispatch((_, getState) => { let state = getState(); + if (state.getIn(['compose', 'text']).trim().length !== 0) { dispatch(openModal('CONFIRM', { message: intl.formatMessage(messages.replyMessage), @@ -77,7 +75,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ }, onReblog (status, e) { - if (e.shiftKey || !boostModal) { + if ((e && e.shiftKey) || !boostModal) { this.onModalReblog(status); } else { dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); @@ -137,16 +135,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onBlock (status) { const account = status.get('account'); - dispatch(openModal('CONFIRM', { - message: @{account.get('acct')} }} />, - confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => dispatch(blockAccount(account.get('id'))), - secondary: intl.formatMessage(messages.blockAndReport), - onSecondary: () => { - dispatch(blockAccount(account.get('id'))); - dispatch(initReport(account, status)); - }, - })); + dispatch(initBlockModal(account)); }, onReport (status) { diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js index 2609b96ff..b6eec2243 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.js +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js @@ -96,6 +96,12 @@ export default class MediaItem extends ImmutablePureComponent { if (attachment.get('type') === 'unknown') { // Skip + } else if (attachment.get('type') === 'audio') { + thumbnail = ( + + + + ); } else if (attachment.get('type') === 'image') { const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0; const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0; @@ -113,6 +119,7 @@ export default class MediaItem extends ImmutablePureComponent { ); } else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) { const autoPlay = !isIOS() && autoPlayGif; + const label = attachment.get('type') === 'video' ? : 'GIF'; thumbnail = (
@@ -129,7 +136,7 @@ export default class MediaItem extends ImmutablePureComponent { muted /> - GIF + {label}
); } diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index 5d6a53e18..de481075c 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -56,6 +56,7 @@ class AccountGallery extends ImmutablePureComponent { isLoading: PropTypes.bool, hasMore: PropTypes.bool, isAccount: PropTypes.bool, + multiColumn: PropTypes.bool, }; state = { @@ -101,6 +102,8 @@ class AccountGallery extends ImmutablePureComponent { handleOpenMedia = attachment => { if (attachment.get('type') === 'video') { this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') })); + } else if (attachment.get('type') === 'audio') { + this.props.dispatch(openModal('AUDIO', { media: attachment, status: attachment.get('status') })); } else { const media = attachment.getIn(['status', 'media_attachments']); const index = media.findIndex(x => x.get('id') === attachment.get('id')); @@ -116,7 +119,7 @@ class AccountGallery extends ImmutablePureComponent { } render () { - const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props; + const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount, multiColumn } = this.props; const { width } = this.state; if (!isAccount) { @@ -143,7 +146,7 @@ class AccountGallery extends ImmutablePureComponent { return ( - +
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index 4d4ae6e82..8728b4806 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -5,7 +5,6 @@ import Header from '../components/header'; import { followAccount, unfollowAccount, - blockAccount, unblockAccount, unmuteAccount, pinAccount, @@ -16,6 +15,7 @@ import { directCompose, } from '../../../actions/compose'; import { initMuteModal } from '../../../actions/mutes'; +import { initBlockModal } from '../../../actions/blocks'; import { initReport } from '../../../actions/reports'; import { openModal } from '../../../actions/modal'; import { blockDomain, unblockDomain } from '../../../actions/domain_blocks'; @@ -25,9 +25,7 @@ import { List as ImmutableList } from 'immutable'; const messages = defineMessages({ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, - blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, - blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, }); const makeMapStateToProps = () => { @@ -64,16 +62,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ if (account.getIn(['relationship', 'blocking'])) { dispatch(unblockAccount(account.get('id'))); } else { - dispatch(openModal('CONFIRM', { - message: @{account.get('acct')} }} />, - confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => dispatch(blockAccount(account.get('id'))), - secondary: intl.formatMessage(messages.blockAndReport), - onSecondary: () => { - dispatch(blockAccount(account.get('id'))); - dispatch(initReport(account)); - }, - })); + dispatch(initBlockModal(account)); } }, diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index 27581bfdc..8d0cbe5a1 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -44,6 +44,7 @@ class AccountTimeline extends ImmutablePureComponent { withReplies: PropTypes.bool, blockedBy: PropTypes.bool, isAccount: PropTypes.bool, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -77,11 +78,12 @@ class AccountTimeline extends ImmutablePureComponent { } render () { - const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount } = this.props; + const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn } = this.props; if (!isAccount) { return ( + ); @@ -99,7 +101,7 @@ class AccountTimeline extends ImmutablePureComponent { return ( - + } @@ -112,6 +114,7 @@ class AccountTimeline extends ImmutablePureComponent { onLoadMore={this.handleLoadMore} shouldUpdateScroll={shouldUpdateScroll} emptyMessage={emptyMessage} + bindToDocument={!multiColumn} /> ); diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js new file mode 100644 index 000000000..95e5675f3 --- /dev/null +++ b/app/javascript/mastodon/features/audio/index.js @@ -0,0 +1,226 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import WaveSurfer from 'wavesurfer.js'; +import { defineMessages, injectIntl } from 'react-intl'; +import { formatTime } from 'mastodon/features/video'; +import Icon from 'mastodon/components/icon'; +import classNames from 'classnames'; +import { throttle } from 'lodash'; + +const messages = defineMessages({ + play: { id: 'video.play', defaultMessage: 'Play' }, + pause: { id: 'video.pause', defaultMessage: 'Pause' }, + mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, + unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, +}); + +export default @injectIntl +class Audio extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + duration: PropTypes.number, + peaks: PropTypes.arrayOf(PropTypes.number), + height: PropTypes.number, + preload: PropTypes.bool, + editable: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + state = { + currentTime: 0, + duration: null, + paused: true, + muted: false, + volume: 0.5, + }; + + // hard coded in components.scss + // any way to get ::before values programatically? + + volWidth = 50; + + volOffset = 70; + + volHandleOffset = v => { + const offset = v * this.volWidth + this.volOffset; + return (offset > 110) ? 110 : offset; + } + + setVolumeRef = c => { + this.volume = c; + } + + setWaveformRef = c => { + this.waveform = c; + } + + componentDidMount () { + if (this.waveform) { + this._updateWaveform(); + } + } + + componentDidUpdate (prevProps) { + if (this.waveform && prevProps.src !== this.props.src) { + this._updateWaveform(); + } + } + + componentWillUnmount () { + if (this.wavesurfer) { + this.wavesurfer.destroy(); + this.wavesurfer = null; + } + } + + _updateWaveform () { + const { src, height, duration, peaks, preload } = this.props; + + const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color'); + const waveColor = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color'); + + if (this.wavesurfer) { + this.wavesurfer.destroy(); + this.loaded = false; + } + + const wavesurfer = WaveSurfer.create({ + container: this.waveform, + height, + barWidth: 3, + cursorWidth: 0, + progressColor, + waveColor, + backend: 'MediaElement', + interact: preload, + }); + + wavesurfer.setVolume(this.state.volume); + + if (preload) { + wavesurfer.load(src); + this.loaded = true; + } else { + wavesurfer.load(src, peaks, 'none', duration); + this.loaded = false; + } + + wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) })); + wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) })); + wavesurfer.on('pause', () => this.setState({ paused: true })); + wavesurfer.on('play', () => this.setState({ paused: false })); + wavesurfer.on('volume', volume => this.setState({ volume })); + wavesurfer.on('mute', muted => this.setState({ muted })); + + this.wavesurfer = wavesurfer; + } + + togglePlay = () => { + if (this.state.paused) { + if (!this.props.preload && !this.loaded) { + this.wavesurfer.createBackend(); + this.wavesurfer.createPeakCache(); + this.wavesurfer.load(this.props.src); + this.wavesurfer.toggleInteraction(); + this.loaded = true; + } + + this.wavesurfer.play(); + this.setState({ paused: false }); + } else { + this.wavesurfer.pause(); + this.setState({ paused: true }); + } + } + + toggleMute = () => { + this.wavesurfer.setMute(!this.state.muted); + } + + handleVolumeMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseVolSlide, true); + document.addEventListener('mouseup', this.handleVolumeMouseUp, true); + document.addEventListener('touchmove', this.handleMouseVolSlide, true); + document.addEventListener('touchend', this.handleVolumeMouseUp, true); + + this.handleMouseVolSlide(e); + + e.preventDefault(); + e.stopPropagation(); + } + + handleVolumeMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseVolSlide, true); + document.removeEventListener('mouseup', this.handleVolumeMouseUp, true); + document.removeEventListener('touchmove', this.handleMouseVolSlide, true); + document.removeEventListener('touchend', this.handleVolumeMouseUp, true); + } + + handleMouseVolSlide = throttle(e => { + const rect = this.volume.getBoundingClientRect(); + const x = (e.clientX - rect.left) / this.volWidth; // x position within the element. + + if(!isNaN(x)) { + let slideamt = x; + + if (x > 1) { + slideamt = 1; + } else if(x < 0) { + slideamt = 0; + } + + this.wavesurfer.setVolume(slideamt); + } + }, 60); + + render () { + const { height, intl, alt, editable } = this.props; + const { paused, muted, volume, currentTime } = this.state; + + const volumeWidth = muted ? 0 : volume * this.volWidth; + const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume); + + return ( +
+
+
+ +
+ +
+
+
+ + + +
+
+ + +
+ + + {formatTime(currentTime)} + / + {formatTime(this.state.duration || Math.floor(this.props.duration))} + +
+
+
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js index 96a219c94..051431ed2 100644 --- a/app/javascript/mastodon/features/blocks/index.js +++ b/app/javascript/mastodon/features/blocks/index.js @@ -32,6 +32,7 @@ class Blocks extends ImmutablePureComponent { accountIds: ImmutablePropTypes.list, hasMore: PropTypes.bool, intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, }; componentWillMount () { @@ -43,7 +44,7 @@ class Blocks extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { intl, accountIds, shouldUpdateScroll, hasMore } = this.props; + const { intl, accountIds, shouldUpdateScroll, hasMore, multiColumn } = this.props; if (!accountIds) { return ( @@ -56,7 +57,7 @@ class Blocks extends ImmutablePureComponent { const emptyMessage = ; return ( - + {accountIds.map(id => diff --git a/app/javascript/mastodon/features/community_timeline/components/column_settings.js b/app/javascript/mastodon/features/community_timeline/components/column_settings.js index 8250190a7..0cb6db883 100644 --- a/app/javascript/mastodon/features/community_timeline/components/column_settings.js +++ b/app/javascript/mastodon/features/community_timeline/components/column_settings.js @@ -20,7 +20,7 @@ class ColumnSettings extends React.PureComponent { return (
- } /> + } />
); diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index 7d26c98b0..30153cc15 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -18,9 +18,10 @@ const mapStateToProps = (state, { onlyMedia, columnId }) => { const uuid = columnId; const columns = state.getIn(['settings', 'columns']); const index = columns.findIndex(c => c.get('uuid') === uuid); + const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]); return { - hasUnread: state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`, 'unread']) > 0, + hasUnread: !!timelineState && (timelineState.get('unread') > 0 || timelineState.get('pendingItems').size > 0), onlyMedia: (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']), }; }; @@ -105,7 +106,7 @@ class CommunityTimeline extends React.PureComponent { const pinned = !!columnId; return ( - + } shouldUpdateScroll={shouldUpdateScroll} + bindToDocument={!multiColumn} /> ); diff --git a/app/javascript/mastodon/features/compose/components/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js index d0303dbfb..dd2632796 100644 --- a/app/javascript/mastodon/features/compose/components/action_bar.js +++ b/app/javascript/mastodon/features/compose/components/action_bar.js @@ -23,9 +23,14 @@ class ActionBar extends React.PureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, + onLogout: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; + handleLogout = () => { + this.props.onLogout(); + } + render () { const { intl } = this.props; @@ -44,7 +49,7 @@ class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' }); menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' }); menu.push(null); - menu.push({ text: intl.formatMessage(messages.logout), href: '/auth/sign_out', target: null, method: 'delete' }); + menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout }); return (
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index c1429c756..e57c3c20c 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -6,7 +6,7 @@ import Overlay from 'react-overlays/lib/Overlay'; import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import detectPassiveEvents from 'detect-passive-events'; -import { buildCustomEmojis } from '../../emoji/emoji'; +import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji'; const messages = defineMessages({ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, @@ -31,19 +31,6 @@ let EmojiPicker, Emoji; // load asynchronously const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`; const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; -const categoriesSort = [ - 'recent', - 'custom', - 'people', - 'nature', - 'foods', - 'activity', - 'places', - 'objects', - 'symbols', - 'flags', -]; - class ModifierPickerMenu extends React.PureComponent { static propTypes = { @@ -241,8 +228,23 @@ class EmojiPickerMenu extends React.PureComponent { } const title = intl.formatMessage(messages.emoji); + const { modifierOpen } = this.state; + const categoriesSort = [ + 'recent', + 'people', + 'nature', + 'foods', + 'activity', + 'places', + 'objects', + 'symbols', + 'flags', + ]; + + categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort()); + return (
- +
); diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js index 7f9edfeee..3e36a922b 100644 --- a/app/javascript/mastodon/features/compose/components/search.js +++ b/app/javascript/mastodon/features/compose/components/search.js @@ -60,12 +60,17 @@ class Search extends React.PureComponent { onShow: PropTypes.func.isRequired, openInRoute: PropTypes.bool, intl: PropTypes.object.isRequired, + singleColumn: PropTypes.bool, }; state = { expanded: false, }; + setRef = c => { + this.searchForm = c; + } + handleChange = (e) => { this.props.onChange(e.target.value); } @@ -95,6 +100,13 @@ class Search extends React.PureComponent { handleFocus = () => { this.setState({ expanded: true }); this.props.onShow(); + + if (this.searchForm && !this.props.singleColumn) { + const { left, right } = this.searchForm.getBoundingClientRect(); + if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) { + this.searchForm.scrollIntoView(); + } + } } handleBlur = () => { @@ -111,6 +123,7 @@ class Search extends React.PureComponent {
); } @@ -76,6 +88,18 @@ class SearchResults extends ImmutablePureComponent {
{results.get('statuses').map(statusId => )} + + {results.get('statuses').size >= 5 && } +
+ ); + } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) { + statuses = ( +
+
+ +
+ +
); } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) { @@ -97,6 +121,8 @@ class SearchResults extends ImmutablePureComponent {
{results.get('hashtags').map(hashtag => )} + + {results.get('hashtags').size >= 5 && }
); } diff --git a/app/javascript/mastodon/features/compose/components/text_icon_button.js b/app/javascript/mastodon/features/compose/components/text_icon_button.js index 9c8ffab1f..f0b133538 100644 --- a/app/javascript/mastodon/features/compose/components/text_icon_button.js +++ b/app/javascript/mastodon/features/compose/components/text_icon_button.js @@ -1,6 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; +const iconStyle = { + height: null, + lineHeight: '27px', + width: `${18 * 1.28571429}px`, +}; + export default class TextIconButton extends React.PureComponent { static propTypes = { @@ -20,7 +26,14 @@ export default class TextIconButton extends React.PureComponent { const { label, title, active, ariaControls } = this.props; return ( - ); diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js index 629cbc36a..b9f0fbe3a 100644 --- a/app/javascript/mastodon/features/compose/components/upload.js +++ b/app/javascript/mastodon/features/compose/components/upload.js @@ -4,16 +4,11 @@ import PropTypes from 'prop-types'; import Motion from '../../ui/util/optional_motion'; import spring from 'react-motion/lib/spring'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; -const messages = defineMessages({ - description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, -}); - -export default @injectIntl -class Upload extends ImmutablePureComponent { +export default class Upload extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -21,30 +16,10 @@ class Upload extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, - intl: PropTypes.object.isRequired, onUndo: PropTypes.func.isRequired, - onDescriptionChange: PropTypes.func.isRequired, onOpenFocalPoint: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, }; - state = { - hovered: false, - focused: false, - dirtyDescription: null, - }; - - handleKeyDown = (e) => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - this.handleSubmit(); - } - } - - handleSubmit = () => { - this.handleInputBlur(); - this.props.onSubmit(this.context.router.history); - } - handleUndoClick = e => { e.stopPropagation(); this.props.onUndo(this.props.media.get('id')); @@ -55,69 +30,21 @@ class Upload extends ImmutablePureComponent { this.props.onOpenFocalPoint(this.props.media.get('id')); } - handleInputChange = e => { - this.setState({ dirtyDescription: e.target.value }); - } - - handleMouseEnter = () => { - this.setState({ hovered: true }); - } - - handleMouseLeave = () => { - this.setState({ hovered: false }); - } - - handleInputFocus = () => { - this.setState({ focused: true }); - } - - handleClick = () => { - this.setState({ focused: true }); - } - - handleInputBlur = () => { - const { dirtyDescription } = this.state; - - this.setState({ focused: false, dirtyDescription: null }); - - if (dirtyDescription !== null) { - this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription); - } - } - render () { - const { intl, media } = this.props; - const active = this.state.hovered || this.state.focused; - const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || ''; + const { media } = this.props; const focusX = media.getIn(['meta', 'focus', 'x']); const focusY = media.getIn(['meta', 'focus', 'y']); const x = ((focusX / 2) + .5) * 100; const y = ((focusY / -2) + .5) * 100; return ( -
+
{({ scale }) => (
-
+
- {media.get('type') === 'image' && } -
- -
-