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 000000000..1ac779f25 Binary files /dev/null and b/public/oops.png differ diff --git a/public/robots.txt b/public/robots.txt index 3c9c7c01f..d93648bee 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,5 +1,4 @@ # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file -# -# To ban all spiders from the entire site uncomment the next two lines: -# User-agent: * -# Disallow: / + +User-agent: * +Disallow: /media_proxy/ diff --git a/spec/lib/activitypub/activity/announce_spec.rb b/spec/lib/activitypub/activity/announce_spec.rb index 54dd52a60..aa58d9e23 100644 --- a/spec/lib/activitypub/activity/announce_spec.rb +++ b/spec/lib/activitypub/activity/announce_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe ActivityPub::Activity::Announce do - let(:sender) { Fabricate(:account) } + let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers', uri: 'https://example.com/actor') } let(:recipient) { Fabricate(:account) } let(:status) { Fabricate(:status, account: recipient) } @@ -10,20 +10,162 @@ RSpec.describe ActivityPub::Activity::Announce do '@context': 'https://www.w3.org/ns/activitystreams', id: 'foo', type: 'Announce', - actor: ActivityPub::TagManager.instance.uri_for(sender), - object: ActivityPub::TagManager.instance.uri_for(status), + actor: 'https://example.com/actor', + object: object_json, }.with_indifferent_access end - describe '#perform' do - subject { described_class.new(json, sender) } + let(:unknown_object_json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/actor/hello-world', + type: 'Note', + attributedTo: 'https://example.com/actor', + content: 'Hello world', + to: 'http://example.com/followers', + } + end - before do - subject.perform + subject { described_class.new(json, sender) } + + describe '#perform' do + context 'when sender is followed by a local account' do + before do + Fabricate(:account).follow!(sender) + stub_request(:get, 'https://example.com/actor/hello-world').to_return(body: Oj.dump(unknown_object_json)) + subject.perform + end + + context 'a known status' do + let(:object_json) do + ActivityPub::TagManager.instance.uri_for(status) + end + + it 'creates a reblog by sender of status' do + expect(sender.reblogged?(status)).to be true + end + end + + context 'an unknown status' do + let(:object_json) { 'https://example.com/actor/hello-world' } + + it 'creates a reblog by sender of status' do + reblog = sender.statuses.first + + expect(reblog).to_not be_nil + expect(reblog.reblog.text).to eq 'Hello world' + end + end + + context 'self-boost of a previously unknown status with missing attributedTo' do + let(:object_json) do + { + id: 'https://example.com/actor#bar', + type: 'Note', + content: 'Lorem ipsum', + to: 'http://example.com/followers', + } + end + + it 'creates a reblog by sender of status' do + expect(sender.reblogged?(sender.statuses.first)).to be true + end + end + + context 'self-boost of a previously unknown status with correct attributedTo' do + let(:object_json) do + { + id: 'https://example.com/actor#bar', + type: 'Note', + content: 'Lorem ipsum', + attributedTo: 'https://example.com/actor', + to: 'http://example.com/followers', + } + end + + it 'creates a reblog by sender of status' do + expect(sender.reblogged?(sender.statuses.first)).to be true + end + end end - it 'creates a reblog by sender of status' do - expect(sender.reblogged?(status)).to be true + context 'when the status belongs to a local user' do + before do + subject.perform + end + + let(:object_json) do + ActivityPub::TagManager.instance.uri_for(status) + end + + it 'creates a reblog by sender of status' do + expect(sender.reblogged?(status)).to be true + end + end + + context 'when the sender is relayed' do + let!(:relay_account) { Fabricate(:account, inbox_url: 'https://relay.example.com/inbox') } + let!(:relay) { Fabricate(:relay, inbox_url: 'https://relay.example.com/inbox') } + + subject { described_class.new(json, sender, relayed_through_account: relay_account) } + + context 'and the relay is enabled' do + before do + relay.update(state: :accepted) + subject.perform + end + + let(:object_json) do + { + id: 'https://example.com/actor#bar', + type: 'Note', + content: 'Lorem ipsum', + to: 'http://example.com/followers', + } + end + + it 'creates a reblog by sender of status' do + expect(sender.statuses.count).to eq 2 + end + end + + context 'and the relay is disabled' do + before do + subject.perform + end + + let(:object_json) do + { + id: 'https://example.com/actor#bar', + type: 'Note', + content: 'Lorem ipsum', + to: 'http://example.com/followers', + } + end + + it 'does not create anything' do + expect(sender.statuses.count).to eq 0 + end + end + end + + context 'when the sender has no relevance to local activity' do + before do + subject.perform + end + + let(:object_json) do + { + id: 'https://example.com/actor#bar', + type: 'Note', + content: 'Lorem ipsum', + to: 'http://example.com/followers', + } + end + + it 'does not create anything' do + expect(sender.statuses.count).to eq 0 + end end end end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index cd20b7c7c..26cb84871 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -13,8 +13,6 @@ RSpec.describe ActivityPub::Activity::Create do }.with_indifferent_access end - subject { described_class.new(json, sender) } - before do sender.update(uri: ActivityPub::TagManager.instance.uri_for(sender)) @@ -23,11 +21,402 @@ RSpec.describe ActivityPub::Activity::Create do end describe '#perform' do - before do - subject.perform + context 'when fetching' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + context 'standalone' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + end + + it 'missing to/cc defaults to direct privacy' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'direct' + end + end + + context 'public' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: 'https://www.w3.org/ns/activitystreams#Public', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'public' + end + end + + context 'unlisted' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + cc: 'https://www.w3.org/ns/activitystreams#Public', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'unlisted' + end + end + + context 'private' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: 'http://example.com/followers', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'private' + end + end + + context 'limited' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: ActivityPub::TagManager.instance.uri_for(recipient), + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'limited' + end + + it 'creates silent mention' do + status = sender.statuses.first + expect(status.mentions.first).to be_silent + end + end + + context 'direct' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + to: ActivityPub::TagManager.instance.uri_for(recipient), + tag: { + type: 'Mention', + href: ActivityPub::TagManager.instance.uri_for(recipient), + }, + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'direct' + end + end + + context 'as a reply' do + let(:original_status) { Fabricate(:status) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + inReplyTo: ActivityPub::TagManager.instance.uri_for(original_status), + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.thread).to eq original_status + expect(status.reply?).to be true + expect(status.in_reply_to_account).to eq original_status.account + expect(status.conversation).to eq original_status.conversation + end + end + + context 'with mentions' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + tag: [ + { + type: 'Mention', + href: ActivityPub::TagManager.instance.uri_for(recipient), + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.mentions.map(&:account)).to include(recipient) + end + end + + context 'with mentions missing href' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + tag: [ + { + type: 'Mention', + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + expect(status).to_not be_nil + end + end + + context 'with media attachments' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + attachment: [ + { + type: 'Document', + mediaType: 'image/png', + url: 'http://example.com/attachment.png', + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.media_attachments.map(&:remote_url)).to include('http://example.com/attachment.png') + end + end + + context 'with media attachments with focal points' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + attachment: [ + { + type: 'Document', + mediaType: 'image/png', + url: 'http://example.com/attachment.png', + focalPoint: [0.5, -0.7], + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.media_attachments.map(&:focus)).to include('0.5,-0.7') + end + end + + context 'with media attachments missing url' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + attachment: [ + { + type: 'Document', + mediaType: 'image/png', + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + expect(status).to_not be_nil + end + end + + context 'with hashtags' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + tag: [ + { + type: 'Hashtag', + href: 'http://example.com/blah', + name: '#test', + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.tags.map(&:name)).to include('test') + end + end + + context 'with hashtags missing name' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum', + tag: [ + { + type: 'Hashtag', + href: 'http://example.com/blah', + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + expect(status).to_not be_nil + end + end + + context 'with emojis' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum :tinking:', + tag: [ + { + type: 'Emoji', + icon: { + url: 'http://example.com/emoji.png', + }, + name: 'tinking', + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.emojis.map(&:shortcode)).to include('tinking') + end + end + + context 'with emojis missing name' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum :tinking:', + tag: [ + { + type: 'Emoji', + icon: { + url: 'http://example.com/emoji.png', + }, + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + expect(status).to_not be_nil + end + end + + context 'with emojis missing icon' do + let(:object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, + type: 'Note', + content: 'Lorem ipsum :tinking:', + tag: [ + { + type: 'Emoji', + name: 'tinking', + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + expect(status).to_not be_nil + end + end end - context 'standalone' do + context 'when sender is followed by local users' do + subject { described_class.new(json, sender, delivery: true) } + + before do + Fabricate(:account).follow!(sender) + subject.perform + end + let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, @@ -42,78 +431,23 @@ RSpec.describe ActivityPub::Activity::Create do expect(status).to_not be_nil expect(status.text).to eq 'Lorem ipsum' end - - it 'missing to/cc defaults to direct privacy' do - status = sender.statuses.first - - expect(status).to_not be_nil - expect(status.visibility).to eq 'direct' - end end - context 'public' do - let(:object_json) do - { - id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, - type: 'Note', - content: 'Lorem ipsum', - to: 'https://www.w3.org/ns/activitystreams#Public', - } + context 'when sender replies to local status' do + let!(:local_status) { Fabricate(:status) } + + subject { described_class.new(json, sender, delivery: true) } + + before do + subject.perform end - it 'creates status' do - status = sender.statuses.first - - expect(status).to_not be_nil - expect(status.visibility).to eq 'public' - end - end - - context 'unlisted' do - let(:object_json) do - { - id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, - type: 'Note', - content: 'Lorem ipsum', - cc: 'https://www.w3.org/ns/activitystreams#Public', - } - end - - it 'creates status' do - status = sender.statuses.first - - expect(status).to_not be_nil - expect(status.visibility).to eq 'unlisted' - end - end - - context 'private' do - let(:object_json) do - { - id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, - type: 'Note', - content: 'Lorem ipsum', - to: 'http://example.com/followers', - } - end - - it 'creates status' do - status = sender.statuses.first - - expect(status).to_not be_nil - expect(status.visibility).to eq 'private' - end - end - - context 'limited' do - let(:recipient) { Fabricate(:account) } - let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, type: 'Note', content: 'Lorem ipsum', - to: ActivityPub::TagManager.instance.uri_for(recipient), + inReplyTo: ActivityPub::TagManager.instance.uri_for(local_status), } end @@ -121,28 +455,25 @@ RSpec.describe ActivityPub::Activity::Create do status = sender.statuses.first expect(status).to_not be_nil - expect(status.visibility).to eq 'limited' - end - - it 'creates silent mention' do - status = sender.statuses.first - expect(status.mentions.first).to be_silent + expect(status.text).to eq 'Lorem ipsum' end end - context 'direct' do - let(:recipient) { Fabricate(:account) } + context 'when sender targets a local user' do + let!(:local_account) { Fabricate(:account) } + + subject { described_class.new(json, sender, delivery: true) } + + before do + subject.perform + end let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, type: 'Note', content: 'Lorem ipsum', - to: ActivityPub::TagManager.instance.uri_for(recipient), - tag: { - type: 'Mention', - href: ActivityPub::TagManager.instance.uri_for(recipient), - }, + to: ActivityPub::TagManager.instance.uri_for(local_account), } end @@ -150,19 +481,25 @@ RSpec.describe ActivityPub::Activity::Create do status = sender.statuses.first expect(status).to_not be_nil - expect(status.visibility).to eq 'direct' + expect(status.text).to eq 'Lorem ipsum' end end - context 'as a reply' do - let(:original_status) { Fabricate(:status) } + context 'when sender cc\'s a local user' do + let!(:local_account) { Fabricate(:account) } + + subject { described_class.new(json, sender, delivery: true) } + + before do + subject.perform + end let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, type: 'Note', content: 'Lorem ipsum', - inReplyTo: ActivityPub::TagManager.instance.uri_for(original_status), + cc: ActivityPub::TagManager.instance.uri_for(local_account), } end @@ -170,240 +507,27 @@ RSpec.describe ActivityPub::Activity::Create do status = sender.statuses.first expect(status).to_not be_nil - expect(status.thread).to eq original_status - expect(status.reply?).to be true - expect(status.in_reply_to_account).to eq original_status.account - expect(status.conversation).to eq original_status.conversation + expect(status.text).to eq 'Lorem ipsum' end end - context 'with mentions' do - let(:recipient) { Fabricate(:account) } + context 'when the sender has no relevance to local activity' do + subject { described_class.new(json, sender, delivery: true) } + + before do + subject.perform + end let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, type: 'Note', content: 'Lorem ipsum', - tag: [ - { - type: 'Mention', - href: ActivityPub::TagManager.instance.uri_for(recipient), - }, - ], } end - it 'creates status' do - status = sender.statuses.first - - expect(status).to_not be_nil - expect(status.mentions.map(&:account)).to include(recipient) - end - end - - context 'with mentions missing href' do - let(:object_json) do - { - id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, - type: 'Note', - content: 'Lorem ipsum', - tag: [ - { - type: 'Mention', - }, - ], - } - end - - it 'creates status' do - status = sender.statuses.first - expect(status).to_not be_nil - end - end - - context 'with media attachments' do - let(:object_json) do - { - id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, - type: 'Note', - content: 'Lorem ipsum', - attachment: [ - { - type: 'Document', - mediaType: 'image/png', - url: 'http://example.com/attachment.png', - }, - ], - } - end - - it 'creates status' do - status = sender.statuses.first - - expect(status).to_not be_nil - expect(status.media_attachments.map(&:remote_url)).to include('http://example.com/attachment.png') - end - end - - context 'with media attachments with focal points' do - let(:object_json) do - { - id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, - type: 'Note', - content: 'Lorem ipsum', - attachment: [ - { - type: 'Document', - mediaType: 'image/png', - url: 'http://example.com/attachment.png', - focalPoint: [0.5, -0.7], - }, - ], - } - end - - it 'creates status' do - status = sender.statuses.first - - expect(status).to_not be_nil - expect(status.media_attachments.map(&:focus)).to include('0.5,-0.7') - end - end - - context 'with media attachments missing url' do - let(:object_json) do - { - id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, - type: 'Note', - content: 'Lorem ipsum', - attachment: [ - { - type: 'Document', - mediaType: 'image/png', - }, - ], - } - end - - it 'creates status' do - status = sender.statuses.first - expect(status).to_not be_nil - end - end - - context 'with hashtags' do - let(:object_json) do - { - id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, - type: 'Note', - content: 'Lorem ipsum', - tag: [ - { - type: 'Hashtag', - href: 'http://example.com/blah', - name: '#test', - }, - ], - } - end - - it 'creates status' do - status = sender.statuses.first - - expect(status).to_not be_nil - expect(status.tags.map(&:name)).to include('test') - end - end - - context 'with hashtags missing name' do - let(:object_json) do - { - id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, - type: 'Note', - content: 'Lorem ipsum', - tag: [ - { - type: 'Hashtag', - href: 'http://example.com/blah', - }, - ], - } - end - - it 'creates status' do - status = sender.statuses.first - expect(status).to_not be_nil - end - end - - context 'with emojis' do - let(:object_json) do - { - id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, - type: 'Note', - content: 'Lorem ipsum :tinking:', - tag: [ - { - type: 'Emoji', - icon: { - url: 'http://example.com/emoji.png', - }, - name: 'tinking', - }, - ], - } - end - - it 'creates status' do - status = sender.statuses.first - - expect(status).to_not be_nil - expect(status.emojis.map(&:shortcode)).to include('tinking') - end - end - - context 'with emojis missing name' do - let(:object_json) do - { - id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, - type: 'Note', - content: 'Lorem ipsum :tinking:', - tag: [ - { - type: 'Emoji', - icon: { - url: 'http://example.com/emoji.png', - }, - }, - ], - } - end - - it 'creates status' do - status = sender.statuses.first - expect(status).to_not be_nil - end - end - - context 'with emojis missing icon' do - let(:object_json) do - { - id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, - type: 'Note', - content: 'Lorem ipsum :tinking:', - tag: [ - { - type: 'Emoji', - name: 'tinking', - }, - ], - } - end - - it 'creates status' do - status = sender.statuses.first - expect(status).to_not be_nil + it 'does not create anything' do + expect(sender.statuses.count).to eq 0 end end end diff --git a/spec/lib/formatter_spec.rb b/spec/lib/formatter_spec.rb index 0c1efe7c3..96d2fc7e0 100644 --- a/spec/lib/formatter_spec.rb +++ b/spec/lib/formatter_spec.rb @@ -74,10 +74,36 @@ RSpec.describe Formatter do end context 'given a URL with a query string' do - let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' } + context 'with escaped unicode character' do + let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' } - it 'matches the full URL' do - is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink"' + it 'matches the full URL' do + is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink"' + end + end + + context 'with unicode character' do + let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓&q=autolink' } + + it 'matches the full URL' do + is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓&q=autolink"' + end + end + + context 'with unicode character at the end' do + let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓' } + + it 'matches the full URL' do + is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓"' + end + end + + context 'with escaped and not escaped unicode characters' do + let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&utf81=✓&q=autolink' } + + it 'preserves escaped unicode characters' do + is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&utf81=✓&q=autolink"' + end end end @@ -89,6 +115,22 @@ RSpec.describe Formatter do end end + context 'given a URL in quotation marks' do + let(:text) { '"https://example.com/"' } + + it 'does not match the quotation marks' do + is_expected.to include 'href="https://example.com/"' + end + end + + context 'given a URL in angle brackets' do + let(:text) { '' } + + 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)