Merge pull request #1663 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes
This commit is contained in:
		
						commit
						e58e0eb9aa
					
				| @ -202,10 +202,6 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io | |||||||
| # Name of the pam service used for checking if an user can register (pam "account" section is evaluated) (nil (disabled) by default) | # Name of the pam service used for checking if an user can register (pam "account" section is evaluated) (nil (disabled) by default) | ||||||
| # PAM_CONTROLLED_SERVICE=rpam | # PAM_CONTROLLED_SERVICE=rpam | ||||||
| 
 | 
 | ||||||
| # Global OAuth settings (optional) : |  | ||||||
| # If you have only one strategy, you may want to enable this |  | ||||||
| # OAUTH_REDIRECT_AT_SIGN_IN=true |  | ||||||
| 
 |  | ||||||
| # Optional CAS authentication (cf. omniauth-cas) : | # Optional CAS authentication (cf. omniauth-cas) : | ||||||
| # CAS_ENABLED=true | # CAS_ENABLED=true | ||||||
| # CAS_URL=https://sso.myserver.com/ | # CAS_URL=https://sso.myserver.com/ | ||||||
|  | |||||||
| @ -83,10 +83,14 @@ class Api::V1::AccountsController < Api::BaseController | |||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def check_enabled_registrations |   def check_enabled_registrations | ||||||
|     forbidden if single_user_mode? || !allowed_registrations? |     forbidden if single_user_mode? || omniauth_only? || !allowed_registrations? | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def allowed_registrations? |   def allowed_registrations? | ||||||
|     Setting.registrations_mode != 'none' |     Setting.registrations_mode != 'none' | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   def omniauth_only? | ||||||
|  |     ENV['OMNIAUTH_ONLY'] == 'true' | ||||||
|  |   end | ||||||
| end | end | ||||||
|  | |||||||
							
								
								
									
										21
									
								
								app/controllers/api/v1/statuses/histories_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/controllers/api/v1/statuses/histories_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,21 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class Api::V1::Statuses::HistoriesController < Api::BaseController | ||||||
|  |   include Authorization | ||||||
|  | 
 | ||||||
|  |   before_action -> { authorize_if_got_token! :read, :'read:statuses' } | ||||||
|  |   before_action :set_status | ||||||
|  | 
 | ||||||
|  |   def show | ||||||
|  |     render json: @status.edits, each_serializer: REST::StatusEditSerializer | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   private | ||||||
|  | 
 | ||||||
|  |   def set_status | ||||||
|  |     @status = Status.find(params[:status_id]) | ||||||
|  |     authorize @status, :show? | ||||||
|  |   rescue Mastodon::NotPermittedError | ||||||
|  |     not_found | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										21
									
								
								app/controllers/api/v1/statuses/sources_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/controllers/api/v1/statuses/sources_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,21 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class Api::V1::Statuses::SourcesController < Api::BaseController | ||||||
|  |   include Authorization | ||||||
|  | 
 | ||||||
|  |   before_action -> { doorkeeper_authorize! :read, :'read:statuses' } | ||||||
|  |   before_action :set_status | ||||||
|  | 
 | ||||||
|  |   def show | ||||||
|  |     render json: @status, serializer: REST::StatusSourceSerializer | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   private | ||||||
|  | 
 | ||||||
|  |   def set_status | ||||||
|  |     @status = Status.find(params[:status_id]) | ||||||
|  |     authorize @status, :show? | ||||||
|  |   rescue Mastodon::NotPermittedError | ||||||
|  |     not_found | ||||||
|  |   end | ||||||
|  | end | ||||||
| @ -82,13 +82,17 @@ class Auth::RegistrationsController < Devise::RegistrationsController | |||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def check_enabled_registrations |   def check_enabled_registrations | ||||||
|     redirect_to root_path if single_user_mode? || !allowed_registrations? |     redirect_to root_path if single_user_mode? || omniauth_only? || !allowed_registrations? | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def allowed_registrations? |   def allowed_registrations? | ||||||
|     Setting.registrations_mode != 'none' || @invite&.valid_for_use? |     Setting.registrations_mode != 'none' || @invite&.valid_for_use? | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def omniauth_only? | ||||||
|  |     ENV['OMNIAUTH_ONLY'] == 'true' | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def invite_code |   def invite_code | ||||||
|     if params[:user] |     if params[:user] | ||||||
|       params[:user][:invite_code] |       params[:user][:invite_code] | ||||||
|  | |||||||
| @ -15,14 +15,6 @@ class Auth::SessionsController < Devise::SessionsController | |||||||
|   before_action :set_instance_presenter, only: [:new] |   before_action :set_instance_presenter, only: [:new] | ||||||
|   before_action :set_body_classes |   before_action :set_body_classes | ||||||
| 
 | 
 | ||||||
|   def new |  | ||||||
|     Devise.omniauth_configs.each do |provider, config| |  | ||||||
|       return redirect_to(omniauth_authorize_path(resource_name, provider)) if config.strategy.redirect_at_sign_in |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     super |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def create |   def create | ||||||
|     super do |resource| |     super do |resource| | ||||||
|       # We only need to call this if this hasn't already been |       # We only need to call this if this hasn't already been | ||||||
| @ -89,14 +81,6 @@ class Auth::SessionsController < Devise::SessionsController | |||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def after_sign_out_path_for(_resource_or_scope) |  | ||||||
|     Devise.omniauth_configs.each_value do |config| |  | ||||||
|       return root_path if config.strategy.redirect_at_sign_in |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     super |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def require_no_authentication |   def require_no_authentication | ||||||
|     super |     super | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -50,13 +50,37 @@ module ApplicationHelper | |||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def available_sign_up_path |   def available_sign_up_path | ||||||
|     if closed_registrations? |     if closed_registrations? || omniauth_only? | ||||||
|       'https://joinmastodon.org/#getting-started' |       'https://joinmastodon.org/#getting-started' | ||||||
|     else |     else | ||||||
|       new_user_registration_path |       new_user_registration_path | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def omniauth_only? | ||||||
|  |     ENV['OMNIAUTH_ONLY'] == 'true' | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def link_to_login(name = nil, html_options = nil, &block) | ||||||
|  |     target = new_user_session_path | ||||||
|  | 
 | ||||||
|  |     if omniauth_only? && Devise.mappings[:user].omniauthable? && User.omniauth_providers.size == 1 | ||||||
|  |       target = omniauth_authorize_path(:user, User.omniauth_providers[0]) | ||||||
|  |       html_options ||= {} | ||||||
|  |       html_options[:method] = :post | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     if block_given? | ||||||
|  |       link_to(target, html_options, &block) | ||||||
|  |     else | ||||||
|  |       link_to(name, target, html_options) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def provider_sign_in_link(provider) | ||||||
|  |     link_to I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize), omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def open_deletion? |   def open_deletion? | ||||||
|     Setting.open_deletion |     Setting.open_deletion | ||||||
|   end |   end | ||||||
|  | |||||||
| @ -34,7 +34,13 @@ module JsonLdHelper | |||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def as_array(value) |   def as_array(value) | ||||||
|     value.is_a?(Array) ? value : [value] |     if value.nil? | ||||||
|  |       [] | ||||||
|  |     elsif value.is_a?(Array) | ||||||
|  |       value | ||||||
|  |     else | ||||||
|  |       [value] | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def value_or_id(value) |   def value_or_id(value) | ||||||
|  | |||||||
| @ -54,9 +54,10 @@ export function normalizeStatus(status, normalOldStatus) { | |||||||
|     normalStatus.poll = status.poll.id; |     normalStatus.poll = status.poll.id; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   // Only calculate these values when status first encountered
 |   // Only calculate these values when status first encountered and
 | ||||||
|   // Otherwise keep the ones already in the reducer
 |   // when the underlying values change. Otherwise keep the ones
 | ||||||
|   if (normalOldStatus) { |   // already in the reducer
 | ||||||
|  |   if (normalOldStatus && normalOldStatus.get('content') === normalStatus.content && normalOldStatus.get('spoiler_text') === normalStatus.spoiler_text) { | ||||||
|     normalStatus.search_index = normalOldStatus.get('search_index'); |     normalStatus.search_index = normalOldStatus.get('search_index'); | ||||||
|     normalStatus.contentHtml = normalOldStatus.get('contentHtml'); |     normalStatus.contentHtml = normalOldStatus.get('contentHtml'); | ||||||
|     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); |     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); | ||||||
|  | |||||||
| @ -128,6 +128,9 @@ export function deleteStatusFail(id, error) { | |||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | export const updateStatus = status => dispatch => | ||||||
|  |   dispatch(importFetchedStatus(status)); | ||||||
|  | 
 | ||||||
| export function fetchContext(id) { | export function fetchContext(id) { | ||||||
|   return (dispatch, getState) => { |   return (dispatch, getState) => { | ||||||
|     dispatch(fetchContextRequest(id)); |     dispatch(fetchContextRequest(id)); | ||||||
|  | |||||||
| @ -10,6 +10,7 @@ import { | |||||||
| } from './timelines'; | } from './timelines'; | ||||||
| import { updateNotifications, expandNotifications } from './notifications'; | import { updateNotifications, expandNotifications } from './notifications'; | ||||||
| import { updateConversations } from './conversations'; | import { updateConversations } from './conversations'; | ||||||
|  | import { updateStatus } from './statuses'; | ||||||
| import { | import { | ||||||
|   fetchAnnouncements, |   fetchAnnouncements, | ||||||
|   updateAnnouncements, |   updateAnnouncements, | ||||||
| @ -75,6 +76,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti | |||||||
|         case 'update': |         case 'update': | ||||||
|           dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept)); |           dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept)); | ||||||
|           break; |           break; | ||||||
|  |         case 'status.update': | ||||||
|  |           dispatch(updateStatus(JSON.parse(data.payload))); | ||||||
|  |           break; | ||||||
|         case 'delete': |         case 'delete': | ||||||
|           dispatch(deleteFromTimelines(data.payload)); |           dispatch(deleteFromTimelines(data.payload)); | ||||||
|           break; |           break; | ||||||
|  | |||||||
| @ -88,7 +88,7 @@ export default class Retention extends React.PureComponent { | |||||||
|               </td> |               </td> | ||||||
| 
 | 
 | ||||||
|               {data[0].data.slice(1).map((retention, i) => { |               {data[0].data.slice(1).map((retention, i) => { | ||||||
|                 const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].percent - sum)/(k + 1) : sum, 0); |                 const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].rate - sum)/(k + 1) : sum, 0); | ||||||
| 
 | 
 | ||||||
|                 return ( |                 return ( | ||||||
|                   <td key={retention.date}> |                   <td key={retention.date}> | ||||||
| @ -118,8 +118,8 @@ export default class Retention extends React.PureComponent { | |||||||
| 
 | 
 | ||||||
|                 {cohort.data.slice(1).map(retention => ( |                 {cohort.data.slice(1).map(retention => ( | ||||||
|                   <td key={retention.date}> |                   <td key={retention.date}> | ||||||
|                     <div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.percent * 100)}`)}> |                     <div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.rate * 100)}`)}> | ||||||
|                       <FormattedNumber value={retention.percent} style='percent' /> |                       <FormattedNumber value={retention.rate} style='percent' /> | ||||||
|                     </div> |                     </div> | ||||||
|                   </td> |                   </td> | ||||||
|                 ))} |                 ))} | ||||||
|  | |||||||
| @ -38,6 +38,7 @@ const messages = defineMessages({ | |||||||
|   admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, |   admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, | ||||||
|   copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, |   copy: { id: 'status.copy', defaultMessage: 'Copy link to status' }, | ||||||
|   hide: { id: 'status.hide', defaultMessage: 'Hide toot' }, |   hide: { id: 'status.hide', defaultMessage: 'Hide toot' }, | ||||||
|  |   edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| export default @injectIntl | export default @injectIntl | ||||||
| @ -324,7 +325,9 @@ class StatusActionBar extends ImmutablePureComponent { | |||||||
|           </div>, |           </div>, | ||||||
|         ]} |         ]} | ||||||
| 
 | 
 | ||||||
|         <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> |         <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'> | ||||||
|  |           <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>} | ||||||
|  |         </a> | ||||||
|       </div> |       </div> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -58,6 +58,7 @@ class ComposeForm extends ImmutablePureComponent { | |||||||
|     onPickEmoji: PropTypes.func, |     onPickEmoji: PropTypes.func, | ||||||
|     showSearch: PropTypes.bool, |     showSearch: PropTypes.bool, | ||||||
|     anyMedia: PropTypes.bool, |     anyMedia: PropTypes.bool, | ||||||
|  |     isInReply: PropTypes.bool, | ||||||
|     singleColumn: PropTypes.bool, |     singleColumn: PropTypes.bool, | ||||||
| 
 | 
 | ||||||
|     advancedOptions: ImmutablePropTypes.map, |     advancedOptions: ImmutablePropTypes.map, | ||||||
| @ -233,7 +234,7 @@ class ComposeForm extends ImmutablePureComponent { | |||||||
|     //  Caret/selection handling.
 |     //  Caret/selection handling.
 | ||||||
|     if (focusDate !== prevProps.focusDate) { |     if (focusDate !== prevProps.focusDate) { | ||||||
|       switch (true) { |       switch (true) { | ||||||
|       case preselectDate !== prevProps.preselectDate && preselectOnReply: |       case preselectDate !== prevProps.preselectDate && this.props.isInReply && preselectOnReply: | ||||||
|         selectionStart = text.search(/\s/) + 1; |         selectionStart = text.search(/\s/) + 1; | ||||||
|         selectionEnd = text.length; |         selectionEnd = text.length; | ||||||
|         break; |         break; | ||||||
|  | |||||||
| @ -68,6 +68,7 @@ function mapStateToProps (state) { | |||||||
|     spoilersAlwaysOn: spoilersAlwaysOn, |     spoilersAlwaysOn: spoilersAlwaysOn, | ||||||
|     mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']), |     mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']), | ||||||
|     preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']), |     preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']), | ||||||
|  |     isInReply: state.getIn(['compose', 'in_reply_to']) !== null, | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -7,7 +7,7 @@ import StatusContent from 'flavours/glitch/components/status_content'; | |||||||
| import MediaGallery from 'flavours/glitch/components/media_gallery'; | import MediaGallery from 'flavours/glitch/components/media_gallery'; | ||||||
| import AttachmentList from 'flavours/glitch/components/attachment_list'; | import AttachmentList from 'flavours/glitch/components/attachment_list'; | ||||||
| import { Link } from 'react-router-dom'; | import { Link } from 'react-router-dom'; | ||||||
| import { FormattedDate } from 'react-intl'; | import { injectIntl, FormattedDate, FormattedMessage } from 'react-intl'; | ||||||
| import Card from './card'; | import Card from './card'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import Video from 'flavours/glitch/features/video'; | import Video from 'flavours/glitch/features/video'; | ||||||
| @ -20,7 +20,8 @@ import Icon from 'flavours/glitch/components/icon'; | |||||||
| import AnimatedNumber from 'flavours/glitch/components/animated_number'; | import AnimatedNumber from 'flavours/glitch/components/animated_number'; | ||||||
| import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; | import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; | ||||||
| 
 | 
 | ||||||
| export default class DetailedStatus extends ImmutablePureComponent { | export default @injectIntl | ||||||
|  | class DetailedStatus extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|   static contextTypes = { |   static contextTypes = { | ||||||
|     router: PropTypes.object, |     router: PropTypes.object, | ||||||
| @ -40,6 +41,7 @@ export default class DetailedStatus extends ImmutablePureComponent { | |||||||
|     showMedia: PropTypes.bool, |     showMedia: PropTypes.bool, | ||||||
|     usingPiP: PropTypes.bool, |     usingPiP: PropTypes.bool, | ||||||
|     onToggleMediaVisibility: PropTypes.func, |     onToggleMediaVisibility: PropTypes.func, | ||||||
|  |     intl: PropTypes.object.isRequired, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   state = { |   state = { | ||||||
| @ -111,7 +113,7 @@ export default class DetailedStatus extends ImmutablePureComponent { | |||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; |     const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; | ||||||
|     const { expanded, onToggleHidden, settings, usingPiP } = this.props; |     const { expanded, onToggleHidden, settings, usingPiP, intl } = this.props; | ||||||
|     const outerStyle = { boxSizing: 'border-box' }; |     const outerStyle = { boxSizing: 'border-box' }; | ||||||
|     const { compact } = this.props; |     const { compact } = this.props; | ||||||
| 
 | 
 | ||||||
| @ -125,6 +127,7 @@ export default class DetailedStatus extends ImmutablePureComponent { | |||||||
|     let reblogLink = ''; |     let reblogLink = ''; | ||||||
|     let reblogIcon = 'retweet'; |     let reblogIcon = 'retweet'; | ||||||
|     let favouriteLink = ''; |     let favouriteLink = ''; | ||||||
|  |     let edited = ''; | ||||||
| 
 | 
 | ||||||
|     if (this.props.measureHeight) { |     if (this.props.measureHeight) { | ||||||
|       outerStyle.height = `${this.state.height}px`; |       outerStyle.height = `${this.state.height}px`; | ||||||
| @ -258,6 +261,15 @@ export default class DetailedStatus extends ImmutablePureComponent { | |||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     if (status.get('edited_at')) { | ||||||
|  |       edited = ( | ||||||
|  |         <React.Fragment> | ||||||
|  |           <React.Fragment> · </React.Fragment> | ||||||
|  |           <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(status.get('edited_at'), { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} /> | ||||||
|  |         </React.Fragment> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     return ( |     return ( | ||||||
|       <div style={outerStyle}> |       <div style={outerStyle}> | ||||||
|         <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}> |         <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}> | ||||||
| @ -283,7 +295,7 @@ export default class DetailedStatus extends ImmutablePureComponent { | |||||||
|           <div className='detailed-status__meta'> |           <div className='detailed-status__meta'> | ||||||
|             <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'> |             <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'> | ||||||
|               <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> |               <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> | ||||||
|             </a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} |             </a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|  | |||||||
| @ -205,6 +205,17 @@ | |||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .status__content__edited-label { | ||||||
|  |   display: block; | ||||||
|  |   cursor: default; | ||||||
|  |   font-size: 15px; | ||||||
|  |   line-height: 20px; | ||||||
|  |   padding: 0; | ||||||
|  |   padding-top: 8px; | ||||||
|  |   color: $dark-text-color; | ||||||
|  |   font-weight: 500; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .status__content__spoiler-link { | .status__content__spoiler-link { | ||||||
|   display: inline-block; |   display: inline-block; | ||||||
|   border-radius: 2px; |   border-radius: 2px; | ||||||
|  | |||||||
| @ -54,9 +54,10 @@ export function normalizeStatus(status, normalOldStatus) { | |||||||
|     normalStatus.poll = status.poll.id; |     normalStatus.poll = status.poll.id; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   // Only calculate these values when status first encountered
 |   // Only calculate these values when status first encountered and
 | ||||||
|   // Otherwise keep the ones already in the reducer
 |   // when the underlying values change. Otherwise keep the ones
 | ||||||
|   if (normalOldStatus) { |   // already in the reducer
 | ||||||
|  |   if (normalOldStatus && normalOldStatus.get('content') === normalStatus.content && normalOldStatus.get('spoiler_text') === normalStatus.spoiler_text) { | ||||||
|     normalStatus.search_index = normalOldStatus.get('search_index'); |     normalStatus.search_index = normalOldStatus.get('search_index'); | ||||||
|     normalStatus.contentHtml = normalOldStatus.get('contentHtml'); |     normalStatus.contentHtml = normalOldStatus.get('contentHtml'); | ||||||
|     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); |     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); | ||||||
|  | |||||||
| @ -131,6 +131,9 @@ export function deleteStatusFail(id, error) { | |||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | export const updateStatus = status => dispatch => | ||||||
|  |   dispatch(importFetchedStatus(status)); | ||||||
|  | 
 | ||||||
| export function fetchContext(id) { | export function fetchContext(id) { | ||||||
|   return (dispatch, getState) => { |   return (dispatch, getState) => { | ||||||
|     dispatch(fetchContextRequest(id)); |     dispatch(fetchContextRequest(id)); | ||||||
|  | |||||||
| @ -10,6 +10,7 @@ import { | |||||||
| } from './timelines'; | } from './timelines'; | ||||||
| import { updateNotifications, expandNotifications } from './notifications'; | import { updateNotifications, expandNotifications } from './notifications'; | ||||||
| import { updateConversations } from './conversations'; | import { updateConversations } from './conversations'; | ||||||
|  | import { updateStatus } from './statuses'; | ||||||
| import { | import { | ||||||
|   fetchAnnouncements, |   fetchAnnouncements, | ||||||
|   updateAnnouncements, |   updateAnnouncements, | ||||||
| @ -75,6 +76,9 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti | |||||||
|         case 'update': |         case 'update': | ||||||
|           dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept)); |           dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept)); | ||||||
|           break; |           break; | ||||||
|  |         case 'status.update': | ||||||
|  |           dispatch(updateStatus(JSON.parse(data.payload))); | ||||||
|  |           break; | ||||||
|         case 'delete': |         case 'delete': | ||||||
|           dispatch(deleteFromTimelines(data.payload)); |           dispatch(deleteFromTimelines(data.payload)); | ||||||
|           break; |           break; | ||||||
|  | |||||||
| @ -88,7 +88,7 @@ export default class Retention extends React.PureComponent { | |||||||
|               </td> |               </td> | ||||||
| 
 | 
 | ||||||
|               {data[0].data.slice(1).map((retention, i) => { |               {data[0].data.slice(1).map((retention, i) => { | ||||||
|                 const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].percent - sum)/(k + 1) : sum, 0); |                 const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].rate - sum)/(k + 1) : sum, 0); | ||||||
| 
 | 
 | ||||||
|                 return ( |                 return ( | ||||||
|                   <td key={retention.date}> |                   <td key={retention.date}> | ||||||
| @ -118,8 +118,8 @@ export default class Retention extends React.PureComponent { | |||||||
| 
 | 
 | ||||||
|                 {cohort.data.slice(1).map(retention => ( |                 {cohort.data.slice(1).map(retention => ( | ||||||
|                   <td key={retention.date}> |                   <td key={retention.date}> | ||||||
|                     <div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.percent * 100)}`)}> |                     <div className={classNames('retention__table__box', `retention__table__box--${roundTo10(retention.rate * 100)}`)}> | ||||||
|                       <FormattedNumber value={retention.percent} style='percent' /> |                       <FormattedNumber value={retention.rate} style='percent' /> | ||||||
|                     </div> |                     </div> | ||||||
|                   </td> |                   </td> | ||||||
|                 ))} |                 ))} | ||||||
|  | |||||||
| @ -57,6 +57,7 @@ const messages = defineMessages({ | |||||||
|   unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, |   unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, | ||||||
|   private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, |   private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, | ||||||
|   direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, |   direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, | ||||||
|  |   edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| export default @injectIntl | export default @injectIntl | ||||||
| @ -483,7 +484,7 @@ class Status extends ImmutablePureComponent { | |||||||
|             <div className='status__info'> |             <div className='status__info'> | ||||||
|               <a onClick={this.handleClick} href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'> |               <a onClick={this.handleClick} href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'> | ||||||
|                 <span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span> |                 <span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span> | ||||||
|                 <RelativeTimestamp timestamp={status.get('created_at')} /> |                 <RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>} | ||||||
|               </a> |               </a> | ||||||
| 
 | 
 | ||||||
|               <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'> |               <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'> | ||||||
|  | |||||||
| @ -61,6 +61,7 @@ class ComposeForm extends ImmutablePureComponent { | |||||||
|     onPickEmoji: PropTypes.func.isRequired, |     onPickEmoji: PropTypes.func.isRequired, | ||||||
|     showSearch: PropTypes.bool, |     showSearch: PropTypes.bool, | ||||||
|     anyMedia: PropTypes.bool, |     anyMedia: PropTypes.bool, | ||||||
|  |     isInReply: PropTypes.bool, | ||||||
|     singleColumn: PropTypes.bool, |     singleColumn: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
| @ -150,7 +151,7 @@ class ComposeForm extends ImmutablePureComponent { | |||||||
|     if (this.props.focusDate !== prevProps.focusDate) { |     if (this.props.focusDate !== prevProps.focusDate) { | ||||||
|       let selectionEnd, selectionStart; |       let selectionEnd, selectionStart; | ||||||
| 
 | 
 | ||||||
|       if (this.props.preselectDate !== prevProps.preselectDate) { |       if (this.props.preselectDate !== prevProps.preselectDate && this.props.isInReply) { | ||||||
|         selectionEnd   = this.props.text.length; |         selectionEnd   = this.props.text.length; | ||||||
|         selectionStart = this.props.text.search(/\s/) + 1; |         selectionStart = this.props.text.search(/\s/) + 1; | ||||||
|       } else if (typeof this.props.caretPosition === 'number') { |       } else if (typeof this.props.caretPosition === 'number') { | ||||||
|  | |||||||
| @ -25,6 +25,7 @@ const mapStateToProps = state => ({ | |||||||
|   isUploading: state.getIn(['compose', 'is_uploading']), |   isUploading: state.getIn(['compose', 'is_uploading']), | ||||||
|   showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), |   showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), | ||||||
|   anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, |   anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, | ||||||
|  |   isInReply: state.getIn(['compose', 'in_reply_to']) !== null, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const mapDispatchToProps = (dispatch) => ({ | const mapDispatchToProps = (dispatch) => ({ | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ import DisplayName from '../../../components/display_name'; | |||||||
| import StatusContent from '../../../components/status_content'; | import StatusContent from '../../../components/status_content'; | ||||||
| import MediaGallery from '../../../components/media_gallery'; | import MediaGallery from '../../../components/media_gallery'; | ||||||
| import { Link } from 'react-router-dom'; | import { Link } from 'react-router-dom'; | ||||||
| import { injectIntl, defineMessages, FormattedDate } from 'react-intl'; | import { injectIntl, defineMessages, FormattedDate, FormattedMessage } from 'react-intl'; | ||||||
| import Card from './card'; | import Card from './card'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import Video from '../../video'; | import Video from '../../video'; | ||||||
| @ -116,6 +116,7 @@ class DetailedStatus extends ImmutablePureComponent { | |||||||
|     let reblogLink = ''; |     let reblogLink = ''; | ||||||
|     let reblogIcon = 'retweet'; |     let reblogIcon = 'retweet'; | ||||||
|     let favouriteLink = ''; |     let favouriteLink = ''; | ||||||
|  |     let edited = ''; | ||||||
| 
 | 
 | ||||||
|     if (this.props.measureHeight) { |     if (this.props.measureHeight) { | ||||||
|       outerStyle.height = `${this.state.height}px`; |       outerStyle.height = `${this.state.height}px`; | ||||||
| @ -237,6 +238,15 @@ class DetailedStatus extends ImmutablePureComponent { | |||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     if (status.get('edited_at')) { | ||||||
|  |       edited = ( | ||||||
|  |         <React.Fragment> | ||||||
|  |           <React.Fragment> · </React.Fragment> | ||||||
|  |           <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(status.get('edited_at'), { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} /> | ||||||
|  |         </React.Fragment> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     return ( |     return ( | ||||||
|       <div style={outerStyle}> |       <div style={outerStyle}> | ||||||
|         <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}> |         <div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}> | ||||||
| @ -252,7 +262,7 @@ class DetailedStatus extends ImmutablePureComponent { | |||||||
|           <div className='detailed-status__meta'> |           <div className='detailed-status__meta'> | ||||||
|             <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'> |             <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'> | ||||||
|               <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> |               <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> | ||||||
|             </a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} |             </a>{edited}{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink} | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|  | |||||||
| @ -967,6 +967,17 @@ | |||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .status__content__edited-label { | ||||||
|  |   display: block; | ||||||
|  |   cursor: default; | ||||||
|  |   font-size: 15px; | ||||||
|  |   line-height: 20px; | ||||||
|  |   padding: 0; | ||||||
|  |   padding-top: 8px; | ||||||
|  |   color: $dark-text-color; | ||||||
|  |   font-weight: 500; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .status__content__spoiler-link { | .status__content__spoiler-link { | ||||||
|   display: inline-block; |   display: inline-block; | ||||||
|   border-radius: 2px; |   border-radius: 2px; | ||||||
|  | |||||||
| @ -94,49 +94,6 @@ class ActivityPub::Activity | |||||||
|     equals_or_includes_any?(@object['type'], CONVERTED_TYPES) |     equals_or_includes_any?(@object['type'], CONVERTED_TYPES) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def distribute(status) |  | ||||||
|     crawl_links(status) |  | ||||||
| 
 |  | ||||||
|     notify_about_reblog(status) if reblog_of_local_account?(status) && !reblog_by_following_group_account?(status) |  | ||||||
|     notify_about_mentions(status) |  | ||||||
| 
 |  | ||||||
|     # Only continue if the status is supposed to have arrived in real-time. |  | ||||||
|     # Note that if @options[:override_timestamps] isn't set, the status |  | ||||||
|     # may have a lower snowflake id than other existing statuses, potentially |  | ||||||
|     # "hiding" it from paginated API calls |  | ||||||
|     return unless @options[:override_timestamps] || status.within_realtime_window? |  | ||||||
| 
 |  | ||||||
|     distribute_to_followers(status) |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def reblog_of_local_account?(status) |  | ||||||
|     status.reblog? && status.reblog.account.local? |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def reblog_by_following_group_account?(status) |  | ||||||
|     status.reblog? && status.account.group? && status.reblog.account.following?(status.account) |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def notify_about_reblog(status) |  | ||||||
|     NotifyService.new.call(status.reblog.account, :reblog, status) |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def notify_about_mentions(status) |  | ||||||
|     status.active_mentions.includes(:account).each do |mention| |  | ||||||
|       next unless mention.account.local? && audience_includes?(mention.account) |  | ||||||
|       NotifyService.new.call(mention.account, :mention, mention) |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def crawl_links(status) |  | ||||||
|     # Spread out crawling randomly to avoid DDoSing the link |  | ||||||
|     LinkCrawlWorker.perform_in(rand(1..59).seconds, status.id) |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def distribute_to_followers(status) |  | ||||||
|     ::DistributionWorker.perform_async(status.id) |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def delete_arrived_first?(uri) |   def delete_arrived_first?(uri) | ||||||
|     redis.exists?("delete_upon_arrival:#{@account.id}:#{uri}") |     redis.exists?("delete_upon_arrival:#{@account.id}:#{uri}") | ||||||
|   end |   end | ||||||
|  | |||||||
| @ -25,7 +25,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity | |||||||
|       Trends.tags.register(@status) |       Trends.tags.register(@status) | ||||||
|       Trends.links.register(@status) |       Trends.links.register(@status) | ||||||
| 
 | 
 | ||||||
|       distribute(@status) |       distribute | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     @status |     @status | ||||||
| @ -33,6 +33,22 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity | |||||||
| 
 | 
 | ||||||
|   private |   private | ||||||
| 
 | 
 | ||||||
|  |   def distribute | ||||||
|  |     # Notify the author of the original status if that status is local | ||||||
|  |     NotifyService.new.call(@status.reblog.account, :reblog, @status) if reblog_of_local_account?(@status) && !reblog_by_following_group_account?(@status) | ||||||
|  | 
 | ||||||
|  |     # Distribute into home and list feeds | ||||||
|  |     ::DistributionWorker.perform_async(@status.id) if @options[:override_timestamps] || @status.within_realtime_window? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def reblog_of_local_account?(status) | ||||||
|  |     status.reblog? && status.reblog.account.local? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def reblog_by_following_group_account?(status) | ||||||
|  |     status.reblog? && status.account.group? && status.reblog.account.following?(status.account) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def audience_to |   def audience_to | ||||||
|     as_array(@json['to']).map { |x| value_or_id(x) } |     as_array(@json['to']).map { |x| value_or_id(x) } | ||||||
|   end |   end | ||||||
|  | |||||||
| @ -69,9 +69,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity | |||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def process_status |   def process_status | ||||||
|     @tags     = [] |     @tags                 = [] | ||||||
|     @mentions = [] |     @mentions             = [] | ||||||
|     @params   = {} |     @silenced_account_ids = [] | ||||||
|  |     @params               = {} | ||||||
| 
 | 
 | ||||||
|     process_status_params |     process_status_params | ||||||
|     process_tags |     process_tags | ||||||
| @ -84,10 +85,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity | |||||||
| 
 | 
 | ||||||
|     resolve_thread(@status) |     resolve_thread(@status) | ||||||
|     fetch_replies(@status) |     fetch_replies(@status) | ||||||
|     distribute(@status) |     distribute | ||||||
|     forward_for_reply |     forward_for_reply | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def distribute | ||||||
|  |     # Spread out crawling randomly to avoid DDoSing the link | ||||||
|  |     LinkCrawlWorker.perform_in(rand(1..59).seconds, @status.id) | ||||||
|  | 
 | ||||||
|  |     # Distribute into home and list feeds and notify mentioned accounts | ||||||
|  |     ::DistributionWorker.perform_async(@status.id, silenced_account_ids: @silenced_account_ids) if @options[:override_timestamps] || @status.within_realtime_window? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def find_existing_status |   def find_existing_status | ||||||
|     status   = status_from_uri(object_uri) |     status   = status_from_uri(object_uri) | ||||||
|     status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present? |     status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present? | ||||||
| @ -95,19 +104,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity | |||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def process_status_params |   def process_status_params | ||||||
|  |     @status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url) | ||||||
|  | 
 | ||||||
|     @params = begin |     @params = begin | ||||||
|       { |       { | ||||||
|         uri: object_uri, |         uri: @status_parser.uri, | ||||||
|         url: object_url || object_uri, |         url: @status_parser.url || @status_parser.uri, | ||||||
|         account: @account, |         account: @account, | ||||||
|         text: text_from_content || '', |         text: converted_object_type? ? converted_text : (@status_parser.text || ''), | ||||||
|         language: detected_language, |         language: @status_parser.language || detected_language, | ||||||
|         spoiler_text: converted_object_type? ? '' : (text_from_summary || ''), |         spoiler_text: converted_object_type? ? '' : (@status_parser.spoiler_text || ''), | ||||||
|         created_at: @object['published'], |         created_at: @status_parser.created_at, | ||||||
|  |         edited_at: @status_parser.edited_at, | ||||||
|         override_timestamps: @options[:override_timestamps], |         override_timestamps: @options[:override_timestamps], | ||||||
|         reply: @object['inReplyTo'].present?, |         reply: @status_parser.reply, | ||||||
|         sensitive: @account.sensitized? || @object['sensitive'] || false, |         sensitive: @account.sensitized? || @status_parser.sensitive || false, | ||||||
|         visibility: visibility_from_audience, |         visibility: @status_parser.visibility, | ||||||
|         thread: replied_to_status, |         thread: replied_to_status, | ||||||
|         conversation: conversation_from_uri(@object['conversation']), |         conversation: conversation_from_uri(@object['conversation']), | ||||||
|         media_attachment_ids: process_attachments.take(4).map(&:id), |         media_attachment_ids: process_attachments.take(4).map(&:id), | ||||||
| @ -117,50 +129,52 @@ class ActivityPub::Activity::Create < ActivityPub::Activity | |||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def process_audience |   def process_audience | ||||||
|     (audience_to + audience_cc).uniq.each do |audience| |     # Unlike with tags, there is no point in resolving accounts we don't already | ||||||
|       next if ActivityPub::TagManager.instance.public_collection?(audience) |     # know here, because silent mentions would only be used for local access control anyway | ||||||
|  |     accounts_in_audience = (audience_to + audience_cc).uniq.filter_map do |audience| | ||||||
|  |       account_from_uri(audience) unless ActivityPub::TagManager.instance.public_collection?(audience) | ||||||
|  |     end | ||||||
| 
 | 
 | ||||||
|       # Unlike with tags, there is no point in resolving accounts we don't already |     # If the payload was delivered to a specific inbox, the inbox owner must have | ||||||
|       # know here, because silent mentions would only be used for local access |     # access to it, unless they already have access to it anyway | ||||||
|       # control anyway |     if @options[:delivered_to_account_id] | ||||||
|       account = account_from_uri(audience) |       accounts_in_audience << delivered_to_account | ||||||
|  |       accounts_in_audience.uniq! | ||||||
|  |     end | ||||||
| 
 | 
 | ||||||
|       next if account.nil? || @mentions.any? { |mention| mention.account_id == account.id } |     accounts_in_audience.each do |account| | ||||||
|  |       # This runs after tags are processed, and those translate into non-silent | ||||||
|  |       # mentions, which take precedence | ||||||
|  |       next if @mentions.any? { |mention| mention.account_id == account.id } | ||||||
| 
 | 
 | ||||||
|       @mentions << Mention.new(account: account, silent: true) |       @mentions << Mention.new(account: account, silent: true) | ||||||
| 
 | 
 | ||||||
|       # If there is at least one silent mention, then the status can be considered |       # If there is at least one silent mention, then the status can be considered | ||||||
|       # as a limited-audience status, and not strictly a direct message, but only |       # as a limited-audience status, and not strictly a direct message, but only | ||||||
|       # if we considered a direct message in the first place |       # if we considered a direct message in the first place | ||||||
|       next unless @params[:visibility] == :direct && direct_message.nil? |       @params[:visibility] = :limited if @params[:visibility] == :direct && !@object['directMessage'] | ||||||
| 
 |  | ||||||
|       @params[:visibility] = :limited |  | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     # If the payload was delivered to a specific inbox, the inbox owner must have |     # Accounts that are tagged but are not in the audience are not | ||||||
|     # access to it, unless they already have access to it anyway |     # supposed to be notified explicitly | ||||||
|     return if @options[:delivered_to_account_id].nil? || @mentions.any? { |mention| mention.account_id == @options[:delivered_to_account_id] } |     @silenced_account_ids = @mentions.map(&:account_id) - accounts_in_audience.map(&:id) | ||||||
| 
 |  | ||||||
|     @mentions << Mention.new(account_id: @options[:delivered_to_account_id], silent: true) |  | ||||||
| 
 |  | ||||||
|     return unless @params[:visibility] == :direct && direct_message.nil? |  | ||||||
| 
 |  | ||||||
|     @params[:visibility] = :limited |  | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def postprocess_audience_and_deliver |   def postprocess_audience_and_deliver | ||||||
|     return if @status.mentions.find_by(account_id: @options[:delivered_to_account_id]) |     return if @status.mentions.find_by(account_id: @options[:delivered_to_account_id]) | ||||||
| 
 | 
 | ||||||
|     delivered_to_account = Account.find(@options[:delivered_to_account_id]) |  | ||||||
| 
 |  | ||||||
|     @status.mentions.create(account: delivered_to_account, silent: true) |     @status.mentions.create(account: delivered_to_account, silent: true) | ||||||
|     @status.update(visibility: :limited) if @status.direct_visibility? && direct_message.nil? |     @status.update(visibility: :limited) if @status.direct_visibility? && !@object['directMessage'] | ||||||
| 
 | 
 | ||||||
|     return unless delivered_to_account.following?(@account) |     return unless delivered_to_account.following?(@account) | ||||||
| 
 | 
 | ||||||
|     FeedInsertWorker.perform_async(@status.id, delivered_to_account.id, :home) |     FeedInsertWorker.perform_async(@status.id, delivered_to_account.id, :home) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def delivered_to_account | ||||||
|  |     @delivered_to_account ||= Account.find(@options[:delivered_to_account_id]) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def attach_tags(status) |   def attach_tags(status) | ||||||
|     @tags.each do |tag| |     @tags.each do |tag| | ||||||
|       status.tags << tag |       status.tags << tag | ||||||
| @ -215,21 +229,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity | |||||||
| 
 | 
 | ||||||
|   def process_emoji(tag) |   def process_emoji(tag) | ||||||
|     return if skip_download? |     return if skip_download? | ||||||
|     return if tag['name'].blank? || tag['icon'].blank? || tag['icon']['url'].blank? |  | ||||||
| 
 | 
 | ||||||
|     shortcode = tag['name'].delete(':') |     custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(tag) | ||||||
|     image_url = tag['icon']['url'] |  | ||||||
|     uri       = tag['id'] |  | ||||||
|     updated   = tag['updated'] |  | ||||||
|     emoji     = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain) |  | ||||||
| 
 | 
 | ||||||
|     return unless emoji.nil? || image_url != emoji.image_remote_url || (updated && updated >= emoji.updated_at) |     return if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank? | ||||||
| 
 | 
 | ||||||
|     emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri) |     emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain) | ||||||
|     emoji.image_remote_url = image_url | 
 | ||||||
|     emoji.save |     return unless emoji.nil? || custom_emoji_parser.image_remote_url != emoji.image_remote_url || (custom_emoji_parser.updated_at && custom_emoji_parser.updated_at >= emoji.updated_at) | ||||||
|   rescue Seahorse::Client::NetworkingError => e | 
 | ||||||
|     Rails.logger.warn "Error storing emoji: #{e}" |     begin | ||||||
|  |       emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: custom_emoji_parser.shortcode, uri: custom_emoji_parser.uri) | ||||||
|  |       emoji.image_remote_url = custom_emoji_parser.image_remote_url | ||||||
|  |       emoji.save | ||||||
|  |     rescue Seahorse::Client::NetworkingError => e | ||||||
|  |       Rails.logger.warn "Error storing emoji: #{e}" | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def process_attachments |   def process_attachments | ||||||
| @ -238,14 +253,23 @@ class ActivityPub::Activity::Create < ActivityPub::Activity | |||||||
|     media_attachments = [] |     media_attachments = [] | ||||||
| 
 | 
 | ||||||
|     as_array(@object['attachment']).each do |attachment| |     as_array(@object['attachment']).each do |attachment| | ||||||
|       next if attachment['url'].blank? || media_attachments.size >= 4 |       media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment) | ||||||
|  | 
 | ||||||
|  |       next if media_attachment_parser.remote_url.blank? || media_attachments.size >= 4 | ||||||
| 
 | 
 | ||||||
|       begin |       begin | ||||||
|         href             = Addressable::URI.parse(attachment['url']).normalize.to_s |         media_attachment = MediaAttachment.create( | ||||||
|         media_attachment = MediaAttachment.create(account: @account, remote_url: href, thumbnail_remote_url: icon_url_from_attachment(attachment), description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil) |           account: @account, | ||||||
|  |           remote_url: media_attachment_parser.remote_url, | ||||||
|  |           thumbnail_remote_url: media_attachment_parser.thumbnail_remote_url, | ||||||
|  |           description: media_attachment_parser.description, | ||||||
|  |           focus: media_attachment_parser.focus, | ||||||
|  |           blurhash: media_attachment_parser.blurhash | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|         media_attachments << media_attachment |         media_attachments << media_attachment | ||||||
| 
 | 
 | ||||||
|         next if unsupported_media_type?(attachment['mediaType']) || skip_download? |         next if unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download? | ||||||
| 
 | 
 | ||||||
|         media_attachment.download_file! |         media_attachment.download_file! | ||||||
|         media_attachment.download_thumbnail! |         media_attachment.download_thumbnail! | ||||||
| @ -263,42 +287,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity | |||||||
|     media_attachments |     media_attachments | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def icon_url_from_attachment(attachment) |  | ||||||
|     url = attachment['icon'].is_a?(Hash) ? attachment['icon']['url'] : attachment['icon'] |  | ||||||
|     Addressable::URI.parse(url).normalize.to_s if url.present? |  | ||||||
|   rescue Addressable::URI::InvalidURIError |  | ||||||
|     nil |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def process_poll |   def process_poll | ||||||
|     return unless @object['type'] == 'Question' && (@object['anyOf'].is_a?(Array) || @object['oneOf'].is_a?(Array)) |     poll_parser = ActivityPub::Parser::PollParser.new(@object) | ||||||
| 
 | 
 | ||||||
|     expires_at = begin |     return unless poll_parser.valid? | ||||||
|       if @object['closed'].is_a?(String) |  | ||||||
|         @object['closed'] |  | ||||||
|       elsif !@object['closed'].nil? && !@object['closed'].is_a?(FalseClass) |  | ||||||
|         Time.now.utc |  | ||||||
|       else |  | ||||||
|         @object['endTime'] |  | ||||||
|       end |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     if @object['anyOf'].is_a?(Array) |  | ||||||
|       multiple = true |  | ||||||
|       items    = @object['anyOf'] |  | ||||||
|     else |  | ||||||
|       multiple = false |  | ||||||
|       items    = @object['oneOf'] |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     voters_count = @object['votersCount'] |  | ||||||
| 
 | 
 | ||||||
|     @account.polls.new( |     @account.polls.new( | ||||||
|       multiple: multiple, |       multiple: poll_parser.multiple, | ||||||
|       expires_at: expires_at, |       expires_at: poll_parser.expires_at, | ||||||
|       options: items.map { |item| item['name'].presence || item['content'] }.compact, |       options: poll_parser.options, | ||||||
|       cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }, |       cached_tallies: poll_parser.cached_tallies, | ||||||
|       voters_count: voters_count |       voters_count: poll_parser.voters_count | ||||||
|     ) |     ) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
| @ -351,29 +350,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity | |||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def visibility_from_audience |  | ||||||
|     if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) } |  | ||||||
|       :public |  | ||||||
|     elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) } |  | ||||||
|       :unlisted |  | ||||||
|     elsif audience_to.include?(@account.followers_url) |  | ||||||
|       :private |  | ||||||
|     elsif direct_message == false |  | ||||||
|       :limited |  | ||||||
|     else |  | ||||||
|       :direct |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def audience_includes?(account) |  | ||||||
|     uri = ActivityPub::TagManager.instance.uri_for(account) |  | ||||||
|     audience_to.include?(uri) || audience_cc.include?(uri) |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def direct_message |  | ||||||
|     @object['directMessage'] |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def replied_to_status |   def replied_to_status | ||||||
|     return @replied_to_status if defined?(@replied_to_status) |     return @replied_to_status if defined?(@replied_to_status) | ||||||
| 
 | 
 | ||||||
| @ -390,81 +366,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity | |||||||
|     value_or_id(@object['inReplyTo']) |     value_or_id(@object['inReplyTo']) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def text_from_content |   def converted_text | ||||||
|     return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type? |     Formatter.instance.linkify([@status_parser.title.presence, @status_parser.spoiler_text.presence, @status_parser.url || @status_parser.uri].compact.join("\n\n")) | ||||||
| 
 |  | ||||||
|     if @object['content'].present? |  | ||||||
|       @object['content'] |  | ||||||
|     elsif content_language_map? |  | ||||||
|       @object['contentMap'].values.first |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def text_from_summary |  | ||||||
|     if @object['summary'].present? |  | ||||||
|       @object['summary'] |  | ||||||
|     elsif summary_language_map? |  | ||||||
|       @object['summaryMap'].values.first |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def text_from_name |  | ||||||
|     if @object['name'].present? |  | ||||||
|       @object['name'] |  | ||||||
|     elsif name_language_map? |  | ||||||
|       @object['nameMap'].values.first |  | ||||||
|     end |  | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def detected_language |   def detected_language | ||||||
|     if content_language_map? |     LanguageDetector.instance.detect(@status_parser.text, @account) if supported_object_type? | ||||||
|       @object['contentMap'].keys.first |  | ||||||
|     elsif name_language_map? |  | ||||||
|       @object['nameMap'].keys.first |  | ||||||
|     elsif summary_language_map? |  | ||||||
|       @object['summaryMap'].keys.first |  | ||||||
|     elsif supported_object_type? |  | ||||||
|       LanguageDetector.instance.detect(text_from_content, @account) |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def object_url |  | ||||||
|     return if @object['url'].blank? |  | ||||||
| 
 |  | ||||||
|     url_candidate = url_to_href(@object['url'], 'text/html') |  | ||||||
| 
 |  | ||||||
|     if invalid_origin?(url_candidate) |  | ||||||
|       nil |  | ||||||
|     else |  | ||||||
|       url_candidate |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def summary_language_map? |  | ||||||
|     @object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty? |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def content_language_map? |  | ||||||
|     @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty? |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def name_language_map? |  | ||||||
|     @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty? |  | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def unsupported_media_type?(mime_type) |   def unsupported_media_type?(mime_type) | ||||||
|     mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type) |     mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def supported_blurhash?(blurhash) |  | ||||||
|     components = blurhash.blank? || !blurhash_valid_chars?(blurhash) ? nil : Blurhash.components(blurhash) |  | ||||||
|     components.present? && components.none? { |comp| comp > 5 } |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def blurhash_valid_chars?(blurhash) |  | ||||||
|     /^[\w#$%*+-.:;=?@\[\]^{|}~]+$/.match?(blurhash) |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def skip_download? |   def skip_download? | ||||||
|     return @skip_download if defined?(@skip_download) |     return @skip_download if defined?(@skip_download) | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,32 +1,31 @@ | |||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class ActivityPub::Activity::Update < ActivityPub::Activity | class ActivityPub::Activity::Update < ActivityPub::Activity | ||||||
|   SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze |  | ||||||
| 
 |  | ||||||
|   def perform |   def perform | ||||||
|     dereference_object! |     dereference_object! | ||||||
| 
 | 
 | ||||||
|     if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES) |     if equals_or_includes_any?(@object['type'], %w(Application Group Organization Person Service)) | ||||||
|       update_account |       update_account | ||||||
|     elsif equals_or_includes_any?(@object['type'], %w(Question)) |     elsif equals_or_includes_any?(@object['type'], %w(Note Question)) | ||||||
|       update_poll |       update_status | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   private |   private | ||||||
| 
 | 
 | ||||||
|   def update_account |   def update_account | ||||||
|     return if @account.uri != object_uri |     return reject_payload! if @account.uri != object_uri | ||||||
| 
 | 
 | ||||||
|     ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true) |     ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def update_poll |   def update_status | ||||||
|     return reject_payload! if invalid_origin?(@object['id']) |     return reject_payload! if invalid_origin?(@object['id']) | ||||||
| 
 | 
 | ||||||
|     status = Status.find_by(uri: object_uri, account_id: @account.id) |     status = Status.find_by(uri: object_uri, account_id: @account.id) | ||||||
|     return if status.nil? || status.preloadable_poll.nil? |  | ||||||
| 
 | 
 | ||||||
|     ActivityPub::ProcessPollService.new.call(status.preloadable_poll, @object) |     return if status.nil? | ||||||
|  | 
 | ||||||
|  |     ActivityPub::ProcessStatusUpdateService.new.call(status, @object) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  | |||||||
							
								
								
									
										27
									
								
								app/lib/activitypub/parser/custom_emoji_parser.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								app/lib/activitypub/parser/custom_emoji_parser.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class ActivityPub::Parser::CustomEmojiParser | ||||||
|  |   include JsonLdHelper | ||||||
|  | 
 | ||||||
|  |   def initialize(json) | ||||||
|  |     @json = json | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def uri | ||||||
|  |     @json['id'] | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def shortcode | ||||||
|  |     @json['name']&.delete(':') | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def image_remote_url | ||||||
|  |     @json.dig('icon', 'url') | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def updated_at | ||||||
|  |     @json['updated']&.to_datetime | ||||||
|  |   rescue ArgumentError | ||||||
|  |     nil | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										58
									
								
								app/lib/activitypub/parser/media_attachment_parser.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								app/lib/activitypub/parser/media_attachment_parser.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,58 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class ActivityPub::Parser::MediaAttachmentParser | ||||||
|  |   include JsonLdHelper | ||||||
|  | 
 | ||||||
|  |   def initialize(json) | ||||||
|  |     @json = json | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   # @param [MediaAttachment] previous_record | ||||||
|  |   def significantly_changes?(previous_record) | ||||||
|  |     remote_url != previous_record.remote_url || | ||||||
|  |       thumbnail_remote_url != previous_record.thumbnail_remote_url || | ||||||
|  |       description != previous_record.description | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def remote_url | ||||||
|  |     Addressable::URI.parse(@json['url'])&.normalize&.to_s | ||||||
|  |   rescue Addressable::URI::InvalidURIError | ||||||
|  |     nil | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def thumbnail_remote_url | ||||||
|  |     Addressable::URI.parse(@json['icon'].is_a?(Hash) ? @json['icon']['url'] : @json['icon'])&.normalize&.to_s | ||||||
|  |   rescue Addressable::URI::InvalidURIError | ||||||
|  |     nil | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def description | ||||||
|  |     @json['summary'].presence || @json['name'].presence | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def focus | ||||||
|  |     @json['focalPoint'] | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def blurhash | ||||||
|  |     supported_blurhash? ? @json['blurhash'] : nil | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def file_content_type | ||||||
|  |     @json['mediaType'] | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   private | ||||||
|  | 
 | ||||||
|  |   def supported_blurhash? | ||||||
|  |     components = begin | ||||||
|  |       blurhash = @json['blurhash'] | ||||||
|  | 
 | ||||||
|  |       if blurhash.present? && /^[\w#$%*+-.:;=?@\[\]^{|}~]+$/.match?(blurhash) | ||||||
|  |         Blurhash.components(blurhash) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     components.present? && components.none? { |comp| comp > 5 } | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										53
									
								
								app/lib/activitypub/parser/poll_parser.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								app/lib/activitypub/parser/poll_parser.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,53 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class ActivityPub::Parser::PollParser | ||||||
|  |   include JsonLdHelper | ||||||
|  | 
 | ||||||
|  |   def initialize(json) | ||||||
|  |     @json = json | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def valid? | ||||||
|  |     equals_or_includes?(@json['type'], 'Question') && items.is_a?(Array) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   # @param [Poll] previous_record | ||||||
|  |   def significantly_changes?(previous_record) | ||||||
|  |     options != previous_record.options || | ||||||
|  |       multiple != previous_record.multiple | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def options | ||||||
|  |     items.filter_map { |item| item['name'].presence || item['content'] } | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def multiple | ||||||
|  |     @json['anyOf'].is_a?(Array) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def expires_at | ||||||
|  |     if @json['closed'].is_a?(String) | ||||||
|  |       @json['closed'].to_datetime | ||||||
|  |     elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass) | ||||||
|  |       Time.now.utc | ||||||
|  |     else | ||||||
|  |       @json['endTime']&.to_datetime | ||||||
|  |     end | ||||||
|  |   rescue ArgumentError | ||||||
|  |     nil | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def voters_count | ||||||
|  |     @json['votersCount'] | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def cached_tallies | ||||||
|  |     items.map { |item| item.dig('replies', 'totalItems') || 0 } | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   private | ||||||
|  | 
 | ||||||
|  |   def items | ||||||
|  |     @json['anyOf'] || @json['oneOf'] | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										124
									
								
								app/lib/activitypub/parser/status_parser.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								app/lib/activitypub/parser/status_parser.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,124 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class ActivityPub::Parser::StatusParser | ||||||
|  |   include JsonLdHelper | ||||||
|  | 
 | ||||||
|  |   # @param [Hash] json | ||||||
|  |   # @param [Hash] magic_values | ||||||
|  |   # @option magic_values [String] :followers_collection | ||||||
|  |   def initialize(json, magic_values = {}) | ||||||
|  |     @json         = json | ||||||
|  |     @object       = json['object'] || json | ||||||
|  |     @magic_values = magic_values | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def uri | ||||||
|  |     id = @object['id'] | ||||||
|  | 
 | ||||||
|  |     if id&.start_with?('bear:') | ||||||
|  |       Addressable::URI.parse(id).query_values['u'] | ||||||
|  |     else | ||||||
|  |       id | ||||||
|  |     end | ||||||
|  |   rescue Addressable::URI::InvalidURIError | ||||||
|  |     id | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def url | ||||||
|  |     url_to_href(@object['url'], 'text/html') if @object['url'].present? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def text | ||||||
|  |     if @object['content'].present? | ||||||
|  |       @object['content'] | ||||||
|  |     elsif content_language_map? | ||||||
|  |       @object['contentMap'].values.first | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def spoiler_text | ||||||
|  |     if @object['summary'].present? | ||||||
|  |       @object['summary'] | ||||||
|  |     elsif summary_language_map? | ||||||
|  |       @object['summaryMap'].values.first | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def title | ||||||
|  |     if @object['name'].present? | ||||||
|  |       @object['name'] | ||||||
|  |     elsif name_language_map? | ||||||
|  |       @object['nameMap'].values.first | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def created_at | ||||||
|  |     @object['published']&.to_datetime | ||||||
|  |   rescue ArgumentError | ||||||
|  |     nil | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def edited_at | ||||||
|  |     @object['updated']&.to_datetime | ||||||
|  |   rescue ArgumentError | ||||||
|  |     nil | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def reply | ||||||
|  |     @object['inReplyTo'].present? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def sensitive | ||||||
|  |     @object['sensitive'] | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def visibility | ||||||
|  |     if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) } | ||||||
|  |       :public | ||||||
|  |     elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) } | ||||||
|  |       :unlisted | ||||||
|  |     elsif audience_to.include?(@magic_values[:followers_collection]) | ||||||
|  |       :private | ||||||
|  |     elsif direct_message == false | ||||||
|  |       :limited | ||||||
|  |     else | ||||||
|  |       :direct | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def language | ||||||
|  |     if content_language_map? | ||||||
|  |       @object['contentMap'].keys.first | ||||||
|  |     elsif name_language_map? | ||||||
|  |       @object['nameMap'].keys.first | ||||||
|  |     elsif summary_language_map? | ||||||
|  |       @object['summaryMap'].keys.first | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def direct_message | ||||||
|  |     @object['directMessage'] | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   private | ||||||
|  | 
 | ||||||
|  |   def audience_to | ||||||
|  |     as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) } | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def audience_cc | ||||||
|  |     as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) } | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def summary_language_map? | ||||||
|  |     @object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def content_language_map? | ||||||
|  |     @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def name_language_map? | ||||||
|  |     @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty? | ||||||
|  |   end | ||||||
|  | end | ||||||
| @ -6,7 +6,7 @@ class Admin::Metrics::Retention | |||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   class CohortData < ActiveModelSerializers::Model |   class CohortData < ActiveModelSerializers::Model | ||||||
|     attributes :date, :percent, :value |     attributes :date, :rate, :value | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def initialize(start_at, end_at, frequency) |   def initialize(start_at, end_at, frequency) | ||||||
| @ -59,7 +59,7 @@ class Admin::Metrics::Retention | |||||||
| 
 | 
 | ||||||
|       current_cohort.data << CohortData.new( |       current_cohort.data << CohortData.new( | ||||||
|         date: row['retention_period'], |         date: row['retention_period'], | ||||||
|         percent: rate.to_f, |         rate: rate.to_f, | ||||||
|         value: value.to_s |         value: value.to_s | ||||||
|       ) |       ) | ||||||
|     end |     end | ||||||
|  | |||||||
| @ -55,46 +55,50 @@ class FeedManager | |||||||
|   # Add a status to a home feed and send a streaming API update |   # Add a status to a home feed and send a streaming API update | ||||||
|   # @param [Account] account |   # @param [Account] account | ||||||
|   # @param [Status] status |   # @param [Status] status | ||||||
|  |   # @param [Boolean] update | ||||||
|   # @return [Boolean] |   # @return [Boolean] | ||||||
|   def push_to_home(account, status) |   def push_to_home(account, status, update: false) | ||||||
|     return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?) |     return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?) | ||||||
| 
 | 
 | ||||||
|     trim(:home, account.id) |     trim(:home, account.id) | ||||||
|     PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}") if push_update_required?("timeline:#{account.id}") |     PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}", update: update) if push_update_required?("timeline:#{account.id}") | ||||||
|     true |     true | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   # Remove a status from a home feed and send a streaming API update |   # Remove a status from a home feed and send a streaming API update | ||||||
|   # @param [Account] account |   # @param [Account] account | ||||||
|   # @param [Status] status |   # @param [Status] status | ||||||
|  |   # @param [Boolean] update | ||||||
|   # @return [Boolean] |   # @return [Boolean] | ||||||
|   def unpush_from_home(account, status) |   def unpush_from_home(account, status, update: false) | ||||||
|     return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?) |     return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?) | ||||||
| 
 | 
 | ||||||
|     redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) |     redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update | ||||||
|     true |     true | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   # Add a status to a list feed and send a streaming API update |   # Add a status to a list feed and send a streaming API update | ||||||
|   # @param [List] list |   # @param [List] list | ||||||
|   # @param [Status] status |   # @param [Status] status | ||||||
|  |   # @param [Boolean] update | ||||||
|   # @return [Boolean] |   # @return [Boolean] | ||||||
|   def push_to_list(list, status) |   def push_to_list(list, status, update: false) | ||||||
|     return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) |     return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) | ||||||
| 
 | 
 | ||||||
|     trim(:list, list.id) |     trim(:list, list.id) | ||||||
|     PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}") |     PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}", update: update) if push_update_required?("timeline:list:#{list.id}") | ||||||
|     true |     true | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   # Remove a status from a list feed and send a streaming API update |   # Remove a status from a list feed and send a streaming API update | ||||||
|   # @param [List] list |   # @param [List] list | ||||||
|   # @param [Status] status |   # @param [Status] status | ||||||
|  |   # @param [Boolean] update | ||||||
|   # @return [Boolean] |   # @return [Boolean] | ||||||
|   def unpush_from_list(list, status) |   def unpush_from_list(list, status, update: false) | ||||||
|     return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) |     return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) | ||||||
| 
 | 
 | ||||||
|     redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) |     redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update | ||||||
|     true |     true | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
| @ -102,11 +106,11 @@ class FeedManager | |||||||
|   # @param [Account] account |   # @param [Account] account | ||||||
|   # @param [Status] status |   # @param [Status] status | ||||||
|   # @return [Boolean] |   # @return [Boolean] | ||||||
|   def push_to_direct(account, status) |   def push_to_direct(account, status, update: false) | ||||||
|     return false unless add_to_feed(:direct, account.id, status) |     return false unless add_to_feed(:direct, account.id, status) | ||||||
| 
 | 
 | ||||||
|     trim(:direct, account.id) |     trim(:direct, account.id) | ||||||
|     PushUpdateWorker.perform_async(account.id, status.id, "timeline:direct:#{account.id}") |     PushUpdateWorker.perform_async(account.id, status.id, "timeline:direct:#{account.id}") unless update | ||||||
|     true |     true | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
| @ -114,10 +118,10 @@ class FeedManager | |||||||
|   # @param [List] list |   # @param [List] list | ||||||
|   # @param [Status] status |   # @param [Status] status | ||||||
|   # @return [Boolean] |   # @return [Boolean] | ||||||
|   def unpush_from_direct(account, status) |   def unpush_from_direct(account, status, update: false) | ||||||
|     return false unless remove_from_feed(:direct, account.id, status) |     return false unless remove_from_feed(:direct, account.id, status) | ||||||
| 
 | 
 | ||||||
|     redis.publish("timeline:direct:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) |     redis.publish("timeline:direct:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update | ||||||
|     true |     true | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,8 +1,12 @@ | |||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class StatusReachFinder | class StatusReachFinder | ||||||
|   def initialize(status) |   # @param [Status] status | ||||||
|     @status = status |   # @param [Hash] options | ||||||
|  |   # @option options [Boolean] :unsafe | ||||||
|  |   def initialize(status, options = {}) | ||||||
|  |     @status  = status | ||||||
|  |     @options = options | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def inboxes |   def inboxes | ||||||
| @ -38,7 +42,7 @@ class StatusReachFinder | |||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def replied_to_account_id |   def replied_to_account_id | ||||||
|     @status.in_reply_to_account_id |     @status.in_reply_to_account_id if distributable? | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def reblog_of_account_id |   def reblog_of_account_id | ||||||
| @ -49,21 +53,26 @@ class StatusReachFinder | |||||||
|     @status.mentions.pluck(:account_id) |     @status.mentions.pluck(:account_id) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   # Beware: Reblogs can be created without the author having had access to the status | ||||||
|   def reblogs_account_ids |   def reblogs_account_ids | ||||||
|     @status.reblogs.pluck(:account_id) |     @status.reblogs.pluck(:account_id) if distributable? || unsafe? | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   # Beware: Favourites can be created without the author having had access to the status | ||||||
|   def favourites_account_ids |   def favourites_account_ids | ||||||
|     @status.favourites.pluck(:account_id) |     @status.favourites.pluck(:account_id) if distributable? || unsafe? | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   # Beware: Replies can be created without the author having had access to the status | ||||||
|   def replies_account_ids |   def replies_account_ids | ||||||
|     @status.replies.pluck(:account_id) |     @status.replies.pluck(:account_id) if distributable? || unsafe? | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def followers_inboxes |   def followers_inboxes | ||||||
|     if @status.in_reply_to_local_account? && @status.distributable? |     if @status.in_reply_to_local_account? && distributable? | ||||||
|       @status.account.followers.or(@status.thread.account.followers).inboxes |       @status.account.followers.or(@status.thread.account.followers).inboxes | ||||||
|  |     elsif @status.direct_visibility? || @status.limited_visibility? | ||||||
|  |       [] | ||||||
|     else |     else | ||||||
|       @status.account.followers.inboxes |       @status.account.followers.inboxes | ||||||
|     end |     end | ||||||
| @ -76,4 +85,12 @@ class StatusReachFinder | |||||||
|       [] |       [] | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   def distributable? | ||||||
|  |     @status.public_visibility? || @status.unlisted_visibility? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def unsafe? | ||||||
|  |     @options[:unsafe] | ||||||
|  |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -429,6 +429,9 @@ class Account < ApplicationRecord | |||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   class << self |   class << self | ||||||
|  |     DISALLOWED_TSQUERY_CHARACTERS = /['?\\:‘’]/.freeze | ||||||
|  |     TEXTSEARCH = "(setweight(to_tsvector('simple', accounts.display_name), 'A') || setweight(to_tsvector('simple', accounts.username), 'B') || setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C'))" | ||||||
|  | 
 | ||||||
|     def readonly_attributes |     def readonly_attributes | ||||||
|       super - %w(statuses_count following_count followers_count) |       super - %w(statuses_count following_count followers_count) | ||||||
|     end |     end | ||||||
| @ -439,70 +442,29 @@ class Account < ApplicationRecord | |||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def search_for(terms, limit = 10, offset = 0) |     def search_for(terms, limit = 10, offset = 0) | ||||||
|       textsearch, query = generate_query_for_search(terms) |       tsquery = generate_query_for_search(terms) | ||||||
| 
 | 
 | ||||||
|       sql = <<-SQL.squish |       sql = <<-SQL.squish | ||||||
|         SELECT |         SELECT | ||||||
|           accounts.*, |           accounts.*, | ||||||
|           ts_rank_cd(#{textsearch}, #{query}, 32) AS rank |           ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank | ||||||
|         FROM accounts |         FROM accounts | ||||||
|         WHERE #{query} @@ #{textsearch} |         WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH} | ||||||
|           AND accounts.suspended_at IS NULL |           AND accounts.suspended_at IS NULL | ||||||
|           AND accounts.moved_to_account_id IS NULL |           AND accounts.moved_to_account_id IS NULL | ||||||
|         ORDER BY rank DESC |         ORDER BY rank DESC | ||||||
|         LIMIT ? OFFSET ? |         LIMIT :limit OFFSET :offset | ||||||
|       SQL |       SQL | ||||||
| 
 | 
 | ||||||
|       records = find_by_sql([sql, limit, offset]) |       records = find_by_sql([sql, limit: limit, offset: offset, tsquery: tsquery]) | ||||||
|       ActiveRecord::Associations::Preloader.new.preload(records, :account_stat) |       ActiveRecord::Associations::Preloader.new.preload(records, :account_stat) | ||||||
|       records |       records | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def advanced_search_for(terms, account, limit = 10, following = false, offset = 0) |     def advanced_search_for(terms, account, limit = 10, following = false, offset = 0) | ||||||
|       textsearch, query = generate_query_for_search(terms) |       tsquery = generate_query_for_search(terms) | ||||||
| 
 |       sql = advanced_search_for_sql_template(following) | ||||||
|       if following |       records = find_by_sql([sql, id: account.id, limit: limit, offset: offset, tsquery: tsquery]) | ||||||
|         sql = <<-SQL.squish |  | ||||||
|           WITH first_degree AS ( |  | ||||||
|             SELECT target_account_id |  | ||||||
|             FROM follows |  | ||||||
|             WHERE account_id = ? |  | ||||||
|             UNION ALL |  | ||||||
|             SELECT ? |  | ||||||
|           ) |  | ||||||
|           SELECT |  | ||||||
|             accounts.*, |  | ||||||
|             (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank |  | ||||||
|           FROM accounts |  | ||||||
|           LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) |  | ||||||
|           WHERE accounts.id IN (SELECT * FROM first_degree) |  | ||||||
|             AND #{query} @@ #{textsearch} |  | ||||||
|             AND accounts.suspended_at IS NULL |  | ||||||
|             AND accounts.moved_to_account_id IS NULL |  | ||||||
|           GROUP BY accounts.id |  | ||||||
|           ORDER BY rank DESC |  | ||||||
|           LIMIT ? OFFSET ? |  | ||||||
|         SQL |  | ||||||
| 
 |  | ||||||
|         records = find_by_sql([sql, account.id, account.id, account.id, limit, offset]) |  | ||||||
|       else |  | ||||||
|         sql = <<-SQL.squish |  | ||||||
|           SELECT |  | ||||||
|             accounts.*, |  | ||||||
|             (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank |  | ||||||
|           FROM accounts |  | ||||||
|           LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?) |  | ||||||
|           WHERE #{query} @@ #{textsearch} |  | ||||||
|             AND accounts.suspended_at IS NULL |  | ||||||
|             AND accounts.moved_to_account_id IS NULL |  | ||||||
|           GROUP BY accounts.id |  | ||||||
|           ORDER BY rank DESC |  | ||||||
|           LIMIT ? OFFSET ? |  | ||||||
|         SQL |  | ||||||
| 
 |  | ||||||
|         records = find_by_sql([sql, account.id, account.id, limit, offset]) |  | ||||||
|       end |  | ||||||
| 
 |  | ||||||
|       ActiveRecord::Associations::Preloader.new.preload(records, :account_stat) |       ActiveRecord::Associations::Preloader.new.preload(records, :account_stat) | ||||||
|       records |       records | ||||||
|     end |     end | ||||||
| @ -524,12 +486,55 @@ class Account < ApplicationRecord | |||||||
| 
 | 
 | ||||||
|     private |     private | ||||||
| 
 | 
 | ||||||
|     def generate_query_for_search(terms) |     def generate_query_for_search(unsanitized_terms) | ||||||
|       terms      = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' '))) |       terms = unsanitized_terms.gsub(DISALLOWED_TSQUERY_CHARACTERS, ' ') | ||||||
|       textsearch = "(setweight(to_tsvector('simple', accounts.display_name), 'A') || setweight(to_tsvector('simple', accounts.username), 'B') || setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C'))" |  | ||||||
|       query      = "to_tsquery('simple', ''' ' || #{terms} || ' ''' || ':*')" |  | ||||||
| 
 | 
 | ||||||
|       [textsearch, query] |       # The final ":*" is for prefix search. | ||||||
|  |       # The trailing space does not seem to fit any purpose, but `to_tsquery` | ||||||
|  |       # behaves differently with and without a leading space if the terms start | ||||||
|  |       # with `./`, `../`, or `.. `. I don't understand why, so, in doubt, keep | ||||||
|  |       # the same query. | ||||||
|  |       "' #{terms} ':*" | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def advanced_search_for_sql_template(following) | ||||||
|  |       if following | ||||||
|  |         <<-SQL.squish | ||||||
|  |           WITH first_degree AS ( | ||||||
|  |             SELECT target_account_id | ||||||
|  |             FROM follows | ||||||
|  |             WHERE account_id = :id | ||||||
|  |             UNION ALL | ||||||
|  |             SELECT :id | ||||||
|  |           ) | ||||||
|  |           SELECT | ||||||
|  |             accounts.*, | ||||||
|  |             (count(f.id) + 1) * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank | ||||||
|  |           FROM accounts | ||||||
|  |           LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id) | ||||||
|  |           WHERE accounts.id IN (SELECT * FROM first_degree) | ||||||
|  |             AND to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH} | ||||||
|  |             AND accounts.suspended_at IS NULL | ||||||
|  |             AND accounts.moved_to_account_id IS NULL | ||||||
|  |           GROUP BY accounts.id | ||||||
|  |           ORDER BY rank DESC | ||||||
|  |           LIMIT :limit OFFSET :offset | ||||||
|  |         SQL | ||||||
|  |       else | ||||||
|  |         <<-SQL.squish | ||||||
|  |           SELECT | ||||||
|  |             accounts.*, | ||||||
|  |             (count(f.id) + 1) * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank | ||||||
|  |           FROM accounts | ||||||
|  |           LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id) OR (accounts.id = f.target_account_id AND f.account_id = :id) | ||||||
|  |           WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH} | ||||||
|  |             AND accounts.suspended_at IS NULL | ||||||
|  |             AND accounts.moved_to_account_id IS NULL | ||||||
|  |           GROUP BY accounts.id | ||||||
|  |           ORDER BY rank DESC | ||||||
|  |           LIMIT :limit OFFSET :offset | ||||||
|  |         SQL | ||||||
|  |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -26,6 +26,7 @@ class Poll < ApplicationRecord | |||||||
|   belongs_to :status |   belongs_to :status | ||||||
| 
 | 
 | ||||||
|   has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :delete_all |   has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :delete_all | ||||||
|  |   has_many :voters, -> { group('accounts.id') }, through: :votes, class_name: 'Account', source: :account | ||||||
| 
 | 
 | ||||||
|   has_many :notifications, as: :activity, dependent: :destroy |   has_many :notifications, as: :activity, dependent: :destroy | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -26,6 +26,7 @@ | |||||||
| #  poll_id                :bigint(8) | #  poll_id                :bigint(8) | ||||||
| #  content_type           :string | #  content_type           :string | ||||||
| #  deleted_at             :datetime | #  deleted_at             :datetime | ||||||
|  | #  edited_at              :datetime | ||||||
| # | # | ||||||
| 
 | 
 | ||||||
| class Status < ApplicationRecord | class Status < ApplicationRecord | ||||||
| @ -59,6 +60,8 @@ class Status < ApplicationRecord | |||||||
|   belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true |   belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true | ||||||
|   belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true |   belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true | ||||||
| 
 | 
 | ||||||
|  |   has_many :edits, class_name: 'StatusEdit', inverse_of: :status, dependent: :destroy | ||||||
|  | 
 | ||||||
|   has_many :favourites, inverse_of: :status, dependent: :destroy |   has_many :favourites, inverse_of: :status, dependent: :destroy | ||||||
|   has_many :bookmarks, inverse_of: :status, dependent: :destroy |   has_many :bookmarks, inverse_of: :status, dependent: :destroy | ||||||
|   has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy |   has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy | ||||||
| @ -100,15 +103,12 @@ class Status < ApplicationRecord | |||||||
|   scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) } |   scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) } | ||||||
|   scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) } |   scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) } | ||||||
|   scope :tagged_with_all, ->(tag_ids) { |   scope :tagged_with_all, ->(tag_ids) { | ||||||
|     Array(tag_ids).reduce(self) do |result, id| |     Array(tag_ids).map(&:to_i).reduce(self) do |result, id| | ||||||
|       result.joins("INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}") |       result.joins("INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}") | ||||||
|     end |     end | ||||||
|   } |   } | ||||||
|   scope :tagged_with_none, ->(tag_ids) { |   scope :tagged_with_none, ->(tag_ids) { | ||||||
|     Array(tag_ids).reduce(self) do |result, id| |     where('NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))', tag_ids) | ||||||
|       result.joins("LEFT OUTER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}") |  | ||||||
|             .where("t#{id}.tag_id IS NULL") |  | ||||||
|     end |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   scope :not_local_only, -> { where(local_only: [false, nil]) } |   scope :not_local_only, -> { where(local_only: [false, nil]) } | ||||||
| @ -215,6 +215,10 @@ class Status < ApplicationRecord | |||||||
|     public_visibility? || unlisted_visibility? |     public_visibility? || unlisted_visibility? | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def edited? | ||||||
|  |     edited_at.present? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   alias sign? distributable? |   alias sign? distributable? | ||||||
| 
 | 
 | ||||||
|   def with_media? |   def with_media? | ||||||
|  | |||||||
							
								
								
									
										23
									
								
								app/models/status_edit.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/models/status_edit.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | # == Schema Information | ||||||
|  | # | ||||||
|  | # Table name: status_edits | ||||||
|  | # | ||||||
|  | #  id                        :bigint(8)        not null, primary key | ||||||
|  | #  status_id                 :bigint(8)        not null | ||||||
|  | #  account_id                :bigint(8) | ||||||
|  | #  text                      :text             default(""), not null | ||||||
|  | #  spoiler_text              :text             default(""), not null | ||||||
|  | #  media_attachments_changed :boolean          default(FALSE), not null | ||||||
|  | #  created_at                :datetime         not null | ||||||
|  | #  updated_at                :datetime         not null | ||||||
|  | # | ||||||
|  | 
 | ||||||
|  | class StatusEdit < ApplicationRecord | ||||||
|  |   belongs_to :status | ||||||
|  |   belongs_to :account, optional: true | ||||||
|  | 
 | ||||||
|  |   default_scope { order(id: :asc) } | ||||||
|  | 
 | ||||||
|  |   delegate :local?, to: :status | ||||||
|  | end | ||||||
| @ -10,7 +10,6 @@ | |||||||
| #  encrypted_password        :string           default(""), not null | #  encrypted_password        :string           default(""), not null | ||||||
| #  reset_password_token      :string | #  reset_password_token      :string | ||||||
| #  reset_password_sent_at    :datetime | #  reset_password_sent_at    :datetime | ||||||
| #  remember_created_at       :datetime |  | ||||||
| #  sign_in_count             :integer          default(0), not null | #  sign_in_count             :integer          default(0), not null | ||||||
| #  current_sign_in_at        :datetime | #  current_sign_in_at        :datetime | ||||||
| #  last_sign_in_at           :datetime | #  last_sign_in_at           :datetime | ||||||
| @ -32,7 +31,6 @@ | |||||||
| #  disabled                  :boolean          default(FALSE), not null | #  disabled                  :boolean          default(FALSE), not null | ||||||
| #  moderator                 :boolean          default(FALSE), not null | #  moderator                 :boolean          default(FALSE), not null | ||||||
| #  invite_id                 :bigint(8) | #  invite_id                 :bigint(8) | ||||||
| #  remember_token            :string |  | ||||||
| #  chosen_languages          :string           is an Array | #  chosen_languages          :string           is an Array | ||||||
| #  created_by_application_id :bigint(8) | #  created_by_application_id :bigint(8) | ||||||
| #  approved                  :boolean          default(TRUE), not null | #  approved                  :boolean          default(TRUE), not null | ||||||
| @ -44,6 +42,11 @@ | |||||||
| # | # | ||||||
| 
 | 
 | ||||||
| class User < ApplicationRecord | class User < ApplicationRecord | ||||||
|  |   self.ignored_columns = %w( | ||||||
|  |     remember_created_at | ||||||
|  |     remember_token | ||||||
|  |   ) | ||||||
|  | 
 | ||||||
|   include Settings::Extend |   include Settings::Extend | ||||||
|   include UserRoles |   include UserRoles | ||||||
| 
 | 
 | ||||||
| @ -329,10 +332,9 @@ class User < ApplicationRecord | |||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def reset_password! |   def reset_password! | ||||||
|     # First, change password to something random, invalidate the remember-me token, |     # First, change password to something random and deactivate all sessions | ||||||
|     # and deactivate all sessions |  | ||||||
|     transaction do |     transaction do | ||||||
|       update(remember_token: nil, remember_created_at: nil, password: SecureRandom.hex) |       update(password: SecureRandom.hex) | ||||||
|       session_activations.destroy_all |       session_activations.destroy_all | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -11,6 +11,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer | |||||||
| 
 | 
 | ||||||
|   attribute :content |   attribute :content | ||||||
|   attribute :content_map, if: :language? |   attribute :content_map, if: :language? | ||||||
|  |   attribute :updated, if: :edited? | ||||||
| 
 | 
 | ||||||
|   attribute :direct_message, if: :non_public? |   attribute :direct_message, if: :non_public? | ||||||
| 
 | 
 | ||||||
| @ -76,6 +77,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer | |||||||
|     object.language.present? |     object.language.present? | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   delegate :edited?, to: :object | ||||||
|  | 
 | ||||||
|   def in_reply_to |   def in_reply_to | ||||||
|     return unless object.reply? && !object.thread.nil? |     return unless object.reply? && !object.thread.nil? | ||||||
| 
 | 
 | ||||||
| @ -90,6 +93,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer | |||||||
|     object.created_at.iso8601 |     object.created_at.iso8601 | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def updated | ||||||
|  |     object.edited_at.iso8601 | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def url |   def url | ||||||
|     ActivityPub::TagManager.instance.url_for(object) |     ActivityPub::TagManager.instance.url_for(object) | ||||||
|   end |   end | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ class REST::Admin::CohortSerializer < ActiveModel::Serializer | |||||||
|   attributes :period, :frequency |   attributes :period, :frequency | ||||||
| 
 | 
 | ||||||
|   class CohortDataSerializer < ActiveModel::Serializer |   class CohortDataSerializer < ActiveModel::Serializer | ||||||
|     attributes :date, :percent, :value |     attributes :date, :rate, :value | ||||||
| 
 | 
 | ||||||
|     def date |     def date | ||||||
|       object.date.iso8601 |       object.date.iso8601 | ||||||
|  | |||||||
							
								
								
									
										6
									
								
								app/serializers/rest/status_edit_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								app/serializers/rest/status_edit_serializer.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class REST::StatusEditSerializer < ActiveModel::Serializer | ||||||
|  |   attributes :text, :spoiler_text, :media_attachments_changed, | ||||||
|  |              :created_at | ||||||
|  | end | ||||||
| @ -4,7 +4,7 @@ class REST::StatusSerializer < ActiveModel::Serializer | |||||||
|   attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, |   attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, | ||||||
|              :sensitive, :spoiler_text, :visibility, :language, |              :sensitive, :spoiler_text, :visibility, :language, | ||||||
|              :uri, :url, :replies_count, :reblogs_count, |              :uri, :url, :replies_count, :reblogs_count, | ||||||
|              :favourites_count |              :favourites_count, :edited_at | ||||||
| 
 | 
 | ||||||
|   attribute :favourited, if: :current_user? |   attribute :favourited, if: :current_user? | ||||||
|   attribute :reblogged, if: :current_user? |   attribute :reblogged, if: :current_user? | ||||||
|  | |||||||
							
								
								
									
										9
									
								
								app/serializers/rest/status_source_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/serializers/rest/status_source_serializer.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class REST::StatusSourceSerializer < ActiveModel::Serializer | ||||||
|  |   attributes :id, :text, :spoiler_text, :content_type | ||||||
|  | 
 | ||||||
|  |   def id | ||||||
|  |     object.id.to_s | ||||||
|  |   end | ||||||
|  | end | ||||||
| @ -8,6 +8,6 @@ class ActivityPub::FetchRemotePollService < BaseService | |||||||
| 
 | 
 | ||||||
|     return unless supported_context?(json) |     return unless supported_context?(json) | ||||||
| 
 | 
 | ||||||
|     ActivityPub::ProcessPollService.new.call(poll, json) |     ActivityPub::ProcessStatusUpdateService.new.call(poll.status, json) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -1,64 +0,0 @@ | |||||||
| # frozen_string_literal: true |  | ||||||
| 
 |  | ||||||
| class ActivityPub::ProcessPollService < BaseService |  | ||||||
|   include JsonLdHelper |  | ||||||
| 
 |  | ||||||
|   def call(poll, json) |  | ||||||
|     @json = json |  | ||||||
| 
 |  | ||||||
|     return unless expected_type? |  | ||||||
| 
 |  | ||||||
|     previous_expires_at = poll.expires_at |  | ||||||
| 
 |  | ||||||
|     expires_at = begin |  | ||||||
|       if @json['closed'].is_a?(String) |  | ||||||
|         @json['closed'] |  | ||||||
|       elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass) |  | ||||||
|         Time.now.utc |  | ||||||
|       else |  | ||||||
|         @json['endTime'] |  | ||||||
|       end |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     items = begin |  | ||||||
|       if @json['anyOf'].is_a?(Array) |  | ||||||
|         @json['anyOf'] |  | ||||||
|       else |  | ||||||
|         @json['oneOf'] |  | ||||||
|       end |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     voters_count = @json['votersCount'] |  | ||||||
| 
 |  | ||||||
|     latest_options = items.filter_map { |item| item['name'].presence || item['content'] } |  | ||||||
| 
 |  | ||||||
|     # If for some reasons the options were changed, it invalidates all previous |  | ||||||
|     # votes, so we need to remove them |  | ||||||
|     poll.votes.delete_all if latest_options != poll.options |  | ||||||
| 
 |  | ||||||
|     begin |  | ||||||
|       poll.update!( |  | ||||||
|         last_fetched_at: Time.now.utc, |  | ||||||
|         expires_at: expires_at, |  | ||||||
|         options: latest_options, |  | ||||||
|         cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }, |  | ||||||
|         voters_count: voters_count |  | ||||||
|       ) |  | ||||||
|     rescue ActiveRecord::StaleObjectError |  | ||||||
|       poll.reload |  | ||||||
|       retry |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     # If the poll had no expiration date set but now has, and people have voted, |  | ||||||
|     # schedule a notification. |  | ||||||
|     if previous_expires_at.nil? && poll.expires_at.present? && poll.votes.exists? |  | ||||||
|       PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id) |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   private |  | ||||||
| 
 |  | ||||||
|   def expected_type? |  | ||||||
|     equals_or_includes_any?(@json['type'], %w(Question)) |  | ||||||
|   end |  | ||||||
| end |  | ||||||
							
								
								
									
										275
									
								
								app/services/activitypub/process_status_update_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										275
									
								
								app/services/activitypub/process_status_update_service.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,275 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class ActivityPub::ProcessStatusUpdateService < BaseService | ||||||
|  |   include JsonLdHelper | ||||||
|  | 
 | ||||||
|  |   def call(status, json) | ||||||
|  |     @json                      = json | ||||||
|  |     @status_parser             = ActivityPub::Parser::StatusParser.new(@json) | ||||||
|  |     @uri                       = @status_parser.uri | ||||||
|  |     @status                    = status | ||||||
|  |     @account                   = status.account | ||||||
|  |     @media_attachments_changed = false | ||||||
|  | 
 | ||||||
|  |     # Only native types can be updated at the moment | ||||||
|  |     return if !expected_type? || already_updated_more_recently? | ||||||
|  | 
 | ||||||
|  |     # Only allow processing one create/update per status at a time | ||||||
|  |     RedisLock.acquire(lock_options) do |lock| | ||||||
|  |       if lock.acquired? | ||||||
|  |         Status.transaction do | ||||||
|  |           create_previous_edit! | ||||||
|  |           update_media_attachments! | ||||||
|  |           update_poll! | ||||||
|  |           update_immediate_attributes! | ||||||
|  |           update_metadata! | ||||||
|  |           create_edit! | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         queue_poll_notifications! | ||||||
|  |         reset_preview_card! | ||||||
|  |         broadcast_updates! | ||||||
|  |       else | ||||||
|  |         raise Mastodon::RaceConditionError | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   private | ||||||
|  | 
 | ||||||
|  |   def update_media_attachments! | ||||||
|  |     previous_media_attachments = @status.media_attachments.to_a | ||||||
|  |     next_media_attachments     = [] | ||||||
|  | 
 | ||||||
|  |     as_array(@json['attachment']).each do |attachment| | ||||||
|  |       media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment) | ||||||
|  | 
 | ||||||
|  |       next if media_attachment_parser.remote_url.blank? || next_media_attachments.size > 4 | ||||||
|  | 
 | ||||||
|  |       begin | ||||||
|  |         media_attachment   = previous_media_attachments.find { |previous_media_attachment| previous_media_attachment.remote_url == media_attachment_parser.remote_url } | ||||||
|  |         media_attachment ||= MediaAttachment.new(account: @account, remote_url: media_attachment_parser.remote_url) | ||||||
|  | 
 | ||||||
|  |         # If a previously existing media attachment was significantly updated, mark | ||||||
|  |         # media attachments as changed even if none were added or removed | ||||||
|  |         if media_attachment_parser.significantly_changes?(media_attachment) | ||||||
|  |           @media_attachments_changed = true | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         media_attachment.description          = media_attachment_parser.description | ||||||
|  |         media_attachment.focus                = media_attachment_parser.focus | ||||||
|  |         media_attachment.thumbnail_remote_url = media_attachment_parser.thumbnail_remote_url | ||||||
|  |         media_attachment.blurhash             = media_attachment_parser.blurhash | ||||||
|  |         media_attachment.save! | ||||||
|  | 
 | ||||||
|  |         next_media_attachments << media_attachment | ||||||
|  | 
 | ||||||
|  |         next if unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download? | ||||||
|  | 
 | ||||||
|  |         RedownloadMediaWorker.perform_async(media_attachment.id) if media_attachment.remote_url_previously_changed? || media_attachment.thumbnail_remote_url_previously_changed? | ||||||
|  |       rescue Addressable::URI::InvalidURIError => e | ||||||
|  |         Rails.logger.debug "Invalid URL in attachment: #{e}" | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     removed_media_attachments = previous_media_attachments - next_media_attachments | ||||||
|  |     added_media_attachments   = next_media_attachments - previous_media_attachments | ||||||
|  | 
 | ||||||
|  |     MediaAttachment.where(id: removed_media_attachments.map(&:id)).update_all(status_id: nil) | ||||||
|  |     MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id) | ||||||
|  | 
 | ||||||
|  |     @media_attachments_changed = true if removed_media_attachments.any? || added_media_attachments.any? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def update_poll! | ||||||
|  |     previous_poll        = @status.preloadable_poll | ||||||
|  |     @previous_expires_at = previous_poll&.expires_at | ||||||
|  |     poll_parser          = ActivityPub::Parser::PollParser.new(@json) | ||||||
|  | 
 | ||||||
|  |     if poll_parser.valid? | ||||||
|  |       poll = previous_poll || @account.polls.new(status: @status) | ||||||
|  | 
 | ||||||
|  |       # If for some reasons the options were changed, it invalidates all previous | ||||||
|  |       # votes, so we need to remove them | ||||||
|  |       if poll_parser.significantly_changes?(poll) | ||||||
|  |         @media_attachments_changed = true | ||||||
|  |         poll.votes.delete_all unless poll.new_record? | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       poll.last_fetched_at = Time.now.utc | ||||||
|  |       poll.options         = poll_parser.options | ||||||
|  |       poll.multiple        = poll_parser.multiple | ||||||
|  |       poll.expires_at      = poll_parser.expires_at | ||||||
|  |       poll.voters_count    = poll_parser.voters_count | ||||||
|  |       poll.cached_tallies  = poll_parser.cached_tallies | ||||||
|  |       poll.save! | ||||||
|  | 
 | ||||||
|  |       @status.poll_id = poll.id | ||||||
|  |     elsif previous_poll.present? | ||||||
|  |       previous_poll.destroy! | ||||||
|  |       @media_attachments_changed = true | ||||||
|  |       @status.poll_id = nil | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def update_immediate_attributes! | ||||||
|  |     @status.text         = @status_parser.text || '' | ||||||
|  |     @status.spoiler_text = @status_parser.spoiler_text || '' | ||||||
|  |     @status.sensitive    = @account.sensitized? || @status_parser.sensitive || false | ||||||
|  |     @status.language     = @status_parser.language || detected_language | ||||||
|  |     @status.edited_at    = @status_parser.edited_at || Time.now.utc | ||||||
|  | 
 | ||||||
|  |     @status.save! | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def update_metadata! | ||||||
|  |     @raw_tags     = [] | ||||||
|  |     @raw_mentions = [] | ||||||
|  |     @raw_emojis   = [] | ||||||
|  | 
 | ||||||
|  |     as_array(@json['tag']).each do |tag| | ||||||
|  |       if equals_or_includes?(tag['type'], 'Hashtag') | ||||||
|  |         @raw_tags << tag['name'] | ||||||
|  |       elsif equals_or_includes?(tag['type'], 'Mention') | ||||||
|  |         @raw_mentions << tag['href'] | ||||||
|  |       elsif equals_or_includes?(tag['type'], 'Emoji') | ||||||
|  |         @raw_emojis << tag | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     update_tags! | ||||||
|  |     update_mentions! | ||||||
|  |     update_emojis! | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def update_tags! | ||||||
|  |     @status.tags = Tag.find_or_create_by_names(@raw_tags) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def update_mentions! | ||||||
|  |     previous_mentions = @status.active_mentions.includes(:account).to_a | ||||||
|  |     current_mentions  = [] | ||||||
|  | 
 | ||||||
|  |     @raw_mentions.each do |href| | ||||||
|  |       next if href.blank? | ||||||
|  | 
 | ||||||
|  |       account   = ActivityPub::TagManager.instance.uri_to_resource(href, Account) | ||||||
|  |       account ||= ActivityPub::FetchRemoteAccountService.new.call(href) | ||||||
|  | 
 | ||||||
|  |       next if account.nil? | ||||||
|  | 
 | ||||||
|  |       mention   = previous_mentions.find { |x| x.account_id == account.id } | ||||||
|  |       mention ||= account.mentions.new(status: @status) | ||||||
|  | 
 | ||||||
|  |       current_mentions << mention | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     current_mentions.each do |mention| | ||||||
|  |       mention.save if mention.new_record? | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     # If previous mentions are no longer contained in the text, convert them | ||||||
|  |     # to silent mentions, since withdrawing access from someone who already | ||||||
|  |     # received a notification might be more confusing | ||||||
|  |     removed_mentions = previous_mentions - current_mentions | ||||||
|  | 
 | ||||||
|  |     Mention.where(id: removed_mentions.map(&:id)).update_all(silent: true) unless removed_mentions.empty? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def update_emojis! | ||||||
|  |     return if skip_download? | ||||||
|  | 
 | ||||||
|  |     @raw_emojis.each do |raw_emoji| | ||||||
|  |       custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(raw_emoji) | ||||||
|  | 
 | ||||||
|  |       next if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank? | ||||||
|  | 
 | ||||||
|  |       emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain) | ||||||
|  | 
 | ||||||
|  |       next unless emoji.nil? || custom_emoji_parser.image_remote_url != emoji.image_remote_url || (custom_emoji_parser.updated_at && custom_emoji_parser.updated_at >= emoji.updated_at) | ||||||
|  | 
 | ||||||
|  |       begin | ||||||
|  |         emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: custom_emoji_parser.shortcode, uri: custom_emoji_parser.uri) | ||||||
|  |         emoji.image_remote_url = custom_emoji_parser.image_remote_url | ||||||
|  |         emoji.save | ||||||
|  |       rescue Seahorse::Client::NetworkingError => e | ||||||
|  |         Rails.logger.warn "Error storing emoji: #{e}" | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def expected_type? | ||||||
|  |     equals_or_includes_any?(@json['type'], %w(Note Question)) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def lock_options | ||||||
|  |     { redis: Redis.current, key: "create:#{@uri}", autorelease: 15.minutes.seconds } | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def detected_language | ||||||
|  |     LanguageDetector.instance.detect(@status_parser.text, @account) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def create_previous_edit! | ||||||
|  |     # We only need to create a previous edit when no previous edits exist, e.g. | ||||||
|  |     # when the status has never been edited. For other cases, we always create | ||||||
|  |     # an edit, so the step can be skipped | ||||||
|  | 
 | ||||||
|  |     return if @status.edits.any? | ||||||
|  | 
 | ||||||
|  |     @status.edits.create( | ||||||
|  |       text: @status.text, | ||||||
|  |       spoiler_text: @status.spoiler_text, | ||||||
|  |       media_attachments_changed: false, | ||||||
|  |       account_id: @account.id, | ||||||
|  |       created_at: @status.created_at | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def create_edit! | ||||||
|  |     return unless @status.text_previously_changed? || @status.spoiler_text_previously_changed? || @media_attachments_changed | ||||||
|  | 
 | ||||||
|  |     @status_edit = @status.edits.create( | ||||||
|  |       text: @status.text, | ||||||
|  |       spoiler_text: @status.spoiler_text, | ||||||
|  |       media_attachments_changed: @media_attachments_changed, | ||||||
|  |       account_id: @account.id, | ||||||
|  |       created_at: @status.edited_at | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def skip_download? | ||||||
|  |     return @skip_download if defined?(@skip_download) | ||||||
|  | 
 | ||||||
|  |     @skip_download ||= DomainBlock.reject_media?(@account.domain) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def unsupported_media_type?(mime_type) | ||||||
|  |     mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def already_updated_more_recently? | ||||||
|  |     @status.edited_at.present? && @status_parser.edited_at.present? && @status.edited_at > @status_parser.edited_at | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def reset_preview_card! | ||||||
|  |     @status.preview_cards.clear if @status.text_previously_changed? || @status.spoiler_text.present? | ||||||
|  |     LinkCrawlWorker.perform_in(rand(1..59).seconds, @status.id) if @status.spoiler_text.blank? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def broadcast_updates! | ||||||
|  |     ::DistributionWorker.perform_async(@status.id, update: true) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def queue_poll_notifications! | ||||||
|  |     poll = @status.preloadable_poll | ||||||
|  | 
 | ||||||
|  |     # If the poll had no expiration date set but now has, or now has a sooner | ||||||
|  |     # expiration date, and people have voted, schedule a notification | ||||||
|  | 
 | ||||||
|  |     return unless poll.present? && poll.expires_at.present? && poll.votes.exists? | ||||||
|  | 
 | ||||||
|  |     PollExpirationNotifyWorker.remove_from_scheduled(poll.id) if @previous_expires_at.present? && @previous_expires_at > poll.expires_at | ||||||
|  |     PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id) | ||||||
|  |   end | ||||||
|  | end | ||||||
| @ -3,118 +3,134 @@ | |||||||
| class FanOutOnWriteService < BaseService | class FanOutOnWriteService < BaseService | ||||||
|   # Push a status into home and mentions feeds |   # Push a status into home and mentions feeds | ||||||
|   # @param [Status] status |   # @param [Status] status | ||||||
|   def call(status) |   # @param [Hash] options | ||||||
|     raise Mastodon::RaceConditionError if status.visibility.nil? |   # @option options [Boolean] update | ||||||
|  |   # @option options [Array<Integer>] silenced_account_ids | ||||||
|  |   def call(status, options = {}) | ||||||
|  |     @status    = status | ||||||
|  |     @account   = status.account | ||||||
|  |     @options   = options | ||||||
| 
 | 
 | ||||||
|     deliver_to_self(status) if status.account.local? |     check_race_condition! | ||||||
| 
 | 
 | ||||||
|     if status.direct_visibility? |     fan_out_to_local_recipients! | ||||||
|       deliver_to_mentioned_followers(status) |     fan_out_to_public_streams! if broadcastable? | ||||||
|       deliver_to_direct_timelines(status) |  | ||||||
|       deliver_to_own_conversation(status) |  | ||||||
|     elsif status.limited_visibility? |  | ||||||
|       deliver_to_mentioned_followers(status) |  | ||||||
|     else |  | ||||||
|       deliver_to_followers(status) |  | ||||||
|       deliver_to_lists(status) |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     return if status.account.silenced? || !status.public_visibility? |  | ||||||
|     return if status.reblog? && !Setting.show_reblogs_in_public_timelines |  | ||||||
| 
 |  | ||||||
|     render_anonymous_payload(status) |  | ||||||
| 
 |  | ||||||
|     deliver_to_hashtags(status) |  | ||||||
| 
 |  | ||||||
|     return if status.reply? && status.in_reply_to_account_id != status.account_id && !Setting.show_replies_in_public_timelines |  | ||||||
| 
 |  | ||||||
|     deliver_to_public(status) |  | ||||||
|     deliver_to_media(status) if status.media_attachments.any? |  | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   private |   private | ||||||
| 
 | 
 | ||||||
|   def deliver_to_self(status) |   def check_race_condition! | ||||||
|     Rails.logger.debug "Delivering status #{status.id} to author" |     # I don't know why but at some point we had an issue where | ||||||
|     FeedManager.instance.push_to_home(status.account, status) |     # this service was being executed with status objects | ||||||
|     FeedManager.instance.push_to_direct(status.account, status) if status.direct_visibility? |     # that had a null visibility - which should not be possible | ||||||
|  |     # since the column in the database is not nullable. | ||||||
|  |     # | ||||||
|  |     # This check re-queues the service to be run at a later time | ||||||
|  |     # with the full object, if something like it occurs | ||||||
|  | 
 | ||||||
|  |     raise Mastodon::RaceConditionError if @status.visibility.nil? | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def deliver_to_followers(status) |   def fan_out_to_local_recipients! | ||||||
|     Rails.logger.debug "Delivering status #{status.id} to followers" |     deliver_to_self! | ||||||
|  |     notify_mentioned_accounts! | ||||||
| 
 | 
 | ||||||
|     status.account.followers_for_local_distribution.select(:id).reorder(nil).find_in_batches do |followers| |     case @status.visibility.to_sym | ||||||
|  |     when :public, :unlisted, :private | ||||||
|  |       deliver_to_all_followers! | ||||||
|  |       deliver_to_lists! | ||||||
|  |     when :limited | ||||||
|  |       deliver_to_mentioned_followers! | ||||||
|  |     else | ||||||
|  |       deliver_to_mentioned_followers! | ||||||
|  |       deliver_to_conversation! | ||||||
|  |       deliver_to_direct_timelines! | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def fan_out_to_public_streams! | ||||||
|  |     broadcast_to_hashtag_streams! | ||||||
|  |     broadcast_to_public_streams! | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def deliver_to_self! | ||||||
|  |     FeedManager.instance.push_to_home(@account, @status, update: update?) if @account.local? | ||||||
|  |     FeedManager.instance.push_to_direct(@account, @status, update: update?) if @account.local? && @status.direct_visibility? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def notify_mentioned_accounts! | ||||||
|  |     @status.active_mentions.where.not(id: @options[:silenced_account_ids] || []).joins(:account).merge(Account.local).select(:id, :account_id).reorder(nil).find_in_batches do |mentions| | ||||||
|  |       LocalNotificationWorker.push_bulk(mentions) do |mention| | ||||||
|  |         [mention.account_id, mention.id, 'Mention', :mention] | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def deliver_to_all_followers! | ||||||
|  |     @account.followers_for_local_distribution.select(:id).reorder(nil).find_in_batches do |followers| | ||||||
|       FeedInsertWorker.push_bulk(followers) do |follower| |       FeedInsertWorker.push_bulk(followers) do |follower| | ||||||
|         [status.id, follower.id, :home] |         [@status.id, follower.id, :home, update: update?] | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def deliver_to_lists(status) |   def deliver_to_lists! | ||||||
|     Rails.logger.debug "Delivering status #{status.id} to lists" |     @account.lists_for_local_distribution.select(:id).reorder(nil).find_in_batches do |lists| | ||||||
| 
 |  | ||||||
|     status.account.lists_for_local_distribution.select(:id).reorder(nil).find_in_batches do |lists| |  | ||||||
|       FeedInsertWorker.push_bulk(lists) do |list| |       FeedInsertWorker.push_bulk(lists) do |list| | ||||||
|         [status.id, list.id, :list] |         [@status.id, list.id, :list, update: update?] | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def deliver_to_mentioned_followers(status) |   def deliver_to_mentioned_followers! | ||||||
|     Rails.logger.debug "Delivering status #{status.id} to limited followers" |     @status.mentions.joins(:account).merge(@account.followers_for_local_distribution).select(:id, :account_id).reorder(nil).find_in_batches do |mentions| | ||||||
| 
 |  | ||||||
|     status.mentions.joins(:account).merge(status.account.followers_for_local_distribution).select(:id, :account_id).reorder(nil).find_in_batches do |mentions| |  | ||||||
|       FeedInsertWorker.push_bulk(mentions) do |mention| |       FeedInsertWorker.push_bulk(mentions) do |mention| | ||||||
|         [status.id, mention.account_id, :home] |         [@status.id, mention.account_id, :home, update: update?] | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def render_anonymous_payload(status) |   def deliver_to_direct_timelines! | ||||||
|     @payload = InlineRenderer.render(status, nil, :status) |     FeedInsertWorker.push_bulk(@status.mentions.includes(:account).map(&:account).select { |mentioned_account| mentioned_account.local? }) do |account| | ||||||
|     @payload = Oj.dump(event: :update, payload: @payload) |       [@status.id, account.id, :direct, update: update?] | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def deliver_to_hashtags(status) |  | ||||||
|     Rails.logger.debug "Delivering status #{status.id} to hashtags" |  | ||||||
| 
 |  | ||||||
|     status.tags.pluck(:name).each do |hashtag| |  | ||||||
|       Redis.current.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload) |  | ||||||
|       Redis.current.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if status.local? |  | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def deliver_to_public(status) |   def broadcast_to_hashtag_streams! | ||||||
|     Rails.logger.debug "Delivering status #{status.id} to public timeline" |     @status.tags.pluck(:name).each do |hashtag| | ||||||
| 
 |       Redis.current.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", anonymous_payload) | ||||||
|     Redis.current.publish('timeline:public', @payload) |       Redis.current.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", anonymous_payload) if @status.local? | ||||||
|     if status.local? |  | ||||||
|       Redis.current.publish('timeline:public:local', @payload) |  | ||||||
|     else |  | ||||||
|       Redis.current.publish('timeline:public:remote', @payload) |  | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def deliver_to_media(status) |   def broadcast_to_public_streams! | ||||||
|     Rails.logger.debug "Delivering status #{status.id} to media timeline" |     return if @status.reply? && @status.in_reply_to_account_id != @account.id && !Setting.show_replies_in_public_timelines | ||||||
| 
 | 
 | ||||||
|     Redis.current.publish('timeline:public:media', @payload) |     Redis.current.publish('timeline:public', anonymous_payload) | ||||||
|     if status.local? |     Redis.current.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', anonymous_payload) | ||||||
|       Redis.current.publish('timeline:public:local:media', @payload) | 
 | ||||||
|     else |     if @status.media_attachments.any? | ||||||
|       Redis.current.publish('timeline:public:remote:media', @payload) |       Redis.current.publish('timeline:public:media', anonymous_payload) | ||||||
|  |       Redis.current.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', anonymous_payload) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def deliver_to_direct_timelines(status) |   def deliver_to_conversation! | ||||||
|     Rails.logger.debug "Delivering status #{status.id} to direct timelines" |     AccountConversation.add_status(@account, @status) unless update? | ||||||
| 
 |  | ||||||
|     FeedInsertWorker.push_bulk(status.mentions.includes(:account).map(&:account).select { |mentioned_account| mentioned_account.local? }) do |account| |  | ||||||
|       [status.id, account.id, :direct] |  | ||||||
|     end |  | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def deliver_to_own_conversation(status) |   def anonymous_payload | ||||||
|     AccountConversation.add_status(status.account, status) |     @anonymous_payload ||= Oj.dump( | ||||||
|  |       event: update? ? :'status.update' : :update, | ||||||
|  |       payload: InlineRenderer.render(@status, nil, :status) | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def update? | ||||||
|  |     @is_update | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def broadcastable? | ||||||
|  |     @status.public_visibility? && !@account.silenced? && (!@status.reblog? || Setting.show_reblogs_in_public_timelines) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -8,12 +8,23 @@ class ProcessMentionsService < BaseService | |||||||
|   # remote users |   # remote users | ||||||
|   # @param [Status] status |   # @param [Status] status | ||||||
|   def call(status) |   def call(status) | ||||||
|     return unless status.local? |     @status = status | ||||||
| 
 | 
 | ||||||
|     @status  = status |     return unless @status.local? | ||||||
|     mentions = [] |  | ||||||
| 
 | 
 | ||||||
|     status.text = status.text.gsub(Account::MENTION_RE) do |match| |     @previous_mentions = @status.active_mentions.includes(:account).to_a | ||||||
|  |     @current_mentions  = [] | ||||||
|  | 
 | ||||||
|  |     Status.transaction do | ||||||
|  |       scan_text! | ||||||
|  |       assign_mentions! | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   private | ||||||
|  | 
 | ||||||
|  |   def scan_text! | ||||||
|  |     @status.text = @status.text.gsub(Account::MENTION_RE) do |match| | ||||||
|       username, domain = Regexp.last_match(1).split('@') |       username, domain = Regexp.last_match(1).split('@') | ||||||
| 
 | 
 | ||||||
|       domain = begin |       domain = begin | ||||||
| @ -26,49 +37,45 @@ class ProcessMentionsService < BaseService | |||||||
| 
 | 
 | ||||||
|       mentioned_account = Account.find_remote(username, domain) |       mentioned_account = Account.find_remote(username, domain) | ||||||
| 
 | 
 | ||||||
|  |       # If the account cannot be found or isn't the right protocol, | ||||||
|  |       # first try to resolve it | ||||||
|       if mention_undeliverable?(mentioned_account) |       if mention_undeliverable?(mentioned_account) | ||||||
|         begin |         begin | ||||||
|           mentioned_account = resolve_account_service.call(Regexp.last_match(1)) |           mentioned_account = ResolveAccountService.new.call(Regexp.last_match(1)) | ||||||
|         rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError |         rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError | ||||||
|           mentioned_account = nil |           mentioned_account = nil | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|  |       # If after resolving it still isn't found or isn't the right | ||||||
|  |       # protocol, then give up | ||||||
|       next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended? |       next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended? | ||||||
| 
 | 
 | ||||||
|       mention = mentioned_account.mentions.new(status: status) |       mention   = @previous_mentions.find { |x| x.account_id == mentioned_account.id } | ||||||
|       mentions << mention if mention.save |       mention ||= mentioned_account.mentions.new(status: @status) | ||||||
|  | 
 | ||||||
|  |       @current_mentions << mention | ||||||
| 
 | 
 | ||||||
|       "@#{mentioned_account.acct}" |       "@#{mentioned_account.acct}" | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     status.save! |     @status.save! | ||||||
| 
 |  | ||||||
|     mentions.each { |mention| create_notification(mention) } |  | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   private |   def assign_mentions! | ||||||
|  |     @current_mentions.each do |mention| | ||||||
|  |       mention.save if mention.new_record? | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     # If previous mentions are no longer contained in the text, convert them | ||||||
|  |     # to silent mentions, since withdrawing access from someone who already | ||||||
|  |     # received a notification might be more confusing | ||||||
|  |     removed_mentions = @previous_mentions - @current_mentions | ||||||
|  | 
 | ||||||
|  |     Mention.where(id: removed_mentions.map(&:id)).update_all(silent: true) unless removed_mentions.empty? | ||||||
|  |   end | ||||||
| 
 | 
 | ||||||
|   def mention_undeliverable?(mentioned_account) |   def mention_undeliverable?(mentioned_account) | ||||||
|     mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus?) |     mentioned_account.nil? || (!mentioned_account.local? && !mentioned_account.activitypub?) | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def create_notification(mention) |  | ||||||
|     mentioned_account = mention.account |  | ||||||
| 
 |  | ||||||
|     if mentioned_account.local? |  | ||||||
|       LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name, :mention) |  | ||||||
|     elsif mentioned_account.activitypub? && !@status.local_only? |  | ||||||
|       ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url, { synchronize_followers: !mention.status.distributable? }) |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def activitypub_json |  | ||||||
|     return @activitypub_json if defined?(@activitypub_json) |  | ||||||
|     @activitypub_json = Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account)) |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def resolve_account_service |  | ||||||
|     ResolveAccountService.new |  | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -89,7 +89,7 @@ class RemoveStatusService < BaseService | |||||||
|     # the author and wouldn't normally receive the delete |     # the author and wouldn't normally receive the delete | ||||||
|     # notification - so here, we explicitly send it to them |     # notification - so here, we explicitly send it to them | ||||||
| 
 | 
 | ||||||
|     status_reach_finder = StatusReachFinder.new(@status) |     status_reach_finder = StatusReachFinder.new(@status, unsafe: true) | ||||||
| 
 | 
 | ||||||
|     ActivityPub::DeliveryWorker.push_bulk(status_reach_finder.inboxes) do |inbox_url| |     ActivityPub::DeliveryWorker.push_bulk(status_reach_finder.inboxes) do |inbox_url| | ||||||
|       [signed_activity_json, @account.id, inbox_url] |       [signed_activity_json, @account.id, inbox_url] | ||||||
|  | |||||||
| @ -1,13 +1,22 @@ | |||||||
| = simple_form_for(new_user, url: user_session_path, namespace: 'login') do |f| | - unless omniauth_only? | ||||||
|   .fields-group |   = simple_form_for(new_user, url: user_session_path, namespace: 'login') do |f| | ||||||
|     - if use_seamless_external_login? |     .fields-group | ||||||
|       = f.input :email, placeholder: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false |       - if use_seamless_external_login? | ||||||
|     - else |         = f.input :email, placeholder: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false | ||||||
|       = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false |       - else | ||||||
|  |         = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false | ||||||
| 
 | 
 | ||||||
|     = f.input :password, placeholder: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }, hint: false |       = f.input :password, placeholder: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }, hint: false | ||||||
| 
 | 
 | ||||||
|   .actions |     .actions | ||||||
|     = f.button :button, t('auth.login'), type: :submit, class: 'button button-primary' |       = f.button :button, t('auth.login'), type: :submit, class: 'button button-primary' | ||||||
| 
 | 
 | ||||||
|   %p.hint.subtle-hint= link_to t('auth.trouble_logging_in'), new_user_password_path |     %p.hint.subtle-hint= link_to t('auth.trouble_logging_in'), new_user_password_path | ||||||
|  | 
 | ||||||
|  | - if Devise.mappings[:user].omniauthable? and User.omniauth_providers.any? | ||||||
|  |   .simple_form.alternative-login | ||||||
|  |     %h4= omniauth_only? ? t('auth.log_in_with') : t('auth.or_log_in_with') | ||||||
|  | 
 | ||||||
|  |     .actions | ||||||
|  |       - User.omniauth_providers.each do |provider| | ||||||
|  |         = provider_sign_in_link(provider) | ||||||
|  | |||||||
| @ -27,6 +27,9 @@ | |||||||
|         · |         · | ||||||
|       = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener noreferrer' do |       = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener noreferrer' do | ||||||
|         %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) |         %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) | ||||||
|  |       - if status.edited? | ||||||
|  |         · | ||||||
|  |         = t('statuses.edited_at', date: l(status.edited_at)) | ||||||
|       - if status.discarded? |       - if status.discarded? | ||||||
|         · |         · | ||||||
|         %span.negative-hint= t('admin.statuses.deleted') |         %span.negative-hint= t('admin.statuses.deleted') | ||||||
|  | |||||||
| @ -4,24 +4,25 @@ | |||||||
| - content_for :header_tags do | - content_for :header_tags do | ||||||
|   = render partial: 'shared/og' |   = render partial: 'shared/og' | ||||||
| 
 | 
 | ||||||
| = simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| | - unless omniauth_only? | ||||||
|   .fields-group |   = simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| | ||||||
|     - if use_seamless_external_login? |     .fields-group | ||||||
|       = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false |       - if use_seamless_external_login? | ||||||
|     - else |         = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false | ||||||
|       = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false |       - else | ||||||
|   .fields-group |         = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false | ||||||
|     = f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }, hint: false |     .fields-group | ||||||
|  |       = f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }, hint: false | ||||||
| 
 | 
 | ||||||
|   .actions |     .actions | ||||||
|     = f.button :button, t('auth.login'), type: :submit |       = f.button :button, t('auth.login'), type: :submit | ||||||
| 
 | 
 | ||||||
| - if devise_mapping.omniauthable? and resource_class.omniauth_providers.any? | - if devise_mapping.omniauthable? and resource_class.omniauth_providers.any? | ||||||
|   .simple_form.alternative-login |   .simple_form.alternative-login | ||||||
|     %h4= t('auth.or_log_in_with') |     %h4= omniauth_only? ? t('auth.log_in_with') : t('auth.or_log_in_with') | ||||||
| 
 | 
 | ||||||
|     .actions |     .actions | ||||||
|       - resource_class.omniauth_providers.each do |provider| |       - resource_class.omniauth_providers.each do |provider| | ||||||
|         = link_to t("auth.providers.#{provider}", default: provider.to_s.chomp("_oauth2").capitalize), omniauth_authorize_path(resource_name, provider), class: "button button-#{provider}", method: :post |         = provider_sign_in_link(provider) | ||||||
| 
 | 
 | ||||||
| .form-footer= render 'auth/shared/links' | .form-footer= render 'auth/shared/links' | ||||||
|  | |||||||
| @ -3,7 +3,7 @@ | |||||||
|     %li= link_to t('settings.account_settings'), edit_user_registration_path |     %li= link_to t('settings.account_settings'), edit_user_registration_path | ||||||
|   - else |   - else | ||||||
|     - if controller_name != 'sessions' |     - if controller_name != 'sessions' | ||||||
|       %li= link_to t('auth.login'), new_user_session_path |       %li= link_to_login t('auth.login') | ||||||
| 
 | 
 | ||||||
|     - if controller_name != 'registrations' |     - if controller_name != 'registrations' | ||||||
|       %li= link_to t('auth.register'), available_sign_up_path |       %li= link_to t('auth.register'), available_sign_up_path | ||||||
|  | |||||||
| @ -21,7 +21,7 @@ | |||||||
|             - if user_signed_in? |             - if user_signed_in? | ||||||
|               = link_to t('settings.back'), root_url, class: 'nav-link nav-button webapp-btn' |               = link_to t('settings.back'), root_url, class: 'nav-link nav-button webapp-btn' | ||||||
|             - else |             - else | ||||||
|               = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn nav-link nav-button' |               = link_to_login t('auth.login'), class: 'webapp-btn nav-link nav-button' | ||||||
|               = link_to t('auth.register'), available_sign_up_path, class: 'webapp-btn nav-link nav-button' |               = link_to t('auth.register'), available_sign_up_path, class: 'webapp-btn nav-link nav-button' | ||||||
| 
 | 
 | ||||||
|     .container= yield |     .container= yield | ||||||
|  | |||||||
| @ -37,10 +37,15 @@ | |||||||
| 
 | 
 | ||||||
|   .detailed-status__meta |   .detailed-status__meta | ||||||
|     %data.dt-published{ value: status.created_at.to_time.iso8601 } |     %data.dt-published{ value: status.created_at.to_time.iso8601 } | ||||||
|  |     - if status.edited? | ||||||
|  |       %data.dt-updated{ value: status.edited_at.to_time.iso8601 } | ||||||
| 
 | 
 | ||||||
|     = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: stream_link_target, rel: 'noopener noreferrer' do |     = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: stream_link_target, rel: 'noopener noreferrer' do | ||||||
|       %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) |       %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) | ||||||
|     · |     · | ||||||
|  |     - if status.edited? | ||||||
|  |       = t('statuses.edited_at', date: l(status.edited_at)) | ||||||
|  |       · | ||||||
|     %span.detailed-status__visibility-icon |     %span.detailed-status__visibility-icon | ||||||
|       = visibility_icon status |       = visibility_icon status | ||||||
|     · |     · | ||||||
|  | |||||||
| @ -7,6 +7,9 @@ | |||||||
|       %span.status__visibility-icon>< |       %span.status__visibility-icon>< | ||||||
|         = visibility_icon status |         = visibility_icon status | ||||||
|       %time.time-ago{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) |       %time.time-ago{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) | ||||||
|  |       - if status.edited? | ||||||
|  |         %abbr{ title: t('statuses.edited_at', date: l(status.edited_at.to_date)) } | ||||||
|  |           * | ||||||
|     %data.dt-published{ value: status.created_at.to_time.iso8601 } |     %data.dt-published{ value: status.created_at.to_time.iso8601 } | ||||||
| 
 | 
 | ||||||
|     .p-author.h-card |     .p-author.h-card | ||||||
|  | |||||||
| @ -56,6 +56,6 @@ | |||||||
| 
 | 
 | ||||||
| - if include_threads && !embedded_view? && !user_signed_in? | - if include_threads && !embedded_view? && !user_signed_in? | ||||||
|   .entry{ class: entry_classes } |   .entry{ class: entry_classes } | ||||||
|     = link_to new_user_session_path, class: 'load-more load-gap' do |     = link_to_login class: 'load-more load-gap' do | ||||||
|       = fa_icon 'comments' |       = fa_icon 'comments' | ||||||
|       = t('statuses.sign_in_to_participate') |       = t('statuses.sign_in_to_participate') | ||||||
|  | |||||||
| @ -1,54 +1,32 @@ | |||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class ActivityPub::DistributionWorker | class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker | ||||||
|   include Sidekiq::Worker |   # Distribute a new status or an edit of a status to all the places | ||||||
|   include Payloadable |   # where the status is supposed to go or where it was interacted with | ||||||
| 
 |  | ||||||
|   sidekiq_options queue: 'push' |  | ||||||
| 
 |  | ||||||
|   def perform(status_id) |   def perform(status_id) | ||||||
|     @status  = Status.find(status_id) |     @status  = Status.find(status_id) | ||||||
|     @account = @status.account |     @account = @status.account | ||||||
| 
 | 
 | ||||||
|     return if skip_distribution? |     distribute! | ||||||
| 
 |  | ||||||
|     ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| |  | ||||||
|       [payload, @account.id, inbox_url, { synchronize_followers: !@status.distributable? }] |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     relay! if relayable? |  | ||||||
|   rescue ActiveRecord::RecordNotFound |   rescue ActiveRecord::RecordNotFound | ||||||
|     true |     true | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   private |   protected | ||||||
| 
 |  | ||||||
|   def skip_distribution? |  | ||||||
|     @status.direct_visibility? || @status.limited_visibility? |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def relayable? |  | ||||||
|     @status.public_visibility? |  | ||||||
|   end |  | ||||||
| 
 | 
 | ||||||
|   def inboxes |   def inboxes | ||||||
|     # Deliver the status to all followers. |     @inboxes ||= StatusReachFinder.new(@status).inboxes | ||||||
|     # If the status is a reply to another local status, also forward it to that |  | ||||||
|     # status' authors' followers. |  | ||||||
|     @inboxes ||= if @status.in_reply_to_local_account? && @status.distributable? |  | ||||||
|                    @account.followers.or(@status.thread.account.followers).inboxes |  | ||||||
|                  else |  | ||||||
|                    @account.followers.inboxes |  | ||||||
|                  end |  | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def payload |   def payload | ||||||
|     @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @account)) |     @payload ||= Oj.dump(serialize_payload(activity, ActivityPub::ActivitySerializer, signer: @account)) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def relay! |   def activity | ||||||
|     ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url| |     ActivityPub::ActivityPresenter.from_status(@status) | ||||||
|       [payload, @account.id, inbox_url] |   end | ||||||
|     end | 
 | ||||||
|  |   def options | ||||||
|  |     { synchronize_followers: @status.private_visibility? } | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -2,22 +2,47 @@ | |||||||
| 
 | 
 | ||||||
| class ActivityPub::RawDistributionWorker | class ActivityPub::RawDistributionWorker | ||||||
|   include Sidekiq::Worker |   include Sidekiq::Worker | ||||||
|  |   include Payloadable | ||||||
| 
 | 
 | ||||||
|   sidekiq_options queue: 'push' |   sidekiq_options queue: 'push' | ||||||
| 
 | 
 | ||||||
|  |   # Base worker for when you want to queue up a bunch of deliveries of | ||||||
|  |   # some payload. In this case, we have already generated JSON and | ||||||
|  |   # we are going to distribute it to the account's followers minus | ||||||
|  |   # the explicitly provided inboxes | ||||||
|   def perform(json, source_account_id, exclude_inboxes = []) |   def perform(json, source_account_id, exclude_inboxes = []) | ||||||
|     @account = Account.find(source_account_id) |     @account         = Account.find(source_account_id) | ||||||
|  |     @json            = json | ||||||
|  |     @exclude_inboxes = exclude_inboxes | ||||||
| 
 | 
 | ||||||
|     ActivityPub::DeliveryWorker.push_bulk(inboxes - exclude_inboxes) do |inbox_url| |     distribute! | ||||||
|       [json, @account.id, inbox_url] |  | ||||||
|     end |  | ||||||
|   rescue ActiveRecord::RecordNotFound |   rescue ActiveRecord::RecordNotFound | ||||||
|     true |     true | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   private |   protected | ||||||
|  | 
 | ||||||
|  |   def distribute! | ||||||
|  |     return if inboxes.empty? | ||||||
|  | 
 | ||||||
|  |     ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| | ||||||
|  |       [payload, source_account_id, inbox_url, options] | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def payload | ||||||
|  |     @json | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def source_account_id | ||||||
|  |     @account.id | ||||||
|  |   end | ||||||
| 
 | 
 | ||||||
|   def inboxes |   def inboxes | ||||||
|     @inboxes ||= @account.followers.inboxes |     @inboxes ||= @account.followers.inboxes - @exclude_inboxes | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def options | ||||||
|  |     {} | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -1,34 +0,0 @@ | |||||||
| # frozen_string_literal: true |  | ||||||
| 
 |  | ||||||
| # Obsolete but kept around to make sure existing jobs do not fail after upgrade. |  | ||||||
| # Should be removed in a subsequent release. |  | ||||||
| 
 |  | ||||||
| class ActivityPub::ReplyDistributionWorker |  | ||||||
|   include Sidekiq::Worker |  | ||||||
|   include Payloadable |  | ||||||
| 
 |  | ||||||
|   sidekiq_options queue: 'push' |  | ||||||
| 
 |  | ||||||
|   def perform(status_id) |  | ||||||
|     @status  = Status.find(status_id) |  | ||||||
|     @account = @status.thread&.account |  | ||||||
| 
 |  | ||||||
|     return unless @account.present? && @status.distributable? |  | ||||||
| 
 |  | ||||||
|     ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| |  | ||||||
|       [payload, @status.account_id, inbox_url] |  | ||||||
|     end |  | ||||||
|   rescue ActiveRecord::RecordNotFound |  | ||||||
|     true |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   private |  | ||||||
| 
 |  | ||||||
|   def inboxes |  | ||||||
|     @inboxes ||= @account.followers.inboxes |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def payload |  | ||||||
|     @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account)) |  | ||||||
|   end |  | ||||||
| end |  | ||||||
| @ -1,33 +1,24 @@ | |||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class ActivityPub::UpdateDistributionWorker | class ActivityPub::UpdateDistributionWorker < ActivityPub::RawDistributionWorker | ||||||
|   include Sidekiq::Worker |   # Distribute an profile update to servers that might have a copy | ||||||
|   include Payloadable |   # of the account in question | ||||||
| 
 |  | ||||||
|   sidekiq_options queue: 'push' |  | ||||||
| 
 |  | ||||||
|   def perform(account_id, options = {}) |   def perform(account_id, options = {}) | ||||||
|     @options = options.with_indifferent_access |     @options = options.with_indifferent_access | ||||||
|     @account = Account.find(account_id) |     @account = Account.find(account_id) | ||||||
| 
 | 
 | ||||||
|     ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| |     distribute! | ||||||
|       [signed_payload, @account.id, inbox_url] |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url| |  | ||||||
|       [signed_payload, @account.id, inbox_url] |  | ||||||
|     end |  | ||||||
|   rescue ActiveRecord::RecordNotFound |   rescue ActiveRecord::RecordNotFound | ||||||
|     true |     true | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   private |   protected | ||||||
| 
 | 
 | ||||||
|   def inboxes |   def inboxes | ||||||
|     @inboxes ||= @account.followers.inboxes |     @inboxes ||= AccountReachFinder.new(@account).inboxes | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def signed_payload |   def payload | ||||||
|     @signed_payload ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account, sign_with: @options[:sign_with])) |     @payload ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account, sign_with: @options[:sign_with])) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -3,10 +3,10 @@ | |||||||
| class DistributionWorker | class DistributionWorker | ||||||
|   include Sidekiq::Worker |   include Sidekiq::Worker | ||||||
| 
 | 
 | ||||||
|   def perform(status_id) |   def perform(status_id, options = {}) | ||||||
|     RedisLock.acquire(redis: Redis.current, key: "distribute:#{status_id}", autorelease: 5.minutes.seconds) do |lock| |     RedisLock.acquire(redis: Redis.current, key: "distribute:#{status_id}", autorelease: 5.minutes.seconds) do |lock| | ||||||
|       if lock.acquired? |       if lock.acquired? | ||||||
|         FanOutOnWriteService.new.call(Status.find(status_id)) |         FanOutOnWriteService.new.call(Status.find(status_id), **options.symbolize_keys) | ||||||
|       else |       else | ||||||
|         raise Mastodon::RaceConditionError |         raise Mastodon::RaceConditionError | ||||||
|       end |       end | ||||||
|  | |||||||
| @ -3,9 +3,10 @@ | |||||||
| class FeedInsertWorker | class FeedInsertWorker | ||||||
|   include Sidekiq::Worker |   include Sidekiq::Worker | ||||||
| 
 | 
 | ||||||
|   def perform(status_id, id, type = :home) |   def perform(status_id, id, type = :home, options = {}) | ||||||
|     @type     = type.to_sym |     @type      = type.to_sym | ||||||
|     @status   = Status.find(status_id) |     @status    = Status.find(status_id) | ||||||
|  |     @options   = options.symbolize_keys | ||||||
| 
 | 
 | ||||||
|     case @type |     case @type | ||||||
|     when :home |     when :home | ||||||
| @ -25,10 +26,12 @@ class FeedInsertWorker | |||||||
|   private |   private | ||||||
| 
 | 
 | ||||||
|   def check_and_insert |   def check_and_insert | ||||||
|     return if feed_filtered? |     if feed_filtered? | ||||||
| 
 |       perform_unpush if update? | ||||||
|     perform_push |     else | ||||||
|     perform_notify if notify? |       perform_push | ||||||
|  |       perform_notify if notify? | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def feed_filtered? |   def feed_filtered? | ||||||
| @ -51,15 +54,30 @@ class FeedInsertWorker | |||||||
|   def perform_push |   def perform_push | ||||||
|     case @type |     case @type | ||||||
|     when :home |     when :home | ||||||
|       FeedManager.instance.push_to_home(@follower, @status) |       FeedManager.instance.push_to_home(@follower, @status, update: update?) | ||||||
|     when :list |     when :list | ||||||
|       FeedManager.instance.push_to_list(@list, @status) |       FeedManager.instance.push_to_list(@list, @status, update: update?) | ||||||
|     when :direct |     when :direct | ||||||
|       FeedManager.instance.push_to_direct(@account, @status) |       FeedManager.instance.push_to_direct(@account, @status, update: update?) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def perform_unpush | ||||||
|  |     case @type | ||||||
|  |     when :home | ||||||
|  |       FeedManager.instance.unpush_from_home(@follower, @status, update: true) | ||||||
|  |     when :list | ||||||
|  |       FeedManager.instance.unpush_from_list(@list, @status, update: true) | ||||||
|  |     when :direct | ||||||
|  |       FeedManager.instance.unpush_from_direct(@account, @status, update: true) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def perform_notify |   def perform_notify | ||||||
|     NotifyService.new.call(@follower, :status, @status) |     NotifyService.new.call(@follower, :status, @status) | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   def update? | ||||||
|  |     @options[:update] | ||||||
|  |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -12,6 +12,8 @@ class LocalNotificationWorker | |||||||
|       activity = activity_class_name.constantize.find(activity_id) |       activity = activity_class_name.constantize.find(activity_id) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  |     return if Notification.where(account: receiver, activity: activity).any? | ||||||
|  | 
 | ||||||
|     NotifyService.new.call(receiver, type || activity_class_name.underscore, activity) |     NotifyService.new.call(receiver, type || activity_class_name.underscore, activity) | ||||||
|   rescue ActiveRecord::RecordNotFound |   rescue ActiveRecord::RecordNotFound | ||||||
|     true |     true | ||||||
|  | |||||||
| @ -6,19 +6,44 @@ class PollExpirationNotifyWorker | |||||||
|   sidekiq_options lock: :until_executed |   sidekiq_options lock: :until_executed | ||||||
| 
 | 
 | ||||||
|   def perform(poll_id) |   def perform(poll_id) | ||||||
|     poll = Poll.find(poll_id) |     @poll = Poll.find(poll_id) | ||||||
| 
 | 
 | ||||||
|     # Notify poll owner and remote voters |     return if does_not_expire? | ||||||
|     if poll.local? |     requeue! && return if not_due_yet? | ||||||
|       ActivityPub::DistributePollUpdateWorker.perform_async(poll.status.id) |  | ||||||
|       NotifyService.new.call(poll.account, :poll, poll) |  | ||||||
|     end |  | ||||||
| 
 | 
 | ||||||
|     # Notify local voters |     notify_remote_voters_and_owner! if @poll.local? | ||||||
|     poll.votes.includes(:account).group(:account_id).select(:account_id).map(&:account).select(&:local?).each do |account| |     notify_local_voters! | ||||||
|       NotifyService.new.call(account, :poll, poll) |  | ||||||
|     end |  | ||||||
|   rescue ActiveRecord::RecordNotFound |   rescue ActiveRecord::RecordNotFound | ||||||
|     true |     true | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   def self.remove_from_scheduled(poll_id) | ||||||
|  |     queue = Sidekiq::ScheduledSet.new | ||||||
|  |     queue.select { |scheduled| scheduled.klass == name && scheduled.args[0] == poll_id }.map(&:delete) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   private | ||||||
|  | 
 | ||||||
|  |   def does_not_expire? | ||||||
|  |     @poll.expires_at.nil? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def not_due_yet? | ||||||
|  |     @poll.expires_at.present? && !@poll.expired? | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def requeue! | ||||||
|  |     PollExpirationNotifyWorker.perform_at(@poll.expires_at + 5.minutes, @poll.id) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def notify_remote_voters_and_owner! | ||||||
|  |     ActivityPub::DistributePollUpdateWorker.perform_async(@poll.status.id) | ||||||
|  |     NotifyService.new.call(@poll.account, :poll, @poll) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def notify_local_voters! | ||||||
|  |     @poll.voters.merge(Account.local).find_each do |account| | ||||||
|  |       NotifyService.new.call(account, :poll, @poll) | ||||||
|  |     end | ||||||
|  |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -2,15 +2,38 @@ | |||||||
| 
 | 
 | ||||||
| class PushUpdateWorker | class PushUpdateWorker | ||||||
|   include Sidekiq::Worker |   include Sidekiq::Worker | ||||||
|  |   include Redisable | ||||||
| 
 | 
 | ||||||
|   def perform(account_id, status_id, timeline_id = nil) |   def perform(account_id, status_id, timeline_id = nil, options = {}) | ||||||
|     account     = Account.find(account_id) |     @account     = Account.find(account_id) | ||||||
|     status      = Status.find(status_id) |     @status      = Status.find(status_id) | ||||||
|     message     = InlineRenderer.render(status, account, :status) |     @timeline_id = timeline_id || "timeline:#{account.id}" | ||||||
|     timeline_id = "timeline:#{account.id}" if timeline_id.nil? |     @options     = options.symbolize_keys | ||||||
| 
 | 
 | ||||||
|     Redis.current.publish(timeline_id, Oj.dump(event: :update, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i)) |     publish! | ||||||
|   rescue ActiveRecord::RecordNotFound |   rescue ActiveRecord::RecordNotFound | ||||||
|     true |     true | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   private | ||||||
|  | 
 | ||||||
|  |   def payload | ||||||
|  |     InlineRenderer.render(@status, @account, :status) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def message | ||||||
|  |     Oj.dump( | ||||||
|  |       event: update? ? :'status.update' : :update, | ||||||
|  |       payload: payload, | ||||||
|  |       queued_at: (Time.now.to_f * 1000.0).to_i | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def publish! | ||||||
|  |     redis.publish(@timeline_id, message) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def update? | ||||||
|  |     @options[:update] | ||||||
|  |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -60,46 +60,6 @@ | |||||||
|       "confidence": "High", |       "confidence": "High", | ||||||
|       "note": "" |       "note": "" | ||||||
|     }, |     }, | ||||||
|     { |  | ||||||
|       "warning_type": "SQL Injection", |  | ||||||
|       "warning_code": 0, |  | ||||||
|       "fingerprint": "6e4051854bb62e2ddbc671f82d6c2328892e1134b8b28105ecba9b0122540714", |  | ||||||
|       "check_name": "SQL", |  | ||||||
|       "message": "Possible SQL injection", |  | ||||||
|       "file": "app/models/account.rb", |  | ||||||
|       "line": 484, |  | ||||||
|       "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", |  | ||||||
|       "code": "find_by_sql([\"          WITH first_degree AS (\\n            SELECT target_account_id\\n            FROM follows\\n            WHERE account_id = ?\\n            UNION ALL\\n            SELECT ?\\n          )\\n          SELECT\\n            accounts.*,\\n            (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n          FROM accounts\\n          LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?)\\n          WHERE accounts.id IN (SELECT * FROM first_degree)\\n            AND #{query} @@ #{textsearch}\\n            AND accounts.suspended_at IS NULL\\n            AND accounts.moved_to_account_id IS NULL\\n          GROUP BY accounts.id\\n          ORDER BY rank DESC\\n          LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, account.id, limit, offset])", |  | ||||||
|       "render_path": null, |  | ||||||
|       "location": { |  | ||||||
|         "type": "method", |  | ||||||
|         "class": "Account", |  | ||||||
|         "method": "advanced_search_for" |  | ||||||
|       }, |  | ||||||
|       "user_input": "textsearch", |  | ||||||
|       "confidence": "Medium", |  | ||||||
|       "note": "" |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       "warning_type": "SQL Injection", |  | ||||||
|       "warning_code": 0, |  | ||||||
|       "fingerprint": "6f075c1484908e3ec9bed21ab7cf3c7866be8da3881485d1c82e13093aefcbd7", |  | ||||||
|       "check_name": "SQL", |  | ||||||
|       "message": "Possible SQL injection", |  | ||||||
|       "file": "app/models/status.rb", |  | ||||||
|       "line": 105, |  | ||||||
|       "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", |  | ||||||
|       "code": "result.joins(\"LEFT OUTER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}\")", |  | ||||||
|       "render_path": null, |  | ||||||
|       "location": { |  | ||||||
|         "type": "method", |  | ||||||
|         "class": "Status", |  | ||||||
|         "method": null |  | ||||||
|       }, |  | ||||||
|       "user_input": "id", |  | ||||||
|       "confidence": "Weak", |  | ||||||
|       "note": "" |  | ||||||
|     }, |  | ||||||
|     { |     { | ||||||
|       "warning_type": "SQL Injection", |       "warning_type": "SQL Injection", | ||||||
|       "warning_code": 0, |       "warning_code": 0, | ||||||
| @ -180,26 +140,6 @@ | |||||||
|       "confidence": "Medium", |       "confidence": "Medium", | ||||||
|       "note": "" |       "note": "" | ||||||
|     }, |     }, | ||||||
|     { |  | ||||||
|       "warning_type": "SQL Injection", |  | ||||||
|       "warning_code": 0, |  | ||||||
|       "fingerprint": "9251d682c4e2840e1b2fea91e7d758efe2097ecb7f6255c065e3750d25eb178c", |  | ||||||
|       "check_name": "SQL", |  | ||||||
|       "message": "Possible SQL injection", |  | ||||||
|       "file": "app/models/account.rb", |  | ||||||
|       "line": 453, |  | ||||||
|       "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", |  | ||||||
|       "code": "find_by_sql([\"        SELECT\\n          accounts.*,\\n          ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n        FROM accounts\\n        WHERE #{query} @@ #{textsearch}\\n          AND accounts.suspended_at IS NULL\\n          AND accounts.moved_to_account_id IS NULL\\n        ORDER BY rank DESC\\n        LIMIT ? OFFSET ?\\n\".squish, limit, offset])", |  | ||||||
|       "render_path": null, |  | ||||||
|       "location": { |  | ||||||
|         "type": "method", |  | ||||||
|         "class": "Account", |  | ||||||
|         "method": "search_for" |  | ||||||
|       }, |  | ||||||
|       "user_input": "textsearch", |  | ||||||
|       "confidence": "Medium", |  | ||||||
|       "note": "" |  | ||||||
|     }, |  | ||||||
|     { |     { | ||||||
|       "warning_type": "Redirect", |       "warning_type": "Redirect", | ||||||
|       "warning_code": 18, |       "warning_code": 18, | ||||||
| @ -270,26 +210,6 @@ | |||||||
|       "confidence": "Weak", |       "confidence": "Weak", | ||||||
|       "note": "" |       "note": "" | ||||||
|     }, |     }, | ||||||
|     { |  | ||||||
|       "warning_type": "SQL Injection", |  | ||||||
|       "warning_code": 0, |  | ||||||
|       "fingerprint": "e21d8fee7a5805761679877ca35ed1029c64c45ef3b4012a30262623e1ba8bb9", |  | ||||||
|       "check_name": "SQL", |  | ||||||
|       "message": "Possible SQL injection", |  | ||||||
|       "file": "app/models/account.rb", |  | ||||||
|       "line": 500, |  | ||||||
|       "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", |  | ||||||
|       "code": "find_by_sql([\"          SELECT\\n            accounts.*,\\n            (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank\\n          FROM accounts\\n          LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)\\n          WHERE #{query} @@ #{textsearch}\\n            AND accounts.suspended_at IS NULL\\n            AND accounts.moved_to_account_id IS NULL\\n          GROUP BY accounts.id\\n          ORDER BY rank DESC\\n          LIMIT ? OFFSET ?\\n\".squish, account.id, account.id, limit, offset])", |  | ||||||
|       "render_path": null, |  | ||||||
|       "location": { |  | ||||||
|         "type": "method", |  | ||||||
|         "class": "Account", |  | ||||||
|         "method": "advanced_search_for" |  | ||||||
|       }, |  | ||||||
|       "user_input": "textsearch", |  | ||||||
|       "confidence": "Medium", |  | ||||||
|       "note": "" |  | ||||||
|     }, |  | ||||||
|     { |     { | ||||||
|       "warning_type": "Mass Assignment", |       "warning_type": "Mass Assignment", | ||||||
|       "warning_code": 105, |       "warning_code": 105, | ||||||
|  | |||||||
| @ -5,7 +5,6 @@ end | |||||||
| Devise.setup do |config| | Devise.setup do |config| | ||||||
|   # Devise omniauth strategies |   # Devise omniauth strategies | ||||||
|   options = {} |   options = {} | ||||||
|   options[:redirect_at_sign_in] = ENV['OAUTH_REDIRECT_AT_SIGN_IN'] == 'true' |  | ||||||
| 
 | 
 | ||||||
|   # CAS strategy |   # CAS strategy | ||||||
|   if ENV['CAS_ENABLED'] == 'true' |   if ENV['CAS_ENABLED'] == 'true' | ||||||
|  | |||||||
| @ -844,6 +844,7 @@ en: | |||||||
|     invalid_reset_password_token: Password reset token is invalid or expired. Please request a new one. |     invalid_reset_password_token: Password reset token is invalid or expired. Please request a new one. | ||||||
|     link_to_otp: Enter a two-factor code from your phone or a recovery code |     link_to_otp: Enter a two-factor code from your phone or a recovery code | ||||||
|     link_to_webauth: Use your security key device |     link_to_webauth: Use your security key device | ||||||
|  |     log_in_with: Log in with | ||||||
|     login: Log in |     login: Log in | ||||||
|     logout: Logout |     logout: Logout | ||||||
|     migrate_account: Move to a different account |     migrate_account: Move to a different account | ||||||
| @ -1309,6 +1310,7 @@ en: | |||||||
|     disallowed_hashtags: |     disallowed_hashtags: | ||||||
|       one: 'contained a disallowed hashtag: %{tags}' |       one: 'contained a disallowed hashtag: %{tags}' | ||||||
|       other: 'contained the disallowed hashtags: %{tags}' |       other: 'contained the disallowed hashtags: %{tags}' | ||||||
|  |     edited_at: Edited %{date} | ||||||
|     errors: |     errors: | ||||||
|       in_reply_not_found: The post you are trying to reply to does not appear to exist. |       in_reply_not_found: The post you are trying to reply to does not appear to exist. | ||||||
|     language_detection: Automatically detect language |     language_detection: Automatically detect language | ||||||
|  | |||||||
| @ -352,6 +352,9 @@ Rails.application.routes.draw do | |||||||
| 
 | 
 | ||||||
|           resource :pin, only: :create |           resource :pin, only: :create | ||||||
|           post :unpin, to: 'pins#destroy' |           post :unpin, to: 'pins#destroy' | ||||||
|  | 
 | ||||||
|  |           resource :history, only: :show | ||||||
|  |           resource :source, only: :show | ||||||
|         end |         end | ||||||
| 
 | 
 | ||||||
|         member do |         member do | ||||||
|  | |||||||
							
								
								
									
										5
									
								
								db/migrate/20210904215403_add_edited_at_to_statuses.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								db/migrate/20210904215403_add_edited_at_to_statuses.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | |||||||
|  | class AddEditedAtToStatuses < ActiveRecord::Migration[6.1] | ||||||
|  |   def change | ||||||
|  |     add_column :statuses, :edited_at, :datetime | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										13
									
								
								db/migrate/20210908220918_create_status_edits.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								db/migrate/20210908220918_create_status_edits.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | |||||||
|  | class CreateStatusEdits < ActiveRecord::Migration[6.1] | ||||||
|  |   def change | ||||||
|  |     create_table :status_edits do |t| | ||||||
|  |       t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade } | ||||||
|  |       t.belongs_to :account, null: true, foreign_key: { on_delete: :nullify } | ||||||
|  |       t.text :text, null: false, default: '' | ||||||
|  |       t.text :spoiler_text, null: false, default: '' | ||||||
|  |       t.boolean :media_attachments_changed, null: false, default: false | ||||||
|  | 
 | ||||||
|  |       t.timestamps | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
| @ -0,0 +1,9 @@ | |||||||
|  | class RemoveMentionsStatusIdIndex < ActiveRecord::Migration[6.1] | ||||||
|  |   def up | ||||||
|  |     remove_index :mentions, name: :mentions_status_id_index if index_exists?(:mentions, :status_id, name: :mentions_status_id_index) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def down | ||||||
|  |     # As this index should not exist and is a duplicate of another index, do not re-create it | ||||||
|  |   end | ||||||
|  | end | ||||||
| @ -0,0 +1,13 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class RemoveIndexUsersOnRememberToken < ActiveRecord::Migration[6.1] | ||||||
|  |   disable_ddl_transaction! | ||||||
|  | 
 | ||||||
|  |   def up | ||||||
|  |     remove_index :users, name: :index_users_on_remember_token | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def down | ||||||
|  |     add_index :users, :remember_token, algorithm: :concurrently, unique: true, name: :index_users_on_remember_token | ||||||
|  |   end | ||||||
|  | end | ||||||
| @ -0,0 +1,8 @@ | |||||||
|  | class RemoveRememberableFromUsers < ActiveRecord::Migration[6.1] | ||||||
|  |   def change | ||||||
|  |     safety_assured do | ||||||
|  |       remove_column :users, :remember_token, :string, null: true, default: nil | ||||||
|  |       remove_column :users, :remember_created_at, :datetime, null: true, default: nil | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										20
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								db/schema.rb
									
									
									
									
									
								
							| @ -10,7 +10,7 @@ | |||||||
| # | # | ||||||
| # It's strongly recommended that you check this file into your version control system. | # It's strongly recommended that you check this file into your version control system. | ||||||
| 
 | 
 | ||||||
| ActiveRecord::Schema.define(version: 2022_01_16_202951) do | ActiveRecord::Schema.define(version: 2022_01_18_183123) do | ||||||
| 
 | 
 | ||||||
|   # These are extensions that must be enabled in order to support this database |   # These are extensions that must be enabled in order to support this database | ||||||
|   enable_extension "plpgsql" |   enable_extension "plpgsql" | ||||||
| @ -816,6 +816,18 @@ ActiveRecord::Schema.define(version: 2022_01_16_202951) do | |||||||
|     t.index ["var"], name: "index_site_uploads_on_var", unique: true |     t.index ["var"], name: "index_site_uploads_on_var", unique: true | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   create_table "status_edits", force: :cascade do |t| | ||||||
|  |     t.bigint "status_id", null: false | ||||||
|  |     t.bigint "account_id" | ||||||
|  |     t.text "text", default: "", null: false | ||||||
|  |     t.text "spoiler_text", default: "", null: false | ||||||
|  |     t.boolean "media_attachments_changed", default: false, null: false | ||||||
|  |     t.datetime "created_at", precision: 6, null: false | ||||||
|  |     t.datetime "updated_at", precision: 6, null: false | ||||||
|  |     t.index ["account_id"], name: "index_status_edits_on_account_id" | ||||||
|  |     t.index ["status_id"], name: "index_status_edits_on_status_id" | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   create_table "status_pins", force: :cascade do |t| |   create_table "status_pins", force: :cascade do |t| | ||||||
|     t.bigint "account_id", null: false |     t.bigint "account_id", null: false | ||||||
|     t.bigint "status_id", null: false |     t.bigint "status_id", null: false | ||||||
| @ -856,6 +868,7 @@ ActiveRecord::Schema.define(version: 2022_01_16_202951) do | |||||||
|     t.bigint "poll_id" |     t.bigint "poll_id" | ||||||
|     t.string "content_type" |     t.string "content_type" | ||||||
|     t.datetime "deleted_at" |     t.datetime "deleted_at" | ||||||
|  |     t.datetime "edited_at" | ||||||
|     t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" |     t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" | ||||||
|     t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)" |     t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)" | ||||||
|     t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" |     t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" | ||||||
| @ -926,7 +939,6 @@ ActiveRecord::Schema.define(version: 2022_01_16_202951) do | |||||||
|     t.string "encrypted_password", default: "", null: false |     t.string "encrypted_password", default: "", null: false | ||||||
|     t.string "reset_password_token" |     t.string "reset_password_token" | ||||||
|     t.datetime "reset_password_sent_at" |     t.datetime "reset_password_sent_at" | ||||||
|     t.datetime "remember_created_at" |  | ||||||
|     t.integer "sign_in_count", default: 0, null: false |     t.integer "sign_in_count", default: 0, null: false | ||||||
|     t.datetime "current_sign_in_at" |     t.datetime "current_sign_in_at" | ||||||
|     t.datetime "last_sign_in_at" |     t.datetime "last_sign_in_at" | ||||||
| @ -948,7 +960,6 @@ ActiveRecord::Schema.define(version: 2022_01_16_202951) do | |||||||
|     t.boolean "disabled", default: false, null: false |     t.boolean "disabled", default: false, null: false | ||||||
|     t.boolean "moderator", default: false, null: false |     t.boolean "moderator", default: false, null: false | ||||||
|     t.bigint "invite_id" |     t.bigint "invite_id" | ||||||
|     t.string "remember_token" |  | ||||||
|     t.string "chosen_languages", array: true |     t.string "chosen_languages", array: true | ||||||
|     t.bigint "created_by_application_id" |     t.bigint "created_by_application_id" | ||||||
|     t.boolean "approved", default: true, null: false |     t.boolean "approved", default: true, null: false | ||||||
| @ -961,7 +972,6 @@ ActiveRecord::Schema.define(version: 2022_01_16_202951) do | |||||||
|     t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true |     t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true | ||||||
|     t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id" |     t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id" | ||||||
|     t.index ["email"], name: "index_users_on_email", unique: true |     t.index ["email"], name: "index_users_on_email", unique: true | ||||||
|     t.index ["remember_token"], name: "index_users_on_remember_token", unique: true |  | ||||||
|     t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true |     t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
| @ -1083,6 +1093,8 @@ ActiveRecord::Schema.define(version: 2022_01_16_202951) do | |||||||
|   add_foreign_key "scheduled_statuses", "accounts", on_delete: :cascade |   add_foreign_key "scheduled_statuses", "accounts", on_delete: :cascade | ||||||
|   add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade |   add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade | ||||||
|   add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade |   add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade | ||||||
|  |   add_foreign_key "status_edits", "accounts", on_delete: :nullify | ||||||
|  |   add_foreign_key "status_edits", "statuses", on_delete: :cascade | ||||||
|   add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade |   add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade | ||||||
|   add_foreign_key "status_pins", "statuses", on_delete: :cascade |   add_foreign_key "status_pins", "statuses", on_delete: :cascade | ||||||
|   add_foreign_key "status_stats", "statuses", on_delete: :cascade |   add_foreign_key "status_stats", "statuses", on_delete: :cascade | ||||||
|  | |||||||
| @ -14,7 +14,7 @@ module Mastodon | |||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     MIN_SUPPORTED_VERSION = 2019_10_01_213028 |     MIN_SUPPORTED_VERSION = 2019_10_01_213028 | ||||||
|     MAX_SUPPORTED_VERSION = 2021_05_26_193025 |     MAX_SUPPORTED_VERSION = 2022_01_18_183123 | ||||||
| 
 | 
 | ||||||
|     # Stubs to enjoy ActiveRecord queries while not depending on a particular |     # Stubs to enjoy ActiveRecord queries while not depending on a particular | ||||||
|     # version of the code/database |     # version of the code/database | ||||||
| @ -84,13 +84,14 @@ module Mastodon | |||||||
| 
 | 
 | ||||||
|         owned_classes = [ |         owned_classes = [ | ||||||
|           Status, StatusPin, MediaAttachment, Poll, Report, Tombstone, Favourite, |           Status, StatusPin, MediaAttachment, Poll, Report, Tombstone, Favourite, | ||||||
|           Follow, FollowRequest, Block, Mute, AccountIdentityProof, |           Follow, FollowRequest, Block, Mute, | ||||||
|           AccountModerationNote, AccountPin, AccountStat, ListAccount, |           AccountModerationNote, AccountPin, AccountStat, ListAccount, | ||||||
|           PollVote, Mention |           PollVote, Mention | ||||||
|         ] |         ] | ||||||
|         owned_classes << AccountDeletionRequest if ActiveRecord::Base.connection.table_exists?(:account_deletion_requests) |         owned_classes << AccountDeletionRequest if ActiveRecord::Base.connection.table_exists?(:account_deletion_requests) | ||||||
|         owned_classes << AccountNote if ActiveRecord::Base.connection.table_exists?(:account_notes) |         owned_classes << AccountNote if ActiveRecord::Base.connection.table_exists?(:account_notes) | ||||||
|         owned_classes << FollowRecommendationSuppression if ActiveRecord::Base.connection.table_exists?(:follow_recommendation_suppressions) |         owned_classes << FollowRecommendationSuppression if ActiveRecord::Base.connection.table_exists?(:follow_recommendation_suppressions) | ||||||
|  |         owned_classes << AccountIdentityProof if ActiveRecord::Base.connection.table_exists?(:account_identity_proofs) | ||||||
| 
 | 
 | ||||||
|         owned_classes.each do |klass| |         owned_classes.each do |klass| | ||||||
|           klass.where(account_id: other_account.id).find_each do |record| |           klass.where(account_id: other_account.id).find_each do |record| | ||||||
| @ -139,17 +140,22 @@ module Mastodon | |||||||
|       @prompt = TTY::Prompt.new |       @prompt = TTY::Prompt.new | ||||||
| 
 | 
 | ||||||
|       if ActiveRecord::Migrator.current_version < MIN_SUPPORTED_VERSION |       if ActiveRecord::Migrator.current_version < MIN_SUPPORTED_VERSION | ||||||
|         @prompt.warn 'Your version of the database schema is too old and is not supported by this script.' |         @prompt.error 'Your version of the database schema is too old and is not supported by this script.' | ||||||
|         @prompt.warn 'Please update to at least Mastodon 3.0.0 before running this script.' |         @prompt.error 'Please update to at least Mastodon 3.0.0 before running this script.' | ||||||
|         exit(1) |         exit(1) | ||||||
|       elsif ActiveRecord::Migrator.current_version > MAX_SUPPORTED_VERSION |       elsif ActiveRecord::Migrator.current_version > MAX_SUPPORTED_VERSION | ||||||
|         @prompt.warn 'Your version of the database schema is more recent than this script, this may cause unexpected errors.' |         @prompt.warn 'Your version of the database schema is more recent than this script, this may cause unexpected errors.' | ||||||
|         exit(1) unless @prompt.yes?('Continue anyway?') |         exit(1) unless @prompt.yes?('Continue anyway? (Yes/No)') | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       if Sidekiq::ProcessSet.new.any? | ||||||
|  |         @prompt.error 'It seems Sidekiq is running. All Mastodon processes need to be stopped when using this script.' | ||||||
|  |         exit(1) | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       @prompt.warn 'This task will take a long time to run and is potentially destructive.' |       @prompt.warn 'This task will take a long time to run and is potentially destructive.' | ||||||
|       @prompt.warn 'Please make sure to stop Mastodon and have a backup.' |       @prompt.warn 'Please make sure to stop Mastodon and have a backup.' | ||||||
|       exit(1) unless @prompt.yes?('Continue?') |       exit(1) unless @prompt.yes?('Continue? (Yes/No)') | ||||||
| 
 | 
 | ||||||
|       deduplicate_users! |       deduplicate_users! | ||||||
|       deduplicate_account_domain_blocks! |       deduplicate_account_domain_blocks! | ||||||
| @ -236,12 +242,14 @@ module Mastodon | |||||||
|         end |         end | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE remember_token IS NOT NULL GROUP BY remember_token HAVING count(*) > 1").each do |row| |       if ActiveRecord::Migrator.current_version < 20220118183010 | ||||||
|         users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1) |         ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE remember_token IS NOT NULL GROUP BY remember_token HAVING count(*) > 1").each do |row| | ||||||
|         @prompt.warn "Unsetting remember token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}" |           users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1) | ||||||
|  |           @prompt.warn "Unsetting remember token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}" | ||||||
| 
 | 
 | ||||||
|         users.each do |user| |           users.each do |user| | ||||||
|           user.update!(remember_token: nil) |             user.update!(remember_token: nil) | ||||||
|  |           end | ||||||
|         end |         end | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
| @ -257,7 +265,7 @@ module Mastodon | |||||||
|       @prompt.say 'Restoring users indexes…' |       @prompt.say 'Restoring users indexes…' | ||||||
|       ActiveRecord::Base.connection.add_index :users, ['confirmation_token'], name: 'index_users_on_confirmation_token', unique: true |       ActiveRecord::Base.connection.add_index :users, ['confirmation_token'], name: 'index_users_on_confirmation_token', unique: true | ||||||
|       ActiveRecord::Base.connection.add_index :users, ['email'], name: 'index_users_on_email', unique: true |       ActiveRecord::Base.connection.add_index :users, ['email'], name: 'index_users_on_email', unique: true | ||||||
|       ActiveRecord::Base.connection.add_index :users, ['remember_token'], name: 'index_users_on_remember_token', unique: true |       ActiveRecord::Base.connection.add_index :users, ['remember_token'], name: 'index_users_on_remember_token', unique: true if ActiveRecord::Migrator.current_version < 20220118183010 | ||||||
|       ActiveRecord::Base.connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true |       ActiveRecord::Base.connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
| @ -274,6 +282,8 @@ module Mastodon | |||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def deduplicate_account_identity_proofs! |     def deduplicate_account_identity_proofs! | ||||||
|  |       return unless ActiveRecord::Base.connection.table_exists?(:account_identity_proofs) | ||||||
|  | 
 | ||||||
|       remove_index_if_exists!(:account_identity_proofs, 'index_account_proofs_on_account_and_provider_and_username') |       remove_index_if_exists!(:account_identity_proofs, 'index_account_proofs_on_account_and_provider_and_username') | ||||||
| 
 | 
 | ||||||
|       @prompt.say 'Removing duplicate account identity proofs…' |       @prompt.say 'Removing duplicate account identity proofs…' | ||||||
|  | |||||||
| @ -441,7 +441,7 @@ namespace :mastodon do | |||||||
| 
 | 
 | ||||||
|   namespace :webpush do |   namespace :webpush do | ||||||
|     desc 'Generate VAPID key' |     desc 'Generate VAPID key' | ||||||
|     task generate_vapid_key: :environment do |     task :generate_vapid_key do | ||||||
|       vapid_key = Webpush.generate_key |       vapid_key = Webpush.generate_key | ||||||
|       puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" |       puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" | ||||||
|       puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" |       puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" | ||||||
|  | |||||||
| @ -0,0 +1,29 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | require 'rails_helper' | ||||||
|  | 
 | ||||||
|  | describe Api::V1::Statuses::HistoriesController do | ||||||
|  |   render_views | ||||||
|  | 
 | ||||||
|  |   let(:user)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } | ||||||
|  |   let(:app)   { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } | ||||||
|  |   let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses', application: app) } | ||||||
|  | 
 | ||||||
|  |   context 'with an oauth token' do | ||||||
|  |     before do | ||||||
|  |       allow(controller).to receive(:doorkeeper_token) { token } | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     describe 'GET #show' do | ||||||
|  |       let(:status) { Fabricate(:status, account: user.account) } | ||||||
|  | 
 | ||||||
|  |       before do | ||||||
|  |         get :show, params: { status_id: status.id } | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'returns http success' do | ||||||
|  |         expect(response).to have_http_status(200) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										29
									
								
								spec/controllers/api/v1/statuses/sources_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								spec/controllers/api/v1/statuses/sources_controller_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,29 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | require 'rails_helper' | ||||||
|  | 
 | ||||||
|  | describe Api::V1::Statuses::SourcesController do | ||||||
|  |   render_views | ||||||
|  | 
 | ||||||
|  |   let(:user)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } | ||||||
|  |   let(:app)   { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } | ||||||
|  |   let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses', application: app) } | ||||||
|  | 
 | ||||||
|  |   context 'with an oauth token' do | ||||||
|  |     before do | ||||||
|  |       allow(controller).to receive(:doorkeeper_token) { token } | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     describe 'GET #show' do | ||||||
|  |       let(:status) { Fabricate(:status, account: user.account) } | ||||||
|  | 
 | ||||||
|  |       before do | ||||||
|  |         get :show, params: { status_id: status.id } | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       it 'returns http success' do | ||||||
|  |         expect(response).to have_http_status(200) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										6
									
								
								spec/fabricators/preview_card_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								spec/fabricators/preview_card_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,6 @@ | |||||||
|  | Fabricator(:preview_card) do | ||||||
|  |   url { Faker::Internet.url } | ||||||
|  |   title { Faker::Lorem.sentence } | ||||||
|  |   description { Faker::Lorem.paragraph } | ||||||
|  |   type 'link' | ||||||
|  | end | ||||||
							
								
								
									
										7
									
								
								spec/fabricators/status_edit_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								spec/fabricators/status_edit_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | |||||||
|  | Fabricator(:status_edit) do | ||||||
|  |   status                    nil | ||||||
|  |   account                   nil | ||||||
|  |   text                      "MyText" | ||||||
|  |   spoiler_text              "MyText" | ||||||
|  |   media_attachments_changed false | ||||||
|  | end | ||||||
							
								
								
									
										109
									
								
								spec/lib/status_reach_finder_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								spec/lib/status_reach_finder_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,109 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | require 'rails_helper' | ||||||
|  | 
 | ||||||
|  | describe StatusReachFinder do | ||||||
|  |   describe '#inboxes' do | ||||||
|  |     context 'for a local status' do | ||||||
|  |       let(:parent_status) { nil } | ||||||
|  |       let(:visibility) { :public } | ||||||
|  |       let(:alice) { Fabricate(:account, username: 'alice') } | ||||||
|  |       let(:status) { Fabricate(:status, account: alice, thread: parent_status, visibility: visibility) } | ||||||
|  | 
 | ||||||
|  |       subject { described_class.new(status) } | ||||||
|  | 
 | ||||||
|  |       context 'when it contains mentions of remote accounts' do | ||||||
|  |         let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } | ||||||
|  | 
 | ||||||
|  |         before do | ||||||
|  |           status.mentions.create!(account: bob) | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         it 'includes the inbox of the mentioned account' do | ||||||
|  |           expect(subject.inboxes).to include 'https://foo.bar/inbox' | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when it has been reblogged by a remote account' do | ||||||
|  |         let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } | ||||||
|  | 
 | ||||||
|  |         before do | ||||||
|  |           bob.statuses.create!(reblog: status) | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         it 'includes the inbox of the reblogger' do | ||||||
|  |           expect(subject.inboxes).to include 'https://foo.bar/inbox' | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         context 'when status is not public' do | ||||||
|  |           let(:visibility) { :private } | ||||||
|  | 
 | ||||||
|  |           it 'does not include the inbox of the reblogger' do | ||||||
|  |             expect(subject.inboxes).to_not include 'https://foo.bar/inbox' | ||||||
|  |           end | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when it has been favourited by a remote account' do | ||||||
|  |         let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } | ||||||
|  | 
 | ||||||
|  |         before do | ||||||
|  |           bob.favourites.create!(status: status) | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         it 'includes the inbox of the favouriter' do | ||||||
|  |           expect(subject.inboxes).to include 'https://foo.bar/inbox' | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         context 'when status is not public' do | ||||||
|  |           let(:visibility) { :private } | ||||||
|  | 
 | ||||||
|  |           it 'does not include the inbox of the favouriter' do | ||||||
|  |             expect(subject.inboxes).to_not include 'https://foo.bar/inbox' | ||||||
|  |           end | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when it has been replied to by a remote account' do | ||||||
|  |         let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } | ||||||
|  | 
 | ||||||
|  |         before do | ||||||
|  |           bob.statuses.create!(thread: status, text: 'Hoge') | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         context do | ||||||
|  |           it 'includes the inbox of the replier' do | ||||||
|  |             expect(subject.inboxes).to include 'https://foo.bar/inbox' | ||||||
|  |           end | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         context 'when status is not public' do | ||||||
|  |           let(:visibility) { :private } | ||||||
|  | 
 | ||||||
|  |           it 'does not include the inbox of the replier' do | ||||||
|  |             expect(subject.inboxes).to_not include 'https://foo.bar/inbox' | ||||||
|  |           end | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       context 'when it is a reply to a remote account' do | ||||||
|  |         let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') } | ||||||
|  |         let(:parent_status) { Fabricate(:status, account: bob) } | ||||||
|  | 
 | ||||||
|  |         context do | ||||||
|  |           it 'includes the inbox of the replied-to account' do | ||||||
|  |             expect(subject.inboxes).to include 'https://foo.bar/inbox' | ||||||
|  |           end | ||||||
|  |         end | ||||||
|  | 
 | ||||||
|  |         context 'when status is not public and replied-to account is not mentioned' do | ||||||
|  |           let(:visibility) { :private } | ||||||
|  | 
 | ||||||
|  |           it 'does not include the inbox of the replied-to account' do | ||||||
|  |             expect(subject.inboxes).to_not include 'https://foo.bar/inbox' | ||||||
|  |           end | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										5
									
								
								spec/models/status_edit_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								spec/models/status_edit_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | |||||||
|  | require 'rails_helper' | ||||||
|  | 
 | ||||||
|  | RSpec.describe StatusEdit, type: :model do | ||||||
|  |   pending "add some examples to (or delete) #{__FILE__}" | ||||||
|  | end | ||||||
| @ -354,6 +354,87 @@ RSpec.describe Status, type: :model do | |||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   describe '.tagged_with' do | ||||||
|  |     let(:tag1)     { Fabricate(:tag) } | ||||||
|  |     let(:tag2)     { Fabricate(:tag) } | ||||||
|  |     let(:tag3)     { Fabricate(:tag) } | ||||||
|  |     let!(:status1) { Fabricate(:status, tags: [tag1]) } | ||||||
|  |     let!(:status2) { Fabricate(:status, tags: [tag2]) } | ||||||
|  |     let!(:status3) { Fabricate(:status, tags: [tag3]) } | ||||||
|  |     let!(:status4) { Fabricate(:status, tags: []) } | ||||||
|  |     let!(:status5) { Fabricate(:status, tags: [tag1, tag2, tag3]) } | ||||||
|  | 
 | ||||||
|  |     context 'when given one tag' do | ||||||
|  |       it 'returns the expected statuses' do | ||||||
|  |         expect(Status.tagged_with([tag1.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status5.id] | ||||||
|  |         expect(Status.tagged_with([tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status5.id] | ||||||
|  |         expect(Status.tagged_with([tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status3.id, status5.id] | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context 'when given multiple tags' do | ||||||
|  |       it 'returns the expected statuses' do | ||||||
|  |         expect(Status.tagged_with([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status2.id, status5.id] | ||||||
|  |         expect(Status.tagged_with([tag1.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status3.id, status5.id] | ||||||
|  |         expect(Status.tagged_with([tag2.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status3.id, status5.id] | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   describe '.tagged_with_all' do | ||||||
|  |     let(:tag1)     { Fabricate(:tag) } | ||||||
|  |     let(:tag2)     { Fabricate(:tag) } | ||||||
|  |     let(:tag3)     { Fabricate(:tag) } | ||||||
|  |     let!(:status1) { Fabricate(:status, tags: [tag1]) } | ||||||
|  |     let!(:status2) { Fabricate(:status, tags: [tag2]) } | ||||||
|  |     let!(:status3) { Fabricate(:status, tags: [tag3]) } | ||||||
|  |     let!(:status4) { Fabricate(:status, tags: []) } | ||||||
|  |     let!(:status5) { Fabricate(:status, tags: [tag1, tag2]) } | ||||||
|  | 
 | ||||||
|  |     context 'when given one tag' do | ||||||
|  |       it 'returns the expected statuses' do | ||||||
|  |         expect(Status.tagged_with_all([tag1.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status5.id] | ||||||
|  |         expect(Status.tagged_with_all([tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status5.id] | ||||||
|  |         expect(Status.tagged_with_all([tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status3.id] | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context 'when given multiple tags' do | ||||||
|  |       it 'returns the expected statuses' do | ||||||
|  |         expect(Status.tagged_with_all([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status5.id] | ||||||
|  |         expect(Status.tagged_with_all([tag1.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [] | ||||||
|  |         expect(Status.tagged_with_all([tag2.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [] | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   describe '.tagged_with_none' do | ||||||
|  |     let(:tag1)     { Fabricate(:tag) } | ||||||
|  |     let(:tag2)     { Fabricate(:tag) } | ||||||
|  |     let(:tag3)     { Fabricate(:tag) } | ||||||
|  |     let!(:status1) { Fabricate(:status, tags: [tag1]) } | ||||||
|  |     let!(:status2) { Fabricate(:status, tags: [tag2]) } | ||||||
|  |     let!(:status3) { Fabricate(:status, tags: [tag3]) } | ||||||
|  |     let!(:status4) { Fabricate(:status, tags: []) } | ||||||
|  |     let!(:status5) { Fabricate(:status, tags: [tag1, tag2, tag3]) } | ||||||
|  | 
 | ||||||
|  |     context 'when given one tag' do | ||||||
|  |       it 'returns the expected statuses' do | ||||||
|  |         expect(Status.tagged_with_none([tag1.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status3.id, status4.id] | ||||||
|  |         expect(Status.tagged_with_none([tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status3.id, status4.id] | ||||||
|  |         expect(Status.tagged_with_none([tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status2.id, status4.id] | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     context 'when given multiple tags' do | ||||||
|  |       it 'returns the expected statuses' do | ||||||
|  |         expect(Status.tagged_with_none([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status3.id, status4.id] | ||||||
|  |         expect(Status.tagged_with_none([tag1.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status4.id] | ||||||
|  |         expect(Status.tagged_with_none([tag2.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status4.id] | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   describe '.permitted_for' do |   describe '.permitted_for' do | ||||||
|     subject { described_class.permitted_for(target_account, account).pluck(:visibility) } |     subject { described_class.permitted_for(target_account, account).pluck(:visibility) } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -67,7 +67,7 @@ RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do | |||||||
| 
 | 
 | ||||||
|         expect(status).to_not be_nil |         expect(status).to_not be_nil | ||||||
|         expect(status.url).to eq "https://#{valid_domain}/watch?v=12345" |         expect(status.url).to eq "https://#{valid_domain}/watch?v=12345" | ||||||
|         expect(strip_tags(status.text)).to eq "Nyan Cat 10 hours remix https://#{valid_domain}/watch?v=12345" |         expect(strip_tags(status.text)).to eq "Nyan Cat 10 hours remixhttps://#{valid_domain}/watch?v=12345" | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
| @ -100,7 +100,7 @@ RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do | |||||||
| 
 | 
 | ||||||
|         expect(status).to_not be_nil |         expect(status).to_not be_nil | ||||||
|         expect(status.url).to eq "https://#{valid_domain}/watch?v=12345" |         expect(status.url).to eq "https://#{valid_domain}/watch?v=12345" | ||||||
|         expect(strip_tags(status.text)).to eq "Nyan Cat 10 hours remix https://#{valid_domain}/watch?v=12345" |         expect(strip_tags(status.text)).to eq "Nyan Cat 10 hours remixhttps://#{valid_domain}/watch?v=12345" | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
| @ -120,7 +120,7 @@ RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do | |||||||
| 
 | 
 | ||||||
|         expect(status).to_not be_nil |         expect(status).to_not be_nil | ||||||
|         expect(status.url).to eq "https://#{valid_domain}/@foo/1234" |         expect(status.url).to eq "https://#{valid_domain}/@foo/1234" | ||||||
|         expect(strip_tags(status.text)).to eq "Let's change the world https://#{valid_domain}/@foo/1234" |         expect(strip_tags(status.text)).to eq "Let's change the worldhttps://#{valid_domain}/@foo/1234" | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,37 +1,112 @@ | |||||||
| require 'rails_helper' | require 'rails_helper' | ||||||
| 
 | 
 | ||||||
| RSpec.describe FanOutOnWriteService, type: :service do | RSpec.describe FanOutOnWriteService, type: :service do | ||||||
|   let(:author)   { Fabricate(:account, username: 'tom') } |   let(:last_active_at) { Time.now.utc } | ||||||
|   let(:status)   { Fabricate(:status, text: 'Hello @alice #test', account: author) } |  | ||||||
|   let(:alice)    { Fabricate(:user, account: Fabricate(:account, username: 'alice')).account } |  | ||||||
|   let(:follower) { Fabricate(:account, username: 'bob') } |  | ||||||
| 
 | 
 | ||||||
|   subject { FanOutOnWriteService.new } |   let!(:alice) { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'alice')).account } | ||||||
|  |   let!(:bob)   { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'bob')).account } | ||||||
|  |   let!(:tom)   { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'tom')).account } | ||||||
|  | 
 | ||||||
|  |   subject { described_class.new } | ||||||
|  | 
 | ||||||
|  |   let(:status) { Fabricate(:status, account: alice, visibility: visibility, text: 'Hello @bob #hoge') } | ||||||
| 
 | 
 | ||||||
|   before do |   before do | ||||||
|     alice |     bob.follow!(alice) | ||||||
|     follower.follow!(author) |     tom.follow!(alice) | ||||||
| 
 | 
 | ||||||
|     ProcessMentionsService.new.call(status) |     ProcessMentionsService.new.call(status) | ||||||
|     ProcessHashtagsService.new.call(status) |     ProcessHashtagsService.new.call(status) | ||||||
| 
 | 
 | ||||||
|  |     allow(Redis.current).to receive(:publish) | ||||||
|  | 
 | ||||||
|     subject.call(status) |     subject.call(status) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   it 'delivers status to home timeline' do |   def home_feed_of(account) | ||||||
|     expect(HomeFeed.new(author).get(10).map(&:id)).to include status.id |     HomeFeed.new(account).get(10).map(&:id) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   it 'delivers status to local followers' do |   context 'when status is public' do | ||||||
|     pending 'some sort of problem in test environment causes this to sometimes fail' |     let(:visibility) { 'public' } | ||||||
|     expect(HomeFeed.new(follower).get(10).map(&:id)).to include status.id | 
 | ||||||
|  |     it 'is added to the home feed of its author' do | ||||||
|  |       expect(home_feed_of(alice)).to include status.id | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'is added to the home feed of a follower' do | ||||||
|  |       expect(home_feed_of(bob)).to include status.id | ||||||
|  |       expect(home_feed_of(tom)).to include status.id | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'is broadcast to the hashtag stream' do | ||||||
|  |       expect(Redis.current).to have_received(:publish).with('timeline:hashtag:hoge', anything) | ||||||
|  |       expect(Redis.current).to have_received(:publish).with('timeline:hashtag:hoge:local', anything) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'is broadcast to the public stream' do | ||||||
|  |       expect(Redis.current).to have_received(:publish).with('timeline:public', anything) | ||||||
|  |       expect(Redis.current).to have_received(:publish).with('timeline:public:local', anything) | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   it 'delivers status to hashtag' do |   context 'when status is limited' do | ||||||
|     expect(TagFeed.new(Tag.find_by(name: 'test'), alice).get(20).map(&:id)).to include status.id |     let(:visibility) { 'limited' } | ||||||
|  | 
 | ||||||
|  |     it 'is added to the home feed of its author' do | ||||||
|  |       expect(home_feed_of(alice)).to include status.id | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'is added to the home feed of the mentioned follower' do | ||||||
|  |       expect(home_feed_of(bob)).to include status.id | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'is not added to the home feed of the other follower' do | ||||||
|  |       expect(home_feed_of(tom)).to_not include status.id | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'is not broadcast publicly' do | ||||||
|  |       expect(Redis.current).to_not have_received(:publish).with('timeline:hashtag:hoge', anything) | ||||||
|  |       expect(Redis.current).to_not have_received(:publish).with('timeline:public', anything) | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   it 'delivers status to public timeline' do |   context 'when status is private' do | ||||||
|     expect(PublicFeed.new(alice).get(20).map(&:id)).to include status.id |     let(:visibility) { 'private' } | ||||||
|  | 
 | ||||||
|  |     it 'is added to the home feed of its author' do | ||||||
|  |       expect(home_feed_of(alice)).to include status.id | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'is added to the home feed of a follower' do | ||||||
|  |       expect(home_feed_of(bob)).to include status.id | ||||||
|  |       expect(home_feed_of(tom)).to include status.id | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'is not broadcast publicly' do | ||||||
|  |       expect(Redis.current).to_not have_received(:publish).with('timeline:hashtag:hoge', anything) | ||||||
|  |       expect(Redis.current).to_not have_received(:publish).with('timeline:public', anything) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   context 'when status is direct' do | ||||||
|  |     let(:visibility) { 'direct' } | ||||||
|  | 
 | ||||||
|  |     it 'is added to the home feed of its author' do | ||||||
|  |       expect(home_feed_of(alice)).to include status.id | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'is added to the home feed of the mentioned follower' do | ||||||
|  |       expect(home_feed_of(bob)).to include status.id | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'is not added to the home feed of the other follower' do | ||||||
|  |       expect(home_feed_of(tom)).to_not include status.id | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     it 'is not broadcast publicly' do | ||||||
|  |       expect(Redis.current).to_not have_received(:publish).with('timeline:hashtag:hoge', anything) | ||||||
|  |       expect(Redis.current).to_not have_received(:publish).with('timeline:public', anything) | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -9,75 +9,55 @@ RSpec.describe ProcessMentionsService, type: :service do | |||||||
| 
 | 
 | ||||||
|   context 'ActivityPub' do |   context 'ActivityPub' do | ||||||
|     context do |     context do | ||||||
|       let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } |       let!(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } | ||||||
| 
 | 
 | ||||||
|       before do |       before do | ||||||
|         stub_request(:post, remote_user.inbox_url) |  | ||||||
|         subject.call(status) |         subject.call(status) | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       it 'creates a mention' do |       it 'creates a mention' do | ||||||
|         expect(remote_user.mentions.where(status: status).count).to eq 1 |         expect(remote_user.mentions.where(status: status).count).to eq 1 | ||||||
|       end |       end | ||||||
| 
 |  | ||||||
|       it 'sends activity to the inbox' do |  | ||||||
|         expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once |  | ||||||
|       end |  | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     context 'with an IDN domain' do |     context 'with an IDN domain' do | ||||||
|       let(:remote_user) { Fabricate(:account, username: 'sneak', protocol: :activitypub, domain: 'xn--hresiar-mxa.ch', inbox_url: 'http://example.com/inbox') } |       let!(:remote_user) { Fabricate(:account, username: 'sneak', protocol: :activitypub, domain: 'xn--hresiar-mxa.ch', inbox_url: 'http://example.com/inbox') } | ||||||
|       let(:status) { Fabricate(:status, account: account, text: "Hello @sneak@hæresiar.ch") } |       let!(:status) { Fabricate(:status, account: account, text: "Hello @sneak@hæresiar.ch") } | ||||||
| 
 | 
 | ||||||
|       before do |       before do | ||||||
|         stub_request(:post, remote_user.inbox_url) |  | ||||||
|         subject.call(status) |         subject.call(status) | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       it 'creates a mention' do |       it 'creates a mention' do | ||||||
|         expect(remote_user.mentions.where(status: status).count).to eq 1 |         expect(remote_user.mentions.where(status: status).count).to eq 1 | ||||||
|       end |       end | ||||||
| 
 |  | ||||||
|       it 'sends activity to the inbox' do |  | ||||||
|         expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once |  | ||||||
|       end |  | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     context 'with an IDN TLD' do |     context 'with an IDN TLD' do | ||||||
|       let(:remote_user) { Fabricate(:account, username: 'foo', protocol: :activitypub, domain: 'xn--y9a3aq.xn--y9a3aq', inbox_url: 'http://example.com/inbox') } |       let!(:remote_user) { Fabricate(:account, username: 'foo', protocol: :activitypub, domain: 'xn--y9a3aq.xn--y9a3aq', inbox_url: 'http://example.com/inbox') } | ||||||
|       let(:status) { Fabricate(:status, account: account, text: "Hello @foo@հայ.հայ") } |       let!(:status) { Fabricate(:status, account: account, text: "Hello @foo@հայ.հայ") } | ||||||
| 
 | 
 | ||||||
|       before do |       before do | ||||||
|         stub_request(:post, remote_user.inbox_url) |  | ||||||
|         subject.call(status) |         subject.call(status) | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       it 'creates a mention' do |       it 'creates a mention' do | ||||||
|         expect(remote_user.mentions.where(status: status).count).to eq 1 |         expect(remote_user.mentions.where(status: status).count).to eq 1 | ||||||
|       end |       end | ||||||
| 
 |  | ||||||
|       it 'sends activity to the inbox' do |  | ||||||
|         expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once |  | ||||||
|       end |  | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   context 'Temporarily-unreachable ActivityPub user' do |   context 'Temporarily-unreachable ActivityPub user' do | ||||||
|     let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox', last_webfingered_at: nil) } |     let!(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox', last_webfingered_at: nil) } | ||||||
| 
 | 
 | ||||||
|     before do |     before do | ||||||
|       stub_request(:get, "https://example.com/.well-known/host-meta").to_return(status: 404) |       stub_request(:get, "https://example.com/.well-known/host-meta").to_return(status: 404) | ||||||
|       stub_request(:get, "https://example.com/.well-known/webfinger?resource=acct:remote_user@example.com").to_return(status: 500) |       stub_request(:get, "https://example.com/.well-known/webfinger?resource=acct:remote_user@example.com").to_return(status: 500) | ||||||
|       stub_request(:post, remote_user.inbox_url) |  | ||||||
|       subject.call(status) |       subject.call(status) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     it 'creates a mention' do |     it 'creates a mention' do | ||||||
|       expect(remote_user.mentions.where(status: status).count).to eq 1 |       expect(remote_user.mentions.where(status: status).count).to eq 1 | ||||||
|     end |     end | ||||||
| 
 |  | ||||||
|     it 'sends activity to the inbox' do |  | ||||||
|       expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once |  | ||||||
|     end |  | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  | |||||||
| @ -35,13 +35,16 @@ describe ActivityPub::DistributionWorker do | |||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     context 'with direct status' do |     context 'with direct status' do | ||||||
|  |       let(:mentioned_account) { Fabricate(:account, protocol: :activitypub, inbox_url: 'https://foo.bar/inbox')} | ||||||
|  | 
 | ||||||
|       before do |       before do | ||||||
|         status.update(visibility: :direct) |         status.update(visibility: :direct) | ||||||
|  |         status.mentions.create!(account: mentioned_account) | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       it 'does nothing' do |       it 'delivers to mentioned accounts' do | ||||||
|         subject.perform(status.id) |         subject.perform(status.id) | ||||||
|         expect(ActivityPub::DeliveryWorker).to_not have_received(:push_bulk) |         expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['https://foo.bar/inbox']) | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  | |||||||
| @ -45,7 +45,7 @@ describe FeedInsertWorker do | |||||||
|         result = subject.perform(status.id, follower.id) |         result = subject.perform(status.id, follower.id) | ||||||
| 
 | 
 | ||||||
|         expect(result).to be_nil |         expect(result).to be_nil | ||||||
|         expect(instance).to have_received(:push_to_home).with(follower, status) |         expect(instance).to have_received(:push_to_home).with(follower, status, update: nil) | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user