diff options
Diffstat (limited to 'app')
66 files changed, 491 insertions, 430 deletions
diff --git a/app/assets/javascripts/components/actions/suggestions.jsx b/app/assets/javascripts/components/actions/suggestions.jsx deleted file mode 100644 index 6b3aa69dd..000000000 --- a/app/assets/javascripts/components/actions/suggestions.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import api from '../api'; - -export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST'; -export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS'; -export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL'; - -export function fetchSuggestions() { - return (dispatch, getState) => { - dispatch(fetchSuggestionsRequest()); - - api(getState).get('/api/v1/accounts/suggestions').then(response => { - dispatch(fetchSuggestionsSuccess(response.data)); - }).catch(error => { - dispatch(fetchSuggestionsFail(error)); - }); - }; -}; - -export function fetchSuggestionsRequest() { - return { - type: SUGGESTIONS_FETCH_REQUEST - }; -}; - -export function fetchSuggestionsSuccess(accounts) { - return { - type: SUGGESTIONS_FETCH_SUCCESS, - accounts: accounts - }; -}; - -export function fetchSuggestionsFail(error) { - return { - type: SUGGESTIONS_FETCH_FAIL, - error: error - }; -}; diff --git a/app/assets/javascripts/components/components/status.jsx b/app/assets/javascripts/components/components/status.jsx index 603561ab3..df5f0f2c2 100644 --- a/app/assets/javascripts/components/components/status.jsx +++ b/app/assets/javascripts/components/components/status.jsx @@ -82,7 +82,7 @@ const Status = React.createClass({ ); } - if (status.get('media_attachments').size > 0) { + if (status.get('media_attachments').size > 0 && !this.props.muted) { if (status.getIn(['media_attachments', 0, 'type']) === 'video') { media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} />; } else { diff --git a/app/assets/javascripts/components/containers/mastodon.jsx b/app/assets/javascripts/components/containers/mastodon.jsx index c9f037ec2..c42582bfd 100644 --- a/app/assets/javascripts/components/containers/mastodon.jsx +++ b/app/assets/javascripts/components/containers/mastodon.jsx @@ -39,6 +39,8 @@ import en from 'react-intl/locale-data/en'; import de from 'react-intl/locale-data/de'; import es from 'react-intl/locale-data/es'; import fr from 'react-intl/locale-data/fr'; +import pt from 'react-intl/locale-data/pt'; +import hu from 'react-intl/locale-data/hu'; import getMessagesForLocale from '../locales'; const store = configureStore(); @@ -47,7 +49,7 @@ const browserHistory = useRouterHistory(createBrowserHistory)({ basename: '/web' }); -addLocaleData([...en, ...de, ...es, ...fr]); +addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu]); const Mastodon = React.createClass({ diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx index d39a06062..b890e15c1 100644 --- a/app/assets/javascripts/components/features/account/components/header.jsx +++ b/app/assets/javascripts/components/features/account/components/header.jsx @@ -47,7 +47,7 @@ const Header = React.createClass({ const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; return ( - <div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', position: 'relative' }}> + <div style={{ flex: '0 0 auto', background: '#2f3441', textAlign: 'center', backgroundImage: `url(${account.get('header')})`, backgroundSize: 'cover', backgroundPosition: 'center', position: 'relative' }}> <div style={{ background: 'rgba(47, 52, 65, 0.9)', padding: '20px 10px' }}> <a href={account.get('url')} target='_blank' rel='noopener' style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}> <div style={{ width: '90px', margin: '0 auto', marginBottom: '10px' }}> diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx index 5ad1ca172..b16731c05 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -55,7 +55,8 @@ const textareaStyle = { padding: '10px', fontFamily: 'Roboto', fontSize: '14px', - margin: '0' + margin: '0', + resize: 'vertical' }; const renderInputComponent = inputProps => ( diff --git a/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx b/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx deleted file mode 100644 index 6850629ba..000000000 --- a/app/assets/javascripts/components/features/compose/components/suggestions_box.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import PureRenderMixin from 'react-addons-pure-render-mixin'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import AccountContainer from '../../../containers/account_container'; -import { FormattedMessage } from 'react-intl'; - -const outerStyle = { - position: 'relative' -}; - -const headerStyle = { - fontSize: '14px', - fontWeight: '500', - display: 'block', - padding: '10px', - color: '#9baec8', - background: '#454b5e', - overflow: 'hidden' -}; - -const nextStyle = { - display: 'inline-block', - float: 'right', - fontWeight: '400', - color: '#2b90d9' -}; - -const SuggestionsBox = React.createClass({ - - propTypes: { - accountIds: ImmutablePropTypes.list, - perWindow: React.PropTypes.number - }, - - getInitialState () { - return { - index: 0 - }; - }, - - getDefaultProps () { - return { - perWindow: 2 - }; - }, - - mixins: [PureRenderMixin], - - handleNextClick (e) { - e.preventDefault(); - - let newIndex = this.state.index + 1; - - if (this.props.accountIds.skip(this.props.perWindow * newIndex).size === 0) { - newIndex = 0; - } - - this.setState({ index: newIndex }); - }, - - render () { - const { accountIds, perWindow } = this.props; - - if (!accountIds || accountIds.size === 0) { - return <div />; - } - - let nextLink = ''; - - if (accountIds.size > perWindow) { - nextLink = <a href='#' style={nextStyle} onClick={this.handleNextClick}><FormattedMessage id='suggestions_box.refresh' defaultMessage='Refresh' /></a>; - } - - return ( - <div style={outerStyle}> - <strong style={headerStyle}> - <FormattedMessage id='suggestions_box.who_to_follow' defaultMessage='Who to follow' /> {nextLink} - </strong> - - {accountIds.skip(perWindow * this.state.index).take(perWindow).map(accountId => <AccountContainer key={accountId} id={accountId} withNote={false} />)} - </div> - ); - } - -}); - -export default SuggestionsBox; diff --git a/app/assets/javascripts/components/features/compose/containers/suggestions_container.jsx b/app/assets/javascripts/components/features/compose/containers/suggestions_container.jsx deleted file mode 100644 index 944ceed85..000000000 --- a/app/assets/javascripts/components/features/compose/containers/suggestions_container.jsx +++ /dev/null @@ -1,8 +0,0 @@ -import { connect } from 'react-redux'; -import SuggestionsBox from '../components/suggestions_box'; - -const mapStateToProps = (state) => ({ - accountIds: state.getIn(['user_lists', 'suggestions']) -}); - -export default connect(mapStateToProps)(SuggestionsBox); diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx index 5c1b22e00..4017c8949 100644 --- a/app/assets/javascripts/components/features/compose/index.jsx +++ b/app/assets/javascripts/components/features/compose/index.jsx @@ -3,9 +3,7 @@ import ComposeFormContainer from './containers/compose_form_container'; import UploadFormContainer from './containers/upload_form_container'; import NavigationContainer from './containers/navigation_container'; import PureRenderMixin from 'react-addons-pure-render-mixin'; -import SuggestionsContainer from './containers/suggestions_container'; import SearchContainer from './containers/search_container'; -import { fetchSuggestions } from '../../actions/suggestions'; import { connect } from 'react-redux'; import { mountCompose, unmountCompose } from '../../actions/compose'; @@ -19,7 +17,6 @@ const Compose = React.createClass({ componentDidMount () { this.props.dispatch(mountCompose()); - this.props.dispatch(fetchSuggestions()); }, componentWillUnmount () { @@ -29,14 +26,10 @@ const Compose = React.createClass({ render () { return ( <Drawer> - <div style={{ flex: '1 1 auto' }}> - <SearchContainer /> - <NavigationContainer /> - <ComposeFormContainer /> - <UploadFormContainer /> - </div> - - <SuggestionsContainer /> + <SearchContainer /> + <NavigationContainer /> + <ComposeFormContainer /> + <UploadFormContainer /> </Drawer> ); } diff --git a/app/assets/javascripts/components/locales/de.jsx b/app/assets/javascripts/components/locales/de.jsx index 85412635e..4e2a70edb 100644 --- a/app/assets/javascripts/components/locales/de.jsx +++ b/app/assets/javascripts/components/locales/de.jsx @@ -41,8 +41,6 @@ const en = { "search.placeholder": "Suche", "search.account": "Konto", "search.hashtag": "Hashtag", - "suggestions_box.who_to_follow": "Wem folgen", - "suggestions_box.refresh": "Aktualisieren", "upload_button.label": "Media-Datei anfügen", "upload_form.undo": "Entfernen", "notification.follow": "{name} folgt dir", diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx index 0ea324f66..41a44e3dc 100644 --- a/app/assets/javascripts/components/locales/en.jsx +++ b/app/assets/javascripts/components/locales/en.jsx @@ -5,9 +5,9 @@ const en = { "status.mention": "Mention", "status.delete": "Delete", "status.reply": "Reply", - "status.reblog": "Reblog", + "status.reblog": "Boost", "status.favourite": "Favourite", - "status.reblogged_by": "{name} reblogged", + "status.reblogged_by": "{name} boosted", "status.sensitive_warning": "Sensitive content", "status.sensitive_toggle": "Click to view", "video_player.toggle_sound": "Toggle sound", @@ -45,13 +45,11 @@ const en = { "search.placeholder": "Search", "search.account": "Account", "search.hashtag": "Hashtag", - "suggestions_box.who_to_follow": "Who to follow", - "suggestions_box.refresh": "Refresh", "upload_button.label": "Add media", "upload_form.undo": "Undo", "notification.follow": "{name} followed you", "notification.favourite": "{name} favourited your status", - "notification.reblog": "{name} reblogged your status", + "notification.reblog": "{name} boosted your status", "notification.mention": "{name} mentioned you" }; diff --git a/app/assets/javascripts/components/locales/es.jsx b/app/assets/javascripts/components/locales/es.jsx index 47377e5ae..d4434bba7 100644 --- a/app/assets/javascripts/components/locales/es.jsx +++ b/app/assets/javascripts/components/locales/es.jsx @@ -42,8 +42,6 @@ const es = { "search.placeholder": "Buscar", "search.account": "Cuenta", "search.hashtag": "Etiqueta", - "suggestions_box.who_to_follow": "A quién seguir", - "suggestions_box.refresh": "Refrescar", "upload_button.label": "Añadir medio", "upload_form.undo": "Deshacer", "notification.follow": "{name} le esta ahora siguiendo", diff --git a/app/assets/javascripts/components/locales/fr.jsx b/app/assets/javascripts/components/locales/fr.jsx index 0cf4c5d52..c4458a145 100644 --- a/app/assets/javascripts/components/locales/fr.jsx +++ b/app/assets/javascripts/components/locales/fr.jsx @@ -7,22 +7,24 @@ const fr = { "status.reply": "Répondre", "status.reblog": "Partager", "status.favourite": "Ajouter aux favoris", - "status.reblogged_by": "{name} a partagé", + "status.reblogged_by": "{name} a partagé :", + "status.sensitive_warning": "Contenu délicat", + "status.sensitive_toggle": "Cliquer pour dévoiler", "video_player.toggle_sound": "Mettre/Couper le son", "account.mention": "Mentionner", "account.edit_profile": "Modifier le profil", "account.unblock": "Débloquer", - "account.unfollow": "Se désabonner", + "account.unfollow": "Ne plus suivre", "account.block": "Bloquer", - "account.follow": "S’abonner", + "account.follow": "Suivre", "account.posts": "Statuts", "account.follows": "Abonnements", "account.followers": "Abonnés", "account.follows_you": "Vous suit", "getting_started.heading": "Pour commencer", - "getting_started.about_addressing": "Vous pouvez vous abonner aux statuts de quelqu’un en entrant dans le champs de recherche leur nom d’utilisateur et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.", - "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, le nom d’utilisateur suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.", - "getting_started.about_developer": "Pour s’abonner au développeur de ce projet, c’est Gargron@mastodon.social", + "getting_started.about_addressing": "Vous pouvez vous suivre les statuts de quelqu’un en entrant dans le champs de recherche leur identifiant et le domaine de leur instance, séparés par un @ à la manière d’une adresse courriel.", + "getting_started.about_shortcuts": "Si cette personne utilise la même instance que vous, l’identifiant suffit. C’est le même principe pour mentionner quelqu’un dans vos statuts.", + "getting_started.about_developer": "Pour suivre le développeur de ce projet, c’est Gargron@mastodon.social", "column.home": "Accueil", "column.mentions": "Mentions", "column.public": "Fil public", @@ -32,23 +34,22 @@ const fr = { "tabs_bar.mentions": "Mentions", "tabs_bar.public": "Public", "tabs_bar.notifications": "Notifications", - "compose_form.placeholder": "Qu’avez vous en tête ?", + "compose_form.placeholder": "Qu’avez-vous en tête ?", "compose_form.publish": "Pouet", + "compose_form.sensitive": "Marquer le contenu comme délicat", "navigation_bar.settings": "Paramètres", "navigation_bar.public_timeline": "Public", - "navigation_bar.logout": "Se déconnecter", + "navigation_bar.logout": "Déconnexion", "reply_indicator.cancel": "Annuler", "search.placeholder": "Chercher", "search.account": "Compte", "search.hashtag": "Mot-clé", - "suggestions_box.who_to_follow": "Suggestions", - "suggestions_box.refresh": "Rafraîchir", "upload_button.label": "Joindre un média", "upload_form.undo": "Annuler", - "notification.follow": "{name} s’est abonné⋅e à vos statuts", - "notification.favourite": "{name} a ajouté votre statut à ses favoris", - "notification.reblog": "{name} a partagé votre statut", - "notification.mention": "{name} vous a mentionné⋅e" + "notification.follow": "{name} vous suit.", + "notification.favourite": "{name} a ajouté à ses favoris :", + "notification.reblog": "{name} a partagé votre statut :", + "notification.mention": "{name} vous a mentionné⋅e :" }; export default fr; diff --git a/app/assets/javascripts/components/locales/hu.jsx b/app/assets/javascripts/components/locales/hu.jsx new file mode 100644 index 000000000..4a446965c --- /dev/null +++ b/app/assets/javascripts/components/locales/hu.jsx @@ -0,0 +1,55 @@ +const hu = { + "column_back_button.label": "Vissza", + "lightbox.close": "Bezárás", + "loading_indicator.label": "Betöltés...", + "status.mention": "Említés", + "status.delete": "Törlés", + "status.reply": "Válasz", + "status.reblog": "Reblog", + "status.favourite": "Kedvenc", + "status.reblogged_by": "{name} reblogolta", + "status.sensitive_warning": "Érzékeny tartalom", + "status.sensitive_toggle": "Katt a megtekintéshez", + "video_player.toggle_sound": "Hang kapcsolása", + "account.mention": "Említés", + "account.edit_profile": "Profil szerkesztése", + "account.unblock": "Blokkolás levétele", + "account.unfollow": "Követés abbahagyása", + "account.block": "Blokkolás", + "account.follow": "Követés", + "account.posts": "Posts", + "account.follows": "Követők", + "account.followers": "Követők", + "account.follows_you": "Követnek téged", + "getting_started.heading": "Első lépések", + "getting_started.about_addressing": "Követhetsz embereket felhasználónevük és a doménjük ismeretében, amennyiben megadod ezt az e-mail-szerű címet az oldalsáv tetején lévő rubrikában.", + "getting_started.about_shortcuts": "Ha a célzott személy azonos doménen tartózkodik, a felhasználónév elegendő. Ugyanez érvényes mikor személyeket említesz az állapotokban.", + "getting_started.about_developer": "A projekt fejlesztője követhető, mint Gargron@mastodon.social", + "column.home": "Kezdőlap", + "column.mentions": "Említések", + "column.public": "Nyilvános", + "column.notifications": "Értesítések", + "tabs_bar.compose": "Összeállítás", + "tabs_bar.home": "Kezdőlap", + "tabs_bar.mentions": "Említések", + "tabs_bar.public": "Nyilvános", + "tabs_bar.notifications": "Notifications", + "compose_form.placeholder": "Mire gondolsz?", + "compose_form.publish": "Tülk!", + "compose_form.sensitive": "Tartalom érzékenynek jelölése", + "navigation_bar.settings": "Beállítások", + "navigation_bar.public_timeline": "Nyilvános időfolyam", + "navigation_bar.logout": "Kijelentkezés", + "reply_indicator.cancel": "Mégsem", + "search.placeholder": "Keresés", + "search.account": "Fiók", + "search.hashtag": "Hashtag", + "upload_button.label": "Média hozzáadása", + "upload_form.undo": "Mégsem", + "notification.follow": "{name} követ téged", + "notification.favourite": "{name} kedvencnek jelölte az állapotod", + "notification.reblog": "{name} reblogolta az állapotod", + "notification.mention": "{name} megemlített" +}; + +export default hu; diff --git a/app/assets/javascripts/components/locales/index.jsx b/app/assets/javascripts/components/locales/index.jsx index 7fb43dd33..f172b1c51 100644 --- a/app/assets/javascripts/components/locales/index.jsx +++ b/app/assets/javascripts/components/locales/index.jsx @@ -1,11 +1,17 @@ import en from './en'; import de from './de'; import es from './es'; +import hu from './hu'; +import fr from './fr'; +import pt from './pt'; const locales = { en, de, - es + es, + hu, + fr, + pt }; export default function getMessagesForLocale (locale) { diff --git a/app/assets/javascripts/components/locales/pt.jsx b/app/assets/javascripts/components/locales/pt.jsx index 02b21f3cb..e67bd80ac 100644 --- a/app/assets/javascripts/components/locales/pt.jsx +++ b/app/assets/javascripts/components/locales/pt.jsx @@ -40,8 +40,6 @@ const pt = { "search.placeholder": "Busca", "search.account": "Conta", "search.hashtag": "Hashtag", - "suggestions_box.who_to_follow": "Quem seguir", - "suggestions_box.refresh": "Recarregar", "upload_button.label": "Adicionar media", "upload_form.undo": "Desfazer" }; diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx index 68247a98c..52be648b3 100644 --- a/app/assets/javascripts/components/reducers/accounts.jsx +++ b/app/assets/javascripts/components/reducers/accounts.jsx @@ -8,7 +8,6 @@ import { ACCOUNT_TIMELINE_FETCH_SUCCESS, ACCOUNT_TIMELINE_EXPAND_SUCCESS } from '../actions/accounts'; -import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions'; import { COMPOSE_SUGGESTIONS_READY } from '../actions/compose'; import { REBLOG_SUCCESS, @@ -71,7 +70,6 @@ export default function accounts(state = initialState, action) { case ACCOUNT_FETCH_SUCCESS: case NOTIFICATIONS_UPDATE: return normalizeAccount(state, action.account); - case SUGGESTIONS_FETCH_SUCCESS: case FOLLOWERS_FETCH_SUCCESS: case FOLLOWERS_EXPAND_SUCCESS: case FOLLOWING_FETCH_SUCCESS: diff --git a/app/assets/javascripts/components/reducers/user_lists.jsx b/app/assets/javascripts/components/reducers/user_lists.jsx index 65598f8a0..3608e4209 100644 --- a/app/assets/javascripts/components/reducers/user_lists.jsx +++ b/app/assets/javascripts/components/reducers/user_lists.jsx @@ -4,7 +4,6 @@ import { FOLLOWING_FETCH_SUCCESS, FOLLOWING_EXPAND_SUCCESS } from '../actions/accounts'; -import { SUGGESTIONS_FETCH_SUCCESS } from '../actions/suggestions'; import { REBLOGS_FETCH_SUCCESS, FAVOURITES_FETCH_SUCCESS @@ -14,7 +13,6 @@ import Immutable from 'immutable'; const initialState = Immutable.Map({ followers: Immutable.Map(), following: Immutable.Map(), - suggestions: Immutable.List(), reblogged_by: Immutable.Map(), favourited_by: Immutable.Map() }); @@ -42,8 +40,6 @@ export default function userLists(state = initialState, action) { return normalizeList(state, 'following', action.id, action.accounts, action.next); case FOLLOWING_EXPAND_SUCCESS: return appendToList(state, 'following', action.id, action.accounts, action.next); - case SUGGESTIONS_FETCH_SUCCESS: - return state.set('suggestions', Immutable.List(action.accounts.map(item => item.id))); case REBLOGS_FETCH_SUCCESS: return state.setIn(['reblogged_by', action.id], Immutable.List(action.accounts.map(item => item.id))); case FAVOURITES_FETCH_SUCCESS: diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 05a309365..bbbeafefe 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -234,3 +234,4 @@ body { @import 'stream_entries'; @import 'components'; @import 'about'; +@import 'tables'; diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index 306f474d6..81270edf6 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -44,15 +44,20 @@ code { label { font-family: 'Roboto'; font-size: 14px; - color: #9baec8; + color: white; display: block; } - input[type=checkbox] { - display: inline-block; + label.checkbox { position: relative; - top: 3px; - margin-right: 8px; + padding-left: 25px; + } + + input[type=checkbox] { + position: absolute; + left: 0; + top: 1px; + margin: 0; } } @@ -161,11 +166,10 @@ code { text-align: center; a { - color: #9baec8; + color: white; text-decoration: none; &:hover { - color: #d9e1e8; text-decoration: underline; } } diff --git a/app/assets/stylesheets/tables.scss b/app/assets/stylesheets/tables.scss new file mode 100644 index 000000000..89b35891d --- /dev/null +++ b/app/assets/stylesheets/tables.scss @@ -0,0 +1,25 @@ +.table { + width: 100%; + max-width: 100%; + border-spacing: 0; + border-collapse: collapse; + + th, td { + padding: 8px; + line-height: 1.42857143; + vertical-align: top; + border-top: 1px solid #ddd; + text-align: left; + } + + & > thead > tr > th { + vertical-align: bottom; + border-bottom: 2px solid #ddd; + border-top: 0; + font-weight: 500; + } +} + +samp { + font-family: 'Roboto Mono', monospace; +} diff --git a/app/controllers/admin/pubsubhubbub_controller.rb b/app/controllers/admin/pubsubhubbub_controller.rb new file mode 100644 index 000000000..7e6bc75ea --- /dev/null +++ b/app/controllers/admin/pubsubhubbub_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Admin::PubsubhubbubController < ApplicationController + before_action :require_admin! + + layout 'public' + + def index + @subscriptions = Subscription.order('id desc').includes(:account).paginate(page: params[:page], per_page: 40) + end +end diff --git a/app/controllers/api/push_controller.rb b/app/controllers/api/push_controller.rb new file mode 100644 index 000000000..78d4e36e6 --- /dev/null +++ b/app/controllers/api/push_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Api::PushController < ApiController + def update + mode = params['hub.mode'] + topic = params['hub.topic'] + callback = params['hub.callback'] + lease_seconds = params['hub.lease_seconds'] + secret = params['hub.secret'] + + case mode + when 'subscribe' + response, status = Pubsubhubbub::SubscribeService.new.call(topic_to_account(topic), callback, secret, lease_seconds) + when 'unsubscribe' + response, status = Pubsubhubbub::UnsubscribeService.new.call(topic_to_account(topic), callback) + else + response = "Unknown mode: #{mode}" + status = 422 + end + + render plain: response, status: status + end + + private + + def topic_to_account(topic_url) + return if topic_url.blank? + + uri = Addressable::URI.parse(topic_url) + params = Rails.application.routes.recognize_path(uri.path) + domain = uri.host + (uri.port ? ":#{uri.port}" : '') + + return unless TagManager.instance.local_domain?(domain) && params[:controller] == 'accounts' && params[:action] == 'show' && params[:format] == 'atom' + + Account.find_local(params[:username]) + end +end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 4ae900583..9a356196c 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -46,19 +46,9 @@ class Api::V1::AccountsController < ApiController render action: :index end - def common_followers - @accounts = @account.common_followers_with(current_user.account) - render action: :index - end - - def suggestions - @accounts = FollowSuggestion.get(current_user.account_id) - render action: :index - end - def statuses @statuses = @account.statuses.paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a - @statuses = cache(@statuses) + @statuses = cache_collection(@statuses, Status) set_maps(@statuses) set_counters_maps(@statuses) @@ -121,23 +111,4 @@ class Api::V1::AccountsController < ApiController @followed_by = Account.followed_by_map([@account.id], current_user.account_id) @blocking = Account.blocking_map([@account.id], current_user.account_id) end - - def cache(raw) - uncached_ids = [] - cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key)) - - raw.each do |status| - uncached_ids << status.id unless cached_keys_with_value.key?(status.cache_key) - end - - unless uncached_ids.empty? - uncached = Status.where(id: uncached_ids).with_includes.map { |s| [s.id, s] }.to_h - - uncached.values.each do |status| - Rails.cache.write(status.cache_key, status) - end - end - - raw.map { |status| cached_keys_with_value[status.cache_key] || uncached[status.id] } - end end diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index d74b99a86..a24e0beb7 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -8,7 +8,7 @@ class Api::V1::NotificationsController < ApiController def index @notifications = Notification.where(account: current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) - @notifications = cache(@notifications) + @notifications = cache_collection(@notifications, Notification) statuses = @notifications.select { |n| !n.target_status.nil? }.map(&:target_status) set_maps(statuses) @@ -20,25 +20,4 @@ class Api::V1::NotificationsController < ApiController set_pagination_headers(next_path, prev_path) end - - private - - def cache(raw) - uncached_ids = [] - cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key)) - - raw.each do |notification| - uncached_ids << notification.id unless cached_keys_with_value.key?(notification.cache_key) - end - - unless uncached_ids.empty? - uncached = Notification.where(id: uncached_ids).with_includes.map { |n| [n.id, n] }.to_h - - uncached.values.each do |notification| - Rails.cache.write(notification.cache_key, notification) - end - end - - raw.map { |notification| cached_keys_with_value[notification.cache_key] || uncached[notification.id] } - end end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index a693ce00d..a0b15cfbc 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -58,7 +58,7 @@ class Api::V1::StatusesController < ApiController def destroy @status = Status.where(account_id: current_user.account).find(params[:id]) - RemoveStatusService.new.call(@status) + RemovalWorker.perform_async(@status.id) render_empty end @@ -68,8 +68,12 @@ class Api::V1::StatusesController < ApiController end def unreblog - RemoveStatusService.new.call(Status.where(account_id: current_user.account, reblog_of_id: params[:id]).first!) - @status = Status.find(params[:id]) + reblog = Status.where(account_id: current_user.account, reblog_of_id: params[:id]).first! + @status = reblog.reblog + @reblogged_map = { @status.id => false } + + RemovalWorker.perform_async(reblog.id) + render action: :show end diff --git a/app/controllers/api/v1/timelines_controller.rb b/app/controllers/api/v1/timelines_controller.rb index b1d7c3052..89e54e2cf 100644 --- a/app/controllers/api/v1/timelines_controller.rb +++ b/app/controllers/api/v1/timelines_controller.rb @@ -8,6 +8,7 @@ class Api::V1::TimelinesController < ApiController def home @statuses = Feed.new(:home, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a + @statuses = cache_collection(@statuses) set_maps(@statuses) set_counters_maps(@statuses) @@ -23,6 +24,7 @@ class Api::V1::TimelinesController < ApiController def mentions @statuses = Feed.new(:mentions, current_account).get(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a + @statuses = cache_collection(@statuses) set_maps(@statuses) set_counters_maps(@statuses) @@ -38,7 +40,7 @@ class Api::V1::TimelinesController < ApiController def public @statuses = Status.as_public_timeline(current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a - @statuses = cache(@statuses) + @statuses = cache_collection(@statuses) set_maps(@statuses) set_counters_maps(@statuses) @@ -55,7 +57,7 @@ class Api::V1::TimelinesController < ApiController def tag @tag = Tag.find_by(name: params[:id].downcase) @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account).paginate_by_max_id(DEFAULT_STATUSES_LIMIT, params[:max_id], params[:since_id]).to_a - @statuses = cache(@statuses) + @statuses = cache_collection(@statuses) set_maps(@statuses) set_counters_maps(@statuses) @@ -71,22 +73,7 @@ class Api::V1::TimelinesController < ApiController private - def cache(raw) - uncached_ids = [] - cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key)) - - raw.each do |status| - uncached_ids << status.id unless cached_keys_with_value.key?(status.cache_key) - end - - unless uncached_ids.empty? - uncached = Status.where(id: uncached_ids).with_includes.map { |s| [s.id, s] }.to_h - - uncached.values.each do |status| - Rails.cache.write(status.cache_key, status) - end - end - - raw.map { |status| cached_keys_with_value[status.cache_key] || uncached[status.id] } + def cache_collection(raw) + super(raw, Status) end end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index a3a2a3275..d2d3bc4a4 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -48,7 +48,7 @@ class ApiController < ApplicationController response.headers['X-RateLimit-Limit'] = match_data[:limit].to_s response.headers['X-RateLimit-Remaining'] = (match_data[:limit] - match_data[:count]).to_s - response.headers['X-RateLimit-Reset'] = (now + (match_data[:period] - now.to_i % match_data[:period])).to_s + response.headers['X-RateLimit-Reset'] = (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6) end def set_pagination_headers(next_path = nil, prev_path = nil) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index effb4ed78..ba0098c71 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -14,7 +14,6 @@ class ApplicationController < ActionController::Base before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? before_action :set_locale - before_action :check_rack_mini_profiler def raise_not_found raise ActionController::RoutingError, "No route matches #{params[:unmatched_route]}" @@ -32,8 +31,8 @@ class ApplicationController < ActionController::Base I18n.locale = I18n.default_locale end - def check_rack_mini_profiler - Rack::MiniProfiler.authorize_request if current_user && current_user.admin? + def require_admin! + redirect_to root_path unless current_user&.admin? end protected @@ -53,4 +52,23 @@ class ApplicationController < ActionController::Base def current_account @current_account ||= current_user.try(:account) end + + def cache_collection(raw, klass) + uncached_ids = [] + cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key)) + + raw.each do |item| + uncached_ids << item.id unless cached_keys_with_value.key?(item.cache_key) + end + + unless uncached_ids.empty? + uncached = klass.where(id: uncached_ids).with_includes.map { |item| [item.id, item] }.to_h + + uncached.values.each do |item| + Rails.cache.write(item.cache_key, item) + end + end + + raw.map { |item| cached_keys_with_value[item.cache_key] || uncached[item.id] }.compact + end end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 5be8719ae..cacc03b65 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -14,7 +14,10 @@ class Settings::PreferencesController < ApplicationController current_user.settings(:notification_emails).favourite = user_params[:notification_emails][:favourite] == '1' current_user.settings(:notification_emails).mention = user_params[:notification_emails][:mention] == '1' - if current_user.update(user_params.except(:notification_emails)) + current_user.settings(:interactions).must_be_follower = user_params[:interactions][:must_be_follower] == '1' + current_user.settings(:interactions).must_be_following = user_params[:interactions][:must_be_following] == '1' + + if current_user.update(user_params.except(:notification_emails, :interactions)) redirect_to settings_preferences_path, notice: I18n.t('generic.changes_saved_msg') else render action: :show @@ -24,6 +27,6 @@ class Settings::PreferencesController < ApplicationController private def user_params - params.require(:user).permit(:locale, notification_emails: [:follow, :reblog, :favourite, :mention]) + params.require(:user).permit(:locale, notification_emails: [:follow, :reblog, :favourite, :mention], interactions: [:must_be_follower, :must_be_following]) end end diff --git a/app/helpers/admin/pubsubhubbub_helper.rb b/app/helpers/admin/pubsubhubbub_helper.rb new file mode 100644 index 000000000..41c874a62 --- /dev/null +++ b/app/helpers/admin/pubsubhubbub_helper.rb @@ -0,0 +1,2 @@ +module Admin::PubsubhubbubHelper +end diff --git a/app/helpers/atom_builder_helper.rb b/app/helpers/atom_builder_helper.rb index 52190adae..13faaa261 100644 --- a/app/helpers/atom_builder_helper.rb +++ b/app/helpers/atom_builder_helper.rb @@ -116,9 +116,9 @@ module AtomBuilderHelper end def link_avatar(xml, account) - single_link_avatar(xml, account, :large, 300) - single_link_avatar(xml, account, :medium, 96) - single_link_avatar(xml, account, :small, 48) + single_link_avatar(xml, account, :large, 300) + # single_link_avatar(xml, account, :medium, 96) + # single_link_avatar(xml, account, :small, 48) end def logo(xml, url) diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 75ee2f8d9..26c4cd58f 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -5,7 +5,9 @@ module SettingsHelper en: 'English', de: 'Deutsch', es: 'Español', + pt: 'Português', fr: 'Français', + hu: 'Magyar', }.freeze def human_locale(locale) diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index c8512476d..b812ad1f4 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -68,30 +68,34 @@ class FeedManager def filter_from_home?(status, receiver) should_filter = false - if status.reply? && !status.thread.account.nil? # Filter out if it's a reply - should_filter = !receiver.following?(status.thread.account) # and I'm not following the person it's a reply to - should_filter &&= !(receiver.id == status.thread.account_id) # and it's not a reply to me - should_filter &&= !(status.account_id == status.thread.account_id) # and it's not a self-reply - elsif status.reblog? # Filter out a reblog - should_filter = receiver.blocking?(status.reblog.account) # if I'm blocking the reblogged person + if status.reply? && !status.thread.account.nil? # Filter out if it's a reply + should_filter = !receiver.following?(status.thread.account) # and I'm not following the person it's a reply to + should_filter &&= !(receiver.id == status.thread.account_id) # and it's not a reply to me + should_filter &&= !(status.account_id == status.thread.account_id) # and it's not a self-reply + elsif status.reblog? # Filter out a reblog + should_filter = receiver.blocking?(status.reblog.account) # if I'm blocking the reblogged person end + should_filter ||= receiver.blocking?(status.mentions.map(&:account_id)) # or if it mentions someone I blocked + should_filter end def filter_from_mentions?(status, receiver) - should_filter = receiver.id == status.account_id # Filter if I'm mentioning myself - should_filter ||= receiver.blocking?(status.account) # or it's from someone I blocked + should_filter = receiver.id == status.account_id # Filter if I'm mentioning myself + should_filter ||= receiver.blocking?(status.account) # or it's from someone I blocked + should_filter ||= receiver.blocking?(status.mentions.includes(:account).map(&:account)) # or if it mentions someone I blocked - if status.reply? && !status.thread.account.nil? # or it's a reply - should_filter ||= receiver.blocking?(status.thread.account) # to a user I blocked + if status.reply? && !status.thread.account.nil? # or it's a reply + should_filter ||= receiver.blocking?(status.thread.account) # to a user I blocked end should_filter end def filter_from_public?(status, receiver) - should_filter = receiver.blocking?(status.account) + should_filter = receiver.blocking?(status.account) + should_filter ||= receiver.blocking?(status.mentions.includes(:account).map(&:account)) if status.reply? && !status.thread.account.nil? should_filter ||= receiver.blocking?(status.thread.account) diff --git a/app/models/account.rb b/app/models/account.rb index 16d654195..0f3d0dda2 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -13,12 +13,12 @@ class Account < ApplicationRecord validates :username, presence: true, uniqueness: { scope: :domain, case_sensitive: true }, unless: 'local?' # Avatar upload - has_attached_file :avatar, styles: { large: '300x300#', medium: '96x96#', small: '48x48#' } + has_attached_file :avatar, styles: { large: '300x300#' }, convert_options: { all: '-strip' } validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES validates_attachment_size :avatar, less_than: 2.megabytes # Header upload - has_attached_file :header, styles: { medium: '700x335#' } + has_attached_file :header, styles: { medium: '700x335#' }, convert_options: { all: '-strip' } validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES validates_attachment_size :header, less_than: 2.megabytes @@ -44,8 +44,12 @@ class Account < ApplicationRecord has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account + # Media has_many :media_attachments, dependent: :destroy + # PuSH subscriptions + has_many :subscriptions, dependent: :destroy + pg_search_scope :search_for, against: { username: 'A', domain: 'B' }, using: { tsearch: { prefix: true } } scope :remote, -> { where.not(domain: nil) } @@ -66,12 +70,12 @@ class Account < ApplicationRecord def unfollow!(other_account) follow = active_relationships.find_by(target_account: other_account) - follow.destroy unless follow.nil? + follow&.destroy end def unblock!(other_account) block = block_relationships.find_by(target_account: other_account) - block.destroy unless block.nil? + block&.destroy end def following?(other_account) @@ -116,7 +120,11 @@ class Account < ApplicationRecord end def avatar_remote_url=(url) - self.avatar = URI.parse(url) unless self[:avatar_remote_url] == url + parsed_url = URI.parse(url) + + return if !%w(http https).include?(parsed_url.scheme) || self[:avatar_remote_url] == url + + self.avatar = parsed_url self[:avatar_remote_url] = url rescue OpenURI::HTTPError => e Rails.logger.debug "Error fetching remote avatar: #{e}" @@ -130,15 +138,6 @@ class Account < ApplicationRecord username end - def common_followers_with(other_account) - results = Neography::Rest.new.execute_query('MATCH (a {account_id: {a_id}})-[:follows]->(b)-[:follows]->(c {account_id: {c_id}}) RETURN b.account_id', a_id: id, c_id: other_account.id) - ids = results['data'].map(&:first) - accounts = Account.where(id: ids).with_counters.limit(20).map { |a| [a.id, a] }.to_h - ids.map { |id| accounts[id] }.compact - rescue Neography::NeographyError, Excon::Error::Socket - [] - end - class << self def find_local!(username) find_remote!(username, nil) diff --git a/app/models/feed.rb b/app/models/feed.rb index 45cb923d1..7b181d529 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -16,8 +16,8 @@ class Feed RegenerationWorker.perform_async(@account.id, @type) @statuses = Status.send("as_#{@type}_timeline", @account).paginate_by_max_id(limit, nil, nil) else - status_map = cache(unhydrated) - @statuses = unhydrated.map { |id| status_map[id] }.compact + status_map = Status.where(id: unhydrated).map { |s| [s.id, s] }.to_h + @statuses = unhydrated.map { |id| status_map[id] }.compact end @statuses @@ -25,29 +25,6 @@ class Feed private - def cache(ids) - raw = Status.where(id: ids).to_a - uncached_ids = [] - cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key)) - - raw.each do |status| - uncached_ids << status.id unless cached_keys_with_value.key?(status.cache_key) - end - - unless uncached_ids.empty? - uncached = Status.where(id: uncached_ids).with_includes.map { |s| [s.id, s] }.to_h - - uncached.values.each do |status| - Rails.cache.write(status.cache_key, status) - end - end - - cached = cached_keys_with_value.values.map { |s| [s.id, s] }.to_h - cached.merge!(uncached) unless uncached_ids.empty? - - cached - end - def key FeedManager.instance.key(@type, @account.id) end diff --git a/app/models/follow.rb b/app/models/follow.rb index cc5bceb75..f83490caa 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -27,32 +27,4 @@ class Follow < ApplicationRecord def title destroyed? ? "#{account.acct} is no longer following #{target_account.acct}" : "#{account.acct} started following #{target_account.acct}" end - - after_create :add_to_graph - after_destroy :remove_from_graph - - def sync! - add_to_graph - end - - private - - def add_to_graph - neo = Neography::Rest.new - - a = neo.create_unique_node('account_index', 'Account', account_id.to_s, account_id: account_id) - b = neo.create_unique_node('account_index', 'Account', target_account_id.to_s, account_id: target_account_id) - - neo.create_unique_relationship('follow_index', 'Follow', id.to_s, 'follows', a, b) - rescue Neography::NeographyError, Excon::Error::Socket => e - Rails.logger.error e - end - - def remove_from_graph - neo = Neography::Rest.new - rel = neo.get_relationship_index('follow_index', 'Follow', id.to_s) - neo.delete_relationship(rel) - rescue Neography::NeographyError, Excon::Error::Socket => e - Rails.logger.error e - end end diff --git a/app/models/follow_suggestion.rb b/app/models/follow_suggestion.rb deleted file mode 100644 index 2daa40dcb..000000000 --- a/app/models/follow_suggestion.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -class FollowSuggestion - class << self - def get(for_account_id, limit = 10) - neo = Neography::Rest.new - - query = <<END -MATCH (a {account_id: {id}})-[:follows]->(b)-[:follows]->(c) -WHERE a <> c -AND NOT (a)-[:follows]->(c) -RETURN DISTINCT c.account_id, count(b), c.nodeRank -ORDER BY count(b) DESC, c.nodeRank DESC -LIMIT {limit} -END - - results = neo.execute_query(query, id: for_account_id, limit: limit) - - if results.empty? || results['data'].empty? - results = fallback(for_account_id, limit) - elsif results['data'].size < limit - results['data'] = (results['data'] + fallback(for_account_id, limit - results['data'].size)['data']).uniq - end - - account_ids = results['data'].map(&:first) - blocked_ids = Block.where(account_id: for_account_id).pluck(:target_account_id) - accounts_map = Account.where(id: account_ids - blocked_ids).with_counters.map { |a| [a.id, a] }.to_h - - account_ids.map { |id| accounts_map[id] }.compact - rescue Neography::NeographyError, Excon::Error::Socket => e - Rails.logger.error e - return [] - end - - private - - def fallback(for_account_id, limit) - neo = Neography::Rest.new - - query = <<END -MATCH (b) -RETURN b.account_id -ORDER BY b.nodeRank DESC -LIMIT {limit} -END - - neo.execute_query(query, id: for_account_id, limit: limit) - end - end -end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index bfbf00d76..f1b9b8112 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -16,6 +16,8 @@ class MediaAttachment < ApplicationRecord validates :account, presence: true + default_scope { order('id asc') } + def local? remote_url.blank? end diff --git a/app/models/status.rb b/app/models/status.rb index 3402929bf..f9dcd97e4 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -97,7 +97,10 @@ class Status < ApplicationRecord end def as_public_timeline(account = nil) - query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id').where('accounts.silenced = FALSE') + query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id') + .where('accounts.silenced = FALSE') + .where('statuses.in_reply_to_id IS NULL') + .where('statuses.reblog_of_id IS NULL') query = filter_timeline(query, account) unless account.nil? query end @@ -106,6 +109,8 @@ class Status < ApplicationRecord query = tag.statuses .joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id') .where('accounts.silenced = FALSE') + .where('statuses.in_reply_to_id IS NULL') + .where('statuses.reblog_of_id IS NULL') query = filter_timeline(query, account) unless account.nil? query end @@ -123,13 +128,7 @@ class Status < ApplicationRecord def filter_timeline(query, account) blocked = Block.where(account: account).pluck(:target_account_id) return query if blocked.empty? - - query - .joins('LEFT OUTER JOIN statuses AS parents ON statuses.in_reply_to_id = parents.id') - .joins('LEFT OUTER JOIN statuses AS reblogs ON statuses.reblog_of_id = reblogs.id') - .where('statuses.account_id NOT IN (?)', blocked) - .where('(parents.id IS NULL OR parents.account_id NOT IN (?))', blocked) - .where('(reblogs.id IS NULL OR reblogs.account_id NOT IN (?))', blocked) + query.where('statuses.account_id NOT IN (?)', blocked) end end diff --git a/app/models/subscription.rb b/app/models/subscription.rb new file mode 100644 index 000000000..497cabb09 --- /dev/null +++ b/app/models/subscription.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Subscription < ApplicationRecord + MIN_EXPIRATION = 3600 * 24 * 7 + MAX_EXPIRATION = 3600 * 24 * 30 + + belongs_to :account + + validates :callback_url, presence: true + validates :callback_url, uniqueness: { scope: :account_id } + + scope :active, -> { where(confirmed: true).where('expires_at > ?', Time.now.utc) } + + def lease_seconds=(str) + self.expires_at = Time.now.utc + [[MIN_EXPIRATION, str.to_i].max, MAX_EXPIRATION].min.seconds + end + + def lease_seconds + (expires_at - Time.now.utc).to_i + end + + before_validation :set_min_expiration + + private + + def set_min_expiration + self.lease_seconds = 0 unless expires_at + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 366172e9a..423833d47 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -15,6 +15,7 @@ class User < ApplicationRecord has_settings do |s| s.key :notification_emails, defaults: { follow: false, reblog: false, favourite: false, mention: false } + s.key :interactions, defaults: { must_be_follower: false, must_be_following: false } end def send_devise_notification(notification, *args) diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 78301c6ca..40d8a0fee 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -41,14 +41,17 @@ class FanOutOnWriteService < BaseService end def deliver_to_hashtags(status) - Rails.logger.debug "Delivering status #{status.id} to hashtags" + return if status.reblog? || status.reply? + Rails.logger.debug "Delivering status #{status.id} to hashtags" status.tags.find_each do |tag| FeedManager.instance.broadcast("hashtag:#{tag.name}", type: 'update', id: status.id) end end def deliver_to_public(status) + return if status.reblog? || status.reply? + Rails.logger.debug "Delivering status #{status.id} to public timeline" FeedManager.instance.broadcast(:public, type: 'update', id: status.id) end diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index 781b03b40..2f280e03f 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -7,7 +7,9 @@ class FavouriteService < BaseService # @return [Favourite] def call(account, status) favourite = Favourite.create!(account: account, status: status) + HubPingWorker.perform_async(account.id) + Pubsubhubbub::DistributionWorker.perform_async(favourite.stream_entry.id) if status.local? NotifyService.new.call(status.account, favourite) diff --git a/app/services/follow_remote_account_service.rb b/app/services/follow_remote_account_service.rb index 37339d8ed..f640222b0 100644 --- a/app/services/follow_remote_account_service.rb +++ b/app/services/follow_remote_account_service.rb @@ -80,8 +80,7 @@ class FollowRemoteAccountService < BaseService end def get_profile(xml, account) - author = xml.at_xpath('/xmlns:feed/xmlns:author') || xml.at_xpath('/xmlns:feed').at_xpath('./dfrn:owner', dfrn: DFRN_NS) - update_remote_profile_service.call(author, account) + update_remote_profile_service.call(xml.at_xpath('/xmlns:feed'), account) end def update_remote_profile_service diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index a57e1b28a..09fa295e3 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -19,7 +19,10 @@ class FollowService < BaseService end merge_into_timeline(target_account, source_account) + HubPingWorker.perform_async(source_account.id) + Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id) + follow end diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 772adfb90..1efd326b0 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -36,6 +36,8 @@ class NotifyService < BaseService blocked = false blocked ||= @recipient.id == @notification.from_account.id blocked ||= @recipient.blocking?(@notification.from_account) + blocked ||= (@recipient.user.settings(:interactions).must_be_follower && !@notification.from_account.following?(@recipient)) + blocked ||= (@recipient.user.settings(:interactions).must_be_following && !@recipient.following?(@notification.from_account)) blocked ||= send("blocked_#{@notification.type}?") blocked end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 76366e984..979a157e9 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -14,8 +14,11 @@ class PostStatusService < BaseService attach_media(status, options[:media_ids]) process_mentions_service.call(status) process_hashtags_service.call(status) + DistributionWorker.perform_async(status.id) HubPingWorker.perform_async(account.id) + Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) + status end diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb index 1cd801b80..a7a4cb2b0 100644 --- a/app/services/process_feed_service.rb +++ b/app/services/process_feed_service.rb @@ -16,7 +16,7 @@ class ProcessFeedService < BaseService def update_author(xml, account) return if xml.at_xpath('/xmlns:feed').nil? - UpdateRemoteProfileService.new.call(xml.at_xpath('/xmlns:feed/xmlns:author'), account) + UpdateRemoteProfileService.new.call(xml.at_xpath('/xmlns:feed'), account, true) end def process_entries(xml, account) diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb index 3bf3471ec..fa14c44da 100644 --- a/app/services/process_hashtags_service.rb +++ b/app/services/process_hashtags_service.rb @@ -4,7 +4,7 @@ class ProcessHashtagsService < BaseService def call(status, tags = []) tags = status.text.scan(Tag::HASHTAG_RE).map(&:first) if status.local? - tags.map(&:downcase).uniq.each do |tag| + tags.map { |str| str.mb_chars.downcase }.uniq.each do |tag| status.tags << Tag.where(name: tag).first_or_initialize(name: tag) end end diff --git a/app/services/process_interaction_service.rb b/app/services/process_interaction_service.rb index e7bb3c73b..6b2f6e2d2 100644 --- a/app/services/process_interaction_service.rb +++ b/app/services/process_interaction_service.rb @@ -26,7 +26,7 @@ class ProcessInteractionService < BaseService end if salmon.verify(envelope, account.keypair) - update_remote_profile_service.call(xml.at_xpath('/xmlns:entry/xmlns:author'), account) + update_remote_profile_service.call(xml.at_xpath('/xmlns:entry'), account, true) case verb(xml) when :follow @@ -74,7 +74,7 @@ class ProcessInteractionService < BaseService end def delete_post!(xml, account) - status = Status.find(activity_id(xml)) + status = Status.find(xml.at_xpath('//xmlns:id').content) return if status.nil? diff --git a/app/services/pubsubhubbub/subscribe_service.rb b/app/services/pubsubhubbub/subscribe_service.rb new file mode 100644 index 000000000..343376d77 --- /dev/null +++ b/app/services/pubsubhubbub/subscribe_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Pubsubhubbub::SubscribeService < BaseService + def call(account, callback, secret, lease_seconds) + return ['Invalid topic URL', 422] if account.nil? + return ['Invalid callback URL', 422] unless !callback.blank? && callback =~ /\A#{URI.regexp(%w(http https))}\z/ + + subscription = Subscription.where(account: account, callback_url: callback).first_or_create!(account: account, callback_url: callback) + Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'subscribe', secret, lease_seconds) + + ['', 202] + end +end diff --git a/app/services/pubsubhubbub/unsubscribe_service.rb b/app/services/pubsubhubbub/unsubscribe_service.rb new file mode 100644 index 000000000..62459a0aa --- /dev/null +++ b/app/services/pubsubhubbub/unsubscribe_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Pubsubhubbub::UnsubscribeService < BaseService + def call(account, callback) + return ['Invalid topic URL', 422] if account.nil? + + subscription = Subscription.where(account: account, callback_url: callback) + + unless subscription.nil? + Pubsubhubbub::ConfirmationWorker.perform_async(subscription.id, 'unsubscribe') + end + + ['', 202] + end +end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 6543d4ae7..39fdb4ea7 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -7,8 +7,10 @@ class ReblogService < BaseService # @return [Status] def call(account, reblogged_status) reblog = account.statuses.create!(reblog: reblogged_status, text: '') + DistributionWorker.perform_async(reblog.id) HubPingWorker.perform_async(account.id) + Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) if reblogged_status.local? NotifyService.new.call(reblogged_status.account, reblog) diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 689abc97b..4e03661da 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -10,6 +10,11 @@ class RemoveStatusService < BaseService remove_from_public(status) status.destroy! + + if status.account.local? + HubPingWorker.perform_async(status.account.id) + Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) + end end private diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 598c7d02c..1ae1d5a80 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -2,9 +2,9 @@ class SearchService < BaseService def call(query, limit, resolve = false) - return if query.blank? + return if query.blank? || query.start_with?('#') - username, domain = query.split('@') + username, domain = query.gsub(/\A@/, '').split('@') results = if domain.nil? Account.search_for(username) diff --git a/app/services/update_remote_profile_service.rb b/app/services/update_remote_profile_service.rb index 2909ae12a..56b25816f 100644 --- a/app/services/update_remote_profile_service.rb +++ b/app/services/update_remote_profile_service.rb @@ -2,24 +2,24 @@ class UpdateRemoteProfileService < BaseService POCO_NS = 'http://portablecontacts.net/spec/1.0' + DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0' - def call(author_xml, account) - return if author_xml.nil? + def call(xml, account, resubscribe = false) + return if xml.nil? - account.display_name = if author_xml.at_xpath('./poco:displayName', poco: POCO_NS).nil? - account.username - else - author_xml.at_xpath('./poco:displayName', poco: POCO_NS).content - end + author_xml = xml.at_xpath('./xmlns:author') || xml.at_xpath('./dfrn:owner', dfrn: DFRN_NS) + hub_link = xml.at_xpath('./xmlns:link[@rel="hub"]') - unless author_xml.at_xpath('./poco:note').nil? - account.note = author_xml.at_xpath('./poco:note', poco: POCO_NS).content - end - - unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]').nil? - account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]').attribute('href').value + unless author_xml.nil? + account.display_name = author_xml.at_xpath('./poco:displayName', poco: POCO_NS).content unless author_xml.at_xpath('./poco:displayName', poco: POCO_NS).nil? + account.note = author_xml.at_xpath('./poco:note', poco: POCO_NS).content unless author_xml.at_xpath('./poco:note').nil? + account.avatar_remote_url = author_xml.at_xpath('./xmlns:link[@rel="avatar"]')['href'] unless author_xml.at_xpath('./xmlns:link[@rel="avatar"]').nil? || author_xml.at_xpath('./xmlns:link[@rel="avatar"]')['href'].blank? end + old_hub_url = account.hub_url + account.hub_url = hub_link['href'] if !hub_link.nil? && !hub_link['href'].blank? && (hub_link['href'] != old_hub_url) account.save! + + SubscribeService.new.call(account) if resubscribe && (account.hub_url != old_hub_url) end end diff --git a/app/views/accounts/show.atom.ruby b/app/views/accounts/show.atom.ruby index d7b2201d4..558c777f0 100644 --- a/app/views/accounts/show.atom.ruby +++ b/app/views/accounts/show.atom.ruby @@ -1,3 +1,5 @@ +# frozen_string_literal: true + Nokogiri::XML::Builder.new do |xml| feed(xml) do simple_id xml, account_url(@account, format: 'atom') @@ -12,6 +14,7 @@ Nokogiri::XML::Builder.new do |xml| link_alternate xml, TagManager.instance.url_for(@account) link_self xml, account_url(@account, format: 'atom') + link_hub xml, api_push_url link_hub xml, Rails.configuration.x.hub_url link_salmon xml, api_salmon_url(@account.id) diff --git a/app/views/admin/pubsubhubbub/index.html.haml b/app/views/admin/pubsubhubbub/index.html.haml new file mode 100644 index 000000000..bb897eb89 --- /dev/null +++ b/app/views/admin/pubsubhubbub/index.html.haml @@ -0,0 +1,20 @@ +%table.table + %thead + %tr + %th Topic + %th Callback URL + %th Confirmed + %th Expires in + %tbody + - @subscriptions.each do |subscription| + %tr + %td + %samp= subscription.account.acct + %td + %samp= subscription.callback_url + %td + - if subscription.confirmed? + %i.fa.fa-check + %td= distance_of_time_in_words(Time.now, subscription.expires_at) + += will_paginate @subscriptions, pagination_options diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index 693702ff7..db5b9fb48 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -12,6 +12,10 @@ = ff.input :favourite, as: :boolean, wrapper: :with_label = ff.input :mention, as: :boolean, wrapper: :with_label + = f.simple_fields_for :interactions, current_user.settings(:interactions) do |ff| + = ff.input :must_be_follower, as: :boolean, wrapper: :with_label + = ff.input :must_be_following, as: :boolean, wrapper: :with_label + .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/workers/processing_worker.rb b/app/workers/processing_worker.rb index 3b11a4c5e..b31cd0aaf 100644 --- a/app/workers/processing_worker.rb +++ b/app/workers/processing_worker.rb @@ -2,6 +2,8 @@ class ProcessingWorker include Sidekiq::Worker + + sidekiq_options backtrace: true def perform(account_id, body) ProcessFeedService.new.call(body, Account.find(account_id)) diff --git a/app/workers/pubsubhubbub/confirmation_worker.rb b/app/workers/pubsubhubbub/confirmation_worker.rb new file mode 100644 index 000000000..489bd8359 --- /dev/null +++ b/app/workers/pubsubhubbub/confirmation_worker.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class Pubsubhubbub::ConfirmationWorker + include Sidekiq::Worker + include RoutingHelper + + sidekiq_options queue: 'push' + + def perform(subscription_id, mode, secret = nil, lease_seconds = nil) + subscription = Subscription.find(subscription_id) + challenge = SecureRandom.hex + + subscription.secret = secret + subscription.lease_seconds = lease_seconds + subscription.confirmed = true + + response = HTTP.headers(user_agent: 'Mastodon/PubSubHubbub') + .timeout(:per_operation, write: 20, connect: 20, read: 50) + .get(subscription.callback_url, params: { + 'hub.topic' => account_url(subscription.account, format: :atom), + 'hub.mode' => mode, + 'hub.challenge' => challenge, + 'hub.lease_seconds' => subscription.lease_seconds, + }) + + body = response.body.to_s + + Rails.logger.debug "Confirming PuSH subscription for #{subscription.callback_url} with challenge #{challenge}: #{body}" + + if mode == 'subscribe' && body == challenge + subscription.save! + elsif (mode == 'unsubscribe' && body == challenge) || !subscription.confirmed? + subscription.destroy! + end + end +end diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb new file mode 100644 index 000000000..6d526c2b1 --- /dev/null +++ b/app/workers/pubsubhubbub/delivery_worker.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Pubsubhubbub::DeliveryWorker + include Sidekiq::Worker + include RoutingHelper + + sidekiq_options queue: 'push' + + def perform(subscription_id, payload) + subscription = Subscription.find(subscription_id) + headers = {} + + headers['User-Agent'] = 'Mastodon/PubSubHubbub' + headers['Link'] = LinkHeader.new([[api_push_url, [%w(rel hub)]], [account_url(subscription.account, format: :atom), [%w(rel self)]]]).to_s + headers['X-Hub-Signature'] = signature(subscription.secret, payload) unless subscription.secret.blank? + + response = HTTP.timeout(:per_operation, write: 50, connect: 20, read: 50) + .headers(headers) + .post(subscription.callback_url, body: payload) + + raise "Delivery failed for #{subscription.callback_url}: HTTP #{response.code}" unless response.code > 199 && response.code < 300 + end + + private + + def signature(secret, payload) + hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), secret, payload) + "sha1=#{hmac}" + end +end diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb new file mode 100644 index 000000000..b0ddc71c1 --- /dev/null +++ b/app/workers/pubsubhubbub/distribution_worker.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Pubsubhubbub::DistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(stream_entry_id) + stream_entry = StreamEntry.find(stream_entry_id) + account = stream_entry.account + renderer = AccountsController.renderer.new(method: 'get', http_host: Rails.configuration.x.local_domain, https: Rails.configuration.x.use_https) + payload = renderer.render(:show, assigns: { account: account, entries: [stream_entry] }, formats: [:atom]) + + Subscription.where(account: account).active.select('id').find_each do |subscription| + Pubsubhubbub::DeliveryWorker.perform_async(subscription.id, payload) + end + end +end diff --git a/app/workers/removal_worker.rb b/app/workers/removal_worker.rb new file mode 100644 index 000000000..7470c54f5 --- /dev/null +++ b/app/workers/removal_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class RemovalWorker + include Sidekiq::Worker + + def perform(status_id) + RemoveStatusService.new.call(Status.find(status_id)) + end +end \ No newline at end of file diff --git a/app/workers/salmon_worker.rb b/app/workers/salmon_worker.rb index 24fb94012..0903ca487 100644 --- a/app/workers/salmon_worker.rb +++ b/app/workers/salmon_worker.rb @@ -2,6 +2,8 @@ class SalmonWorker include Sidekiq::Worker + + sidekiq_options backtrace: true def perform(account_id, body) ProcessInteractionService.new.call(body, Account.find(account_id)) diff --git a/app/workers/thread_resolve_worker.rb b/app/workers/thread_resolve_worker.rb index 700161989..84eae73be 100644 --- a/app/workers/thread_resolve_worker.rb +++ b/app/workers/thread_resolve_worker.rb @@ -7,9 +7,9 @@ class ThreadResolveWorker child_status = Status.find(child_status_id) parent_status = FetchRemoteStatusService.new.call(parent_url) - unless parent_status.nil? - child_status.thread = parent_status - child_status.save! - end + return if parent_status.nil? + + child_status.thread = parent_status + child_status.save! end end |