Merge branch 'master' into master

This commit is contained in:
André Lewin 2017-04-05 20:28:58 +02:00 committed by GitHub
commit bf7cefa516
117 changed files with 1783 additions and 276 deletions

2
.buildpacks Normal file
View File

@ -0,0 +1,2 @@
https://github.com/Scalingo/nodejs-buildpack
https://github.com/Scalingo/ruby-buildpack

View File

@ -22,6 +22,8 @@ OTP_SECRET=
# SINGLE_USER_MODE=true
# Prevent registrations with following e-mail domains
# EMAIL_DOMAIN_BLACKLIST=example1.com|example2.de|etc
# Only allow registrations with the following e-mail domains
# EMAIL_DOMAIN_WHITELIST=example1.com|example2.de|etc
# E-mail configuration
SMTP_SERVER=smtp.mailgun.org

2
.slugignore Normal file
View File

@ -0,0 +1,2 @@
node_modules/
.cache/

View File

@ -8,8 +8,6 @@ gem 'sass-rails', '~> 5.0'
gem 'uglifier', '>= 1.3.0'
gem 'coffee-rails', '~> 4.1.0'
gem 'jquery-rails'
gem 'jbuilder', '~> 2.0'
gem 'sdoc', '~> 0.4.0', group: :doc
gem 'puma'
gem 'hamlit-rails'
@ -38,7 +36,7 @@ gem 'rqrcode'
gem 'twitter-text'
gem 'oj'
gem 'hiredis'
gem 'redis', '~>3.2'
gem 'redis', '~>3.2', require: ['redis', 'redis/connection/hiredis']
gem 'fast_blank'
gem 'htmlentities'
gem 'simple_form'
@ -46,6 +44,7 @@ gem 'will_paginate'
gem 'rack-attack'
gem 'rack-cors', require: 'rack/cors'
gem 'sidekiq'
gem 'sidekiq-unique-jobs'
gem 'rails-settings-cached'
gem 'simple-navigation'
gem 'statsd-instrument'
@ -66,9 +65,10 @@ group :development, :test do
end
group :test do
gem 'faker'
gem 'rspec-sidekiq'
gem 'simplecov', require: false
gem 'webmock'
gem 'rspec-sidekiq'
end
group :development do

View File

@ -149,6 +149,8 @@ GEM
erubis (2.7.0)
execjs (2.7.0)
fabrication (2.15.2)
faker (1.6.6)
i18n (~> 0.5)
fast_blank (1.0.0)
font-awesome-rails (4.6.3.1)
railties (>= 3.2, < 5.1)
@ -196,9 +198,6 @@ GEM
parser (>= 2.2.3.0)
term-ansicolor (>= 1.3.2)
terminal-table (>= 1.5.1)
jbuilder (2.6.0)
activesupport (>= 3.0.0, < 5.1)
multi_json (~> 1.2)
jmespath (1.3.1)
jquery-rails (4.1.1)
rails-dom-testing (>= 1, < 3)
@ -229,7 +228,6 @@ GEM
mimemagic (0.3.2)
mini_portile2 (2.1.0)
minitest (5.10.1)
multi_json (1.12.1)
net-scp (1.2.1)
net-ssh (>= 2.6.5)
net-ssh (4.0.1)
@ -308,8 +306,6 @@ GEM
thor (>= 0.18.1, < 2.0)
rainbow (2.1.0)
rake (12.0.0)
rdoc (4.2.2)
json (~> 1.4)
react-rails (1.10.0)
babel-transpiler (>= 0.7.0)
coffee-script-source (~> 1.8)
@ -379,14 +375,14 @@ GEM
sprockets (>= 2.8, < 4.0)
sprockets-rails (>= 2.0, < 4.0)
tilt (>= 1.1, < 3)
sdoc (0.4.1)
json (~> 1.7, >= 1.7.7)
rdoc (~> 4.0)
sidekiq (4.2.7)
concurrent-ruby (~> 1.0)
connection_pool (~> 2.2, >= 2.2.0)
rack-protection (>= 1.5.0)
redis (~> 3.2, >= 3.2.1)
sidekiq-unique-jobs (4.0.18)
sidekiq (>= 2.6)
thor
simple-navigation (4.0.3)
activesupport (>= 2.3.2)
simple_form (3.2.1)
@ -467,6 +463,7 @@ DEPENDENCIES
doorkeeper
dotenv-rails
fabrication
faker
fast_blank
font-awesome-rails
fuubar
@ -477,7 +474,6 @@ DEPENDENCIES
http
httplog
i18n-tasks (~> 0.9.6)
jbuilder (~> 2.0)
jquery-rails
letter_opener
letter_opener_web
@ -508,8 +504,8 @@ DEPENDENCIES
rubocop
ruby-oembed
sass-rails (~> 5.0)
sdoc (~> 0.4.0)
sidekiq
sidekiq-unique-jobs
simple-navigation
simple_form
simplecov

5
ISSUE_TEMPLATE.md Normal file
View File

@ -0,0 +1,5 @@
[Issue text goes here].
* * * *
- [ ] I searched or browsed the repos other issues to ensure this is not a duplicate.

View File

@ -117,6 +117,12 @@ Which will re-create the updated containers, leaving databases and data as is. D
Docker is great for quickly trying out software, but it has its drawbacks too. If you prefer to run Mastodon without using Docker, refer to the [production guide](docs/Running-Mastodon/Production-guide.md) for examples, configuration and instructions.
## Deployment on Scalingo
[![Deploy on Scalingo](https://cdn.scalingo.com/deploy/button.svg)](https://my.scalingo.com/deploy?source=https://github.com/tootsuite/mastodon#master)
[You can view a guide for deployment on Scalingo here.](docs/Running-Mastodon/Scalingo-guide.md)
## Deployment on Heroku (experimental)
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 59 KiB

View File

@ -46,6 +46,7 @@ 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 uk from 'react-intl/locale-data/uk';
import fi from 'react-intl/locale-data/fi';
import eo from 'react-intl/locale-data/eo';
import getMessagesForLocale from '../locales';
import { hydrateStore } from '../actions/store';
@ -59,7 +60,7 @@ const browserHistory = useRouterHistory(createBrowserHistory)({
basename: '/web'
});
addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk, ...eo]);
addLocaleData([...en, ...de, ...es, ...fr, ...pt, ...hu, ...uk, ...fi, ...eo]);
const Mastodon = React.createClass({

View File

@ -43,7 +43,7 @@ const GettingStarted = ({ intl, me }) => {
<div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}>
<div className='static-content getting-started'>
<p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p>
<p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/mastodon/blob/master/docs/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p>
</div>
</div>
</Column>

View File

@ -9,7 +9,7 @@ const iconStyle = {
};
const ClearColumnButton = ({ onClick }) => (
<div className='column-icon' style={iconStyle} onClick={onClick}>
<div className='column-icon' tabindex='0' style={iconStyle} onClick={onClick}>
<i className='fa fa-trash' />
</div>
);

View File

@ -25,7 +25,7 @@ const en = {
"getting_started.heading": "Getting started",
"getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.",
"getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.",
"getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}. {apps}.",
"getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.",
"column.home": "Home",
"column.community": "Local timeline",
"column.public": "Federated timeline",

View File

@ -0,0 +1,68 @@
const fi = {
"column_back_button.label": "Takaisin",
"lightbox.close": "Sulje",
"loading_indicator.label": "Ladataan...",
"status.mention": "Mainitse @{name}",
"status.delete": "Poista",
"status.reply": "Vastaa",
"status.reblog": "Buustaa",
"status.favourite": "Tykkää",
"status.reblogged_by": "{name} buustasi",
"status.sensitive_warning": "Arkaluontoista sisältöä",
"status.sensitive_toggle": "Klikkaa nähdäksesi",
"video_player.toggle_sound": "Äänet päälle/pois",
"account.mention": "Mainitse @{name}",
"account.edit_profile": "Muokkaa",
"account.unblock": "Salli @{name}",
"account.unfollow": "Lopeta seuraaminen",
"account.block": "Estä @{name}",
"account.follow": "Seuraa",
"account.posts": "Postit",
"account.follows": "Seuraa",
"account.followers": "Seuraajia",
"account.follows_you": "Seuraa sinua",
"account.requested": "Odottaa hyväksyntää",
"getting_started.heading": "Aloitus",
"getting_started.about_addressing": "Voit seurata ihmisiä jos tiedät heidän käyttäjänimensä ja domainin missä he ovat syöttämällä e-mail-esque osoitteen Etsi kenttään.",
"getting_started.about_shortcuts": "Jos etsimäsi henkilö on samassa domainissa kuin sinä, pelkkä käyttäjänimi kelpaa. Sama pätee kun mainitset ihmisiä statuksessasi",
"getting_started.open_source_notice": "Mastodon Mastodon on avoimen lähdekoodin ohjelma. Voit avustaa tai raportoida ongelmia GitHub palvelussa {github}. {apps}.",
"column.home": "Koti",
"column.community": "Paikallinen aikajana",
"column.public": "Yleinen aikajana",
"column.notifications": "Ilmoitukset",
"tabs_bar.compose": "Luo",
"tabs_bar.home": "Koti",
"tabs_bar.mentions": "Maininnat",
"tabs_bar.public": "Yleinen aikajana",
"tabs_bar.notifications": "Ilmoitukset",
"compose_form.placeholder": "Mitä sinulla on mielessä?",
"compose_form.publish": "Toot",
"compose_form.sensitive": "Merkitse media herkäksi",
"compose_form.spoiler": "Piiloita teksti varoituksen taakse",
"compose_form.private": "Merkitse yksityiseksi",
"compose_form.privacy_disclaimer": "Sinun yksityinen status toimitetaan mainitsemallesi käyttäjille domaineissa {domains}. Luotatko {domainsCount, plural, one {tähän palvelimeen} other {näihin palvelimiin}}? Postauksen yksityisyys toimii van Mastodon palvelimilla. Jos {domains} {domainsCount, plural, one {ei ole Mastodon palvelin} other {eivät ole Mastodon palvelin}}, viestiin ei tule Yksityinen-merkintää, ja sitä voidaan boostata tai muuten tehdä näkyväksi muille vastaanottajille.",
"compose_form.unlisted": "Älä näytä yleisillä aikajanoilla",
"navigation_bar.edit_profile": "Muokkaa profiilia",
"navigation_bar.preferences": "Ominaisuudet",
"navigation_bar.community_timeline": "Paikallinen aikajana",
"navigation_bar.public_timeline": "Yleinen aikajana",
"navigation_bar.logout": "Kirjaudu ulos",
"reply_indicator.cancel": "Peruuta",
"search.placeholder": "Hae",
"search.account": "Tili",
"search.hashtag": "Hashtag",
"upload_button.label": "Lisää mediaa",
"upload_form.undo": "Peru",
"notification.follow": "{name} seurasi sinua",
"notification.favourite": "{name} tykkäsi statuksestasi",
"notification.reblog": "{name} buustasi statustasi",
"notification.mention": "{name} mainitsi sinut",
"notifications.column_settings.alert": "Työpöytä ilmoitukset",
"notifications.column_settings.show": "Näytä sarakkeessa",
"notifications.column_settings.follow": "Uusia seuraajia:",
"notifications.column_settings.favourite": "Tykkäyksiä:",
"notifications.column_settings.mention": "Mainintoja:",
"notifications.column_settings.reblog": "Buusteja:",
};
export default fi;

View File

@ -5,6 +5,7 @@ import hu from './hu';
import fr from './fr';
import pt from './pt';
import uk from './uk';
import fi from './fi';
import eo from './eo';
const locales = {
@ -15,6 +16,7 @@ const locales = {
fr,
pt,
uk,
fi,
eo
};

View File

@ -319,7 +319,7 @@
}
}
.simple_form {
.simple_form, .closed-registrations-message {
width: 300px;
flex: 0 0 auto;
background: rgba(darken($color1, 7%), 0.5);
@ -340,3 +340,11 @@
}
}
}
.closed-registrations-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}

View File

@ -34,6 +34,7 @@
text-align: center;
position: relative;
z-index: 2;
text-shadow: 0 0 2px $color8;
small {
display: block;
@ -128,6 +129,7 @@
text-transform: uppercase;
display: block;
margin-bottom: 5px;
text-shadow: 0 0 2px $color8;
}
.counter-number {
@ -385,5 +387,6 @@
.account__header__content {
font-size: 14px;
color: $color1;
text-shadow: 0 0 2px $color8;
}
}

View File

@ -4,7 +4,9 @@ class AboutController < ApplicationController
before_action :set_body_classes
def index
@description = Setting.site_description
@description = Setting.site_description
@open_registrations = Setting.open_registrations
@closed_registrations_message = Setting.closed_registrations_message
@user = User.new
@user.build_account

View File

@ -9,6 +9,24 @@ class Admin::DomainBlocksController < ApplicationController
@blocks = DomainBlock.paginate(page: params[:page], per_page: 40)
end
def new
@domain_block = DomainBlock.new
end
def create
@domain_block = DomainBlock.new(resource_params)
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id)
redirect_to admin_domain_blocks_path, notice: 'Domain block is now being processed'
else
render action: :new
end
end
private
def resource_params
params.require(:domain_block).permit(:domain, :severity)
end
end

View File

@ -16,19 +16,19 @@ class Admin::ReportsController < ApplicationController
end
def resolve
@report.update(action_taken: true)
@report.update(action_taken: true, action_taken_by_account_id: current_account.id)
redirect_to admin_report_path(@report)
end
def suspend
Admin::SuspensionWorker.perform_async(@report.target_account.id)
@report.update(action_taken: true)
Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id)
redirect_to admin_report_path(@report)
end
def silence
@report.target_account.update(silenced: true)
@report.update(action_taken: true)
Report.unresolved.where(target_account: @report.target_account).update_all(action_taken: true, action_taken_by_account_id: current_account.id)
redirect_to admin_report_path(@report)
end

View File

@ -11,9 +11,13 @@ class Admin::SettingsController < ApplicationController
def update
@setting = Setting.where(var: params[:id]).first_or_initialize(var: params[:id])
value = settings_params[:value]
if @setting.value != params[:setting][:value]
@setting.value = params[:setting][:value]
# Special cases
value = value == 'true' if @setting.var == 'open_registrations'
if @setting.value != value
@setting.value = value
@setting.save
end
@ -22,4 +26,10 @@ class Admin::SettingsController < ApplicationController
format.json { respond_with_bip(@setting) }
end
end
private
def settings_params
params.require(:setting).permit(:value)
end
end

View File

@ -4,6 +4,12 @@ class Api::V1::AppsController < ApiController
respond_to :json
def create
@app = Doorkeeper::Application.create!(name: params[:client_name], redirect_uri: params[:redirect_uris], scopes: (params[:scopes] || Doorkeeper.configuration.default_scopes), website: params[:website])
@app = Doorkeeper::Application.create!(name: app_params[:client_name], redirect_uri: app_params[:redirect_uris], scopes: (app_params[:scopes] || Doorkeeper.configuration.default_scopes), website: app_params[:website])
end
private
def app_params
params.permit(:client_name, :redirect_uris, :scopes, :website)
end
end

View File

@ -7,7 +7,7 @@ class Api::V1::FollowsController < ApiController
respond_to :json
def create
raise ActiveRecord::RecordNotFound if params[:uri].blank?
raise ActiveRecord::RecordNotFound if follow_params[:uri].blank?
@account = FollowService.new.call(current_user.account, target_uri).try(:target_account)
render action: :show
@ -16,6 +16,10 @@ class Api::V1::FollowsController < ApiController
private
def target_uri
params[:uri].strip.gsub(/\A@/, '')
follow_params[:uri].strip.gsub(/\A@/, '')
end
def follow_params
params.permit(:uri)
end
end

View File

@ -10,10 +10,16 @@ class Api::V1::MediaController < ApiController
respond_to :json
def create
@media = MediaAttachment.create!(account: current_user.account, file: params[:file])
@media = MediaAttachment.create!(account: current_user.account, file: media_params[:file])
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
render json: { error: 'File type of uploaded media could not be verified' }, status: 422
rescue Paperclip::Error
render json: { error: 'Error processing thumbnail for uploaded media' }, status: 500
end
private
def media_params
params.permit(:file)
end
end

View File

@ -12,13 +12,19 @@ class Api::V1::ReportsController < ApiController
end
def create
status_ids = params[:status_ids].is_a?(Enumerable) ? params[:status_ids] : [params[:status_ids]]
status_ids = report_params[:status_ids].is_a?(Enumerable) ? report_params[:status_ids] : [report_params[:status_ids]]
@report = Report.create!(account: current_account,
target_account: Account.find(params[:account_id]),
target_account: Account.find(report_params[:account_id]),
status_ids: Status.find(status_ids).pluck(:id),
comment: params[:comment])
comment: report_params[:comment])
render :show
end
private
def report_params
params.permit(:account_id, :comment, status_ids: [])
end
end

View File

@ -62,11 +62,11 @@ class Api::V1::StatusesController < ApiController
end
def create
@status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids],
sensitive: params[:sensitive],
spoiler_text: params[:spoiler_text],
visibility: params[:visibility],
application: doorkeeper_token.application)
@status = PostStatusService.new.call(current_user.account, status_params[:status], status_params[:in_reply_to_id].blank? ? nil : Status.find(status_params[:in_reply_to_id]), media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text],
visibility: status_params[:visibility],
application: doorkeeper_token.application)
render action: :show
end
@ -111,4 +111,8 @@ class Api::V1::StatusesController < ApiController
@status = Status.find(params[:id])
raise ActiveRecord::RecordNotFound unless @status.permitted?(current_account)
end
def status_params
params.permit(:status, :in_reply_to_id, :sensitive, :spoiler_text, :visibility, media_ids: [])
end
end

View File

@ -39,7 +39,14 @@ class ApplicationController < ActionController::Base
end
def set_user_activity
current_user.touch(:current_sign_in_at) if !current_user.nil? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < 24.hours.ago)
return unless !current_user.nil? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < 24.hours.ago)
# Mark user as signed-in today
current_user.update_tracked_fields(request)
# If the sign in is after a two week break, we need to regenerate their feed
RegenerationWorker.perform_async(current_user.account_id) if current_user.last_sign_in_at < 14.days.ago
return
end
def check_suspension

View File

@ -3,7 +3,7 @@
class Auth::RegistrationsController < Devise::RegistrationsController
layout :determine_layout
before_action :check_single_user_mode
before_action :check_enabled_registrations, only: [:new, :create]
before_action :configure_sign_up_params, only: [:create]
protected
@ -27,12 +27,12 @@ class Auth::RegistrationsController < Devise::RegistrationsController
new_user_session_path
end
def check_single_user_mode
redirect_to root_path if Rails.configuration.x.single_user_mode
def check_enabled_registrations
redirect_to root_path if Rails.configuration.x.single_user_mode || !Setting.open_registrations
end
private
def determine_layout
%w(edit update).include?(action_name) ? 'admin' : 'auth'
end

View File

@ -3,6 +3,7 @@
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
skip_before_action :authenticate_resource_owner!
before_action :set_locale
before_action :store_current_location
before_action :authenticate_resource_owner!
@ -11,4 +12,10 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
def store_current_location
store_location_for(:user, request.url)
end
def set_locale
I18n.locale = current_user.try(:locale) || I18n.default_locale
rescue I18n::InvalidLocale
I18n.locale = I18n.default_locale
end
end

View File

@ -8,6 +8,7 @@ class RemoteFollowController < ApplicationController
def new
@remote_follow = RemoteFollow.new
@remote_follow.acct = session[:remote_follow] if session.key?(:remote_follow)
end
def create
@ -22,6 +23,8 @@ class RemoteFollowController < ApplicationController
render(:new) && return
end
session[:remote_follow] = @remote_follow.acct
redirect_to Addressable::Template.new(redirect_url_link.template).expand(uri: "#{@account.username}@#{Rails.configuration.x.local_domain}").to_s
else
render :new

View File

@ -10,6 +10,7 @@ module SettingsHelper
hu: 'Magyar',
uk: 'Українська',
'zh-CN': '简体中文',
fi: 'Suomi',
eo: 'Esperanto',
}.freeze

View File

@ -2,17 +2,30 @@
class EmailValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if Rails.configuration.x.email_domains_blacklist.empty?
record.errors.add(attribute, I18n.t('users.invalid_email')) if blocked_email?(value)
end
private
def blocked_email?(value)
on_blacklist?(value) || not_on_whitelist?(value)
end
def on_blacklist?(value)
return false if Rails.configuration.x.email_domains_blacklist.blank?
domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')
regexp = Regexp.new("@(.+\\.)?(#{domains})", true)
value =~ regexp
end
def not_on_whitelist?(value)
return false if Rails.configuration.x.email_domains_whitelist.blank?
domains = Rails.configuration.x.email_domains_whitelist.gsub('.', '\.')
regexp = Regexp.new("@(.+\\.)?(#{domains})", true)
value !~ regexp
end
end

View File

@ -4,4 +4,5 @@ module Mastodon
class Error < StandardError; end
class NotPermittedError < Error; end
class ValidationError < Error; end
class RaceConditionError < Error; end
end

View File

@ -5,17 +5,17 @@ require 'singleton'
class FeedManager
include Singleton
MAX_ITEMS = 800
MAX_ITEMS = 400
def key(type, id)
"feed:#{type}:#{id}"
end
def filter?(timeline_type, status, receiver)
def filter?(timeline_type, status, receiver_id)
if timeline_type == :home
filter_from_home?(status, receiver)
filter_from_home?(status, receiver_id)
elsif timeline_type == :mentions
filter_from_mentions?(status, receiver)
filter_from_mentions?(status, receiver_id)
else
false
end
@ -34,12 +34,7 @@ class FeedManager
trim(timeline_type, account.id)
end
broadcast(account.id, event: 'update', payload: inline_render(account, 'api/v1/statuses/show', status))
end
def broadcast(timeline_id, options = {})
options[:queued_at] = (Time.now.to_f * 1000.0).to_i
ActionCable.server.broadcast("timeline:#{timeline_id}", options)
PushUpdateWorker.perform_async(account.id, status.id)
end
def trim(type, account_id)
@ -50,10 +45,18 @@ class FeedManager
def merge_into_timeline(from_account, into_account)
timeline_key = key(:home, into_account.id)
query = from_account.statuses.limit(FeedManager::MAX_ITEMS / 4)
from_account.statuses.limit(MAX_ITEMS).each do |status|
next if status.direct_visibility? || filter?(:home, status, into_account)
redis.zadd(timeline_key, status.id, status.id)
if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
query = query.where('id > ?', oldest_home_score)
end
redis.pipelined do
query.each do |status|
next if status.direct_visibility? || filter?(:home, status, into_account)
redis.zadd(timeline_key, status.id, status.id)
end
end
trim(:home, into_account.id)
@ -61,31 +64,16 @@ class FeedManager
def unmerge_from_timeline(from_account, into_account)
timeline_key = key(:home, into_account.id)
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
from_account.statuses.select('id').find_each do |status|
redis.zrem(timeline_key, status.id)
redis.zremrangebyscore(timeline_key, status.id, status.id)
end
end
def inline_render(target_account, template, object)
rabl_scope = Class.new do
include RoutingHelper
def initialize(account)
@account = account
end
def current_user
@account.try(:user)
end
def current_account
@account
from_account.statuses.select('id').where('id > ?', oldest_home_score).find_in_batches do |statuses|
redis.pipelined do
statuses.each do |status|
redis.zrem(timeline_key, status.id)
redis.zremrangebyscore(timeline_key, status.id, status.id)
end
end
end
Rabl::Renderer.new(template, object, view_path: 'app/views', format: :json, scope: rabl_scope.new(target_account)).render
end
private
@ -94,37 +82,39 @@ class FeedManager
Redis.current
end
def filter_from_home?(status, receiver)
return true if receiver.muting?(status.account)
def filter_from_home?(status, receiver_id)
return true if status.reply? && status.in_reply_to_id.nil?
should_filter = false
check_for_mutes = [status.account_id]
check_for_mutes.concat([status.reblog.account_id]) if status.reblog?
if status.reply? && status.in_reply_to_id.nil?
should_filter = true
elsif status.reply? && !status.in_reply_to_account_id.nil? # Filter out if it's a reply
should_filter = !receiver.following?(status.in_reply_to_account) # and I'm not following the person it's a reply to
should_filter &&= !(receiver.id == status.in_reply_to_account_id) # and it's not a reply to me
should_filter &&= !(status.account_id == status.in_reply_to_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
should_filter ||= receiver.muting?(status.reblog.account) # or muting that person
should_filter ||= status.reblog.account.blocking?(receiver) # or if the author of the reblogged status is blocking me
return true if Mute.where(account_id: receiver_id, target_account_id: check_for_mutes).any?
check_for_blocks = status.mentions.map(&:account_id)
check_for_blocks.concat([status.reblog.account_id]) if status.reblog?
return true if Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?
if status.reply? && !status.in_reply_to_account_id.nil? # Filter out if it's a reply
should_filter = !Follow.where(account_id: receiver_id, target_account_id: status.in_reply_to_account_id).exists? # and I'm not following the person it's a reply to
should_filter &&= !(receiver_id == status.in_reply_to_account_id) # and it's not a reply to me
should_filter &&= !(status.account_id == status.in_reply_to_account_id) # and it's not a self-reply
return should_filter
elsif status.reblog? # Filter out a reblog
return Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists? # or if the author of the reblogged status is blocking me
end
should_filter ||= receiver.blocking?(status.mentions.map(&:account_id)) # or if it mentions someone I blocked
should_filter
false
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.blocking?(status.mentions.includes(:account).map(&:account)) # or if it mentions someone I blocked
should_filter ||= (status.account.silenced? && !receiver.following?(status.account)) # of if the account is silenced and I'm not following them
def filter_from_mentions?(status, receiver_id)
check_for_blocks = [status.account_id]
check_for_blocks.concat(status.mentions.pluck(:account_id))
check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
if status.reply? && !status.in_reply_to_account_id.nil? # or it's a reply
should_filter ||= receiver.blocking?(status.in_reply_to_account) # to a user I blocked
end
should_filter = receiver_id == status.account_id # Filter if I'm mentioning myself
should_filter ||= Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? # or it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
should_filter
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class InlineRablScope
include RoutingHelper
def initialize(account)
@account = account
end
def current_user
@account.try(:user)
end
def current_account
@account
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class InlineRenderer
def self.render(status, current_account, template)
Rabl::Renderer.new(
template,
status,
view_path: 'app/views',
format: :json,
scope: InlineRablScope.new(current_account)
).render
end
end

View File

@ -3,9 +3,8 @@
class Block < ApplicationRecord
include Paginable
belongs_to :account
belongs_to :target_account, class_name: 'Account'
belongs_to :account, required: true
belongs_to :target_account, class_name: 'Account', required: true
validates :account, :target_account, presence: true
validates :account_id, uniqueness: { scope: :target_account_id }
end

View File

@ -10,17 +10,9 @@ class Feed
max_id = '+inf' if max_id.blank?
since_id = '-inf' if since_id.blank?
unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i)
status_map = Status.where(id: unhydrated).cache_ids.map { |s| [s.id, s] }.to_h
# If we're after most recent items and none are there, we need to precompute the feed
if unhydrated.empty? && max_id == '+inf' && since_id == '-inf'
RegenerationWorker.perform_async(@account.id, @type)
@statuses = Status.send("as_#{@type}_timeline", @account).cache_ids.paginate_by_max_id(limit, nil, nil)
else
status_map = Status.where(id: unhydrated).cache_ids.map { |s| [s.id, s] }.to_h
@statuses = unhydrated.map { |id| status_map[id] }.compact
end
@statuses
unhydrated.map { |id| status_map[id] }.compact
end
private

View File

@ -3,11 +3,14 @@
class Follow < ApplicationRecord
include Paginable
belongs_to :account, counter_cache: :following_count
belongs_to :target_account, class_name: 'Account', counter_cache: :followers_count
belongs_to :account, counter_cache: :following_count, required: true
belongs_to :target_account,
class_name: 'Account',
counter_cache: :followers_count,
required: true
has_one :notification, as: :activity, dependent: :destroy
validates :account, :target_account, presence: true
validates :account_id, uniqueness: { scope: :target_account_id }
end

View File

@ -3,12 +3,11 @@
class FollowRequest < ApplicationRecord
include Paginable
belongs_to :account
belongs_to :target_account, class_name: 'Account'
belongs_to :account, required: true
belongs_to :target_account, class_name: 'Account', required: true
has_one :notification, as: :activity, dependent: :destroy
validates :account, :target_account, presence: true
validates :account_id, uniqueness: { scope: :target_account_id }
def authorize!

View File

@ -1,11 +1,10 @@
# frozen_string_literal: true
class Mention < ApplicationRecord
belongs_to :account, inverse_of: :mentions
belongs_to :status
belongs_to :account, inverse_of: :mentions, required: true
belongs_to :status, required: true
has_one :notification, as: :activity, dependent: :destroy
validates :account, :status, presence: true
validates :account, uniqueness: { scope: :status }
end

View File

@ -3,6 +3,7 @@
class Report < ApplicationRecord
belongs_to :account
belongs_to :target_account, class_name: 'Account'
belongs_to :action_taken_by_account, class_name: 'Account'
scope :unresolved, -> { where(action_taken: false) }
scope :resolved, -> { where(action_taken: true) }

View File

@ -161,9 +161,9 @@ class Status < ApplicationRecord
return where.not(visibility: [:private, :direct]) if account.nil?
if target_account.blocking?(account) # get rid of blocked peeps
where('1 = 0')
none
elsif account.id == target_account.id # author can see own stuff
where('1 = 1')
all
elsif account.following?(target_account) # followers can see followers-only stuff, but also things they are mentioned in
joins('LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = ' + account.id.to_s)
.where('statuses.visibility != ? OR mentions.id IS NOT NULL', Status.visibilities[:direct])
@ -188,7 +188,7 @@ class Status < ApplicationRecord
end
before_validation do
text.strip!
text&.strip!
spoiler_text&.strip!
self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply

View File

@ -1,13 +1,11 @@
# frozen_string_literal: true
class BlockDomainService < BaseService
def call(domain, severity)
DomainBlock.where(domain: domain).first_or_create!(domain: domain, severity: severity)
if severity == :silence
Account.where(domain: domain).update_all(silenced: true)
def call(domain_block)
if domain_block.silence?
Account.where(domain: domain_block.domain).update_all(silenced: true)
else
Account.where(domain: domain).find_each do |account|
Account.where(domain: domain_block.domain).find_each do |account|
account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed?
SuspendAccountService.new.call(account)
end

View File

@ -4,6 +4,8 @@ class FanOutOnWriteService < BaseService
# Push a status into home and mentions feeds
# @param [Status] status
def call(status)
raise Mastodon::RaceConditionError if status.visibility.nil?
deliver_to_self(status) if status.account.local?
if status.direct_visibility?
@ -14,6 +16,7 @@ class FanOutOnWriteService < BaseService
return if status.account.silenced? || !status.public_visibility? || status.reblog?
render_anonymous_payload(status)
deliver_to_hashtags(status)
return if status.reply? && status.in_reply_to_account_id != status.account_id
@ -31,9 +34,8 @@ class FanOutOnWriteService < BaseService
def deliver_to_followers(status)
Rails.logger.debug "Delivering status #{status.id} to followers"
status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).find_each do |follower|
next if FeedManager.instance.filter?(:home, status, follower)
FeedManager.instance.push(:home, follower, status)
status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', 14.days.ago).select(:id).find_each do |follower|
FeedInsertWorker.perform_async(status.id, follower.id)
end
end
@ -42,28 +44,28 @@ class FanOutOnWriteService < BaseService
status.mentions.includes(:account).each do |mention|
mentioned_account = mention.account
next if !mentioned_account.local? || !mentioned_account.following?(status.account) || FeedManager.instance.filter?(:home, status, mentioned_account)
next if !mentioned_account.local? || !mentioned_account.following?(status.account) || FeedManager.instance.filter?(:home, status, mention.account_id)
FeedManager.instance.push(:home, mentioned_account, status)
end
end
def render_anonymous_payload(status)
@payload = InlineRenderer.render(status, nil, 'api/v1/statuses/show')
end
def deliver_to_hashtags(status)
Rails.logger.debug "Delivering status #{status.id} to hashtags"
payload = FeedManager.instance.inline_render(nil, 'api/v1/statuses/show', status)
status.tags.find_each do |tag|
FeedManager.instance.broadcast("hashtag:#{tag.name}", event: 'update', payload: payload)
FeedManager.instance.broadcast("hashtag:#{tag.name}:local", event: 'update', payload: payload) if status.account.local?
status.tags.pluck(:name).each do |hashtag|
Redis.current.publish("hashtag:#{hashtag}", Oj.dump(event: :update, payload: @payload))
Redis.current.publish("hashtag:#{hashtag}:local", Oj.dump(event: :update, payload: @payload)) if status.account.local?
end
end
def deliver_to_public(status)
Rails.logger.debug "Delivering status #{status.id} to public timeline"
payload = FeedManager.instance.inline_render(nil, 'api/v1/statuses/show', status)
FeedManager.instance.broadcast(:public, event: 'update', payload: payload)
FeedManager.instance.broadcast('public:local', event: 'update', payload: payload) if status.account.local?
Redis.current.publish('public', Oj.dump(event: 'update', payload: @payload))
Redis.current.publish('public:local', Oj.dump(event: 'update', payload: @payload)) if status.account.local?
end
end

View File

@ -17,7 +17,7 @@ class NotifyService < BaseService
private
def blocked_mention?
FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient)
FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient.id)
end
def blocked_favourite?
@ -50,7 +50,7 @@ class NotifyService < BaseService
def create_notification
@notification.save!
return unless @notification.browserable?
FeedManager.instance.broadcast(@recipient.id, event: 'notification', payload: FeedManager.instance.inline_render(@recipient, 'api/v1/notifications/show', @notification))
Redis.current.publish(@recipient.id, Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, 'api/v1/notifications/show')))
end
def send_email

View File

@ -5,9 +5,11 @@ class PrecomputeFeedService < BaseService
# @param [Symbol] type :home or :mentions
# @param [Account] account
def call(_, account)
Status.as_home_timeline(account).limit(FeedManager::MAX_ITEMS).each do |status|
next if status.direct_visibility? || FeedManager.instance.filter?(:home, status, account)
redis.zadd(FeedManager.instance.key(:home, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
redis.pipelined do
Status.as_home_timeline(account).limit(FeedManager::MAX_ITEMS / 4).each do |status|
next if status.direct_visibility? || FeedManager.instance.filter?(:home, status, account.id)
redis.zadd(FeedManager.instance.key(:home, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
end
end
end

View File

@ -65,17 +65,17 @@ class RemoveStatusService < BaseService
redis.zremrangebyscore