From 238f51ab19acba98b80545a503f614e22c495b4e Mon Sep 17 00:00:00 2001 From: riking Date: Mon, 23 Apr 2018 15:01:21 -0700 Subject: [PATCH 001/194] UX: Add link to /groups from Admin>Users --- app/assets/javascripts/admin/templates/users-list.hbs | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/admin/templates/users-list.hbs b/app/assets/javascripts/admin/templates/users-list.hbs index 645fc83c1f..21364f0fd9 100644 --- a/app/assets/javascripts/admin/templates/users-list.hbs +++ b/app/assets/javascripts/admin/templates/users-list.hbs @@ -10,6 +10,7 @@ {{nav-item route='adminUsersList.show' routeParam='suspended' label='admin.users.nav.suspended'}} {{nav-item route='adminUsersList.show' routeParam='silenced' label='admin.users.nav.silenced'}} {{nav-item route='adminUsersList.show' routeParam='suspect' label='admin.users.nav.suspect'}} + {{nav-item route='groups' label='groups.index.title'}}
From 91b31860a14a5e0334856b80cb24fe6db0023208 Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Fri, 4 May 2018 15:31:48 -0700 Subject: [PATCH 002/194] Feature: Push notifications for Android (#5792) * Feature: Push notifications for Android Notification config for desktop and mobile are merged. Desktop notifications stay as they are for desktop views. If mobile mode, push notifications are enabled. Added push notification subscriptions in their own table, rather than through custom fields. Notification banner prompts appear for both mobile and desktop when enabled. --- Gemfile | 3 + Gemfile.lock | 6 + .../images/push-notifications/check.png | Bin 0 -> 844 bytes .../images/push-notifications/discourse.png | Bin 0 -> 7980 bytes .../push-notifications/group_mentioned.png | Bin 0 -> 2653 bytes .../images/push-notifications/linked.png | Bin 0 -> 1898 bytes .../images/push-notifications/mentioned.png | Bin 0 -> 2653 bytes .../images/push-notifications/posted.png | Bin 0 -> 1224 bytes .../push-notifications/private_message.png | Bin 0 -> 1444 bytes .../images/push-notifications/quoted.png | Bin 0 -> 1000 bytes .../images/push-notifications/replied.png | Bin 0 -> 1224 bytes .../desktop-notification-config.js.es6 | 68 +++++++--- .../notification-consent-banner.js.es6 | 45 +++++++ .../subscribe-user-notifications.js.es6 | 27 ++-- .../lib/desktop-notifications.js.es6 | 22 +++- .../discourse/lib/push-notifications.js.es6 | 119 ++++++++++++++++++ .../discourse/templates/application.hbs | 1 + .../desktop-notification-config.hbs | 7 +- .../notification-consent-banner.hbs | 8 ++ app/assets/javascripts/service-worker.js.erb | 74 +++++++++++ .../mobile/push-notifications-mobile.scss | 3 + .../push_notification_controller.rb | 21 ++++ app/jobs/regular/send_push_notification.rb | 8 ++ app/models/push_subscription.rb | 13 ++ app/models/user.rb | 2 + app/services/push_notification_pusher.rb | 80 ++++++++++++ config/initializers/100-push-notifications.rb | 17 +++ config/locales/client.en.yml | 8 +- config/locales/server.en.yml | 15 +++ config/routes.rb | 3 + config/site_settings.yml | 15 +++ ...20180425185749_create_push_subscription.rb | 9 ++ .../push_notification_controller_spec.rb | 48 +++++++ .../services/push_notification_pusher_spec.rb | 14 +++ 34 files changed, 603 insertions(+), 33 deletions(-) create mode 100644 app/assets/images/push-notifications/check.png create mode 100644 app/assets/images/push-notifications/discourse.png create mode 100644 app/assets/images/push-notifications/group_mentioned.png create mode 100644 app/assets/images/push-notifications/linked.png create mode 100644 app/assets/images/push-notifications/mentioned.png create mode 100644 app/assets/images/push-notifications/posted.png create mode 100644 app/assets/images/push-notifications/private_message.png create mode 100644 app/assets/images/push-notifications/quoted.png create mode 100644 app/assets/images/push-notifications/replied.png create mode 100644 app/assets/javascripts/discourse/components/notification-consent-banner.js.es6 create mode 100644 app/assets/javascripts/discourse/lib/push-notifications.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/components/notification-consent-banner.hbs create mode 100644 app/assets/stylesheets/mobile/push-notifications-mobile.scss create mode 100644 app/controllers/push_notification_controller.rb create mode 100644 app/jobs/regular/send_push_notification.rb create mode 100644 app/models/push_subscription.rb create mode 100644 app/services/push_notification_pusher.rb create mode 100644 config/initializers/100-push-notifications.rb create mode 100644 db/migrate/20180425185749_create_push_subscription.rb create mode 100644 spec/controllers/push_notification_controller_spec.rb create mode 100644 spec/services/push_notification_pusher_spec.rb diff --git a/Gemfile b/Gemfile index 4a7c9a2147..23da61e81d 100644 --- a/Gemfile +++ b/Gemfile @@ -185,3 +185,6 @@ if ENV["IMPORT"] == "1" gem 'ruby-bbcode-to-md', github: 'nlalonde/ruby-bbcode-to-md' gem 'reverse_markdown' end + +gem 'hkdf', '0.3.0', require: false +gem 'webpush', '0.3.2', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 668d8882a2..0641d8cab0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -131,6 +131,7 @@ GEM hashie (3.5.5) highline (1.7.8) hiredis (0.6.1) + hkdf (0.3.0) htmlentities (4.3.4) http_accept_language (2.0.5) i18n (0.8.6) @@ -388,6 +389,9 @@ GEM addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff + webpush (0.3.2) + hkdf (~> 0.2) + jwt PLATFORMS ruby @@ -428,6 +432,7 @@ DEPENDENCIES gc_tracer highline hiredis + hkdf (= 0.3.0) htmlentities http_accept_language (~> 2.0.5) listen @@ -498,6 +503,7 @@ DEPENDENCIES unf unicorn webmock + webpush (= 0.3.2) BUNDLED WITH 1.16.1 diff --git a/app/assets/images/push-notifications/check.png b/app/assets/images/push-notifications/check.png new file mode 100644 index 0000000000000000000000000000000000000000..ad223d26f347a856191dc7ac019b2f2eccee7fc5 GIT binary patch literal 844 zcmeAS@N?(olHy`uVBq!ia0vp^b3mAb8A$F;GpYwtmjZl3T!Hk&;RURlvMPbTaxDq+ z3ua(sVrF4wW9Q)F=Hcbz7Z4PcS5Q<^R#8<`*U;3`);G4Wc69UdOHRwku4|q&WA3~K zi&n4QwEe)v%l95Udh+bW%hx}D|DD<<;laSb_{7u2F{I+w+ndM3m>dOKA1Y}yDQP^@ z(R$X%FXUSKFqg z)y)geu$K6+;n$wH*-m%=t@oX)myo^!CKfmbx1^!?*KXLUP`cueWBr zUy{2!O}aR4WAEuj+hkYwYIo(z`@jkrlfLVP-n4Z?wx%@L` z&KDE8CG50&i8oqo(Q-!e1$*e7|Zt81&bU7VO35%B!3o9OEuVSbm~)W0>Z zee=CN`(N%BA;V2Gr*0P9YxUAg$?ViO*%BwSMM+!y&OBR?y`THuW~I_atX3^B0s)7_Vpg8(c bU(0mE|307nF{_op1jOL!>gTe~DWM4fPY#-y literal 0 HcmV?d00001 diff --git a/app/assets/images/push-notifications/discourse.png b/app/assets/images/push-notifications/discourse.png new file mode 100644 index 0000000000000000000000000000000000000000..46d9720633b58b56eba57c456c1c525022a6fc62 GIT binary patch literal 7980 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 L7-?5%+J*lQvFLZR literal 0 HcmV?d00001 diff --git a/app/assets/images/push-notifications/group_mentioned.png b/app/assets/images/push-notifications/group_mentioned.png new file mode 100644 index 0000000000000000000000000000000000000000..ebb5560414b8bdae210c0c4023fc70832555495b GIT binary patch literal 2653 zcmeHJ={FmS5|3SDZ4<;=lr*7=3Qg4-Nknd>5uughilwBg){@)S78J3?TCJsm1XXL( z#a7!>lxn0}t=okjTWK#vYw6;8@0|DHeR%)DopWa9%!m0fzxmCQP9i$W0o8y206@+O zhbHg&(toS8*q-e=cPt(N5Y;6SD44x4_aFHOf&Y&HdxYz^$C7(dWTGoTL{v;%LQ+b4 zA5cbCPF`XE0T5V8Sp}l1rlF~IP+Ld$ke)vDFbr;Bc*N+av5Bb}!rbCG@`R<8wT-Qv zJ<0)%!8$tOek_#;#9ivXrALkOc$RLZdzkx2%jM%}dvIN4nkUX?_q}KNLR*KP zUgD&FkHThL|6D|y=h5pU|8-OdBL3-o(O)H-j?k=yq?n6WV;RdV9}#6{YfdBfM3&>> zB^5t!w^;LG+46(i@Oh`9pJGtVKO`k*q7x7B-?sINqsCe`F?56y?QKkuPoLG&S zI|K6cVlj?ARNZ>BCTwV79N=txNpLc1H- zUOY`b<4Bd5AwstfY|kF#IrF&?k1cs;0q?BCJMqDw^dD`~W);WP_*Yl#%PG#nwR;L? zlvnr>2Zgmt1N?KrmEx3`a=}K|H7jYNFp4^OXEBi8&U~|;<6^pG8mvy=0k*lD5atF4 zaB~g6zP@{weK2Hawab^V9Jt>=J=f2RqP4?DpMAZYUxoY zjt%>7x;qHPJKeN66HPj)C{w(GHEud|f!`>x8b1l&m}!`TaI1ziL)gMvmFTG&Q=aK| zBp}XUfDtTKNS-Ae(yszXb90J&&-v(c;r}Y*O(c1K!N3|)yk7ZWX$Y4khf*6fT_U17 zUr2wYNmF*sXoqWFQ>;X!y{WB6RQJ!z-fjPxERG=v|S?q z8>{dAjORYCLvwN^UqisYv#JI9p;XibhMFVq-$L=Fp|Qoh$rWFG?H7E|Ud3d3!MuL&bw zuRgi}?UDW@>3)7T&!BpaGbf|BSiP!_bj9`xI6=?qH0&hB5FvI|=pFFZ9rB4gCLf)3XVN|mwpVxVc zBSF-tB1z2wlx&a5xQ^#mXQGamF7gza5jnzG|CV&2cHTB@qc`0KXoIsXg!+dk3*ma} zq=!<6ci;@|Lf1NsvUS0I?Hj}y(^3Z3K54>^EA7!)O-b}uOEE>#NC~aCgzEk?HRJ>^ zm^YnlFY@FMysdVIB>ohe)hGU+l&F02$yTIeog5soS8wGlrj*bR|8$6uL)x(>gUO4j zpjhx#wzrFA+Grdm08UPZc_xA0f>CVUf^XV))hWQ-4=UhC37|pPgguKqm72+Za8ag>P@q@dJ|O!CQN`-FTMI3A|dmkvmA!99S#ML}Z` zZaNrHiU_h21K-k#BC+VWP(vdOj@Qi#QykkM)uyhh7|h{DLIsFx;DfGI(ZFbOP*QXD z6PxNP1%WW#-#e*K3`yDR?0%K%V~sjl^?W+2H56{%15ZLh`qT~7O}Zla^^SS21y}^~ z1(u`I=V1WyCOCAAir2F%rVkWKBHi~W6>b>R-l&5ak0J0SqYF>fy|I(;tPo@(KkGYB zUefdUndcPUN1nnwrvOeg@3B=eJia2J0vOE+E&kBW<`-V4gRn;nNS4XS*i4JAdTYhb zx|=pp z!Bj;r<(gR+oL8)QOT&Vc09s}Z-}Q&e@OTHy55N_(Bq@DHeKwt7etw8c#MfwpJp-w` z0>zlT=Q|||ZF3nUsti3e^Z8|p#GES+VoS;_4X*YtKmnsnUJOCyidqf%-@h=ju2~5r z;aQhC;@@n!UY~q5^S0t%3&Yp{iaGsf$%7Y{bB#F=ZldTJ<=C)?%e^xFkhb-v$5hSr zfW7S`aVD(2R+iBs0L!|n8V%ej{N&BDTH9NCFMW$F9EaV8s;2dvYobP(apj>H=1|pb zn6ras; zyd7#{W)KAGFLQ|lH(;N~J`O$5G=-W>E&asJctrnD0-=x7(~GrDYTcE%3$cat zbkf}r~m)} literal 0 HcmV?d00001 diff --git a/app/assets/images/push-notifications/linked.png b/app/assets/images/push-notifications/linked.png new file mode 100644 index 0000000000000000000000000000000000000000..5e25f2426a1ac4db09462d0491382164b647d27c GIT binary patch literal 1898 zcmeH{`CHNl9LG^n2vH>Q$UO2uP?>k))kG5wRKz1ElI4A%I8iJ!GqiMy&E}m)$`>zM zrAeA;W_GxQS!#&Qqh`%{6=qM5Wz*K!Put(HXP@Wud4HbwukZKsyh^APUp1H>3>of0 z3=TaU7EU`7dGuIxOl(|2Vp4KSD)ZZPRwnyI_Q_MZd7Of?g+;|BrRU2olvh;OUTnC; zYiVo0)+Oxe?du;L8h$YHXjDA*WPDO0eKs>Y|6<|Q;?m0MYuVd(?>9ew+Ws7c?wkjK zz%?Xq&rsbxvU}U~ka!Se<85@Tj*3a`;MgsJZrdKO2wGirjVHd+kEU1DT30*R*1n(| zFcJ_2woZ3j-tH)ZKRNlU?9Hv^)tOTt9&CRiNE`$H*&U6U)8-;{>6y)+f@sr`{C%69 z`CO!ad{dm2rMCp7x(S%{#H}QNwRq24Z%a0wYgdl!z?zx8;q#;|#ucKC>Bgi}=?ls* zehxP@W}EvoIVeLoL%4+@idb8G34e6HEMD4&bx|>5hokD~*ZGX|6-K~(PNhkvLux*Y zT;|oih9V}of%Dy}auL0hkhq%%cMtlBG(sZ+%V&z*@9iSz024&mSQtu>M-b7e=KlFJ zFdZqvLi~^?wssYMm9CZJJ*g$I_wvPXp#i}?NbY+?&THH$g@(}_=klZ#7VmU!+he9zrIU~ufJ>OAXlgtMY3n%cBwmz zH}_&Y6l%;gA=Bg62?-c+G6yYs*hgn)LVtH0t%##}dx(>N_%f~`*L5N8aZr`y8+K*d zdO~uZm&@iwoGnsmfL0$}74lh4eD$c3pWGayhS57GyPb836ufF@pmUi%v}vDIwW z&UET=V^)9HW6re(VJfDEnd>96*owl$*M%)kjR$3@h%>(2JK!8e z9jm&)2OB++AK$fU;L)R?eBO#Zh(N<#;n|n#0U2K4>8t531DJ{ocfJheE|oYT_WP`^ z6xG@?X@c(30_*G~K2x1>4!h>bYL*;_a2q?45Zsaj9ZWFSF-WMhe!r6arpVGGM2JY^ zlVA*hEHy(LwNO**3s{01<^dwlMY@Au5C+AF;4P|->MdCeD7V#rXph;2=611bV5_IeMA~zBh4WhptWn4Iu>;^T!mG+DjxDA?Oz}{leq=Lz=aC~w4o{A z4L>H=rd#LFlE)Jg!>!8+T*I7&u9XmNsq$UoZd?pxMP1hxZ-@X2iYk%3v0(-cG^QoK98 Hq6+>3Y0HMR literal 0 HcmV?d00001 diff --git a/app/assets/images/push-notifications/mentioned.png b/app/assets/images/push-notifications/mentioned.png new file mode 100644 index 0000000000000000000000000000000000000000..ebb5560414b8bdae210c0c4023fc70832555495b GIT binary patch literal 2653 zcmeHJ={FmS5|3SDZ4<;=lr*7=3Qg4-Nknd>5uughilwBg){@)S78J3?TCJsm1XXL( z#a7!>lxn0}t=okjTWK#vYw6;8@0|DHeR%)DopWa9%!m0fzxmCQP9i$W0o8y206@+O zhbHg&(toS8*q-e=cPt(N5Y;6SD44x4_aFHOf&Y&HdxYz^$C7(dWTGoTL{v;%LQ+b4 zA5cbCPF`XE0T5V8Sp}l1rlF~IP+Ld$ke)vDFbr;Bc*N+av5Bb}!rbCG@`R<8wT-Qv zJ<0)%!8$tOek_#;#9ivXrALkOc$RLZdzkx2%jM%}dvIN4nkUX?_q}KNLR*KP zUgD&FkHThL|6D|y=h5pU|8-OdBL3-o(O)H-j?k=yq?n6WV;RdV9}#6{YfdBfM3&>> zB^5t!w^;LG+46(i@Oh`9pJGtVKO`k*q7x7B-?sINqsCe`F?56y?QKkuPoLG&S zI|K6cVlj?ARNZ>BCTwV79N=txNpLc1H- zUOY`b<4Bd5AwstfY|kF#IrF&?k1cs;0q?BCJMqDw^dD`~W);WP_*Yl#%PG#nwR;L? zlvnr>2Zgmt1N?KrmEx3`a=}K|H7jYNFp4^OXEBi8&U~|;<6^pG8mvy=0k*lD5atF4 zaB~g6zP@{weK2Hawab^V9Jt>=J=f2RqP4?DpMAZYUxoY zjt%>7x;qHPJKeN66HPj)C{w(GHEud|f!`>x8b1l&m}!`TaI1ziL)gMvmFTG&Q=aK| zBp}XUfDtTKNS-Ae(yszXb90J&&-v(c;r}Y*O(c1K!N3|)yk7ZWX$Y4khf*6fT_U17 zUr2wYNmF*sXoqWFQ>;X!y{WB6RQJ!z-fjPxERG=v|S?q z8>{dAjORYCLvwN^UqisYv#JI9p;XibhMFVq-$L=Fp|Qoh$rWFG?H7E|Ud3d3!MuL&bw zuRgi}?UDW@>3)7T&!BpaGbf|BSiP!_bj9`xI6=?qH0&hB5FvI|=pFFZ9rB4gCLf)3XVN|mwpVxVc zBSF-tB1z2wlx&a5xQ^#mXQGamF7gza5jnzG|CV&2cHTB@qc`0KXoIsXg!+dk3*ma} zq=!<6ci;@|Lf1NsvUS0I?Hj}y(^3Z3K54>^EA7!)O-b}uOEE>#NC~aCgzEk?HRJ>^ zm^YnlFY@FMysdVIB>ohe)hGU+l&F02$yTIeog5soS8wGlrj*bR|8$6uL)x(>gUO4j zpjhx#wzrFA+Grdm08UPZc_xA0f>CVUf^XV))hWQ-4=UhC37|pPgguKqm72+Za8ag>P@q@dJ|O!CQN`-FTMI3A|dmkvmA!99S#ML}Z` zZaNrHiU_h21K-k#BC+VWP(vdOj@Qi#QykkM)uyhh7|h{DLIsFx;DfGI(ZFbOP*QXD z6PxNP1%WW#-#e*K3`yDR?0%K%V~sjl^?W+2H56{%15ZLh`qT~7O}Zla^^SS21y}^~ z1(u`I=V1WyCOCAAir2F%rVkWKBHi~W6>b>R-l&5ak0J0SqYF>fy|I(;tPo@(KkGYB zUefdUndcPUN1nnwrvOeg@3B=eJia2J0vOE+E&kBW<`-V4gRn;nNS4XS*i4JAdTYhb zx|=pp z!Bj;r<(gR+oL8)QOT&Vc09s}Z-}Q&e@OTHy55N_(Bq@DHeKwt7etw8c#MfwpJp-w` z0>zlT=Q|||ZF3nUsti3e^Z8|p#GES+VoS;_4X*YtKmnsnUJOCyidqf%-@h=ju2~5r z;aQhC;@@n!UY~q5^S0t%3&Yp{iaGsf$%7Y{bB#F=ZldTJ<=C)?%e^xFkhb-v$5hSr zfW7S`aVD(2R+iBs0L!|n8V%ej{N&BDTH9NCFMW$F9EaV8s;2dvYobP(apj>H=1|pb zn6ras; zyd7#{W)KAGFLQ|lH(;N~J`O$5G=-W>E&asJctrnD0-=x7(~GrDYTcE%3$cat zbkf}r~m)} literal 0 HcmV?d00001 diff --git a/app/assets/images/push-notifications/posted.png b/app/assets/images/push-notifications/posted.png new file mode 100644 index 0000000000000000000000000000000000000000..41d02aff0eb818cf92e97266eaec467cb70b220b GIT binary patch literal 1224 zcmeAS@N?(olHy`uVBq!ia0vp^b3mAb8A$F;Gpc7`V9X2f32_B-CyoMog+Q12k?p_` zuPh1j3ua(sVrF4wW9Q)J;pG<)6cQE@6_b>fl~YzxRnyec(bdy8F*CQav9)(_barub z_wx4f3k(Vg4GWKsOHN78$jr_yEw8AosjF{jZf)!C>Fb|3ecpn_OINR5zh(Q*eTR>q zI&=QwfqFL{J7Znf?Y2k_ry!iY6WR4puzKV(Ox}P8ZS$qDB@!XRp zmgY)5hQ}n5VT41LZp{7Gtljx{_>ynQRhb1B733a!(vX{GzJXUDeA~l^HD(rb?zgpZ z-#Adq4nA0vqp`zdw&bp$cb|@aPPh83>)U(Nq-AZ^ z{QF|(Oy3{avUZ!vo$Eo&S<#t)ZplT=4&P|cp8Z)Wvpga9p6#W^?Bd@g0@t5?tFc~p zw)SG~v8_A5MsKz?_*R|ua@$)Ysl6K)&kj5|Q#`CdRd$Z#hDPa-Lo@fV#{6m)dY3+z zHMVzJUc`jb+!MPj*(0ZmC){14@cNt5>pYGP@46LUOHC{lcZoDNeU@GG=<%ED_8;fp zbd3}@d}6WE=kP+|z2}#iAJXmEl(%NU3um~P(;{wsb*f7dRH-**}( zYVBCOXmz)_+%{MH_whopv1d{*@04uS?pUYy?ai%S5uMelyLSDUU2r#sH7}&3l#}iC z8z%{K#a+8pva9}n7s!h$DeYYLkMHRI4vpAN#U0ld+D4a@o-CNTlp!y$q?GsMy4|8O zNv`43Kdey`KCo9jH1^T=hUs;8l(+u1w~d(f(yQjGp#8e=4PHMB3+89;{rqrK#tymr zd~Ioy)o#CDeKza#j>`oWKR@z^_qR(&Z$A6z=97(Y6_2NFzh3a0p)fo=_j7}pW%l2O zb4lvv-#2VEl?|@mx$(kB(HM1=cUR(Xzn}8pp1^m7+)KT`Qn!WAKPQm5-1=JIQ<17i zwYQ%%JaSvx_h;Jgv@`2PwAN+#n6L7^_53{-Jlz0O(8m84j1+Dje&O^~r4pDy7(8A5 KT-G@yGywp;vSxPx literal 0 HcmV?d00001 diff --git a/app/assets/images/push-notifications/private_message.png b/app/assets/images/push-notifications/private_message.png new file mode 100644 index 0000000000000000000000000000000000000000..8e71e69c7fe1027f47fc6adfbf1928d80da6fb75 GIT binary patch literal 1444 zcmeAS@N?(olHy`uVBq!ia0vp^b3mAb8A$F;Gpc7`V5|!832_B-Cys)F2!YN}MOI+w z*OmnN1v4-*F|)9;v2$>8ar5x<@e2qFi-?L#NJ>e|$jT`wDrsnH85$Xzn3|beSlZY* zI6Awzd;13lg+@e1$Hrx57Z#V4mRD5P)YUgMHFr;(G-c}Sx$_n*Ua@+^rY&2y@7%TT zz`?`EPnb6{s!iRdoLADcj&l-cOo&3eNj6|J*TuYEz1!VBzfgcYoSCIwY4${M6rZ zRA2nOd)fr=kEhpeJh`wtHm}|PozC{zjWtE)JD=(qp3*Hi?Q3{yrd-_iVD_G(9}VH} z42w>szOuOMH+xOdk4eGjqF#MCktja@cI}gCr%j4>-khH3Q}ko&yyI_uqMBv$DlP9w z)i2_+{Btzm++6>Ka+WqP!b^9THQXt(#$_F&r%{3m+aK73qwMmJGVZF?j=-Wc;S5cD?<+X?k0G(F6ZW z2KlGgorp<%HRVgQon^twia|<)aS=oFl&){P zyUX7#n|WO3%J$P=`ej(ws@MgtdEc@%f}?42(Syik3zb>ZZOU7ubSe)-S{lS%cp%Lh zwDvuB>^!prnU)Q=zdU%mO?H3T#Aniu%(S*5`MvrpySRwq-MpDDYLjQYES!G%R^rZ60+thn!RWXD(=D>%r}96h zOijvpvj0J8-uC_MYlXZVmwlBBoEF;Tb$#{an^L HB{Ts5NzTht literal 0 HcmV?d00001 diff --git a/app/assets/images/push-notifications/quoted.png b/app/assets/images/push-notifications/quoted.png new file mode 100644 index 0000000000000000000000000000000000000000..01d889b468aff03bd17acbed41156a72ecc5fb70 GIT binary patch literal 1000 zcmeAS@N?(olHy`uVBq!ia0vp^b3mAb8A$F;Gpc7`V3ZB;32_B-CyoLfA@GK`@HH@i z;!1-2f*BZ@m|0la*f}`4czF2)gd}9-}}tgP+qUESP0y}W%x!@^@?HaT*s^u|&OQ4M96WyF;^nK?Z@zf{@$=X3KY#x{ z9nk-bfq_ZJ)5S5Q;?~<+H-m%&MOqVgO>XFNJ`y>{)MU}3rY_+oWs4?eH|Cq~|4ZDv z<-4l*-2LBE#Miz5w|=X(_}q|5ot%OQ@X^5X_aE>3Zj%2hHeV?9ns9Bd?dq);OFd@1 zTyGY(zVMLD%YJ#I-M0-AZ{2rpJELdfwczOnMd^}RDrZU_Ot|@%jWdKr|Esjhh2;-a zUw>keYSW+lkn=K^{3dCOMk_&YR=<)teU}TZ{Eol0kX`;5!g*J3uh$*V#QD{_j#K4A z)gK{`1>GOL6&j=L7f*L^iUmn+mDa^Ssd8iC&DNz;_uEwN zHP7Qz$@>(2(Oco*xug1xEmaag4KE@jW1K^ycO)1RDA3-c;7DbToCs&G{Sqb!Q4~-jn}DpF4+2Mf~@Z( zgYKyd*Q#(9Z#GJq9<1W=ZH1Ld#&7jAC*QuBp!-YR<@cN4wbt9LU(Px5@>|!U8Td(5LJ!gF0>a2YKfFsl1HL|nHy%yZk7muq@y>NWi!n0?zKia=5zO7qm z|1R|1^a}ayb1c>N9sg_fcGeEL?>VQ;8_nF_?3w-1Z$b6%vasW}j~tnN-%g2d)P8Iq y)X-w}V@rP8;kj?Odtdi&vE_15LIM;2F+5MSsQY%QhzFP=89ZJ6T-G@yGywo-#M(Fj literal 0 HcmV?d00001 diff --git a/app/assets/images/push-notifications/replied.png b/app/assets/images/push-notifications/replied.png new file mode 100644 index 0000000000000000000000000000000000000000..41d02aff0eb818cf92e97266eaec467cb70b220b GIT binary patch literal 1224 zcmeAS@N?(olHy`uVBq!ia0vp^b3mAb8A$F;Gpc7`V9X2f32_B-CyoMog+Q12k?p_` zuPh1j3ua(sVrF4wW9Q)J;pG<)6cQE@6_b>fl~YzxRnyec(bdy8F*CQav9)(_barub z_wx4f3k(Vg4GWKsOHN78$jr_yEw8AosjF{jZf)!C>Fb|3ecpn_OINR5zh(Q*eTR>q zI&=QwfqFL{J7Znf?Y2k_ry!iY6WR4puzKV(Ox}P8ZS$qDB@!XRp zmgY)5hQ}n5VT41LZp{7Gtljx{_>ynQRhb1B733a!(vX{GzJXUDeA~l^HD(rb?zgpZ z-#Adq4nA0vqp`zdw&bp$cb|@aPPh83>)U(Nq-AZ^ z{QF|(Oy3{avUZ!vo$Eo&S<#t)ZplT=4&P|cp8Z)Wvpga9p6#W^?Bd@g0@t5?tFc~p zw)SG~v8_A5MsKz?_*R|ua@$)Ysl6K)&kj5|Q#`CdRd$Z#hDPa-Lo@fV#{6m)dY3+z zHMVzJUc`jb+!MPj*(0ZmC){14@cNt5>pYGP@46LUOHC{lcZoDNeU@GG=<%ED_8;fp zbd3}@d}6WE=kP+|z2}#iAJXmEl(%NU3um~P(;{wsb*f7dRH-**}( zYVBCOXmz)_+%{MH_whopv1d{*@04uS?pUYy?ai%S5uMelyLSDUU2r#sH7}&3l#}iC z8z%{K#a+8pva9}n7s!h$DeYYLkMHRI4vpAN#U0ld+D4a@o-CNTlp!y$q?GsMy4|8O zNv`43Kdey`KCo9jH1^T=hUs;8l(+u1w~d(f(yQjGp#8e=4PHMB3+89;{rqrK#tymr zd~Ioy)o#CDeKza#j>`oWKR@z^_qR(&Z$A6z=97(Y6_2NFzh3a0p)fo=_j7}pW%l2O zb4lvv-#2VEl?|@mx$(kB(HM1=cUR(Xzn}8pp1^m7+)KT`Qn!WAKPQm5-1=JIQ<17i zwYQ%%JaSvx_h;Jgv@`2PwAN+#n6L7^_53{-Jlz0O(8m84j1+Dje&O^~r4pDy7(8A5 KT-G@yGywp;vSxPx literal 0 HcmV?d00001 diff --git a/app/assets/javascripts/discourse/components/desktop-notification-config.js.es6 b/app/assets/javascripts/discourse/components/desktop-notification-config.js.es6 index d1ad4f28ab..9b981b6b8f 100644 --- a/app/assets/javascripts/discourse/components/desktop-notification-config.js.es6 +++ b/app/assets/javascripts/discourse/components/desktop-notification-config.js.es6 @@ -1,6 +1,13 @@ import computed from 'ember-addons/ember-computed-decorators'; import KeyValueStore from 'discourse/lib/key-value-store'; -import { context } from 'discourse/lib/desktop-notifications'; +import { context, confirmNotification } from 'discourse/lib/desktop-notifications'; +import { + subscribe as subscribePushNotification, + unsubscribe as unsubscribePushNotification, + isPushNotificationsSupported, + keyValueStore as pushNotificationKeyValueStore, + userSubscriptionKey as pushNotificationUserSubscriptionKey +} from 'discourse/lib/push-notifications'; const keyValueStore = new KeyValueStore(context); @@ -28,11 +35,6 @@ export default Ember.Component.extend({ return typeof window.Notification === "undefined"; }, - @computed("isNotSupported", "notificationsPermission") - isDefaultPermission(isNotSupported, notificationsPermission) { - return isNotSupported ? false : notificationsPermission === "default"; - }, - @computed("isNotSupported", "notificationsPermission") isDeniedPermission(isNotSupported, notificationsPermission) { return isNotSupported ? false : notificationsPermission === "denied"; @@ -44,27 +46,65 @@ export default Ember.Component.extend({ }, @computed("isGrantedPermission", "notificationsDisabled") - isEnabled(isGrantedPermission, notificationsDisabled) { + isEnabledDesktop(isGrantedPermission, notificationsDisabled) { return isGrantedPermission ? !notificationsDisabled : false; }, - actions: { - requestPermission() { - Notification.requestPermission(() => this.propertyDidChange('notificationsPermission')); + @computed + isEnabledPush: { + set(value) { + const user = this.currentUser; + if(!user) { + return false; + } + pushNotificationKeyValueStore.setItem(pushNotificationUserSubscriptionKey(user), value); + return pushNotificationKeyValueStore.getItem(pushNotificationUserSubscriptionKey(user)); }, + get() { + const user = this.currentUser; + return user ? pushNotificationKeyValueStore.getItem(pushNotificationUserSubscriptionKey(user)) : false; + } + }, + isEnabled: Ember.computed.or("isEnabledDesktop", "isEnabledPush"), + + isPushNotificationsPreferred() { + if(!this.site.mobileView) { + return false; + } + return isPushNotificationsSupported(this.site.mobileView); + }, + + actions: { recheckPermission() { this.propertyDidChange('notificationsPermission'); }, turnoff() { - this.set('notificationsDisabled', 'disabled'); - this.propertyDidChange('notificationsPermission'); + if(this.get('isEnabledDesktop')) { + this.set('notificationsDisabled', 'disabled'); + this.propertyDidChange('notificationsPermission'); + } + if(this.get('isEnabledPush')) { + unsubscribePushNotification(this.currentUser, () => { + this.set("isEnabledPush", ''); + }); + } }, turnon() { - this.set('notificationsDisabled', ''); - this.propertyDidChange('notificationsPermission'); + if(this.isPushNotificationsPreferred()) { + subscribePushNotification(() => { + this.set("isEnabledPush", 'subscribed'); + }, this.siteSettings.vapid_public_key_bytes); + } + else { + this.set('notificationsDisabled', ''); + Notification.requestPermission(() => { + confirmNotification(); + this.propertyDidChange('notificationsPermission'); + }); + } } } }); diff --git a/app/assets/javascripts/discourse/components/notification-consent-banner.js.es6 b/app/assets/javascripts/discourse/components/notification-consent-banner.js.es6 new file mode 100644 index 0000000000..2c2f5c1012 --- /dev/null +++ b/app/assets/javascripts/discourse/components/notification-consent-banner.js.es6 @@ -0,0 +1,45 @@ +import { default as computed } from 'ember-addons/ember-computed-decorators'; + +import { + keyValueStore as pushNotificationKeyValueStore +} from 'discourse/lib/push-notifications'; + +import { default as DesktopNotificationConfig } from 'discourse/components/desktop-notification-config'; + +const userDismissedPromptKey = "dismissed-prompt"; + +export default DesktopNotificationConfig.extend({ + @computed + bannerDismissed: { + set(value) { + pushNotificationKeyValueStore.setItem(userDismissedPromptKey, value); + return pushNotificationKeyValueStore.getItem(userDismissedPromptKey); + }, + get() { + return pushNotificationKeyValueStore.getItem(userDismissedPromptKey); + } + }, + + @computed("isNotSupported", "isEnabled", "bannerDismissed", "currentUser.reply_count", "currentUser.topic_count") + showNotificationPromptBanner(isNotSupported, isEnabled, bannerDismissed, replyCount, topicCount) { + return (this.siteSettings.push_notifications_prompt && + !isNotSupported && + this.currentUser && + replyCount + topicCount > 0 && + Notification.permission !== "denied" && + Notification.permission !== "granted" && + !isEnabled && + !bannerDismissed + ); + }, + + actions: { + turnon() { + this._super(); + this.set("bannerDismissed", true); + }, + dismiss() { + this.set("bannerDismissed", true); + } + } +}); diff --git a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 index a477463d95..fd2c8ff418 100644 --- a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 +++ b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 @@ -2,8 +2,14 @@ import { init as initDesktopNotifications, onNotification, - alertChannel + alertChannel, + disable as disableDesktopNotifications, } from 'discourse/lib/desktop-notifications'; +import { + register as registerPushNotifications, + unsubscribe as unsubscribePushNotifications, + isPushNotificationsEnabled +} from 'discourse/lib/push-notifications'; export default { name: 'subscribe-user-notifications', @@ -11,14 +17,9 @@ export default { initialize(container) { const user = container.lookup('current-user:main'); - const keyValueStore = container.lookup('key-value-store:main'); const bus = container.lookup('message-bus:main'); const appEvents = container.lookup('app-events:main'); - // clear old cached notifications, we used to store in local storage - // TODO 2017 delete this line - keyValueStore.remove('recent-notifications'); - if (user) { if (user.get('staff')) { bus.subscribe('/flagged_counts', data => { @@ -87,6 +88,7 @@ export default { const site = container.lookup('site:main'); const siteSettings = container.lookup('site-settings:main'); + const router = container.lookup('router:main'); bus.subscribe("/categories", data => { _.each(data.categories, c => site.updateCategory(c)); @@ -100,9 +102,16 @@ export default { }); if (!Ember.testing) { - if (!site.mobileView) { - bus.subscribe(alertChannel(user), data => onNotification(data, user)); - initDesktopNotifications(bus, appEvents); + + bus.subscribe(alertChannel(user), data => onNotification(data, user)); + initDesktopNotifications(bus, appEvents); + + if(isPushNotificationsEnabled(user, site.mobileView)) { + disableDesktopNotifications(); + registerPushNotifications(Discourse.User.current(), site.mobileView, router, appEvents); + } + else { + unsubscribePushNotifications(user); } } } diff --git a/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 b/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 index 12c481e4af..d07442d24c 100644 --- a/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 +++ b/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 @@ -55,6 +55,22 @@ function init(messageBus, appEvents) { } } +function confirmNotification() { + const notification = new Notification(I18n.t("notifications.popup.confirm_title", {site_title: Discourse.SiteSettings.title}), { + body: I18n.t("notifications.popup.confirm_body"), + icon: Discourse.SiteSettings.logo_small_url || Discourse.SiteSettings.logo_url, + tag: "confirm-subscription" + }); + + const clickEventHandler = () => notification.close(); + + notification.addEventListener('click', clickEventHandler); + setTimeout(() => { + notification.close(); + notification.removeEventListener('click', clickEventHandler); + }, 10 * 1000); +} + // This function is only called if permission was granted function setupNotifications(appEvents) { @@ -167,4 +183,8 @@ function unsubscribe(bus, user) { bus.unsubscribe(alertChannel(user)); } -export { context, init, onNotification, unsubscribe, alertChannel }; +function disable() { + keyValueStore.setItem('notifications-disabled', 'disabled'); +} + +export { context, init, onNotification, unsubscribe, alertChannel, confirmNotification, disable }; diff --git a/app/assets/javascripts/discourse/lib/push-notifications.js.es6 b/app/assets/javascripts/discourse/lib/push-notifications.js.es6 new file mode 100644 index 0000000000..53976b1cba --- /dev/null +++ b/app/assets/javascripts/discourse/lib/push-notifications.js.es6 @@ -0,0 +1,119 @@ +import { ajax } from 'discourse/lib/ajax'; +import KeyValueStore from 'discourse/lib/key-value-store'; + +export const keyValueStore = new KeyValueStore("discourse_push_notifications_"); + +export function userSubscriptionKey(user) { + return `subscribed-${user.get('id')}`; +} + +function sendSubscriptionToServer(subscription, sendConfirmation) { + ajax('/push_notifications/subscribe', { + type: 'POST', + data: { subscription: subscription.toJSON(), send_confirmation: sendConfirmation } + }); +} + +function userAgentVersionChecker(agent, version, mobileView) { + const uaMatch = navigator.userAgent.match(new RegExp(`${agent}\/(\\d+)\\.\\d`)); + if (uaMatch && mobileView) return false; + if (!uaMatch || parseInt(uaMatch[1]) < version) return false; + return true; +} + +function resetIdle() { + if('controller' in navigator.serviceWorker && navigator.serviceWorker.controller != null) { + navigator.serviceWorker.controller.postMessage({lastAction: Date.now()}); + } +} + +function setupActivityListeners(appEvents) { + window.addEventListener("focus", resetIdle); + + if (document) { + document.addEventListener("scroll", resetIdle); + } + + appEvents.on('page:changed', resetIdle); +} + +export function isPushNotificationsSupported(mobileView) { + if (!(('serviceWorker' in navigator) && + (ServiceWorkerRegistration && + (typeof(Notification) !== "undefined") && + ('showNotification' in ServiceWorkerRegistration.prototype) && + ('PushManager' in window)))) { + + return false; + } + + if ((!userAgentVersionChecker('Firefox', 44, mobileView)) && + (!userAgentVersionChecker('Chrome', 50))) { + return false; + } + + return true; +} + +export function isPushNotificationsEnabled(user, mobileView) { + return user && isPushNotificationsSupported(mobileView) && keyValueStore.getItem(userSubscriptionKey(user)); +} + +export function register(user, mobileView, router, appEvents) { + if (!isPushNotificationsSupported(mobileView)) return; + if (Notification.permission === 'denied' || !user) return; + + navigator.serviceWorker.ready.then(serviceWorkerRegistration => { + serviceWorkerRegistration.pushManager.getSubscription().then(subscription => { + if (subscription) { + sendSubscriptionToServer(subscription, false); + // Resync localStorage + keyValueStore.setItem(userSubscriptionKey(user), 'subscribed'); + } + setupActivityListeners(appEvents); + }).catch(e => Ember.Logger.error(e)); + }); + + navigator.serviceWorker.addEventListener('message', (event) => { + if ('url' in event.data) { + const url = event.data.url; + router.handleURL(url); + } + }); +} + +export function subscribe(callback, applicationServerKey, mobileView) { + if (!isPushNotificationsSupported(mobileView)) return; + + navigator.serviceWorker.ready.then(serviceWorkerRegistration => { + serviceWorkerRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: new Uint8Array(applicationServerKey.split("|")) // eslint-disable-line no-undef + }).then(subscription => { + sendSubscriptionToServer(subscription, true); + if (callback) callback(); + }).catch(e => Ember.Logger.error(e)); + }); +} + +export function unsubscribe(user, callback, mobileView) { + if (!isPushNotificationsSupported(mobileView)) return; + + keyValueStore.setItem(userSubscriptionKey(user), ''); + navigator.serviceWorker.ready.then(serviceWorkerRegistration => { + serviceWorkerRegistration.pushManager.getSubscription().then(subscription => { + if (subscription) { + subscription.unsubscribe().then((successful) => { + if (successful) { + ajax('/push_notifications/unsubscribe', { + type: 'POST', + data: { subscription: subscription.toJSON() } + }); + } + }); + } + }).catch(e => Ember.Logger.error(e)); + + if (callback) callback(); + }); +} diff --git a/app/assets/javascripts/discourse/templates/application.hbs b/app/assets/javascripts/discourse/templates/application.hbs index 6063284f8e..c75d0087c1 100644 --- a/app/assets/javascripts/discourse/templates/application.hbs +++ b/app/assets/javascripts/discourse/templates/application.hbs @@ -14,6 +14,7 @@ {{#if showTop}} {{custom-html name="top"}} {{/if}} + {{notification-consent-banner}} {{global-notice}} {{create-topics-notice}}
diff --git a/app/assets/javascripts/discourse/templates/components/desktop-notification-config.hbs b/app/assets/javascripts/discourse/templates/components/desktop-notification-config.hbs index dc8edd2018..d1ffffbdee 100644 --- a/app/assets/javascripts/discourse/templates/components/desktop-notification-config.hbs +++ b/app/assets/javascripts/discourse/templates/components/desktop-notification-config.hbs @@ -1,15 +1,10 @@ - {{#if isNotSupported}} {{d-button icon="bell-slash" label="user.desktop_notifications.not_supported" disabled="true"}} {{/if}} -{{#if isDefaultPermission}} - {{d-button icon="bell-slash" label="user.desktop_notifications.perm_default" action="requestPermission"}} -{{/if}} {{#if isDeniedPermission}} {{d-button icon="bell-slash" label="user.desktop_notifications.perm_denied_btn" action="recheckPermission" disabled='true'}} {{i18n "user.desktop_notifications.perm_denied_expl"}} -{{/if}} -{{#if isGrantedPermission}} +{{else}} {{#if isEnabled}} {{d-button icon="bell-slash-o" label="user.desktop_notifications.disable" action="turnoff"}} {{i18n "user.desktop_notifications.currently_enabled"}} diff --git a/app/assets/javascripts/discourse/templates/components/notification-consent-banner.hbs b/app/assets/javascripts/discourse/templates/components/notification-consent-banner.hbs new file mode 100644 index 0000000000..f258abf113 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/notification-consent-banner.hbs @@ -0,0 +1,8 @@ +{{#if showNotificationPromptBanner}} +
+ +
+{{/if}} diff --git a/app/assets/javascripts/service-worker.js.erb b/app/assets/javascripts/service-worker.js.erb index f091e07dc2..959ed9a716 100644 --- a/app/assets/javascripts/service-worker.js.erb +++ b/app/assets/javascripts/service-worker.js.erb @@ -95,6 +95,80 @@ self.addEventListener('fetch', function(event) { // handled by the browser as if there were no service worker involvement. }); + +const idleThresholdTime = 1000 * 10; // 10 seconds +var lastAction = -1; + +function isIdle() { + return lastAction + idleThresholdTime < Date.now(); +} + +function showNotification(title, body, icon, badge, tag, baseUrl, url) { + var notificationOptions = { + body: body, + icon: icon, + badge: badge, + data: { url: url, baseUrl: baseUrl }, + tag: tag + } + + return self.registration.showNotification(title, notificationOptions); +} + +self.addEventListener('push', function(event) { + var payload = event.data.json(); + if(!isIdle() && payload.hide_when_active) { + return false; + } + + event.waitUntil( + self.registration.getNotifications({ tag: payload.tag }).then(function(notifications) { + if (notifications && notifications.length > 0) { + notifications.forEach(function(notification) { + notification.close(); + }); + } + + return showNotification(payload.title, payload.body, payload.icon, payload.badge, payload.tag, payload.base_url, payload.url); + }) + ); +}); + +self.addEventListener('notificationclick', function(event) { + // Android doesn't close the notification when you click on it + // See: http://crbug.com/463146 + event.notification.close(); + var url = event.notification.data.url; + var baseUrl = event.notification.data.baseUrl; + + // This looks to see if the current window is already open and + // focuses if it is + event.waitUntil( + clients.matchAll({ type: "window" }) + .then(function(clientList) { + var reusedClientWindow = clientList.some(function(client) { + if (client.url === baseUrl + url && 'focus' in client) { + client.focus(); + return true; + } + + if ('postMessage' in client && 'focus' in client) { + client.focus(); + client.postMessage({ url: url }); + return true; + } + return false; + }); + + if (!reusedClientWindow && clients.openWindow) return clients.openWindow(baseUrl + url); + }) + ); +}); + +self.addEventListener('message', event => { + if('lastAction' in event.data){ + lastAction = event.data.lastAction; + }}); <% DiscoursePluginRegistry.service_workers.each do |js| %> <%=raw "#{File.read(js)}" %> <% end %> diff --git a/app/assets/stylesheets/mobile/push-notifications-mobile.scss b/app/assets/stylesheets/mobile/push-notifications-mobile.scss new file mode 100644 index 0000000000..3d538d62b9 --- /dev/null +++ b/app/assets/stylesheets/mobile/push-notifications-mobile.scss @@ -0,0 +1,3 @@ +.push-notification-prompt .consent_banner { + margin-bottom: 30px; +} diff --git a/app/controllers/push_notification_controller.rb b/app/controllers/push_notification_controller.rb new file mode 100644 index 0000000000..8f7d340b06 --- /dev/null +++ b/app/controllers/push_notification_controller.rb @@ -0,0 +1,21 @@ +class PushNotificationController < ApplicationController + layout false + before_action :ensure_logged_in + skip_before_action :preload_json + + def subscribe + PushNotificationPusher.subscribe(current_user, push_params, params[:send_confirmation]) + render json: success_json + end + + def unsubscribe + PushNotificationPusher.unsubscribe(current_user, push_params) + render json: success_json + end + + private + + def push_params + params.require(:subscription).permit(:endpoint, keys: [:p256dh, :auth]) + end +end diff --git a/app/jobs/regular/send_push_notification.rb b/app/jobs/regular/send_push_notification.rb new file mode 100644 index 0000000000..c27f1bbf27 --- /dev/null +++ b/app/jobs/regular/send_push_notification.rb @@ -0,0 +1,8 @@ +module Jobs + class SendPushNotification < Jobs::Base + def execute(args) + user = User.find(args[:user_id]) + PushNotificationPusher.push(user, args[:payload]) + end + end +end diff --git a/app/models/push_subscription.rb b/app/models/push_subscription.rb new file mode 100644 index 0000000000..f64c2b659d --- /dev/null +++ b/app/models/push_subscription.rb @@ -0,0 +1,13 @@ +class PushSubscription < ActiveRecord::Base + belongs_to :user +end + +# == Schema Information +# +# Table name: push_subscription +# +# id :integer not null, primary key +# user_id :integer not null +# data :string not null +# created_at :datetime not null +# updated_at :datetime not null diff --git a/app/models/user.rb b/app/models/user.rb index 9989bff0cf..e549cf2f16 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -80,6 +80,8 @@ class User < ActiveRecord::Base has_one :api_key, dependent: :destroy + has_many :push_subscriptions, dependent: :destroy + belongs_to :uploaded_avatar, class_name: 'Upload' has_many :acting_group_histories, dependent: :destroy, foreign_key: :acting_user_id, class_name: 'GroupHistory' diff --git a/app/services/push_notification_pusher.rb b/app/services/push_notification_pusher.rb new file mode 100644 index 0000000000..a9b4ab0f2a --- /dev/null +++ b/app/services/push_notification_pusher.rb @@ -0,0 +1,80 @@ +require_dependency 'webpush' + +class PushNotificationPusher + def self.push(user, payload) + subscriptions(user).each do |subscription| + subscription = JSON.parse(subscription.data) + + message = { + title: I18n.t( + "discourse_push_notifications.popup.#{Notification.types[payload[:notification_type]]}", + site_title: SiteSetting.title, + topic: payload[:topic_title], + username: payload[:username] + ), + body: payload[:excerpt], + badge: get_badge, + icon: ActionController::Base.helpers.image_url("push-notifications/#{Notification.types[payload[:notification_type]]}.png"), + tag: "#{Discourse.current_hostname}-#{payload[:topic_id]}", + base_url: Discourse.base_url, + url: payload[:post_url], + hide_when_active: true + } + send_notification user, subscription, message + end + end + + def self.subscriptions(user) + user.push_subscriptions + end + + def self.clear_subscriptions(user) + user.push_subscriptions.clear + end + + def self.subscribe(user, subscription, send_confirmation) + PushSubscription.create user: user, data: subscription.to_json + if send_confirmation == "true" + message = { + title: I18n.t("discourse_push_notifications.popup.confirm_title", + site_title: SiteSetting.title), + body: I18n.t("discourse_push_notifications.popup.confirm_body"), + icon: ActionController::Base.helpers.image_url("push-notifications/check.png"), + badge: get_badge, + tag: "#{Discourse.current_hostname}-subscription" + } + + send_notification user, subscription, message + end + end + + def self.unsubscribe(user, subscription) + PushSubscription.find_by(user: user, data: subscription.to_json)&.destroy + end + + protected + + def self.get_badge + return !SiteSetting.push_notifications_icon_url.blank? ? + SiteSetting.push_notifications_icon_url : + ActionController::Base.helpers.image_url("push-notifications/discourse.png") + end + + def self.send_notification(user, subscription, message) + begin + response = Webpush.payload_send( + endpoint: subscription["endpoint"], + message: message.to_json, + p256dh: subscription.dig("keys", "p256dh"), + auth: subscription.dig("keys", "auth"), + vapid: { + subject: Discourse.base_url, + public_key: SiteSetting.vapid_public_key, + private_key: SiteSetting.vapid_private_key + } + ) + rescue Webpush::InvalidSubscription => e + unsubscribe user, subscription + end + end +end diff --git a/config/initializers/100-push-notifications.rb b/config/initializers/100-push-notifications.rb new file mode 100644 index 0000000000..54e6a62010 --- /dev/null +++ b/config/initializers/100-push-notifications.rb @@ -0,0 +1,17 @@ +require_dependency 'webpush' + +if SiteSetting.vapid_public_key.blank? || SiteSetting.vapid_private_key.blank? + vapid_key = Webpush.generate_key + SiteSetting.vapid_public_key = vapid_key.public_key + SiteSetting.vapid_private_key = vapid_key.private_key +end + +SiteSetting.vapid_public_key_bytes = Base64.urlsafe_decode64(SiteSetting.vapid_public_key).bytes.join("|") + +DiscourseEvent.on(:post_notification_alert) do |user, payload| + Jobs.enqueue(:send_push_notification, user_id: user.id, payload: payload) +end + +DiscourseEvent.on(:user_logged_out) do |user| + PushNotificationPusher.clear_subscriptions(user) +end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 048a736883..18c2ebd037 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -617,7 +617,7 @@ en: notifications: "Notifications" statistics: "Stats" desktop_notifications: - label: "Desktop Notifications" + label: "Live Notifications" not_supported: "Notifications are not supported on this browser. Sorry." perm_default: "Turn On Notifications" perm_denied_btn: "Permission Denied" @@ -627,6 +627,7 @@ en: enable: "Enable Notifications" currently_disabled: "" each_browser_note: "Note: You have to change this setting on every browser you use." + consent_prompt: "Do you want live notifications when people reply to your posts?" dismiss: 'Dismiss' dismiss_notifications: "Dismiss All" dismiss_notifications_tooltip: "Mark all unread notifications as read" @@ -1439,6 +1440,8 @@ en: posted: '{{username}} posted in "{{topic}}" - {{site_title}}' private_message: '{{username}} sent you a personal message in "{{topic}}" - {{site_title}}' linked: '{{username}} linked to your post from "{{topic}}" - {{site_title}}' + confirm_title: 'Notifications enabled - %{site_title}' + confirm_body: 'Success! Notifications have been enabled.' upload_selector: title: "Add an image" @@ -2699,7 +2702,6 @@ en: safe_mode: enabled: "Safe mode is enabled, to exit safe mode close this browser window" - # This section is exported to the javascript for i18n in the admin section admin_js: type_to_filter: "type to filter..." @@ -3889,7 +3891,7 @@ en: label: "New:" add: "Add" filter: "Search (URL or External URL)" - + wizard_js: wizard: done: "Done" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 41b7fdcab2..c32b1b2ed9 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1710,6 +1710,9 @@ en: shared_drafts_category: "Enable the Shared Drafts feature by designating a category for topic drafts." + push_notifications_prompt: "Display user consent prompt." + push_notifications_icon_url: "The badge icon that appears in the notification corner. Recommended size is 96px by 96px." + errors: invalid_email: "Invalid email address." invalid_username: "There's no user with that username." @@ -3853,3 +3856,15 @@ en: graph_title: "Search Count" joined: "Joined" + + discourse_push_notifications: + popup: + mentioned: '%{username} mentioned you in "%{topic}" - %{site_title}' + group_mentioned: '%{username} mentioned you in "%{topic}" - %{site_title}' + quoted: '%{username} quoted you in "%{topic}" - %{site_title}' + replied: '%{username} replied to you in "%{topic}" - %{site_title}' + posted: '%{username} posted in "%{topic}" - %{site_title}' + private_message: '%{username} sent you a private message in "%{topic}" - %{site_title}' + linked: '%{username} linked to your post from "%{topic}" - %{site_title}' + confirm_title: 'Notifications enabled - %{site_title}' + confirm_body: 'Success! Notifications have been enabled.' diff --git a/config/routes.rb b/config/routes.rb index 76a3a69063..f3c0a2a26a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -802,4 +802,7 @@ Discourse::Application.routes.draw do get "*url", to: 'permalinks#show', constraints: PermalinkConstraint.new + post "/push_notifications/subscribe" => "push_notification#subscribe" + post "/push_notifications/unsubscribe" => "push_notification#unsubscribe" + end diff --git a/config/site_settings.yml b/config/site_settings.yml index a6bfc78d92..680b9b72c3 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -229,6 +229,21 @@ basic: enable_whispers: client: true default: false + push_notifications_prompt: + default: false + client: true + push_notifications_icon_url: + default: '' + vapid_public_key_bytes: + default: '' + client: true + hidden: true + vapid_public_key: + default: '' + hidden: true + vapid_private_key: + default: '' + hidden: true login: invite_only: diff --git a/db/migrate/20180425185749_create_push_subscription.rb b/db/migrate/20180425185749_create_push_subscription.rb new file mode 100644 index 0000000000..67936fcec0 --- /dev/null +++ b/db/migrate/20180425185749_create_push_subscription.rb @@ -0,0 +1,9 @@ +class CreatePushSubscription < ActiveRecord::Migration[5.1] + def change + create_table :push_subscriptions do |t| + t.integer :user_id, null: false + t.string :data, null: false + t.timestamps + end + end +end diff --git a/spec/controllers/push_notification_controller_spec.rb b/spec/controllers/push_notification_controller_spec.rb new file mode 100644 index 0000000000..06569214e1 --- /dev/null +++ b/spec/controllers/push_notification_controller_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +describe PushNotificationController do + + context "logged out" do + it "should not allow subscribe" do + get :subscribe, params: { username: "test", subscription: { endpoint: "endpoint", keys: { p256dh: "256dh", auth: "auth" } }, send_confirmation: false, format: :json }, format: :json + expect(response).not_to be_success + json = JSON.parse(response.body) + + expect(response).not_to be_success + end + end + + context "logged in" do + + let(:user) { log_in } + + it "should subscribe" do + get :subscribe, params: { username: user.username, subscription: { endpoint: "endpoint", keys: { p256dh: "256dh", auth: "auth" } }, send_confirmation: false, format: :json }, format: :json + expect(response).to be_success + json = JSON.parse(response.body) + + expect(response).to be_success + end + + it "should unsubscribe with existing subscription" do + sub = { endpoint: "endpoint", keys: { p256dh: "256dh", auth: "auth" } } + PushSubscription.create(user: user, data: sub.to_json) + + get :unsubscribe, params: { username: user.username, subscription: sub, format: :json }, format: :json + expect(response).to be_success + json = JSON.parse(response.body) + + expect(response).to be_success + end + + it "should unsubscribe without subscription" do + + get :unsubscribe, params: { username: user.username, subscription: { endpoint: "endpoint", keys: { p256dh: "256dh", auth: "auth" } }, format: :json }, format: :json + expect(response).to be_success + json = JSON.parse(response.body) + + expect(response).to be_success + end + end + +end diff --git a/spec/services/push_notification_pusher_spec.rb b/spec/services/push_notification_pusher_spec.rb new file mode 100644 index 0000000000..33ec16c625 --- /dev/null +++ b/spec/services/push_notification_pusher_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe PushNotificationPusher do + + it "returns badges url by default" do + expect(PushNotificationPusher.get_badge).to eq("/assets/push-notifications/discourse.png") + end + + it "returns custom badges url" do + SiteSetting.push_notifications_icon_url = "/test.png" + expect(PushNotificationPusher.get_badge).to eq("/test.png") + end + +end From c450b70bccaf5083794055bd1fb1fbdc8f5e4b2f Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Fri, 4 May 2018 15:42:48 -0700 Subject: [PATCH 003/194] fix: revert to es5 function syntax --- app/assets/javascripts/service-worker.js.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/service-worker.js.erb b/app/assets/javascripts/service-worker.js.erb index 959ed9a716..fba1cfdef2 100644 --- a/app/assets/javascripts/service-worker.js.erb +++ b/app/assets/javascripts/service-worker.js.erb @@ -165,7 +165,7 @@ self.addEventListener('notificationclick', function(event) { ); }); -self.addEventListener('message', event => { +self.addEventListener('message', function(event) { if('lastAction' in event.data){ lastAction = event.data.lastAction; }}); From bae7203f4234e9ed23bc8e37213805f704ef0653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Sat, 5 May 2018 01:51:53 +0200 Subject: [PATCH 004/194] FIX: proper category > subcategory order in List-Id description header --- lib/email/sender.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/email/sender.rb b/lib/email/sender.rb index dbd0bdb983..6ded4b88e9 100644 --- a/lib/email/sender.rb +++ b/lib/email/sender.rb @@ -119,7 +119,7 @@ module Email # subcategory case if !topic.category.parent_category_id.nil? parent_category_name = Category.find_by(id: topic.category.parent_category_id).name - list_id = "#{SiteSetting.title} | #{topic.category.name} #{parent_category_name} <#{topic.category.name.downcase.tr(' ', '-')}.#{parent_category_name.downcase.tr(' ', '-')}.#{host}>" + list_id = "#{SiteSetting.title} | #{parent_category_name} #{topic.category.name} <#{topic.category.name.downcase.tr(' ', '-')}.#{parent_category_name.downcase.tr(' ', '-')}.#{host}>" end else list_id = "#{SiteSetting.title} <#{host}>" From 008480127fd6fd84a009d389bffe45acfa6ccd7d Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Fri, 4 May 2018 18:58:27 -0700 Subject: [PATCH 005/194] Feature: Add plugin outlet for desktop (live) notifications area --- .../discourse/templates/preferences/notifications.hbs | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/discourse/templates/preferences/notifications.hbs b/app/assets/javascripts/discourse/templates/preferences/notifications.hbs index 6d6a535f59..d4d258c748 100644 --- a/app/assets/javascripts/discourse/templates/preferences/notifications.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/notifications.hbs @@ -24,6 +24,7 @@ {{desktop-notification-config}}
{{i18n 'user.desktop_notifications.each_browser_note'}}
+ {{plugin-outlet name="user-preferences-desktop-notifications" args=(hash model=model save=(action "save"))}} {{#if siteSettings.enable_personal_messages}} From bb377e2021245a7889089f6f3087d576e6c2b344 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 5 May 2018 15:28:07 +1000 Subject: [PATCH 006/194] delete swap files --- .../.discourse-cronos-create-form.js.es6.swp | Bin 12288 -> 0 bytes .../.discourse-cronos.js.es6.swp | Bin 12288 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 plugins/discourse-local-dates/assets/javascripts/discourse/components/.discourse-cronos-create-form.js.es6.swp delete mode 100644 plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/.discourse-cronos.js.es6.swp diff --git a/plugins/discourse-local-dates/assets/javascripts/discourse/components/.discourse-cronos-create-form.js.es6.swp b/plugins/discourse-local-dates/assets/javascripts/discourse/components/.discourse-cronos-create-form.js.es6.swp deleted file mode 100644 index 0d6133e0334fa3a31e85072734b8a408870dae74..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI2?`s@I7{{m9-)*(Ef`V_RYp{Em+cYhdl9*CyT2e?y_IWPQ!ZMFbo= znPh354Fw^u;}>T`tteXvV_w!&>uC}ve8}gV2|l#wFZ;ZnhAsRqz3NG`mBcJwU#T7O z#eBi4jU;V)i`--UVE?gM3#-5ltw1Ik)k8bTf&IgIsz2!Lp}X&yxuK_QCubF~3RnfK z0#*U5fK|XMU=^?mbSofQTgcO>x=B|H{k^56|f3e1*`&A0jq#jz$#!B zunJfOtOEZ-1^j@JSMbOG<(n~h{QtlC`~Ty+3AqS5U_-RKd?X2>Alk!B+6+c0&FD--FM=XW)Hs4ZI7kf|tNkAOcIE1}4BL*a^Pf zhPw*j8SogmAAENwA@6~=!5iRpa0NUMPJsz>QTb`G_$EB@FORWA^l@6iYRo9j+>iAUaf{k;fFCxYc(%Q zlBKML9#EGiQN+>_)3=IMlYOh;ZiTjq9O7U;Mm17O#o3U1U1o9~7=s_zS!A3FH|>C1 zORgg?ANj%=r0Rzrd?Spa<9Um#>s7zKAr@o^ktET)pB`JrEhneq`9Uzv&I?!IpQB0K z2p4ovqe(EYf)Fa z7ga@!hzaF^$<0bJN@H2lcHR3%_1Tjl#ojAsuTqEV4R1=t5KM(x0aKR#Bjv&?UDRHc zO~>ax!Fd!CGH21v#{$xXhPksw=|NfSxt&JDC>Wrq&5bLHm-RUR2j z9p1}C-Rw++#&J}LurBkEz!-nNSxh!4V>QJtOI6ivRw&nHS=4qL%dWcYWJR}$oJ5z2 ztWYL3sB|<>5S^n(l6f4_%Z$5DZZ$~r$v{D;8~_S>+`3uMbz^4Lyr$oBmB^l0^maTG^siET7coLuL)KqnRoK8%P zG@Fj>vYbqpMcyWRE4+;hJKsuDLG|XK$C~qa-#uz>tb2I7kng^D7=^gR4#PGz=Pm8f rMw&F~UM792c<00OWk?mwSy&BNJxP6$q%yz{=# z%-C9G+A~*{SLiuwhTwXPkk*}F)=pRrGW8T8+zw>zp;y{YLy;b8M&5wK`sFB199DNd z?%;{DdhDlNFXUxS%}Js#;&q#ICU||*zGibL@nSstUVF$cu#+t}+75@RR99=0Ta2GQ zJR)tO6}U$fNJXc1?l^ho>Dg?nKUtZiPdxteJ(9dmQ!Ahq&^W0cXIStRBE2Va9P!ENvvxCu7E zC9n)mg8RS`Fadsgh>&l=ZSVnj7u*7GgIB>(FaZeo5qf+FJ_YZAcfc)h2`mAr*K^<` zcnN$6-Uq9|0KY-gUxB`~0$KsBfL1^&@Lwy??-NS<)p`7-G;l2^N|+_sb+H<8v6@5< znwWpVXYc$7xriY=Vp9JO6n9qY^55xJ~p z$sDa;ZMwE#rgi$+>eW_#I%{{Lkc-SCU7$Ot>M=h?MhWYBT(D#{J8ozOE?qc}b{Gs4 ziEzTu>M&cR3FFQ2I9qTyImtc2E;Av#u*?q$ha@Y!b51LDTkS+ z!)~fYD6M>Q47({j;j@;J<%A*hH9!H6woiuc0 z02F5`)7VtORiyxOp4(}sqh`HlifD_4Ssd9Dk`CE62f5_HBommBt#v8*`I|OR3kP zX3-^ElV-iKRsV;SxgA`@ex~$5?A=2CUWhvJt#_4$7yp#ub zfVH?`4#usL@pf5brNS{GWA0IA*QZ5M_U>;WsMm1SR#s|DOZ4K!xge-0e=6UBBuF!e z0!%r^QZ0jtM@v4yARcb<*!P54*@XZd&&M`3u~g1eQMCd)F5)is$-yhFrDhjfS8kTV zITp_7crCS!a!aLSX{O5iZ4&0BNrFvZniW<1E# Date: Sat, 5 May 2018 10:51:54 +0200 Subject: [PATCH 007/194] FEATURE: keep formatting when quoting --- app/assets/javascripts/application.js | 2 ++ app/assets/javascripts/discourse/controllers/topic.js.es6 | 1 - app/assets/javascripts/discourse/lib/utilities.js.es6 | 7 ++----- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index a1888006db..e7e9db37b4 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -9,6 +9,8 @@ //= require ./deprecated // Stuff we need to load first +//= require ./discourse/helpers/parse-html +//= require ./discourse/lib/to-markdown //= require ./discourse/lib/utilities //= require ./discourse/lib/page-visible //= require ./discourse/lib/logout diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 3a8782ab4a..46448d8447 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -131,7 +131,6 @@ export default Ember.Controller.extend(BufferedContent, { return this.get('model.postStream').loadPost(postId).then(post => { const composer = this.get('composer'); const viewOpen = composer.get('model.viewOpen'); - const quotedText = Quote.build(post, buffer); // If we can't create a post, delegate to reply as new topic diff --git a/app/assets/javascripts/discourse/lib/utilities.js.es6 b/app/assets/javascripts/discourse/lib/utilities.js.es6 index 987e868247..0e14caabc9 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js.es6 +++ b/app/assets/javascripts/discourse/lib/utilities.js.es6 @@ -1,4 +1,5 @@ import { escape } from 'pretty-text/sanitizer'; +import toMarkdown from 'discourse/lib/to-markdown'; const homepageSelector = 'meta[name=discourse_current_homepage]'; @@ -113,12 +114,8 @@ export function selectedText() { $div.find(".clicks").remove(); // replace emojis $div.find("img.emoji").replaceWith(function() { return this.title; }); - // replace br with newlines - $div.find("br").replaceWith(() => "\n"); - // enforce newline at the end of paragraphs - $div.find("p").append(() => "\n"); - return String($div.text()).trim().replace(/(^\s*\n)+/gm, "\n"); + return toMarkdown($div.html()); } // Determine the row and col of the caret in an element From 738f3f52298ecb2619b3493dd42f472fda0a6d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Sat, 5 May 2018 11:21:07 +0200 Subject: [PATCH 008/194] Load missing libraries for server-side PrettyText --- lib/pretty_text.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb index 082835eb91..ebfe8a23e1 100644 --- a/lib/pretty_text.rb +++ b/lib/pretty_text.rb @@ -82,6 +82,8 @@ module PrettyText ctx_load_manifest(ctx, "markdown-it-bundle.js") root_path = "#{Rails.root}/app/assets/javascripts/" + apply_es6_file(ctx, root_path, "discourse/helpers/parse-html") + apply_es6_file(ctx, root_path, "discourse/lib/to-markdown") apply_es6_file(ctx, root_path, "discourse/lib/utilities") PrettyText::Helpers.instance_methods.each do |method| From 71f74c0b28692d89f545f9eb814d2d901b88735c Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Sun, 6 May 2018 12:39:59 +0300 Subject: [PATCH 009/194] Improved email validation in user search module. (#5804) --- app/assets/javascripts/discourse/lib/user-search.js.es6 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/lib/user-search.js.es6 b/app/assets/javascripts/discourse/lib/user-search.js.es6 index a6349e2fa0..84edc4e4d6 100644 --- a/app/assets/javascripts/discourse/lib/user-search.js.es6 +++ b/app/assets/javascripts/discourse/lib/user-search.js.es6 @@ -1,5 +1,6 @@ import { CANCELLED_STATUS } from 'discourse/lib/autocomplete'; import { userPath } from 'discourse/lib/url'; +import { emailValid } from 'discourse/lib/utilities'; var cache = {}, cacheTopicId, @@ -61,7 +62,7 @@ function organizeResults(r, options) { }); } - if (!options.disallowEmails && options.term.match(/@/)) { + if (!options.disallowEmails && emailValid(options.term)) { let e = { username: options.term }; emails = [ e ]; results.push(e); From 9c6cf9eb143effa908615bee48c87635d47d6435 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 7 May 2018 09:50:56 +1000 Subject: [PATCH 010/194] FIX: decorate user stream should include element In the past we would call the decorate function with no element this made the extension on user stream pointless --- app/assets/javascripts/discourse/lib/plugin-api.js.es6 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 index 44b1437860..bd5be300f7 100644 --- a/app/assets/javascripts/discourse/lib/plugin-api.js.es6 +++ b/app/assets/javascripts/discourse/lib/plugin-api.js.es6 @@ -723,7 +723,12 @@ export function withPluginApi(version, apiCodeCallback, opts) { let _decorateId = 0; function decorate(klass, evt, cb) { const mixin = {}; - mixin["_decorate_" + (_decorateId++)] = function($elem) { cb($elem); }.on(evt); + mixin["_decorate_" + (_decorateId++)] = function($elem) { + $elem = $elem || this.$(); + if ($elem) { + cb($elem); + } + }.on(evt); klass.reopen(mixin); } From aa6b7791476049edaf4c72f867d006f320f53a2a Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 7 May 2018 11:25:05 +1000 Subject: [PATCH 011/194] DEV: add cache breaker for development Previously iOS could cache scripts (wrongly) which made debugging very hard --- app/helpers/application_helper.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index cfc0006677..696a2a870a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -75,6 +75,13 @@ module ApplicationHelper path.gsub!("#{GlobalSetting.cdn_url}/assets/", "#{GlobalSetting.cdn_url}/brotli_asset/") end + if Rails.env == "development" + if !path.include?("?") + # cache breaker for mobile iOS + path = path + "?#{Time.now.to_f}" + end + end + " ".html_safe end From 911f898a2344d2689cc2913f37c836875f73cc7d Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 7 May 2018 11:25:46 +1000 Subject: [PATCH 012/194] UX: improved performance of emoji dialog Shift all emoji loading into an animation frame to chrome stops deferring timers --- .../discourse/components/emoji-picker.js.es6 | 57 +++++++++++++------ 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/app/assets/javascripts/discourse/components/emoji-picker.js.es6 b/app/assets/javascripts/discourse/components/emoji-picker.js.es6 index 827e0460c6..0bf7f9f7bf 100644 --- a/app/assets/javascripts/discourse/components/emoji-picker.js.es6 +++ b/app/assets/javascripts/discourse/components/emoji-picker.js.es6 @@ -50,7 +50,7 @@ export default Ember.Component.extend({ this._positionPicker(); this._scrollTo(); this._updateSelectedDiversity(); - this._checkVisibleSection(); + this._checkVisibleSection(true); }); }, @@ -106,7 +106,7 @@ export default Ember.Component.extend({ } this._updateSelectedDiversity(); - this._checkVisibleSection(); + this._checkVisibleSection(true); }, @observes("recentEmojis") @@ -192,7 +192,7 @@ export default Ember.Component.extend({ _unbindEvents() { this.$().off(); this.$(window).off("resize"); - this.$modal.off("click"); + clearInterval(this._refreshInterval); $("#reply-control").off("div-resizing"); $('html').off("mouseup.emoji-picker"); }, @@ -316,18 +316,27 @@ export default Ember.Component.extend({ }, _bindSectionsScroll() { - this.$list.on("scroll", () => { - this.scrollPosition = this.$list.scrollTop(); + let onScroll = () => { run.debounce(this, this._checkVisibleSection, 50); - }); + }; + + this.$list.on("scroll", onScroll); + this._refreshInterval = setInterval(onScroll, 100); }, - _checkVisibleSection() { + _checkVisibleSection(force) { // make sure we stop loading if picker has been removed if(!this.$picker) { return; } + const newPosition = this.$list.scrollTop(); + if (newPosition === this.scrollPosition && !force) { + return; + } + + this.scrollPosition = newPosition; + const $sections = this.$list.find(".section"); const listHeight = this.$list.innerHeight(); let $selectedSection; @@ -523,19 +532,31 @@ export default Ember.Component.extend({ }, _setButtonBackground(button, diversity) { - const $button = $(button); - const code = this._codeWithDiversity( - $button.attr("title"), - diversity || $button.hasClass("diversity") - ); - // force visual reloading if needed - if($button.css("background-image") !== "none") { - $button.css("background-image", ""); + if (!button) { + return; } - $button - .attr("data-loaded", 1) - .css("background-image", `url("${emojiUrlFor(code)}")`); + const $button = $(button); + button = $button[0]; + + // changing style can force layout events + // this could slow down timers and lead to + // chrome delaying the request + window.requestAnimationFrame(() =>{ + const code = this._codeWithDiversity( + $button.attr("title"), + diversity || $button.hasClass("diversity") + ); + + // // force visual reloading if needed + if(button.style.backgroundImage !== "none") { + button.style.backgroundImage = ""; + } + + button.style.backgroundImage = `url("${emojiUrlFor(code)}")`; + $button.attr("data-loaded", 1); + }); + }, }); From 77eb93ffb792a2ee1fbef90f4331538feb80597b Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 11:25:01 +0800 Subject: [PATCH 013/194] Fabricate records with the right attributes during fabrication. --- spec/mailers/user_notifications_spec.rb | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/spec/mailers/user_notifications_spec.rb b/spec/mailers/user_notifications_spec.rb index 322b643cf6..bb8118d499 100644 --- a/spec/mailers/user_notifications_spec.rb +++ b/spec/mailers/user_notifications_spec.rb @@ -424,18 +424,15 @@ describe UserNotifications do end it "includes a list of participants, groups first with member lists" do - group1 = Fabricate(:group) - group2 = Fabricate(:group) - group1.name = "group1" - group2.name = "group2" - user1 = Fabricate(:user) - user2 = Fabricate(:user) - user1.username = "one" - user2.username = "two" - user1.groups = [ group1, group2 ] - user2.groups = [ group1 ] - topic.allowed_users = [ user1, user2 ] - topic.allowed_groups = [ group1, group2 ] + group1 = Fabricate(:group, name: "group1") + group2 = Fabricate(:group, name: "group2") + + user1 = Fabricate(:user, username: "one", groups: [group1, group2]) + user2 = Fabricate(:user, username: "two", groups: [group1]) + + topic.allowed_users = [user1, user2] + topic.allowed_groups = [group1, group2] + mail = UserNotifications.user_private_message( response.user, post: response, From 0d74c30fa70b651086034cb81cd889f9b69fd7a2 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 11:38:04 +0800 Subject: [PATCH 014/194] Remove more uses of `rescue nil`. --- lib/search.rb | 5 ++++- plugins/poll/plugin.rb | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/search.rb b/lib/search.rb index a391ebbd5a..a61a36eb0f 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -73,7 +73,10 @@ class Search return if day == 0 || month == 0 || day > 31 || month > 12 - return Time.zone.parse("#{year}-#{month}-#{day}") rescue nil + return begin + Time.zone.parse("#{year}-#{month}-#{day}") + rescue ArgumentError + end end if str.downcase == "yesterday" diff --git a/plugins/poll/plugin.rb b/plugins/poll/plugin.rb index 82efa7fd43..ec13cc8749 100644 --- a/plugins/poll/plugin.rb +++ b/plugins/poll/plugin.rb @@ -66,7 +66,12 @@ after_initialize do # ensure no race condition when poll is automatically closed if poll["close"].present? - close_date = Time.zone.parse(poll["close"]) rescue nil + close_date = + begin + close_date = Time.zone.parse(poll["close"]) + rescue ArgumentError + end + raise StandardError.new I18n.t("poll.poll_must_be_open_to_vote") if close_date && close_date <= Time.zone.now end @@ -159,7 +164,12 @@ after_initialize do Jobs.cancel_scheduled_job(:close_poll, post_id: post.id, poll_name: name) if poll["status"] == "open" && poll["close"].present? - close_date = Time.zone.parse(poll["close"]) rescue nil + close_date = + begin + Time.zone.parse(poll["close"]) + rescue ArgumentError + end + Jobs.enqueue_at(close_date, :close_poll, post_id: post.id, poll_name: name) if close_date && close_date > Time.zone.now end end From b618ffb7159b69c91ff56d2dde33d7bf738560cd Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 12:07:50 +0800 Subject: [PATCH 015/194] Remove file that is no longer used. --- .../discourse/lib/binary-search.js.es6 | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 app/assets/javascripts/discourse/lib/binary-search.js.es6 diff --git a/app/assets/javascripts/discourse/lib/binary-search.js.es6 b/app/assets/javascripts/discourse/lib/binary-search.js.es6 deleted file mode 100644 index 03675866e0..0000000000 --- a/app/assets/javascripts/discourse/lib/binary-search.js.es6 +++ /dev/null @@ -1,29 +0,0 @@ -// The binarySearch() function is licensed under the UNLICENSE -// https://github.com/Olical/binary-search - -// Modified for use in Discourse - -export default function binarySearch(list, target, keyProp) { - var min = 0; - var max = list.length - 1; - var guess; - var keyProperty = keyProp || "id"; - - while (min <= max) { - guess = Math.floor((min + max) / 2); - - if (Em.get(list[guess], keyProperty) === target) { - return guess; - } - else { - if (Em.get(list[guess], keyProperty) < target) { - min = guess + 1; - } - else { - max = guess - 1; - } - } - } - - return -Math.floor((min + max) / 2); -} From aa0d32231c082190abd0b9926dc9fcc43415645f Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 13:56:27 +0800 Subject: [PATCH 016/194] FIX: Incorrect query when removing a group owner. https://meta.discourse.org/t/group-rename-and-group-owners-removal-problems/85596 --- app/controllers/admin/groups_controller.rb | 2 +- spec/requests/admin/groups_controller_spec.rb | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index e56fe53c11..c8a328000d 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -105,7 +105,7 @@ class Admin::GroupsController < Admin::AdminController end def remove_owner - group = Group.find_by(params.require(:id)) + group = Group.find_by(id: params.require(:id)) raise Discourse::NotFound unless group return can_not_modify_automatic if group.automatic diff --git a/spec/requests/admin/groups_controller_spec.rb b/spec/requests/admin/groups_controller_spec.rb index 20caa5b645..7939a48f3b 100644 --- a/spec/requests/admin/groups_controller_spec.rb +++ b/spec/requests/admin/groups_controller_spec.rb @@ -51,6 +51,19 @@ RSpec.describe Admin::GroupsController do end end + describe '#remove_owner' do + it 'should work' do + group.add_owner(user) + + delete "/admin/groups/#{group.id}/owners.json", params: { + user_id: user.id + } + + expect(response.status).to eq(200) + expect(group.group_users.where(owner: true)).to eq([]) + end + end + describe "#bulk_perform" do let(:group) do Fabricate(:group, From ad833c4485e3abf4a9cf847b1ef2903f12b7272b Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 14:02:11 +0800 Subject: [PATCH 017/194] FIX: Don't downcase group name. --- app/models/group.rb | 7 ++++--- spec/models/group_spec.rb | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/models/group.rb b/app/models/group.rb index 9f049c8821..53f9d6a3ca 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -598,12 +598,13 @@ class Group < ActiveRecord::Base def name_format_validator self.name.strip! - self.name.downcase! UsernameValidator.perform_validation(self, 'name') || begin - if will_save_change_to_name? && name_was&.downcase != self.name + name_lower = self.name.downcase + + if self.will_save_change_to_name? && self.name_was&.downcase != name_lower existing = Group.exec_sql( - User::USERNAME_EXISTS_SQL, username: self.name + User::USERNAME_EXISTS_SQL, username: name_lower ).values.present? if existing diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index d4d6e5a954..c4c34bc23f 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -21,7 +21,7 @@ describe Group do end end - describe '#username' do + describe '#name' do context 'when a user with a similar name exists' do it 'should not be valid' do new_group = Fabricate.build(:group, name: admin.username.upcase) From 32147d4ff99f2d5083a049e1605ae3cc35d709f3 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 15:17:34 +0800 Subject: [PATCH 018/194] FIX: Search term scoped to topic should be highlighted in widget dom era. https://meta.discourse.org/t/highlight-the-search-results/10322/4?u=tgxworld --- app/assets/javascripts/discourse/widgets/post-cooked.js.es6 | 4 +++- app/assets/javascripts/discourse/widgets/search-menu.js.es6 | 1 + test/javascripts/acceptance/search-test.js.es6 | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 b/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 index 16333eb951..107383d935 100644 --- a/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-cooked.js.es6 @@ -20,7 +20,9 @@ export default class PostCooked { } update(prev) { - if (prev.attrs.cooked !== this.attrs.cooked) { + if ((prev.attrs.cooked !== this.attrs.cooked) || + (prev.attrs.highlightTerm !== this.attrs.highlightTerm)) { + return this.init(); } } diff --git a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 index 5bd25047f1..39921dbc62 100644 --- a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 @@ -64,6 +64,7 @@ const SearchHelper = { } searchData.results = content; + widget.appEvents.trigger('post-stream:refresh', { force: true }); }).finally(() => { searchData.loading = false; widget.scheduleRerender(); diff --git a/test/javascripts/acceptance/search-test.js.es6 b/test/javascripts/acceptance/search-test.js.es6 index 5929078b28..d388d67787 100644 --- a/test/javascripts/acceptance/search-test.js.es6 +++ b/test/javascripts/acceptance/search-test.js.es6 @@ -69,6 +69,11 @@ QUnit.test("Search with context", assert => { andThen(() => { assert.ok(exists('.search-menu .results ul li'), 'it shows results'); + + assert.ok( + exists('.cooked span.highlight-strong'), + 'it should highlight the search term' + ); }); visit("/"); From 21007a4a8d766bbe40bca04882a134a09b035feb Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 15:40:46 +0800 Subject: [PATCH 019/194] Rewrite push notifications controller specs as request specs. * Improve assertions to test for the outcome we expected instead of just asserting for a 200 response. * Remove duplicated assertion. --- .../push_notification_controller_spec.rb | 48 ------------ .../push_notification_controller_spec.rb | 74 +++++++++++++++++++ 2 files changed, 74 insertions(+), 48 deletions(-) delete mode 100644 spec/controllers/push_notification_controller_spec.rb create mode 100644 spec/requests/push_notification_controller_spec.rb diff --git a/spec/controllers/push_notification_controller_spec.rb b/spec/controllers/push_notification_controller_spec.rb deleted file mode 100644 index 06569214e1..0000000000 --- a/spec/controllers/push_notification_controller_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -require 'rails_helper' - -describe PushNotificationController do - - context "logged out" do - it "should not allow subscribe" do - get :subscribe, params: { username: "test", subscription: { endpoint: "endpoint", keys: { p256dh: "256dh", auth: "auth" } }, send_confirmation: false, format: :json }, format: :json - expect(response).not_to be_success - json = JSON.parse(response.body) - - expect(response).not_to be_success - end - end - - context "logged in" do - - let(:user) { log_in } - - it "should subscribe" do - get :subscribe, params: { username: user.username, subscription: { endpoint: "endpoint", keys: { p256dh: "256dh", auth: "auth" } }, send_confirmation: false, format: :json }, format: :json - expect(response).to be_success - json = JSON.parse(response.body) - - expect(response).to be_success - end - - it "should unsubscribe with existing subscription" do - sub = { endpoint: "endpoint", keys: { p256dh: "256dh", auth: "auth" } } - PushSubscription.create(user: user, data: sub.to_json) - - get :unsubscribe, params: { username: user.username, subscription: sub, format: :json }, format: :json - expect(response).to be_success - json = JSON.parse(response.body) - - expect(response).to be_success - end - - it "should unsubscribe without subscription" do - - get :unsubscribe, params: { username: user.username, subscription: { endpoint: "endpoint", keys: { p256dh: "256dh", auth: "auth" } }, format: :json }, format: :json - expect(response).to be_success - json = JSON.parse(response.body) - - expect(response).to be_success - end - end - -end diff --git a/spec/requests/push_notification_controller_spec.rb b/spec/requests/push_notification_controller_spec.rb new file mode 100644 index 0000000000..ef89ff9ed9 --- /dev/null +++ b/spec/requests/push_notification_controller_spec.rb @@ -0,0 +1,74 @@ +require 'rails_helper' + +describe PushNotificationController do + let(:user) { Fabricate(:user) } + + context "logged out" do + it "should not allow subscribe" do + post '/push_notifications/subscribe.json', params: { + username: "test", + subscription: { + endpoint: "endpoint", + keys: { + p256dh: "256dh", + auth: "auth" + } + }, + send_confirmation: false + } + + expect(response.status).to eq(403) + end + end + + context "logged in" do + before { sign_in(user) } + + it "should subscribe" do + post '/push_notifications/subscribe.json', params: { + username: user.username, + subscription: { + endpoint: "endpoint", + keys: { + p256dh: "256dh", + auth: "auth" + } + }, + send_confirmation: false + } + + expect(response.status).to eq(200) + expect(user.push_subscriptions.count).to eq(1) + end + + it "should unsubscribe with existing subscription" do + sub = { endpoint: "endpoint", keys: { p256dh: "256dh", auth: "auth" } } + PushSubscription.create!(user: user, data: sub.to_json) + + post '/push_notifications/unsubscribe.json', params: { + username: user.username, + subscription: sub + } + + expect(response.status).to eq(200) + expect(user.push_subscriptions).to eq([]) + end + + it "should unsubscribe without subscription" do + post '/push_notifications/unsubscribe.json', params: { + username: user.username, + subscription: { + endpoint: "endpoint", + keys: { + p256dh: "256dh", + auth: "auth" + } + } + } + + expect(response.status).to eq(200) + expect(user.push_subscriptions).to eq([]) + end + end + +end From 5993a3ae3a5dd279e8f823e58e09b7bd417f0615 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 15:45:20 +0800 Subject: [PATCH 020/194] Don't pin version for the webpush gem. --- Gemfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 23da61e81d..c89be78a7c 100644 --- a/Gemfile +++ b/Gemfile @@ -186,5 +186,4 @@ if ENV["IMPORT"] == "1" gem 'reverse_markdown' end -gem 'hkdf', '0.3.0', require: false -gem 'webpush', '0.3.2', require: false +gem 'webpush', require: false From 5a57a454fe713080814736442a6feb4863ec8c70 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 15:59:16 +0800 Subject: [PATCH 021/194] Revert "FIX: Only show topic timer to staff" This reverts commit e3e6fd6ea0ecf2611738127efc585afbf727d219. --- .../javascripts/discourse/widgets/topic-admin-menu.js.es6 | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 b/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 index 2e04ab2ab3..8688c3d6e4 100644 --- a/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/topic-admin-menu.js.es6 @@ -146,12 +146,11 @@ export default createWidget('topic-admin-menu', { icon: 'lock', label: 'actions.close' }); } - if (this.currentUser.get('staff')) { - buttons.push({ className: 'topic-admin-status-update', + + buttons.push({ className: 'topic-admin-status-update', action: 'showTopicStatusUpdate', icon: 'clock-o', label: 'actions.timed_update' }); - } const isPrivateMessage = topic.get('isPrivateMessage'); From ff6be3c2e3981562771f64f813833e6bfe2ccc4b Mon Sep 17 00:00:00 2001 From: Misaka 0x4e21 Date: Mon, 7 May 2018 08:03:26 +0000 Subject: [PATCH 022/194] FEATURE: add profile_background fields into SSO (#5701) Add profile_background and card_background fields into Discourse SSO. --- app/controllers/session_controller.rb | 21 +++ .../download_profile_background_from_url.rb | 28 +++ app/models/discourse_single_sign_on.rb | 46 ++++- app/models/single_sign_on_record.rb | 2 + app/models/user_profile.rb | 31 ++++ .../single_sign_on_record_serializer.rb | 4 +- config/locales/server.en.yml | 2 + config/locales/server.zh_CN.yml | 2 + config/site_settings.yml | 2 + ...background_url_to_single_sign_on_record.rb | 6 + lib/single_sign_on.rb | 2 +- spec/controllers/session_controller_spec.rb | 118 ++++++++++++- ...wnload_profile_background_from_url_spec.rb | 16 ++ spec/models/discourse_single_sign_on_spec.rb | 163 ++++++++++++++++++ spec/models/user_profile_spec.rb | 35 ++++ 15 files changed, 474 insertions(+), 4 deletions(-) create mode 100644 app/jobs/regular/download_profile_background_from_url.rb create mode 100644 db/migrate/20180316092939_add_external_profile_and_card_background_url_to_single_sign_on_record.rb create mode 100644 spec/jobs/download_profile_background_from_url_spec.rb diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 2e8a75ab07..dfee47d632 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -1,5 +1,6 @@ require_dependency 'rate_limiter' require_dependency 'single_sign_on' +require_dependency 'url_helper' class SessionController < ApplicationController class LocalLoginNotAllowed < StandardError; end @@ -54,6 +55,26 @@ class SessionController < ApplicationController sso.moderator = current_user.moderator? sso.groups = current_user.groups.pluck(:name).join(",") + sso.avatar_url = Discourse.store.cdn_url UrlHelper.absolute( + "#{Discourse.store.absolute_base_url}/#{Discourse.store.get_path_for_upload(current_user.uploaded_avatar)}" + ) unless current_user.uploaded_avatar.nil? + sso.profile_background_url = UrlHelper.absolute upload_cdn_path( + current_user.user_profile.profile_background + ) if current_user.user_profile.profile_background.present? + sso.card_background_url = UrlHelper.absolute upload_cdn_path( + current_user.user_profile.card_background + ) if current_user.user_profile.card_background.present? + + sso.avatar_url = Discourse.store.cdn_url UrlHelper.absolute( + "#{Discourse.store.absolute_base_url}/#{Discourse.store.get_path_for_upload(current_user.uploaded_avatar)}" + ) unless current_user.uploaded_avatar.nil? + sso.profile_background_url = UrlHelper.absolute upload_cdn_path( + current_user.user_profile.profile_background + ) if current_user.user_profile.profile_background.present? + sso.card_background_url = UrlHelper.absolute upload_cdn_path( + current_user.user_profile.card_background + ) if current_user.user_profile.card_background.present? + if sso.return_sso_url.blank? render plain: "return_sso_url is blank, it must be provided", status: 400 return diff --git a/app/jobs/regular/download_profile_background_from_url.rb b/app/jobs/regular/download_profile_background_from_url.rb new file mode 100644 index 0000000000..43414db9d1 --- /dev/null +++ b/app/jobs/regular/download_profile_background_from_url.rb @@ -0,0 +1,28 @@ +module Jobs + + class DownloadProfileBackgroundFromUrl < Jobs::Base + sidekiq_options retry: false + + def execute(args) + url = args[:url] + user_id = args[:user_id] + + raise Discourse::InvalidParameters.new(:url) if url.blank? + raise Discourse::InvalidParameters.new(:user_id) if user_id.blank? + + return unless user = User.find_by(id: user_id) + + begin + UserProfile.import_url_for_user( + url, + user, + is_card_background: args[:is_card_background], + ) + rescue Discourse::InvalidParameters => e + raise e unless e.message == 'url' + end + end + + end + +end diff --git a/app/models/discourse_single_sign_on.rb b/app/models/discourse_single_sign_on.rb index bc8988d9de..80c1f56dcf 100644 --- a/app/models/discourse_single_sign_on.rb +++ b/app/models/discourse_single_sign_on.rb @@ -190,13 +190,31 @@ class DiscourseSingleSignOn < SingleSignOn ) end + if profile_background_url.present? + Jobs.enqueue(:download_profile_background_from_url, + url: profile_background_url, + user_id: user.id, + is_card_background: false + ) + end + + if card_background_url.present? + Jobs.enqueue(:download_profile_background_from_url, + url: card_background_url, + user_id: user.id, + is_card_background: true + ) + end + user.create_single_sign_on_record!( last_payload: unsigned_payload, external_id: external_id, external_username: username, external_email: email, external_name: name, - external_avatar_url: avatar_url + external_avatar_url: avatar_url, + external_profile_background_url: profile_background_url, + external_card_background_url: card_background_url ) end end @@ -233,10 +251,36 @@ class DiscourseSingleSignOn < SingleSignOn end end + profile_background_missing = user.user_profile.profile_background.blank? || Upload.get_from_url(user.user_profile.profile_background).blank? + if (profile_background_missing || SiteSetting.sso_overrides_profile_background) && profile_background_url.present? + profile_background_changed = sso_record.external_profile_background_url != profile_background_url + if profile_background_changed || profile_background_missing + Jobs.enqueue(:download_profile_background_from_url, + url: profile_background_url, + user_id: user.id, + is_card_background: false + ) + end + end + + card_background_missing = user.user_profile.card_background.blank? || Upload.get_from_url(user.user_profile.card_background).blank? + if (card_background_missing || SiteSetting.sso_overrides_profile_background) && card_background_url.present? + card_background_changed = sso_record.external_card_background_url != card_background_url + if card_background_changed || card_background_missing + Jobs.enqueue(:download_profile_background_from_url, + url: card_background_url, + user_id: user.id, + is_card_background: true + ) + end + end + # change external attributes for sso record sso_record.external_username = username sso_record.external_email = email sso_record.external_name = name sso_record.external_avatar_url = avatar_url + sso_record.external_profile_background_url = profile_background_url + sso_record.external_card_background_url = card_background_url end end diff --git a/app/models/single_sign_on_record.rb b/app/models/single_sign_on_record.rb index d556a208bc..4c7f23fd7f 100644 --- a/app/models/single_sign_on_record.rb +++ b/app/models/single_sign_on_record.rb @@ -18,6 +18,8 @@ end # external_email :string # external_name :string # external_avatar_url :string(1000) +# external_profile_background_url :string(1000) +# external_card_background_url :string(1000) # # Indexes # diff --git a/app/models/user_profile.rb b/app/models/user_profile.rb index 825b945107..721d04196a 100644 --- a/app/models/user_profile.rb +++ b/app/models/user_profile.rb @@ -1,3 +1,4 @@ +require_dependency 'upload_creator' class UserProfile < ActiveRecord::Base # TODO: remove this after Nov 1, 2018 @@ -78,6 +79,36 @@ class UserProfile < ActiveRecord::Base update_columns(bio_cooked: cooked, bio_cooked_version: BAKED_VERSION) end + def self.import_url_for_user(background_url, user, options = nil) + tempfile = FileHelper.download( + background_url, + max_file_size: SiteSetting.max_image_size_kb.kilobytes, + tmp_file_name: "sso-profile-background", + follow_redirect: true + ) + + return unless tempfile + + ext = FastImage.type(tempfile).to_s + tempfile.rewind + + is_card_background = !options || options[:is_card_background] + type = is_card_background ? "card_background" : "profile_background" + + upload = UploadCreator.new(tempfile, "external-profile-background." + ext, origin: background_url, type: type).create_for(user.id) + + if (is_card_background) + user.user_profile.upload_card_background(upload) + else + user.user_profile.upload_profile_background(upload) + end + + rescue Net::ReadTimeout, OpenURI::HTTPError + # skip saving, we are not connected to the net + ensure + tempfile.close! if tempfile && tempfile.respond_to?(:close!) + end + protected def trigger_badges diff --git a/app/serializers/single_sign_on_record_serializer.rb b/app/serializers/single_sign_on_record_serializer.rb index a70591531f..d2e35119b1 100644 --- a/app/serializers/single_sign_on_record_serializer.rb +++ b/app/serializers/single_sign_on_record_serializer.rb @@ -3,5 +3,7 @@ class SingleSignOnRecordSerializer < ApplicationSerializer :last_payload, :created_at, :updated_at, :external_username, :external_email, :external_name, - :external_avatar_url + :external_avatar_url, + :external_profile_background_url, + :external_card_background_url end diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index c32b1b2ed9..ab64ced197 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1231,6 +1231,8 @@ en: sso_overrides_username: "Overrides local username with external site username from SSO payload on every login, and prevent local changes. (WARNING: discrepancies can occur due to differences in username length/requirements)" sso_overrides_name: "Overrides local full name with external site full name from SSO payload on every login, and prevent local changes." sso_overrides_avatar: "Overrides user avatar with external site avatar from SSO payload. If enabled, disabling allow_uploaded_avatars is highly recommended" + sso_overrides_profile_background: "Overrides user profile background with external site avatar from SSO payload." + sso_overrides_card_background: "Overrides user card background with external site avatar from SSO payload." sso_not_approved_url: "Redirect unapproved SSO accounts to this URL" sso_allows_all_return_paths: "Do not restrict the domain for return_paths provided by SSO (by default return path must be on current site)" diff --git a/config/locales/server.zh_CN.yml b/config/locales/server.zh_CN.yml index d9550fb94c..043f95e6a2 100644 --- a/config/locales/server.zh_CN.yml +++ b/config/locales/server.zh_CN.yml @@ -961,6 +961,8 @@ zh_CN: sso_overrides_username: "每一次登录时,用 SSO 信息中的外部站点的用户名覆盖本地用户名,并且阻止本地的用户名修改。(警告:因格本地用户名的长度和其他要求,用户名可能会有所差异)" sso_overrides_name: "每一次登录时,用 SSO 信息中的外部站点的全名覆盖本地全名,并且阻止本地的全名修改。" sso_overrides_avatar: "用单点登录信息中的外部站点头像覆盖用户头像。如果启用,强烈建议禁用 allow_uploaded_avatars" + sso_overrides_profile_background: "用单点登录信息中的外部站点个人档背景图片覆盖用户头像。" + sso_overrides_card_background: "用单点登录信息中的外部站点用户卡背景图片覆盖用户头像。" sso_not_approved_url: "重定向未受许可的单点登录账号至这个 URL" sso_allows_all_return_paths: "不限制 SSO 提供的 return_paths 中的域名(默认情况下返回地址必须位于当前站点)" allow_new_registrations: "允许新用户注册。取消选择将阻止任何人创建一个新账户。" diff --git a/config/site_settings.yml b/config/site_settings.yml index 680b9b72c3..a2bb4d912b 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -348,6 +348,8 @@ login: sso_overrides_avatar: default: false client: true + sso_overrides_profile_background: false + sso_overrides_card_background: false sso_not_approved_url: '' email_domains_blacklist: default: 'mailinator.com' diff --git a/db/migrate/20180316092939_add_external_profile_and_card_background_url_to_single_sign_on_record.rb b/db/migrate/20180316092939_add_external_profile_and_card_background_url_to_single_sign_on_record.rb new file mode 100644 index 0000000000..62a9393dad --- /dev/null +++ b/db/migrate/20180316092939_add_external_profile_and_card_background_url_to_single_sign_on_record.rb @@ -0,0 +1,6 @@ +class AddExternalProfileAndCardBackgroundUrlToSingleSignOnRecord < ActiveRecord::Migration[5.1] + def change + add_column :single_sign_on_records, :external_profile_background_url, :string + add_column :single_sign_on_records, :external_card_background_url, :string + end +end diff --git a/lib/single_sign_on.rb b/lib/single_sign_on.rb index a0fdf25705..3e449b8462 100644 --- a/lib/single_sign_on.rb +++ b/lib/single_sign_on.rb @@ -1,7 +1,7 @@ class SingleSignOn ACCESSORS = [:nonce, :name, :username, :email, :avatar_url, :avatar_force_update, :require_activation, :bio, :external_id, :return_sso_url, :admin, :moderator, :suppress_welcome_message, :title, - :add_groups, :remove_groups, :groups] + :add_groups, :remove_groups, :groups, :profile_background_url, :card_background_url] FIXNUMS = [] BOOLS = [:avatar_force_update, :admin, :moderator, :require_activation, :suppress_welcome_message] NONCE_EXPIRY_TIME = 10.minutes diff --git a/spec/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb index b084051300..476bdb1e16 100644 --- a/spec/controllers/session_controller_spec.rb +++ b/spec/controllers/session_controller_spec.rb @@ -28,8 +28,9 @@ describe SessionController do end end - describe '#sso_login' do + let(:logo_fixture) { "http://#{Discourse.current_hostname}/uploads/logo.png" } + describe '#sso_login' do before do @sso_url = "http://somesite.com/discourse_sso" @sso_secret = "shjkfdhsfkjh" @@ -294,6 +295,11 @@ describe SessionController do describe 'can act as an SSO provider' do before do + stub_request(:any, /#{Discourse.current_hostname}\/uploads/).to_return( + status: 200, + body: lambda { |request| file_from_fixtures("logo.png") } + ) + SiteSetting.enable_sso_provider = true SiteSetting.enable_sso = false SiteSetting.enable_local_logins = true @@ -307,7 +313,15 @@ describe SessionController do @user = Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true) group = Fabricate(:group) group.add(@user) + + @user.create_user_avatar! + UserAvatar.import_url_for_user(logo_fixture, @user) + UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: false) + UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: true) + @user.reload + @user.user_avatar.reload + @user.user_profile.reload EmailToken.update_all(confirmed: true) end @@ -334,6 +348,14 @@ describe SessionController do expect(sso2.admin).to eq(true) expect(sso2.moderator).to eq(false) expect(sso2.groups).to eq(@user.groups.pluck(:name).join(",")) + + expect(sso2.avatar_url.blank?).to_not eq(true) + expect(sso2.profile_background_url.blank?).to_not eq(true) + expect(sso2.card_background_url.blank?).to_not eq(true) + + expect(sso2.avatar_url).to start_with(Discourse.base_url) + expect(sso2.profile_background_url).to start_with(Discourse.base_url) + expect(sso2.card_background_url).to start_with(Discourse.base_url) end it "successfully redirects user to return_sso_url when the user is logged in" do @@ -353,6 +375,70 @@ describe SessionController do expect(sso2.external_id).to eq(@user.id.to_s) expect(sso2.admin).to eq(true) expect(sso2.moderator).to eq(false) + expect(sso2.groups).to eq(@user.groups.pluck(:name).join(",")) + + expect(sso2.avatar_url.blank?).to_not eq(true) + expect(sso2.profile_background_url.blank?).to_not eq(true) + expect(sso2.card_background_url.blank?).to_not eq(true) + + expect(sso2.avatar_url).to start_with(Discourse.base_url) + expect(sso2.profile_background_url).to start_with(Discourse.base_url) + expect(sso2.card_background_url).to start_with(Discourse.base_url) + end + + it 'handles non local content correctly' do + SiteSetting.avatar_sizes = "100|49" + SiteSetting.enable_s3_uploads = true + SiteSetting.s3_access_key_id = "XXX" + SiteSetting.s3_secret_access_key = "XXX" + SiteSetting.s3_upload_bucket = "test" + SiteSetting.s3_cdn_url = "http://cdn.com" + + stub_request(:any, /test.s3.amazonaws.com/).to_return(status: 200, body: "", headers: {}) + + @user.create_user_avatar! + upload = Fabricate(:upload, url: "//test.s3.amazonaws.com/something") + + Fabricate(:optimized_image, + sha1: SecureRandom.hex << "A" * 8, + upload: upload, + width: 98, + height: 98, + url: "//test.s3.amazonaws.com/something/else" + ) + + @user.update_columns(uploaded_avatar_id: upload.id) + @user.user_profile.update_columns( + profile_background: "//test.s3.amazonaws.com/something", + card_background: "//test.s3.amazonaws.com/something" + ) + + @user.reload + @user.user_avatar.reload + @user.user_profile.reload + + log_in_user(@user) + + stub_request(:get, "http://cdn.com/something/else").to_return( + body: lambda { |request| File.new(Rails.root + 'spec/fixtures/images/logo.png') } + ) + + get :sso_provider, params: Rack::Utils.parse_query(@sso.payload) + + location = response.header["Location"] + # javascript code will handle redirection of user to return_sso_url + expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/) + + payload = location.split("?")[1] + sso2 = SingleSignOn.parse(payload, "topsecret") + + expect(sso2.avatar_url.blank?).to_not eq(true) + expect(sso2.profile_background_url.blank?).to_not eq(true) + expect(sso2.card_background_url.blank?).to_not eq(true) + + expect(sso2.avatar_url).to start_with(SiteSetting.s3_cdn_url) + expect(sso2.profile_background_url).to start_with(SiteSetting.s3_cdn_url) + expect(sso2.card_background_url).to start_with(SiteSetting.s3_cdn_url) end end @@ -414,6 +500,11 @@ describe SessionController do describe '#sso_provider' do before do + stub_request(:any, /#{Discourse.current_hostname}\/uploads/).to_return( + status: 200, + body: lambda { |request| file_from_fixtures("logo.png") } + ) + SiteSetting.enable_sso_provider = true SiteSetting.enable_sso = false SiteSetting.enable_local_logins = true @@ -425,6 +516,14 @@ describe SessionController do @sso.return_sso_url = "http://somewhere.over.rainbow/sso" @user = Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true) + @user.create_user_avatar! + UserAvatar.import_url_for_user(logo_fixture, @user) + UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: false) + UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: true) + + @user.reload + @user.user_avatar.reload + @user.user_profile.reload EmailToken.update_all(confirmed: true) end @@ -450,6 +549,15 @@ describe SessionController do expect(sso2.external_id).to eq(@user.id.to_s) expect(sso2.admin).to eq(true) expect(sso2.moderator).to eq(false) + expect(sso2.groups).to eq(@user.groups.pluck(:name).join(",")) + + expect(sso2.avatar_url.blank?).to_not eq(true) + expect(sso2.profile_background_url.blank?).to_not eq(true) + expect(sso2.card_background_url.blank?).to_not eq(true) + + expect(sso2.avatar_url).to start_with(Discourse.base_url) + expect(sso2.profile_background_url).to start_with(Discourse.base_url) + expect(sso2.card_background_url).to start_with(Discourse.base_url) end it "successfully redirects user to return_sso_url when the user is logged in" do @@ -469,6 +577,14 @@ describe SessionController do expect(sso2.external_id).to eq(@user.id.to_s) expect(sso2.admin).to eq(true) expect(sso2.moderator).to eq(false) + + expect(sso2.avatar_url.blank?).to_not eq(true) + expect(sso2.profile_background_url.blank?).to_not eq(true) + expect(sso2.card_background_url.blank?).to_not eq(true) + + expect(sso2.avatar_url).to start_with(Discourse.base_url) + expect(sso2.profile_background_url).to start_with(Discourse.base_url) + expect(sso2.card_background_url).to start_with(Discourse.base_url) end end diff --git a/spec/jobs/download_profile_background_from_url_spec.rb b/spec/jobs/download_profile_background_from_url_spec.rb new file mode 100644 index 0000000000..a7913cc73c --- /dev/null +++ b/spec/jobs/download_profile_background_from_url_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +RSpec.describe Jobs::DownloadProfileBackgroundFromUrl do + let(:user) { Fabricate(:user) } + + describe 'when url is invalid' do + it 'should not raise any error' do + expect do + described_class.new.execute( + url: '/assets/something/nice.jpg', + user_id: user.id + ) + end.to_not raise_error + end + end +end diff --git a/spec/models/discourse_single_sign_on_spec.rb b/spec/models/discourse_single_sign_on_spec.rb index ff22598ebe..f125d0c590 100644 --- a/spec/models/discourse_single_sign_on_spec.rb +++ b/spec/models/discourse_single_sign_on_spec.rb @@ -584,4 +584,167 @@ describe DiscourseSingleSignOn do end end end + + context 'when sso_overrides_profile_background is not enabled' do + + it "correctly handles provided profile_background_urls" do + sso = DiscourseSingleSignOn.new + sso.external_id = 666 + sso.email = "sam@sam.com" + sso.name = "sam" + sso.username = "sam" + sso.profile_background_url = "http://awesome.com/image.png" + sso.suppress_welcome_message = true + + FileHelper.stubs(:download).returns(file_from_fixtures("logo.png")) + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + profile_background = user.user_profile.profile_background + + # initial creation ... + expect(profile_background).to_not eq(nil) + expect(profile_background).to_not eq('') + + FileHelper.stubs(:download) { raise "should not be called" } + sso.profile_background_url = "https://some.new/avatar.png" + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + + # profile_background updated but no override specified ... + expect(user.user_profile.profile_background).to eq(profile_background) + end + end + + context 'when sso_overrides_profile_background is enabled' do + let!(:sso_record) { Fabricate(:single_sign_on_record, external_profile_background_url: "http://example.com/an_image.png") } + + let!(:sso) { + sso = DiscourseSingleSignOn.new + sso.username = "test" + sso.name = "test" + sso.email = sso_record.user.email + sso.external_id = sso_record.external_id + sso + } + + let(:logo) { file_from_fixtures("logo.png") } + + before do + SiteSetting.sso_overrides_profile_background = true + end + + it "deal with no profile_background_url passed for an existing user with a profile_background" do + Sidekiq::Testing.inline! do + # Deliberately not setting profile_background_url so it should not update + sso_record.user.user_profile.update_columns(profile_background: '') + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + + expect(user).to_not be_nil + expect(user.user_profile.profile_background).to eq('') + end + end + + it "deal with a profile_background_url passed for an existing user with a profile_background" do + Sidekiq::Testing.inline! do + FileHelper.stubs(:download).returns(logo) + + sso_record.user.user_profile.update_columns(profile_background: '') + + sso.profile_background_url = "http://example.com/a_different_image.png" + + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + + expect(user).to_not be_nil + expect(user.user_profile.profile_background).to_not eq('') + end + end + end + + context 'when sso_overrides_card_background is not enabled' do + + it "correctly handles provided card_background_urls" do + sso = DiscourseSingleSignOn.new + sso.external_id = 666 + sso.email = "sam@sam.com" + sso.name = "sam" + sso.username = "sam" + sso.card_background_url = "http://awesome.com/image.png" + sso.suppress_welcome_message = true + + FileHelper.stubs(:download).returns(file_from_fixtures("logo.png")) + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + card_background = user.user_profile.card_background + + # initial creation ... + expect(card_background).to_not eq(nil) + expect(card_background).to_not eq('') + + FileHelper.stubs(:download) { raise "should not be called" } + sso.card_background_url = "https://some.new/avatar.png" + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + + # card_background updated but no override specified ... + expect(user.user_profile.card_background).to eq(card_background) + end + end + + context 'when sso_overrides_card_background is enabled' do + let!(:sso_record) { Fabricate(:single_sign_on_record, external_card_background_url: "http://example.com/an_image.png") } + + let!(:sso) { + sso = DiscourseSingleSignOn.new + sso.username = "test" + sso.name = "test" + sso.email = sso_record.user.email + sso.external_id = sso_record.external_id + sso + } + + let(:logo) { file_from_fixtures("logo.png") } + + before do + SiteSetting.sso_overrides_card_background = true + end + + it "deal with no card_background_url passed for an existing user with a card_background" do + Sidekiq::Testing.inline! do + # Deliberately not setting card_background_url so it should not update + sso_record.user.user_profile.update_columns(card_background: '') + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + + expect(user).to_not be_nil + expect(user.user_profile.card_background).to eq('') + end + end + + it "deal with a card_background_url passed for an existing user with a card_background_url" do + Sidekiq::Testing.inline! do + FileHelper.stubs(:download).returns(logo) + + sso_record.user.user_profile.update_columns(card_background: '') + + sso.card_background_url = "http://example.com/a_different_image.png" + + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + + expect(user).to_not be_nil + expect(user.user_profile.card_background).to_not eq('') + end + end + end + end diff --git a/spec/models/user_profile_spec.rb b/spec/models/user_profile_spec.rb index ea5aacf327..52db905933 100644 --- a/spec/models/user_profile_spec.rb +++ b/spec/models/user_profile_spec.rb @@ -202,4 +202,39 @@ describe UserProfile do end end end + + context '.import_url_for_user' do + let(:user) { Fabricate(:user) } + + before do + stub_request(:any, "thisfakesomething.something.com") + .to_return(body: "abc", status: 404, headers: { 'Content-Length' => 3 }) + end + + describe 'when profile_background_url returns an invalid status code' do + it 'should not do anything' do + url = "http://thisfakesomething.something.com/" + + UserProfile.import_url_for_user(url, user, is_card_background: false) + + user.reload + + expect(user.user_profile.profile_background).to eq(nil) + end + end + + describe 'when card_background_url returns an invalid status code' do + it 'should not do anything' do + url = "http://thisfakesomething.something.com/" + + UserProfile.import_url_for_user(url, user, is_card_background: true) + + user.reload + + expect(user.user_profile.card_background).to eq(nil) + end + end + + end + end From b18e0825b5794119fc43737c2723f59b0eb2091e Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 17:09:58 +0800 Subject: [PATCH 023/194] Update Gemfile.lock. --- Gemfile.lock | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0641d8cab0..32c275a8b5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -432,7 +432,6 @@ DEPENDENCIES gc_tracer highline hiredis - hkdf (= 0.3.0) htmlentities http_accept_language (~> 2.0.5) listen @@ -503,7 +502,7 @@ DEPENDENCIES unf unicorn webmock - webpush (= 0.3.2) + webpush BUNDLED WITH 1.16.1 From 4f4c5763118f2f07e5f734b6b65307ccbe843f45 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 17:17:54 +0800 Subject: [PATCH 024/194] FIX: Retrigger search scoped to a topic if topic changes. https://meta.discourse.org/t/highlight-the-search-results/10322/12?u=tgxworld --- .../discourse/widgets/search-menu.js.es6 | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 index 39921dbc62..4b8d5c1772 100644 --- a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 @@ -12,6 +12,7 @@ export function initSearchData() { searchData.term = undefined; searchData.typeFilter = null; searchData.invalidTerm = false; + searchData.topicId = null; } initSearchData(); @@ -64,7 +65,11 @@ const SearchHelper = { } searchData.results = content; - widget.appEvents.trigger('post-stream:refresh', { force: true }); + + if (searchContext.type === 'topic') { + widget.appEvents.trigger('post-stream:refresh', { force: true }); + searchData.topicId = searchContext.id; + } }).finally(() => { searchData.loading = false; widget.scheduleRerender(); @@ -164,11 +169,15 @@ export default createWidget('search-menu', { }, html(attrs) { - if (searchData.contextEnabled !== attrs.contextEnabled) { - searchData.contextEnabled = attrs.contextEnabled; - if (searchData.term) this.triggerSearch(); - } else { - searchData.contextEnabled = attrs.contextEnabled; + searchData.contextEnabled = attrs.contextEnabled; + + const shouldTriggerSearch = ( + (searchData.contextEnabled !== attrs.contextEnabled) || + (this.searchContext().type === 'topic' && searchData.topicId !== this.searchContext().id) + ); + + if (shouldTriggerSearch && searchData.term) { + this.triggerSearch(); } return this.attach('menu-panel', { maxWidth: 500, contents: () => this.panelContents() }); From 94163d7f1ab3241056ee9d6c2f7e54977206fbec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Mon, 7 May 2018 11:31:20 +0200 Subject: [PATCH 025/194] add spec for auto-close polls --- .../spec/controllers/posts_controller_spec.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/poll/spec/controllers/posts_controller_spec.rb b/plugins/poll/spec/controllers/posts_controller_spec.rb index bd39395419..917592e53c 100644 --- a/plugins/poll/spec/controllers/posts_controller_spec.rb +++ b/plugins/poll/spec/controllers/posts_controller_spec.rb @@ -35,6 +35,21 @@ describe PostsController do expect(json["polls"]["poll"]).to be end + it "schedules auto-close job" do + name = "auto_close" + close_date = 1.month.from_now + + post :create, params: { + title: title, raw: "[poll name=#{name} close=#{close_date.iso8601}]\n- A\n- B\n[/poll]" + }, format: :json + + expect(response).to be_success + json = ::JSON.parse(response.body) + expect(json["polls"][name]["close"]).to be + + expect(Jobs.scheduled_for(:close_poll, post_id: Post.last.id, poll_name: name)).to be + end + it "should have different options" do post :create, params: { title: title, raw: "[poll]\n- A\n- A\n[/poll]" From a98aae3bcd5dd4456c0e15715f4f4306caa35393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Mon, 7 May 2018 11:43:55 +0200 Subject: [PATCH 026/194] FIX: topic search wasn't working for unlisted topics --- lib/search.rb | 3 ++- spec/components/search_spec.rb | 11 ++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/search.rb b/lib/search.rb index a61a36eb0f..236ba6c96e 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -660,10 +660,11 @@ class Search .joins(:post_search_data, :topic) .joins("LEFT JOIN categories ON categories.id = topics.category_id") .where("topics.deleted_at" => nil) - .where("topics.visible") is_topic_search = @search_context.present? && @search_context.is_a?(Topic) + posts = posts.where("topics.visible") unless is_topic_search + if opts[:private_messages] || (is_topic_search && @search_context.private_message?) posts = posts.where("topics.archetype = ?", Archetype.private_message) diff --git a/spec/components/search_spec.rb b/spec/components/search_spec.rb index 1e5c1a723e..148f28be8b 100644 --- a/spec/components/search_spec.rb +++ b/spec/components/search_spec.rb @@ -251,7 +251,6 @@ describe Search do end it 'displays multiple results within a topic' do - topic = Fabricate(:topic) topic2 = Fabricate(:topic) @@ -260,8 +259,7 @@ describe Search do post1 = new_post('this is the other post I am posting', topic) post2 = new_post('this is my first post I am posting', topic) - post3 = new_post('this is a real long and complicated bla this is my second post I am Posting birds - with more stuff bla bla', topic) + post3 = new_post('this is a real long and complicated bla this is my second post I am Posting birds with more stuff bla bla', topic) post4 = new_post('this is my fourth post I am posting', topic) # update posts_count @@ -281,6 +279,13 @@ describe Search do results = Search.execute('"fourth post I am posting"', search_context: post1.topic) expect(results.posts.length).to eq(1) end + + it "works for unlisted topics" do + topic.update_attributes(visible: false) + post = new_post('discourse is awesome', topic) + results = Search.execute('discourse', search_context: topic) + expect(results.posts.length).to eq(1) + end end context 'searching the OP' do From fc4fde453db47b6d3738b374fe8f2ba65870b747 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 17:44:29 +0800 Subject: [PATCH 027/194] PERF: Remove N+1 query. --- app/services/post_alerter.rb | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 2c30ada818..22ff864f84 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -133,9 +133,11 @@ class PostAlerter .pluck(:user_id) group_ids = topic.allowed_groups.pluck(:group_id) - group_watchers = GroupUser.where(group_id: group_ids, - notification_level: GroupUser.notification_levels[:watching_first_post]) - .pluck(:user_id) + + group_watchers = GroupUser.where( + group_id: group_ids, + notification_level: GroupUser.notification_levels[:watching_first_post] + ).pluck(:user_id) watchers = [cat_watchers, tag_watchers, group_watchers].flatten @@ -146,18 +148,15 @@ class PostAlerter def notify_first_post_watchers(post, user_ids) return if user_ids.blank? - user_ids.uniq! # Don't notify the OP user_ids -= [post.user_id] - users = User.where(id: user_ids) - DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) - user_ids.each do |id| - u = User.find_by(id: id) - create_notification(u, Notification.types[:watching_first_post], post) + + users.each do |user| + create_notification(user, Notification.types[:watching_first_post], post) end end From 0042d94f76ccb6a4e265c272fc9c20985861f818 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 17:48:25 +0800 Subject: [PATCH 028/194] Fix broken JS specs. --- .../javascripts/discourse/widgets/search-menu.js.es6 | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 index 4b8d5c1772..ac0ea6a68c 100644 --- a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 @@ -66,7 +66,7 @@ const SearchHelper = { searchData.results = content; - if (searchContext.type === 'topic') { + if (searchContext && searchContext.type === 'topic') { widget.appEvents.trigger('post-stream:refresh', { force: true }); searchData.topicId = searchContext.id; } @@ -170,10 +170,15 @@ export default createWidget('search-menu', { html(attrs) { searchData.contextEnabled = attrs.contextEnabled; + const searchContext = this.searchContext(); const shouldTriggerSearch = ( (searchData.contextEnabled !== attrs.contextEnabled) || - (this.searchContext().type === 'topic' && searchData.topicId !== this.searchContext().id) + (searchContext && + searchContext.type === 'topic' && + searchData.topicId !== null && + searchData.topicId !== searchContext.id + ) ); if (shouldTriggerSearch && searchData.term) { From 5e45b682a2f6080817a4b12c87112bdab4df5ced Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 18:34:45 +0800 Subject: [PATCH 029/194] Fix JS tests take 2. --- app/assets/javascripts/discourse/widgets/search-menu.js.es6 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 index ac0ea6a68c..aa2a45c4a7 100644 --- a/app/assets/javascripts/discourse/widgets/search-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/search-menu.js.es6 @@ -69,6 +69,8 @@ const SearchHelper = { if (searchContext && searchContext.type === 'topic') { widget.appEvents.trigger('post-stream:refresh', { force: true }); searchData.topicId = searchContext.id; + } else { + searchData.topicId = null; } }).finally(() => { searchData.loading = false; From ee1eb1a5bd834ed49e7efe2ee58bb567556a8c7c Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Mon, 7 May 2018 21:29:06 +0800 Subject: [PATCH 030/194] FIX: Retrigger notification when a topic is recategorized. https://meta.discourse.org/t/notifications-when-a-topic-is-recategorized/63079 --- app/models/topic.rb | 16 +++++++++++++++- spec/fabricators/topic_fabricator.rb | 1 - spec/models/topic_spec.rb | 28 ++++++++++++++++++++++++---- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/app/models/topic.rb b/app/models/topic.rb index b8be34167e..6b01d9e453 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -662,12 +662,26 @@ SQL if self.category_id != new_category.id self.update!(category_id: new_category.id) - Category.where(id: old_category.id).update_all("topic_count = topic_count - 1") if old_category + + if old_category + Category + .where(id: old_category.id) + .update_all("topic_count = topic_count - 1") + end # when a topic changes category we may have to start watching it # if we happen to have read state for it CategoryUser.auto_watch(category_id: new_category.id, topic_id: self.id) CategoryUser.auto_track(category_id: new_category.id, topic_id: self.id) + + post = self.ordered_posts.first + + if post + PostAlerter.new.notify_post_users( + post, + [post.user, post.last_editor].uniq + ) + end end Category.where(id: new_category.id).update_all("topic_count = topic_count + 1") diff --git a/spec/fabricators/topic_fabricator.rb b/spec/fabricators/topic_fabricator.rb index 1fd06233cd..b3efbb98e3 100644 --- a/spec/fabricators/topic_fabricator.rb +++ b/spec/fabricators/topic_fabricator.rb @@ -17,7 +17,6 @@ Fabricator(:banner_topic, from: :topic) do end Fabricator(:private_message_topic, from: :topic) do - user category_id { nil } title { sequence(:title) { |i| "This is a private message #{i}" } } archetype "private_message" diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 6a17b19bec..aed6e653dd 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -1191,11 +1191,8 @@ describe Topic do category.reload end - it 'increases the topic_count' do - expect(category.topic_count).to eq(1) - end - it "doesn't change the topic_count when the value doesn't change" do + expect(category.topic_count).to eq(1) expect { topic.change_category_to_id(category.id); category.reload }.not_to change(category, :topic_count) end @@ -1215,6 +1212,29 @@ describe Topic do expect(category.reload.topic_count).to eq(0) end + describe 'user that watching the new category' do + it 'should generate the notification for the topic' do + topic.posts << Fabricate(:post) + + CategoryUser.set_notification_level_for_category( + user, + CategoryUser::notification_levels[:watching], + new_category.id + ) + + expect do + topic.change_category_to_id(new_category.id) + end.to change { Notification.count }.by(1) + + notification = Notification.last + + expect(notification.notification_type).to eq(Notification.types[:posted]) + expect(notification.topic_id).to eq(topic.id) + expect(notification.user_id).to eq(user.id) + expect(notification.post_number).to eq(1) + end + end + describe 'when new category is set to auto close by default' do before do new_category.update!(auto_close_hours: 5) From 9738025bb2847e398b5fda36abb419ec0291d58d Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Mon, 7 May 2018 10:44:37 -0700 Subject: [PATCH 031/194] Feature: display description for group cards --- .../discourse/templates/components/group-card-contents.hbs | 1 + app/assets/stylesheets/desktop/user-card.scss | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/app/assets/javascripts/discourse/templates/components/group-card-contents.hbs b/app/assets/javascripts/discourse/templates/components/group-card-contents.hbs index 4ae2f03a3d..4094741647 100644 --- a/app/assets/javascripts/discourse/templates/components/group-card-contents.hbs +++ b/app/assets/javascripts/discourse/templates/components/group-card-contents.hbs @@ -20,6 +20,7 @@ {{else}}

{{group.name}}

{{/if}} +

{{{group.bio_cooked}}}

diff --git a/app/assets/stylesheets/desktop/user-card.scss b/app/assets/stylesheets/desktop/user-card.scss index df18f58a82..db50a811b3 100644 --- a/app/assets/stylesheets/desktop/user-card.scss +++ b/app/assets/stylesheets/desktop/user-card.scss @@ -48,6 +48,11 @@ $user_card_background: $secondary; } } + .bio { + max-height: 200px; + overflow: auto; + } + &.no-bg { min-height: 50px; From ac60a843290bab828d08ec52326fc959ecfeb363 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Mon, 7 May 2018 15:14:18 -0400 Subject: [PATCH 032/194] FEATURE: New site setting `min_flags_staff_visibility` When set higher than 1, flags won't show up for staff in the admin section unless the minimum threshold of flags on a post is reached. --- app/models/post_action.rb | 13 +++++-- config/locales/server.en.yml | 1 + config/site_settings.yml | 1 + lib/flag_query.rb | 27 +++++++++++---- spec/components/flag_query_spec.rb | 55 ++++++++++++++++++++++++++++++ spec/models/post_action_spec.rb | 11 ++++++ 6 files changed, 99 insertions(+), 9 deletions(-) diff --git a/app/models/post_action.rb b/app/models/post_action.rb index 8e705527cd..b60fe0f5df 100644 --- a/app/models/post_action.rb +++ b/app/models/post_action.rb @@ -52,13 +52,22 @@ class PostAction < ActiveRecord::Base end def self.update_flagged_posts_count - posts_flagged_count = PostAction.active + flagged_relation = PostAction.active .flags .joins(post: :topic) .where('posts.deleted_at' => nil) .where('topics.deleted_at' => nil) .where('posts.user_id > 0') - .count('DISTINCT posts.id') + .group("posts.id") + + if SiteSetting.min_flags_staff_visibility > 1 + flagged_relation = flagged_relation + .having("count(*) >= ?", SiteSetting.min_flags_staff_visibility) + end + + posts_flagged_count = flagged_relation + .pluck("posts.id") + .count $redis.set('posts_flagged_count', posts_flagged_count) user_ids = User.staff.pluck(:id) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 41b7fdcab2..db052f97cf 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1473,6 +1473,7 @@ en: auto_silence_fast_typers_max_trust_level: "Maximum trust level to auto silence fast typers" auto_silence_first_post_regex: "Case insensitive regex that if passed will cause first post by user to be silenced and sent to approval queue. Example: raging|a[bc]a , will cause all posts containing raging or aba or aca to be silenced on first. Only applies to first post." flags_default_topics: "Show flagged topics by default in the admin section" + min_flags_staff_visibility: "The minimum amount of flags on a post must have before staff can see it in the admin section" reply_by_email_enabled: "Enable replying to topics via email." reply_by_email_address: "Template for reply by email incoming email address, for example: %{reply_key}@reply.example.com or replies+%{reply_key}@example.com" diff --git a/config/site_settings.yml b/config/site_settings.yml index a6bfc78d92..eeb504b480 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1096,6 +1096,7 @@ spam: flags_default_topics: default: false client: true + min_flags_staff_visibility: 1 rate_limits: unique_posts_mins: 5 diff --git a/lib/flag_query.rb b/lib/flag_query.rb index 268e4abef1..11db2aa887 100644 --- a/lib/flag_query.rb +++ b/lib/flag_query.rb @@ -21,12 +21,16 @@ module FlagQuery total_rows = actions.count - post_ids = actions.limit(per_page) + post_ids_relation = actions.limit(per_page) .offset(offset) .group(:post_id) .order('MIN(post_actions.created_at) DESC') - .pluck(:post_id) - .uniq + + if opts[:filter] != "old" && SiteSetting.min_flags_staff_visibility > 1 + post_ids_relation = post_ids_relation.having("count(*) >= ?", SiteSetting.min_flags_staff_visibility) + end + + post_ids = post_ids_relation.pluck(:post_id).uniq posts = SqlBuilder.new(" SELECT p.id, @@ -182,18 +186,25 @@ module FlagQuery ft_by_id = {} users_by_id = {} topics_by_id = {} + counts_by_post = {} results.each do |pa| if pa.post.present? && pa.post.topic.present? - ft = ft_by_id[pa.post.topic.id] ||= OpenStruct.new( + topic_id = pa.post.topic.id + + ft = ft_by_id[topic_id] ||= OpenStruct.new( topic: pa.post.topic, flag_counts: {}, user_ids: [], - last_flag_at: pa.created_at + last_flag_at: pa.created_at, + meets_minimum: false ) - topics_by_id[pa.post.topic.id] = pa.post.topic + counts_by_post[pa.post.id] ||= 0 + sum = counts_by_post[pa.post.id] += 1 + ft.meets_minimum = true if sum >= SiteSetting.min_flags_staff_visibility + topics_by_id[topic_id] = pa.post.topic ft.flag_counts[pa.post_action_type_id] ||= 0 ft.flag_counts[pa.post_action_type_id] += 1 @@ -204,9 +215,11 @@ module FlagQuery end end + flagged_topics = ft_by_id.values.select { |ft| ft.meets_minimum } + Topic.preload_custom_fields(topics_by_id.values, TopicList.preloaded_custom_fields) - { flagged_topics: ft_by_id.values, users: users_by_id.values } + { flagged_topics: flagged_topics, users: users_by_id.values } end private diff --git a/spec/components/flag_query_spec.rb b/spec/components/flag_query_spec.rb index 592045e641..428a5aa319 100644 --- a/spec/components/flag_query_spec.rb +++ b/spec/components/flag_query_spec.rb @@ -5,6 +5,41 @@ describe FlagQuery do let(:codinghorror) { Fabricate(:coding_horror) } + describe "flagged_topics" do + it "respects `min_flags_staff_visibility`" do + admin = Fabricate(:admin) + moderator = Fabricate(:moderator) + + post = create_post + + PostAction.act(moderator, post, PostActionType.types[:spam]) + + SiteSetting.min_flags_staff_visibility = 1 + + result = FlagQuery.flagged_topics + expect(result[:flagged_topics]).to be_present + ft = result[:flagged_topics].first + expect(ft.topic).to eq(post.topic) + expect(ft.flag_counts).to eq(PostActionType.types[:spam] => 1) + + SiteSetting.min_flags_staff_visibility = 2 + + result = FlagQuery.flagged_topics + expect(result[:flagged_topics]).to be_blank + + PostAction.act(admin, post, PostActionType.types[:inappropriate]) + result = FlagQuery.flagged_topics + expect(result[:flagged_topics]).to be_present + ft = result[:flagged_topics].first + expect(ft.topic).to eq(post.topic) + expect(ft.flag_counts).to eq( + PostActionType.types[:spam] => 1, + PostActionType.types[:inappropriate] => 1 + ) + end + + end + describe "flagged_posts_report" do it "does not return flags on system posts" do admin = Fabricate(:admin) @@ -75,7 +110,27 @@ describe FlagQuery do posts, users = FlagQuery.flagged_posts_report(moderator) expect(posts.count).to eq(1) + end + it "respects `min_flags_staff_visibility`" do + admin = Fabricate(:admin) + moderator = Fabricate(:moderator) + + post = create_post + + PostAction.act(moderator, post, PostActionType.types[:spam]) + + SiteSetting.min_flags_staff_visibility = 2 + posts, topics, users = FlagQuery.flagged_posts_report(admin) + expect(posts).to be_blank + expect(topics).to be_blank + expect(users).to be_blank + + PostAction.act(admin, post, PostActionType.types[:inappropriate]) + posts, topics, users = FlagQuery.flagged_posts_report(admin) + expect(posts).to be_present + expect(topics).to be_present + expect(users).to be_present end end diff --git a/spec/models/post_action_spec.rb b/spec/models/post_action_spec.rb index da30be3cc9..7a10f34648 100644 --- a/spec/models/post_action_spec.rb +++ b/spec/models/post_action_spec.rb @@ -141,6 +141,17 @@ describe PostAction do expect(PostAction.flagged_posts_count).to eq(0) end + it "respects min_flags_staff_visibility" do + SiteSetting.min_flags_staff_visibility = 2 + expect(PostAction.flagged_posts_count).to eq(0) + + PostAction.act(codinghorror, post, PostActionType.types[:off_topic]) + expect(PostAction.flagged_posts_count).to eq(0) + + PostAction.act(eviltrout, post, PostActionType.types[:off_topic]) + expect(PostAction.flagged_posts_count).to eq(1) + end + it "should reset counts when a topic is deleted" do PostAction.act(codinghorror, post, PostActionType.types[:off_topic]) post.topic.trash! From 2002a7c4eff8e9de4046cda56809b77ddf658879 Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Mon, 7 May 2018 17:00:40 -0700 Subject: [PATCH 033/194] FIX: regenerate vapid keys when the public key bytes is blank --- config/initializers/100-push-notifications.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/100-push-notifications.rb b/config/initializers/100-push-notifications.rb index 54e6a62010..8cb0542cab 100644 --- a/config/initializers/100-push-notifications.rb +++ b/config/initializers/100-push-notifications.rb @@ -1,6 +1,6 @@ require_dependency 'webpush' -if SiteSetting.vapid_public_key.blank? || SiteSetting.vapid_private_key.blank? +if SiteSetting.vapid_public_key.blank? || SiteSetting.vapid_private_key.blank? || SiteSetting.vapid_public_key_bytes.blank? vapid_key = Webpush.generate_key SiteSetting.vapid_public_key = vapid_key.public_key SiteSetting.vapid_private_key = vapid_key.private_key From 8cf0f51eb2c9ef5c6155d77aab1d0d9c7b104af0 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Tue, 8 May 2018 10:30:33 +0800 Subject: [PATCH 034/194] UX: Display site settings shortcut for `poll` and `discourse-nginx-performance-report`. https://meta.discourse.org/t/improving-admin-plugins/84585/29?u=tgxworld --- .../admin/routes/admin-plugins.js.es6 | 8 +++++--- app/serializers/admin_plugin_serializer.rb | 17 +++++++++++++---- lib/plugin/instance.rb | 8 ++++++++ .../plugin.rb | 3 +++ plugins/poll/plugin.rb | 2 ++ spec/components/plugin/instance_spec.rb | 15 +++++++++++++++ 6 files changed, 46 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/admin/routes/admin-plugins.js.es6 b/app/assets/javascripts/admin/routes/admin-plugins.js.es6 index 820c5207d4..8c5886f4a4 100644 --- a/app/assets/javascripts/admin/routes/admin-plugins.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-plugins.js.es6 @@ -8,12 +8,15 @@ export default Ember.Route.extend({ const controller = this.controllerFor('adminSiteSettings'); this.transitionTo('adminSiteSettingsCategory', 'plugins').then(() => { if (plugin) { + const siteSettingFilter = plugin.get('enabled_setting_filter'); const match = /^(.*)_enabled/.exec(plugin.get('enabled_setting')); - if (match[1]) { + const filter = siteSettingFilter || match[1]; + + if (filter) { // filterContent() is normally on a debounce from typing. // Because we don't want the default of "All Results", we tell it // to skip the next debounce. - controller.set('filter', match[1]); + controller.set('filter', filter); controller.set('_skipBounce', true); controller.filterContentNow('plugins'); } @@ -22,4 +25,3 @@ export default Ember.Route.extend({ } } }); - diff --git a/app/serializers/admin_plugin_serializer.rb b/app/serializers/admin_plugin_serializer.rb index b104d69adb..2ac11f3413 100644 --- a/app/serializers/admin_plugin_serializer.rb +++ b/app/serializers/admin_plugin_serializer.rb @@ -6,7 +6,8 @@ class AdminPluginSerializer < ApplicationSerializer :admin_route, :enabled, :enabled_setting, - :is_official + :is_official, + :enabled_setting_filter def id object.metadata.name @@ -28,12 +29,20 @@ class AdminPluginSerializer < ApplicationSerializer object.enabled? end - def enabled_setting + def include_enabled_setting? object.enabled_site_setting end - def include_enabled_setting? - enabled_setting.present? + def enabled_setting + true + end + + def include_enabled_setting_filter? + object.enabled_site_setting_filter.present? + end + + def enabled_setting_filter + object.enabled_site_setting_filter end def include_url? diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index 0bc54a09c8..3a12e02df4 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -493,6 +493,14 @@ JS PluginGem.load(path, name, version, opts) end + def enabled_site_setting_filter(filter = nil) + if filter + @enabled_setting_filter = filter + else + @enabled_setting_filter + end + end + def enabled_site_setting(setting = nil) if setting @enabled_site_setting = setting diff --git a/plugins/discourse-nginx-performance-report/plugin.rb b/plugins/discourse-nginx-performance-report/plugin.rb index 4b139778de..c0f1159a6a 100644 --- a/plugins/discourse-nginx-performance-report/plugin.rb +++ b/plugins/discourse-nginx-performance-report/plugin.rb @@ -3,6 +3,9 @@ # version: 0.1 # url: https://github.com/discourse/discourse/tree/master/plugins/discourse-nginx-performance-report +enabled_site_setting :daily_performance_report +enabled_site_setting_filter "daily_performance_report" + after_initialize do load File.expand_path("../app/jobs/scheduled/daily_performance_report.rb", __FILE__) end diff --git a/plugins/poll/plugin.rb b/plugins/poll/plugin.rb index ec13cc8749..f4f2c41d8d 100644 --- a/plugins/poll/plugin.rb +++ b/plugins/poll/plugin.rb @@ -9,6 +9,8 @@ register_asset "stylesheets/common/poll-ui-builder.scss" register_asset "stylesheets/desktop/poll.scss", :desktop register_asset "stylesheets/mobile/poll.scss", :mobile +enabled_site_setting :poll_enabled + PLUGIN_NAME ||= "discourse_poll".freeze DATA_PREFIX ||= "data-poll-".freeze diff --git a/spec/components/plugin/instance_spec.rb b/spec/components/plugin/instance_spec.rb index de6641218a..b791a7d8f8 100644 --- a/spec/components/plugin/instance_spec.rb +++ b/spec/components/plugin/instance_spec.rb @@ -381,4 +381,19 @@ describe Plugin::Instance do end end + describe '#enabled_site_setting_filter' do + describe 'when filter is blank' do + it 'should return the right value' do + expect(Plugin::Instance.new.enabled_site_setting_filter).to eq(nil) + end + end + + it 'should set the right value' do + instance = Plugin::Instance.new + instance.enabled_site_setting_filter('test') + + expect(instance.enabled_site_setting_filter).to eq('test') + end + end + end From 3a6e137e70c0d57e977679b8a39494c17b1c469e Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Tue, 8 May 2018 08:14:49 +0530 Subject: [PATCH 035/194] FIX: add context for deactivated user logs --- app/assets/javascripts/admin/models/admin-user.js.es6 | 3 ++- app/controllers/admin/users_controller.rb | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/admin/models/admin-user.js.es6 b/app/assets/javascripts/admin/models/admin-user.js.es6 index 6cd414e976..b826426473 100644 --- a/app/assets/javascripts/admin/models/admin-user.js.es6 +++ b/app/assets/javascripts/admin/models/admin-user.js.es6 @@ -300,7 +300,8 @@ const AdminUser = Discourse.User.extend({ deactivate() { return ajax('/admin/users/' + this.id + '/deactivate', { - type: 'PUT' + type: 'PUT', + data: { context: document.location.pathname } }).then(function() { window.location.reload(); }).catch(function(e) { diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index d0053c5aaa..10922e3c90 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -276,7 +276,7 @@ class Admin::UsersController < Admin::AdminController def deactivate guardian.ensure_can_deactivate!(@user) @user.deactivate - StaffActionLogger.new(current_user).log_user_deactivate(@user, I18n.t('user.deactivated_by_staff')) + StaffActionLogger.new(current_user).log_user_deactivate(@user, I18n.t('user.deactivated_by_staff'), params.slice(:context)) refresh_browser @user render body: nil end From 086cb0f8497402a20c5f7b6d6873e915e1d52e4b Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Tue, 8 May 2018 10:50:11 +0800 Subject: [PATCH 036/194] Fix brittle spec. --- spec/models/group_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index c4c34bc23f..4027250bef 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -685,7 +685,7 @@ describe Group do it "should publish the group's categories to the client" do group.update!(public_admission: true, categories: [category]) - message = MessageBus.track_publish { group.add(user) }.first + message = MessageBus.track_publish("/categories") { group.add(user) }.first expect(message.data[:categories].count).to eq(1) expect(message.data[:categories].first[:id]).to eq(category.id) From c6f45fcfdb535aa99ef7ecb67797a8c67964fa94 Mon Sep 17 00:00:00 2001 From: Guo Xiang Tan Date: Tue, 8 May 2018 13:24:58 +0800 Subject: [PATCH 037/194] Expose an API for plugins to be hidden on the admin plugin page. --- app/controllers/admin/plugins_controller.rb | 2 +- lib/discourse.rb | 8 ++++++++ lib/plugin/instance.rb | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/controllers/admin/plugins_controller.rb b/app/controllers/admin/plugins_controller.rb index 516c1e0560..3221d104bb 100644 --- a/app/controllers/admin/plugins_controller.rb +++ b/app/controllers/admin/plugins_controller.rb @@ -1,7 +1,7 @@ class Admin::PluginsController < Admin::AdminController def index - render_serialized(Discourse.plugins, AdminPluginSerializer, root: 'plugins') + render_serialized(Discourse.display_plugins, AdminPluginSerializer, root: 'plugins') end end diff --git a/lib/discourse.rb b/lib/discourse.rb index 0673e1f119..52a166e63f 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -164,6 +164,14 @@ module Discourse @plugins ||= [] end + def self.hidden_plugins + @hidden_plugins ||= [] + end + + def self.display_plugins + self.plugins - self.hidden_plugins + end + def self.plugin_themes @plugin_themes ||= plugins.map(&:themes).flatten end diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index 3a12e02df4..81b7d7e243 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -493,6 +493,10 @@ JS PluginGem.load(path, name, version, opts) end + def hide_plugin + Discourse.hidden_plugins << self + end + def enabled_site_setting_filter(filter = nil) if filter @enabled_setting_filter = filter From 858a26603111e1a7f63da2d4c680dcdbd5d6108c Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 8 May 2018 15:59:03 +1000 Subject: [PATCH 038/194] FIX: exact matching should also match on title --- lib/search.rb | 2 +- spec/components/search_spec.rb | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/search.rb b/lib/search.rb index 236ba6c96e..adab7e3a7e 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -698,7 +698,7 @@ class Search posts = posts.where("post_search_data.search_data @@ #{ts_query(weight_filter: weights)}") exact_terms = @term.scan(/"([^"]+)"/).flatten exact_terms.each do |exact| - posts = posts.where("posts.raw ilike ?", "%#{exact}%") + posts = posts.where("posts.raw ilike :exact OR topics.title ilike :exact", exact: "%#{exact}%") end end end diff --git a/spec/components/search_spec.rb b/spec/components/search_spec.rb index 148f28be8b..273be47c31 100644 --- a/spec/components/search_spec.rb +++ b/spec/components/search_spec.rb @@ -282,7 +282,7 @@ describe Search do it "works for unlisted topics" do topic.update_attributes(visible: false) - post = new_post('discourse is awesome', topic) + _post = new_post('discourse is awesome', topic) results = Search.execute('discourse', search_context: topic) expect(results.posts.length).to eq(1) end @@ -312,6 +312,16 @@ describe Search do end end + context 'searching for quoted title' do + it "can find quoted title" do + create_post(raw: "this is the raw body", title: "I am a title yeah") + result = Search.execute('"a title yeah"') + + expect(result.posts.length).to eq(1) + end + + end + context "search for a topic by id" do let(:result) { Search.execute(topic.id, type_filter: 'topic', search_for_id: true, min_search_term_length: 1) } From 62c266f987c5af59a7d373623fc43769029f106d Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Tue, 8 May 2018 15:05:12 +0530 Subject: [PATCH 039/194] add a rake task to move all topics from one category to another --- lib/tasks/categories.rake | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 lib/tasks/categories.rake diff --git a/lib/tasks/categories.rake b/lib/tasks/categories.rake new file mode 100644 index 0000000000..920b3255ee --- /dev/null +++ b/lib/tasks/categories.rake @@ -0,0 +1,20 @@ +task "categories:move_topics", [:from_category, :to_category] => [:environment] do |_, args| + from_category_id = args[:from_category] + to_category_id = args[:to_category] + + if !from_category_id || !to_category_id + puts "ERROR: Expecting categories:move_topics[from_category_id,to_category_id]" + exit 1 + end + + from_category = Category.find(from_category_id) + to_category = Category.find(to_category_id) + + if from_category && to_category + Topic.where(category_id: from_category_id).update_all(category_id: to_category_id) + from_category.update_attribute(:topic_count, 0) + Category.update_stats + end + + puts "", "Done!", "" +end From e9d92da9eebc14015efa381e584e90ded07f78eb Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Tue, 8 May 2018 15:14:19 +0530 Subject: [PATCH 040/194] minor optimizations to categories:move_topics rake task --- lib/tasks/categories.rake | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/tasks/categories.rake b/lib/tasks/categories.rake index 920b3255ee..407bf65f74 100644 --- a/lib/tasks/categories.rake +++ b/lib/tasks/categories.rake @@ -10,9 +10,12 @@ task "categories:move_topics", [:from_category, :to_category] => [:environment] from_category = Category.find(from_category_id) to_category = Category.find(to_category_id) - if from_category && to_category - Topic.where(category_id: from_category_id).update_all(category_id: to_category_id) + if from_category.present? && to_category.present? + puts "Moving topics from #{from_category.slug} to #{to_category.slug}..." + Topic.where(category_id: from_category.id).update_all(category_id: to_category.id) from_category.update_attribute(:topic_count, 0) + + puts "Updating category stats..." Category.update_stats end From 2e67998319950fb5caa6d5a9110285fa26ddbff7 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Tue, 8 May 2018 16:02:43 +0200 Subject: [PATCH 041/194] Improvements for user renaming (#5810) * FEATURE: Update avatars in posts and revisions when user gets renamed * FIX: Replace username in deleted posts when user gets renamed * FEATURE: Replace username in notifications when user gets renamed FEATURE: Update mentions and quotes when user gets merged --- app/jobs/regular/update_username.rb | 71 ++++++++++-- app/services/user_merger.rb | 52 +-------- spec/services/user_merger_spec.rb | 72 ++---------- spec/services/username_changer_spec.rb | 149 ++++++++++++++++++++++++- 4 files changed, 219 insertions(+), 125 deletions(-) diff --git a/app/jobs/regular/update_username.rb b/app/jobs/regular/update_username.rb index ebc5f1aa0b..13e4f8bd84 100644 --- a/app/jobs/regular/update_username.rb +++ b/app/jobs/regular/update_username.rb @@ -3,21 +3,22 @@ module Jobs def execute(args) @user_id = args[:user_id] - - username = args[:old_username] - @raw_mention_regex = /(?:(?> 'original_username' + WHEN :old_username + THEN :new_username + ELSE NULL END, + 'display_username', CASE data :: JSONB ->> 'display_username' + WHEN :old_username + THEN :new_username + ELSE NULL END, + 'username', CASE data :: JSONB ->> 'username' + WHEN :old_username + THEN :new_username + ELSE NULL END + ) + )) :: JSON + WHERE EXISTS( + SELECT 1 + FROM posts AS p + WHERE p.topic_id = n.topic_id + AND p.post_number = n.post_number + AND p.user_id = :user_id) + OR (n.notification_type IN (:notification_types_with_correct_user_id) AND n.user_id = :user_id) + OR (n.notification_type = :invitee_accepted_notification_type + AND EXISTS( + SELECT 1 + FROM invites i + WHERE i.user_id = :user_id AND n.user_id = i.invited_by_id + ) + ) + SQL + end + protected def post_conditions(post_id_column) @@ -82,13 +131,17 @@ module Jobs end doc.css("aside.quote > div.title").each do |div| - # TODO Update avatar URL div.children.each do |child| child.content = child.content.gsub(@cooked_quote_username_regex, @new_username) if child.text? end + div.at_css("img.avatar")&.replace(avatar_img) end doc.to_html end + + def avatar_img + @avatar_img ||= PrettyText.avatar_img(User.find_by_id(@user_id).avatar_template, "tiny") + end end end diff --git a/app/services/user_merger.rb b/app/services/user_merger.rb index bc982523a0..b257ecd4d2 100644 --- a/app/services/user_merger.rb +++ b/app/services/user_merger.rb @@ -5,7 +5,7 @@ class UserMerger end def merge! - update_notifications + update_username move_posts update_user_ids merge_given_daily_likes @@ -23,52 +23,10 @@ class UserMerger protected - def update_notifications - params = { - source_user_id: @source_user.id, - source_username: @source_user.username, - target_username: @target_user.username, - notification_types_with_correct_user_id: [ - Notification.types[:granted_badge], - Notification.types[:group_message_summary] - ], - invitee_accepted_notification_type: Notification.types[:invitee_accepted] - } - - Notification.exec_sql(<<~SQL, params) - UPDATE notifications AS n - SET data = (data :: JSONB || - jsonb_strip_nulls( - jsonb_build_object( - 'original_username', CASE data :: JSONB ->> 'original_username' - WHEN :source_username - THEN :target_username - ELSE NULL END, - 'display_username', CASE data :: JSONB ->> 'display_username' - WHEN :source_username - THEN :target_username - ELSE NULL END, - 'username', CASE data :: JSONB ->> 'username' - WHEN :source_username - THEN :target_username - ELSE NULL END - ) - )) :: JSON - WHERE EXISTS( - SELECT 1 - FROM posts AS p - WHERE p.topic_id = n.topic_id - AND p.post_number = n.post_number - AND p.user_id = :source_user_id) - OR (n.notification_type IN (:notification_types_with_correct_user_id) AND n.user_id = :source_user_id) - OR (n.notification_type = :invitee_accepted_notification_type - AND EXISTS( - SELECT 1 - FROM invites i - WHERE i.user_id = :source_user_id AND n.user_id = i.invited_by_id - ) - ) - SQL + def update_username + Jobs::UpdateUsername.new.execute(user_id: @source_user.id, + old_username: @source_user.username, + new_username: @target_user.username) end def move_posts diff --git a/spec/services/user_merger_spec.rb b/spec/services/user_merger_spec.rb index ac5d3545b4..236372b125 100644 --- a/spec/services/user_merger_spec.rb +++ b/spec/services/user_merger_spec.rb @@ -289,69 +289,6 @@ describe UserMerger do expect(Notification.where(user_id: target_user.id).count).to eq(2) expect(Notification.where(user_id: source_user.id).count).to eq(0) end - - def create_notification(type, notified_user, post, data = {}) - Fabricate( - :notification, - notification_type: Notification.types[type], - user: notified_user, - data: data.to_json, - topic: post&.topic, - post_number: post&.post_number - ) - end - - def notification_data(notification) - JSON.parse(notification.reload.data, symbolize_names: true) - end - - def original_and_display_username(user) - { original_username: user.username, display_username: user.username, foo: "bar" } - end - - def original_username_and_some_text_as_display_username(user) - { original_username: user.username, display_username: "some text", foo: "bar" } - end - - def only_display_username(user) - { display_username: user.username } - end - - def username_and_something_else(user) - { username: user.username, foo: "bar" } - end - - it "updates notification data" do - notified_user = Fabricate(:user) - p1 = Fabricate(:post, post_number: 1, user: source_user) - p2 = Fabricate(:post, post_number: 1, user: walter) - Fabricate(:invite, invited_by: notified_user, user: source_user) - Fabricate(:invite, invited_by: notified_user, user: walter) - - n01 = create_notification(:mentioned, notified_user, p1, original_and_display_username(source_user)) - n02 = create_notification(:mentioned, notified_user, p2, original_and_display_username(walter)) - n03 = create_notification(:mentioned, notified_user, p1, original_username_and_some_text_as_display_username(source_user)) - n04 = create_notification(:mentioned, notified_user, p1, only_display_username(source_user)) - n05 = create_notification(:invitee_accepted, notified_user, nil, only_display_username(source_user)) - n06 = create_notification(:invitee_accepted, notified_user, nil, only_display_username(walter)) - n07 = create_notification(:granted_badge, source_user, nil, username_and_something_else(source_user)) - n08 = create_notification(:granted_badge, walter, nil, username_and_something_else(walter)) - n09 = create_notification(:group_message_summary, source_user, nil, username_and_something_else(source_user)) - n10 = create_notification(:group_message_summary, walter, nil, username_and_something_else(walter)) - - merge_users! - - expect(notification_data(n01)).to eq(original_and_display_username(target_user)) - expect(notification_data(n02)).to eq(original_and_display_username(walter)) - expect(notification_data(n03)).to eq(original_username_and_some_text_as_display_username(target_user)) - expect(notification_data(n04)).to eq(only_display_username(target_user)) - expect(notification_data(n05)).to eq(only_display_username(target_user)) - expect(notification_data(n06)).to eq(only_display_username(walter)) - expect(notification_data(n07)).to eq(username_and_something_else(target_user)) - expect(notification_data(n08)).to eq(username_and_something_else(walter)) - expect(notification_data(n09)).to eq(username_and_something_else(target_user)) - expect(notification_data(n10)).to eq(username_and_something_else(walter)) - end end context "post actions" do @@ -1068,4 +1005,13 @@ describe UserMerger do expect(User.find_by_username(source_user.username)).to be_nil end + + it "updates the username" do + Jobs::UpdateUsername.any_instance + .expects(:execute) + .with(user_id: source_user.id, old_username: 'alice1', new_username: 'alice') + .once + + merge_users! + end end diff --git a/spec/services/username_changer_spec.rb b/spec/services/username_changer_spec.rb index dad6e4d158..dd74ca95ad 100644 --- a/spec/services/username_changer_spec.rb +++ b/spec/services/username_changer_spec.rb @@ -98,13 +98,15 @@ describe UsernameChanger do before { UserActionCreator.enable } after { UserActionCreator.disable } - def create_post_and_change_username(args = {}) + def create_post_and_change_username(args = {}, &block) post = create_post(args.merge(topic_id: topic.id)) args.delete(:revisions)&.each do |revision| post.revise(post.user, revision, force_new_version: true) end + block.call(post) if block + UsernameChanger.change(user, 'bar') post.reload end @@ -231,14 +233,26 @@ describe UsernameChanger do expect(post.revisions[2].modifications["cooked"][0]).to eq(%Q(

Hello @bar!

)) expect(post.revisions[2].modifications["cooked"][1]).to eq(%Q(

Hello @bar!!

)) end + + it 'replaces mentions in posts marked for deletion' do + post = create_post_and_change_username(raw: "Hello @foo") do |p| + PostDestroyer.new(p.user, p).destroy + end + + expect(post.raw).to_not include("@foo") + expect(post.cooked).to_not include("foo") + expect(post.revisions.count).to eq(1) + + expect(post.revisions[0].modifications["raw"][0]).to eq("Hello @bar") + expect(post.revisions[0].modifications["cooked"][0]).to eq(%Q(

Hello @bar

)) + end end context 'quotes' do let(:quoted_post) { create_post(user: user, topic: topic, post_number: 1, raw: "quoted post") } + let(:avatar_url) { user.avatar_template.gsub("{size}", "40") } it 'replaces the username in quote tags' do - avatar_url = user.avatar_template_url.gsub("{size}", "40") - post = create_post_and_change_username(raw: <<~RAW) Lorem ipsum @@ -280,7 +294,7 @@ describe UsernameChanger do