From 2a779205b7920b1792cf46b48ea5001006412283 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?k=C9=9C=3A=CA=B3=20cybredragon?= Date: Sun, 24 Feb 2019 01:55:42 +0100 Subject: [PATCH] Merge 2.7.3 (#8) --- AUTHORS.md | 70 +- CHANGELOG.md | 58 ++ README.md | 2 +- app/chewy/statuses_index.rb | 4 +- .../admin/custom_emojis_controller.rb | 3 + app/controllers/admin/instances_controller.rb | 2 +- .../admin/reported_statuses_controller.rb | 4 + .../api/v1/apps/credentials_controller.rb | 2 +- .../auth/registrations_controller.rb | 1 + .../authorized_applications_controller.rb | 5 + app/helpers/admin/filter_helper.rb | 2 +- app/helpers/stream_entries_helper.rb | 2 +- app/javascript/mastodon/components/account.js | 2 +- .../mastodon/components/display_name.js | 22 +- app/javascript/mastodon/components/domain.js | 2 +- .../intersection_observer_article.js | 2 +- .../mastodon/components/media_gallery.js | 10 +- .../mastodon/components/scrollable_list.js | 28 +- app/javascript/mastodon/components/status.js | 69 +- .../mastodon/components/status_action_bar.js | 30 +- .../mastodon/containers/compose_container.js | 3 + .../features/account/components/header.js | 2 +- .../mastodon/features/blocks/index.js | 5 +- .../compose/components/compose_form.js | 2 +- .../compose/components/privacy_dropdown.js | 2 +- .../features/compose/components/upload.js | 3 +- .../mastodon/features/domain_blocks/index.js | 5 +- .../features/follow_requests/index.js | 5 +- .../components/column_settings.js | 55 +- .../features/hashtag_timeline/index.js | 17 +- .../mastodon/features/mutes/index.js | 5 +- .../notifications/components/notification.js | 32 +- .../features/status/components/card.js | 5 +- .../status/components/detailed_status.js | 2 +- .../mastodon/features/video/index.js | 5 +- app/javascript/packs/error.js | 13 + app/javascript/styles/contrast/diff.scss | 55 ++ app/javascript/styles/mastodon/_mixins.scss | 31 + app/javascript/styles/mastodon/about.scss | 11 +- app/javascript/styles/mastodon/basics.scss | 14 +- .../styles/mastodon/components.scss | 99 ++- app/lib/activity_tracker.rb | 6 +- app/lib/activitypub/activity.rb | 51 +- app/lib/activitypub/activity/announce.rb | 15 +- app/lib/activitypub/activity/create.rb | 37 +- app/lib/feed_manager.rb | 9 +- app/lib/formatter.rb | 49 +- app/lib/ostatus/activity/base.rb | 6 +- app/lib/potential_friendship_tracker.rb | 8 +- app/models/account_conversation.rb | 3 +- app/models/concerns/redisable.rb | 11 + app/models/domain_block.rb | 2 + app/models/feed.rb | 6 +- app/models/instance_filter.rb | 8 +- app/models/relay.rb | 2 + app/models/trending_tags.rb | 6 +- .../activitypub/activity_serializer.rb | 8 +- .../rest/application_serializer.rb | 6 +- app/serializers/rest/instance_serializer.rb | 6 +- .../activitypub/process_account_service.rb | 2 +- .../activitypub/process_collection_service.rb | 1 + app/services/batched_remove_status_service.rb | 5 +- app/services/follow_service.rb | 6 +- app/services/post_status_service.rb | 6 +- app/services/remove_status_service.rb | 19 +- app/services/suspend_account_service.rb | 10 +- app/validators/email_mx_validator.rb | 1 + app/views/admin/accounts/index.html.haml | 5 +- app/views/admin/accounts/show.html.haml | 6 + app/views/admin/change_emails/show.html.haml | 2 +- app/views/admin/instances/index.html.haml | 14 + app/views/layouts/error.html.haml | 7 +- .../low_priority_delivery_worker.rb | 5 + app/workers/activitypub/processing_worker.rb | 2 +- .../scheduler/feed_cleanup_scheduler.rb | 5 +- config/initializers/rack_attack.rb | 11 +- config/initializers/twitter_regex.rb | 4 +- config/locales/en.yml | 5 +- dist/mastodon-streaming.service | 2 +- lib/mastodon/version.rb | 6 +- public/oops.png | Bin 0 -> 20552 bytes public/robots.txt | 7 +- .../lib/activitypub/activity/announce_spec.rb | 160 +++- spec/lib/activitypub/activity/create_spec.rb | 738 ++++++++++-------- spec/lib/formatter_spec.rb | 84 +- spec/validators/email_mx_validator_spec.rb | 38 + 86 files changed, 1502 insertions(+), 579 deletions(-) create mode 100644 app/javascript/packs/error.js create mode 100644 app/models/concerns/redisable.rb create mode 100644 app/workers/activitypub/low_priority_delivery_worker.rb create mode 100644 public/oops.png diff --git a/AUTHORS.md b/AUTHORS.md index 1bcf455b1..3171214e0 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -9,18 +9,18 @@ and provided thanks to the work of the following contributors: * [akihikodaki](https://github.com/akihikodaki) * [ThibG](https://github.com/ThibG) * [mjankowski](https://github.com/mjankowski) +* [dependabot[bot]](https://github.com/apps/dependabot) * [unarist](https://github.com/unarist) * [m4sk1n](https://github.com/m4sk1n) -* [dependabot[bot]](https://github.com/apps/dependabot) * [yiskah](https://github.com/yiskah) * [nolanlawson](https://github.com/nolanlawson) -* [sorin-davidoi](https://github.com/sorin-davidoi) * [ysksn](https://github.com/ysksn) +* [sorin-davidoi](https://github.com/sorin-davidoi) * [abcang](https://github.com/abcang) * [lynlynlynx](https://github.com/lynlynlynx) -* [alpaca-tc](https://github.com/alpaca-tc) * [mayaeh](https://github.com/mayaeh) * [renatolond](https://github.com/renatolond) +* [alpaca-tc](https://github.com/alpaca-tc) * [nclm](https://github.com/nclm) * [ineffyble](https://github.com/ineffyble) * [jeroenpraat](https://github.com/jeroenpraat) @@ -28,9 +28,9 @@ and provided thanks to the work of the following contributors: * [Quent-in](https://github.com/Quent-in) * [JantsoP](https://github.com/JantsoP) * [mabkenar](https://github.com/mabkenar) +* [Kjwon15](https://github.com/Kjwon15) * [nullkal](https://github.com/nullkal) * [yookoala](https://github.com/yookoala) -* [Kjwon15](https://github.com/Kjwon15) * [shuheiktgw](https://github.com/shuheiktgw) * [ashfurrow](https://github.com/ashfurrow) * [Quenty31](https://github.com/Quenty31) @@ -48,16 +48,16 @@ and provided thanks to the work of the following contributors: * [rkarabut](https://github.com/rkarabut) * [yukimochi](https://github.com/yukimochi) * [Artoria2e5](https://github.com/Artoria2e5) +* [nightpool](https://github.com/nightpool) * [marrus-sh](https://github.com/marrus-sh) * [krainboltgreene](https://github.com/krainboltgreene) -* [patf](https://github.com/patf) +* [pfigel](https://github.com/pfigel) * [Aldarone](https://github.com/Aldarone) * [BoFFire](https://github.com/BoFFire) * [clworld](https://github.com/clworld) * [dracos](https://github.com/dracos) * [SerCom_KC](mailto:sercom-kc@users.noreply.github.com) * [Sylvhem](https://github.com/Sylvhem) -* [nightpool](https://github.com/nightpool) * [MasterGroosha](https://github.com/MasterGroosha) * [JeanGauthier](https://github.com/JeanGauthier) * [kschaper](https://github.com/kschaper) @@ -77,11 +77,14 @@ and provided thanks to the work of the following contributors: * [johnsudaar](https://github.com/johnsudaar) * [trebmuh](https://github.com/trebmuh) * [Rakib Hasan](mailto:rmhasan@gmail.com) +* [ashleyhull-versent](https://github.com/ashleyhull-versent) * [lindwurm](https://github.com/lindwurm) * [victorhck](mailto:victorhck@geeko.site) * [voidsatisfaction](https://github.com/voidsatisfaction) +* [rinsuki](https://github.com/rinsuki) * [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) * [spla](mailto:spla@mastodont.cat) @@ -92,20 +95,20 @@ and provided thanks to the work of the following contributors: * [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) * [tsuwatch](https://github.com/tsuwatch) * [victorhck](https://github.com/victorhck) -* [ashleyhull-versent](https://github.com/ashleyhull-versent) * [kedamaDQ](https://github.com/kedamaDQ) * [puckipedia](https://github.com/puckipedia) * [fvh-P](https://github.com/fvh-P) * [contraexemplo](https://github.com/contraexemplo) +* [Aditoo17](https://github.com/Aditoo17) * [kazu9su](https://github.com/kazu9su) * [Komic](https://github.com/Komic) * [lmorchard](https://github.com/lmorchard) * [diomed](https://github.com/diomed) -* [ariasuni](https://github.com/ariasuni) * [Neetshin](mailto:neetshin@neetsh.in) * [rainyday](https://github.com/rainyday) * [ProgVal](https://github.com/ProgVal) @@ -114,7 +117,8 @@ 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) -* [rtucker](https://github.com/rtucker) +* [trwnh](https://github.com/trwnh) +* [JMendyk](https://github.com/JMendyk) * [KScl](https://github.com/KScl) * [sterdev](https://github.com/sterdev) * [TheKinrar](https://github.com/TheKinrar) @@ -125,16 +129,16 @@ and provided thanks to the work of the following contributors: * [fhemberger](https://github.com/fhemberger) * [greysteil](https://github.com/greysteil) * [hensmith](https://github.com/hensmith) -* [hinaloe](https://github.com/hinaloe) * [d6rkaiz](https://github.com/d6rkaiz) * [Reverite](https://github.com/Reverite) -* [JMendyk](https://github.com/JMendyk) * [JohnD28](https://github.com/JohnD28) * [znz](https://github.com/znz) * [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) @@ -144,9 +148,9 @@ and provided thanks to the work of the following contributors: * [noraworld](https://github.com/noraworld) * [theboss](https://github.com/theboss) * [178inaba](https://github.com/178inaba) -* [Aditoo17](https://github.com/Aditoo17) * [alyssais](https://github.com/alyssais) -* [kodnaplakal](https://github.com/kodnaplakal) +* [hiphref](https://github.com/hiphref) +* [BenLubar](https://github.com/BenLubar) * [stalker314314](https://github.com/stalker314314) * [huertanix](https://github.com/huertanix) * [genesixx](https://github.com/genesixx) @@ -157,6 +161,7 @@ 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) * [nevillepark](https://github.com/nevillepark) * [ornithocoder](https://github.com/ornithocoder) * [pierreozoux](https://github.com/pierreozoux) @@ -164,7 +169,6 @@ and provided thanks to the work of the following contributors: * [Ram Lmn](mailto:ramlmn@users.noreply.github.com) * [harukasan](https://github.com/harukasan) * [stamak](https://github.com/stamak) -* [noellabo](https://github.com/noellabo) * [Technowix](mailto:technowix@users.noreply.github.com) * [Eychics](https://github.com/Eychics) * [Thor Harald Johansen](mailto:thj@thj.no) @@ -179,21 +183,20 @@ 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) -* [trwnh](https://github.com/trwnh) +* [tmm576](https://github.com/tmm576) * [unsmell](https://github.com/unsmell) * [valerauko](https://github.com/valerauko) * [chriswmartin](https://github.com/chriswmartin) * [vahnj](https://github.com/vahnj) * [ikuradon](https://github.com/ikuradon) * [AndreLewin](https://github.com/AndreLewin) -* [rinsuki](https://github.com/rinsuki) * [0xflotus](https://github.com/0xflotus) * [redtachyons](https://github.com/redtachyons) * [thurloat](https://github.com/thurloat) * [aaribaud](https://github.com/aaribaud) +* [pointlessone](https://github.com/pointlessone) * [Andrew](mailto:andrewlchronister@gmail.com) * [estuans](https://github.com/estuans) -* [BenLubar](https://github.com/BenLubar) * [dissolve](https://github.com/dissolve) * [PurpleBooth](https://github.com/PurpleBooth) * [bradurani](https://github.com/bradurani) @@ -216,6 +219,7 @@ and provided thanks to the work of the following contributors: * [ErikXXon](https://github.com/ErikXXon) * [ian-kelling](https://github.com/ian-kelling) * [immae](https://github.com/immae) +* [J0WI](https://github.com/J0WI) * [foozmeat](https://github.com/foozmeat) * [jasonrhodes](https://github.com/jasonrhodes) * [Jason Snell](mailto:jason@newrelic.com) @@ -230,6 +234,7 @@ and provided thanks to the work of the following contributors: * [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) @@ -238,6 +243,7 @@ and provided thanks to the work of the following contributors: * [ignisf](https://github.com/ignisf) * [raymestalez](https://github.com/raymestalez) * [remram44](https://github.com/remram44) +* [sts10](https://github.com/sts10) * [sascha-sl](https://github.com/sascha-sl) * [u1-liquid](https://github.com/u1-liquid) * [sim6](https://github.com/sim6) @@ -288,6 +294,7 @@ and provided thanks to the work of the following contributors: * [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) @@ -297,7 +304,6 @@ and provided thanks to the work of the following contributors: * [unleashed](https://github.com/unleashed) * [alxrcs](https://github.com/alxrcs) * [console-cowboy](https://github.com/console-cowboy) -* [pointlessone](https://github.com/pointlessone) * [Alkarex](https://github.com/Alkarex) * [a2](https://github.com/a2) * [0xa](https://github.com/0xa) @@ -329,6 +335,7 @@ and provided thanks to the work of the following contributors: * [Motoma](https://github.com/Motoma) * [chriswk](https://github.com/chriswk) * [csu](https://github.com/csu) +* [clarcharr](https://github.com/clarcharr) * [kklleemm](https://github.com/kklleemm) * [colindean](https://github.com/colindean) * [dachinat](https://github.com/dachinat) @@ -356,6 +363,7 @@ and provided thanks to the work of the following contributors: * [espenronnevik](https://github.com/espenronnevik) * [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) * [hattori6789](https://github.com/hattori6789) @@ -416,6 +424,7 @@ and provided thanks to the work of the following contributors: * [martymcguire](https://github.com/martymcguire) * [marvinkopf](https://github.com/marvinkopf) * [otsune](https://github.com/otsune) +* [mbugowski](https://github.com/mbugowski) * [Mathias B](mailto:10813340+mathias-b@users.noreply.github.com) * [matt-auckland](https://github.com/matt-auckland) * [webroo](https://github.com/webroo) @@ -434,7 +443,6 @@ and provided thanks to the work of the following contributors: * [premist](https://github.com/premist) * [Mnkai](https://github.com/Mnkai) * [mitchhentges](https://github.com/mitchhentges) -* [moritzheiber](https://github.com/moritzheiber) * [mouse-reeve](https://github.com/mouse-reeve) * [Mozinet-fr](https://github.com/Mozinet-fr) * [lae](https://github.com/lae) @@ -458,17 +466,17 @@ and provided thanks to the work of the following contributors: * [Pangoraw](https://github.com/Pangoraw) * [peterkeen](https://github.com/peterkeen) * [pgate](https://github.com/pgate) -* [retokromer](https://github.com/retokromer) -* [rfwatson](https://github.com/rfwatson) -* [rfreebern](https://github.com/rfreebern) +* [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) -* [sylph01](https://github.com/sylph01) -* [S-H-GAMELINKS](https://github.com/S-H-GAMELINKS) -* [staticsafe](https://github.com/staticsafe) -* [snwh](https://github.com/snwh) -* [sts10](https://github.com/sts10) -* [skoji](https://github.com/skoji) -* [ScienJus](https://github.com/ScienJus) +* [Ryo Kajiwara](mailto:kfe-fecn6.prussian@s01.info) +* [S.H](mailto:gamelinks007@gmail.com) +* [Sadiq Saif](mailto:staticsafe@users.noreply.github.com) +* [Sam Hewitt](mailto:hewittsamuel@gmail.com) +* [Satoshi KOJIMA](mailto:skoji@mac.com) +* [ScienJus](mailto:i@scienjus.com) * [Scott Larkin](mailto:scott@codeclimate.com) * [Sebastian Hübner](mailto:imolein@users.noreply.github.com) * [Sebastian Morr](mailto:sebastian@morr.cc) @@ -483,6 +491,7 @@ and provided thanks to the work of the following contributors: * [Sir-Boops](mailto:admin@boops.me) * [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) @@ -532,6 +541,7 @@ and provided thanks to the work of the following contributors: * [fsubal](mailto:fsubal@users.noreply.github.com) * [fusshi-](mailto:dikky1218@users.noreply.github.com) * [gentaro](mailto:gentaroooo@gmail.com) +* [gol-cha](mailto:info@mevo.xyz) * [hakoai](mailto:hk--76@qa2.so-net.ne.jp) * [haosbvnker](mailto:github@chaosbunker.com) * [isati](mailto:phil@juchnowi.cz) @@ -549,12 +559,12 @@ and provided thanks to the work of the following contributors: * [luzpaz](mailto:luzpaz@users.noreply.github.com) * [maxypy](mailto:maxime@mpigou.fr) * [mhe](mailto:mail@marcus-herrmann.com) +* [mike castleman](mailto:m@mlcastle.net) * [mimikun](mailto:dzdzble_effort_311@outlook.jp) * [mshrtkch](mailto:mshrtkch@users.noreply.github.com) * [muan](mailto:muan@github.com) * [namelessGonbai](mailto:43787036+namelessgonbai@users.noreply.github.com) * [neetshin](mailto:neetshin@neetsh.in) -* [nightpool](mailto:nightpool@users.noreply.github.com) * [rch850](mailto:rich850@gmail.com) * [roikale](mailto:roikale@users.noreply.github.com) * [rysiekpl](mailto:rysiek@hackerspace.pl) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfb6b15f5..b7040f924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,64 @@ Changelog All notable changes to this project will be documented in this file. +## [2.7.3] - 2019-02-23 +### Added + +- Add domain filter to the admin federation page ([ThibG](https://github.com/tootsuite/mastodon/pull/10071)) +- Add quick link from admin account view to block/unblock instance ([ThibG](https://github.com/tootsuite/mastodon/pull/10073)) + +### Fixed + +- Fix video player width not being updated to fit container width ([ThibG](https://github.com/tootsuite/mastodon/pull/10069)) +- Fix domain filter being shown in admin page when local filter is active ([ThibG](https://github.com/tootsuite/mastodon/pull/10074)) +- Fix crash when conversations have no valid participants ([ThibG](https://github.com/tootsuite/mastodon/pull/10078)) +- Fix error when performing admin actions on no statuses ([ThibG](https://github.com/tootsuite/mastodon/pull/10094)) + +### Changed + +- Change custom emojis to randomize stored file name ([hinaloe](https://github.com/tootsuite/mastodon/pull/10090)) + +## [2.7.2] - 2019-02-17 +### Added + +- Add support for IPv6 in e-mail validation ([zoc](https://github.com/tootsuite/mastodon/pull/10009)) +- Add record of IP address used for signing up ([ThibG](https://github.com/tootsuite/mastodon/pull/10026)) +- Add tight rate-limit for API deletions (30 per 30 minutes) ([Gargron](https://github.com/tootsuite/mastodon/pull/10042)) +- Add support for embedded `Announce` objects attributed to the same actor ([ThibG](https://github.com/tootsuite/mastodon/pull/9998), [Gargron](https://github.com/tootsuite/mastodon/pull/10065)) +- Add spam filter for `Create` and `Announce` activities ([Gargron](https://github.com/tootsuite/mastodon/pull/10005), [Gargron](https://github.com/tootsuite/mastodon/pull/10041), [Gargron](https://github.com/tootsuite/mastodon/pull/10062)) +- Add `registrations` attribute to `GET /api/v1/instance` ([Gargron](https://github.com/tootsuite/mastodon/pull/10060)) +- Add `vapid_key` to `POST /api/v1/apps` and `GET /api/v1/apps/verify_credentials` ([Gargron](https://github.com/tootsuite/mastodon/pull/10058)) + +### Fixed + +- Fix link color and add link underlines in high-contrast theme ([Gargron](https://github.com/tootsuite/mastodon/pull/9949), [Gargron](https://github.com/tootsuite/mastodon/pull/10028)) +- Fix unicode characters in URLs not being linkified ([JMendyk](https://github.com/tootsuite/mastodon/pull/8447), [hinaloe](https://github.com/tootsuite/mastodon/pull/9991)) +- Fix URLs linkifier grabbing ending quotation as part of the link ([Gargron](https://github.com/tootsuite/mastodon/pull/9997)) +- Fix authorized applications page design ([rinsuki](https://github.com/tootsuite/mastodon/pull/9969)) +- Fix custom emojis not showing up in share page emoji picker ([rinsuki](https://github.com/tootsuite/mastodon/pull/9970)) +- Fix too liberal application of whitespace in toots ([trwnh](https://github.com/tootsuite/mastodon/pull/9968)) +- Fix misleading e-mail hint being displayed in admin view ([ThibG](https://github.com/tootsuite/mastodon/pull/9973)) +- Fix tombstones not being cleared out ([abcang](https://github.com/tootsuite/mastodon/pull/9978)) +- Fix some timeline jumps ([ThibG](https://github.com/tootsuite/mastodon/pull/9982), [ThibG](https://github.com/tootsuite/mastodon/pull/10001), [rinsuki](https://github.com/tootsuite/mastodon/pull/10046)) +- Fix content warning input taking keyboard focus even when hidden ([hinaloe](https://github.com/tootsuite/mastodon/pull/10017)) +- Fix hashtags select styling in default and high-contrast themes ([Gargron](https://github.com/tootsuite/mastodon/pull/10029)) +- Fix style regressions on landing page ([Gargron](https://github.com/tootsuite/mastodon/pull/10030)) +- Fix hashtag column not subscribing to stream on mount ([Gargron](https://github.com/tootsuite/mastodon/pull/10040)) +- Fix relay enabling/disabling not resetting inbox availability status ([Gargron](https://github.com/tootsuite/mastodon/pull/10048)) +- Fix mutes, blocks, domain blocks and follow requests not paginating ([Gargron](https://github.com/tootsuite/mastodon/pull/10057)) +- Fix crash on public hashtag pages when streaming fails ([ThibG](https://github.com/tootsuite/mastodon/pull/10061)) + +### Changed + +- Change icon for unlisted visibility level ([clarcharr](https://github.com/tootsuite/mastodon/pull/9952)) +- Change queue of actor deletes from push to pull for non-follower recipients ([ThibG](https://github.com/tootsuite/mastodon/pull/10016)) +- Change robots.txt to exclude media proxy URLs ([nightpool](https://github.com/tootsuite/mastodon/pull/10038)) +- Change upload description input to allow line breaks ([BenLubar](https://github.com/tootsuite/mastodon/pull/10036)) +- Change `dist/mastodon-streaming.service` to recommend running node without intermediary npm command ([nolanlawson](https://github.com/tootsuite/mastodon/pull/10032)) +- Change conversations to always show names of other participants ([Gargron](https://github.com/tootsuite/mastodon/pull/10047)) +- Change buttons on timeline preview to open the interaction dialog ([Gargron](https://github.com/tootsuite/mastodon/pull/10054)) +- Change error graphic to hover-to-play ([Gargron](https://github.com/tootsuite/mastodon/pull/10055)) + ## [2.7.1] - 2019-01-28 ### Fixed diff --git a/README.md b/README.md index 5ad6894ca..03c8472b9 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ You can open issues for bugs you've found or features you think are missing. You ## License -Copyright (C) 2016-2018 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md)) +Copyright (C) 2016-2019 Eugen Rochko & other Mastodon contributors (see [AUTHORS.md](AUTHORS.md)) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb index d3104172c..eafc1818b 100644 --- a/app/chewy/statuses_index.rb +++ b/app/chewy/statuses_index.rb @@ -31,7 +31,7 @@ class StatusesIndex < Chewy::Index }, } - define_type ::Status.unscoped.without_reblogs do + define_type ::Status.unscoped.without_reblogs.includes(:media_attachments) do crutch :mentions do |collection| data = ::Mention.where(status_id: collection.map(&:id)).pluck(:status_id, :account_id) data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) } @@ -50,7 +50,7 @@ class StatusesIndex < Chewy::Index root date_detection: false do field :account_id, type: 'long' - field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].join("\n\n") } do + field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).join("\n\n") } do field :stemmed, type: 'text', analyzer: 'content' end diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index d61bafdf0..f77699166 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -5,6 +5,9 @@ module Admin 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]) diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index 431ce6f4d..6dd659a30 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -38,7 +38,7 @@ module Admin end def filter_params - params.permit(:limited) + params.permit(:limited, :by_domain) end end end diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb index d3c2f5e9e..3ba9f5df2 100644 --- a/app/controllers/admin/reported_statuses_controller.rb +++ b/app/controllers/admin/reported_statuses_controller.rb @@ -10,6 +10,10 @@ module Admin @form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button)) flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save + redirect_to admin_report_path(@report) + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.statuses.no_status_selected') + redirect_to admin_report_path(@report) end diff --git a/app/controllers/api/v1/apps/credentials_controller.rb b/app/controllers/api/v1/apps/credentials_controller.rb index e469c7d21..8b63d0490 100644 --- a/app/controllers/api/v1/apps/credentials_controller.rb +++ b/app/controllers/api/v1/apps/credentials_controller.rb @@ -6,6 +6,6 @@ class Api::V1::Apps::CredentialsController < Api::BaseController respond_to :json def show - render json: doorkeeper_token.application, serializer: REST::StatusSerializer::ApplicationSerializer + render json: doorkeeper_token.application, serializer: REST::ApplicationSerializer, fields: %i(name website vapid_key) end end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index f2a832542..ad7b1859f 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -28,6 +28,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController resource.invite_code = params[:invite_code] if resource.invite_code.blank? resource.agreement = true + resource.current_sign_in_ip = request.remote_ip if resource.current_sign_in_ip.nil? resource.build_account if resource.account.nil? end diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index 0c28d194b..f3d235366 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -5,6 +5,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio before_action :store_current_location before_action :authenticate_resource_owner! + before_action :set_body_classes include Localized @@ -15,6 +16,10 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio private + def set_body_classes + @body_classes = 'admin' + end + def store_current_location store_location_for(:user, request.url) end diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 97beb587f..275b5f2fe 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -6,7 +6,7 @@ module Admin::FilterHelper INVITE_FILTER = %i(available expired).freeze CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze TAGS_FILTERS = %i(hidden).freeze - INSTANCES_FILTERS = %i(limited).freeze + INSTANCES_FILTERS = %i(limited by_domain).freeze FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS + TAGS_FILTERS + INSTANCES_FILTERS diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index 033d435c4..7a74c0b7d 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -170,7 +170,7 @@ module StreamEntriesHelper when 'public' fa_icon 'globe fw' when 'unlisted' - fa_icon 'unlock-alt fw' + fa_icon 'unlock fw' when 'private' fa_icon 'lock fw' when 'direct' diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 206030c00..2705a6001 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -88,7 +88,7 @@ class Account extends ImmutablePureComponent { if (requested) { buttons = ; } else if (blocking) { - buttons = ; + buttons = ; } else if (muting) { let hidingNotificationsButton; if (account.getIn(['relationship', 'muting_notifications'])) { diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js index acddf77c5..6b9dd6f81 100644 --- a/app/javascript/mastodon/components/display_name.js +++ b/app/javascript/mastodon/components/display_name.js @@ -11,26 +11,36 @@ export default class DisplayName extends React.PureComponent { }; render () { - const { account, others, localDomain } = this.props; - const displayNameHtml = { __html: account.get('display_name_html') }; + const { others, localDomain } = this.props; - let suffix; + let displayName, suffix, account; if (others && others.size > 1) { - suffix = `+${others.size}`; + displayName = others.take(2).map(a => ).reduce((prev, cur) => [prev, ', ', cur]); + + if (others.size - 2 > 0) { + suffix = `+${others.size - 2}`; + } } else { + if (others && others.size > 0) { + account = others.first(); + } else { + account = this.props.account; + } + let acct = account.get('acct'); if (acct.indexOf('@') === -1 && localDomain) { acct = `${acct}@${localDomain}`; } - suffix = @{acct}; + displayName = ; + suffix = @{acct}; } return ( - {suffix} + {displayName} {suffix} ); } diff --git a/app/javascript/mastodon/components/domain.js b/app/javascript/mastodon/components/domain.js index 24f80e788..85729ca94 100644 --- a/app/javascript/mastodon/components/domain.js +++ b/app/javascript/mastodon/components/domain.js @@ -32,7 +32,7 @@ class Account extends ImmutablePureComponent {
- +
diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js index de2203a4b..e453730ba 100644 --- a/app/javascript/mastodon/components/intersection_observer_article.js +++ b/app/javascript/mastodon/components/intersection_observer_article.js @@ -65,7 +65,7 @@ export default class IntersectionObserverArticle extends React.Component { } updateStateAfterIntersection = (prevState) => { - if (prevState.isIntersecting && !this.entry.isIntersecting) { + if (prevState.isIntersecting !== false && !this.entry.isIntersecting) { scheduleIdleTask(this.hideIfNotIntersecting); } return { diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index c507920d0..a2bc95255 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -194,6 +194,8 @@ class MediaGallery extends React.PureComponent { height: PropTypes.number.isRequired, onOpenMedia: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + defaultWidth: PropTypes.number, + cacheWidth: PropTypes.func, }; static defaultProps = { @@ -202,6 +204,7 @@ class MediaGallery extends React.PureComponent { state = { visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all', + width: this.props.defaultWidth, }; componentWillReceiveProps (nextProps) { @@ -221,6 +224,7 @@ class MediaGallery extends React.PureComponent { handleRef = (node) => { if (node /*&& this.isStandaloneEligible()*/) { // offsetWidth triggers a layout, so only calculate when we need to + if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth); this.setState({ width: node.offsetWidth, }); @@ -233,8 +237,10 @@ class MediaGallery extends React.PureComponent { } render () { - const { media, intl, sensitive, height } = this.props; - const { width, visible } = this.state; + const { media, intl, sensitive, height, defaultWidth } = this.props; + const { visible } = this.state; + + const width = this.state.width || defaultWidth; let children; diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js index fec06e263..0376cf85a 100644 --- a/app/javascript/mastodon/components/scrollable_list.js +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -40,6 +40,7 @@ export default class ScrollableList extends PureComponent { state = { fullscreen: null, + cachedMediaWidth: 250, // Default media/card width using default Mastodon theme }; intersectionObserverWrapper = new IntersectionObserverWrapper(); @@ -130,6 +131,20 @@ export default class ScrollableList extends PureComponent { this.handleScroll(); } + getScrollPosition = () => { + if (this.node && (this.node.scrollTop > 0 || this.mouseMovedRecently)) { + return { height: this.node.scrollHeight, top: this.node.scrollTop }; + } else { + return null; + } + } + + updateScrollBottom = (snapshot) => { + const newScrollTop = this.node.scrollHeight - snapshot; + + this.setScrollTop(newScrollTop); + } + getSnapshotBeforeUpdate (prevProps) { const someItemInserted = React.Children.count(prevProps.children) > 0 && React.Children.count(prevProps.children) < React.Children.count(this.props.children) && @@ -150,6 +165,12 @@ export default class ScrollableList extends PureComponent { } } + cacheMediaWidth = (width) => { + if (width && this.state.cachedMediaWidth !== width) { + this.setState({ cachedMediaWidth: width }); + } + } + componentWillUnmount () { this.clearMouseIdleTimer(); this.detachScrollListener(); @@ -239,7 +260,12 @@ export default class ScrollableList extends PureComponent { intersectionObserverWrapper={this.intersectionObserverWrapper} saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null} > - {child} + {React.cloneElement(child, { + getScrollPosition: this.getScrollPosition, + updateScrollBottom: this.updateScrollBottom, + cachedMediaWidth: this.state.cachedMediaWidth, + cacheMediaWidth: this.cacheMediaWidth, + })} ))} diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 7339af4db..b7fcc846b 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -68,6 +68,10 @@ class Status extends ImmutablePureComponent { onMoveUp: PropTypes.func, onMoveDown: PropTypes.func, showThread: PropTypes.bool, + getScrollPosition: PropTypes.func, + updateScrollBottom: PropTypes.func, + cacheMediaWidth: PropTypes.func, + cachedMediaWidth: PropTypes.number, }; // Avoid checking props that are functions (and whose equality will always @@ -79,6 +83,43 @@ class Status extends ImmutablePureComponent { 'hidden', ]; + // Track height changes we know about to compensate scrolling + componentDidMount () { + this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card'); + } + + getSnapshotBeforeUpdate () { + if (this.props.getScrollPosition) { + return this.props.getScrollPosition(); + } else { + return null; + } + } + + // Compensate height changes + componentDidUpdate (prevProps, prevState, snapshot) { + const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card'); + if (doShowCard && !this.didShowCard) { + this.didShowCard = true; + if (snapshot !== null && this.props.updateScrollBottom) { + if (this.node && this.node.offsetTop < snapshot.top) { + this.props.updateScrollBottom(snapshot.height - snapshot.top); + } + } + } + } + + componentWillUnmount() { + if (this.node && this.props.getScrollPosition) { + const position = this.props.getScrollPosition(); + if (position !== null && this.node.offsetTop < position.top) { + requestAnimationFrame(() => { + this.props.updateScrollBottom(position.height - position.top); + }); + } + } + } + handleClick = () => { if (this.props.onClick) { this.props.onClick(); @@ -165,6 +206,10 @@ class Status extends ImmutablePureComponent { } } + handleRef = c => { + this.node = c; + } + render () { let media = null; let statusAvatar, prepend, rebloggedByText; @@ -179,7 +224,7 @@ class Status extends ImmutablePureComponent { if (hidden) { return ( -
+
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.get('content')}
@@ -194,7 +239,7 @@ class Status extends ImmutablePureComponent { return ( -
+
@@ -242,11 +287,12 @@ class Status extends ImmutablePureComponent { preview={video.get('preview_url')} src={video.get('url')} alt={video.get('description')} - width={239} + width={this.props.cachedMediaWidth} height={110} inline sensitive={status.get('sensitive')} onOpenVideo={this.handleOpenVideo} + cacheWidth={this.props.cacheMediaWidth} /> )} @@ -254,7 +300,16 @@ class Status extends ImmutablePureComponent { } else { media = ( - {Component => } + {Component => ( + + )} ); } @@ -264,11 +319,13 @@ class Status extends ImmutablePureComponent { onOpenMedia={this.props.onOpenMedia} card={status.get('card')} compact + cacheWidth={this.props.cacheMediaWidth} + defaultWidth={this.props.cachedMediaWidth} /> ); } - if (otherAccounts) { + if (otherAccounts && otherAccounts.size > 0) { statusAvatar = ; } else if (account === undefined || account === null) { statusAvatar = ; @@ -290,7 +347,7 @@ class Status extends ImmutablePureComponent { return ( -
+
{prepend}
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 4bbeb9192..86b947ced 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -77,7 +77,11 @@ class StatusActionBar extends ImmutablePureComponent { ] handleReplyClick = () => { - this.props.onReply(this.props.status, this.context.router.history); + if (me) { + this.props.onReply(this.props.status, this.context.router.history); + } else { + this._openInteractionDialog('reply'); + } } handleShareClick = () => { @@ -90,11 +94,23 @@ class StatusActionBar extends ImmutablePureComponent { } handleFavouriteClick = () => { - this.props.onFavourite(this.props.status); + if (me) { + this.props.onFavourite(this.props.status); + } else { + this._openInteractionDialog('favourite'); + } } - handleReblogClick = (e) => { - this.props.onReblog(this.props.status, e); + handleReblogClick = e => { + if (me) { + this.props.onReblog(this.props.status, e); + } else { + this._openInteractionDialog('reblog'); + } + } + + _openInteractionDialog = type => { + window.open(`/interact/${this.props.status.get('id')}?type=${type}`, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes'); } handleDeleteClick = () => { @@ -211,9 +227,9 @@ class StatusActionBar extends ImmutablePureComponent { return (
- - - + + + {shareButton}
diff --git a/app/javascript/mastodon/containers/compose_container.js b/app/javascript/mastodon/containers/compose_container.js index 5ee1d2f14..7bc7bbaa4 100644 --- a/app/javascript/mastodon/containers/compose_container.js +++ b/app/javascript/mastodon/containers/compose_container.js @@ -7,6 +7,7 @@ import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from '../locales'; import Compose from '../features/standalone/compose'; import initialState from '../initial_state'; +import { fetchCustomEmojis } from '../actions/custom_emojis'; const { localeData, messages } = getLocale(); addLocaleData(localeData); @@ -17,6 +18,8 @@ if (initialState) { store.dispatch(hydrateStore(initialState)); } +store.dispatch(fetchCustomEmojis()); + export default class TimelineContainer extends React.PureComponent { static propTypes = { diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 2ab25cde4..4f6d0712d 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -132,7 +132,7 @@ class Header extends ImmutablePureComponent { } else if (account.getIn(['relationship', 'blocking'])) { actionBtn = (
- +
); } diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js index ca7ce6f8e..96a219c94 100644 --- a/app/javascript/mastodon/features/blocks/index.js +++ b/app/javascript/mastodon/features/blocks/index.js @@ -18,6 +18,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ accountIds: state.getIn(['user_lists', 'blocks', 'items']), + hasMore: !!state.getIn(['user_lists', 'blocks', 'next']), }); export default @connect(mapStateToProps) @@ -29,6 +30,7 @@ class Blocks extends ImmutablePureComponent { dispatch: PropTypes.func.isRequired, shouldUpdateScroll: PropTypes.func, accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, intl: PropTypes.object.isRequired, }; @@ -41,7 +43,7 @@ class Blocks extends ImmutablePureComponent { }, 300, { leading: true }); render () { - const { intl, accountIds, shouldUpdateScroll } = this.props; + const { intl, accountIds, shouldUpdateScroll, hasMore } = this.props; if (!accountIds) { return ( @@ -59,6 +61,7 @@ class Blocks extends ImmutablePureComponent { diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 8aa069047..561c7d901 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -179,7 +179,7 @@ class ComposeForm extends ImmutablePureComponent {
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index 5698765d9..e8f57a466 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -214,7 +214,7 @@ class PrivacyDropdown extends React.PureComponent { this.options = [ { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, - { icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, + { icon: 'unlock', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, ]; diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js index a1e99dcbb..947c3acc9 100644 --- a/app/javascript/mastodon/features/compose/components/upload.js +++ b/app/javascript/mastodon/features/compose/components/upload.js @@ -107,9 +107,8 @@ class Upload extends ImmutablePureComponent {
); @@ -170,7 +188,17 @@ class Notification extends ImmutablePureComponent {
-
); diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index 8491299ef..30ed3fc21 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -60,6 +60,8 @@ export default class Card extends React.PureComponent { maxDescription: PropTypes.number, onOpenMedia: PropTypes.func.isRequired, compact: PropTypes.bool, + defaultWidth: PropTypes.number, + cacheWidth: PropTypes.func, }; static defaultProps = { @@ -68,7 +70,7 @@ export default class Card extends React.PureComponent { }; state = { - width: 280, + width: this.props.defaultWidth || 280, embedded: false, }; @@ -111,6 +113,7 @@ export default class Card extends React.PureComponent { setRef = c => { if (c) { + if (this.props.cacheWidth) this.props.cacheWidth(c.offsetWidth); this.setState({ width: c.offsetWidth }); } } diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 0a0428a39..55eaedbd5 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -86,7 +86,7 @@ export default class DetailedStatus extends ImmutablePureComponent { } render () { - const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; + const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; const outerStyle = { boxSizing: 'border-box' }; const { compact } = this.props; diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index 0d0c24d71..11c21d59d 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -99,6 +99,7 @@ class Video extends React.PureComponent { onCloseVideo: PropTypes.func, detailed: PropTypes.bool, inline: PropTypes.bool, + cacheWidth: PropTypes.func, intl: PropTypes.object.isRequired, }; @@ -108,7 +109,7 @@ class Video extends React.PureComponent { volume: 0.5, paused: true, dragging: false, - containerWidth: false, + containerWidth: this.props.width, fullscreen: false, hovered: false, muted: false, @@ -128,6 +129,7 @@ class Video extends React.PureComponent { this.player = c; if (c) { + if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth); this.setState({ containerWidth: c.offsetWidth, }); @@ -344,7 +346,6 @@ class Video extends React.PureComponent { width = containerWidth; height = containerWidth / (16/9); - playerStyle.width = width; playerStyle.height = height; } diff --git a/app/javascript/packs/error.js b/app/javascript/packs/error.js new file mode 100644 index 000000000..685c89065 --- /dev/null +++ b/app/javascript/packs/error.js @@ -0,0 +1,13 @@ +import ready from '../mastodon/ready'; + +ready(() => { + const image = document.querySelector('img'); + + image.addEventListener('mouseenter', () => { + image.src = '/oops.gif'; + }); + + image.addEventListener('mouseleave', () => { + image.src = '/oops.png'; + }); +}); diff --git a/app/javascript/styles/contrast/diff.scss b/app/javascript/styles/contrast/diff.scss index eee9ecc3e..8429103b8 100644 --- a/app/javascript/styles/contrast/diff.scss +++ b/app/javascript/styles/contrast/diff.scss @@ -12,3 +12,58 @@ } } } + +.rich-formatting a, +.rich-formatting p a, +.rich-formatting li a, +.landing-page__short-description p a, +.status__content a, +.reply-indicator__content a { + color: lighten($ui-highlight-color, 12%); + text-decoration: underline; + + &.mention { + text-decoration: none; + } + + &.mention span { + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + + &.status__content__spoiler-link { + color: $secondary-text-color; + text-decoration: none; + } +} + +.status__content__read-more-button { + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } +} + +.getting-started__footer a { + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } +} diff --git a/app/javascript/styles/mastodon/_mixins.scss b/app/javascript/styles/mastodon/_mixins.scss index d5bafe6b6..08806599e 100644 --- a/app/javascript/styles/mastodon/_mixins.scss +++ b/app/javascript/styles/mastodon/_mixins.scss @@ -41,3 +41,34 @@ font-size: 16px; } } + +@mixin search-popout() { + background: $simple-background-color; + border-radius: 4px; + padding: 10px 14px; + padding-bottom: 14px; + margin-top: 10px; + color: $light-text-color; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + + h4 { + text-transform: uppercase; + color: $light-text-color; + font-size: 13px; + font-weight: 500; + margin-bottom: 10px; + } + + li { + padding: 4px 0; + } + + ul { + margin-bottom: 10px; + } + + em { + font-weight: 500; + color: $inverted-text-color; + } +} diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss index b6c92a09e..701642be8 100644 --- a/app/javascript/styles/mastodon/about.scss +++ b/app/javascript/styles/mastodon/about.scss @@ -49,15 +49,9 @@ $small-breakpoint: 960px; } } + strong, em { - display: inline; - margin: 0; - padding: 0; font-weight: 700; - background: transparent; - font-family: inherit; - font-size: inherit; - line-height: inherit; color: lighten($darker-text-color, 10%); } @@ -796,7 +790,7 @@ $small-breakpoint: 960px; width: 100%; display: flex; flex-direction: row-reverse; - flex-wrap: wrap; + flex-wrap: nowrap; justify-content: space-between; align-items: center; } @@ -846,6 +840,7 @@ $small-breakpoint: 960px; } strong { + font-weight: 500; display: inline; margin: 0; padding: 0; diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss index 746def625..4411ca0b4 100644 --- a/app/javascript/styles/mastodon/basics.scss +++ b/app/javascript/styles/mastodon/basics.scss @@ -100,12 +100,14 @@ body { vertical-align: middle; margin: 20px; - img { - display: block; - max-width: 470px; - width: 100%; - height: auto; - margin-top: -120px; + &__illustration { + img { + display: block; + max-width: 470px; + width: 100%; + height: auto; + margin-top: -120px; + } } h1 { diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 2ddf86390..255b1be77 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -475,7 +475,7 @@ opacity: 0; transition: opacity .1s ease; - input { + textarea { background: transparent; color: $secondary-text-color; border: 0; @@ -637,7 +637,6 @@ font-weight: 400; overflow: hidden; text-overflow: ellipsis; - white-space: pre-wrap; padding-top: 2px; color: $primary-text-color; @@ -661,6 +660,7 @@ p { margin-bottom: 20px; + white-space: pre-wrap; &:last-child { margin-bottom: 0; @@ -3050,14 +3050,41 @@ a.status-card.compact:hover { display: block; font-weight: 500; margin-bottom: 10px; +} - .column-settings__hashtag-select { +.column-settings__hashtags { + .column-settings__row { + margin-bottom: 15px; + } + + .column-select { &__control { @include search-input(); } + &__placeholder { + color: $dark-text-color; + padding-left: 2px; + font-size: 12px; + } + + &__value-container { + padding-left: 6px; + } + &__multi-value { background: lighten($ui-base-color, 8%); + + &__remove { + cursor: pointer; + + &:hover, + &:active, + &:focus { + background: lighten($ui-base-color, 12%); + color: lighten($darker-text-color, 4%); + } + } } &__multi-value__label, @@ -3065,9 +3092,42 @@ a.status-card.compact:hover { color: $darker-text-color; } - &__indicator-separator, + &__clear-indicator, &__dropdown-indicator { - display: none; + cursor: pointer; + transition: none; + color: $dark-text-color; + + &:hover, + &:active, + &:focus { + color: lighten($dark-text-color, 4%); + } + } + + &__indicator-separator { + background-color: lighten($ui-base-color, 8%); + } + + &__menu { + @include search-popout(); + padding: 0; + background: $ui-secondary-color; + } + + &__menu-list { + padding: 6px; + } + + &__option { + color: $inverted-text-color; + border-radius: 4px; + font-size: 14px; + + &--is-focused, + &--is-selected { + background: darken($ui-secondary-color, 10%); + } } } } @@ -4933,34 +4993,7 @@ a.status-card.compact:hover { } .search-popout { - background: $simple-background-color; - border-radius: 4px; - padding: 10px 14px; - padding-bottom: 14px; - margin-top: 10px; - color: $light-text-color; - box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); - - h4 { - text-transform: uppercase; - color: $light-text-color; - font-size: 13px; - font-weight: 500; - margin-bottom: 10px; - } - - li { - padding: 4px 0; - } - - ul { - margin-bottom: 10px; - } - - em { - font-weight: 500; - color: $inverted-text-color; - } + @include search-popout(); } noscript { diff --git a/app/lib/activity_tracker.rb b/app/lib/activity_tracker.rb index 5b4972674..ae3c11b6a 100644 --- a/app/lib/activity_tracker.rb +++ b/app/lib/activity_tracker.rb @@ -4,6 +4,8 @@ class ActivityTracker EXPIRE_AFTER = 90.days.seconds class << self + include Redisable + def increment(prefix) key = [prefix, current_week].join(':') @@ -20,10 +22,6 @@ class ActivityTracker private - def redis - Redis.current - end - def current_week Time.zone.today.cweek end diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 87318fb1c..11fa3363a 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -2,6 +2,10 @@ class ActivityPub::Activity include JsonLdHelper + include Redisable + + SUPPORTED_TYPES = %w(Note).freeze + CONVERTED_TYPES = %w(Image Video Article Page).freeze def initialize(json, account, **options) @json = json @@ -70,8 +74,16 @@ class ActivityPub::Activity @object_uri ||= value_or_id(@object) end - def redis - Redis.current + def unsupported_object_type? + @object.is_a?(String) || !(supported_object_type? || converted_object_type?) + end + + def supported_object_type? + equals_or_includes_any?(@object['type'], SUPPORTED_TYPES) + end + + def converted_object_type? + equals_or_includes_any?(@object['type'], CONVERTED_TYPES) end def distribute(status) @@ -123,6 +135,24 @@ class ActivityPub::Activity redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri) end + def status_from_object + # If the status is already known, return it + status = status_from_uri(object_uri) + + return status unless status.nil? + + # If the boosted toot is embedded and it is a self-boost, handle it like a Create + unless unsupported_object_type? + actor_id = value_or_id(first_of_value(@object['attributedTo'])) || @account.uri + + if actor_id == @account.uri + return ActivityPub::Activity.factory({ 'type' => 'Create', 'actor' => actor_id, 'object' => @object }, @account).perform + end + end + + fetch_remote_original_status + end + def fetch_remote_original_status if object_uri.start_with?('http') return if ActivityPub::TagManager.instance.local_uri?(object_uri) @@ -137,4 +167,21 @@ class ActivityPub::Activity ensure redis.del(key) end + + def fetch? + !@options[:delivery] + end + + def followed_by_local_accounts? + @account.passive_relationships.exists? + end + + def requested_through_relay? + @options[:relayed_through_account] && Relay.find_by(inbox_url: @options[:relayed_through_account].inbox_url)&.enabled? + end + + def reject_payload! + Rails.logger.info("Rejected #{@json['type']} activity #{@json['id']} from #{@account.uri}#{@options[:relayed_through_account] && "via #{@options[:relayed_through_account].uri}"}") + nil + end end diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 34d1b7cbd..9f8ffd9fb 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -2,10 +2,11 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity def perform - original_status = status_from_uri(object_uri) - original_status ||= fetch_remote_original_status + return reject_payload! if delete_arrived_first?(@json['id']) || !related_to_local_activity? - return if original_status.nil? || delete_arrived_first?(@json['id']) || !announceable?(original_status) + original_status = status_from_object + + return reject_payload! if original_status.nil? || !announceable?(original_status) status = Status.find_by(account: @account, reblog: original_status) @@ -41,4 +42,12 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity def announceable?(status) status.account_id == @account.id || status.public_visibility? || status.unlisted_visibility? end + + def related_to_local_activity? + followed_by_local_accounts? || requested_through_relay? || reblog_of_local_status? + end + + def reblog_of_local_status? + status_from_uri(object_uri)&.account&.local? + end end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index b49657d4b..d7bd65c80 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -1,12 +1,8 @@ # frozen_string_literal: true class ActivityPub::Activity::Create < ActivityPub::Activity - SUPPORTED_TYPES = %w(Note).freeze - CONVERTED_TYPES = %w(Image Video Article Page).freeze - def perform - return if unsupported_object_type? || invalid_origin?(@object['id']) - return if Tombstone.exists?(uri: @object['id']) + return reject_payload! if unsupported_object_type? || invalid_origin?(@object['id']) || Tombstone.exists?(uri: @object['id']) || !related_to_local_activity? RedisLock.acquire(lock_options) do |lock| if lock.acquired? @@ -318,22 +314,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty? end - def unsupported_object_type? - @object.is_a?(String) || !(supported_object_type? || converted_object_type?) - end - def unsupported_media_type?(mime_type) mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type) end - def supported_object_type? - equals_or_includes_any?(@object['type'], SUPPORTED_TYPES) - end - - def converted_object_type? - equals_or_includes_any?(@object['type'], CONVERTED_TYPES) - end - def skip_download? return @skip_download if defined?(@skip_download) @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media? @@ -352,6 +336,25 @@ class ActivityPub::Activity::Create < ActivityPub::Activity !replied_to_status.nil? && replied_to_status.account.local? end + def related_to_local_activity? + fetch? || followed_by_local_accounts? || requested_through_relay? || + responds_to_followed_account? || addresses_local_accounts? + end + + def responds_to_followed_account? + !replied_to_status.nil? && (replied_to_status.account.local? || replied_to_status.account.passive_relationships.exists?) + end + + def addresses_local_accounts? + return true if @options[:delivered_to_account_id] + + local_usernames = (as_array(@object['to']) + as_array(@object['cc'])).uniq.select { |uri| ActivityPub::TagManager.instance.local_uri?(uri) }.map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username) } + + return false if local_usernames.empty? + + Account.local.where(username: local_usernames).exists? + end + def forward_for_reply return unless @json['signature'].present? && reply_to_local? ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url]) diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index f99df33e5..d77cdb3a3 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -4,6 +4,7 @@ require 'singleton' class FeedManager include Singleton + include Redisable MAX_ITEMS = 400 @@ -35,7 +36,7 @@ class FeedManager def unpush_from_home(account, status) return false unless remove_from_feed(:home, account.id, status) - Redis.current.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) + redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) true end @@ -53,7 +54,7 @@ class FeedManager def unpush_from_list(list, status) return false unless remove_from_feed(:list, list.id, status) - Redis.current.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) + redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) true end @@ -142,10 +143,6 @@ class FeedManager private - def redis - Redis.current - end - def push_update_required?(timeline_id) redis.exists("subscribed:#{timeline_id}") end diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index 4d758df91..1fa690c23 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -99,7 +99,7 @@ class Formatter end def encode_and_link_urls(html, accounts = nil, options = {}) - entities = Extractor.extract_entities_with_indices(html, extract_url_without_protocol: false) + entities = utf8_friendly_extractor(html, extract_url_without_protocol: false) if accounts.is_a?(Hash) options = accounts @@ -199,6 +199,53 @@ class Formatter result.flatten.join end + UNICODE_ESCAPE_BLACKLIST_RE = /\p{Z}|\p{P}/ + + def utf8_friendly_extractor(text, options = {}) + old_to_new_index = [0] + + escaped = text.chars.map do |c| + output = begin + if c.ord.to_s(16).length > 2 && UNICODE_ESCAPE_BLACKLIST_RE.match(c).nil? + CGI.escape(c) + else + c + end + end + + old_to_new_index << old_to_new_index.last + output.length + + output + end.join + + # Note: I couldn't obtain list_slug with @user/list-name format + # for mention so this requires additional check + special = Extractor.extract_urls_with_indices(escaped, options).map do |extract| + # exactly one of :url, :hashtag, :screen_name, :cashtag keys is present + key = (extract.keys & [:url, :hashtag, :screen_name, :cashtag]).first + + new_indices = [ + old_to_new_index.find_index(extract[:indices].first), + old_to_new_index.find_index(extract[:indices].last), + ] + + has_prefix_char = [:hashtag, :screen_name, :cashtag].include?(key) + value_indices = [ + new_indices.first + (has_prefix_char ? 1 : 0), # account for #, @ or $ + new_indices.last - 1, + ] + + next extract.merge( + :indices => new_indices, + key => text[value_indices.first..value_indices.last] + ) + end + + standard = Extractor.extract_entities_with_indices(text, options) + + Extractor.remove_overlapping_entities(special + standard) + end + def link_to_url(entity, options = {}) url = Addressable::URI.parse(entity[:url]) html_attrs = { target: '_blank', rel: 'nofollow noopener' } diff --git a/app/lib/ostatus/activity/base.rb b/app/lib/ostatus/activity/base.rb index c5933f3ad..db70f1998 100644 --- a/app/lib/ostatus/activity/base.rb +++ b/app/lib/ostatus/activity/base.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class OStatus::Activity::Base + include Redisable + def initialize(xml, account = nil, **options) @xml = xml @account = account @@ -66,8 +68,4 @@ class OStatus::Activity::Base Status.find_by(uri: uri) end end - - def redis - Redis.current - end end diff --git a/app/lib/potential_friendship_tracker.rb b/app/lib/potential_friendship_tracker.rb index 017a9748d..188aa4a27 100644 --- a/app/lib/potential_friendship_tracker.rb +++ b/app/lib/potential_friendship_tracker.rb @@ -11,6 +11,8 @@ class PotentialFriendshipTracker }.freeze class << self + include Redisable + def record(account_id, target_account_id, action) return if account_id == target_account_id @@ -31,11 +33,5 @@ class PotentialFriendshipTracker return [] if account_ids.empty? Account.searchable.where(id: account_ids) end - - private - - def redis - Redis.current - end end end diff --git a/app/models/account_conversation.rb b/app/models/account_conversation.rb index cc6b39279..0c03747e2 100644 --- a/app/models/account_conversation.rb +++ b/app/models/account_conversation.rb @@ -30,7 +30,8 @@ class AccountConversation < ApplicationRecord if participant_account_ids.empty? [account] else - Account.where(id: participant_account_ids) + participants = Account.where(id: participant_account_ids) + participants.empty? ? [account] : participants end end diff --git a/app/models/concerns/redisable.rb b/app/models/concerns/redisable.rb new file mode 100644 index 000000000..c6cf97359 --- /dev/null +++ b/app/models/concerns/redisable.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Redisable + extend ActiveSupport::Concern + + private + + def redis + Redis.current + end +end diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index 1064ea7c8..069cda367 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -24,6 +24,8 @@ class DomainBlock < ApplicationRecord has_many :accounts, foreign_key: :domain, primary_key: :domain delegate :count, to: :accounts, prefix: true + scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } + def self.blocked?(domain) where(domain: domain, severity: :suspend).exists? end diff --git a/app/models/feed.rb b/app/models/feed.rb index 5bce88f25..0e8943ff8 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Feed + include Redisable + def initialize(type, id) @type = type @id = id @@ -27,8 +29,4 @@ class Feed def key FeedManager.instance.key(@type, @id) end - - def redis - Redis.current - end end diff --git a/app/models/instance_filter.rb b/app/models/instance_filter.rb index 3483d8cd6..848fff53e 100644 --- a/app/models/instance_filter.rb +++ b/app/models/instance_filter.rb @@ -9,9 +9,13 @@ class InstanceFilter def results if params[:limited].present? - DomainBlock.order(id: :desc) + scope = DomainBlock + scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present? + scope.order(id: :desc) else - Account.remote.by_domain_accounts + scope = Account.remote + scope = scope.matches_domain(params[:by_domain]) if params[:by_domain].present? + scope.by_domain_accounts end end end diff --git a/app/models/relay.rb b/app/models/relay.rb index 7478c110d..6934a5c62 100644 --- a/app/models/relay.rb +++ b/app/models/relay.rb @@ -29,6 +29,7 @@ class Relay < ApplicationRecord payload = Oj.dump(follow_activity(activity_id)) update!(state: :pending, follow_activity_id: activity_id) + DeliveryFailureTracker.new(inbox_url).track_success! ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url) end @@ -37,6 +38,7 @@ class Relay < ApplicationRecord payload = Oj.dump(unfollow_activity(activity_id)) update!(state: :idle, follow_activity_id: nil) + DeliveryFailureTracker.new(inbox_url).track_success! ActivityPub::DeliveryWorker.perform_async(payload, some_local_account.id, inbox_url) end diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb index 3a8be2164..148535c21 100644 --- a/app/models/trending_tags.rb +++ b/app/models/trending_tags.rb @@ -7,6 +7,8 @@ class TrendingTags THRESHOLD = 5 class << self + include Redisable + def record_use!(tag, account, at_time = Time.now.utc) return if disallowed_hashtags.include?(tag.name) || account.silenced? || account.bot? @@ -59,9 +61,5 @@ class TrendingTags @disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String @disallowed_hashtags = @disallowed_hashtags.map(&:downcase) end - - def redis - Redis.current - end end end diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb index 50c4f6a04..b51e8c544 100644 --- a/app/serializers/activitypub/activity_serializer.rb +++ b/app/serializers/activitypub/activity_serializer.rb @@ -3,8 +3,8 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer attributes :id, :type, :actor, :published, :to, :cc - has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, unless: :announce? - attribute :proper_uri, key: :object, if: :announce? + has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, unless: :owned_announce? + attribute :proper_uri, key: :object, if: :owned_announce? attribute :atom_uri, if: :announce? def id @@ -42,4 +42,8 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer def announce? object.reblog? end + + def owned_announce? + announce? && object.account == object.proper.account && object.proper.private_visibility? + end end diff --git a/app/serializers/rest/application_serializer.rb b/app/serializers/rest/application_serializer.rb index a9316cd4b..ab68219ad 100644 --- a/app/serializers/rest/application_serializer.rb +++ b/app/serializers/rest/application_serializer.rb @@ -2,7 +2,7 @@ class REST::ApplicationSerializer < ActiveModel::Serializer attributes :id, :name, :website, :redirect_uri, - :client_id, :client_secret + :client_id, :client_secret, :vapid_key def id object.id.to_s @@ -19,4 +19,8 @@ class REST::ApplicationSerializer < ActiveModel::Serializer def website object.website.presence end + + def vapid_key + Rails.configuration.x.vapid_public_key + end end diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index e3e64ea87..216808ffb 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -5,7 +5,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer attributes :uri, :title, :description, :email, :version, :urls, :stats, :thumbnail, - :languages + :languages, :registrations has_one :contact_account, serializer: REST::AccountSerializer @@ -51,6 +51,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer [I18n.default_locale] end + def registrations + Setting.open_registrations && !Rails.configuration.x.single_user_mode + end + private def instance_presenter diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 487456f3a..5e3308428 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -212,7 +212,7 @@ class ActivityPub::ProcessAccountService < BaseService end def clear_tombstones! - Tombstone.delete_all(account_id: @account.id) + Tombstone.where(account_id: @account.id).delete_all end def protocol_changed? diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb index 5c54aad89..881df478b 100644 --- a/app/services/activitypub/process_collection_service.rb +++ b/app/services/activitypub/process_collection_service.rb @@ -44,6 +44,7 @@ class ActivityPub::ProcessCollectionService < BaseService end def verify_account! + @options[:relayed_through_account] = @account @account = ActivityPub::LinkedDataSignature.new(@json).verify_account! rescue JSON::LD::JsonLdError => e Rails.logger.debug "Could not verify LD-Signature for #{value_or_id(@json['actor'])}: #{e.message}" diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 2e61904fc..cd3b14e08 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -2,6 +2,7 @@ class BatchedRemoveStatusService < BaseService include StreamEntryRenderer + include Redisable # Delete given statuses and reblogs of them # Dispatch PuSH updates of the deleted statuses, but only local ones @@ -109,10 +110,6 @@ class BatchedRemoveStatusService < BaseService end end - def redis - Redis.current - end - def build_xml(stream_entry) return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id) diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 9d36a1449..92d8c864a 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class FollowService < BaseService + include Redisable + # Follow a remote user, notify remote user about the follow # @param [Account] source_account From which to follow # @param [String, Account] uri User URI to follow in the form of username@domain (or account record) @@ -67,10 +69,6 @@ class FollowService < BaseService follow end - def redis - Redis.current - end - def build_follow_request_xml(follow_request) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_request_salmon(follow_request)) end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 9959bb1fb..686b10c58 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class PostStatusService < BaseService + include Redisable + MIN_SCHEDULE_OFFSET = 5.minutes.freeze # Post a text status update, fetch and notify remote users mentioned @@ -110,10 +112,6 @@ class PostStatusService < BaseService ProcessHashtagsService.new end - def redis - Redis.current - end - def scheduled? @scheduled_at.present? end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 11d28e783..28c5224b0 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -2,6 +2,7 @@ class RemoveStatusService < BaseService include StreamEntryRenderer + include Redisable def call(status, **options) @payload = Oj.dump(event: :delete, payload: status.id.to_s) @@ -55,7 +56,7 @@ class RemoveStatusService < BaseService def remove_from_affected @mentions.map(&:account).select(&:local?).each do |account| - Redis.current.publish("timeline:#{account.id}", @payload) + redis.publish("timeline:#{account.id}", @payload) end end @@ -133,26 +134,22 @@ class RemoveStatusService < BaseService return unless @status.public_visibility? @tags.each do |hashtag| - Redis.current.publish("timeline:hashtag:#{hashtag}", @payload) - Redis.current.publish("timeline:hashtag:#{hashtag}:local", @payload) if @status.local? + redis.publish("timeline:hashtag:#{hashtag}", @payload) + redis.publish("timeline:hashtag:#{hashtag}:local", @payload) if @status.local? end end def remove_from_public return unless @status.public_visibility? - Redis.current.publish('timeline:public', @payload) - Redis.current.publish('timeline:public:local', @payload) if @status.local? + redis.publish('timeline:public', @payload) + redis.publish('timeline:public:local', @payload) if @status.local? end def remove_from_media return unless @status.public_visibility? - Redis.current.publish('timeline:public:media', @payload) - Redis.current.publish('timeline:public:local:media', @payload) if @status.local? - end - - def redis - Redis.current + redis.publish('timeline:public:media', @payload) + redis.publish('timeline:public:local:media', @payload) if @status.local? end end diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index 1bc2314de..fc3bc03a5 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -102,6 +102,10 @@ class SuspendAccountService < BaseService ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url| [delete_actor_json, @account.id, inbox_url] end + + ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url| + [delete_actor_json, @account.id, inbox_url] + end end def delete_actor_json @@ -117,7 +121,11 @@ class SuspendAccountService < BaseService end def delivery_inboxes - Account.inboxes + Relay.enabled.pluck(:inbox_url) + @delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url) + end + + def low_priority_delivery_inboxes + Account.inboxes - delivery_inboxes end def associations_for_destruction diff --git a/app/validators/email_mx_validator.rb b/app/validators/email_mx_validator.rb index 5b4c684b2..96fbedcfc 100644 --- a/app/validators/email_mx_validator.rb +++ b/app/validators/email_mx_validator.rb @@ -24,6 +24,7 @@ class EmailMxValidator < ActiveModel::Validator ([domain] + hostnames).uniq.each do |hostname| ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::A).to_a.map { |e| e.address.to_s }) + ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::AAAA).to_a.map { |e| e.address.to_s }) end end diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml index 91fddadf8..345f74f90 100644 --- a/app/views/admin/accounts/index.html.haml +++ b/app/views/admin/accounts/index.html.haml @@ -26,8 +26,9 @@ = hidden_field_tag key, params[key] - %i(username by_domain display_name email ip).each do |key| - .input.string.optional - = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.accounts.#{key}") + - unless key == :by_domain && params[:remote].blank? + .input.string.optional + = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.accounts.#{key}") .actions %button= t('admin.accounts.search') diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index 280a834ba..7ac73bd07 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -166,6 +166,12 @@ - else = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@account.id, type: 'suspend'), class: 'button button--destructive' if can?(:suspend, @account) + - unless @account.local? + - if DomainBlock.where(domain: @account.domain).exists? + = link_to t('admin.domain_blocks.undo'), admin_instance_path(@account.domain), class: 'button' + - else + = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @account.domain), class: 'button button--destructive' + %hr.spacer/ - unless @warnings.empty? diff --git a/app/views/admin/change_emails/show.html.haml b/app/views/admin/change_emails/show.html.haml index 6febef9b1..6ff0d785e 100644 --- a/app/views/admin/change_emails/show.html.haml +++ b/app/views/admin/change_emails/show.html.haml @@ -3,7 +3,7 @@ = simple_form_for @user, url: admin_account_change_email_path(@account.id) do |f| .fields-group - = f.input :email, wrapper: :with_label, disabled: true, label: t('admin.accounts.change_email.current_email') + = f.input :email, wrapper: :with_label, hint: false, disabled: true, label: t('admin.accounts.change_email.current_email') .fields-group = f.input :unconfirmed_email, wrapper: :with_label, label: t('admin.accounts.change_email.new_email') diff --git a/app/views/admin/instances/index.html.haml b/app/views/admin/instances/index.html.haml index ce35b5db4..235927140 100644 --- a/app/views/admin/instances/index.html.haml +++ b/app/views/admin/instances/index.html.haml @@ -11,6 +11,20 @@ %div{ style: 'flex: 1 1 auto; text-align: right' } = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path, class: 'button' += form_tag admin_instances_url, method: 'GET', class: 'simple_form' do + .fields-group + - Admin::FilterHelper::INSTANCES_FILTERS.each do |key| + - if params[key].present? + = hidden_field_tag key, params[key] + + - %i(by_domain).each do |key| + .input.string.optional + = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.instances.#{key}") + + .actions + %button= t('admin.accounts.search') + = link_to t('admin.accounts.reset'), admin_instances_path, class: 'button negative' + %hr.spacer/ - @instances.each do |instance| diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml index 37359b89b..25c85abf9 100644 --- a/app/views/layouts/error.html.haml +++ b/app/views/layouts/error.html.haml @@ -7,8 +7,11 @@ %meta{ content: 'width=device-width,initial-scale=1', name: 'viewport' }/ = stylesheet_pack_tag 'common', media: 'all' = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all' + = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous' + = javascript_pack_tag 'error', integrity: true, crossorigin: 'anonymous' %body.error .dialog - %img{ alt: Setting.default_settings['site_title'], src: '/oops.gif' }/ - %div + .dialog__illustration + %img{ alt: Setting.default_settings['site_title'], src: '/oops.png' }/ + .dialog__message %h1= yield :content diff --git a/app/workers/activitypub/low_priority_delivery_worker.rb b/app/workers/activitypub/low_priority_delivery_worker.rb new file mode 100644 index 000000000..a141b8f78 --- /dev/null +++ b/app/workers/activitypub/low_priority_delivery_worker.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ActivityPub::LowPriorityDeliveryWorker < ActivityPub::DeliveryWorker + sidekiq_options queue: 'pull', retry: 8, dead: false +end diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb index a8a3ebf0f..a3abe72cf 100644 --- a/app/workers/activitypub/processing_worker.rb +++ b/app/workers/activitypub/processing_worker.rb @@ -6,6 +6,6 @@ class ActivityPub::ProcessingWorker sidekiq_options backtrace: true def perform(account_id, body, delivered_to_account_id = nil) - ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id) + ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id, delivery: true) end end diff --git a/app/workers/scheduler/feed_cleanup_scheduler.rb b/app/workers/scheduler/feed_cleanup_scheduler.rb index cd2273418..bf5e20757 100644 --- a/app/workers/scheduler/feed_cleanup_scheduler.rb +++ b/app/workers/scheduler/feed_cleanup_scheduler.rb @@ -2,6 +2,7 @@ class Scheduler::FeedCleanupScheduler include Sidekiq::Worker + include Redisable sidekiq_options unique: :until_executed, retry: 0 @@ -57,8 +58,4 @@ class Scheduler::FeedCleanupScheduler def feed_manager FeedManager.instance end - - def redis - Redis.current - end end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 35302e37b..28201cc64 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -46,14 +46,14 @@ class Rack::Attack end throttle('throttle_authenticated_api', limit: 300, period: 5.minutes) do |req| - req.api_request? && req.authenticated_user_id + req.authenticated_user_id if req.api_request? end throttle('throttle_unauthenticated_api', limit: 7_500, period: 5.minutes) do |req| req.ip if req.api_request? end - throttle('throttle_media', limit: 30, period: 30.minutes) do |req| + throttle('throttle_api_media', limit: 30, period: 30.minutes) do |req| req.authenticated_user_id if req.post? && req.path.start_with?('/api/v1/media') end @@ -61,6 +61,13 @@ class Rack::Attack req.ip if req.post? && req.path == '/api/v1/accounts' end + API_DELETE_REBLOG_REGEX = /\A\/api\/v1\/statuses\/[\d]+\/unreblog/.freeze + API_DELETE_STATUS_REGEX = /\A\/api\/v1\/statuses\/[\d]+/.freeze + + throttle('throttle_api_delete', limit: 30, period: 30.minutes) do |req| + req.authenticated_user_id if (req.post? && req.path =~ API_DELETE_REBLOG_REGEX) || (req.delete? && req.path =~ API_DELETE_STATUS_REGEX) + end + throttle('protected_paths', limit: 25, period: 5.minutes) do |req| req.ip if req.post? && req.path =~ PROTECTED_PATHS_REGEX end diff --git a/config/initializers/twitter_regex.rb b/config/initializers/twitter_regex.rb index 0e8f5bfeb..0ddbbee98 100644 --- a/config/initializers/twitter_regex.rb +++ b/config/initializers/twitter_regex.rb @@ -1,7 +1,7 @@ module Twitter class Regex - REGEXEN[:valid_general_url_path_chars] = /[^\p{White_Space}\(\)\?]/iou - REGEXEN[:valid_url_path_ending_chars] = /[^\p{White_Space}\(\)\?!\*';:=\,\.\$%\[\]~&\|@]|(?:#{REGEXEN[:valid_url_balanced_parens]})/iou + REGEXEN[:valid_general_url_path_chars] = /[^\p{White_Space}<>\(\)\?]/iou + REGEXEN[:valid_url_path_ending_chars] = /[^\p{White_Space}\(\)\?!\*"'「」<>;:=\,\.\$%\[\]~&\|@]|(?:#{REGEXEN[:valid_url_balanced_parens]})/iou REGEXEN[:valid_url_balanced_parens] = / \( (?: diff --git a/config/locales/en.yml b/config/locales/en.yml index 4ae3cdec4..f8dc59160 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -302,6 +302,7 @@ en: back_to_account: Back To Account title: "%{acct}'s Followers" instances: + by_domain: Domain delivery_available: Delivery is available known_accounts: one: "%{count} known account" @@ -929,9 +930,9 @@ en:

Originally adapted from the Discourse privacy policy.

title: "%{instance} Terms of Service and Privacy Policy" themes: - contrast: High contrast + contrast: Mastodon (High contrast) default: Cybrespace - mastodon: Mastodon + mastodon: Mastodon (Dark) mastodon-light: Mastodon (light) win95: Windows 95 light: Cybre Lite diff --git a/dist/mastodon-streaming.service b/dist/mastodon-streaming.service index 5d7c129df..c324fccf4 100644 --- a/dist/mastodon-streaming.service +++ b/dist/mastodon-streaming.service @@ -9,7 +9,7 @@ WorkingDirectory=/home/mastodon/live Environment="NODE_ENV=production" Environment="PORT=4000" Environment="STREAMING_CLUSTER_NUM=1" -ExecStart=/usr/bin/npm run start +ExecStart=/usr/bin/node ./streaming TimeoutSec=15 Restart=always diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 02ef157b1..005cd52f6 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -13,7 +13,7 @@ module Mastodon end def patch - 1 + 3 end def pre @@ -29,7 +29,7 @@ module Mastodon end def to_s - [to_a.join('.'), flags, '-cybre'].join + [to_a.join('.'), flags].join end def repository @@ -37,7 +37,7 @@ module Mastodon end def source_base_url - 'https://cybre.tech/cybrespace/mastodon' + "https://github.com/#{repository}" end # specify git tag or commit hash here diff --git a/public/oops.png b/public/oops.png new file mode 100644 index 0000000000000000000000000000000000000000..1ac779f2551c4997c06851b0b41d0fb4ad24dee0 GIT binary patch literal 20552 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 rEj2ZHC1ouorNL_f>i' } + + it 'does not match the angle brackets' do + is_expected.to include 'href="https://example.com/"' + end + end + context 'given a URL with Japanese path string' do let(:text) { 'https://ja.wikipedia.org/wiki/日本' } @@ -105,6 +147,22 @@ RSpec.describe Formatter do end end + context 'given a URL with a full-width space' do + let(:text) { 'https://example.com/ abc123' } + + it 'does not match the full-width space' do + is_expected.to include 'href="https://example.com/"' + end + end + + context 'given a URL in Japanese quotation marks' do + let(:text) { '「[https://example.org/」' } + + it 'does not match the quotation marks' do + is_expected.to include 'href="https://example.org/"' + end + end + context 'given a URL with Simplified Chinese path string' do let(:text) { 'https://baike.baidu.com/item/中华人民共和国' } @@ -124,7 +182,11 @@ RSpec.describe Formatter do context 'given a URL containing unsafe code (XSS attack, visible part)' do let(:text) { %q{http://example.com/bb} } - it 'escapes the HTML in the URL' do + it 'does not include the HTML in the URL' do + is_expected.to include '"http://example.com/b"' + end + + it 'escapes the HTML' do is_expected.to include '<del>b</del>' end end @@ -132,7 +194,11 @@ RSpec.describe Formatter do context 'given a URL containing unsafe code (XSS attack, invisible part)' do let(:text) { %q{http://example.com/blahblahblahblah/a} } - it 'escapes the HTML in the URL' do + it 'does not include the HTML in the URL' do + is_expected.to include '"http://example.com/blahblahblahblah/a"' + end + + it 'escapes the HTML' do is_expected.to include '<script>alert("Hello")</script>' end end @@ -168,6 +234,14 @@ RSpec.describe Formatter do is_expected.to include '/tags/hashtag" class="mention hashtag" rel="tag">#hashtag' end end + + context 'given text containing a hashtag with Unicode chars' do + let(:text) { '#hashtagタグ' } + + it 'creates a hashtag link' do + is_expected.to include '/tags/hashtag%E3%82%BF%E3%82%B0" class="mention hashtag" rel="tag">#hashtagタグ' + end + end end describe '#format_spoiler' do diff --git a/spec/validators/email_mx_validator_spec.rb b/spec/validators/email_mx_validator_spec.rb index bc68f63cf..48e17a4f1 100644 --- a/spec/validators/email_mx_validator_spec.rb +++ b/spec/validators/email_mx_validator_spec.rb @@ -11,6 +11,7 @@ describe EmailMxValidator do allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) + allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) allow(resolver).to receive(:timeouts=).and_return(nil) allow(Resolv::DNS).to receive(:open).and_yield(resolver) @@ -23,7 +24,9 @@ describe EmailMxValidator do allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) + allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([]) + allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) allow(resolver).to receive(:timeouts=).and_return(nil) allow(Resolv::DNS).to receive(:open).and_yield(resolver) @@ -37,6 +40,21 @@ describe EmailMxValidator do allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '1.2.3.4')]) + allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) + allow(resolver).to receive(:timeouts=).and_return(nil) + allow(Resolv::DNS).to receive(:open).and_yield(resolver) + + subject.validate(user) + expect(user.errors).to have_received(:add) + end + + it 'adds an error if the AAAA record is blacklisted' do + EmailDomainBlock.create!(domain: 'fd00::1') + resolver = double + + allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([]) + allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) + allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([double(address: 'fd00::1')]) allow(resolver).to receive(:timeouts=).and_return(nil) allow(Resolv::DNS).to receive(:open).and_yield(resolver) @@ -50,7 +68,25 @@ describe EmailMxValidator do allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) + allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '2.3.4.5')]) + allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) + allow(resolver).to receive(:timeouts=).and_return(nil) + allow(Resolv::DNS).to receive(:open).and_yield(resolver) + + subject.validate(user) + expect(user.errors).to have_received(:add) + end + + it 'adds an error if the MX IPv6 record is blacklisted' do + EmailDomainBlock.create!(domain: 'fd00::2') + resolver = double + + allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')]) + allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) + allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) + allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([]) + allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([double(address: 'fd00::2')]) allow(resolver).to receive(:timeouts=).and_return(nil) allow(Resolv::DNS).to receive(:open).and_yield(resolver) @@ -64,7 +100,9 @@ describe EmailMxValidator do allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) + allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '2.3.4.5')]) + allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([double(address: 'fd00::2')]) allow(resolver).to receive(:timeouts=).and_return(nil) allow(Resolv::DNS).to receive(:open).and_yield(resolver)